# 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) | | 1–49 | High-precedence operator deny rules (explicit blocks) | | 50–99 | 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": "", "actions": ["action:verb", ...], "resource_type": "", "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 -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 -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 -tags "env:staging" ``` 2. Explicit deny for production (low priority number = evaluated first): ```json { "effect": "deny", "subject_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": "", "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 -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": "", "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 -grantee ``` Or, equivalently, via a policy rule: ```json { "effect": "allow", "subject_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": "" } ``` ```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 -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.