Decisions
The three checks, what a decision returns, the deny reasons, Explain, errors and strict mode.
The three checks
Every endpoint makes the checks that apply to it. Skipping one is a security bug.
| Check | Question | Go | TypeScript |
|---|---|---|---|
| Route guard | Does the caller hold the permission anywhere? Cheap: no Cerbos call. | Guard.Require | @RequirePermission |
| Record check | May they do it to this record? | Assert, Check, CheckMany | assert, check, checkMany |
| List filter | Which records may they see? | Plan + sqlfilter.Translate | plan + drizzleFilter / planToSql |
The guard is not the decision: someone who approves leave for their team holds leave.request:approve, but not for every request. Routes with no record at all (opening a screen, reading a catalog) use RequireCapability / @RequireCapability, which is only the guard.
For creates, build the record you're about to write and check it before writing. For updates that depend on state, re-check the state in the UPDATE (where status = 'pending') to close races. Reads answer 404 when the record exists but may not be read, so IDs can't be probed.
Check, Assert, CheckMany
Checkreturns allowed or not, and an error for outages (Go:(false, err)).Assertreturns nothing when allowed and an access error otherwise. Outages become a denial with reasonunavailable. Use it in handlers.CheckManydecides several permissions on several records in one Cerbos call: the way to reveal a sensitive field on every row of a page.
Deny reasons
Every denial carries a reason. Assert reports the cheap ones: no_grant when the permission is held nowhere, policy when Cerbos denied this record, unavailable for outages, and (outside strict mode) the programmer errors. Explain reports why Cerbos denied, from outputs the generated policies emit for exactly that:
| Reason | Means | From |
|---|---|---|
no_grant | They hold the permission at no scope. | checks, Explain |
policy | Cerbos denied this record; Explain says why. | checks |
other_org | The record belongs to another org. | Explain |
scope | They hold it, but the record is outside every scope they hold it at. | Explain |
condition | The record is in scope, but the action's condition is false. | Explain |
org_rule | An org rule for their org denied it. | Explain |
unknown_permission | Not in the manifest (a typo, or stale generated files). | checks, Explain |
invalid_resource | Missing or extra attributes, a wrong type, a value outside an enum, the wrong kind. | checks, Explain |
unavailable | Cerbos, the store or the resolver failed. | checks |
no_subject | No subject on the request. | the guard |
Decision logs carry the same outputs, so the finer reason of every denial is also in the audit trail.
Explain
Explain (and POST /access/explain) answers "why can't they see this?":
{
"allowed": false,
"permission": "leave.request:approve",
"reason": "condition",
"grants": [{ "source": "assignment", "id": "a1", "roleKey": "line_manager", "scope": "team" }],
"matchedPolicy": "resource.leave_request.vdefault"
}It lists every grant of the permission and where it came from (an assignment or a group mapping), the scope that allowed it, or the org rule that denied it. Explaining someone else's decision needs access.decision:explain. The admin screens put it one click from every assignment.
Errors
Access errors have a code, which maps to an HTTP status:
| Code | Status | When |
|---|---|---|
denied | 403 | a denial; reason says why |
escalation | 403 | an admin action beyond the caller's reach |
invalid | 400 | bad input; validation problems name the field |
not_found | 404 | |
conflict | 409 | the last admin, an already revoked assignment, a duplicate key |
unavailable | 503 | Cerbos or the store failed (admin APIs; checks deny) |
accesshttp.WriteError (Go) and the Nest exception filter render them as RFC 9457 problems and hide internals. An error from access is a denial: never catch one and carry on.
Strict mode
Turn it on in development and tests (Strict: true on access.Config and accesshttp.Guard; strict: true on Access and AccessModule). It does two things:
- Programmer errors (
unknown_permission,invalid_resource) becomeinvaliderrors (400) instead of quiet denials. - A guarded route whose handler returns success without checking the route's permission against a record (
Assert,Check,CheckManyorPlanon that same permission) fails with 500unchecked_requirement. Handlers that fail before touching a record are fine.
In production, the same missing check is logged instead.