- 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>
16 KiB
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.
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:
- Sort all rules (built-in defaults + operator rules) by
Priorityascending. Lower number = evaluated first. Stable sort preserves insertion order within the same priority. - Deny-wins: the first matching
denyrule terminates evaluation immediately and returnsDeny. - First-allow: if no
denymatched, the first matchingallowrule returnsAllow. - 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
{
"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
# 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
# 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.
-
Grant Alice the role
svc:payments-api:mciasctl accounts roles grant -id <alice-uuid> -role svc:payments-api -
Create the allow rule (
rule.json):{ "effect": "allow", "roles": ["svc:payments-api"], "actions": ["pgcreds:read"], "resource_type": "pgcreds", "service_names": ["payments-api"] }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.
-
Tag all staging system accounts:
mciasctl accounts update -id <svc-uuid> -tags "env:staging" -
Explicit deny for production (low priority number = evaluated first):
{ "effect": "deny", "subject_uuid": "<deploy-agent-uuid>", "resource_type": "pgcreds", "required_tags": ["env:production"] }mciasctl policy create -description "deploy-agent: deny production pgcreds" \ -json deny.json -priority 10 -
Allow for staging:
{ "effect": "allow", "subject_uuid": "<deploy-agent-uuid>", "actions": ["pgcreds:read"], "resource_type": "pgcreds", "required_tags": ["env:staging"] }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.
{
"effect": "allow",
"roles": ["secrets-reader"],
"actions": ["pgcreds:read"],
"resource_type": "pgcreds"
}
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:
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.
{
"effect": "allow",
"subject_uuid": "<deploy-agent-uuid>",
"actions": ["pgcreds:read"],
"resource_type": "pgcreds",
"required_tags": ["env:production"]
}
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.
-
Grant delegation via the delegation API (preferred for token issuance; see ARCHITECTURE.md §21):
mciasctl accounts token delegates grant \ -id <worker-bot-uuid> -grantee <bob-uuid>Or, equivalently, via a policy rule:
{ "effect": "allow", "subject_uuid": "<bob-uuid>", "actions": ["tokens:issue", "tokens:renew"], "resource_type": "token", "service_names": ["worker-bot"] }mciasctl policy create -description "Bob: issue worker-bot token" \ -json rule.json -priority 50 -
Bob uses the
/service-accountsUI page ormciasctlto 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.
{
"effect": "deny",
"subject_uuid": "<mallory-uuid>"
}
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:
mciasctl policy update -id <rule-id> -enabled=false
7. Security Recommendations
-
Prefer explicit deny rules for sensitive resources. Use
required_tagsorservice_namesto scope allow rules narrowly, and add a corresponding deny rule at a lower priority number for the resources that must never be accessible. -
Use time-scoped rules for temporary access. Set
expires_atinstead of creating a rule and relying on manual deletion. The engine enforces expiry automatically at cache-load time. -
Avoid wildcard allow rules without resource scoping. A rule with only
rolesandactionsbut noresource_type,service_names, orrequired_tagsmatches every resource of every type. Scope rules as narrowly as the use case allows. -
Audit deny events. Every explicit deny produces a
policy_denyaudit event. Review the audit log (GET /v1/auditor the/auditUI page) regularly to detect unexpected access patterns. -
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.
-
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.
-
Reload after bulk changes. After importing many rules via the REST API, send
SIGHUPtomciassrvto 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.