diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 04ebc9c..32e2051 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -137,6 +137,19 @@ Reserved roles: Role assignment requires admin privileges. +### Tags + +Accounts (both human and system) may carry zero or more string tags stored in +the `account_tags` table. Tags are used by the policy engine to match resource +access rules against machine or service identity. + +Tag naming convention (not enforced by the schema, but recommended): +- `env:production`, `env:staging` — environment tier +- `svc:payments-api` — named service association +- `machine:db-west-01` — specific host label + +Tag management requires admin privileges. + ### Account Lifecycle ``` @@ -313,6 +326,23 @@ All endpoints use JSON request/response bodies. All responses include a | GET | `/v1/accounts/{id}/pgcreds` | admin JWT | Retrieve Postgres credentials | | PUT | `/v1/accounts/{id}/pgcreds` | admin JWT | Set/update Postgres credentials | +### Tag Endpoints (admin only) + +| Method | Path | Auth required | Description | +|---|---|---|---| +| GET | `/v1/accounts/{id}/tags` | admin JWT | List tags for account | +| PUT | `/v1/accounts/{id}/tags` | admin JWT | Replace tag set for account | + +### Policy Endpoints (admin only) + +| Method | Path | Auth required | Description | +|---|---|---|---| +| GET | `/v1/policy/rules` | admin JWT | List all policy rules | +| POST | `/v1/policy/rules` | admin JWT | Create a new policy rule | +| GET | `/v1/policy/rules/{id}` | admin JWT | Get a single policy rule | +| PATCH | `/v1/policy/rules/{id}` | admin JWT | Update rule (priority, enabled, description) | +| DELETE | `/v1/policy/rules/{id}` | admin JWT | Delete a policy rule | + ### Audit Endpoints (admin only) | Method | Path | Auth required | Description | @@ -443,6 +473,31 @@ CREATE TABLE audit_log ( CREATE INDEX idx_audit_time ON audit_log (event_time); CREATE INDEX idx_audit_actor ON audit_log (actor_id); CREATE INDEX idx_audit_event ON audit_log (event_type); + +-- Machine/service tags on accounts (many-to-many). +-- Used by the policy engine for resource gating (e.g. env:production, svc:payments-api). +CREATE TABLE 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 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. +CREATE TABLE policy_rules ( + id INTEGER PRIMARY KEY, + priority INTEGER NOT NULL DEFAULT 100, -- lower value = evaluated first + 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')) +); ``` ### Schema Notes @@ -527,8 +582,9 @@ mcias/ │ ├── crypto/ # key management, AES-GCM helpers, master key derivation │ ├── db/ # SQLite access layer (schema, migrations, queries) │ ├── grpcserver/ # gRPC handler implementations (Phase 7) -│ ├── middleware/ # HTTP middleware (auth extraction, logging, rate-limit) -│ ├── model/ # shared data types (Account, Token, Role, etc.) +│ ├── middleware/ # HTTP middleware (auth extraction, logging, rate-limit, policy) +│ ├── model/ # shared data types (Account, Token, Role, PolicyRule, etc.) +│ ├── policy/ # in-process authorization policy engine (§20) │ ├── server/ # HTTP handlers, router setup │ ├── token/ # JWT issuance, validation, revocation │ └── ui/ # web UI context, CSRF, session, template handlers @@ -581,6 +637,12 @@ The `cmd/` packages are thin wrappers that wire dependencies and call into | `totp_removed` | TOTP removed from account | | `pgcred_accessed` | Postgres credentials retrieved | | `pgcred_updated` | Postgres credentials stored/updated | +| `tag_added` | Tag added to account | +| `tag_removed` | Tag removed from account | +| `policy_rule_created` | Policy rule created | +| `policy_rule_updated` | Policy rule updated (priority, enabled, description) | +| `policy_rule_deleted` | Policy rule deleted | +| `policy_deny` | Policy engine denied a request (logged for every explicit deny) | --- @@ -1123,3 +1185,303 @@ Each other language library includes its own inline mock: `tests/mock-server.lisp`; started on a random port per test via `start-mock-server` / `stop-mock-server` - **Python**: `respx` mock transport for `httpx`; `@respx.mock` decorator + +--- + +## 20. Authorization Policy Engine + +### Motivation + +The initial authorization model is binary: the `admin` role grants full access; +all other authenticated principals have access only to self-service operations +(logout, token renewal, TOTP enrollment). As MCIAS manages credentials for +multiple personal applications running on multiple machines, a richer model is +needed: + +- A human account should be able to access credentials for one specific service + without being a full admin. +- A system account (`deploy-agent`) should only operate on hosts tagged + `env:staging`, not `env:production`. +- A "secrets reader" role should read pgcreds for any service but change nothing. + +The policy engine adds fine-grained, attribute-based access control (ABAC) as +an in-process Go package (`internal/policy`) with no external dependencies. + +### Design Principles + +- **Deny-wins**: any explicit `deny` rule overrides all `allow` rules. +- **Default-deny**: if no rule matches, the request is denied. +- **Compiled-in defaults**: a set of built-in rules encoded in Go reproduces + the previous binary behavior exactly. They cannot be disabled via the API. +- **Pure evaluation**: `Evaluate()` is a stateless function; it takes a + `PolicyInput` and a slice of `Rule` values and returns an effect. The caller + assembles the input from JWT claims and DB lookups; the engine never touches + the database. +- **Auditable**: every explicit `deny` produces a `policy_deny` audit event + recording which rule matched. Every `allow` on a sensitive resource (pgcreds, + token issuance) is also logged. + +### Core Types + +```go +// package internal/policy + +type Action string +type ResourceType string +type Effect string + +const ( + // Actions + 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 + 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" + + // Resource types + ResourceAccount ResourceType = "account" + ResourceToken ResourceType = "token" + ResourcePGCreds ResourceType = "pgcreds" + ResourceAuditLog ResourceType = "audit_log" + ResourceTOTP ResourceType = "totp" + ResourcePolicy ResourceType = "policy" + + // Effects + Allow Effect = "allow" + Deny Effect = "deny" +) + +// PolicyInput is assembled by the middleware from JWT claims and request context. +// The engine never accesses the database. +type PolicyInput struct { + Subject string // account UUID from JWT "sub" + AccountType string // "human" or "system" + Roles []string // role strings from JWT "roles" claim + + Action Action + Resource Resource +} + +// Resource describes what the principal is trying to act on. +type Resource struct { + Type ResourceType + OwnerUUID string // UUID of the account that owns this resource + // (e.g. the system account whose pgcreds are requested) + ServiceName string // username of the system account (for service-name gating) + Tags []string // tags on the target account, loaded from account_tags +} + +// Rule is a single policy statement. All populated fields are ANDed. +// A zero/empty field is a wildcard (matches anything). +type Rule struct { + ID int64 // database primary key; 0 for built-in rules + Description string + + // Principal match conditions + Roles []string // principal must hold at least one of these roles + AccountTypes []string // "human", "system", or both + SubjectUUID string // exact principal UUID (for single-account rules) + + // Action match condition + Actions []Action // action must be one of these + + // Resource match conditions + ResourceType ResourceType + OwnerMatchesSubject bool // true: resource.OwnerUUID must equal input.Subject + ServiceNames []string // resource.ServiceName must be in this list + RequiredTags []string // resource must carry ALL of these tags + + Effect Effect + Priority int // lower value = evaluated first; built-in defaults use 0 +} +``` + +### Evaluation Algorithm + +``` +func Evaluate(input PolicyInput, rules []Rule) (Effect, *Rule): + sort rules by Priority ascending (stable) + collect all rules that match input + + for each matched rule (in priority order): + if rule.Effect == Deny: + return Deny, &rule // deny-wins: stop immediately + + for each matched rule (in priority order): + if rule.Effect == Allow: + return Allow, &rule + + return Deny, nil // default-deny +``` + +A rule matches `input` when every populated field satisfies its condition: + +| Field | Match condition | +|---|---| +| `Roles` | `input.Roles` contains at least one element of `rule.Roles` | +| `AccountTypes` | `input.AccountType` is in `rule.AccountTypes` | +| `SubjectUUID` | `input.Subject == rule.SubjectUUID` | +| `Actions` | `input.Action` is in `rule.Actions` | +| `ResourceType` | `input.Resource.Type == rule.ResourceType` | +| `OwnerMatchesSubject` | (if true) `input.Resource.OwnerUUID == input.Subject` | +| `ServiceNames` | `input.Resource.ServiceName` is in `rule.ServiceNames` | +| `RequiredTags` | `input.Resource.Tags` contains ALL elements of `rule.RequiredTags` | + +### Built-in Default Rules + +These rules are compiled into the binary (`internal/policy/defaults.go`). They +cannot be deleted via the API and are always evaluated before DB-backed rules +at the same priority level. + +``` +Priority 0, Allow: roles=[admin], actions= — admin wildcard +Priority 0, Allow: actions=[tokens:renew, auth:logout] — self-service logout/renew +Priority 0, Allow: actions=[totp:enroll] — self-service TOTP enrollment +Priority 0, Allow: accountTypes=[system], actions=[pgcreds:read], + resourceType=pgcreds, ownerMatchesSubject=true + — system account reads own creds +Priority 0, Allow: accountTypes=[system], actions=[tokens:issue, tokens:renew], + resourceType=token, ownerMatchesSubject=true + — system account issues own token +Priority 0, Allow: actions=[tokens:validate, auth:login] — public endpoints (no auth needed) +``` + +These defaults reproduce the previous binary `admin`/not-admin behavior exactly. +Adding custom rules extends the policy without replacing the defaults. + +### Machine/Service Gating + +Tags and service names enable access decisions that depend on which machine or +service the resource belongs to, not just who the principal is. + +**Scenario A — Named service delegation:** + +Alice needs to read Postgres credentials for the `payments-api` system account +but not for any other service. The operator grants Alice the role `svc:payments-api` +and creates one rule: + +```json +{ + "roles": ["svc:payments-api"], + "actions": ["pgcreds:read"], + "resource_type": "pgcreds", + "service_names": ["payments-api"], + "effect": "allow", + "priority": 50, + "description": "Alice may read payments-api pgcreds" +} +``` + +When Alice calls `GET /v1/accounts/{payments-api-uuid}/pgcreds`, the middleware +sets `resource.ServiceName = "payments-api"`. The rule matches; access is +granted. The same call against `user-service` sets a different `ServiceName` +and no rule matches — default-deny applies. + +**Scenario B — Machine-tag gating:** + +The `deploy-agent` system account should only read credentials for accounts +tagged `env:staging`. The operator tags staging accounts with `env:staging` and +creates: + +```json +{ + "subject_uuid": "", + "actions": ["pgcreds:read"], + "resource_type": "pgcreds", + "required_tags": ["env:staging"], + "effect": "allow", + "priority": 50, + "description": "deploy-agent may read staging pgcreds" +} +``` + +For belt-and-suspenders, an explicit deny for production tags: + +```json +{ + "subject_uuid": "", + "resource_type": "pgcreds", + "required_tags": ["env:production"], + "effect": "deny", + "priority": 10, + "description": "deploy-agent denied production pgcreds (deny-wins)" +} +``` + +**Scenario C — Blanket "secrets reader" role:** + +```json +{ + "roles": ["secrets-reader"], + "actions": ["pgcreds:read"], + "resource_type": "pgcreds", + "effect": "allow", + "priority": 50, + "description": "secrets-reader role may read any pgcreds" +} +``` + +No `ServiceNames` or `RequiredTags` field means this matches any service account. + +### Middleware Integration + +`internal/middleware.RequirePolicy(engine, action, resourceType)` is a drop-in +replacement for `RequireRole("admin")`. It: + +1. Extracts `*token.Claims` from context (JWT already validated by `RequireAuth`). +2. Reads the resource UUID from the request path parameter. +3. Queries the database for the target account's UUID, username, and tags. +4. Assembles `PolicyInput`. +5. Calls `engine.Evaluate(input)`. +6. On `Deny`: writes a `policy_deny` audit event and returns HTTP 403. +7. On `Allow`: proceeds to the handler (and optionally writes an allow audit + event for sensitive resources). + +The `Engine` struct wraps the DB-backed rule loader. It caches the current rule +set in memory and reloads on `policy_rule_*` admin events (or on `SIGHUP`). +Built-in default rules are always merged in at priority 0. + +### Migration Path + +The policy engine is introduced without changing existing behavior: + +1. Add `account_tags` and `policy_rules` tables (schema migration). +2. Implement `internal/policy` package with built-in defaults only. +3. Wire `RequirePolicy` in middleware alongside `RequireRole("admin")` — both + must pass. The built-in defaults guarantee the outcome is identical to the + previous binary check. +4. Expose REST endpoints (`/v1/policy/rules`, `/v1/accounts/{id}/tags`) and + corresponding CLI commands and UI pages — operators can now create rules. +5. After validating custom rules in operation, `RequireRole("admin")` can be + removed from endpoints where `RequirePolicy` provides full coverage. + +Step 3 is the correctness gate: zero behavioral change before custom rules are +introduced. + +### Audit Events + +| Event | Trigger | +|---|---| +| `policy_deny` | Policy engine denied a request; details include `{action, resource_type, service_name, required_tags, matched_rule_id}` — never credential material | +| `policy_rule_created` | New rule created | +| `policy_rule_updated` | Rule priority, enabled flag, or description changed | +| `policy_rule_deleted` | Rule deleted | +| `tag_added` | Tag added to an account | +| `tag_removed` | Tag removed from an account | diff --git a/PROGRESS.md b/PROGRESS.md index 47c2b16..e9de45d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,9 +2,73 @@ Source of truth for current development state. --- -All phases complete. 137 Go server tests + 25 Go client tests + 23 Rust client -tests + 37 Lisp client tests + 32 Python client tests pass. Zero race -conditions (go test -race ./...). +All phases complete. Tests: all packages pass `go test ./...`; `golangci-lint run ./...` clean. + +### 2026-03-11 — Postgres Credentials UI + Policy/Tags UI completion + +**internal/ui/** +- `handlers_accounts.go`: added `handleSetPGCreds` — validates form fields, + encrypts password via `crypto.SealAESGCM` with fresh nonce, calls + `db.WritePGCredentials`, writes `EventPGCredUpdated` audit entry, re-reads and + renders `pgcreds_form` fragment; password never echoed in response +- `handlers_accounts.go`: updated `handleAccountDetail` to load PG credentials + for system accounts (non-fatal on `ErrNotFound`) and account tags for all + accounts +- `handlers_policy.go`: fixed `handleSetAccountTags` to render with + `AccountDetailData` (removed `AccountTagsData`); field ordering fixed for + `fieldalignment` linter +- `ui.go`: added `PGCred *model.PGCredential` and `Tags []string` to + `AccountDetailData`; added `pgcreds_form.html` and `tags_editor.html` to + shared template set; registered `PUT /accounts/{id}/pgcreds` and + `PUT /accounts/{id}/tags` routes; removed unused `AccountTagsData` struct; + field alignment fixed on `PolicyRuleView`, `PoliciesData`, `AccountDetailData` +- `ui_test.go`: added 5 new PG credential tests: + `TestSetPGCredsRejectsHumanAccount`, `TestSetPGCredsStoresAndDisplaysMetadata`, + `TestSetPGCredsPasswordNotEchoed`, `TestSetPGCredsRequiresPassword`, + `TestAccountDetailShowsPGCredsSection` + +**web/templates/** +- `fragments/pgcreds_form.html` (new): displays current credential metadata + (host:port, database, username, updated-at — no password); includes HTMX + `hx-put` form for set/replace; system accounts only +- `fragments/tags_editor.html` (new): newline-separated tag textarea with HTMX + `hx-put` for atomic replacement; uses `.Account.UUID` for URL +- `fragments/policy_form.html`: rewritten to use structured fields matching + `handleCreatePolicyRule` parser: `description`, `priority`, `effect` (select), + `roles`/`account_types`/`actions` (multi-select), `resource_type`, `subject_uuid`, + `service_names`, `required_tags`, `owner_matches_subject` (checkbox) +- `policies.html` (new): policies management page with create-form toggle and + rules table (`id="policies-tbody"`) +- `fragments/policy_row.html` (new): HTMX table row with enable/disable toggle + (`hx-patch`) and delete button (`hx-delete`) +- `account_detail.html`: added Tags card (all accounts) and Postgres Credentials + card (system accounts only) +- `base.html`: added Policies nav link + +**internal/server/server.go** +- Removed ~220 lines of duplicate tag and policy handler code that had been + inadvertently added; all real implementations live in `handlers_policy.go` + +**internal/policy/engine_wrapper.go** +- Fixed corrupted source file (invisible character preventing `fmt` usage from + being recognized); rewrote to use `errors.New` for the denial error + +**internal/db/policy_test.go** +- Fixed `CreateAccount` call using string literal `"human"` → `model.AccountTypeHuman` + +**cmd/mciasctl/main.go** +- Added `//nolint:gosec` to three `int(os.Stdin.Fd())` conversions (safe: + uintptr == int on all target platforms; `term.ReadPassword` requires `int`) + +**Linter fixes (all packages)** +- gofmt/goimports applied to `internal/db/policy_test.go`, + `internal/policy/defaults.go`, `internal/policy/engine_test.go`, `internal/ui/ui.go` +- fieldalignment fixed on `model.PolicyRuleRecord`, `policy.Engine`, + `policy.Rule`, `policy.RuleBody`, `ui.PolicyRuleView` + +All tests pass (`go test ./...`); `golangci-lint run ./...` reports 0 issues. + +--- - [x] Phase 0: Repository bootstrap (go.mod, .gitignore, docs) - [x] Phase 1: Foundational packages (model, config, crypto, db) - [x] Phase 2: Auth core (auth, token, middleware) @@ -15,7 +79,96 @@ conditions (go test -race ./...). - [x] Phase 7: gRPC interface (alternate transport; dual-stack with REST) - [x] Phase 8: Operational artifacts (Makefile, Dockerfile, systemd, man pages, install script) - [x] Phase 9: Client libraries (Go, Rust, Common Lisp, Python) +- [x] Phase 10: Policy engine — ABAC with machine/service gating --- +### 2026-03-11 — Phase 10: Policy engine (ABAC + machine/service gating) + +**internal/policy/** (new package) +- `policy.go` — types: `Action`, `ResourceType`, `Effect`, `Resource`, + `PolicyInput`, `Rule`, `RuleBody`; 22 Action constants covering all API + operations +- `engine.go` — `Evaluate(input, operatorRules) (Effect, *Rule)`: pure function; + merges operator rules with default rules, sorts by priority, deny-wins, + then first allow, then default-deny +- `defaults.go` — 6 compiled-in rules (IDs -1 to -6, Priority 0): admin + wildcard, self-service logout/renew, self-service TOTP, system account own + pgcreds, system account own service token, public login/validate endpoints +- `engine_wrapper.go` — `Engine` struct with `sync.RWMutex`; `SetRules()` + decodes DB records; `PolicyRecord` type avoids import cycle +- `engine_test.go` — 11 tests: DefaultDeny, AdminWildcard, SelfService*, + SystemOwn*, DenyWins, ServiceNameGating, MachineTagGating, + OwnerMatchesSubject, PriorityOrder, MultipleRequiredTags, AccountTypeGating + +**internal/db/** +- `migrate.go`: migration id=4 — `account_tags` (account_id+tag PK, FK cascade) + and `policy_rules` (id, priority, description, rule_json, enabled, + created_by, timestamps) tables +- `tags.go` (new): `GetAccountTags`, `AddAccountTag`, `RemoveAccountTag`, + `SetAccountTags` (atomic DELETE+INSERT transaction); sorted alphabetically +- `policy.go` (new): `CreatePolicyRule`, `GetPolicyRule`, `ListPolicyRules`, + `UpdatePolicyRule`, `SetPolicyRuleEnabled`, `DeletePolicyRule` +- `tags_test.go`, `policy_test.go` (new): comprehensive DB-layer tests + +**internal/model/** +- `PolicyRuleRecord` struct added +- New audit event constants: `EventTagAdded`, `EventTagRemoved`, + `EventPolicyRuleCreated`, `EventPolicyRuleUpdated`, `EventPolicyRuleDeleted`, + `EventPolicyDeny` + +**internal/middleware/** +- `RequirePolicy` middleware: assembles `PolicyInput` from JWT claims + + `AccountTypeLookup` closure (DB-backed, avoids JWT schema change) + + `ResourceBuilder` closure; calls `engine.Evaluate`; logs deny via + `PolicyDenyLogger` + +**internal/server/** +- New REST endpoints (all require admin): + - `GET/PUT /v1/accounts/{id}/tags` + - `GET/POST /v1/policy/rules` + - `GET/PATCH/DELETE /v1/policy/rules/{id}` +- `handlers_policy.go`: `handleGetTags`, `handleSetTags`, `handleListPolicyRules`, + `handleCreatePolicyRule`, `handleGetPolicyRule`, `handleUpdatePolicyRule`, + `handleDeletePolicyRule`, `policyRuleToResponse`, `loadPolicyRule` + +**internal/ui/** +- `handlers_policy.go` (new): `handlePoliciesPage`, `handleCreatePolicyRule`, + `handleTogglePolicyRule`, `handleDeletePolicyRule`, `handleSetAccountTags` +- `ui.go`: registered 5 policy UI routes; added `PolicyRuleView`, `PoliciesData`, + `AccountTagsData` view types; added new fragment templates to shared set + +**web/templates/** +- `policies.html` (new): policies management page +- `fragments/policy_row.html` (new): HTMX table row with enable/disable toggle + and delete button +- `fragments/policy_form.html` (new): create form with JSON textarea and action + reference chips +- `fragments/tags_editor.html` (new): newline-separated tag editor with HTMX + PUT for atomic replacement +- `account_detail.html`: added Tags card section using tags_editor fragment +- `base.html`: added Policies nav link + +**cmd/mciasctl/** +- `policy` subcommands: `list`, `create -description STR -json FILE [-priority N]`, + `get -id ID`, `update -id ID [-priority N] [-enabled true|false]`, + `delete -id ID` +- `tag` subcommands: `list -id UUID`, `set -id UUID -tags tag1,tag2,...` + +**openapi.yaml** +- New schemas: `TagsResponse`, `RuleBody`, `PolicyRule` +- New paths: `GET/PUT /v1/accounts/{id}/tags`, + `GET/POST /v1/policy/rules`, `GET/PATCH/DELETE /v1/policy/rules/{id}` +- New tag: `Admin — Policy` + +**Design highlights:** +- Deny-wins + default-deny: explicit Deny beats any Allow; no match = Deny +- AccountType resolved via DB lookup (not JWT) to avoid breaking 29 IssueToken + call sites +- `RequirePolicy` wired alongside `RequireRole("admin")` for belt-and-suspenders + during migration; defaults reproduce current binary behavior exactly +- `policy.PolicyRecord` type avoids circular import between policy/db/model + +All tests pass; `go test ./...` clean; `golangci-lint run ./...` clean. + ### 2026-03-11 — Fix test failures and lockout logic - `internal/db/accounts.go` (IsLockedOut): corrected window-expiry check from diff --git a/cmd/mciasctl/main.go b/cmd/mciasctl/main.go index 3511023..772a6d0 100644 --- a/cmd/mciasctl/main.go +++ b/cmd/mciasctl/main.go @@ -1,7 +1,8 @@ // Command mciasctl is the MCIAS admin CLI. // // It connects to a running mciassrv instance and provides subcommands for -// managing accounts, roles, tokens, and Postgres credentials. +// managing accounts, roles, tokens, Postgres credentials, policy rules, and +// account tags. // // Usage: // @@ -31,6 +32,15 @@ // // pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS] // pgcreds get -id UUID +// +// policy list +// policy create -description STR -json FILE [-priority N] +// policy get -id ID +// policy update -id ID [-priority N] [-enabled true|false] +// policy delete -id ID +// +// tag list -id UUID +// tag set -id UUID -tags tag1,tag2,... package main import ( @@ -93,6 +103,10 @@ func main() { ctl.runToken(subArgs) case "pgcreds": ctl.runPGCreds(subArgs) + case "policy": + ctl.runPolicy(subArgs) + case "tag": + ctl.runTag(subArgs) default: fatalf("unknown command %q; run with no args to see usage", command) } @@ -143,8 +157,8 @@ func (c *controller) authLogin(args []string) { passwd := *password if passwd == "" { fmt.Fprint(os.Stderr, "Password: ") - raw, err := term.ReadPassword(int(os.Stdin.Fd())) - fmt.Fprintln(os.Stderr) // newline after hidden input + raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms + fmt.Fprintln(os.Stderr) // newline after hidden input if err != nil { fatalf("read password: %v", err) } @@ -223,7 +237,7 @@ func (c *controller) accountCreate(args []string) { passwd := *password if passwd == "" && *accountType == "human" { fmt.Fprint(os.Stderr, "Password: ") - raw, err := term.ReadPassword(int(os.Stdin.Fd())) + raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms fmt.Fprintln(os.Stderr) if err != nil { fatalf("read password: %v", err) @@ -442,7 +456,7 @@ func (c *controller) pgCredsSet(args []string) { passwd := *password if passwd == "" { fmt.Fprint(os.Stderr, "Postgres password: ") - raw, err := term.ReadPassword(int(os.Stdin.Fd())) + raw, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // uintptr==int on all target platforms fmt.Fprintln(os.Stderr) if err != nil { fatalf("read password: %v", err) @@ -464,6 +478,189 @@ func (c *controller) pgCredsSet(args []string) { fmt.Println("credentials stored") } +// ---- policy subcommands ---- + +func (c *controller) runPolicy(args []string) { + if len(args) == 0 { + fatalf("policy requires a subcommand: list, create, get, update, delete") + } + switch args[0] { + case "list": + c.policyList() + case "create": + c.policyCreate(args[1:]) + case "get": + c.policyGet(args[1:]) + case "update": + c.policyUpdate(args[1:]) + case "delete": + c.policyDelete(args[1:]) + default: + fatalf("unknown policy subcommand %q", args[0]) + } +} + +func (c *controller) policyList() { + var result json.RawMessage + c.doRequest("GET", "/v1/policy/rules", nil, &result) + printJSON(result) +} + +func (c *controller) policyCreate(args []string) { + fs := flag.NewFlagSet("policy create", flag.ExitOnError) + description := fs.String("description", "", "rule description (required)") + jsonFile := fs.String("json", "", "path to JSON file containing the rule body (required)") + priority := fs.Int("priority", 100, "rule priority (lower = evaluated first)") + _ = fs.Parse(args) + + if *description == "" { + fatalf("policy create: -description is required") + } + if *jsonFile == "" { + fatalf("policy create: -json is required (path to rule body JSON file)") + } + + // G304: path comes from a CLI flag supplied by the operator. + ruleBytes, err := os.ReadFile(*jsonFile) //nolint:gosec + if err != nil { + fatalf("policy create: read %s: %v", *jsonFile, err) + } + + // Validate that the file contains valid JSON before sending. + var ruleBody json.RawMessage + if err := json.Unmarshal(ruleBytes, &ruleBody); err != nil { + fatalf("policy create: invalid JSON in %s: %v", *jsonFile, err) + } + + body := map[string]interface{}{ + "description": *description, + "priority": *priority, + "rule": ruleBody, + } + + var result json.RawMessage + c.doRequest("POST", "/v1/policy/rules", body, &result) + printJSON(result) +} + +func (c *controller) policyGet(args []string) { + fs := flag.NewFlagSet("policy get", flag.ExitOnError) + id := fs.String("id", "", "rule ID (required)") + _ = fs.Parse(args) + + if *id == "" { + fatalf("policy get: -id is required") + } + + var result json.RawMessage + c.doRequest("GET", "/v1/policy/rules/"+*id, nil, &result) + printJSON(result) +} + +func (c *controller) policyUpdate(args []string) { + fs := flag.NewFlagSet("policy update", flag.ExitOnError) + id := fs.String("id", "", "rule ID (required)") + priority := fs.Int("priority", -1, "new priority (-1 = no change)") + enabled := fs.String("enabled", "", "true or false") + _ = fs.Parse(args) + + if *id == "" { + fatalf("policy update: -id is required") + } + + body := map[string]interface{}{} + if *priority >= 0 { + body["priority"] = *priority + } + if *enabled != "" { + switch *enabled { + case "true": + b := true + body["enabled"] = b + case "false": + b := false + body["enabled"] = b + default: + fatalf("policy update: -enabled must be true or false") + } + } + if len(body) == 0 { + fatalf("policy update: at least one of -priority or -enabled is required") + } + + var result json.RawMessage + c.doRequest("PATCH", "/v1/policy/rules/"+*id, body, &result) + printJSON(result) +} + +func (c *controller) policyDelete(args []string) { + fs := flag.NewFlagSet("policy delete", flag.ExitOnError) + id := fs.String("id", "", "rule ID (required)") + _ = fs.Parse(args) + + if *id == "" { + fatalf("policy delete: -id is required") + } + + c.doRequest("DELETE", "/v1/policy/rules/"+*id, nil, nil) + fmt.Println("policy rule deleted") +} + +// ---- tag subcommands ---- + +func (c *controller) runTag(args []string) { + if len(args) == 0 { + fatalf("tag requires a subcommand: list, set") + } + switch args[0] { + case "list": + c.tagList(args[1:]) + case "set": + c.tagSet(args[1:]) + default: + fatalf("unknown tag subcommand %q", args[0]) + } +} + +func (c *controller) tagList(args []string) { + fs := flag.NewFlagSet("tag list", flag.ExitOnError) + id := fs.String("id", "", "account UUID (required)") + _ = fs.Parse(args) + + if *id == "" { + fatalf("tag list: -id is required") + } + + var result json.RawMessage + c.doRequest("GET", "/v1/accounts/"+*id+"/tags", nil, &result) + printJSON(result) +} + +func (c *controller) tagSet(args []string) { + fs := flag.NewFlagSet("tag set", flag.ExitOnError) + id := fs.String("id", "", "account UUID (required)") + tagsFlag := fs.String("tags", "", "comma-separated list of tags (empty string clears all tags)") + _ = fs.Parse(args) + + if *id == "" { + fatalf("tag set: -id is required") + } + + tags := []string{} + if *tagsFlag != "" { + for _, t := range strings.Split(*tagsFlag, ",") { + t = strings.TrimSpace(t) + if t != "" { + tags = append(tags, t) + } + } + } + + body := map[string][]string{"tags": tags} + c.doRequest("PUT", "/v1/accounts/"+*id+"/tags", body, nil) + fmt.Printf("tags set: %v\n", tags) +} + // ---- HTTP helpers ---- // doRequest performs an authenticated JSON HTTP request. If result is non-nil, @@ -588,5 +785,17 @@ Commands: pgcreds get -id UUID pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS] + + policy list + policy create -description STR -json FILE [-priority N] + FILE must contain a JSON rule body, e.g.: + {"effect":"allow","actions":["pgcreds:read"],"resource_type":"pgcreds","owner_matches_subject":true} + policy get -id ID + policy update -id ID [-priority N] [-enabled true|false] + policy delete -id ID + + tag list -id UUID + tag set -id UUID -tags tag1,tag2,... + Pass empty -tags "" to clear all tags. `) } diff --git a/internal/db/migrate.go b/internal/db/migrate.go index bf642a1..43aa540 100644 --- a/internal/db/migrate.go +++ b/internal/db/migrate.go @@ -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')) +); `, }, } diff --git a/internal/db/policy.go b/internal/db/policy.go new file mode 100644 index 0000000..1b7589e --- /dev/null +++ b/internal/db/policy.go @@ -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 +} diff --git a/internal/db/policy_test.go b/internal/db/policy_test.go new file mode 100644 index 0000000..9e35d33 --- /dev/null +++ b/internal/db/policy_test.go @@ -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) + } +} diff --git a/internal/db/tags.go b/internal/db/tags.go new file mode 100644 index 0000000..29d9d37 --- /dev/null +++ b/internal/db/tags.go @@ -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 +} diff --git a/internal/db/tags_test.go b/internal/db/tags_test.go new file mode 100644 index 0000000..07aa458 --- /dev/null +++ b/internal/db/tags_test.go @@ -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) + } +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index fb59c90..fcf79c6 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -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) + }) + } +} diff --git a/internal/model/model.go b/internal/model/model.go index e1513c7..8959c4c 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -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"` +} diff --git a/internal/policy/defaults.go b/internal/policy/defaults.go new file mode 100644 index 0000000..36c6fee --- /dev/null +++ b/internal/policy/defaults.go @@ -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, + }, +} diff --git a/internal/policy/engine.go b/internal/policy/engine.go new file mode 100644 index 0000000..d314758 --- /dev/null +++ b/internal/policy/engine.go @@ -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 +} diff --git a/internal/policy/engine_test.go b/internal/policy/engine_test.go new file mode 100644 index 0000000..b5936b3 --- /dev/null +++ b/internal/policy/engine_test.go @@ -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") + } +} diff --git a/internal/policy/engine_wrapper.go b/internal/policy/engine_wrapper.go new file mode 100644 index 0000000..bb8d478 --- /dev/null +++ b/internal/policy/engine_wrapper.go @@ -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 +} diff --git a/internal/policy/policy.go b/internal/policy/policy.go new file mode 100644 index 0000000..1336fd9 --- /dev/null +++ b/internal/policy/policy.go @@ -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"` +} diff --git a/internal/server/handlers_policy.go b/internal/server/handlers_policy.go new file mode 100644 index 0000000..bbf1766 --- /dev/null +++ b/internal/server/handlers_policy.go @@ -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) +} diff --git a/internal/server/server.go b/internal/server/server.go index 00269b2..be0266c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/internal/ui/handlers_accounts.go b/internal/ui/handlers_accounts.go index 85903cd..e9a27ac 100644 --- a/internal/ui/handlers_accounts.go +++ b/internal/ui/handlers_accounts.go @@ -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, }) } diff --git a/internal/ui/handlers_policy.go b/internal/ui/handlers_policy.go new file mode 100644 index 0000000..9072986 --- /dev/null +++ b/internal/ui/handlers_policy.go @@ -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 +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index c16c73e..c78b40b 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -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 +} diff --git a/openapi.yaml b/openapi.yaml index 3e10ff5..fda25e7 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -118,6 +118,103 @@ components: description: JSON blob with event-specific metadata. Never contains credentials. example: '{"jti":"f47ac10b-..."}' + TagsResponse: + type: object + required: [tags] + properties: + tags: + type: array + items: + type: string + description: Current tag list for the account. + example: ["env:production", "svc:payments-api"] + + RuleBody: + type: object + required: [effect] + description: | + The match conditions and effect of a policy rule. All fields except + `effect` are optional; an omitted field acts as a wildcard. + properties: + effect: + type: string + enum: [allow, deny] + example: allow + roles: + type: array + items: + type: string + description: Subject must have at least one of these roles. + example: ["svc:payments-api"] + account_types: + type: array + items: + type: string + enum: [human, system] + description: Subject account type must be one of these. + example: ["system"] + subject_uuid: + type: string + format: uuid + description: Match only this specific subject UUID. + example: 550e8400-e29b-41d4-a716-446655440000 + actions: + type: array + items: + type: string + description: | + One of the defined action constants, e.g. `pgcreds:read`, + `accounts:list`. Subject action must be in this list. + example: ["pgcreds:read"] + resource_type: + type: string + description: Resource type the rule applies to. + example: pgcreds + owner_matches_subject: + type: boolean + description: Resource owner UUID must equal the subject UUID. + example: true + service_names: + type: array + items: + type: string + description: Resource service name must be one of these. + example: ["payments-api"] + required_tags: + type: array + items: + type: string + description: Resource must have ALL of these tags. + example: ["env:staging"] + + PolicyRule: + type: object + required: [id, priority, description, rule, enabled, created_at, updated_at] + properties: + id: + type: integer + example: 1 + priority: + type: integer + description: Lower number = evaluated first. + example: 100 + description: + type: string + example: Allow payments-api to read its own pgcreds + rule: + $ref: "#/components/schemas/RuleBody" + enabled: + type: boolean + example: true + created_at: + type: string + format: date-time + example: "2026-03-11T09:00:00Z" + updated_at: + type: string + format: date-time + example: "2026-03-11T09:00:00Z" + PGCreds: type: object required: [host, port, database, username, password] @@ -948,6 +1045,233 @@ paths: "403": $ref: "#/components/responses/Forbidden" + /v1/accounts/{id}/tags: + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + example: 550e8400-e29b-41d4-a716-446655440000 + + get: + summary: Get account tags (admin) + description: | + Return the current tag set for an account. Tags are used by the policy + engine for machine/service gating (e.g. `env:production`, + `svc:payments-api`). + operationId: getAccountTags + tags: [Admin — Policy] + security: + - bearerAuth: [] + responses: + "200": + description: Tag list. + content: + application/json: + schema: + $ref: "#/components/schemas/TagsResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + + put: + summary: Set account tags (admin) + description: | + Replace the account's full tag set atomically. Pass an empty array to + clear all tags. Changes take effect immediately for new policy + evaluations; no token renewal is required. + operationId: setAccountTags + tags: [Admin — Policy] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [tags] + properties: + tags: + type: array + items: + type: string + example: ["env:production", "svc:payments-api"] + responses: + "200": + description: Updated tag list. + content: + application/json: + schema: + $ref: "#/components/schemas/TagsResponse" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + + /v1/policy/rules: + get: + summary: List policy rules (admin) + description: | + Return all operator-defined policy rules ordered by priority (ascending). + Built-in default rules (IDs -1 to -6) are not included. + operationId: listPolicyRules + tags: [Admin — Policy] + security: + - bearerAuth: [] + responses: + "200": + description: Array of policy rules. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/PolicyRule" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + + post: + summary: Create policy rule (admin) + description: | + Create a new operator policy rule. Rules are evaluated in priority order + (lower number = evaluated first, default 100). Deny-wins: if any matching + rule has effect `deny`, access is denied regardless of allow rules. + operationId: createPolicyRule + tags: [Admin — Policy] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [description, rule] + properties: + description: + type: string + example: Allow payments-api to read its own pgcreds + priority: + type: integer + description: Evaluation priority. Lower = first. Default 100. + example: 50 + rule: + $ref: "#/components/schemas/RuleBody" + responses: + "201": + description: Rule created. + content: + application/json: + schema: + $ref: "#/components/schemas/PolicyRule" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + + /v1/policy/rules/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: integer + example: 1 + + get: + summary: Get policy rule (admin) + operationId: getPolicyRule + tags: [Admin — Policy] + security: + - bearerAuth: [] + responses: + "200": + description: Policy rule. + content: + application/json: + schema: + $ref: "#/components/schemas/PolicyRule" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + + patch: + summary: Update policy rule (admin) + description: | + Update one or more fields of an existing policy rule. All fields are + optional; omitted fields are left unchanged. + operationId: updatePolicyRule + tags: [Admin — Policy] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + description: + type: string + example: Updated description + priority: + type: integer + example: 75 + enabled: + type: boolean + example: false + rule: + $ref: "#/components/schemas/RuleBody" + responses: + "200": + description: Updated rule. + content: + application/json: + schema: + $ref: "#/components/schemas/PolicyRule" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + + delete: + summary: Delete policy rule (admin) + description: Permanently delete a policy rule. This action cannot be undone. + operationId: deletePolicyRule + tags: [Admin — Policy] + security: + - bearerAuth: [] + responses: + "204": + description: Rule deleted. + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + tags: - name: Public description: No authentication required. @@ -963,3 +1287,5 @@ tags: description: Requires admin role. - name: Admin — Audit description: Requires admin role. + - name: Admin — Policy + description: Requires admin role. Manage policy rules and account tags. diff --git a/web/templates/account_detail.html b/web/templates/account_detail.html index de851f9..c2e1da4 100644 --- a/web/templates/account_detail.html +++ b/web/templates/account_detail.html @@ -40,4 +40,8 @@ {{template "pgcreds_form" .}} {{end}} +
+

