Files
mcias/internal/db/migrate.go
Kyle Isom b2f2f04646 UI: pgcreds create button; show logged-in user
* web/templates/pgcreds.html: New Credentials card is now always
  rendered; Add Credentials toggle button reveals the create form
  (hidden by default). Shows a message when all system accounts
  already have credentials. Previously the card was hidden when
  UncredentialedAccounts was empty.
* internal/ui/ui.go: added ActorName string field to PageData;
  added actorName(r) helper resolving username from JWT claims
  via DB lookup, returns empty string if unauthenticated.
* internal/ui/handlers_*.go: all full-page PageData constructors
  now pass ActorName: u.actorName(r).
* web/templates/base.html: nav bar renders actor username as a
  muted label before the Logout button when logged in.
* web/static/style.css: added .nav-actor rule (muted grey, 0.85rem).
2026-03-12 11:38:57 -07:00

271 lines
10 KiB
Go

package db
import (
"database/sql"
"fmt"
)
// migration represents a single schema migration with an ID and SQL statement.
type migration struct {
sql string
id int
}
// migrations is the ordered list of schema migrations applied to the database.
// Once applied, migrations must never be modified — only new ones appended.
var migrations = []migration{
{
id: 1,
sql: `
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS server_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
signing_key_enc BLOB,
signing_key_nonce BLOB,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY,
uuid TEXT NOT NULL UNIQUE,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
account_type TEXT NOT NULL CHECK (account_type IN ('human','system')),
password_hash TEXT,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active','inactive','deleted')),
totp_required INTEGER NOT NULL DEFAULT 0 CHECK (totp_required IN (0,1)),
totp_secret_enc BLOB,
totp_secret_nonce BLOB,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_accounts_username ON accounts (username);
CREATE INDEX IF NOT EXISTS idx_accounts_uuid ON accounts (uuid);
CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts (status);
CREATE TABLE IF NOT EXISTS account_roles (
id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
role TEXT NOT NULL,
granted_by INTEGER REFERENCES accounts(id),
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
UNIQUE (account_id, role)
);
CREATE INDEX IF NOT EXISTS idx_account_roles_account ON account_roles (account_id);
CREATE TABLE IF NOT EXISTS token_revocation (
id INTEGER PRIMARY KEY,
jti TEXT NOT NULL UNIQUE,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
revoked_at TEXT,
revoke_reason TEXT,
issued_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE INDEX IF NOT EXISTS idx_token_jti ON token_revocation (jti);
CREATE INDEX IF NOT EXISTS idx_token_account ON token_revocation (account_id);
CREATE INDEX IF NOT EXISTS idx_token_expires ON token_revocation (expires_at);
CREATE TABLE IF NOT EXISTS system_tokens (
id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
jti TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS pg_credentials (
id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
pg_host TEXT NOT NULL,
pg_port INTEGER NOT NULL DEFAULT 5432,
pg_database TEXT NOT NULL,
pg_username TEXT NOT NULL,
pg_password_enc BLOB NOT NULL,
pg_password_nonce BLOB NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY,
event_time TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
event_type TEXT NOT NULL,
actor_id INTEGER REFERENCES accounts(id),
target_id INTEGER REFERENCES accounts(id),
ip_address TEXT,
details TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_time ON audit_log (event_time);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log (actor_id);
CREATE INDEX IF NOT EXISTS idx_audit_event ON audit_log (event_type);
`,
},
{
id: 2,
sql: `
-- Add master_key_salt to server_config for Argon2id KDF salt storage.
-- The salt must be stable across restarts so the passphrase always yields the same key.
-- We allow NULL signing_key_enc/nonce temporarily until the first signing key is generated.
ALTER TABLE server_config ADD COLUMN master_key_salt BLOB;
`,
},
{
id: 3,
sql: `
-- Track per-account failed login attempts for lockout enforcement (F-08).
-- One row per account; window_start resets when the window expires or on
-- a successful login. The DB layer enforces atomicity via UPDATE+INSERT.
CREATE TABLE IF NOT EXISTS failed_logins (
account_id INTEGER NOT NULL PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
window_start TEXT NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 1
);
`,
},
{
id: 4,
sql: `
-- Machine/service tags on accounts (many-to-many).
-- Used by the policy engine to gate access by machine or service identity
-- (e.g. env:production, svc:payments-api, machine:db-west-01).
CREATE TABLE IF NOT EXISTS account_tags (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
PRIMARY KEY (account_id, tag)
);
CREATE INDEX IF NOT EXISTS idx_account_tags_account ON account_tags (account_id);
-- Policy rules stored in the database and evaluated in-process.
-- rule_json holds a JSON-encoded policy.RuleBody (all match fields + effect).
-- Built-in default rules are compiled into the binary and are not stored here.
-- Rows with enabled=0 are loaded but skipped during evaluation.
CREATE TABLE IF NOT EXISTS policy_rules (
id INTEGER PRIMARY KEY,
priority INTEGER NOT NULL DEFAULT 100,
description TEXT NOT NULL,
rule_json TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)),
created_by INTEGER REFERENCES accounts(id),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
`,
},
{
id: 5,
sql: `
-- Track which accounts own each set of pg_credentials and which other
-- accounts have been granted read access to them.
--
-- owner_id: the account that administers the credentials and may grant/revoke
-- access. Defaults to the system account itself. This column is
-- nullable so that rows created before migration 5 are not broken.
ALTER TABLE pg_credentials ADD COLUMN owner_id INTEGER REFERENCES accounts(id);
-- pg_credential_access records an explicit "all-or-nothing" read grant from
-- the credential owner to another account. Grantees may view connection
-- metadata (host, port, database, username) but the password is never
-- decrypted for them in the UI. Only the owner may update or delete the
-- credential set.
CREATE TABLE IF NOT EXISTS pg_credential_access (
id INTEGER PRIMARY KEY,
credential_id INTEGER NOT NULL REFERENCES pg_credentials(id) ON DELETE CASCADE,
grantee_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
granted_by INTEGER REFERENCES accounts(id),
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
UNIQUE (credential_id, grantee_id)
);
CREATE INDEX IF NOT EXISTS idx_pgcred_access_cred ON pg_credential_access (credential_id);
CREATE INDEX IF NOT EXISTS idx_pgcred_access_grantee ON pg_credential_access (grantee_id);
`,
},
}
// LatestSchemaVersion is the highest migration ID in the migrations list.
// It is updated automatically when new migrations are appended.
var LatestSchemaVersion = migrations[len(migrations)-1].id
// SchemaVersion returns the current applied schema version of the database.
// Returns 0 if no migrations have been applied yet.
func SchemaVersion(database *DB) (int, error) {
return currentSchemaVersion(database.sql)
}
// Migrate applies any unapplied schema migrations to the database in order.
// It is idempotent: running it multiple times is safe.
func Migrate(db *DB) error {
// Ensure the schema_version table exists first.
if _, err := db.sql.Exec(`
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER NOT NULL
)
`); err != nil {
return fmt.Errorf("db: ensure schema_version: %w", err)
}
currentVersion, err := currentSchemaVersion(db.sql)
if err != nil {
return fmt.Errorf("db: get current schema version: %w", err)
}
for _, m := range migrations {
if m.id <= currentVersion {
continue
}
tx, err := db.sql.Begin()
if err != nil {
return fmt.Errorf("db: begin migration %d transaction: %w", m.id, err)
}
if _, err := tx.Exec(m.sql); err != nil {
_ = tx.Rollback()
return fmt.Errorf("db: apply migration %d: %w", m.id, err)
}
// Update the schema version within the same transaction.
if currentVersion == 0 {
if _, err := tx.Exec(`INSERT INTO schema_version (version) VALUES (?)`, m.id); err != nil {
_ = tx.Rollback()
return fmt.Errorf("db: insert schema version %d: %w", m.id, err)
}
} else {
if _, err := tx.Exec(`UPDATE schema_version SET version = ?`, m.id); err != nil {
_ = tx.Rollback()
return fmt.Errorf("db: update schema version to %d: %w", m.id, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("db: commit migration %d: %w", m.id, err)
}
currentVersion = m.id
}
return nil
}
// currentSchemaVersion returns the current schema version, or 0 if none applied.
func currentSchemaVersion(db *sql.DB) (int, error) {
var version int
err := db.QueryRow(`SELECT version FROM schema_version LIMIT 1`).Scan(&version)
if err != nil {
// No rows means version 0 (fresh database).
return 0, nil //nolint:nilerr
}
return version, nil
}