zynactradocs

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.

CheckQuestionGoTypeScript
Route guardDoes the caller hold the permission anywhere? Cheap: no Cerbos call.Guard.Require@RequirePermission
Record checkMay they do it to this record?Assert, Check, CheckManyassert, check, checkMany
List filterWhich records may they see?Plan + sqlfilter.Translateplan + 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

  • Check returns allowed or not, and an error for outages (Go: (false, err)).
  • Assert returns nothing when allowed and an access error otherwise. Outages become a denial with reason unavailable. Use it in handlers.
  • CheckMany decides 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:

ReasonMeansFrom
no_grantThey hold the permission at no scope.checks, Explain
policyCerbos denied this record; Explain says why.checks
other_orgThe record belongs to another org.Explain
scopeThey hold it, but the record is outside every scope they hold it at.Explain
conditionThe record is in scope, but the action's condition is false.Explain
org_ruleAn org rule for their org denied it.Explain
unknown_permissionNot in the manifest (a typo, or stale generated files).checks, Explain
invalid_resourceMissing or extra attributes, a wrong type, a value outside an enum, the wrong kind.checks, Explain
unavailableCerbos, the store or the resolver failed.checks
no_subjectNo 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:

CodeStatusWhen
denied403a denial; reason says why
escalation403an admin action beyond the caller's reach
invalid400bad input; validation problems name the field
not_found404
conflict409the last admin, an already revoked assignment, a duplicate key
unavailable503Cerbos 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:

  1. Programmer errors (unknown_permission, invalid_resource) become invalid errors (400) instead of quiet denials.
  2. A guarded route whose handler returns success without checking the route's permission against a record (Assert, Check, CheckMany or Plan on that same permission) fails with 500 unchecked_requirement. Handlers that fail before touching a record are fine.

In production, the same missing check is logged instead.

Edit on GitHub

On this page