checkpoint mciassrv
This commit is contained in:
187
internal/db/migrate.go
Normal file
187
internal/db/migrate.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// migration represents a single schema migration with an ID and SQL statement.
|
||||
type migration struct {
|
||||
id int
|
||||
sql string
|
||||
}
|
||||
|
||||
// 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;
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user