How it works
Manifests, generated policies, grants, Cerbos and the path of one request.
zynactra/access splits authorization into three parts that change at different speeds:
| Part | Says | Lives in | Changes by |
|---|---|---|---|
| Manifests | What can be done: resource kinds, their attributes, actions, the scopes each action may be granted at, conditions | access.yaml files in the product repo | a pull request and a deploy |
| Roles and assignments | Who can do it, and where: "Dana is a line manager for her team", "HR for Finance and everything below it" | the access schema in the product's Postgres | admins, through the admin API, at any time |
| Policies | How a decision is made from the two | generated by zaccess gen, served by Cerbos | regenerating from the manifests |
Nobody writes policies by hand, so they are never out of step with the code, and admins change who has access without a deploy.
The pieces
manifests (access.yaml) --zaccess gen--> Cerbos policies + schemas + access.manifest.json + constants
|
request --> your auth --> Subject --> guard --> record check / list plan --> Cerbos sidecar
\-- grants from Postgres (access schema), cached, NOTIFY-invalidatedzaccess gencompiles the product manifest, every plugin manifest and the built-inaccess.*manifest into one Cerbos resource policy per kind, a JSON schema per kind,access.manifest.json(read by both SDKs at startup) and typed constants (accessgen.LeaveRequestApprovein Go,P.LEAVE_REQUEST_APPROVEin TypeScript).- Cerbos runs as a sidecar next to each service and evaluates the generated policies. It never talks to the database.
- The SDK (Go or TypeScript) loads the caller's grants from Postgres, caches them, and sends them to Cerbos with each question.
- The
accessschema holds the permission catalog, roles, assignments, group mappings and the append-only change log, in the product's own database.
One request
Take POST /leave-requests/lr_1/approve from a manager.
- Your auth verifies the token and builds the subject:
{ userId, orgId, idp, groups }. Tokens carry identity and groups only, never permissions (why). - The route guard checks that the subject holds
leave.request:approveat some scope. That's a lookup in the cached grants, no Cerbos call. No grant anywhere: 403 straight away. - The handler loads
lr_1and asks for a record check: may this subject approve this record? The SDK builds the principal (the subject plus its grants and per-user attributes such as a reporting path) and the resource (the record's declared attributes plusorgId), and calls Cerbos. - Cerbos evaluates the generated rule for
approve: same org, and the permission is held, and one of the scopes it's held at matches the record, and the action's condition holds. Any failure is a deny. - In strict mode (development and tests), a handler that returns success without step 3 fails with 500
unchecked_requirement, so a forgotten record check fails a test instead of shipping.
A list endpoint replaces step 3 with a plan: Cerbos returns the condition under which a record would be allowed, and the SDK translates it into a SQL WHERE clause. The database returns exactly the readable rows, so pagination and counts are correct.
Grants
A grant is what a user ends up holding for one permission, merged across their assignments and group mappings: global, self, and per product scope type either "held" (prefix scopes) or a set of IDs (set scopes). Grants travel to Cerbos as a principal attribute on every request:
{
"perms": {
"leave.request:approve": { "global": false, "self": false, "scopes": { "team": true, "org_unit": ["fin", "fin_ap"] } }
},
"reportingPath": "/u_ceo/u_mgr/",
"orgId": "demo"
}Every scope key is always present, so query plans never carry unresolved parts. A check sends only the permissions it decides, so decision logs stay small.
Caching and freshness
Grants and resolver results are cached per subject (60 seconds by default). Every admin change fires a Postgres NOTIFY; each service instance runs a listener that drops the affected entries, so a revoked assignment stops working within seconds everywhere. Without a listener, the cache TTL bounds staleness.
Default deny
Every error path denies: an unknown permission, a resource with missing or extra attributes, Cerbos unreachable, the database down, the resolver failing. There is no "allow if Cerbos is down" switch. In strict mode the programmer errors among them (unknown permissions, invalid resources) surface as invalid errors so they are fixed rather than silently denied.
Two SDKs, one behaviour
The Go and TypeScript SDKs are written natively and share contracts rather than code: the SQL migrations, the compiled manifest, the HTTP API (spec/openapi.yaml) and conformance fixtures that both run in CI. A Go service and a Nest service of the same product make identical decisions and produce identical SQL.