- internal/ui/ui.go: add PGCred, Tags to AccountDetailData; register
PUT /accounts/{id}/pgcreds and PUT /accounts/{id}/tags routes; add
pgcreds_form.html and tags_editor.html to shared template set; remove
unused AccountTagsData; fix fieldalignment on PolicyRuleView, PoliciesData
- internal/ui/handlers_accounts.go: add handleSetPGCreds — encrypts
password via crypto.SealAESGCM, writes audit EventPGCredUpdated, renders
pgcreds_form fragment; password never echoed; load PG creds and tags in
handleAccountDetail
- internal/ui/handlers_policy.go: fix handleSetAccountTags to render with
AccountDetailData instead of removed AccountTagsData
- internal/ui/ui_test.go: add 5 PG credential UI tests
- web/templates/fragments/pgcreds_form.html: new fragment — metadata display
+ set/replace form; system accounts only; password write-only
- web/templates/fragments/tags_editor.html: new fragment — textarea editor
with HTMX PUT for atomic tag replacement
- web/templates/fragments/policy_form.html: rewrite to use structured fields
matching handleCreatePolicyRule (roles/account_types/actions multi-select,
resource_type, subject_uuid, service_names, required_tags, checkbox)
- web/templates/policies.html: new policies management page
- web/templates/fragments/policy_row.html: new HTMX table row with toggle
and delete
- web/templates/account_detail.html: add Tags card and PG Credentials card
- web/templates/base.html: add Policies nav link
- internal/server/server.go: remove ~220 lines of duplicate tag/policy
handler code (real implementations are in handlers_policy.go)
- internal/policy/engine_wrapper.go: fix corrupted source; use errors.New
- internal/db/policy_test.go: use model.AccountTypeHuman constant
- cmd/mciasctl/main.go: add nolint:gosec to int(os.Stdin.Fd()) calls
- gofmt/goimports: db/policy_test.go, policy/defaults.go,
policy/engine_test.go, ui/ui.go, cmd/mciasctl/main.go
- fieldalignment: model.PolicyRuleRecord, policy.Engine, policy.Rule,
policy.RuleBody, ui.PolicyRuleView
Security: PG password encrypted AES-256-GCM with fresh random nonce before
storage; plaintext never logged or returned in any response; audit event
written on every credential write.
242 lines
8.6 KiB
Go
242 lines
8.6 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'))
|
|
);
|
|
`,
|
|
},
|
|
}
|
|
|
|
// 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
|
|
}
|