11. Org rules, decision logs and policy outputs
Org rules are Cerbos scoped policies.
Context
ADR 1 keeps policies static and generated, with roles and assignments in Postgres. That leaves most of what Cerbos offers unused: scoped policies, decision audit logs and rule outputs. Three needs map onto exactly those features:
- A client wants a rule of its own ("only the executive sees executive salaries") without forking the product policies.
- An HR product must answer "who looked at what", and the change log only records admin changes.
- Explain could only say "denied by policy", which doesn't tell support whether the record was out of scope or the condition failed.
Decision
Org rules are Cerbos scoped policies. A product lists org rule files in zaccess.yaml (orgRules: [./orgs/*.yaml], format in spec/org-rules.schema.json). Each file is one org: named rules that deny product permissions when a CEL condition holds. zaccess gen compiles each org and resource kind with rules into policies/orgs/<org>/<kind>.yaml, a resource policy with scope: <org> and SCOPE_PERMISSIONS_REQUIRE_PARENTAL_CONSENT_FOR_ALLOWS, so a scope can only take access away. The SDKs send scope: <orgId> only for kinds the org has rules for (Manifest.PolicyScope); other requests are evaluated as before. Org rules:
- live in the product repo and are reviewed as code, like every other policy (ADR 1);
- can't touch
access.*permissions, so administration is the same in every org; - may only use required attributes, because a missing attribute makes a condition fail, which would skip a deny;
- are named in Explain, decision logs and the plan (as
NOT (...) IS NOT TRUEin SQL).
If the generated scoped policy is missing for an org the manifest says has rules, Cerbos denies. We don't enable lenientScopeSearch, which would fall back to the product policy and skip the rule.
Decision logs are on in the examples. audit.decisionLogsEnabled with the file backend on stdout: every check and plan, with principal, resource, scope, effect and outputs. Checks now send only the permissions they decide (all rules read only their own key), so log entries are small and don't list a person's whole access.
Generated rules emit outputs. ruleActivated reports the scope that allowed the action; conditionNotMet reports other_org, no_grant, scope or condition; org rules report org_rule:<name>. Explain maps them to reasons. Clients that only know the old reasons can treat the new ones as policy.
Consequences
- Plans for orgs with rules carry the rule's negation.
notnow translates to(...) IS NOT TRUEin both translators, so a NULL column keeps the row as Cerbos's check does (a comparison with null is false, its negation true). This also fixes!over nullable columns in ordinary conditions. - Org rules change by deploy, not in the admin UI. Rules that clients edit themselves would need Cerbos's database policy store and are not built.
- Output expressions repeat the rule's checks. They are generated, so they can't drift, but they cost a little evaluation on every check.