Checkpoint: fix all lint warnings

- errorlint: use errors.Is for ErrSealed comparisons in vault_test.go
- gofmt: reformat config, config_test, middleware_test with goimports
- govet/fieldalignment: reorder struct fields in vault.go, csrf.go,
  detail_test.go, middleware_test.go for optimal alignment
- unused: remove unused newCSRFManager in csrf.go (superseded by
  newCSRFManagerFromVault)
- revive/early-return: invert sealed-vault condition in main.go

Security: no auth/crypto logic changed; struct reordering and error
comparison fixes only. newCSRFManager removal is safe — it was never
called; all CSRF construction goes through newCSRFManagerFromVault.

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-03-15 16:40:11 -07:00
parent 9657f18784
commit cb661bb8f5
12 changed files with 708 additions and 41 deletions

514
POLICY.md Normal file
View File

@@ -0,0 +1,514 @@
# MCIAS Policy Engine
Reference guide for the MCIAS attribute-based access control (ABAC) policy
engine. Covers concepts, rule authoring, the full action/resource catalogue,
built-in defaults, time-scoped rules, and worked examples.
For the authoritative design rationale and middleware integration details see
[ARCHITECTURE.md §20](ARCHITECTURE.md).
---
## 1. Concepts
### Evaluation model
The policy engine is a **pure function**: given a `PolicyInput` (assembled from
JWT claims and database lookups) and a slice of `Rule` values, it returns an
`Effect` (`allow` or `deny`) and a pointer to the matching rule.
Evaluation proceeds in three steps:
1. **Sort** all rules (built-in defaults + operator rules) by `Priority`
ascending. Lower number = evaluated first. Stable sort preserves insertion
order within the same priority.
2. **Deny-wins**: the first matching `deny` rule terminates evaluation
immediately and returns `Deny`.
3. **First-allow**: if no `deny` matched, the first matching `allow` rule
returns `Allow`.
4. **Default-deny**: if no rule matched at all, the request is denied.
The engine never touches the database. The caller (middleware) is responsible
for assembling `PolicyInput` from JWT claims and DB lookups before calling
`engine.Evaluate`.
### Rule matching
A rule matches a request when **every populated field** satisfies its
condition. An empty/zero field is a wildcard (matches anything).
| Rule field | Match condition |
|---|---|
| `roles` | Principal holds **at least one** of the listed roles |
| `account_types` | Principal's account type is in the list (`"human"`, `"system"`) |
| `subject_uuid` | Principal UUID equals this value exactly |
| `actions` | Request action is in the list |
| `resource_type` | Target resource type equals this value |
| `owner_matches_subject` | (if `true`) resource owner UUID equals the principal UUID |
| `service_names` | Target service account username is in the list |
| `required_tags` | Target account carries **all** of the listed tags |
All conditions are AND-ed. To express OR across principals or resources, create
multiple rules.
### Priority
| Range | Intended use |
|---|---|
| 0 | Built-in defaults (compiled in; cannot be overridden via API) |
| 149 | High-precedence operator deny rules (explicit blocks) |
| 5099 | Normal operator allow rules |
| 100 | Default for new rules created via API or CLI |
| 101+ | Low-precedence fallback rules |
Because deny-wins applies within the matched set (not just within a priority
band), a `deny` rule at priority 100 still overrides an `allow` at priority 50
if both match. Use explicit deny rules at low priority numbers (e.g. 10) when
you want them to fire before any allow can be considered.
### Built-in default rules
These rules are compiled into the binary (`internal/policy/defaults.go`). They
have IDs -1 through -7, priority 0, and **cannot be disabled or deleted via
the API**. They reproduce the previous binary admin/non-admin behavior exactly.
| ID | Description | Conditions | Effect |
|---|---|---|---|
| -1 | Admin wildcard | `roles=[admin]` | allow |
| -2 | Self-service logout / token renewal | `actions=[auth:logout, tokens:renew]` | allow |
| -3 | Self-service TOTP enrollment | `actions=[totp:enroll]` | allow |
| -7 | Self-service password change | `account_types=[human]`, `actions=[auth:change_password]` | allow |
| -4 | System account reads own pgcreds | `account_types=[system]`, `actions=[pgcreds:read]`, `resource_type=pgcreds`, `owner_matches_subject=true` | allow |
| -5 | System account issues/renews own token | `account_types=[system]`, `actions=[tokens:issue, tokens:renew]`, `resource_type=token`, `owner_matches_subject=true` | allow |
| -6 | Public endpoints | `actions=[tokens:validate, auth:login]` | allow |
Custom operator rules extend this baseline; they do not replace it.
---
## 2. Actions and Resource Types
### Actions
Actions follow the `resource:verb` convention. Use the exact string values
shown below when authoring rules.
| Action string | Description | Notes |
|---|---|---|
| `accounts:list` | List all accounts | admin |
| `accounts:create` | Create an account | admin |
| `accounts:read` | Read account details | admin |
| `accounts:update` | Update account (status, etc.) | admin |
| `accounts:delete` | Soft-delete an account | admin |
| `roles:read` | Read role assignments | admin |
| `roles:write` | Grant or revoke roles | admin |
| `tags:read` | Read account tags | admin |
| `tags:write` | Set account tags | admin |
| `tokens:issue` | Issue or rotate a service token | admin or delegate |
| `tokens:revoke` | Revoke a token | admin |
| `tokens:validate` | Validate a token | public |
| `tokens:renew` | Renew own token | self-service |
| `pgcreds:read` | Read Postgres credentials | admin or delegated |
| `pgcreds:write` | Set Postgres credentials | admin |
| `audit:read` | Read audit log | admin |
| `totp:enroll` | Enroll TOTP | self-service |
| `totp:remove` | Remove TOTP from an account | admin |
| `auth:login` | Authenticate (username + password) | public |
| `auth:logout` | Invalidate own session token | self-service |
| `auth:change_password` | Change own password | self-service |
| `policy:list` | List policy rules | admin |
| `policy:manage` | Create, update, or delete policy rules | admin |
### Resource types
| Resource type string | Description |
|---|---|
| `account` | A human or system account record |
| `token` | A JWT or service bearer token |
| `pgcreds` | A Postgres credential record |
| `audit_log` | The audit event log |
| `totp` | A TOTP enrollment record |
| `policy` | A policy rule record |
---
## 3. Rule Schema
Rules are stored in the `policy_rules` table. The `rule_json` column holds a
JSON-encoded `RuleBody`. All other fields are dedicated columns.
### Database columns
| Column | Type | Description |
|---|---|---|
| `id` | INTEGER PK | Auto-assigned |
| `priority` | INTEGER | Default 100; lower = evaluated first |
| `description` | TEXT | Human-readable label (required) |
| `enabled` | BOOLEAN | Disabled rules are excluded from the cache |
| `not_before` | DATETIME (nullable) | Rule inactive before this UTC timestamp |
| `expires_at` | DATETIME (nullable) | Rule inactive at and after this UTC timestamp |
| `rule_json` | TEXT | JSON-encoded `RuleBody` (see below) |
### RuleBody JSON fields
```json
{
"effect": "allow" | "deny",
"roles": ["role1", "role2"],
"account_types": ["human"] | ["system"] | ["human", "system"],
"subject_uuid": "<UUID string>",
"actions": ["action:verb", ...],
"resource_type": "<resource type string>",
"owner_matches_subject": true | false,
"service_names": ["svc-username", ...],
"required_tags": ["tag:value", ...]
}
```
All fields are optional except `effect`. Omitted fields are wildcards.
---
## 4. Managing Rules
### Via mciasctl
```sh
# List all rules
mciasctl policy list
# Create a rule from a JSON file
mciasctl policy create -description "My rule" -json rule.json
# Create a time-scoped rule
mciasctl policy create \
-description "Temp production access" \
-json rule.json \
-not-before 2026-04-01T00:00:00Z \
-expires-at 2026-04-01T04:00:00Z
# Enable or disable a rule
mciasctl policy update -id 7 -enabled=false
# Delete a rule
mciasctl policy delete -id 7
```
### Via REST API (admin JWT required)
| Method | Path | Description |
|---|---|---|
| GET | `/v1/policy/rules` | List all rules |
| POST | `/v1/policy/rules` | Create a rule |
| GET | `/v1/policy/rules/{id}` | Get a single rule |
| PATCH | `/v1/policy/rules/{id}` | Update priority, enabled, or description |
| DELETE | `/v1/policy/rules/{id}` | Delete a rule |
### Via Web UI
The `/policies` page lists all rules with enable/disable toggles and a create
form. Mutating operations use HTMX partial-page updates.
### Cache reload
The `Engine` caches the active rule set in memory. It reloads automatically
after any `policy_rule_*` admin event. To force a reload without a rule change,
send `SIGHUP` to `mciassrv`.
---
## 5. Account Tags
Tags are key:value strings attached to accounts (human or system) and used as
resource match conditions in rules. They are stored in the `account_tags` table.
### Recommended tag conventions
| Tag | Meaning |
|---|---|
| `env:production` | Account belongs to the production environment |
| `env:staging` | Account belongs to the staging environment |
| `env:dev` | Account belongs to the development environment |
| `svc:payments-api` | Account is associated with the payments-api service |
| `machine:db-west-01` | Account is associated with a specific host |
| `team:platform` | Account is owned by the platform team |
Tag names are not enforced by the schema; the conventions above are
recommendations only.
### Managing tags
```sh
# Set tags on an account (replaces the full tag set atomically)
mciasctl accounts update -id <uuid> -tags "env:staging,svc:payments-api"
# Via REST (admin JWT)
PUT /v1/accounts/{id}/tags
Content-Type: application/json
["env:staging", "svc:payments-api"]
```
---
## 6. Worked Examples
### Example A — Named service delegation
**Goal:** Alice needs to read Postgres credentials for `payments-api` only.
1. Grant Alice the role `svc:payments-api`:
```sh
mciasctl accounts roles grant -id <alice-uuid> -role svc:payments-api
```
2. Create the allow rule (`rule.json`):
```json
{
"effect": "allow",
"roles": ["svc:payments-api"],
"actions": ["pgcreds:read"],
"resource_type": "pgcreds",
"service_names": ["payments-api"]
}
```
```sh
mciasctl policy create -description "Alice: read payments-api pgcreds" \
-json rule.json -priority 50
```
When Alice calls `GET /v1/accounts/{payments-api-uuid}/pgcreds`, the middleware
sets `resource.ServiceName = "payments-api"`. The rule matches and access is
granted. A call against any other service account sets a different
`ServiceName`; no rule matches and default-deny applies.
---
### Example B — Machine-tag gating (staging only)
**Goal:** `deploy-agent` may read pgcreds for staging accounts but must be
explicitly blocked from production.
1. Tag all staging system accounts:
```sh
mciasctl accounts update -id <svc-uuid> -tags "env:staging"
```
2. Explicit deny for production (low priority number = evaluated first):
```json
{
"effect": "deny",
"subject_uuid": "<deploy-agent-uuid>",
"resource_type": "pgcreds",
"required_tags": ["env:production"]
}
```
```sh
mciasctl policy create -description "deploy-agent: deny production pgcreds" \
-json deny.json -priority 10
```
3. Allow for staging:
```json
{
"effect": "allow",
"subject_uuid": "<deploy-agent-uuid>",
"actions": ["pgcreds:read"],
"resource_type": "pgcreds",
"required_tags": ["env:staging"]
}
```
```sh
mciasctl policy create -description "deploy-agent: allow staging pgcreds" \
-json allow.json -priority 50
```
The deny rule (priority 10) fires before the allow rule (priority 50) for any
production-tagged resource. For staging resources the deny does not match and
the allow rule permits access.
---
### Example C — Blanket "secrets reader" role
**Goal:** Any account holding the `secrets-reader` role may read pgcreds for
any service.
```json
{
"effect": "allow",
"roles": ["secrets-reader"],
"actions": ["pgcreds:read"],
"resource_type": "pgcreds"
}
```
```sh
mciasctl policy create -description "secrets-reader: read any pgcreds" \
-json rule.json -priority 50
```
No `service_names` or `required_tags` means the rule matches any target
account. Grant the role to any account that needs broad read access:
```sh
mciasctl accounts roles grant -id <uuid> -role secrets-reader
```
---
### Example D — Time-scoped emergency access
**Goal:** `deploy-agent` needs temporary access to production pgcreds for a
4-hour maintenance window on 2026-04-01.
```json
{
"effect": "allow",
"subject_uuid": "<deploy-agent-uuid>",
"actions": ["pgcreds:read"],
"resource_type": "pgcreds",
"required_tags": ["env:production"]
}
```
```sh
mciasctl policy create \
-description "deploy-agent: temp production access (maintenance window)" \
-json rule.json \
-priority 50 \
-not-before 2026-04-01T02:00:00Z \
-expires-at 2026-04-01T06:00:00Z
```
The engine excludes this rule from the cache before `not_before` and after
`expires_at`. No manual cleanup is required; the rule becomes inert
automatically. Both fields are nullable — omitting either means no constraint
on that end.
---
### Example E — Per-account subject rule
**Goal:** Bob (a contractor) may issue/rotate the token for `worker-bot` only,
without any admin role.
1. Grant delegation via the delegation API (preferred for token issuance; see
ARCHITECTURE.md §21):
```sh
mciasctl accounts token delegates grant \
-id <worker-bot-uuid> -grantee <bob-uuid>
```
Or, equivalently, via a policy rule:
```json
{
"effect": "allow",
"subject_uuid": "<bob-uuid>",
"actions": ["tokens:issue", "tokens:renew"],
"resource_type": "token",
"service_names": ["worker-bot"]
}
```
```sh
mciasctl policy create -description "Bob: issue worker-bot token" \
-json rule.json -priority 50
```
2. Bob uses the `/service-accounts` UI page or `mciasctl` to rotate the token
and download it via the one-time nonce endpoint.
---
### Example F — Deny a specific account from all access
**Goal:** Temporarily block `mallory` (UUID known) from all operations without
deleting the account.
```json
{
"effect": "deny",
"subject_uuid": "<mallory-uuid>"
}
```
```sh
mciasctl policy create -description "Block mallory (incident response)" \
-json rule.json -priority 1
```
Priority 1 ensures this deny fires before any allow rule. Because deny-wins
applies globally (not just within a priority band), this blocks mallory even
though the admin wildcard (priority 0, allow) would otherwise match. Note: the
admin wildcard is an `allow` rule; a `deny` at any priority overrides it for
the matched principal.
To lift the block, delete or disable the rule:
```sh
mciasctl policy update -id <rule-id> -enabled=false
```
---
## 7. Security Recommendations
1. **Prefer explicit deny rules for sensitive resources.** Use `required_tags`
or `service_names` to scope allow rules narrowly, and add a corresponding
deny rule at a lower priority number for the resources that must never be
accessible.
2. **Use time-scoped rules for temporary access.** Set `expires_at` instead of
creating a rule and relying on manual deletion. The engine enforces expiry
automatically at cache-load time.
3. **Avoid wildcard allow rules without resource scoping.** A rule with only
`roles` and `actions` but no `resource_type`, `service_names`, or
`required_tags` matches every resource of every type. Scope rules as
narrowly as the use case allows.
4. **Audit deny events.** Every explicit deny produces a `policy_deny` audit
event. Review the audit log (`GET /v1/audit` or the `/audit` UI page)
regularly to detect unexpected access patterns.
5. **Do not rely on priority alone for security boundaries.** Priority controls
evaluation order, not security strength. A deny rule at priority 100 still
overrides an allow at priority 50 if both match. Use deny rules explicitly
rather than assuming a lower-priority allow will be shadowed.
6. **Keep the built-in defaults intact.** The compiled-in rules reproduce the
baseline admin/self-service behavior. Custom rules extend this baseline;
they cannot disable the defaults. Do not attempt to work around them by
creating conflicting operator rules — the deny-wins semantics mean an
operator deny at priority 1 will block even the admin wildcard for the
matched principal.
7. **Reload after bulk changes.** After importing many rules via the REST API,
send `SIGHUP` to `mciassrv` to force an immediate cache reload rather than
waiting for the next individual rule event.
---
## 8. Audit Events
| Event | Trigger |
|---|---|
| `policy_deny` | Engine denied a request; payload: `{action, resource_type, service_name, required_tags, matched_rule_id}` — never contains credential material |
| `policy_rule_created` | New operator 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 |
All events are written to the `audit_events` table and are visible via
`GET /v1/audit` (admin JWT required) or the `/audit` web UI page.