zynactradocs

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:

PartSaysLives inChanges by
ManifestsWhat can be done: resource kinds, their attributes, actions, the scopes each action may be granted at, conditionsaccess.yaml files in the product repoa pull request and a deploy
Roles and assignmentsWho 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 Postgresadmins, through the admin API, at any time
PoliciesHow a decision is made from the twogenerated by zaccess gen, served by Cerbosregenerating 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-invalidated
  • zaccess gen compiles the product manifest, every plugin manifest and the built-in access.* 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.LeaveRequestApprove in Go, P.LEAVE_REQUEST_APPROVE in 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 access schema 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.

  1. Your auth verifies the token and builds the subject: { userId, orgId, idp, groups }. Tokens carry identity and groups only, never permissions (why).
  2. The route guard checks that the subject holds leave.request:approve at some scope. That's a lookup in the cached grants, no Cerbos call. No grant anywhere: 403 straight away.
  3. The handler loads lr_1 and 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 plus orgId), and calls Cerbos.
  4. 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.
  5. 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.

Edit on GitHub

On this page