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. 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 ### 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 | | GET | `/v1/accounts/{id}/pgcreds` | admin JWT | Retrieve Postgres credentials |
| PUT | `/v1/accounts/{id}/pgcreds` | admin JWT | Set/update 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) ### Audit Endpoints (admin only)
| Method | Path | Auth required | Description | | 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_time ON audit_log (event_time);
CREATE INDEX idx_audit_actor ON audit_log (actor_id); CREATE INDEX idx_audit_actor ON audit_log (actor_id);
CREATE INDEX idx_audit_event ON audit_log (event_type); 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 ### Schema Notes
@@ -527,8 +582,9 @@ mcias/
│ ├── crypto/ # key management, AES-GCM helpers, master key derivation │ ├── crypto/ # key management, AES-GCM helpers, master key derivation
│ ├── db/ # SQLite access layer (schema, migrations, queries) │ ├── db/ # SQLite access layer (schema, migrations, queries)
│ ├── grpcserver/ # gRPC handler implementations (Phase 7) │ ├── grpcserver/ # gRPC handler implementations (Phase 7)
│ ├── middleware/ # HTTP middleware (auth extraction, logging, rate-limit) │ ├── middleware/ # HTTP middleware (auth extraction, logging, rate-limit, policy)
│ ├── model/ # shared data types (Account, Token, Role, etc.) │ ├── model/ # shared data types (Account, Token, Role, PolicyRule, etc.)
│ ├── policy/ # in-process authorization policy engine (§20)
│ ├── server/ # HTTP handlers, router setup │ ├── server/ # HTTP handlers, router setup
│ ├── token/ # JWT issuance, validation, revocation │ ├── token/ # JWT issuance, validation, revocation
│ └── ui/ # web UI context, CSRF, session, template handlers │ └── 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 | | `totp_removed` | TOTP removed from account |
| `pgcred_accessed` | Postgres credentials retrieved | | `pgcred_accessed` | Postgres credentials retrieved |
| `pgcred_updated` | Postgres credentials stored/updated | | `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 `tests/mock-server.lisp`; started on a random port per test via
`start-mock-server` / `stop-mock-server` `start-mock-server` / `stop-mock-server`
- **Python**: `respx` mock transport for `httpx`; `@respx.mock` decorator - **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 |

View File

@@ -2,9 +2,73 @@
Source of truth for current development state. Source of truth for current development state.
--- ---
All phases complete. 137 Go server tests + 25 Go client tests + 23 Rust client All phases complete. Tests: all packages pass `go test ./...`; `golangci-lint run ./...` clean.
tests + 37 Lisp client tests + 32 Python client tests pass. Zero race
conditions (go test -race ./...). ### 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 0: Repository bootstrap (go.mod, .gitignore, docs)
- [x] Phase 1: Foundational packages (model, config, crypto, db) - [x] Phase 1: Foundational packages (model, config, crypto, db)
- [x] Phase 2: Auth core (auth, token, middleware) - [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 7: gRPC interface (alternate transport; dual-stack with REST)
- [x] Phase 8: Operational artifacts (Makefile, Dockerfile, systemd, man pages, install script) - [x] Phase 8: Operational artifacts (Makefile, Dockerfile, systemd, man pages, install script)
- [x] Phase 9: Client libraries (Go, Rust, Common Lisp, Python) - [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 ### 2026-03-11 — Fix test failures and lockout logic
- `internal/db/accounts.go` (IsLockedOut): corrected window-expiry check from - `internal/db/accounts.go` (IsLockedOut): corrected window-expiry check from

View File

@@ -1,7 +1,8 @@
// Command mciasctl is the MCIAS admin CLI. // Command mciasctl is the MCIAS admin CLI.
// //
// It connects to a running mciassrv instance and provides subcommands for // 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: // Usage:
// //
@@ -31,6 +32,15 @@
// //
// pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS] // pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS]
// pgcreds get -id UUID // 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 package main
import ( import (
@@ -93,6 +103,10 @@ func main() {
ctl.runToken(subArgs) ctl.runToken(subArgs)
case "pgcreds": case "pgcreds":
ctl.runPGCreds(subArgs) ctl.runPGCreds(subArgs)
case "policy":
ctl.runPolicy(subArgs)
case "tag":
ctl.runTag(subArgs)
default: default:
fatalf("unknown command %q; run with no args to see usage", command) fatalf("unknown command %q; run with no args to see usage", command)
} }
@@ -143,8 +157,8 @@ func (c *controller) authLogin(args []string) {
passwd := *password passwd := *password
if passwd == "" { if passwd == "" {
fmt.Fprint(os.Stderr, "Password: ") 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) // newline after hidden input fmt.Fprintln(os.Stderr) // newline after hidden input
if err != nil { if err != nil {
fatalf("read password: %v", err) fatalf("read password: %v", err)
} }
@@ -223,7 +237,7 @@ func (c *controller) accountCreate(args []string) {
passwd := *password passwd := *password
if passwd == "" && *accountType == "human" { if passwd == "" && *accountType == "human" {
fmt.Fprint(os.Stderr, "Password: ") 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) fmt.Fprintln(os.Stderr)
if err != nil { if err != nil {
fatalf("read password: %v", err) fatalf("read password: %v", err)
@@ -442,7 +456,7 @@ func (c *controller) pgCredsSet(args []string) {
passwd := *password passwd := *password
if passwd == "" { if passwd == "" {
fmt.Fprint(os.Stderr, "Postgres password: ") 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) fmt.Fprintln(os.Stderr)
if err != nil { if err != nil {
fatalf("read password: %v", err) fatalf("read password: %v", err)
@@ -464,6 +478,189 @@ func (c *controller) pgCredsSet(args []string) {
fmt.Println("credentials stored") 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 ---- // ---- HTTP helpers ----
// doRequest performs an authenticated JSON HTTP request. If result is non-nil, // doRequest performs an authenticated JSON HTTP request. If result is non-nil,
@@ -588,5 +785,17 @@ Commands:
pgcreds get -id UUID pgcreds get -id UUID
pgcreds set -id UUID -host HOST [-port PORT] -db DB -user USER [-password PASS] 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.
`) `)
} }

View File

@@ -131,6 +131,37 @@ CREATE TABLE IF NOT EXISTS failed_logins (
window_start TEXT NOT NULL, window_start TEXT NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 1 attempt_count INTEGER NOT NULL DEFAULT 1
); );
`,
},
{
id: 4,
sql: `
-- Machine/service tags on accounts (many-to-many).
-- Used by the policy engine to gate access by machine or service identity
-- (e.g. env:production, svc:payments-api, machine:db-west-01).
CREATE TABLE IF NOT EXISTS account_tags (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
PRIMARY KEY (account_id, tag)
);
CREATE INDEX IF NOT EXISTS idx_account_tags_account ON account_tags (account_id);
-- Policy rules stored in the database and evaluated in-process.
-- rule_json holds a JSON-encoded policy.RuleBody (all match fields + effect).
-- Built-in default rules are compiled into the binary and are not stored here.
-- Rows with enabled=0 are loaded but skipped during evaluation.
CREATE TABLE IF NOT EXISTS policy_rules (
id INTEGER PRIMARY KEY,
priority INTEGER NOT NULL DEFAULT 100,
description TEXT NOT NULL,
rule_json TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)),
created_by INTEGER REFERENCES accounts(id),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
`, `,
}, },
} }

191
internal/db/policy.go Normal file
View File

@@ -0,0 +1,191 @@
package db
import (
"database/sql"
"errors"
"fmt"
"git.wntrmute.dev/kyle/mcias/internal/model"
)
// CreatePolicyRule inserts a new policy rule record. The returned record
// includes the database-assigned ID and timestamps.
func (db *DB) CreatePolicyRule(description string, priority int, ruleJSON string, createdBy *int64) (*model.PolicyRuleRecord, error) {
n := now()
result, err := db.sql.Exec(`
INSERT INTO policy_rules (priority, description, rule_json, enabled, created_by, created_at, updated_at)
VALUES (?, ?, ?, 1, ?, ?, ?)
`, priority, description, ruleJSON, createdBy, n, n)
if err != nil {
return nil, fmt.Errorf("db: create policy rule: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("db: create policy rule last insert id: %w", err)
}
createdAt, err := parseTime(n)
if err != nil {
return nil, err
}
return &model.PolicyRuleRecord{
ID: id,
Priority: priority,
Description: description,
RuleJSON: ruleJSON,
Enabled: true,
CreatedBy: createdBy,
CreatedAt: createdAt,
UpdatedAt: createdAt,
}, nil
}
// GetPolicyRule retrieves a single policy rule by its database ID.
// Returns ErrNotFound if no such rule exists.
func (db *DB) GetPolicyRule(id int64) (*model.PolicyRuleRecord, error) {
return db.scanPolicyRule(db.sql.QueryRow(`
SELECT id, priority, description, rule_json, enabled, created_by, created_at, updated_at
FROM policy_rules WHERE id = ?
`, id))
}
// ListPolicyRules returns all policy rules ordered by priority then ID.
// When enabledOnly is true, only rules with enabled=1 are returned.
func (db *DB) ListPolicyRules(enabledOnly bool) ([]*model.PolicyRuleRecord, error) {
query := `
SELECT id, priority, description, rule_json, enabled, created_by, created_at, updated_at
FROM policy_rules`
if enabledOnly {
query += ` WHERE enabled = 1`
}
query += ` ORDER BY priority ASC, id ASC`
rows, err := db.sql.Query(query)
if err != nil {
return nil, fmt.Errorf("db: list policy rules: %w", err)
}
defer func() { _ = rows.Close() }()
var rules []*model.PolicyRuleRecord
for rows.Next() {
r, err := db.scanPolicyRuleRow(rows)
if err != nil {
return nil, err
}
rules = append(rules, r)
}
return rules, rows.Err()
}
// UpdatePolicyRule updates the mutable fields of a policy rule.
// Only the fields in the update map are changed; other fields are untouched.
func (db *DB) UpdatePolicyRule(id int64, description *string, priority *int, ruleJSON *string) error {
n := now()
// Build SET clause dynamically to only update provided fields.
// Security: field names are not user-supplied strings — they are selected
// from a fixed set of known column names only.
setClauses := "updated_at = ?"
args := []interface{}{n}
if description != nil {
setClauses += ", description = ?"
args = append(args, *description)
}
if priority != nil {
setClauses += ", priority = ?"
args = append(args, *priority)
}
if ruleJSON != nil {
setClauses += ", rule_json = ?"
args = append(args, *ruleJSON)
}
args = append(args, id)
_, err := db.sql.Exec(`UPDATE policy_rules SET `+setClauses+` WHERE id = ?`, args...)
if err != nil {
return fmt.Errorf("db: update policy rule %d: %w", id, err)
}
return nil
}
// SetPolicyRuleEnabled enables or disables a policy rule by ID.
func (db *DB) SetPolicyRuleEnabled(id int64, enabled bool) error {
enabledInt := 0
if enabled {
enabledInt = 1
}
_, err := db.sql.Exec(`
UPDATE policy_rules SET enabled = ?, updated_at = ? WHERE id = ?
`, enabledInt, now(), id)
if err != nil {
return fmt.Errorf("db: set policy rule %d enabled=%v: %w", id, enabled, err)
}
return nil
}
// DeletePolicyRule removes a policy rule by ID.
func (db *DB) DeletePolicyRule(id int64) error {
_, err := db.sql.Exec(`DELETE FROM policy_rules WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("db: delete policy rule %d: %w", id, err)
}
return nil
}
// scanPolicyRule scans a single policy rule from a *sql.Row.
func (db *DB) scanPolicyRule(row *sql.Row) (*model.PolicyRuleRecord, error) {
var r model.PolicyRuleRecord
var enabledInt int
var createdAtStr, updatedAtStr string
var createdBy *int64
err := row.Scan(
&r.ID, &r.Priority, &r.Description, &r.RuleJSON,
&enabledInt, &createdBy, &createdAtStr, &updatedAtStr,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("db: scan policy rule: %w", err)
}
return finishPolicyRuleScan(&r, enabledInt, createdBy, createdAtStr, updatedAtStr)
}
// scanPolicyRuleRow scans a single policy rule from *sql.Rows.
func (db *DB) scanPolicyRuleRow(rows *sql.Rows) (*model.PolicyRuleRecord, error) {
var r model.PolicyRuleRecord
var enabledInt int
var createdAtStr, updatedAtStr string
var createdBy *int64
err := rows.Scan(
&r.ID, &r.Priority, &r.Description, &r.RuleJSON,
&enabledInt, &createdBy, &createdAtStr, &updatedAtStr,
)
if err != nil {
return nil, fmt.Errorf("db: scan policy rule row: %w", err)
}
return finishPolicyRuleScan(&r, enabledInt, createdBy, createdAtStr, updatedAtStr)
}
func finishPolicyRuleScan(r *model.PolicyRuleRecord, enabledInt int, createdBy *int64, createdAtStr, updatedAtStr string) (*model.PolicyRuleRecord, error) {
r.Enabled = enabledInt == 1
r.CreatedBy = createdBy
var err error
r.CreatedAt, err = parseTime(createdAtStr)
if err != nil {
return nil, err
}
r.UpdatedAt, err = parseTime(updatedAtStr)
if err != nil {
return nil, err
}
return r, nil
}

