Add PG creds + policy/tags UI; fix lint and build

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

View File

@@ -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=<all> — 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": "<deploy-agent 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": "<deploy-agent 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 |