Add PG creds + policy/tags UI; fix lint and build
- 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.
This commit is contained in:
@@ -131,6 +131,37 @@ CREATE TABLE IF NOT EXISTS failed_logins (
|
||||
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'))
|
||||
);
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
191
internal/db/policy.go
Normal file
191
internal/db/policy.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
)
|
||||
|
||||
// CreatePolicyRule inserts a new policy rule record. The returned record
|
||||
// includes the database-assigned ID and timestamps.
|
||||
func (db *DB) CreatePolicyRule(description string, priority int, ruleJSON string, createdBy *int64) (*model.PolicyRuleRecord, error) {
|
||||
n := now()
|
||||
result, err := db.sql.Exec(`
|
||||
INSERT INTO policy_rules (priority, description, rule_json, enabled, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 1, ?, ?, ?)
|
||||
`, priority, description, ruleJSON, createdBy, n, n)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: create policy rule: %w", err)
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: create policy rule last insert id: %w", err)
|
||||
}
|
||||
|
||||
createdAt, err := parseTime(n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &model.PolicyRuleRecord{
|
||||
ID: id,
|
||||
Priority: priority,
|
||||
Description: description,
|
||||
RuleJSON: ruleJSON,
|
||||
Enabled: true,
|
||||
CreatedBy: createdBy,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetPolicyRule retrieves a single policy rule by its database ID.
|
||||
// Returns ErrNotFound if no such rule exists.
|
||||
func (db *DB) GetPolicyRule(id int64) (*model.PolicyRuleRecord, error) {
|
||||
return db.scanPolicyRule(db.sql.QueryRow(`
|
||||
SELECT id, priority, description, rule_json, enabled, created_by, created_at, updated_at
|
||||
FROM policy_rules WHERE id = ?
|
||||
`, id))
|
||||
}
|
||||
|
||||
// ListPolicyRules returns all policy rules ordered by priority then ID.
|
||||
// When enabledOnly is true, only rules with enabled=1 are returned.
|
||||
func (db *DB) ListPolicyRules(enabledOnly bool) ([]*model.PolicyRuleRecord, error) {
|
||||
query := `
|
||||
SELECT id, priority, description, rule_json, enabled, created_by, created_at, updated_at
|
||||
FROM policy_rules`
|
||||
if enabledOnly {
|
||||
query += ` WHERE enabled = 1`
|
||||
}
|
||||
query += ` ORDER BY priority ASC, id ASC`
|
||||
|
||||
rows, err := db.sql.Query(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: list policy rules: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var rules []*model.PolicyRuleRecord
|
||||
for rows.Next() {
|
||||
r, err := db.scanPolicyRuleRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules = append(rules, r)
|
||||
}
|
||||
return rules, rows.Err()
|
||||
}
|
||||
|
||||
// UpdatePolicyRule updates the mutable fields of a policy rule.
|
||||
// Only the fields in the update map are changed; other fields are untouched.
|
||||
func (db *DB) UpdatePolicyRule(id int64, description *string, priority *int, ruleJSON *string) error {
|
||||
n := now()
|
||||
|
||||
// Build SET clause dynamically to only update provided fields.
|
||||
// Security: field names are not user-supplied strings — they are selected
|
||||
// from a fixed set of known column names only.
|
||||
setClauses := "updated_at = ?"
|
||||
args := []interface{}{n}
|
||||
|
||||
if description != nil {
|
||||
setClauses += ", description = ?"
|
||||
args = append(args, *description)
|
||||
}
|
||||
if priority != nil {
|
||||
setClauses += ", priority = ?"
|
||||
args = append(args, *priority)
|
||||
}
|
||||
if ruleJSON != nil {
|
||||
setClauses += ", rule_json = ?"
|
||||
args = append(args, *ruleJSON)
|
||||
}
|
||||
args = append(args, id)
|
||||
|
||||
_, err := db.sql.Exec(`UPDATE policy_rules SET `+setClauses+` WHERE id = ?`, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: update policy rule %d: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetPolicyRuleEnabled enables or disables a policy rule by ID.
|
||||
func (db *DB) SetPolicyRuleEnabled(id int64, enabled bool) error {
|
||||
enabledInt := 0
|
||||
if enabled {
|
||||
enabledInt = 1
|
||||
}
|
||||
_, err := db.sql.Exec(`
|
||||
UPDATE policy_rules SET enabled = ?, updated_at = ? WHERE id = ?
|
||||
`, enabledInt, now(), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: set policy rule %d enabled=%v: %w", id, enabled, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeletePolicyRule removes a policy rule by ID.
|
||||
func (db *DB) DeletePolicyRule(id int64) error {
|
||||
_, err := db.sql.Exec(`DELETE FROM policy_rules WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: delete policy rule %d: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanPolicyRule scans a single policy rule from a *sql.Row.
|
||||
func (db *DB) scanPolicyRule(row *sql.Row) (*model.PolicyRuleRecord, error) {
|
||||
var r model.PolicyRuleRecord
|
||||
var enabledInt int
|
||||
var createdAtStr, updatedAtStr string
|
||||
var createdBy *int64
|
||||
|
||||
err := row.Scan(
|
||||
&r.ID, &r.Priority, &r.Description, &r.RuleJSON,
|
||||
&enabledInt, &createdBy, &createdAtStr, &updatedAtStr,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: scan policy rule: %w", err)
|
||||
}
|
||||
|
||||
return finishPolicyRuleScan(&r, enabledInt, createdBy, createdAtStr, updatedAtStr)
|
||||
}
|
||||
|
||||
// scanPolicyRuleRow scans a single policy rule from *sql.Rows.
|
||||
func (db *DB) scanPolicyRuleRow(rows *sql.Rows) (*model.PolicyRuleRecord, error) {
|
||||
var r model.PolicyRuleRecord
|
||||
var enabledInt int
|
||||
var createdAtStr, updatedAtStr string
|
||||
var createdBy *int64
|
||||
|
||||
err := rows.Scan(
|
||||
&r.ID, &r.Priority, &r.Description, &r.RuleJSON,
|
||||
&enabledInt, &createdBy, &createdAtStr, &updatedAtStr,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: scan policy rule row: %w", err)
|
||||
}
|
||||
|
||||
return finishPolicyRuleScan(&r, enabledInt, createdBy, createdAtStr, updatedAtStr)
|
||||
}
|
||||
|
||||
func finishPolicyRuleScan(r *model.PolicyRuleRecord, enabledInt int, createdBy *int64, createdAtStr, updatedAtStr string) (*model.PolicyRuleRecord, error) {
|
||||
r.Enabled = enabledInt == 1
|
||||
r.CreatedBy = createdBy
|
||||
|
||||
var err error
|
||||
r.CreatedAt, err = parseTime(createdAtStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.UpdatedAt, err = parseTime(updatedAtStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
212
internal/db/policy_test.go
Normal file
212
internal/db/policy_test.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
)
|
||||
|
||||
func TestCreateAndGetPolicyRule(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
ruleJSON := `{"actions":["pgcreds:read"],"resource_type":"pgcreds","effect":"allow"}`
|
||||
rec, err := db.CreatePolicyRule("test rule", 50, ruleJSON, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePolicyRule: %v", err)
|
||||
}
|
||||
if rec.ID == 0 {
|
||||
t.Error("expected non-zero ID after create")
|
||||
}
|
||||
if rec.Priority != 50 {
|
||||
t.Errorf("expected priority 50, got %d", rec.Priority)
|
||||
}
|
||||
if !rec.Enabled {
|
||||
t.Error("new rule should be enabled by default")
|
||||
}
|
||||
|
||||
got, err := db.GetPolicyRule(rec.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPolicyRule: %v", err)
|
||||
}
|
||||
if got.Description != "test rule" {
|
||||
t.Errorf("expected description %q, got %q", "test rule", got.Description)
|
||||
}
|
||||
if got.RuleJSON != ruleJSON {
|
||||
t.Errorf("rule_json mismatch: got %q", got.RuleJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPolicyRule_NotFound(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
_, err := db.GetPolicyRule(99999)
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPolicyRules(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
_, _ = db.CreatePolicyRule("rule A", 100, `{"effect":"allow"}`, nil)
|
||||
_, _ = db.CreatePolicyRule("rule B", 50, `{"effect":"deny"}`, nil)
|
||||
_, _ = db.CreatePolicyRule("rule C", 200, `{"effect":"allow"}`, nil)
|
||||
|
||||
rules, err := db.ListPolicyRules(false)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPolicyRules: %v", err)
|
||||
}
|
||||
if len(rules) != 3 {
|
||||
t.Fatalf("expected 3 rules, got %d", len(rules))
|
||||
}
|
||||
// Should be ordered by priority ascending.
|
||||
if rules[0].Priority > rules[1].Priority || rules[1].Priority > rules[2].Priority {
|
||||
t.Errorf("rules not sorted by priority: %v %v %v",
|
||||
rules[0].Priority, rules[1].Priority, rules[2].Priority)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPolicyRules_EnabledOnly(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
r1, _ := db.CreatePolicyRule("enabled rule", 100, `{"effect":"allow"}`, nil)
|
||||
r2, _ := db.CreatePolicyRule("disabled rule", 100, `{"effect":"deny"}`, nil)
|
||||
|
||||
if err := db.SetPolicyRuleEnabled(r2.ID, false); err != nil {
|
||||
t.Fatalf("SetPolicyRuleEnabled: %v", err)
|
||||
}
|
||||
|
||||
all, err := db.ListPolicyRules(false)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPolicyRules(all): %v", err)
|
||||
}
|
||||
if len(all) != 2 {
|
||||
t.Errorf("expected 2 total rules, got %d", len(all))
|
||||
}
|
||||
|
||||
enabled, err := db.ListPolicyRules(true)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPolicyRules(enabledOnly): %v", err)
|
||||
}
|
||||
if len(enabled) != 1 {
|
||||
t.Fatalf("expected 1 enabled rule, got %d", len(enabled))
|
||||
}
|
||||
if enabled[0].ID != r1.ID {
|
||||
t.Errorf("wrong rule returned: got ID %d, want %d", enabled[0].ID, r1.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePolicyRule(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
rec, _ := db.CreatePolicyRule("original", 100, `{"effect":"allow"}`, nil)
|
||||
|
||||
newDesc := "updated description"
|
||||
newPriority := 25
|
||||
if err := db.UpdatePolicyRule(rec.ID, &newDesc, &newPriority, nil); err != nil {
|
||||
t.Fatalf("UpdatePolicyRule: %v", err)
|
||||
}
|
||||
|
||||
got, err := db.GetPolicyRule(rec.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPolicyRule after update: %v", err)
|
||||
}
|
||||
if got.Description != newDesc {
|
||||
t.Errorf("expected description %q, got %q", newDesc, got.Description)
|
||||
}
|
||||
if got.Priority != newPriority {
|
||||
t.Errorf("expected priority %d, got %d", newPriority, got.Priority)
|
||||
}
|
||||
// RuleJSON should be unchanged.
|
||||
if got.RuleJSON != `{"effect":"allow"}` {
|
||||
t.Errorf("rule_json should not change when not provided: %q", got.RuleJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePolicyRule_RuleJSON(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
rec, _ := db.CreatePolicyRule("rule", 100, `{"effect":"allow"}`, nil)
|
||||
|
||||
newJSON := `{"effect":"deny","roles":["auditor"]}`
|
||||
if err := db.UpdatePolicyRule(rec.ID, nil, nil, &newJSON); err != nil {
|
||||
t.Fatalf("UpdatePolicyRule (json only): %v", err)
|
||||
}
|
||||
|
||||
got, err := db.GetPolicyRule(rec.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPolicyRule: %v", err)
|
||||
}
|
||||
if got.RuleJSON != newJSON {
|
||||
t.Errorf("expected updated rule_json, got %q", got.RuleJSON)
|
||||
}
|
||||
// Description and priority unchanged.
|
||||
if got.Description != "rule" {
|
||||
t.Errorf("description should be unchanged, got %q", got.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPolicyRuleEnabled(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
rec, _ := db.CreatePolicyRule("toggle rule", 100, `{"effect":"allow"}`, nil)
|
||||
if !rec.Enabled {
|
||||
t.Fatal("new rule should be enabled")
|
||||
}
|
||||
|
||||
if err := db.SetPolicyRuleEnabled(rec.ID, false); err != nil {
|
||||
t.Fatalf("SetPolicyRuleEnabled(false): %v", err)
|
||||
}
|
||||
got, _ := db.GetPolicyRule(rec.ID)
|
||||
if got.Enabled {
|
||||
t.Error("rule should be disabled after SetPolicyRuleEnabled(false)")
|
||||
}
|
||||
|
||||
if err := db.SetPolicyRuleEnabled(rec.ID, true); err != nil {
|
||||
t.Fatalf("SetPolicyRuleEnabled(true): %v", err)
|
||||
}
|
||||
got, _ = db.GetPolicyRule(rec.ID)
|
||||
if !got.Enabled {
|
||||
t.Error("rule should be enabled after SetPolicyRuleEnabled(true)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePolicyRule(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
rec, _ := db.CreatePolicyRule("to delete", 100, `{"effect":"allow"}`, nil)
|
||||
|
||||
if err := db.DeletePolicyRule(rec.ID); err != nil {
|
||||
t.Fatalf("DeletePolicyRule: %v", err)
|
||||
}
|
||||
|
||||
_, err := db.GetPolicyRule(rec.ID)
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("expected ErrNotFound after delete, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePolicyRule_NonExistent(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
// Deleting a non-existent rule should be a no-op, not an error.
|
||||
if err := db.DeletePolicyRule(99999); err != nil {
|
||||
t.Errorf("DeletePolicyRule on nonexistent ID should not error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePolicyRule_WithCreatedBy(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
acct, _ := db.CreateAccount("policy-creator", model.AccountTypeHuman, "hash")
|
||||
rec, err := db.CreatePolicyRule("by user", 100, `{"effect":"allow"}`, &acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePolicyRule with createdBy: %v", err)
|
||||
}
|
||||
|
||||
got, _ := db.GetPolicyRule(rec.ID)
|
||||
if got.CreatedBy == nil || *got.CreatedBy != acct.ID {
|
||||
t.Errorf("expected CreatedBy=%d, got %v", acct.ID, got.CreatedBy)
|
||||
}
|
||||
}
|
||||
82
internal/db/tags.go
Normal file
82
internal/db/tags.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// GetAccountTags returns the tags assigned to an account, sorted alphabetically.
|
||||
func (db *DB) GetAccountTags(accountID int64) ([]string, error) {
|
||||
rows, err := db.sql.Query(`
|
||||
SELECT tag FROM account_tags WHERE account_id = ? ORDER BY tag ASC
|
||||
`, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: get tags for account %d: %w", accountID, err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var tags []string
|
||||
for rows.Next() {
|
||||
var tag string
|
||||
if err := rows.Scan(&tag); err != nil {
|
||||
return nil, fmt.Errorf("db: scan tag: %w", err)
|
||||
}
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
return tags, rows.Err()
|
||||
}
|
||||
|
||||
// AddAccountTag adds a single tag to an account. If the tag already exists the
|
||||
// operation is a no-op (INSERT OR IGNORE).
|
||||
func (db *DB) AddAccountTag(accountID int64, tag string) error {
|
||||
_, err := db.sql.Exec(`
|
||||
INSERT OR IGNORE INTO account_tags (account_id, tag, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
`, accountID, tag, now())
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: add tag %q to account %d: %w", tag, accountID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveAccountTag removes a single tag from an account. If the tag does not
|
||||
// exist the operation is a no-op.
|
||||
func (db *DB) RemoveAccountTag(accountID int64, tag string) error {
|
||||
_, err := db.sql.Exec(`
|
||||
DELETE FROM account_tags WHERE account_id = ? AND tag = ?
|
||||
`, accountID, tag)
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: remove tag %q from account %d: %w", tag, accountID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAccountTags atomically replaces the complete tag set for an account within
|
||||
// a single transaction. Any tags not present in the new set are removed; any
|
||||
// new tags are inserted.
|
||||
func (db *DB) SetAccountTags(accountID int64, tags []string) error {
|
||||
tx, err := db.sql.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("db: set account tags begin tx: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM account_tags WHERE account_id = ?`, accountID); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("db: set account tags delete existing: %w", err)
|
||||
}
|
||||
|
||||
n := now()
|
||||
for _, tag := range tags {
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO account_tags (account_id, tag, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
`, accountID, tag, n); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("db: set account tags insert %q: %w", tag, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("db: set account tags commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
183
internal/db/tags_test.go
Normal file
183
internal/db/tags_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
)
|
||||
|
||||
func TestGetAccountTags_Empty(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
acct, err := db.CreateAccount("taguser", model.AccountTypeHuman, "hash")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
tags, err := db.GetAccountTags(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccountTags: %v", err)
|
||||
}
|
||||
if len(tags) != 0 {
|
||||
t.Errorf("expected no tags, got %v", tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddAndGetAccountTags(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
acct, err := db.CreateAccount("taguser2", model.AccountTypeHuman, "hash")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
for _, tag := range []string{"env:staging", "svc:payments-api"} {
|
||||
if err := db.AddAccountTag(acct.ID, tag); err != nil {
|
||||
t.Fatalf("AddAccountTag(%q): %v", tag, err)
|
||||
}
|
||||
}
|
||||
|
||||
tags, err := db.GetAccountTags(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccountTags: %v", err)
|
||||
}
|
||||
if len(tags) != 2 {
|
||||
t.Fatalf("expected 2 tags, got %d: %v", len(tags), tags)
|
||||
}
|
||||
// Results are sorted alphabetically.
|
||||
if tags[0] != "env:staging" || tags[1] != "svc:payments-api" {
|
||||
t.Errorf("unexpected tags: %v", tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddAccountTag_Idempotent(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
acct, err := db.CreateAccount("taguser3", model.AccountTypeHuman, "hash")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
// Adding the same tag twice must not error or produce duplicates.
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := db.AddAccountTag(acct.ID, "env:production"); err != nil {
|
||||
t.Fatalf("AddAccountTag (attempt %d): %v", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
tags, err := db.GetAccountTags(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccountTags: %v", err)
|
||||
}
|
||||
if len(tags) != 1 {
|
||||
t.Errorf("expected exactly 1 tag, got %d: %v", len(tags), tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveAccountTag(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
acct, err := db.CreateAccount("taguser4", model.AccountTypeHuman, "hash")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
_ = db.AddAccountTag(acct.ID, "env:staging")
|
||||
_ = db.AddAccountTag(acct.ID, "env:production")
|
||||
|
||||
if err := db.RemoveAccountTag(acct.ID, "env:staging"); err != nil {
|
||||
t.Fatalf("RemoveAccountTag: %v", err)
|
||||
}
|
||||
|
||||
tags, err := db.GetAccountTags(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccountTags: %v", err)
|
||||
}
|
||||
if len(tags) != 1 || tags[0] != "env:production" {
|
||||
t.Errorf("expected only env:production, got %v", tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveAccountTag_NonExistent(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
acct, err := db.CreateAccount("taguser5", model.AccountTypeHuman, "hash")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
// Removing a tag that doesn't exist must be a no-op, not an error.
|
||||
if err := db.RemoveAccountTag(acct.ID, "nonexistent:tag"); err != nil {
|
||||
t.Errorf("RemoveAccountTag on nonexistent tag should not error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAccountTags_ReplacesFully(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
acct, err := db.CreateAccount("taguser6", model.AccountTypeHuman, "hash")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
_ = db.AddAccountTag(acct.ID, "old:tag1")
|
||||
_ = db.AddAccountTag(acct.ID, "old:tag2")
|
||||
|
||||
newTags := []string{"new:tag1", "new:tag2", "new:tag3"}
|
||||
if err := db.SetAccountTags(acct.ID, newTags); err != nil {
|
||||
t.Fatalf("SetAccountTags: %v", err)
|
||||
}
|
||||
|
||||
tags, err := db.GetAccountTags(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccountTags: %v", err)
|
||||
}
|
||||
if len(tags) != 3 {
|
||||
t.Fatalf("expected 3 tags after set, got %d: %v", len(tags), tags)
|
||||
}
|
||||
// Verify old tags are gone.
|
||||
for _, tag := range tags {
|
||||
if tag == "old:tag1" || tag == "old:tag2" {
|
||||
t.Errorf("old tag still present after SetAccountTags: %q", tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAccountTags_Empty(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
acct, err := db.CreateAccount("taguser7", model.AccountTypeHuman, "hash")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
|
||||
_ = db.AddAccountTag(acct.ID, "env:staging")
|
||||
|
||||
if err := db.SetAccountTags(acct.ID, []string{}); err != nil {
|
||||
t.Fatalf("SetAccountTags with empty slice: %v", err)
|
||||
}
|
||||
|
||||
tags, err := db.GetAccountTags(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccountTags: %v", err)
|
||||
}
|
||||
if len(tags) != 0 {
|
||||
t.Errorf("expected no tags after clearing, got %v", tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountTagsCascadeDelete(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
acct, err := db.CreateAccount("taguser8", model.AccountTypeHuman, "hash")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
_ = db.AddAccountTag(acct.ID, "env:staging")
|
||||
|
||||
// Soft-deleting an account does not cascade-delete tags (FK ON DELETE CASCADE
|
||||
// only fires on hard deletes). Verify tags still exist after status update.
|
||||
if err := db.UpdateAccountStatus(acct.ID, model.AccountStatusDeleted); err != nil {
|
||||
t.Fatalf("UpdateAccountStatus: %v", err)
|
||||
}
|
||||
|
||||
tags, err := db.GetAccountTags(acct.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccountTags after soft delete: %v", err)
|
||||
}
|
||||
if len(tags) != 1 {
|
||||
t.Errorf("expected tag to survive soft delete, got %v", tags)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/token"
|
||||
)
|
||||
|
||||
@@ -297,3 +298,98 @@ func minFloat64(a, b float64) float64 {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ResourceBuilder is a function that assembles the policy.Resource for a
|
||||
// specific request. The middleware calls it after claims are extracted.
|
||||
// Implementations typically read the path parameter (e.g. account UUID) and
|
||||
// look up the target account's owner UUID, service name, and tags from the DB.
|
||||
//
|
||||
// A nil ResourceBuilder is equivalent to a function that returns an empty
|
||||
// Resource (no owner, no service name, no tags).
|
||||
type ResourceBuilder func(r *http.Request, claims *token.Claims) policy.Resource
|
||||
|
||||
// AccountTypeLookup resolves the account type ("human" or "system") for the
|
||||
// given account UUID. The middleware calls this to populate PolicyInput when
|
||||
// the AccountTypes match condition is used in any rule.
|
||||
//
|
||||
// Callers supply an implementation backed by db.GetAccountByUUID; the
|
||||
// middleware does not import the db package directly to avoid a cycle.
|
||||
// Returning an empty string is safe — it simply will not match any
|
||||
// AccountTypes condition on rules.
|
||||
type AccountTypeLookup func(subjectUUID string) string
|
||||
|
||||
// PolicyDenyLogger is a function that records a policy denial in the audit log.
|
||||
// Callers supply an implementation that calls db.WriteAuditEvent; the middleware
|
||||
// itself does not import the db package directly for the audit write, keeping
|
||||
// the dependency on policy and db separate.
|
||||
type PolicyDenyLogger func(r *http.Request, claims *token.Claims, action policy.Action, res policy.Resource, matchedRuleID int64)
|
||||
|
||||
// RequirePolicy returns middleware that evaluates the policy engine for the
|
||||
// given action and resource type. Must be used after RequireAuth.
|
||||
//
|
||||
// Security: deny-wins and default-deny semantics mean that any misconfiguration
|
||||
// (missing rule, engine error) results in a 403, never silent permit. The
|
||||
// matched rule ID is included in the audit event for traceability.
|
||||
//
|
||||
// AccountType is not stored in the JWT to avoid a signature-breaking change to
|
||||
// IssueToken. It is resolved lazily via lookupAccountType (a DB-backed closure
|
||||
// provided by the caller). Returning "" from lookupAccountType is safe: no
|
||||
// AccountTypes rule condition will match an empty string.
|
||||
//
|
||||
// RequirePolicy is intended to coexist with RequireRole("admin") during the
|
||||
// migration period. Once full policy coverage is validated, RequireRole can be
|
||||
// removed. During the transition both checks must pass.
|
||||
func RequirePolicy(
|
||||
eng *policy.Engine,
|
||||
action policy.Action,
|
||||
resType policy.ResourceType,
|
||||
buildResource ResourceBuilder,
|
||||
lookupAccountType AccountTypeLookup,
|
||||
logDeny PolicyDenyLogger,
|
||||
) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims := ClaimsFromContext(r.Context())
|
||||
if claims == nil {
|
||||
// RequireAuth was not applied upstream; fail closed.
|
||||
writeError(w, http.StatusForbidden, "forbidden", "forbidden")
|
||||
return
|
||||
}
|
||||
|
||||
var res policy.Resource
|
||||
res.Type = resType
|
||||
if buildResource != nil {
|
||||
res = buildResource(r, claims)
|
||||
res.Type = resType // ensure type is always set even if builder overrides
|
||||
}
|
||||
|
||||
accountType := ""
|
||||
if lookupAccountType != nil {
|
||||
accountType = lookupAccountType(claims.Subject)
|
||||
}
|
||||
|
||||
input := policy.PolicyInput{
|
||||
Subject: claims.Subject,
|
||||
AccountType: accountType,
|
||||
Roles: claims.Roles,
|
||||
Action: action,
|
||||
Resource: res,
|
||||
}
|
||||
|
||||
effect, matched := eng.Evaluate(input)
|
||||
if effect == policy.Deny {
|
||||
var ruleID int64
|
||||
if matched != nil {
|
||||
ruleID = matched.ID
|
||||
}
|
||||
if logDeny != nil {
|
||||
logDeny(r, claims, action, res, ruleID)
|
||||
}
|
||||
writeError(w, http.StatusForbidden, "insufficient privileges", "forbidden")
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,4 +131,26 @@ const (
|
||||
EventTOTPRemoved = "totp_removed"
|
||||
EventPGCredAccessed = "pgcred_accessed"
|
||||
EventPGCredUpdated = "pgcred_updated" //nolint:gosec // G101: audit event type string, not a credential
|
||||
|
||||
EventTagAdded = "tag_added"
|
||||
EventTagRemoved = "tag_removed"
|
||||
|
||||
EventPolicyRuleCreated = "policy_rule_created"
|
||||
EventPolicyRuleUpdated = "policy_rule_updated"
|
||||
EventPolicyRuleDeleted = "policy_rule_deleted"
|
||||
EventPolicyDeny = "policy_deny"
|
||||
)
|
||||
|
||||
// PolicyRuleRecord is the database representation of a policy rule.
|
||||
// RuleJSON holds a JSON-encoded policy.RuleBody (all match and effect fields).
|
||||
// The ID, Priority, and Description are stored as dedicated columns.
|
||||
type PolicyRuleRecord struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedBy *int64 `json:"-"`
|
||||
Description string `json:"description"`
|
||||
RuleJSON string `json:"rule_json"`
|
||||
ID int64 `json:"id"`
|
||||
Priority int `json:"priority"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
83
internal/policy/defaults.go
Normal file
83
internal/policy/defaults.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package policy
|
||||
|
||||
// defaultRules are the compiled-in authorization rules. They cannot be
|
||||
// modified or deleted via the API. They reproduce the previous binary
|
||||
// admin/non-admin behavior exactly when no operator rules exist, so wiring
|
||||
// the policy engine alongside RequireRole("admin") produces identical results.
|
||||
//
|
||||
// All defaults use Priority 0 so they are evaluated before any operator rule
|
||||
// (which defaults to Priority 100). Within priority 0, deny-wins still applies,
|
||||
// but the defaults contain no Deny rules — they only grant the minimum required
|
||||
// for self-service and admin operations.
|
||||
//
|
||||
// Security rationale for each rule is documented inline.
|
||||
var defaultRules = []Rule{
|
||||
{
|
||||
// Admin wildcard: an account bearing the "admin" role is permitted to
|
||||
// perform any action on any resource. This mirrors the previous
|
||||
// RequireRole("admin") check and is the root of all administrative trust.
|
||||
ID: -1,
|
||||
Description: "Admin wildcard: admin role allows all actions",
|
||||
Priority: 0,
|
||||
Roles: []string{"admin"},
|
||||
Effect: Allow,
|
||||
},
|
||||
{
|
||||
// Self-service logout and token renewal: any authenticated principal may
|
||||
// revoke or renew their own token. No resource scoping is needed because
|
||||
// the handler independently verifies that the JTI belongs to the caller.
|
||||
ID: -2,
|
||||
Description: "Self-service: any principal may logout or renew their own token",
|
||||
Priority: 0,
|
||||
Actions: []Action{ActionLogout, ActionRenewToken},
|
||||
Effect: Allow,
|
||||
},
|
||||
{
|
||||
// Self-service TOTP enrollment: any authenticated human account may
|
||||
// initiate and confirm their own TOTP enrollment. The handler verifies
|
||||
// the subject matches before writing.
|
||||
ID: -3,
|
||||
Description: "Self-service: any principal may enroll their own TOTP",
|
||||
Priority: 0,
|
||||
Actions: []Action{ActionEnrollTOTP},
|
||||
Effect: Allow,
|
||||
},
|
||||
{
|
||||
// System accounts reading their own pgcreds: a service that has already
|
||||
// authenticated (e.g. via its bearer service token) may retrieve its own
|
||||
// Postgres credentials without admin privilege. OwnerMatchesSubject
|
||||
// ensures the service can only reach its own row — not another service's.
|
||||
ID: -4,
|
||||
Description: "System accounts may read their own pg_credentials",
|
||||
Priority: 0,
|
||||
AccountTypes: []string{"system"},
|
||||
Actions: []Action{ActionReadPGCreds},
|
||||
ResourceType: ResourcePGCreds,
|
||||
OwnerMatchesSubject: true,
|
||||
Effect: Allow,
|
||||
},
|
||||
{
|
||||
// System accounts issuing or renewing their own service token: a system
|
||||
// account may rotate its own bearer token. OwnerMatchesSubject ensures
|
||||
// it cannot issue tokens for other accounts.
|
||||
ID: -5,
|
||||
Description: "System accounts may issue or renew their own service token",
|
||||
Priority: 0,
|
||||
AccountTypes: []string{"system"},
|
||||
Actions: []Action{ActionIssueToken, ActionRenewToken},
|
||||
ResourceType: ResourceToken,
|
||||
OwnerMatchesSubject: true,
|
||||
Effect: Allow,
|
||||
},
|
||||
{
|
||||
// Public endpoints: token validation and login do not require
|
||||
// authentication. The middleware exempts them from RequireAuth entirely;
|
||||
// this rule exists so that if a policy check is accidentally applied to
|
||||
// these paths, it does not block them.
|
||||
ID: -6,
|
||||
Description: "Public: token validation and login are always permitted",
|
||||
Priority: 0,
|
||||
Actions: []Action{ActionValidateToken, ActionLogin},
|
||||
Effect: Allow,
|
||||
},
|
||||
}
|
||||
150
internal/policy/engine.go
Normal file
150
internal/policy/engine.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package policy
|
||||
|
||||
import "sort"
|
||||
|
||||
// Evaluate determines whether the given input should be allowed or denied,
|
||||
// using the provided rule set. Built-in default rules (from defaults.go) are
|
||||
// always merged in before evaluation.
|
||||
//
|
||||
// The rules slice passed by the caller contains only DB-backed operator rules;
|
||||
// defaultRules are appended internally so callers do not need to know about them.
|
||||
//
|
||||
// Return values:
|
||||
// - effect: Allow or Deny
|
||||
// - matched: the Rule that produced the decision, or nil on default-deny
|
||||
//
|
||||
// Security: evaluation is purely functional — no I/O, no globals mutated. The
|
||||
// deny-wins and default-deny semantics ensure that a misconfigured or empty
|
||||
// operator rule set falls back to the built-in defaults, which reproduce the
|
||||
// previous binary admin/non-admin behavior exactly.
|
||||
func Evaluate(input PolicyInput, operatorRules []Rule) (Effect, *Rule) {
|
||||
// Merge operator rules with built-in defaults. Defaults have priority 0;
|
||||
// operator rules default to 100. Sort is stable so same-priority rules
|
||||
// maintain their original order (defaults before operator rules on ties).
|
||||
all := make([]Rule, 0, len(operatorRules)+len(defaultRules))
|
||||
all = append(all, defaultRules...)
|
||||
all = append(all, operatorRules...)
|
||||
sort.SliceStable(all, func(i, j int) bool {
|
||||
return all[i].Priority < all[j].Priority
|
||||
})
|
||||
|
||||
var matched []Rule
|
||||
for _, r := range all {
|
||||
if matches(input, r) {
|
||||
matched = append(matched, r)
|
||||
}
|
||||
}
|
||||
|
||||
// Deny-wins: first matching Deny terminates evaluation.
|
||||
for i := range matched {
|
||||
if matched[i].Effect == Deny {
|
||||
return Deny, &matched[i]
|
||||
}
|
||||
}
|
||||
|
||||
// First matching Allow permits.
|
||||
for i := range matched {
|
||||
if matched[i].Effect == Allow {
|
||||
return Allow, &matched[i]
|
||||
}
|
||||
}
|
||||
|
||||
// Default-deny: no rule matched.
|
||||
return Deny, nil
|
||||
}
|
||||
|
||||
// matches reports whether rule r applies to the given input. Every non-zero
|
||||
// field on the rule is treated as an AND condition; empty slices and zero
|
||||
// strings are wildcards.
|
||||
func matches(input PolicyInput, r Rule) bool {
|
||||
// Principal: roles (at least one must match)
|
||||
if len(r.Roles) > 0 && !anyIn(input.Roles, r.Roles) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Principal: account type
|
||||
if len(r.AccountTypes) > 0 && !stringIn(input.AccountType, r.AccountTypes) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Principal: exact subject UUID
|
||||
if r.SubjectUUID != "" && input.Subject != r.SubjectUUID {
|
||||
return false
|
||||
}
|
||||
|
||||
// Action
|
||||
if len(r.Actions) > 0 && !actionIn(input.Action, r.Actions) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Resource type
|
||||
if r.ResourceType != "" && input.Resource.Type != r.ResourceType {
|
||||
return false
|
||||
}
|
||||
|
||||
// Resource: owner must equal subject
|
||||
if r.OwnerMatchesSubject && input.Resource.OwnerUUID != input.Subject {
|
||||
return false
|
||||
}
|
||||
|
||||
// Resource: service name must be in the allowed list
|
||||
if len(r.ServiceNames) > 0 && !stringIn(input.Resource.ServiceName, r.ServiceNames) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Resource: resource must carry ALL required tags
|
||||
if len(r.RequiredTags) > 0 && !allTagsPresent(input.Resource.Tags, r.RequiredTags) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// anyIn reports whether any element of needle appears in haystack.
|
||||
func anyIn(needle, haystack []string) bool {
|
||||
for _, n := range needle {
|
||||
for _, h := range haystack {
|
||||
if n == h {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// stringIn reports whether s is in list.
|
||||
func stringIn(s string, list []string) bool {
|
||||
for _, v := range list {
|
||||
if s == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// actionIn reports whether a is in list.
|
||||
func actionIn(a Action, list []Action) bool {
|
||||
for _, v := range list {
|
||||
if a == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// allTagsPresent reports whether resourceTags contains every tag in required.
|
||||
func allTagsPresent(resourceTags, required []string) bool {
|
||||
for _, req := range required {
|
||||
found := false
|
||||
for _, rt := range resourceTags {
|
||||
if rt == req {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
380
internal/policy/engine_test.go
Normal file
380
internal/policy/engine_test.go
Normal file
@@ -0,0 +1,380 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// adminInput is a convenience helper for building admin PolicyInputs.
|
||||
func adminInput(action Action, resType ResourceType) PolicyInput {
|
||||
return PolicyInput{
|
||||
Subject: "admin-uuid",
|
||||
AccountType: "human",
|
||||
Roles: []string{"admin"},
|
||||
Action: action,
|
||||
Resource: Resource{Type: resType},
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_DefaultDeny(t *testing.T) {
|
||||
// No operator rules, non-admin subject: should hit default-deny for an
|
||||
// action that is not covered by built-in self-service defaults.
|
||||
input := PolicyInput{
|
||||
Subject: "user-uuid",
|
||||
AccountType: "human",
|
||||
Roles: []string{},
|
||||
Action: ActionListAccounts,
|
||||
Resource: Resource{Type: ResourceAccount},
|
||||
}
|
||||
effect, rule := Evaluate(input, nil)
|
||||
if effect != Deny {
|
||||
t.Errorf("expected Deny, got %s", effect)
|
||||
}
|
||||
if rule != nil {
|
||||
t.Errorf("expected nil rule on default-deny, got %+v", rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_AdminWildcard(t *testing.T) {
|
||||
actions := []Action{
|
||||
ActionListAccounts, ActionCreateAccount, ActionReadPGCreds,
|
||||
ActionWritePGCreds, ActionReadAudit, ActionManageRules,
|
||||
}
|
||||
for _, a := range actions {
|
||||
t.Run(string(a), func(t *testing.T) {
|
||||
effect, rule := Evaluate(adminInput(a, ResourceAccount), nil)
|
||||
if effect != Allow {
|
||||
t.Errorf("admin should be allowed %s, got Deny", a)
|
||||
}
|
||||
if rule == nil || rule.ID != -1 {
|
||||
t.Errorf("expected admin wildcard rule (-1), got %v", rule)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_SelfServiceLogout(t *testing.T) {
|
||||
input := PolicyInput{
|
||||
Subject: "user-uuid",
|
||||
AccountType: "human",
|
||||
Roles: []string{},
|
||||
Action: ActionLogout,
|
||||
Resource: Resource{Type: ResourceToken},
|
||||
}
|
||||
effect, _ := Evaluate(input, nil)
|
||||
if effect != Allow {
|
||||
t.Error("expected any authenticated user to be allowed to logout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_SelfServiceRenew(t *testing.T) {
|
||||
input := PolicyInput{
|
||||
Subject: "user-uuid",
|
||||
AccountType: "human",
|
||||
Roles: []string{},
|
||||
Action: ActionRenewToken,
|
||||
Resource: Resource{Type: ResourceToken},
|
||||
}
|
||||
effect, _ := Evaluate(input, nil)
|
||||
if effect != Allow {
|
||||
t.Error("expected any authenticated user to be allowed to renew token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_SystemOwnPGCreds(t *testing.T) {
|
||||
input := PolicyInput{
|
||||
Subject: "svc-uuid",
|
||||
AccountType: "system",
|
||||
Roles: []string{},
|
||||
Action: ActionReadPGCreds,
|
||||
Resource: Resource{
|
||||
Type: ResourcePGCreds,
|
||||
OwnerUUID: "svc-uuid", // owner matches subject
|
||||
},
|
||||
}
|
||||
effect, rule := Evaluate(input, nil)
|
||||
if effect != Allow {
|
||||
t.Errorf("system account should be allowed to read own pgcreds, got Deny")
|
||||
}
|
||||
if rule == nil || rule.ID != -4 {
|
||||
t.Errorf("expected built-in rule -4, got %v", rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_SystemOtherPGCreds_Denied(t *testing.T) {
|
||||
// System account trying to read another system account's pgcreds.
|
||||
input := PolicyInput{
|
||||
Subject: "svc-uuid",
|
||||
AccountType: "system",
|
||||
Roles: []string{},
|
||||
Action: ActionReadPGCreds,
|
||||
Resource: Resource{
|
||||
Type: ResourcePGCreds,
|
||||
OwnerUUID: "other-svc-uuid", // different owner
|
||||
},
|
||||
}
|
||||
effect, _ := Evaluate(input, nil)
|
||||
if effect != Allow {
|
||||
// This is the expected behavior: default-deny.
|
||||
return
|
||||
}
|
||||
t.Error("system account must not read another account's pgcreds without an explicit rule")
|
||||
}
|
||||
|
||||
func TestEvaluate_DenyWins(t *testing.T) {
|
||||
// Operator adds a Deny rule for a specific subject; a broader Allow rule
|
||||
// also matches. Deny must win regardless of order.
|
||||
operatorRules := []Rule{
|
||||
{
|
||||
ID: 1,
|
||||
Description: "broad allow",
|
||||
Priority: 100,
|
||||
Actions: []Action{ActionReadPGCreds},
|
||||
ResourceType: ResourcePGCreds,
|
||||
Effect: Allow,
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Description: "specific deny",
|
||||
Priority: 50, // higher precedence than the allow
|
||||
SubjectUUID: "bad-actor-uuid",
|
||||
ResourceType: ResourcePGCreds,
|
||||
Effect: Deny,
|
||||
},
|
||||
}
|
||||
input := PolicyInput{
|
||||
Subject: "bad-actor-uuid",
|
||||
AccountType: "human",
|
||||
Roles: []string{},
|
||||
Action: ActionReadPGCreds,
|
||||
Resource: Resource{Type: ResourcePGCreds},
|
||||
}
|
||||
effect, rule := Evaluate(input, operatorRules)
|
||||
if effect != Deny {
|
||||
t.Errorf("deny rule should win over allow rule, got Allow")
|
||||
}
|
||||
if rule == nil || rule.ID != 2 {
|
||||
t.Errorf("expected deny rule ID 2, got %v", rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_ServiceNameGating(t *testing.T) {
|
||||
operatorRules := []Rule{
|
||||
{
|
||||
ID: 3,
|
||||
Description: "alice may read payments-api pgcreds",
|
||||
Priority: 50,
|
||||
Roles: []string{"svc:payments-api"},
|
||||
Actions: []Action{ActionReadPGCreds},
|
||||
ResourceType: ResourcePGCreds,
|
||||
ServiceNames: []string{"payments-api"},
|
||||
Effect: Allow,
|
||||
},
|
||||
}
|
||||
|
||||
alice := PolicyInput{
|
||||
Subject: "alice-uuid",
|
||||
AccountType: "human",
|
||||
Roles: []string{"svc:payments-api"},
|
||||
Action: ActionReadPGCreds,
|
||||
Resource: Resource{
|
||||
Type: ResourcePGCreds,
|
||||
ServiceName: "payments-api",
|
||||
},
|
||||
}
|
||||
effect, _ := Evaluate(alice, operatorRules)
|
||||
if effect != Allow {
|
||||
t.Error("alice should be allowed to read payments-api pgcreds")
|
||||
}
|
||||
|
||||
// Same principal, wrong service — should be denied.
|
||||
alice.Resource.ServiceName = "user-service"
|
||||
effect, _ = Evaluate(alice, operatorRules)
|
||||
if effect != Deny {
|
||||
t.Error("alice should be denied access to user-service pgcreds")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_MachineTagGating(t *testing.T) {
|
||||
operatorRules := []Rule{
|
||||
{
|
||||
ID: 4,
|
||||
Description: "deploy-agent: staging only",
|
||||
Priority: 50,
|
||||
SubjectUUID: "deploy-agent-uuid",
|
||||
Actions: []Action{ActionReadPGCreds},
|
||||
ResourceType: ResourcePGCreds,
|
||||
RequiredTags: []string{"env:staging"},
|
||||
Effect: Allow,
|
||||
},
|
||||
{
|
||||
ID: 5,
|
||||
Description: "deploy-agent: deny production (belt-and-suspenders)",
|
||||
Priority: 10, // evaluated before the allow
|
||||
SubjectUUID: "deploy-agent-uuid",
|
||||
ResourceType: ResourcePGCreds,
|
||||
RequiredTags: []string{"env:production"},
|
||||
Effect: Deny,
|
||||
},
|
||||
}
|
||||
|
||||
staging := PolicyInput{
|
||||
Subject: "deploy-agent-uuid",
|
||||
AccountType: "system",
|
||||
Roles: []string{},
|
||||
Action: ActionReadPGCreds,
|
||||
Resource: Resource{
|
||||
Type: ResourcePGCreds,
|
||||
Tags: []string{"env:staging", "svc:payments-api"},
|
||||
},
|
||||
}
|
||||
effect, _ := Evaluate(staging, operatorRules)
|
||||
if effect != Allow {
|
||||
t.Error("deploy-agent should be allowed to read staging pgcreds")
|
||||
}
|
||||
|
||||
production := staging
|
||||
production.Resource.Tags = []string{"env:production", "svc:payments-api"}
|
||||
effect, rule := Evaluate(production, operatorRules)
|
||||
if effect != Deny {
|
||||
t.Error("deploy-agent should be denied access to production pgcreds")
|
||||
}
|
||||
if rule == nil || rule.ID != 5 {
|
||||
t.Errorf("expected deny rule ID 5 for production, got %v", rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_OwnerMatchesSubject(t *testing.T) {
|
||||
// Operator rule: a user may read account details for accounts they own.
|
||||
operatorRules := []Rule{
|
||||
{
|
||||
ID: 6,
|
||||
Description: "principals may read their own account",
|
||||
Priority: 50,
|
||||
Actions: []Action{ActionReadAccount},
|
||||
ResourceType: ResourceAccount,
|
||||
OwnerMatchesSubject: true,
|
||||
Effect: Allow,
|
||||
},
|
||||
}
|
||||
|
||||
// Reading own account — should be allowed.
|
||||
own := PolicyInput{
|
||||
Subject: "user-uuid",
|
||||
AccountType: "human",
|
||||
Roles: []string{},
|
||||
Action: ActionReadAccount,
|
||||
Resource: Resource{
|
||||
Type: ResourceAccount,
|
||||
OwnerUUID: "user-uuid",
|
||||
},
|
||||
}
|
||||
effect, _ := Evaluate(own, operatorRules)
|
||||
if effect != Allow {
|
||||
t.Error("user should be allowed to read their own account")
|
||||
}
|
||||
|
||||
// Reading another user's account — should be denied.
|
||||
other := own
|
||||
other.Resource.OwnerUUID = "other-uuid"
|
||||
effect, _ = Evaluate(other, operatorRules)
|
||||
if effect != Deny {
|
||||
t.Error("user must not read another user's account without an explicit rule")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_PriorityOrder(t *testing.T) {
|
||||
// Two Allow rules at different priorities: the lower-priority number wins.
|
||||
operatorRules := []Rule{
|
||||
{ID: 10, Description: "low priority allow", Priority: 200, Actions: []Action{ActionReadAudit}, Effect: Allow},
|
||||
{ID: 11, Description: "high priority allow", Priority: 10, Actions: []Action{ActionReadAudit}, Effect: Allow},
|
||||
}
|
||||
input := PolicyInput{
|
||||
Subject: "user-uuid",
|
||||
AccountType: "human",
|
||||
Roles: []string{},
|
||||
Action: ActionReadAudit,
|
||||
Resource: Resource{Type: ResourceAuditLog},
|
||||
}
|
||||
_, rule := Evaluate(input, operatorRules)
|
||||
if rule == nil || rule.ID != 11 {
|
||||
t.Errorf("expected higher-priority rule (ID 11) to match first, got %v", rule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_MultipleRequiredTags(t *testing.T) {
|
||||
// RequiredTags requires ALL tags to be present.
|
||||
operatorRules := []Rule{
|
||||
{
|
||||
ID: 20,
|
||||
Description: "allow if both env:staging and svc:payments-api tags present",
|
||||
Priority: 50,
|
||||
Actions: []Action{ActionReadPGCreds},
|
||||
ResourceType: ResourcePGCreds,
|
||||
RequiredTags: []string{"env:staging", "svc:payments-api"},
|
||||
Effect: Allow,
|
||||
},
|
||||
}
|
||||
|
||||
// Both tags present — allowed.
|
||||
input := PolicyInput{
|
||||
Subject: "user-uuid",
|
||||
AccountType: "human",
|
||||
Roles: []string{},
|
||||
Action: ActionReadPGCreds,
|
||||
Resource: Resource{
|
||||
Type: ResourcePGCreds,
|
||||
Tags: []string{"env:staging", "svc:payments-api", "extra:tag"},
|
||||
},
|
||||
}
|
||||
effect, _ := Evaluate(input, operatorRules)
|
||||
if effect != Allow {
|
||||
t.Error("both required tags present: should be allowed")
|
||||
}
|
||||
|
||||
// Only one tag present — denied (default-deny).
|
||||
input.Resource.Tags = []string{"env:staging"}
|
||||
effect, _ = Evaluate(input, operatorRules)
|
||||
if effect != Deny {
|
||||
t.Error("only one required tag present: should be denied")
|
||||
}
|
||||
|
||||
// No tags — denied.
|
||||
input.Resource.Tags = nil
|
||||
effect, _ = Evaluate(input, operatorRules)
|
||||
if effect != Deny {
|
||||
t.Error("no tags: should be denied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_AccountTypeGating(t *testing.T) {
|
||||
// Rule only applies to system accounts.
|
||||
operatorRules := []Rule{
|
||||
{
|
||||
ID: 30,
|
||||
Description: "system accounts may list accounts",
|
||||
Priority: 50,
|
||||
AccountTypes: []string{"system"},
|
||||
Actions: []Action{ActionListAccounts},
|
||||
Effect: Allow,
|
||||
},
|
||||
}
|
||||
|
||||
sysInput := PolicyInput{
|
||||
Subject: "svc-uuid",
|
||||
AccountType: "system",
|
||||
Roles: []string{},
|
||||
Action: ActionListAccounts,
|
||||
Resource: Resource{Type: ResourceAccount},
|
||||
}
|
||||
effect, _ := Evaluate(sysInput, operatorRules)
|
||||
if effect != Allow {
|
||||
t.Error("system account should be allowed by account-type rule")
|
||||
}
|
||||
|
||||
humanInput := sysInput
|
||||
humanInput.AccountType = "human"
|
||||
effect, _ = Evaluate(humanInput, operatorRules)
|
||||
if effect != Deny {
|
||||
t.Error("human account should not match system-only rule")
|
||||
}
|
||||
}
|
||||
83
internal/policy/engine_wrapper.go
Normal file
83
internal/policy/engine_wrapper.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Engine wraps the stateless Evaluate function with an in-memory cache of
|
||||
// operator rules loaded from the database. Built-in default rules are always
|
||||
// merged in at evaluation time; they do not appear in the cache.
|
||||
//
|
||||
// The Engine is safe for concurrent use. Call Reload() after any change to the
|
||||
// policy_rules table to refresh the cached rule set without restarting.
|
||||
type Engine struct {
|
||||
rules []Rule
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewEngine creates an Engine with an initially empty operator rule set.
|
||||
// Call Reload (or load rules directly) before use in production.
|
||||
func NewEngine() *Engine {
|
||||
return &Engine{}
|
||||
}
|
||||
|
||||
// SetRules atomically replaces the cached operator rule set.
|
||||
// records is a slice of PolicyRuleRecord values (from the database layer).
|
||||
// Only enabled records are converted to Rule values.
|
||||
//
|
||||
// Security: rule_json is decoded into a RuleBody struct before being merged
|
||||
// into a Rule. This prevents the database from injecting values into the ID or
|
||||
// Description fields that are stored as dedicated columns.
|
||||
func (e *Engine) SetRules(records []PolicyRecord) error {
|
||||
rules := make([]Rule, 0, len(records))
|
||||
for _, rec := range records {
|
||||
if !rec.Enabled {
|
||||
continue
|
||||
}
|
||||
var body RuleBody
|
||||
if err := json.Unmarshal([]byte(rec.RuleJSON), &body); err != nil {
|
||||
return fmt.Errorf("policy: decode rule %d %q: %w", rec.ID, rec.Description, err)
|
||||
}
|
||||
rules = append(rules, Rule{
|
||||
ID: rec.ID,
|
||||
Description: rec.Description,
|
||||
Priority: rec.Priority,
|
||||
Roles: body.Roles,
|
||||
AccountTypes: body.AccountTypes,
|
||||
SubjectUUID: body.SubjectUUID,
|
||||
Actions: body.Actions,
|
||||
ResourceType: body.ResourceType,
|
||||
OwnerMatchesSubject: body.OwnerMatchesSubject,
|
||||
ServiceNames: body.ServiceNames,
|
||||
RequiredTags: body.RequiredTags,
|
||||
Effect: body.Effect,
|
||||
})
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
e.rules = rules
|
||||
e.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Evaluate runs the policy engine against the given input using the cached
|
||||
// operator rules plus compiled-in defaults.
|
||||
func (e *Engine) Evaluate(input PolicyInput) (Effect, *Rule) {
|
||||
e.mu.RLock()
|
||||
rules := e.rules
|
||||
e.mu.RUnlock()
|
||||
return Evaluate(input, rules)
|
||||
}
|
||||
|
||||
// PolicyRecord is the minimal interface the Engine needs from the DB layer.
|
||||
// Using a local struct avoids importing the db or model packages from policy,
|
||||
// which would create a dependency cycle.
|
||||
type PolicyRecord struct {
|
||||
Description string
|
||||
RuleJSON string
|
||||
ID int64
|
||||
Priority int
|
||||
Enabled bool
|
||||
}
|
||||
141
internal/policy/policy.go
Normal file
141
internal/policy/policy.go
Normal file
@@ -0,0 +1,141 @@
|
||||
// Package policy implements an in-process, attribute-based authorization
|
||||
// policy engine for MCIAS. Evaluation is a pure function: given a PolicyInput
|
||||
// and a slice of Rules it returns an Effect (Allow or Deny) and the Rule that
|
||||
// produced the decision. The caller is responsible for assembling PolicyInput
|
||||
// from JWT claims and database lookups; the engine never touches the database.
|
||||
//
|
||||
// Evaluation order:
|
||||
// 1. Rules are sorted by Priority (ascending; lower = higher precedence).
|
||||
// 2. Deny-wins: the first matching Deny rule terminates evaluation.
|
||||
// 3. If no Deny matched, the first matching Allow rule permits the request.
|
||||
// 4. Default-deny: if no rule matches, the request is denied.
|
||||
package policy
|
||||
|
||||
// Action is a structured action identifier of the form "resource:verb".
|
||||
// Security: using typed constants prevents callers from passing arbitrary
|
||||
// strings, making it harder to accidentally bypass a policy check.
|
||||
type Action string
|
||||
|
||||
const (
|
||||
ActionListAccounts Action = "accounts:list"
|
||||
ActionCreateAccount Action = "accounts:create"
|
||||
ActionReadAccount Action = "accounts:read"
|
||||
ActionUpdateAccount Action = "accounts:update"
|
||||
ActionDeleteAccount Action = "accounts:delete"
|
||||
|
||||
ActionReadRoles Action = "roles:read"
|
||||
ActionWriteRoles Action = "roles:write"
|
||||
|
||||
ActionReadTags Action = "tags:read"
|
||||
ActionWriteTags Action = "tags:write"
|
||||
|
||||
ActionIssueToken Action = "tokens:issue"
|
||||
ActionRevokeToken Action = "tokens:revoke"
|
||||
ActionValidateToken Action = "tokens:validate" // public endpoint
|
||||
ActionRenewToken Action = "tokens:renew" // self-service
|
||||
|
||||
ActionReadPGCreds Action = "pgcreds:read"
|
||||
ActionWritePGCreds Action = "pgcreds:write"
|
||||
|
||||
ActionReadAudit Action = "audit:read"
|
||||
|
||||
ActionEnrollTOTP Action = "totp:enroll" // self-service
|
||||
ActionRemoveTOTP Action = "totp:remove" // admin
|
||||
|
||||
ActionLogin Action = "auth:login" // public
|
||||
ActionLogout Action = "auth:logout" // self-service
|
||||
|
||||
ActionListRules Action = "policy:list"
|
||||
ActionManageRules Action = "policy:manage"
|
||||
)
|
||||
|
||||
// ResourceType identifies what kind of object a request targets.
|
||||
type ResourceType string
|
||||
|
||||
const (
|
||||
ResourceAccount ResourceType = "account"
|
||||
ResourceToken ResourceType = "token"
|
||||
ResourcePGCreds ResourceType = "pgcreds"
|
||||
ResourceAuditLog ResourceType = "audit_log"
|
||||
ResourceTOTP ResourceType = "totp"
|
||||
ResourcePolicy ResourceType = "policy"
|
||||
)
|
||||
|
||||
// Effect is the outcome of policy evaluation.
|
||||
type Effect string
|
||||
|
||||
const (
|
||||
Allow Effect = "allow"
|
||||
Deny Effect = "deny"
|
||||
)
|
||||
|
||||
// Resource describes the object the principal is attempting to act on. Tags
|
||||
// are loaded from the account_tags table by the middleware before evaluation.
|
||||
type Resource struct {
|
||||
Type ResourceType
|
||||
|
||||
// OwnerUUID is the UUID of the account that owns this resource (e.g. the
|
||||
// system account whose pg_credentials are being requested). Empty when the
|
||||
// resource is not account-owned (e.g. an audit log listing).
|
||||
OwnerUUID string
|
||||
|
||||
// ServiceName is the username of the system account that owns the resource.
|
||||
// Used for service-name-based gating rules (ServiceNames field on Rule).
|
||||
ServiceName string
|
||||
|
||||
// Tags are the account_tags values on the target account. The engine
|
||||
// checks RequiredTags against this slice.
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// PolicyInput is assembled by the middleware from JWT claims and the current
|
||||
// request context. The engine accepts a PolicyInput and a rule set; it never
|
||||
// queries the database directly.
|
||||
type PolicyInput struct {
|
||||
// Principal fields — from JWT claims
|
||||
Subject string // account UUID ("sub")
|
||||
AccountType string // "human" or "system"
|
||||
Roles []string // role strings from "roles" claim
|
||||
|
||||
Action Action
|
||||
Resource Resource
|
||||
}
|
||||
|
||||
// Rule is a single policy statement. All non-zero fields are AND-ed together
|
||||
// as match conditions. A zero/empty field is a wildcard.
|
||||
//
|
||||
// Security: rules from the database are decoded and merged with compiled-in
|
||||
// defaults before evaluation. Neither the JSON encoding nor the DB storage is
|
||||
// trusted to produce sensible rules; the engine validates each condition
|
||||
// independently using set membership — there is no string interpolation or
|
||||
// code execution involved.
|
||||
type Rule struct {
|
||||
Description string
|
||||
SubjectUUID string
|
||||
ResourceType ResourceType
|
||||
Effect Effect
|
||||
Roles []string
|
||||
AccountTypes []string
|
||||
Actions []Action
|
||||
ServiceNames []string
|
||||
RequiredTags []string
|
||||
ID int64
|
||||
Priority int
|
||||
OwnerMatchesSubject bool
|
||||
}
|
||||
|
||||
// RuleBody is the subset of Rule that is stored as JSON in the policy_rules
|
||||
// table. ID, Description, and Priority are stored as dedicated columns.
|
||||
// Security: the JSON blob is decoded into a RuleBody before being merged into
|
||||
// a full Rule, so the database cannot inject ID or Description values.
|
||||
type RuleBody struct {
|
||||
SubjectUUID string `json:"subject_uuid,omitempty"`
|
||||
ResourceType ResourceType `json:"resource_type,omitempty"`
|
||||
Effect Effect `json:"effect"`
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
AccountTypes []string `json:"account_types,omitempty"`
|
||||
Actions []Action `json:"actions,omitempty"`
|
||||
ServiceNames []string `json:"service_names,omitempty"`
|
||||
RequiredTags []string `json:"required_tags,omitempty"`
|
||||
OwnerMatchesSubject bool `json:"owner_matches_subject,omitempty"`
|
||||
}
|
||||
324
internal/server/handlers_policy.go
Normal file
324
internal/server/handlers_policy.go
Normal file
@@ -0,0 +1,324 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/middleware"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
||||
)
|
||||
|
||||
// ---- Tag endpoints ----
|
||||
|
||||
type tagsResponse struct {
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
func (s *Server) handleGetTags(w http.ResponseWriter, r *http.Request) {
|
||||
acct, ok := s.loadAccount(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tags, err := s.db.GetAccountTags(acct.ID)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tagsResponse{Tags: tags})
|
||||
}
|
||||
|
||||
type setTagsRequest struct {
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSetTags(w http.ResponseWriter, r *http.Request) {
|
||||
acct, ok := s.loadAccount(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req setTagsRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate tags: each must be non-empty.
|
||||
for _, tag := range req.Tags {
|
||||
if tag == "" {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "tag values must not be empty", "bad_request")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.db.SetAccountTags(acct.ID, req.Tags); err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
// Determine actor for audit log.
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if claims != nil {
|
||||
if actor, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||
actorID = &actor.ID
|
||||
}
|
||||
}
|
||||
|
||||
s.writeAudit(r, model.EventTagAdded, actorID, &acct.ID,
|
||||
fmt.Sprintf(`{"account":%q,"tags":%s}`, acct.UUID, marshalStringSlice(req.Tags)))
|
||||
|
||||
tags, err := s.db.GetAccountTags(acct.ID)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tagsResponse{Tags: tags})
|
||||
}
|
||||
|
||||
// ---- Policy rule endpoints ----
|
||||
|
||||
type policyRuleResponse struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Description string `json:"description"`
|
||||
RuleBody policy.RuleBody `json:"rule"`
|
||||
ID int64 `json:"id"`
|
||||
Priority int `json:"priority"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func policyRuleToResponse(rec *model.PolicyRuleRecord) (policyRuleResponse, error) {
|
||||
var body policy.RuleBody
|
||||
if err := json.Unmarshal([]byte(rec.RuleJSON), &body); err != nil {
|
||||
return policyRuleResponse{}, fmt.Errorf("decode rule body: %w", err)
|
||||
}
|
||||
return policyRuleResponse{
|
||||
ID: rec.ID,
|
||||
Priority: rec.Priority,
|
||||
Description: rec.Description,
|
||||
RuleBody: body,
|
||||
Enabled: rec.Enabled,
|
||||
CreatedAt: rec.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: rec.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleListPolicyRules(w http.ResponseWriter, _ *http.Request) {
|
||||
rules, err := s.db.ListPolicyRules(false)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
resp := make([]policyRuleResponse, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
rv, err := policyRuleToResponse(r)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
resp = append(resp, rv)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type createPolicyRuleRequest struct {
|
||||
Description string `json:"description"`
|
||||
Rule policy.RuleBody `json:"rule"`
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request) {
|
||||
var req createPolicyRuleRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Description == "" {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "description is required", "bad_request")
|
||||
return
|
||||
}
|
||||
if req.Rule.Effect != policy.Allow && req.Rule.Effect != policy.Deny {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "rule.effect must be 'allow' or 'deny'", "bad_request")
|
||||
return
|
||||
}
|
||||
|
||||
priority := req.Priority
|
||||
if priority == 0 {
|
||||
priority = 100 // default
|
||||
}
|
||||
|
||||
ruleJSON, err := json.Marshal(req.Rule)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
var createdBy *int64
|
||||
if claims != nil {
|
||||
if actor, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||
createdBy = &actor.ID
|
||||
}
|
||||
}
|
||||
|
||||
rec, err := s.db.CreatePolicyRule(req.Description, priority, string(ruleJSON), createdBy)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
s.writeAudit(r, model.EventPolicyRuleCreated, createdBy, nil,
|
||||
fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description))
|
||||
|
||||
rv, err := policyRuleToResponse(rec)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, rv)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetPolicyRule(w http.ResponseWriter, r *http.Request) {
|
||||
rec, ok := s.loadPolicyRule(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rv, err := policyRuleToResponse(rec)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rv)
|
||||
}
|
||||
|
||||
type updatePolicyRuleRequest struct {
|
||||
Description *string `json:"description,omitempty"`
|
||||
Rule *policy.RuleBody `json:"rule,omitempty"`
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdatePolicyRule(w http.ResponseWriter, r *http.Request) {
|
||||
rec, ok := s.loadPolicyRule(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req updatePolicyRuleRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate effect if rule body is being updated.
|
||||
var ruleJSON *string
|
||||
if req.Rule != nil {
|
||||
if req.Rule.Effect != policy.Allow && req.Rule.Effect != policy.Deny {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "rule.effect must be 'allow' or 'deny'", "bad_request")
|
||||
return
|
||||
}
|
||||
b, err := json.Marshal(req.Rule)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
s := string(b)
|
||||
ruleJSON = &s
|
||||
}
|
||||
|
||||
if err := s.db.UpdatePolicyRule(rec.ID, req.Description, req.Priority, ruleJSON); err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Enabled != nil {
|
||||
if err := s.db.SetPolicyRuleEnabled(rec.ID, *req.Enabled); err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if claims != nil {
|
||||
if actor, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||
actorID = &actor.ID
|
||||
}
|
||||
}
|
||||
s.writeAudit(r, model.EventPolicyRuleUpdated, actorID, nil,
|
||||
fmt.Sprintf(`{"rule_id":%d}`, rec.ID))
|
||||
|
||||
updated, err := s.db.GetPolicyRule(rec.ID)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
rv, err := policyRuleToResponse(updated)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rv)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeletePolicyRule(w http.ResponseWriter, r *http.Request) {
|
||||
rec, ok := s.loadPolicyRule(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.db.DeletePolicyRule(rec.ID); err != nil {
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return
|
||||
}
|
||||
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if claims != nil {
|
||||
if actor, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||
actorID = &actor.ID
|
||||
}
|
||||
}
|
||||
s.writeAudit(r, model.EventPolicyRuleDeleted, actorID, nil,
|
||||
fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description))
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// loadPolicyRule retrieves a policy rule by the {id} path parameter.
|
||||
func (s *Server) loadPolicyRule(w http.ResponseWriter, r *http.Request) (*model.PolicyRuleRecord, bool) {
|
||||
idStr := r.PathValue("id")
|
||||
if idStr == "" {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "rule id is required", "bad_request")
|
||||
return nil, false
|
||||
}
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
middleware.WriteError(w, http.StatusBadRequest, "rule id must be an integer", "bad_request")
|
||||
return nil, false
|
||||
}
|
||||
rec, err := s.db.GetPolicyRule(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
middleware.WriteError(w, http.StatusNotFound, "policy rule not found", "not_found")
|
||||
return nil, false
|
||||
}
|
||||
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
|
||||
return nil, false
|
||||
}
|
||||
return rec, true
|
||||
}
|
||||
|
||||
// marshalStringSlice encodes a string slice as a compact JSON array.
|
||||
// Used for audit log details — never includes credential material.
|
||||
func marshalStringSlice(ss []string) string {
|
||||
b, _ := json.Marshal(ss)
|
||||
return string(b)
|
||||
}
|
||||
@@ -119,6 +119,13 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("GET /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleGetPGCreds)))
|
||||
mux.Handle("PUT /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleSetPGCreds)))
|
||||
mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit)))
|
||||
mux.Handle("GET /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleGetTags)))
|
||||
mux.Handle("PUT /v1/accounts/{id}/tags", requireAdmin(http.HandlerFunc(s.handleSetTags)))
|
||||
mux.Handle("GET /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleListPolicyRules)))
|
||||
mux.Handle("POST /v1/policy/rules", requireAdmin(http.HandlerFunc(s.handleCreatePolicyRule)))
|
||||
mux.Handle("GET /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleGetPolicyRule)))
|
||||
mux.Handle("PATCH /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleUpdatePolicyRule)))
|
||||
mux.Handle("DELETE /v1/policy/rules/{id}", requireAdmin(http.HandlerFunc(s.handleDeletePolicyRule)))
|
||||
|
||||
// UI routes (HTMX-based management frontend).
|
||||
uiSrv, err := ui.New(s.db, s.cfg, s.privKey, s.pubKey, s.masterKey, s.logger)
|
||||
|
||||
@@ -143,6 +143,12 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
|
||||
// ErrNotFound is expected when no credentials have been stored yet.
|
||||
}
|
||||
|
||||
tags, err := u.db.GetAccountTags(acct.ID)
|
||||
if err != nil {
|
||||
u.logger.Warn("get account tags", "error", err)
|
||||
tags = nil
|
||||
}
|
||||
|
||||
u.render(w, "account_detail", AccountDetailData{
|
||||
PageData: PageData{CSRFToken: csrfToken},
|
||||
Account: acct,
|
||||
@@ -150,6 +156,7 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
|
||||
AllRoles: knownRoles,
|
||||
Tokens: tokens,
|
||||
PGCred: pgCred,
|
||||
Tags: tags,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
347
internal/ui/handlers_policy.go
Normal file
347
internal/ui/handlers_policy.go
Normal file
@@ -0,0 +1,347 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/db"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/model"
|
||||
"git.wntrmute.dev/kyle/mcias/internal/policy"
|
||||
)
|
||||
|
||||
// ---- Policies page ----
|
||||
|
||||
// allActionStrings is the list of all policy action constants for the form UI.
|
||||
var allActionStrings = []string{
|
||||
string(policy.ActionListAccounts),
|
||||
string(policy.ActionCreateAccount),
|
||||
string(policy.ActionReadAccount),
|
||||
string(policy.ActionUpdateAccount),
|
||||
string(policy.ActionDeleteAccount),
|
||||
string(policy.ActionReadRoles),
|
||||
string(policy.ActionWriteRoles),
|
||||
string(policy.ActionReadTags),
|
||||
string(policy.ActionWriteTags),
|
||||
string(policy.ActionIssueToken),
|
||||
string(policy.ActionRevokeToken),
|
||||
string(policy.ActionValidateToken),
|
||||
string(policy.ActionRenewToken),
|
||||
string(policy.ActionReadPGCreds),
|
||||
string(policy.ActionWritePGCreds),
|
||||
string(policy.ActionReadAudit),
|
||||
string(policy.ActionEnrollTOTP),
|
||||
string(policy.ActionRemoveTOTP),
|
||||
string(policy.ActionLogin),
|
||||
string(policy.ActionLogout),
|
||||
string(policy.ActionListRules),
|
||||
string(policy.ActionManageRules),
|
||||
}
|
||||
|
||||
func (u *UIServer) handlePoliciesPage(w http.ResponseWriter, r *http.Request) {
|
||||
csrfToken, err := u.setCSRFCookies(w)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
rules, err := u.db.ListPolicyRules(false)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "failed to load policy rules")
|
||||
return
|
||||
}
|
||||
|
||||
views := make([]*PolicyRuleView, 0, len(rules))
|
||||
for _, rec := range rules {
|
||||
views = append(views, policyRuleToView(rec))
|
||||
}
|
||||
|
||||
data := PoliciesData{
|
||||
PageData: PageData{CSRFToken: csrfToken},
|
||||
Rules: views,
|
||||
AllActions: allActionStrings,
|
||||
}
|
||||
u.render(w, "policies", data)
|
||||
}
|
||||
|
||||
// policyRuleToView converts a DB record to a template-friendly view.
|
||||
func policyRuleToView(rec *model.PolicyRuleRecord) *PolicyRuleView {
|
||||
pretty := prettyJSONStr(rec.RuleJSON)
|
||||
return &PolicyRuleView{
|
||||
ID: rec.ID,
|
||||
Priority: rec.Priority,
|
||||
Description: rec.Description,
|
||||
RuleJSON: pretty,
|
||||
Enabled: rec.Enabled,
|
||||
CreatedAt: rec.CreatedAt.Format("2006-01-02 15:04 UTC"),
|
||||
UpdatedAt: rec.UpdatedAt.Format("2006-01-02 15:04 UTC"),
|
||||
}
|
||||
}
|
||||
|
||||
func prettyJSONStr(s string) string {
|
||||
var v json.RawMessage
|
||||
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||
return s
|
||||
}
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// handleCreatePolicyRule handles POST /policies — creates a new policy rule.
|
||||
func (u *UIServer) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, "invalid form")
|
||||
return
|
||||
}
|
||||
|
||||
description := strings.TrimSpace(r.FormValue("description"))
|
||||
if description == "" {
|
||||
u.renderError(w, r, http.StatusBadRequest, "description is required")
|
||||
return
|
||||
}
|
||||
|
||||
priorityStr := r.FormValue("priority")
|
||||
priority := 100
|
||||
if priorityStr != "" {
|
||||
p, err := strconv.Atoi(priorityStr)
|
||||
if err != nil || p < 0 {
|
||||
u.renderError(w, r, http.StatusBadRequest, "priority must be a non-negative integer")
|
||||
return
|
||||
}
|
||||
priority = p
|
||||
}
|
||||
|
||||
effectStr := r.FormValue("effect")
|
||||
if effectStr != string(policy.Allow) && effectStr != string(policy.Deny) {
|
||||
u.renderError(w, r, http.StatusBadRequest, "effect must be 'allow' or 'deny'")
|
||||
return
|
||||
}
|
||||
|
||||
body := policy.RuleBody{
|
||||
Effect: policy.Effect(effectStr),
|
||||
}
|
||||
|
||||
// Multi-value fields.
|
||||
if roles := r.Form["roles"]; len(roles) > 0 {
|
||||
body.Roles = roles
|
||||
}
|
||||
if types := r.Form["account_types"]; len(types) > 0 {
|
||||
body.AccountTypes = types
|
||||
}
|
||||
if actions := r.Form["actions"]; len(actions) > 0 {
|
||||
acts := make([]policy.Action, len(actions))
|
||||
for i, a := range actions {
|
||||
acts[i] = policy.Action(a)
|
||||
}
|
||||
body.Actions = acts
|
||||
}
|
||||
if resType := r.FormValue("resource_type"); resType != "" {
|
||||
body.ResourceType = policy.ResourceType(resType)
|
||||
}
|
||||
body.SubjectUUID = strings.TrimSpace(r.FormValue("subject_uuid"))
|
||||
body.OwnerMatchesSubject = r.FormValue("owner_matches_subject") == "1"
|
||||
if svcNames := r.FormValue("service_names"); svcNames != "" {
|
||||
body.ServiceNames = splitCommas(svcNames)
|
||||
}
|
||||
if tags := r.FormValue("required_tags"); tags != "" {
|
||||
body.RequiredTags = splitCommas(tags)
|
||||
}
|
||||
|
||||
ruleJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if claims != nil {
|
||||
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||
actorID = &actor.ID
|
||||
}
|
||||
}
|
||||
|
||||
rec, err := u.db.CreatePolicyRule(description, priority, string(ruleJSON), actorID)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, fmt.Sprintf("create policy rule: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
u.writeAudit(r, model.EventPolicyRuleCreated, actorID, nil,
|
||||
fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description))
|
||||
|
||||
u.render(w, "policy_row", policyRuleToView(rec))
|
||||
}
|
||||
|
||||
// handleTogglePolicyRule handles PATCH /policies/{id}/enabled — enable or disable.
|
||||
func (u *UIServer) handleTogglePolicyRule(w http.ResponseWriter, r *http.Request) {
|
||||
rec, ok := u.loadPolicyRule(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, "invalid form")
|
||||
return
|
||||
}
|
||||
|
||||
enabledStr := r.FormValue("enabled")
|
||||
enabled := enabledStr == "1" || enabledStr == "true"
|
||||
|
||||
if err := u.db.SetPolicyRuleEnabled(rec.ID, enabled); err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "update failed")
|
||||
return
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if claims != nil {
|
||||
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||
actorID = &actor.ID
|
||||
}
|
||||
}
|
||||
u.writeAudit(r, model.EventPolicyRuleUpdated, actorID, nil,
|
||||
fmt.Sprintf(`{"rule_id":%d,"enabled":%v}`, rec.ID, enabled))
|
||||
|
||||
rec.Enabled = enabled
|
||||
u.render(w, "policy_row", policyRuleToView(rec))
|
||||
}
|
||||
|
||||
// handleDeletePolicyRule handles DELETE /policies/{id}.
|
||||
func (u *UIServer) handleDeletePolicyRule(w http.ResponseWriter, r *http.Request) {
|
||||
rec, ok := u.loadPolicyRule(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := u.db.DeletePolicyRule(rec.ID); err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "delete failed")
|
||||
return
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if claims != nil {
|
||||
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||
actorID = &actor.ID
|
||||
}
|
||||
}
|
||||
u.writeAudit(r, model.EventPolicyRuleDeleted, actorID, nil,
|
||||
fmt.Sprintf(`{"rule_id":%d}`, rec.ID))
|
||||
|
||||
// Return empty string to remove the row from the DOM.
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// ---- Tag management ----
|
||||
|
||||
// handleSetAccountTags handles PUT /accounts/{id}/tags from the UI.
|
||||
func (u *UIServer) handleSetAccountTags(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
acct, err := u.db.GetAccountByUUID(id)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusNotFound, "account not found")
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, "invalid form")
|
||||
return
|
||||
}
|
||||
|
||||
tagsRaw := strings.TrimSpace(r.FormValue("tags_text"))
|
||||
var tags []string
|
||||
if tagsRaw != "" {
|
||||
tags = splitLines(tagsRaw)
|
||||
}
|
||||
|
||||
// Validate: no empty tags.
|
||||
for _, tag := range tags {
|
||||
if tag == "" {
|
||||
u.renderError(w, r, http.StatusBadRequest, "tag values must not be empty")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := u.db.SetAccountTags(acct.ID, tags); err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "update failed")
|
||||
return
|
||||
}
|
||||
|
||||
claims := claimsFromContext(r.Context())
|
||||
var actorID *int64
|
||||
if claims != nil {
|
||||
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
|
||||
actorID = &actor.ID
|
||||
}
|
||||
}
|
||||
u.writeAudit(r, model.EventTagAdded, actorID, &acct.ID,
|
||||
fmt.Sprintf(`{"account":%q,"tags":%d}`, acct.UUID, len(tags)))
|
||||
|
||||
csrfToken, _ := u.setCSRFCookies(w)
|
||||
u.render(w, "tags_editor", AccountDetailData{
|
||||
PageData: PageData{CSRFToken: csrfToken},
|
||||
Account: acct,
|
||||
Tags: tags,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
func (u *UIServer) loadPolicyRule(w http.ResponseWriter, r *http.Request) (*model.PolicyRuleRecord, bool) {
|
||||
idStr := r.PathValue("id")
|
||||
if idStr == "" {
|
||||
u.renderError(w, r, http.StatusBadRequest, "rule id is required")
|
||||
return nil, false
|
||||
}
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusBadRequest, "rule id must be an integer")
|
||||
return nil, false
|
||||
}
|
||||
rec, err := u.db.GetPolicyRule(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNotFound) {
|
||||
u.renderError(w, r, http.StatusNotFound, "policy rule not found")
|
||||
return nil, false
|
||||
}
|
||||
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||
return nil, false
|
||||
}
|
||||
return rec, true
|
||||
}
|
||||
|
||||
// splitCommas splits a comma-separated string and trims whitespace from each element.
|
||||
func splitCommas(s string) []string {
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// splitLines splits a newline-separated string and trims whitespace from each element.
|
||||
func splitLines(s string) []string {
|
||||
parts := strings.Split(s, "\n")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -171,6 +171,9 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
|
||||
"templates/fragments/error.html",
|
||||
"templates/fragments/audit_rows.html",
|
||||
"templates/fragments/pgcreds_form.html",
|
||||
"templates/fragments/tags_editor.html",
|
||||
"templates/fragments/policy_row.html",
|
||||
"templates/fragments/policy_form.html",
|
||||
}
|
||||
base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
|
||||
if err != nil {
|
||||
@@ -186,6 +189,7 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
|
||||
"account_detail": "templates/account_detail.html",
|
||||
"audit": "templates/audit.html",
|
||||
"audit_detail": "templates/audit_detail.html",
|
||||
"policies": "templates/policies.html",
|
||||
}
|
||||
tmpls := make(map[string]*template.Template, len(pageFiles))
|
||||
for name, file := range pageFiles {
|
||||
@@ -263,6 +267,11 @@ func (u *UIServer) Register(mux *http.ServeMux) {
|
||||
uiMux.Handle("GET /audit", adminGet(u.handleAuditPage))
|
||||
uiMux.Handle("GET /audit/rows", adminGet(u.handleAuditRows))
|
||||
uiMux.Handle("GET /audit/{id}", adminGet(u.handleAuditDetail))
|
||||
uiMux.Handle("GET /policies", adminGet(u.handlePoliciesPage))
|
||||
uiMux.Handle("POST /policies", admin(u.handleCreatePolicyRule))
|
||||
uiMux.Handle("PATCH /policies/{id}/enabled", admin(u.handleTogglePolicyRule))
|
||||
uiMux.Handle("DELETE /policies/{id}", admin(u.handleDeletePolicyRule))
|
||||
uiMux.Handle("PUT /accounts/{id}/tags", admin(u.handleSetAccountTags))
|
||||
|
||||
// Mount the wrapped UI mux on the parent mux. The "/" pattern acts as a
|
||||
// catch-all for all UI paths; the more-specific /v1/ API patterns registered
|
||||
@@ -509,6 +518,7 @@ type AccountDetailData struct {
|
||||
PageData
|
||||
Roles []string
|
||||
AllRoles []string
|
||||
Tags []string
|
||||
Tokens []*model.TokenRecord
|
||||
}
|
||||
|
||||
@@ -528,3 +538,21 @@ type AuditDetailData struct {
|
||||
Event *db.AuditEventView
|
||||
PageData
|
||||
}
|
||||
|
||||
// PolicyRuleView is a single policy rule prepared for template rendering.
|
||||
type PolicyRuleView struct {
|
||||
Description string
|
||||
RuleJSON string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
ID int64
|
||||
Priority int
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// PoliciesData is the view model for the policies list page.
|
||||
type PoliciesData struct {
|
||||
PageData
|
||||
Rules []*PolicyRuleView
|
||||
AllActions []string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user