212
internal/db/policy_test.go Normal file
View File

@@ -0,0 +1,212 @@
package db
import (
"errors"
"testing"
"git.wntrmute.dev/kyle/mcias/internal/model"
)
func TestCreateAndGetPolicyRule(t *testing.T) {
db := openTestDB(t)
ruleJSON := `{"actions":["pgcreds:read"],"resource_type":"pgcreds","effect":"allow"}`
rec, err := db.CreatePolicyRule("test rule", 50, ruleJSON, nil)
if err != nil {
t.Fatalf("CreatePolicyRule: %v", err)
}
if rec.ID == 0 {
t.Error("expected non-zero ID after create")
}
if rec.Priority != 50 {
t.Errorf("expected priority 50, got %d", rec.Priority)
}
if !rec.Enabled {
t.Error("new rule should be enabled by default")
}
got, err := db.GetPolicyRule(rec.ID)
if err != nil {
t.Fatalf("GetPolicyRule: %v", err)
}
if got.Description != "test rule" {
t.Errorf("expected description %q, got %q", "test rule", got.Description)
}
if got.RuleJSON != ruleJSON {
t.Errorf("rule_json mismatch: got %q", got.RuleJSON)
}
}
func TestGetPolicyRule_NotFound(t *testing.T) {
db := openTestDB(t)
_, err := db.GetPolicyRule(99999)
if !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
func TestListPolicyRules(t *testing.T) {
db := openTestDB(t)
_, _ = db.CreatePolicyRule("rule A", 100, `{"effect":"allow"}`, nil)
_, _ = db.CreatePolicyRule("rule B", 50, `{"effect":"deny"}`, nil)
_, _ = db.CreatePolicyRule("rule C", 200, `{"effect":"allow"}`, nil)
rules, err := db.ListPolicyRules(false)
if err != nil {
t.Fatalf("ListPolicyRules: %v", err)
}
if len(rules) != 3 {
t.Fatalf("expected 3 rules, got %d", len(rules))
}
// Should be ordered by priority ascending.
if rules[0].Priority > rules[1].Priority || rules[1].Priority > rules[2].Priority {
t.Errorf("rules not sorted by priority: %v %v %v",
rules[0].Priority, rules[1].Priority, rules[2].Priority)
}
}
func TestListPolicyRules_EnabledOnly(t *testing.T) {
db := openTestDB(t)
r1, _ := db.CreatePolicyRule("enabled rule", 100, `{"effect":"allow"}`, nil)
r2, _ := db.CreatePolicyRule("disabled rule", 100, `{"effect":"deny"}`, nil)
if err := db.SetPolicyRuleEnabled(r2.ID, false); err != nil {
t.Fatalf("SetPolicyRuleEnabled: %v", err)
}
all, err := db.ListPolicyRules(false)
if err != nil {
t.Fatalf("ListPolicyRules(all): %v", err)
}
if len(all) != 2 {
t.Errorf("expected 2 total rules, got %d", len(all))
}
enabled, err := db.ListPolicyRules(true)
if err != nil {
t.Fatalf("ListPolicyRules(enabledOnly): %v", err)
}
if len(enabled) != 1 {
t.Fatalf("expected 1 enabled rule, got %d", len(enabled))
}
if enabled[0].ID != r1.ID {
t.Errorf("wrong rule returned: got ID %d, want %d", enabled[0].ID, r1.ID)
}
}
func TestUpdatePolicyRule(t *testing.T) {
db := openTestDB(t)
rec, _ := db.CreatePolicyRule("original", 100, `{"effect":"allow"}`, nil)
newDesc := "updated description"
newPriority := 25
if err := db.UpdatePolicyRule(rec.ID, &newDesc, &newPriority, nil); err != nil {
t.Fatalf("UpdatePolicyRule: %v", err)
}
got, err := db.GetPolicyRule(rec.ID)
if err != nil {
t.Fatalf("GetPolicyRule after update: %v", err)
}
if got.Description != newDesc {
t.Errorf("expected description %q, got %q", newDesc, got.Description)
}
if got.Priority != newPriority {
t.Errorf("expected priority %d, got %d", newPriority, got.Priority)
}
// RuleJSON should be unchanged.
if got.RuleJSON != `{"effect":"allow"}` {
t.Errorf("rule_json should not change when not provided: %q", got.RuleJSON)
}
}
func TestUpdatePolicyRule_RuleJSON(t *testing.T) {
db := openTestDB(t)
rec, _ := db.CreatePolicyRule("rule", 100, `{"effect":"allow"}`, nil)
newJSON := `{"effect":"deny","roles":["auditor"]}`
if err := db.UpdatePolicyRule(rec.ID, nil, nil, &newJSON); err != nil {
t.Fatalf("UpdatePolicyRule (json only): %v", err)
}
got, err := db.GetPolicyRule(rec.ID)
if err != nil {
t.Fatalf("GetPolicyRule: %v", err)
}
if got.RuleJSON != newJSON {
t.Errorf("expected updated rule_json, got %q", got.RuleJSON)
}
// Description and priority unchanged.
if got.Description != "rule" {
t.Errorf("description should be unchanged, got %q", got.Description)
}
}
func TestSetPolicyRuleEnabled(t *testing.T) {
db := openTestDB(t)
rec, _ := db.CreatePolicyRule("toggle rule", 100, `{"effect":"allow"}`, nil)
if !rec.Enabled {
t.Fatal("new rule should be enabled")
}
if err := db.SetPolicyRuleEnabled(rec.ID, false); err != nil {
t.Fatalf("SetPolicyRuleEnabled(false): %v", err)
}
got, _ := db.GetPolicyRule(rec.ID)
if got.Enabled {
t.Error("rule should be disabled after SetPolicyRuleEnabled(false)")
}
if err := db.SetPolicyRuleEnabled(rec.ID, true); err != nil {
t.Fatalf("SetPolicyRuleEnabled(true): %v", err)
}
got, _ = db.GetPolicyRule(rec.ID)
if !got.Enabled {
t.Error("rule should be enabled after SetPolicyRuleEnabled(true)")
}
}
func TestDeletePolicyRule(t *testing.T) {
db := openTestDB(t)
rec, _ := db.CreatePolicyRule("to delete", 100, `{"effect":"allow"}`, nil)
if err := db.DeletePolicyRule(rec.ID); err != nil {
t.Fatalf("DeletePolicyRule: %v", err)
}
_, err := db.GetPolicyRule(rec.ID)
if !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound after delete, got %v", err)
}
}
func TestDeletePolicyRule_NonExistent(t *testing.T) {
db := openTestDB(t)
// Deleting a non-existent rule should be a no-op, not an error.
if err := db.DeletePolicyRule(99999); err != nil {
t.Errorf("DeletePolicyRule on nonexistent ID should not error: %v", err)
}
}
func TestCreatePolicyRule_WithCreatedBy(t *testing.T) {
db := openTestDB(t)
acct, _ := db.CreateAccount("policy-creator", model.AccountTypeHuman, "hash")
rec, err := db.CreatePolicyRule("by user", 100, `{"effect":"allow"}`, &acct.ID)
if err != nil {
t.Fatalf("CreatePolicyRule with createdBy: %v", err)
}
got, _ := db.GetPolicyRule(rec.ID)
if got.CreatedBy == nil || *got.CreatedBy != acct.ID {
t.Errorf("expected CreatedBy=%d, got %v", acct.ID, got.CreatedBy)
}
}

