initial commit
This commit is contained in:
commit
54576d9d33
61 changed files with 5569 additions and 0 deletions
100
internal/database/database.go
Normal file
100
internal/database/database.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFiles embed.FS
|
||||
|
||||
func Open(ctx context.Context, databaseURL string) (*sql.DB, error) {
|
||||
db, err := sql.Open("pgx", databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(20)
|
||||
db.SetMaxIdleConns(5)
|
||||
db.SetConnMaxLifetime(30 * time.Minute)
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func Migrate(ctx context.Context, db *sql.DB) error {
|
||||
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version text PRIMARY KEY,
|
||||
applied_at timestamptz NOT NULL DEFAULT now()
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create migration table: %w", err)
|
||||
}
|
||||
entries, err := fs.ReadDir(migrationFiles, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migrations: %w", err)
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
var applied bool
|
||||
err := db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)`, entry.Name()).Scan(&applied)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
if applied {
|
||||
continue
|
||||
}
|
||||
content, err := migrationFiles.ReadFile("migrations/" + entry.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
up, err := upSection(string(content))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, up); err == nil {
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO schema_migrations(version) VALUES ($1)`, entry.Name())
|
||||
}
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("apply migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func upSection(content string) (string, error) {
|
||||
const upMarker = "-- +goose Up"
|
||||
const downMarker = "-- +goose Down"
|
||||
start := strings.Index(content, upMarker)
|
||||
if start < 0 {
|
||||
return "", errors.New("missing up marker")
|
||||
}
|
||||
content = content[start+len(upMarker):]
|
||||
if end := strings.Index(content, downMarker); end >= 0 {
|
||||
content = content[:end]
|
||||
}
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return "", errors.New("empty up migration")
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
13
internal/database/database_test.go
Normal file
13
internal/database/database_test.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package database
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestUpSection(t *testing.T) {
|
||||
got, err := upSection("-- +goose Up\nCREATE TABLE example(id int);\n-- +goose Down\nDROP TABLE example;")
|
||||
if err != nil {
|
||||
t.Fatalf("upSection() error = %v", err)
|
||||
}
|
||||
if got != "\nCREATE TABLE example(id int);\n" {
|
||||
t.Fatalf("unexpected section: %q", got)
|
||||
}
|
||||
}
|
||||
101
internal/database/migrations/0001_initial.sql
Normal file
101
internal/database/migrations/0001_initial.sql
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
-- +goose Up
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE tenants (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug text NOT NULL UNIQUE CHECK (slug ~ '^[a-z0-9][a-z0-9-]{1,62}$'),
|
||||
name text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE users (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
subject text NOT NULL UNIQUE,
|
||||
email text NOT NULL,
|
||||
display_name text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_login_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE memberships (
|
||||
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role text NOT NULL CHECK (role IN ('owner', 'admin', 'viewer')),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (tenant_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE applications (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
slug text NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9-]{1,62}$'),
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
allowed_hosts text[] NOT NULL DEFAULT '{}',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, slug)
|
||||
);
|
||||
|
||||
CREATE TABLE api_keys (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
application_id uuid NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
prefix text NOT NULL,
|
||||
secret_hash bytea NOT NULL UNIQUE,
|
||||
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')),
|
||||
expires_at timestamptz,
|
||||
last_used_at timestamptz,
|
||||
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
revoked_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX api_keys_lookup_idx ON api_keys(secret_hash) WHERE status = 'active';
|
||||
CREATE INDEX api_keys_application_idx ON api_keys(application_id);
|
||||
|
||||
CREATE TABLE openapi_contracts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
application_id uuid NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
|
||||
version text NOT NULL,
|
||||
source_url text,
|
||||
document jsonb NOT NULL,
|
||||
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE contract_endpoints (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contract_id uuid NOT NULL REFERENCES openapi_contracts(id) ON DELETE CASCADE,
|
||||
method text NOT NULL,
|
||||
path text NOT NULL,
|
||||
operation_id text NOT NULL DEFAULT '',
|
||||
UNIQUE (contract_id, method, path)
|
||||
);
|
||||
|
||||
CREATE TABLE authorization_events (
|
||||
id bigserial PRIMARY KEY,
|
||||
tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL,
|
||||
application_id uuid REFERENCES applications(id) ON DELETE SET NULL,
|
||||
api_key_id uuid REFERENCES api_keys(id) ON DELETE SET NULL,
|
||||
method text NOT NULL,
|
||||
path text NOT NULL,
|
||||
host text NOT NULL,
|
||||
allowed boolean NOT NULL,
|
||||
reason text NOT NULL,
|
||||
occurred_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX authorization_events_occurred_idx ON authorization_events(occurred_at DESC);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS authorization_events;
|
||||
DROP TABLE IF EXISTS contract_endpoints;
|
||||
DROP TABLE IF EXISTS openapi_contracts;
|
||||
DROP TABLE IF EXISTS api_keys;
|
||||
DROP TABLE IF EXISTS applications;
|
||||
DROP TABLE IF EXISTS memberships;
|
||||
DROP TABLE IF EXISTS users;
|
||||
DROP TABLE IF EXISTS tenants;
|
||||
54
internal/database/migrations/0002_client_login.sql
Normal file
54
internal/database/migrations/0002_client_login.sql
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
-- +goose Up
|
||||
ALTER TABLE applications
|
||||
ADD COLUMN client_id text NOT NULL UNIQUE DEFAULT ('glc_' || encode(gen_random_bytes(18), 'hex')),
|
||||
ADD COLUMN client_secret_hash bytea,
|
||||
ADD COLUMN redirect_uris text[] NOT NULL DEFAULT '{}';
|
||||
|
||||
CREATE UNIQUE INDEX users_email_unique_idx ON users (lower(email));
|
||||
|
||||
CREATE TABLE user_application_access (
|
||||
application_id uuid NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
granted_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (application_id, user_id)
|
||||
);
|
||||
|
||||
INSERT INTO user_application_access(application_id, user_id, granted_by)
|
||||
SELECT a.id, m.user_id, m.user_id
|
||||
FROM applications a
|
||||
JOIN memberships m ON m.tenant_id = a.tenant_id
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
CREATE TABLE client_authorization_codes (
|
||||
code_hash bytea PRIMARY KEY,
|
||||
application_id uuid NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
redirect_uri text NOT NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
used_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE client_user_sessions (
|
||||
token_hash bytea PRIMARY KEY,
|
||||
application_id uuid NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at timestamptz NOT NULL,
|
||||
last_used_at timestamptz,
|
||||
revoked_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX client_user_sessions_user_idx ON client_user_sessions(user_id);
|
||||
CREATE INDEX client_authorization_codes_expiry_idx ON client_authorization_codes(expires_at);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS client_user_sessions;
|
||||
DROP TABLE IF EXISTS client_authorization_codes;
|
||||
DROP TABLE IF EXISTS user_application_access;
|
||||
DROP INDEX IF EXISTS users_email_unique_idx;
|
||||
ALTER TABLE applications
|
||||
DROP COLUMN IF EXISTS redirect_uris,
|
||||
DROP COLUMN IF EXISTS client_secret_hash,
|
||||
DROP COLUMN IF EXISTS client_id;
|
||||
23
internal/database/migrations/0003_password_auth.sql
Normal file
23
internal/database/migrations/0003_password_auth.sql
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
-- +goose Up
|
||||
ALTER TABLE users
|
||||
ADD COLUMN password_hash text,
|
||||
ADD COLUMN password_changed_at timestamptz;
|
||||
|
||||
CREATE TABLE user_login_events (
|
||||
id bigserial PRIMARY KEY,
|
||||
user_id uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
email text NOT NULL,
|
||||
remote_address text NOT NULL,
|
||||
successful boolean NOT NULL,
|
||||
reason text NOT NULL,
|
||||
occurred_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX user_login_events_occurred_idx ON user_login_events(occurred_at DESC);
|
||||
CREATE INDEX user_login_events_email_idx ON user_login_events(lower(email), occurred_at DESC);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS user_login_events;
|
||||
ALTER TABLE users
|
||||
DROP COLUMN IF EXISTS password_changed_at,
|
||||
DROP COLUMN IF EXISTS password_hash;
|
||||
18
internal/database/migrations/0004_installer.sql
Normal file
18
internal/database/migrations/0004_installer.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
-- +goose Up
|
||||
CREATE TABLE installation_settings (
|
||||
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
||||
installed_at timestamptz NOT NULL DEFAULT now(),
|
||||
oidc_issuer text NOT NULL,
|
||||
oidc_client_id text NOT NULL,
|
||||
oidc_client_secret_ciphertext bytea NOT NULL,
|
||||
seeded_demo boolean NOT NULL DEFAULT false
|
||||
);
|
||||
|
||||
CREATE TABLE system_administrators (
|
||||
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS system_administrators;
|
||||
DROP TABLE IF EXISTS installation_settings;
|
||||
Loading…
Add table
Add a link
Reference in a new issue