Skip to content

RBAC

Role Summary
admin Everything below, plus mint and revoke agents, invite users, manage memberships, read the audit log, purge a queue, bulk-delete tasks, and create, edit, or delete schedule definitions.
operator Everything below, plus issue commands (retry, cancel, bulk-retry, restart a worker, resize a pool, add or cancel a consumer, set a per-task rate limit) and control existing schedules (enable, disable, trigger, pause, or resume).
viewer Read ordinary project data: list tasks, view events, read schedules, and read agents. Sensitive surfaces such as the audit log remain admin-only.

Roles are project-scoped: you can be admin on one project and viewer 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.

Every API route resolves (user, project) -> role and calls into the policy engine:

await policy.require_member(
memberships,
user=user,
project=project,
min_role=ProjectRole.OPERATOR,
)

Insufficient role returns 403 with error: "forbidden" and a details object carrying have and need. 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 admin, not viewer: audit data can reveal who did what when, which is itself sensitive.

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, Memberships, Invite (or POST /api/v1/projects/{slug}/invitations).
  2. If the project has an active email notification channel, the link is sent automatically; otherwise the response surfaces the link for out-of-band delivery.
  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 top bar. 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.