82
internal/db/tags.go Normal file
View File

@@ -0,0 +1,82 @@
package db
import (
"fmt"
)
// GetAccountTags returns the tags assigned to an account, sorted alphabetically.
func (db *DB) GetAccountTags(accountID int64) ([]string, error) {
rows, err := db.sql.Query(`
SELECT tag FROM account_tags WHERE account_id = ? ORDER BY tag ASC
`, accountID)
if err != nil {
return nil, fmt.Errorf("db: get tags for account %d: %w", accountID, err)
}
defer func() { _ = rows.Close() }()
var tags []string
for rows.Next() {
var tag string
if err := rows.Scan(&tag); err != nil {
return nil, fmt.Errorf("db: scan tag: %w", err)
}
tags = append(tags, tag)
}
return tags, rows.Err()
}
// AddAccountTag adds a single tag to an account. If the tag already exists the
// operation is a no-op (INSERT OR IGNORE).
func (db *DB) AddAccountTag(accountID int64, tag string) error {
_, err := db.sql.Exec(`
INSERT OR IGNORE INTO account_tags (account_id, tag, created_at)
VALUES (?, ?, ?)
`, accountID, tag, now())
if err != nil {
return fmt.Errorf("db: add tag %q to account %d: %w", tag, accountID, err)
}
return nil
}
// RemoveAccountTag removes a single tag from an account. If the tag does not
// exist the operation is a no-op.
func (db *DB) RemoveAccountTag(accountID int64, tag string) error {
_, err := db.sql.Exec(`
DELETE FROM account_tags WHERE account_id = ? AND tag = ?
`, accountID, tag)
if err != nil {
return fmt.Errorf("db: remove tag %q from account %d: %w", tag, accountID, err)
}
return nil
}
// SetAccountTags atomically replaces the complete tag set for an account within
// a single transaction. Any tags not present in the new set are removed; any
// new tags are inserted.
func (db *DB) SetAccountTags(accountID int64, tags []string) error {
tx, err := db.sql.Begin()
if err != nil {
return fmt.Errorf("db: set account tags begin tx: %w", err)
}
if _, err := tx.Exec(`DELETE FROM account_tags WHERE account_id = ?`, accountID); err != nil {
_ = tx.Rollback()
return fmt.Errorf("db: set account tags delete existing: %w", err)
}
n := now()
for _, tag := range tags {
if _, err := tx.Exec(`
INSERT INTO account_tags (account_id, tag, created_at)
VALUES (?, ?, ?)
`, accountID, tag, n); err != nil {
_ = tx.Rollback()
return fmt.Errorf("db: set account tags insert %q: %w", tag, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("db: set account tags commit: %w", err)
}
return nil
}

183
internal/db/tags_test.go Normal file
View File

@@ -0,0 +1,183 @@
package db
import (
"testing"
"git.wntrmute.dev/kyle/mcias/internal/model"
)
func TestGetAccountTags_Empty(t *testing.T) {
db := openTestDB(t)
acct, err := db.CreateAccount("taguser", model.AccountTypeHuman, "hash")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
tags, err := db.GetAccountTags(acct.ID)
if err != nil {
t.Fatalf("GetAccountTags: %v", err)
}
if len(tags) != 0 {
t.Errorf("expected no tags, got %v", tags)
}
}
func TestAddAndGetAccountTags(t *testing.T) {
db := openTestDB(t)
acct, err := db.CreateAccount("taguser2", model.AccountTypeHuman, "hash")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
for _, tag := range []string{"env:staging", "svc:payments-api"} {
if err := db.AddAccountTag(acct.ID, tag); err != nil {
t.Fatalf("AddAccountTag(%q): %v", tag, err)
}
}
tags, err := db.GetAccountTags(acct.ID)
if err != nil {
t.Fatalf("GetAccountTags: %v", err)
}
if len(tags) != 2 {
t.Fatalf("expected 2 tags, got %d: %v", len(tags), tags)
}
// Results are sorted alphabetically.
if tags[0] != "env:staging" || tags[1] != "svc:payments-api" {
t.Errorf("unexpected tags: %v", tags)
}
}
func TestAddAccountTag_Idempotent(t *testing.T) {
db := openTestDB(t)
acct, err := db.CreateAccount("taguser3", model.AccountTypeHuman, "hash")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
// Adding the same tag twice must not error or produce duplicates.
for i := 0; i < 3; i++ {
if err := db.AddAccountTag(acct.ID, "env:production"); err != nil {
t.Fatalf("AddAccountTag (attempt %d): %v", i+1, err)
}
}
tags, err := db.GetAccountTags(acct.ID)
if err != nil {
t.Fatalf("GetAccountTags: %v", err)
}
if len(tags) != 1 {
t.Errorf("expected exactly 1 tag, got %d: %v", len(tags), tags)
}
}
func TestRemoveAccountTag(t *testing.T) {
db := openTestDB(t)
acct, err := db.CreateAccount("taguser4", model.AccountTypeHuman, "hash")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
_ = db.AddAccountTag(acct.ID, "env:staging")
_ = db.AddAccountTag(acct.ID, "env:production")
if err := db.RemoveAccountTag(acct.ID, "env:staging"); err != nil {
t.Fatalf("RemoveAccountTag: %v", err)
}
tags, err := db.GetAccountTags(acct.ID)
if err != nil {
t.Fatalf("GetAccountTags: %v", err)
}
if len(tags) != 1 || tags[0] != "env:production" {
t.Errorf("expected only env:production, got %v", tags)
}
}
func TestRemoveAccountTag_NonExistent(t *testing.T) {
db := openTestDB(t)
acct, err := db.CreateAccount("taguser5", model.AccountTypeHuman, "hash")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
// Removing a tag that doesn't exist must be a no-op, not an error.
if err := db.RemoveAccountTag(acct.ID, "nonexistent:tag"); err != nil {
t.Errorf("RemoveAccountTag on nonexistent tag should not error: %v", err)
}
}
func TestSetAccountTags_ReplacesFully(t *testing.T) {
db := openTestDB(t)
acct, err := db.CreateAccount("taguser6", model.AccountTypeHuman, "hash")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
_ = db.AddAccountTag(acct.ID, "old:tag1")
_ = db.AddAccountTag(acct.ID, "old:tag2")
newTags := []string{"new:tag1", "new:tag2", "new:tag3"}
if err := db.SetAccountTags(acct.ID, newTags); err != nil {
t.Fatalf("SetAccountTags: %v", err)
}
tags, err := db.GetAccountTags(acct.ID)
if err != nil {
t.Fatalf("GetAccountTags: %v", err)
}
if len(tags) != 3 {
t.Fatalf("expected 3 tags after set, got %d: %v", len(tags), tags)
}
// Verify old tags are gone.
for _, tag := range tags {
if tag == "old:tag1" || tag == "old:tag2" {
t.Errorf("old tag still present after SetAccountTags: %q", tag)
}
}
}
func TestSetAccountTags_Empty(t *testing.T) {
db := openTestDB(t)
acct, err := db.CreateAccount("taguser7", model.AccountTypeHuman, "hash")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
_ = db.AddAccountTag(acct.ID, "env:staging")
if err := db.SetAccountTags(acct.ID, []string{}); err != nil {
t.Fatalf("SetAccountTags with empty slice: %v", err)
}
tags, err := db.GetAccountTags(acct.ID)
if err != nil {
t.Fatalf("GetAccountTags: %v", err)
}
if len(tags) != 0 {
t.Errorf("expected no tags after clearing, got %v", tags)
}
}
func TestAccountTagsCascadeDelete(t *testing.T) {
db := openTestDB(t)
acct, err := db.CreateAccount("taguser8", model.AccountTypeHuman, "hash")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
_ = db.AddAccountTag(acct.ID, "env:staging")
// Soft-deleting an account does not cascade-delete tags (FK ON DELETE CASCADE
// only fires on hard deletes). Verify tags still exist after status update.
if err := db.UpdateAccountStatus(acct.ID, model.AccountStatusDeleted); err != nil {
t.Fatalf("UpdateAccountStatus: %v", err)
}
tags, err := db.GetAccountTags(acct.ID)
if err != nil {
t.Fatalf("GetAccountTags after soft delete: %v", err)
}
if len(tags) != 1 {
t.Errorf("expected tag to survive soft delete, got %v", tags)
}
}

View File

@@ -25,6 +25,7 @@ import (
"time" "time"
"git.wntrmute.dev/kyle/mcias/internal/db" "git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/policy"
"git.wntrmute.dev/kyle/mcias/internal/token" "git.wntrmute.dev/kyle/mcias/internal/token"
) )
@@ -297,3 +298,98 @@ func minFloat64(a, b float64) float64 {
} }
return b 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)
})
}
}

View File

@@ -131,4 +131,26 @@ const (
EventTOTPRemoved = "totp_removed" EventTOTPRemoved = "totp_removed"
EventPGCredAccessed = "pgcred_accessed" EventPGCredAccessed = "pgcred_accessed"
EventPGCredUpdated = "pgcred_updated" //nolint:gosec // G101: audit event type string, not a credential 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"`
}

View File