Tags

+
{{template "tags_editor" .}}
+
{{end}} diff --git a/web/templates/base.html b/web/templates/base.html index 244c48b..4610871 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -14,6 +14,7 @@
  • Dashboard
  • Accounts
  • Audit
  • +
  • Policies
  • diff --git a/web/templates/fragments/policy_form.html b/web/templates/fragments/policy_form.html new file mode 100644 index 0000000..a6ffb53 --- /dev/null +++ b/web/templates/fragments/policy_form.html @@ -0,0 +1,77 @@ +{{define "policy_form"}} +
    +
    + + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    + +
    + +
    +{{end}} diff --git a/web/templates/fragments/policy_row.html b/web/templates/fragments/policy_row.html new file mode 100644 index 0000000..fdab018 --- /dev/null +++ b/web/templates/fragments/policy_row.html @@ -0,0 +1,42 @@ +{{define "policy_row"}} + + {{.ID}} + {{.Priority}} + + {{.Description}} +
    + Show rule JSON +
    {{.RuleJSON}}
    +
    + + + {{/* Extract effect from RuleJSON via prettyJSON — displayed separately */}} + + {{if .Enabled}}enabled{{else}}disabled{{end}} + + + + {{if .Enabled}} + + {{else}} + + {{end}} + + {{.UpdatedAt}} + + + + +{{end}} diff --git a/web/templates/fragments/tags_editor.html b/web/templates/fragments/tags_editor.html new file mode 100644 index 0000000..59682a1 --- /dev/null +++ b/web/templates/fragments/tags_editor.html @@ -0,0 +1,21 @@ +{{define "tags_editor"}} +
    + {{if .Tags}} +
    + {{range .Tags}} + {{.}} + {{end}} +
    + {{else}} +

    No tags assigned.

    + {{end}} +
    + + +
    +
    +{{end}} diff --git a/web/templates/policies.html b/web/templates/policies.html new file mode 100644 index 0000000..33dfb5a --- /dev/null +++ b/web/templates/policies.html @@ -0,0 +1,37 @@ +{{define "policies"}}{{template "base" .}}{{end}} +{{define "title"}}Policy Rules — MCIAS{{end}} +{{define "content"}} + + + + +
    + + + + + + + + + + + + + + {{range .Rules}}{{template "policy_row" .}}{{end}} + +
    IDPriorityDescriptionEffectEnabledUpdatedActions
    +
    +{{end}}