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 ); `, }, } // 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 }