@@ -0,0 +1,83 @@
package policy
// defaultRules are the compiled-in authorization rules. They cannot be
// modified or deleted via the API. They reproduce the previous binary
// admin/non-admin behavior exactly when no operator rules exist, so wiring
// the policy engine alongside RequireRole("admin") produces identical results.
//
// All defaults use Priority 0 so they are evaluated before any operator rule
// (which defaults to Priority 100). Within priority 0, deny-wins still applies,
// but the defaults contain no Deny rules — they only grant the minimum required
// for self-service and admin operations.
//
// Security rationale for each rule is documented inline.
var defaultRules = []Rule{
{
// Admin wildcard: an account bearing the "admin" role is permitted to
// perform any action on any resource. This mirrors the previous
// RequireRole("admin") check and is the root of all administrative trust.
ID: -1,
Description: "Admin wildcard: admin role allows all actions",
Priority: 0,
Roles: []string{"admin"},
Effect: Allow,
},
{
// Self-service logout and token renewal: any authenticated principal may
// revoke or renew their own token. No resource scoping is needed because
// the handler independently verifies that the JTI belongs to the caller.
ID: -2,
Description: "Self-service: any principal may logout or renew their own token",
Priority: 0,
Actions: []Action{ActionLogout, ActionRenewToken},
Effect: Allow,
},
{
// Self-service TOTP enrollment: any authenticated human account may
// initiate and confirm their own TOTP enrollment. The handler verifies
// the subject matches before writing.
ID: -3,
Description: "Self-service: any principal may enroll their own TOTP",
Priority: 0,
Actions: []Action{ActionEnrollTOTP},
Effect: Allow,
},
{
// System accounts reading their own pgcreds: a service that has already
// authenticated (e.g. via its bearer service token) may retrieve its own
// Postgres credentials without admin privilege. OwnerMatchesSubject
// ensures the service can only reach its own row — not another service's.
ID: -4,
Description: "System accounts may read their own pg_credentials",
Priority: 0,
AccountTypes: []string{"system"},
Actions: []Action{ActionReadPGCreds},
ResourceType: ResourcePGCreds,
OwnerMatchesSubject: true,
Effect: Allow,
},
{
// System accounts issuing or renewing their own service token: a system
// account may rotate its own bearer token. OwnerMatchesSubject ensures
// it cannot issue tokens for other accounts.
ID: -5,
Description: "System accounts may issue or renew their own service token",
Priority: 0,
AccountTypes: []string{"system"},
Actions: []Action{ActionIssueToken, ActionRenewToken},
ResourceType: ResourceToken,
OwnerMatchesSubject: true,
Effect: Allow,
},
{
// Public endpoints: token validation and login do not require
// authentication. The middleware exempts them from RequireAuth entirely;
// this rule exists so that if a policy check is accidentally applied to
// these paths, it does not block them.
ID: -6,
Description: "Public: token validation and login are always permitted",
Priority: 0,
Actions: []Action{ActionValidateToken, ActionLogin},
Effect: Allow,
},
}

150
internal/policy/engine.go Normal file
View File

@@ -0,0 +1,150 @@
package policy
import "sort"
// Evaluate determines whether the given input should be allowed or denied,
// using the provided rule set. Built-in default rules (from defaults.go) are
// always merged in before evaluation.
//
// The rules slice passed by the caller contains only DB-backed operator rules;
// defaultRules are appended internally so callers do not need to know about them.
//
// Return values:
// - effect: Allow or Deny
// - matched: the Rule that produced the decision, or nil on default-deny
//
// Security: evaluation is purely functional — no I/O, no globals mutated. The
// deny-wins and default-deny semantics ensure that a misconfigured or empty
// operator rule set falls back to the built-in defaults, which reproduce the
// previous binary admin/non-admin behavior exactly.
func Evaluate(input PolicyInput, operatorRules []Rule) (Effect, *Rule) {
// Merge operator rules with built-in defaults. Defaults have priority 0;
// operator rules default to 100. Sort is stable so same-priority rules
// maintain their original order (defaults before operator rules on ties).
all := make([]Rule, 0, len(operatorRules)+len(defaultRules))
all = append(all, defaultRules...)
all = append(all, operatorRules...)
sort.SliceStable(all, func(i, j int) bool {
return all[i].Priority < all[j].Priority
})
var matched []Rule
for _, r := range all {
if matches(input, r) {
matched = append(matched, r)
}
}
// Deny-wins: first matching Deny terminates evaluation.
for i := range matched {
if matched[i].Effect == Deny {
return Deny, &matched[i]
}
}
// First matching Allow permits.
for i := range matched {
if matched[i].Effect == Allow {
return Allow, &matched[i]
}
}
// Default-deny: no rule matched.
return Deny, nil
}
// matches reports whether rule r applies to the given input. Every non-zero
// field on the rule is treated as an AND condition; empty slices and zero
// strings are wildcards.
func matches(input PolicyInput, r Rule) bool {
// Principal: roles (at least one must match)
if len(r.Roles) > 0 && !anyIn(input.Roles, r.Roles) {
return false
}
// Principal: account type
if len(r.AccountTypes) > 0 && !stringIn(input.AccountType, r.AccountTypes) {
return false
}
// Principal: exact subject UUID
if r.SubjectUUID != "" && input.Subject != r.SubjectUUID {
return false
}
// Action
if len(r.Actions) > 0 && !actionIn(input.Action, r.Actions) {
return false
}
// Resource type
if r.ResourceType != "" && input.Resource.Type != r.ResourceType {
return false
}
// Resource: owner must equal subject
if r.OwnerMatchesSubject && input.Resource.OwnerUUID != input.Subject {
return false
}
// Resource: service name must be in the allowed list
if len(r.ServiceNames) > 0 && !stringIn(input.Resource.ServiceName, r.ServiceNames) {
return false
}
// Resource: resource must carry ALL required tags
if len(r.RequiredTags) > 0 && !allTagsPresent(input.Resource.Tags, r.RequiredTags) {
return false
}
return true
}
// anyIn reports whether any element of needle appears in haystack.
func anyIn(needle, haystack []string) bool {
for _, n := range needle {
for _, h := range haystack {
if n == h {
return true
}
}
}
return false
}
// stringIn reports whether s is in list.
func stringIn(s string, list []string) bool {
for _, v := range list {
if s == v {
return true
}
}
return false
}
// actionIn reports whether a is in list.
func actionIn(a Action, list []Action) bool {
for _, v := range list {
if a == v {
return true
}
}
return false
}
// allTagsPresent reports whether resourceTags contains every tag in required.
func allTagsPresent(resourceTags, required []string) bool {
for _, req := range required {
found := false
for _, rt := range resourceTags {
if rt == req {
found = true
break
}
}
if !found {
return false
}
}
return true
}

View File

