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:
366
ARCHITECTURE.md
366
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=<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 |
|
||||
|
||||
Reference in New Issue
Block a user