db: integrate golang-migrate for schema migrations
- internal/db/migrations/: five embedded SQL files containing
the migration SQL previously held as Go string literals.
Files follow the NNN_description.up.sql naming convention
required by golang-migrate's iofs source.
- internal/db/migrate.go: rewritten to use
github.com/golang-migrate/migrate/v4 with the
database/sqlite driver (modernc.org/sqlite, pure Go) and
source/iofs for compile-time embedded SQL.
- newMigrate() opens a dedicated *sql.DB so m.Close() does
not affect the caller's shared connection.
- Migrate() includes a compatibility shim: reads the legacy
schema_version table and calls m.Force(v) before m.Up()
so existing databases are not re-migrated.
- LatestSchemaVersion promoted from var to const.
- internal/db/db.go: added path field to DB struct; Open()
translates ':memory:' to a named shared-cache URI
(file:mcias_N?mode=memory&cache=shared) so the migration
runner can open a second connection to the same in-memory
database without sharing the handle that golang-migrate
will close on teardown.
- go.mod: added golang-migrate/migrate/v4 v4.19.1 (direct).
All callers unchanged. All tests pass; golangci-lint clean.
This commit is contained in:
@@ -2,268 +2,141 @@ package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
sqlitedriver "github.com/golang-migrate/migrate/v4/database/sqlite"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
_ "modernc.org/sqlite" // driver registration
|
||||
)
|
||||
|
||||
// migration represents a single schema migration with an ID and SQL statement.
|
||||
type migration struct {
|
||||
sql string
|
||||
id int
|
||||
}
|
||||
// migrationsFS embeds all migration SQL files from the migrations/ directory.
|
||||
// Each file is named NNN_description.up.sql (and optionally .down.sql).
|
||||
//
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// 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
|
||||
);
|
||||
// LatestSchemaVersion is the highest migration version defined in the
|
||||
// migrations/ directory. Update this constant whenever a new migration file
|
||||
// is added.
|
||||
const LatestSchemaVersion = 5
|
||||
|
||||
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'))
|
||||
);
|
||||
// newMigrate constructs a migrate.Migrate instance backed by the embedded SQL
|
||||
// files. It opens a dedicated *sql.DB using the same DSN as the main
|
||||
// database so that calling m.Close() (which closes the underlying connection)
|
||||
// does not affect the caller's main database connection.
|
||||
//
|
||||
// Security: migration SQL is embedded at compile time from the migrations/
|
||||
// directory and is never loaded from the filesystem at runtime, preventing
|
||||
// injection of arbitrary SQL via a compromised working directory.
|
||||
func newMigrate(database *DB) (*migrate.Migrate, error) {
|
||||
src, err := iofs.New(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: create migration source: %w", err)
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
// Open a dedicated connection for the migrator. golang-migrate's sqlite
|
||||
// driver calls db.Close() when the migrator is closed; using a dedicated
|
||||
// connection (same DSN, different *sql.DB) prevents it from closing the
|
||||
// shared connection. For in-memory databases, Open() translates
|
||||
// ":memory:" to a named shared-cache URI so both connections see the same
|
||||
// data.
|
||||
migrateDB, err := sql.Open("sqlite", database.path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: open migration connection: %w", err)
|
||||
}
|
||||
migrateDB.SetMaxOpenConns(1)
|
||||
if _, err := migrateDB.Exec("PRAGMA foreign_keys=ON"); err != nil {
|
||||
_ = migrateDB.Close()
|
||||
return nil, fmt.Errorf("db: migration connection pragma: %w", err)
|
||||
}
|
||||
|
||||
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);
|
||||
driver, err := sqlitedriver.WithInstance(migrateDB, &sqlitedriver.Config{
|
||||
MigrationsTable: "schema_migrations",
|
||||
})
|
||||
if err != nil {
|
||||
_ = migrateDB.Close()
|
||||
return nil, fmt.Errorf("db: create migration driver: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
m, err := migrate.NewWithInstance("iofs", src, "sqlite", driver)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: initialise migrator: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
// It is idempotent: running it on an already-current database is safe and
|
||||
// returns nil.
|
||||
//
|
||||
// Existing databases that were migrated by the previous hand-rolled runner
|
||||
// (schema_version table) are handled by the compatibility shim below: the
|
||||
// legacy version is read and used to fast-forward the golang-migrate state
|
||||
// before calling Up, so no migration is applied twice.
|
||||
func Migrate(database *DB) error {
|
||||
// Compatibility shim: if the database was previously migrated by the
|
||||
// hand-rolled runner it has a schema_version table with the current
|
||||
// version. Inform golang-migrate of the existing version so it does
|
||||
// not try to re-apply already-applied migrations.
|
||||
legacyVersion, err := legacySchemaVersion(database)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: get current schema version: %w", err)
|
||||
return fmt.Errorf("db: read legacy schema version: %w", err)
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
if m.id <= currentVersion {
|
||||
continue
|
||||
}
|
||||
m, err := newMigrate(database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { src, drv := m.Close(); _ = src; _ = drv }()
|
||||
|
||||
tx, err := db.sql.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: begin migration %d transaction: %w", m.id, err)
|
||||
if legacyVersion > 0 {
|
||||
// Force the migrator to treat the database as already at
|
||||
// legacyVersion so Up only applies newer migrations.
|
||||
if err := m.Force(legacyVersion); err != nil {
|
||||
return fmt.Errorf("db: force legacy schema version %d: %w", legacyVersion, 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
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("db: apply migrations: %w", err)
|
||||
}
|
||||
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)
|
||||
// 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) {
|
||||
m, err := newMigrate(database)
|
||||
if err != nil {
|
||||
// No rows means version 0 (fresh database).
|
||||
return 0, err
|
||||
}
|
||||
defer func() { src, drv := m.Close(); _ = src; _ = drv }()
|
||||
|
||||
v, _, err := m.Version()
|
||||
if errors.Is(err, migrate.ErrNilVersion) {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("db: read schema version: %w", err)
|
||||
}
|
||||
// Security: v is a migration version number (small positive integer);
|
||||
// the uint→int conversion is safe for any realistic schema version count.
|
||||
return int(v), nil //nolint:gosec // G115: migration version is always a small positive integer
|
||||
}
|
||||
|
||||
// legacySchemaVersion reads the version from the old schema_version table
|
||||
// created by the hand-rolled migration runner. Returns 0 if the table does
|
||||
// not exist (fresh database or already migrated to golang-migrate only).
|
||||
func legacySchemaVersion(database *DB) (int, error) {
|
||||
var version int
|
||||
err := database.sql.QueryRow(
|
||||
`SELECT version FROM schema_version LIMIT 1`,
|
||||
).Scan(&version)
|
||||
if err != nil {
|
||||
// Table does not exist or is empty — treat as version 0.
|
||||
return 0, nil //nolint:nilerr
|
||||
}
|
||||
return version, nil
|
||||
|
||||
Reference in New Issue
Block a user