@@ -0,0 +1,380 @@
package policy
import (
"testing"
)
// adminInput is a convenience helper for building admin PolicyInputs.
func adminInput(action Action, resType ResourceType) PolicyInput {
return PolicyInput{
Subject: "admin-uuid",
AccountType: "human",
Roles: []string{"admin"},
Action: action,
Resource: Resource{Type: resType},
}
}
func TestEvaluate_DefaultDeny(t *testing.T) {
// No operator rules, non-admin subject: should hit default-deny for an
// action that is not covered by built-in self-service defaults.
input := PolicyInput{
Subject: "user-uuid",
AccountType: "human",
Roles: []string{},
Action: ActionListAccounts,
Resource: Resource{Type: ResourceAccount},
}
effect, rule := Evaluate(input, nil)
if effect != Deny {
t.Errorf("expected Deny, got %s", effect)
}
if rule != nil {
t.Errorf("expected nil rule on default-deny, got %+v", rule)
}
}
func TestEvaluate_AdminWildcard(t *testing.T) {
actions := []Action{
ActionListAccounts, ActionCreateAccount, ActionReadPGCreds,
ActionWritePGCreds, ActionReadAudit, ActionManageRules,
}
for _, a := range actions {
t.Run(string(a), func(t *testing.T) {
effect, rule := Evaluate(adminInput(a, ResourceAccount), nil)
if effect != Allow {
t.Errorf("admin should be allowed %s, got Deny", a)
}
if rule == nil || rule.ID != -1 {
t.Errorf("expected admin wildcard rule (-1), got %v", rule)
}
})
}
}
func TestEvaluate_SelfServiceLogout(t *testing.T) {
input := PolicyInput{
Subject: "user-uuid",
AccountType: "human",
Roles: []string{},
Action: ActionLogout,
Resource: Resource{Type: ResourceToken},
}
effect, _ := Evaluate(input, nil)
if effect != Allow {
t.Error("expected any authenticated user to be allowed to logout")
}
}
func TestEvaluate_SelfServiceRenew(t *testing.T) {
input := PolicyInput{
Subject: "user-uuid",
AccountType: "human",
Roles: []string{},
Action: ActionRenewToken,
Resource: Resource{Type: ResourceToken},
}
effect, _ := Evaluate(input, nil)
if effect != Allow {
t.Error("expected any authenticated user to be allowed to renew token")
}
}
func TestEvaluate_SystemOwnPGCreds(t *testing.T) {
input := PolicyInput{
Subject: "svc-uuid",
AccountType: "system",
Roles: []string{},
Action: ActionReadPGCreds,
Resource: Resource{
Type: ResourcePGCreds,
OwnerUUID: "svc-uuid", // owner matches subject
},
}
effect, rule := Evaluate(input, nil)
if effect != Allow {
t.Errorf("system account should be allowed to read own pgcreds, got Deny")
}
if rule == nil || rule.ID != -4 {
t.Errorf("expected built-in rule -4, got %v", rule)
}
}
func TestEvaluate_SystemOtherPGCreds_Denied(t *testing.T) {
// System account trying to read another system account's pgcreds.
input := PolicyInput{
Subject: "svc-uuid",
AccountType: "system",
Roles: []string{},
Action: ActionReadPGCreds,
Resource: Resource{
Type: ResourcePGCreds,
OwnerUUID: "other-svc-uuid", // different owner
},
}
effect, _ := Evaluate(input, nil)
if effect != Allow {
// This is the expected behavior: default-deny.
return
}
t.Error("system account must not read another account's pgcreds without an explicit rule")
}
func TestEvaluate_DenyWins(t *testing.T) {
// Operator adds a Deny rule for a specific subject; a broader Allow rule
// also matches. Deny must win regardless of order.
operatorRules := []Rule{
{
ID: 1,
Description: "broad allow",
Priority: 100,
Actions: []Action{ActionReadPGCreds},
ResourceType: ResourcePGCreds,
Effect: Allow,
},
{
ID: 2,
Description: "specific deny",
Priority: 50, // higher precedence than the allow
SubjectUUID: "bad-actor-uuid",
ResourceType: ResourcePGCreds,
Effect: Deny,
},
}
input := PolicyInput{
Subject: "bad-actor-uuid",
AccountType: "human",
Roles: []string{},
Action: ActionReadPGCreds,
Resource: Resource{Type: ResourcePGCreds},
}
effect, rule := Evaluate(input, operatorRules)
if effect != Deny {
t.Errorf("deny rule should win over allow rule, got Allow")
}
if rule == nil || rule.ID != 2 {
t.Errorf("expected deny rule ID 2, got %v", rule)
}
}
func TestEvaluate_ServiceNameGating(t *testing.T) {
operatorRules := []Rule{
{
ID: 3,
Description: "alice may read payments-api pgcreds",
Priority: 50,
Roles: []string{"svc:payments-api"},
Actions: []Action{ActionReadPGCreds},
ResourceType: ResourcePGCreds,
ServiceNames: []string{"payments-api"},
Effect: Allow,
},
}
alice := PolicyInput{
Subject: "alice-uuid",
AccountType: "human",
Roles: []string{"svc:payments-api"},
Action: ActionReadPGCreds,
Resource: Resource{
Type: ResourcePGCreds,
ServiceName: "payments-api",
},
}
effect, _ := Evaluate(alice, operatorRules)
if effect != Allow {
t.Error("alice should be allowed to read payments-api pgcreds")
}
// Same principal, wrong service — should be denied.
alice.Resource.ServiceName = "user-service"
effect, _ = Evaluate(alice, operatorRules)
if effect != Deny {
t.Error("alice should be denied access to user-service pgcreds")
}
}
func TestEvaluate_MachineTagGating(t *testing.T) {
operatorRules := []Rule{
{
ID: 4,
Description: "deploy-agent: staging only",
Priority: 50,
SubjectUUID: "deploy-agent-uuid",
Actions: []Action{ActionReadPGCreds},
ResourceType: ResourcePGCreds,
RequiredTags: []string{"env:staging"},
Effect: Allow,
},
{
ID: 5,
Description: "deploy-agent: deny production (belt-and-suspenders)",
Priority: 10, // evaluated before the allow
SubjectUUID: "deploy-agent-uuid",
ResourceType: ResourcePGCreds,
RequiredTags: []string{"env:production"},
Effect: Deny,
},
}
staging := PolicyInput{
Subject: "deploy-agent-uuid",
AccountType: "system",
Roles: []string{},
Action: ActionReadPGCreds,
Resource: Resource{
Type: ResourcePGCreds,
Tags: []string{"env:staging", "svc:payments-api"},
},
}
effect, _ := Evaluate(staging, operatorRules)
if effect != Allow {
t.Error("deploy-agent should be allowed to read staging pgcreds")
}
production := staging
production.Resource.Tags = []string{"env:production", "svc:payments-api"}
effect, rule := Evaluate(production, operatorRules)
if effect != Deny {
t.Error("deploy-agent should be denied access to production pgcreds")
}
if rule == nil || rule.ID != 5 {
t.Errorf("expected deny rule ID 5 for production, got %v", rule)
}
}
func TestEvaluate_OwnerMatchesSubject(t *testing.T) {
// Operator rule: a user may read account details for accounts they own.
operatorRules := []Rule{
{
ID: 6,
Description: "principals may read their own account",
Priority: 50,
Actions: []Action{ActionReadAccount},
ResourceType: ResourceAccount,
OwnerMatchesSubject: true,
Effect: Allow,
},
}
// Reading own account — should be allowed.
own := PolicyInput{
Subject: "user-uuid",
AccountType: "human",
Roles: []string{},
Action: ActionReadAccount,
Resource: Resource{
Type: ResourceAccount,
OwnerUUID: "user-uuid",
},
}
effect, _ := Evaluate(own, operatorRules)
if effect != Allow {
t.Error("user should be allowed to read their own account")
}
// Reading another user's account — should be denied.
other := own
other.Resource.OwnerUUID = "other-uuid"
effect, _ = Evaluate(other, operatorRules)
if effect != Deny {
t.Error("user must not read another user's account without an explicit rule")
}
}
func TestEvaluate_PriorityOrder(t *testing.T) {
// Two Allow rules at different priorities: the lower-priority number wins.
operatorRules := []Rule{
{ID: 10, Description: "low priority allow", Priority: 200, Actions: []Action{ActionReadAudit}, Effect: Allow},
{ID: 11, Description: "high priority allow", Priority: 10, Actions: []Action{ActionReadAudit}, Effect: Allow},
}
input := PolicyInput{
Subject: "user-uuid",
AccountType: "human",
Roles: []string{},
Action: ActionReadAudit,
Resource: Resource{Type: ResourceAuditLog},
}
_, rule := Evaluate(input, operatorRules)
if rule == nil || rule.ID != 11 {
t.Errorf("expected higher-priority rule (ID 11) to match first, got %v", rule)
}
}
func TestEvaluate_MultipleRequiredTags(t *testing.T) {
// RequiredTags requires ALL tags to be present.
operatorRules := []Rule{
{
ID: 20,
Description: "allow if both env:staging and svc:payments-api tags present",
Priority: 50,
Actions: []Action{ActionReadPGCreds},
ResourceType: ResourcePGCreds,
RequiredTags: []string{"env:staging", "svc:payments-api"},
Effect: Allow,
},
}
// Both tags present — allowed.
input := PolicyInput{
Subject: "user-uuid",
AccountType: "human",
Roles: []string{},
Action: ActionReadPGCreds,
Resource: Resource{
Type: ResourcePGCreds,
Tags: []string{"env:staging", "svc:payments-api", "extra:tag"},
},
}
effect, _ := Evaluate(input, operatorRules)
if effect != Allow {
t.Error("both required tags present: should be allowed")
}
// Only one tag present — denied (default-deny).
input.Resource.Tags = []string{"env:staging"}
effect, _ = Evaluate(input, operatorRules)
if effect != Deny {
t.Error("only one required tag present: should be denied")
}
// No tags — denied.
input.Resource.Tags = nil
effect, _ = Evaluate(input, operatorRules)
if effect != Deny {
t.Error("no tags: should be denied")
}
}
func TestEvaluate_AccountTypeGating(t *testing.T) {
// Rule only applies to system accounts.
operatorRules := []Rule{
{
ID: 30,
Description: "system accounts may list accounts",
Priority: 50,
AccountTypes: []string{"system"},
Actions: []Action{ActionListAccounts},
Effect: Allow,
},
}
sysInput := PolicyInput{
Subject: "svc-uuid",
AccountType: "system",
Roles: []string{},
Action: ActionListAccounts,
Resource: Resource{Type: ResourceAccount},
}
effect, _ := Evaluate(sysInput, operatorRules)
if effect != Allow {
t.Error("system account should be allowed by account-type rule")
}
humanInput := sysInput
humanInput.AccountType = "human"
effect, _ = Evaluate(humanInput, operatorRules)
if effect != Deny {
t.Error("human account should not match system-only rule")
}
}

View File

@@ -0,0 +1,83 @@
package policy
import (
"encoding/json"
"fmt"
"sync"
)
// Engine wraps the stateless Evaluate function with an in-memory cache of
// operator rules loaded from the database. Built-in default rules are always
// merged in at evaluation time; they do not appear in the cache.
//
// The Engine is safe for concurrent use. Call Reload() after any change to the
// policy_rules table to refresh the cached rule set without restarting.
type Engine struct {
rules []Rule
mu sync.RWMutex
}
// NewEngine creates an Engine with an initially empty operator rule set.
// Call Reload (or load rules directly) before use in production.
func NewEngine() *Engine {
return &Engine{}
}
// SetRules atomically replaces the cached operator rule set.
// records is a slice of PolicyRuleRecord values (from the database layer).
// Only enabled records are converted to Rule values.
//
// Security: rule_json is decoded into a RuleBody struct before being merged
// into a Rule. This prevents the database from injecting values into the ID or
// Description fields that are stored as dedicated columns.
func (e *Engine) SetRules(records []PolicyRecord) error {
rules := make([]Rule, 0, len(records))
for _, rec := range records {
if !rec.Enabled {
continue
}
var body RuleBody
if err := json.Unmarshal([]byte(rec.RuleJSON), &body); err != nil {
return fmt.Errorf("policy: decode rule %d %q: %w", rec.ID, rec.Description, err)
}
rules = append(rules, Rule{
ID: rec.ID,
Description: rec.Description,
Priority: rec.Priority,
Roles: body.Roles,
AccountTypes: body.AccountTypes,
SubjectUUID: body.SubjectUUID,
Actions: body.Actions,
ResourceType: body.ResourceType,
OwnerMatchesSubject: body.OwnerMatchesSubject,
ServiceNames: body.ServiceNames,
RequiredTags: body.RequiredTags,
Effect: body.Effect,
})
}
e.mu.Lock()
e.rules = rules
e.mu.Unlock()
return nil
}
// Evaluate runs the policy engine against the given input using the cached
// operator rules plus compiled-in defaults.
func (e *Engine) Evaluate(input PolicyInput) (Effect, *Rule) {
e.mu.RLock()
rules := e.rules
e.mu.RUnlock()
return Evaluate(input, rules)
}
// PolicyRecord is the minimal interface the Engine needs from the DB layer.
// Using a local struct avoids importing the db or model packages from policy,
// which would create a dependency cycle.
type PolicyRecord struct {
Description string
RuleJSON string
ID int64
Priority int
Enabled bool
}

141
internal/policy/policy.go Normal file
View File

