Files
mcias/POLICY.md
Kyle Isom cb661bb8f5 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>
2026-03-15 16:40:11 -07:00

515 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.