Skip to content

RBAC

Role Summary
admin Everything every other role holds, plus mint and revoke agents, invite users, manage memberships, manage notification channels and automation settings, purge a queue, bulk-delete tasks, and create, edit, or delete schedule definitions.
operator Everything a viewer holds, plus issue commands (retry, cancel, bulk-retry, restart a worker, resize a pool, add or cancel a consumer, set a per-task rate limit), control existing schedules (enable, disable, trigger, pause, or resume), and manage non-destructive automation rules. Operators do not read the audit log.
auditor Everything a viewer holds, plus the audit log: list it, export it, verify the chain, and read the audit forwarder's status. Nothing else: no commands, no schedule control, no memberships, no agent tokens.
viewer Read ordinary project data: list tasks, view events, read schedules, read agents, read commands and automation rules. The audit log is not ordinary project data.

The roles are not a single ladder. admin holds every right; auditor and operator are siblings above viewer, and neither inherits the other. That is the separation of duties the audit log is for: the people who review the record are not the people who produce it. An operator who needs the audit log is given a second account at auditor, or promoted to admin deliberately.

Minting or revoking an agent, granting, changing or removing a membership, creating or revoking an invitation, and archiving a project also require a fresh second factor: the browser session must have verified MFA recently (a user with no MFA enrolled passes), and a bearer or API-key caller gets 401 on those routes because there is no session to step up.

Roles are project-scoped: you can be admin on one project and auditor on another. There is no separate owner tier; admin is the highest project role and the last-admin protection (see below) prevents the project from being orphaned.

Above the project roles there is one instance-wide tier. A user with is_admin set is treated as admin on every project without holding a membership row, and only that tier can create, edit, or archive projects. Treat it as the account you audit most closely.

The role vocabulary lives in one place, z4j_core.policy: the role order and the table that maps every action to the tier that owns it. The brain's policy engine takes both from there and adds what needs the database: resolving the project by slug, loading the caller's membership, standing in an admin membership for the instance-wide tier, and answering 404 to a non-member. A contract test enumerates every (role, action) pair through both engines and fails when they disagree, so the table cannot drift from what the routes enforce.

Every API route resolves (user, project) -> role and calls into that engine, naming either the action or a role floor:

await policy.require_member(
memberships,
user=user,
project=project,
action=Action.READ_AUDIT,
)

Insufficient role returns 403 with error: "forbidden" and a details object carrying have and need; need names the tier that owns the action (auditor for audit reads), in the same vocabulary as this page. A user with no membership on the project at all gets 404 not_found instead, byte-identical to the answer for an unknown slug, so the 403/404 split cannot be used to enumerate project slugs. The frontend hides actions the user cannot perform, but UI hiding is polish only -- the backend is authoritative.

Audit endpoint reads require auditor or admin, not viewer and not operator: audit data can reveal who did what when, which is itself sensitive, and the operators whose actions it records are kept out of it by design. The cross-project activity feed serves the same rows behind the same bar: a project appears in a user's feed only where they hold auditor or admin, never through a viewer or operator membership.

The brain refuses to:

  • Demote the last admin of a project (PATCH membership).
  • Delete the membership row of the last admin (DELETE membership).

Both return 409 with error: "conflict" and a message naming the last-admin rule. To rotate the last admin, add a new admin first.

Admins can invite users to a project at a specific role:

  1. Dashboard, project Settings, Members, Invite (or POST /api/v1/projects/{slug}/invitations).
  2. The response always carries the plaintext token (shown once) and the accept link, plus email_sent: true when the project's email notification channel delivered the link to the invitee, false when no email channel is configured or the send failed, in which case relay the link out-of-band.
  3. Invitee opens the link, fills in display name and password, and POST /api/v1/invitations/accept materialises their user + membership in one step.
  4. The token TTL defaults to 7 days (ttl_days, bounded 1 to 30 server-side). Single-use; reuse returns 404 with error: "not_found" and the message invalid_or_expired, the same answer a token that never existed gets.

A user can belong to multiple projects. The UI shows a project switcher in the sidebar. Each agent token is bound to one project; an agent only ever sees the project it was minted for.

Every membership change writes an audit log entry (membership.granted, membership.updated, membership.revoked), and accepting an invitation writes invitation.accept. Those rows are HMAC-chained, which makes edits and deletions evident to anything writing through z4j. The chain is not append-only against the database itself: a role with write access to the audit tables can still rewrite history. See audit log and HMAC audit chain for what that does and does not cover.