@@ -0,0 +1,141 @@
// Package policy implements an in-process, attribute-based authorization
// policy engine for MCIAS. Evaluation is a pure function: given a PolicyInput
// and a slice of Rules it returns an Effect (Allow or Deny) and the Rule that
// produced the decision. The caller is responsible for assembling PolicyInput
// from JWT claims and database lookups; the engine never touches the database.
//
// Evaluation order:
// 1. Rules are sorted by Priority (ascending; lower = higher precedence).
// 2. Deny-wins: the first matching Deny rule terminates evaluation.
// 3. If no Deny matched, the first matching Allow rule permits the request.
// 4. Default-deny: if no rule matches, the request is denied.
package policy
// Action is a structured action identifier of the form "resource:verb".
// Security: using typed constants prevents callers from passing arbitrary
// strings, making it harder to accidentally bypass a policy check.
type Action string
const (
ActionListAccounts Action = "accounts:list"
ActionCreateAccount Action = "accounts:create"
ActionReadAccount Action = "accounts:read"
ActionUpdateAccount Action = "accounts:update"
ActionDeleteAccount Action = "accounts:delete"
ActionReadRoles Action = "roles:read"
ActionWriteRoles Action = "roles:write"
ActionReadTags Action = "tags:read"
ActionWriteTags Action = "tags:write"
ActionIssueToken Action = "tokens:issue"
ActionRevokeToken Action = "tokens:revoke"
ActionValidateToken Action = "tokens:validate" // public endpoint
ActionRenewToken Action = "tokens:renew" // self-service
ActionReadPGCreds Action = "pgcreds:read"
ActionWritePGCreds Action = "pgcreds:write"
ActionReadAudit Action = "audit:read"
ActionEnrollTOTP Action = "totp:enroll" // self-service
ActionRemoveTOTP Action = "totp:remove" // admin
ActionLogin Action = "auth:login" // public
ActionLogout Action = "auth:logout" // self-service
ActionListRules Action = "policy:list"
ActionManageRules Action = "policy:manage"
)
// ResourceType identifies what kind of object a request targets.
type ResourceType string
const (
ResourceAccount ResourceType = "account"
ResourceToken ResourceType = "token"
ResourcePGCreds ResourceType = "pgcreds"
ResourceAuditLog ResourceType = "audit_log"
ResourceTOTP ResourceType = "totp"
ResourcePolicy ResourceType = "policy"
)
// Effect is the outcome of policy evaluation.
type Effect string
const (
Allow Effect = "allow"
Deny Effect = "deny"
)
// Resource describes the object the principal is attempting to act on. Tags
// are loaded from the account_tags table by the middleware before evaluation.
type Resource struct {
Type ResourceType
// OwnerUUID is the UUID of the account that owns this resource (e.g. the
// system account whose pg_credentials are being requested). Empty when the
// resource is not account-owned (e.g. an audit log listing).
OwnerUUID string
// ServiceName is the username of the system account that owns the resource.
// Used for service-name-based gating rules (ServiceNames field on Rule).
ServiceName string
// Tags are the account_tags values on the target account. The engine
// checks RequiredTags against this slice.
Tags []string
}
// PolicyInput is assembled by the middleware from JWT claims and the current
// request context. The engine accepts a PolicyInput and a rule set; it never
// queries the database directly.
type PolicyInput struct {
// Principal fields — from JWT claims
Subject string // account UUID ("sub")
AccountType string // "human" or "system"
Roles []string // role strings from "roles" claim
Action Action
Resource Resource
}
// Rule is a single policy statement. All non-zero fields are AND-ed together
// as match conditions. A zero/empty field is a wildcard.
//
// Security: rules from the database are decoded and merged with compiled-in
// defaults before evaluation. Neither the JSON encoding nor the DB storage is
// trusted to produce sensible rules; the engine validates each condition
// independently using set membership — there is no string interpolation or
// code execution involved.
type Rule struct {
Description string
SubjectUUID string
ResourceType ResourceType
Effect Effect
Roles []string
AccountTypes []string
Actions []Action
ServiceNames []string
RequiredTags []string
ID int64
Priority int
OwnerMatchesSubject bool
}
// RuleBody is the subset of Rule that is stored as JSON in the policy_rules
// table. ID, Description, and Priority are stored as dedicated columns.
// Security: the JSON blob is decoded into a RuleBody before being merged into
// a full Rule, so the database cannot inject ID or Description values.
type RuleBody struct {
SubjectUUID string `json:"subject_uuid,omitempty"`
ResourceType ResourceType `json:"resource_type,omitempty"`
Effect Effect `json:"effect"`
Roles []string `json:"roles,omitempty"`
AccountTypes []string `json:"account_types,omitempty"`
Actions []Action `json:"actions,omitempty"`
ServiceNames []string `json:"service_names,omitempty"`
RequiredTags []string `json:"required_tags,omitempty"`
OwnerMatchesSubject bool `json:"owner_matches_subject,omitempty"`
}

View File

