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

16 KiB
Raw Permalink Blame History

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:

  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

{
  "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.

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.

  1. Grant Alice the role svc:payments-api:

    mciasctl accounts roles grant -id <alice-uuid> -role svc:payments-api
    
  2. 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.

  1. Tag all staging system accounts:

    mciasctl accounts update -id <svc-uuid> -tags "env:staging"
    
  2. 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
    
  3. 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.

  1. 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
    
  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.

{
  "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

  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.