@@ -0,0 +1,324 @@
package server
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/middleware"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/policy"
)
// ---- Tag endpoints ----
type tagsResponse struct {
Tags []string `json:"tags"`
}
func (s *Server) handleGetTags(w http.ResponseWriter, r *http.Request) {
acct, ok := s.loadAccount(w, r)
if !ok {
return
}
tags, err := s.db.GetAccountTags(acct.ID)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
if tags == nil {
tags = []string{}
}
writeJSON(w, http.StatusOK, tagsResponse{Tags: tags})
}
type setTagsRequest struct {
Tags []string `json:"tags"`
}
func (s *Server) handleSetTags(w http.ResponseWriter, r *http.Request) {
acct, ok := s.loadAccount(w, r)
if !ok {
return
}
var req setTagsRequest
if !decodeJSON(w, r, &req) {
return
}
// Validate tags: each must be non-empty.
for _, tag := range req.Tags {
if tag == "" {
middleware.WriteError(w, http.StatusBadRequest, "tag values must not be empty", "bad_request")
return
}
}
if err := s.db.SetAccountTags(acct.ID, req.Tags); err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
// Determine actor for audit log.
claims := middleware.ClaimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if actor, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
s.writeAudit(r, model.EventTagAdded, actorID, &acct.ID,
fmt.Sprintf(`{"account":%q,"tags":%s}`, acct.UUID, marshalStringSlice(req.Tags)))
tags, err := s.db.GetAccountTags(acct.ID)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
if tags == nil {
tags = []string{}
}
writeJSON(w, http.StatusOK, tagsResponse{Tags: tags})
}
// ---- Policy rule endpoints ----
type policyRuleResponse struct {
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Description string `json:"description"`
RuleBody policy.RuleBody `json:"rule"`
ID int64 `json:"id"`
Priority int `json:"priority"`
Enabled bool `json:"enabled"`
}
func policyRuleToResponse(rec *model.PolicyRuleRecord) (policyRuleResponse, error) {
var body policy.RuleBody
if err := json.Unmarshal([]byte(rec.RuleJSON), &body); err != nil {
return policyRuleResponse{}, fmt.Errorf("decode rule body: %w", err)
}
return policyRuleResponse{
ID: rec.ID,
Priority: rec.Priority,
Description: rec.Description,
RuleBody: body,
Enabled: rec.Enabled,
CreatedAt: rec.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: rec.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}, nil
}
func (s *Server) handleListPolicyRules(w http.ResponseWriter, _ *http.Request) {
rules, err := s.db.ListPolicyRules(false)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
resp := make([]policyRuleResponse, 0, len(rules))
for _, r := range rules {
rv, err := policyRuleToResponse(r)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
resp = append(resp, rv)
}
writeJSON(w, http.StatusOK, resp)
}
type createPolicyRuleRequest struct {
Description string `json:"description"`
Rule policy.RuleBody `json:"rule"`
Priority int `json:"priority"`
}
func (s *Server) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request) {
var req createPolicyRuleRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Description == "" {
middleware.WriteError(w, http.StatusBadRequest, "description is required", "bad_request")
return
}
if req.Rule.Effect != policy.Allow && req.Rule.Effect != policy.Deny {
middleware.WriteError(w, http.StatusBadRequest, "rule.effect must be 'allow' or 'deny'", "bad_request")
return
}
priority := req.Priority
if priority == 0 {
priority = 100 // default
}
ruleJSON, err := json.Marshal(req.Rule)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
claims := middleware.ClaimsFromContext(r.Context())
var createdBy *int64
if claims != nil {
if actor, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
createdBy = &actor.ID
}
}
rec, err := s.db.CreatePolicyRule(req.Description, priority, string(ruleJSON), createdBy)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
s.writeAudit(r, model.EventPolicyRuleCreated, createdBy, nil,
fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description))
rv, err := policyRuleToResponse(rec)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
writeJSON(w, http.StatusCreated, rv)
}
func (s *Server) handleGetPolicyRule(w http.ResponseWriter, r *http.Request) {
rec, ok := s.loadPolicyRule(w, r)
if !ok {
return
}
rv, err := policyRuleToResponse(rec)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
writeJSON(w, http.StatusOK, rv)
}
type updatePolicyRuleRequest struct {
Description *string `json:"description,omitempty"`
Rule *policy.RuleBody `json:"rule,omitempty"`
Priority *int `json:"priority,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
func (s *Server) handleUpdatePolicyRule(w http.ResponseWriter, r *http.Request) {
rec, ok := s.loadPolicyRule(w, r)
if !ok {
return
}
var req updatePolicyRuleRequest
if !decodeJSON(w, r, &req) {
return
}
// Validate effect if rule body is being updated.
var ruleJSON *string
if req.Rule != nil {
if req.Rule.Effect != policy.Allow && req.Rule.Effect != policy.Deny {
middleware.WriteError(w, http.StatusBadRequest, "rule.effect must be 'allow' or 'deny'", "bad_request")
return
}
b, err := json.Marshal(req.Rule)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
s := string(b)
ruleJSON = &s
}
if err := s.db.UpdatePolicyRule(rec.ID, req.Description, req.Priority, ruleJSON); err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
if req.Enabled != nil {
if err := s.db.SetPolicyRuleEnabled(rec.ID, *req.Enabled); err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
}
claims := middleware.ClaimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if actor, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
s.writeAudit(r, model.EventPolicyRuleUpdated, actorID, nil,
fmt.Sprintf(`{"rule_id":%d}`, rec.ID))
updated, err := s.db.GetPolicyRule(rec.ID)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
rv, err := policyRuleToResponse(updated)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
writeJSON(w, http.StatusOK, rv)
}
func (s *Server) handleDeletePolicyRule(w http.ResponseWriter, r *http.Request) {
rec, ok := s.loadPolicyRule(w, r)
if !ok {
return
}
if err := s.db.DeletePolicyRule(rec.ID); err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
claims := middleware.ClaimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if actor, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
s.writeAudit(r, model.EventPolicyRuleDeleted, actorID, nil,
fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description))
w.WriteHeader(http.StatusNoContent)
}
// loadPolicyRule retrieves a policy rule by the {id} path parameter.
func (s *Server) loadPolicyRule(w http.ResponseWriter, r *http.Request) (*model.PolicyRuleRecord, bool) {
idStr := r.PathValue("id")
if idStr == "" {
middleware.WriteError(w, http.StatusBadRequest, "rule id is required", "bad_request")
return nil, false
}
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
middleware.WriteError(w, http.StatusBadRequest, "rule id must be an integer", "bad_request")
return nil, false
}
rec, err := s.db.GetPolicyRule(id)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
middleware.WriteError(w, http.StatusNotFound, "policy rule not found", "not_found")
return nil, false
}
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return nil, false
}
return rec, true
}
// marshalStringSlice encodes a string slice as a compact JSON array.
// Used for audit log details — never includes credential material.
func marshalStringSlice(ss []string) string {
b, _ := json.Marshal(ss)
return string(b)
}

View File

@@ -119,6 +119,13 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleGetPGCreds))) 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("PUT /v1/accounts/{id}/pgcreds", requireAdmin(http.HandlerFunc(s.handleSetPGCreds)))
mux.Handle("GET /v1/audit", requireAdmin(http.HandlerFunc(s.handleListAudit))) 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). // UI routes (HTMX-based management frontend).
uiSrv, err := ui.New(s.db, s.cfg, s.privKey, s.pubKey, s.masterKey, s.logger) uiSrv, err := ui.New(s.db, s.cfg, s.privKey, s.pubKey, s.masterKey, s.logger)

View File

@@ -143,6 +143,12 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
// ErrNotFound is expected when no credentials have been stored yet. // 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{ u.render(w, "account_detail", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken}, PageData: PageData{CSRFToken: csrfToken},
Account: acct, Account: acct,
@@ -150,6 +156,7 @@ func (u *UIServer) handleAccountDetail(w http.ResponseWriter, r *http.Request) {
AllRoles: knownRoles, AllRoles: knownRoles,
Tokens: tokens, Tokens: tokens,
PGCred: pgCred, PGCred: pgCred,
Tags: tags,
}) })
} }

View File

@@ -0,0 +1,347 @@
package ui
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/policy"
)
// ---- Policies page ----
// allActionStrings is the list of all policy action constants for the form UI.
var allActionStrings = []string{
string(policy.ActionListAccounts),
string(policy.ActionCreateAccount),
string(policy.ActionReadAccount),
string(policy.ActionUpdateAccount),
string(policy.ActionDeleteAccount),
string(policy.ActionReadRoles),
string(policy.ActionWriteRoles),
string(policy.ActionReadTags),
string(policy.ActionWriteTags),
string(policy.ActionIssueToken),
string(policy.ActionRevokeToken),
string(policy.ActionValidateToken),
string(policy.ActionRenewToken),
string(policy.ActionReadPGCreds),
string(policy.ActionWritePGCreds),
string(policy.ActionReadAudit),
string(policy.ActionEnrollTOTP),
string(policy.ActionRemoveTOTP),
string(policy.ActionLogin),
string(policy.ActionLogout),
string(policy.ActionListRules),
string(policy.ActionManageRules),
}
func (u *UIServer) handlePoliciesPage(w http.ResponseWriter, r *http.Request) {
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
rules, err := u.db.ListPolicyRules(false)
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to load policy rules")
return
}
views := make([]*PolicyRuleView, 0, len(rules))
for _, rec := range rules {
views = append(views, policyRuleToView(rec))
}
data := PoliciesData{
PageData: PageData{CSRFToken: csrfToken},
Rules: views,
AllActions: allActionStrings,
}
u.render(w, "policies", data)
}
// policyRuleToView converts a DB record to a template-friendly view.
func policyRuleToView(rec *model.PolicyRuleRecord) *PolicyRuleView {
pretty := prettyJSONStr(rec.RuleJSON)
return &PolicyRuleView{
ID: rec.ID,
Priority: rec.Priority,
Description: rec.Description,
RuleJSON: pretty,
Enabled: rec.Enabled,
CreatedAt: rec.CreatedAt.Format("2006-01-02 15:04 UTC"),
UpdatedAt: rec.UpdatedAt.Format("2006-01-02 15:04 UTC"),
}
}
func prettyJSONStr(s string) string {
var v json.RawMessage
if err := json.Unmarshal([]byte(s), &v); err != nil {
return s
}
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return s
}
return string(b)
}
// handleCreatePolicyRule handles POST /policies — creates a new policy rule.
func (u *UIServer) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid form")
return
}
description := strings.TrimSpace(r.FormValue("description"))
if description == "" {
u.renderError(w, r, http.StatusBadRequest, "description is required")
return
}
priorityStr := r.FormValue("priority")
priority := 100
if priorityStr != "" {
p, err := strconv.Atoi(priorityStr)
if err != nil || p < 0 {
u.renderError(w, r, http.StatusBadRequest, "priority must be a non-negative integer")
return
}
priority = p
}
effectStr := r.FormValue("effect")
if effectStr != string(policy.Allow) && effectStr != string(policy.Deny) {
u.renderError(w, r, http.StatusBadRequest, "effect must be 'allow' or 'deny'")
return
}
body := policy.RuleBody{
Effect: policy.Effect(effectStr),
}
// Multi-value fields.
if roles := r.Form["roles"]; len(roles) > 0 {
body.Roles = roles
}
if types := r.Form["account_types"]; len(types) > 0 {
body.AccountTypes = types
}
if actions := r.Form["actions"]; len(actions) > 0 {
acts := make([]policy.Action, len(actions))
for i, a := range actions {
acts[i] = policy.Action(a)
}
body.Actions = acts
}
if resType := r.FormValue("resource_type"); resType != "" {
body.ResourceType = policy.ResourceType(resType)
}
body.SubjectUUID = strings.TrimSpace(r.FormValue("subject_uuid"))
body.OwnerMatchesSubject = r.FormValue("owner_matches_subject") == "1"
if svcNames := r.FormValue("service_names"); svcNames != "" {
body.ServiceNames = splitCommas(svcNames)
}
if tags := r.FormValue("required_tags"); tags != "" {
body.RequiredTags = splitCommas(tags)
}
ruleJSON, err := json.Marshal(body)
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "internal error")
return
}
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
rec, err := u.db.CreatePolicyRule(description, priority, string(ruleJSON), actorID)
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, fmt.Sprintf("create policy rule: %v", err))
return
}
u.writeAudit(r, model.EventPolicyRuleCreated, actorID, nil,
fmt.Sprintf(`{"rule_id":%d,"description":%q}`, rec.ID, rec.Description))
u.render(w, "policy_row", policyRuleToView(rec))
}
// handleTogglePolicyRule handles PATCH /policies/{id}/enabled — enable or disable.
func (u *UIServer) handleTogglePolicyRule(w http.ResponseWriter, r *http.Request) {
rec, ok := u.loadPolicyRule(w, r)
if !ok {
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid form")
return
}
enabledStr := r.FormValue("enabled")
enabled := enabledStr == "1" || enabledStr == "true"
if err := u.db.SetPolicyRuleEnabled(rec.ID, enabled); err != nil {
u.renderError(w, r, http.StatusInternalServerError, "update failed")
return
}
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
u.writeAudit(r, model.EventPolicyRuleUpdated, actorID, nil,
fmt.Sprintf(`{"rule_id":%d,"enabled":%v}`, rec.ID, enabled))
rec.Enabled = enabled
u.render(w, "policy_row", policyRuleToView(rec))
}
// handleDeletePolicyRule handles DELETE /policies/{id}.
func (u *UIServer) handleDeletePolicyRule(w http.ResponseWriter, r *http.Request) {
rec, ok := u.loadPolicyRule(w, r)
if !ok {
return
}
if err := u.db.DeletePolicyRule(rec.ID); err != nil {
u.renderError(w, r, http.StatusInternalServerError, "delete failed")
return
}
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
u.writeAudit(r, model.EventPolicyRuleDeleted, actorID, nil,
fmt.Sprintf(`{"rule_id":%d}`, rec.ID))
// Return empty string to remove the row from the DOM.
w.WriteHeader(http.StatusOK)
}
// ---- Tag management ----
// handleSetAccountTags handles PUT /accounts/{id}/tags from the UI.
func (u *UIServer) handleSetAccountTags(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
acct, err := u.db.GetAccountByUUID(id)
if err != nil {
u.renderError(w, r, http.StatusNotFound, "account not found")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid form")
return
}
tagsRaw := strings.TrimSpace(r.FormValue("tags_text"))
var tags []string
if tagsRaw != "" {
tags = splitLines(tagsRaw)
}
// Validate: no empty tags.
for _, tag := range tags {
if tag == "" {
u.renderError(w, r, http.StatusBadRequest, "tag values must not be empty")
return
}
}
if err := u.db.SetAccountTags(acct.ID, tags); err != nil {
u.renderError(w, r, http.StatusInternalServerError, "update failed")
return
}
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if actor, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
u.writeAudit(r, model.EventTagAdded, actorID, &acct.ID,
fmt.Sprintf(`{"account":%q,"tags":%d}`, acct.UUID, len(tags)))
csrfToken, _ := u.setCSRFCookies(w)
u.render(w, "tags_editor", AccountDetailData{
PageData: PageData{CSRFToken: csrfToken},
Account: acct,
Tags: tags,
})
}
// ---- Helpers ----
func (u *UIServer) loadPolicyRule(w http.ResponseWriter, r *http.Request) (*model.PolicyRuleRecord, bool) {
idStr := r.PathValue("id")
if idStr == "" {
u.renderError(w, r, http.StatusBadRequest, "rule id is required")
return nil, false
}
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
u.renderError(w, r, http.StatusBadRequest, "rule id must be an integer")
return nil, false
}
rec, err := u.db.GetPolicyRule(id)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
u.renderError(w, r, http.StatusNotFound, "policy rule not found")
return nil, false
}
u.renderError(w, r, http.StatusInternalServerError, "internal error")
return nil, false
}
return rec, true
}
// splitCommas splits a comma-separated string and trims whitespace from each element.
func splitCommas(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
// splitLines splits a newline-separated string and trims whitespace from each element.
func splitLines(s string) []string {
parts := strings.Split(s, "\n")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}

View File

@@ -171,6 +171,9 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
"templates/fragments/error.html", "templates/fragments/error.html",
"templates/fragments/audit_rows.html", "templates/fragments/audit_rows.html",
"templates/fragments/pgcreds_form.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...) base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
if err != nil { 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", "account_detail": "templates/account_detail.html",
"audit": "templates/audit.html", "audit": "templates/audit.html",
"audit_detail": "templates/audit_detail.html", "audit_detail": "templates/audit_detail.html",
"policies": "templates/policies.html",
} }
tmpls := make(map[string]*template.Template, len(pageFiles)) tmpls := make(map[string]*template.Template, len(pageFiles))
for name, file := range 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", adminGet(u.handleAuditPage))
uiMux.Handle("GET /audit/rows", adminGet(u.handleAuditRows)) uiMux.Handle("GET /audit/rows", adminGet(u.handleAuditRows))
uiMux.Handle("GET /audit/{id}", adminGet(u.handleAuditDetail)) 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 // 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 // catch-all for all UI paths; the more-specific /v1/ API patterns registered
@@ -509,6 +518,7 @@ type AccountDetailData struct {
PageData PageData
Roles []string Roles []string
AllRoles []string AllRoles []string
Tags []string
Tokens []*model.TokenRecord Tokens []*model.TokenRecord
} }
@@ -528,3 +538,21 @@ type AuditDetailData struct {
Event *db.AuditEventView Event *db.AuditEventView
PageData 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
}

View File

@@ -118,6 +118,103 @@ components:
description: JSON blob with event-specific metadata. Never contains credentials. description: JSON blob with event-specific metadata. Never contains credentials.
example: '{"jti":"f47ac10b-..."}' 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: PGCreds:
type: object type: object
required: [host, port, database, username, password] required: [host, port, database, username, password]
@@ -948,6 +1045,233 @@ paths:
"403": "403":
$ref: "#/components/responses/Forbidden" $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: tags:
- name: Public - name: Public
description: No authentication required. description: No authentication required.
@@ -963,3 +1287,5 @@ tags:
description: Requires admin role. description: Requires admin role.
- name: Admin — Audit - name: Admin — Audit
description: Requires admin role. description: Requires admin role.
- name: Admin — Policy
description: Requires admin role. Manage policy rules and account tags.

View File

@@ -40,4 +40,8 @@
{{template "pgcreds_form" .}} {{template "pgcreds_form" .}}
</div> </div>
{{end}} {{end}}
<div class="card">
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Tags</h2>
<div id="tags-editor">{{template "tags_editor" .}}</div>
</div>
{{end}} {{end}}

View File

@@ -14,6 +14,7 @@
<li><a href="/dashboard">Dashboard</a></li> <li><a href="/dashboard">Dashboard</a></li>
<li><a href="/accounts">Accounts</a></li> <li><a href="/accounts">Accounts</a></li>
<li><a href="/audit">Audit</a></li> <li><a href="/audit">Audit</a></li>
<li><a href="/policies">Policies</a></li>
<li><form method="POST" action="/logout" style="margin:0"><button class="btn btn-sm btn-secondary" type="submit">Logout</button></form></li> <li><form method="POST" action="/logout" style="margin:0"><button class="btn btn-sm btn-secondary" type="submit">Logout</button></form></li>
</ul> </ul>
</div> </div>

View File

@@ -0,0 +1,77 @@
{{define "policy_form"}}
<form hx-post="/policies" hx-target="#policies-tbody" hx-swap="afterbegin">
<div style="display:grid;grid-template-columns:1fr 80px 120px;gap:.5rem;margin-bottom:.5rem">
<input class="form-control" type="text" name="description"
placeholder="Description" required>
<input class="form-control" type="number" name="priority"
placeholder="100" value="100" min="0" max="9999">
<select class="form-control" name="effect" required>
<option value="allow">allow</option>
<option value="deny">deny</option>
</select>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
<div>
<label class="text-small text-muted">Roles (select multiple)</label>
<select class="form-control" name="roles" multiple size="4" style="font-size:.85rem">
<option value="admin">admin</option>
<option value="user">user</option>
<option value="service">service</option>
</select>
</div>
<div>
<label class="text-small text-muted">Account types</label>
<select class="form-control" name="account_types" multiple size="4" style="font-size:.85rem">
<option value="human">human</option>
<option value="system">system</option>
</select>
</div>
</div>
<div style="margin-bottom:.5rem">
<label class="text-small text-muted">Actions (select multiple)</label>
<select class="form-control" name="actions" multiple size="5" style="font-family:monospace;font-size:.8rem">
{{range .AllActions}}
<option value="{{.}}">{{.}}</option>
{{end}}
</select>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
<div>
<label class="text-small text-muted">Resource type</label>
<select class="form-control" name="resource_type" style="font-size:.85rem">
<option value="">(any)</option>
<option value="account">account</option>
<option value="token">token</option>
<option value="pgcreds">pgcreds</option>
<option value="audit_log">audit_log</option>
<option value="totp">totp</option>
<option value="policy">policy</option>
</select>
</div>
<div>
<label class="text-small text-muted">Subject UUID (optional)</label>
<input class="form-control" type="text" name="subject_uuid"
placeholder="Only match this account UUID" style="font-size:.85rem">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
<div>
<label class="text-small text-muted">Service names (comma-separated)</label>
<input class="form-control" type="text" name="service_names"
placeholder="e.g. payments-api,billing" style="font-size:.85rem">
</div>
<div>
<label class="text-small text-muted">Required tags (comma-separated)</label>
<input class="form-control" type="text" name="required_tags"
placeholder="e.g. env:production,svc:billing" style="font-size:.85rem">
</div>
</div>
<div style="margin-bottom:.5rem">
<label style="font-size:.85rem;display:flex;align-items:center;gap:.4rem;cursor:pointer">
<input type="checkbox" name="owner_matches_subject" value="1">
Owner must match subject (self-service rules only)
</label>
</div>
<button class="btn btn-sm btn-secondary" type="submit">Create Rule</button>
</form>
{{end}}

View File

@@ -0,0 +1,42 @@
{{define "policy_row"}}
<tr id="policy-row-{{.ID}}">
<td class="text-small text-muted">{{.ID}}</td>
<td class="text-small">{{.Priority}}</td>
<td>
<strong>{{.Description}}</strong>
<details style="margin-top:.25rem">
<summary class="text-small text-muted" style="cursor:pointer">Show rule JSON</summary>
<pre style="font-size:.75rem;background:#f8fafc;padding:.5rem;border-radius:4px;overflow:auto;margin-top:.25rem">{{.RuleJSON}}</pre>
</details>
</td>
<td class="text-small">
{{/* Extract effect from RuleJSON via prettyJSON — displayed separately */}}
<span class="badge {{if .Enabled}}badge-active{{else}}badge-inactive{{end}}">
{{if .Enabled}}enabled{{else}}disabled{{end}}
</span>
</td>
<td>
{{if .Enabled}}
<button class="btn btn-sm btn-secondary"
hx-patch="/policies/{{.ID}}/enabled"
hx-vals='{"enabled":"0"}'
hx-target="#policy-row-{{.ID}}"
hx-swap="outerHTML">Disable</button>
{{else}}
<button class="btn btn-sm btn-secondary"
hx-patch="/policies/{{.ID}}/enabled"
hx-vals='{"enabled":"1"}'
hx-target="#policy-row-{{.ID}}"
hx-swap="outerHTML">Enable</button>
{{end}}
</td>
<td class="text-small text-muted">{{.UpdatedAt}}</td>
<td>
<button class="btn btn-sm btn-danger"
hx-delete="/policies/{{.ID}}"
hx-target="#policy-row-{{.ID}}"
hx-swap="outerHTML"
hx-confirm="Delete policy rule {{.ID}}? This cannot be undone.">Delete</button>
</td>
</tr>
{{end}}

View File

@@ -0,0 +1,21 @@
{{define "tags_editor"}}
<div id="tags-editor">
{{if .Tags}}
<div style="display:flex;flex-wrap:wrap;gap:.3rem;margin-bottom:.75rem">
{{range .Tags}}
<span style="font-size:.8rem;background:#f1f5f9;padding:.2rem .5rem;border-radius:3px;font-family:monospace">{{.}}</span>
{{end}}
</div>
{{else}}
<p class="text-muted text-small" style="margin-bottom:.75rem">No tags assigned.</p>
{{end}}
<form hx-put="/accounts/{{.Account.UUID}}/tags"
hx-target="#tags-editor" hx-swap="outerHTML">
<textarea class="form-control" name="tags_text" rows="3"
style="font-family:monospace;font-size:.85rem;margin-bottom:.5rem"
placeholder="One tag per line, e.g.&#10;env:production&#10;svc:payments-api">{{range .Tags}}{{.}}
{{end}}</textarea>
<button class="btn btn-sm btn-secondary" type="submit">Save Tags</button>
</form>
</div>
{{end}}

View File

@@ -0,0 +1,37 @@
{{define "policies"}}{{template "base" .}}{{end}}
{{define "title"}}Policy Rules — MCIAS{{end}}
{{define "content"}}
<div class="page-header d-flex align-center justify-between">
<div>
<h1>Policy Rules</h1>
<p class="text-muted text-small">{{len .Rules}} operator rules (built-in defaults not shown)</p>
</div>
<button class="btn btn-primary" onclick="document.getElementById('create-form').style.display='block';this.style.display='none'">
Add Rule
</button>
</div>
<div id="create-form" class="card mt-2" style="display:none">
<h2 style="font-size:1rem;font-weight:600;margin-bottom:1rem">Create Policy Rule</h2>
{{template "policy_form" .}}
</div>
<div class="table-wrapper mt-2">
<table>
<thead>
<tr>
<th>ID</th>
<th>Priority</th>
<th>Description</th>
<th>Effect</th>
<th>Enabled</th>
<th>Updated</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="policies-tbody">
{{range .Rules}}{{template "policy_row" .}}{{end}}
</tbody>
</table>
</div>
{{end}}