Documentation menu

Members and RBAC

The role model — who can do what, where it's enforced, where the seams are. Includes the in-app invite flow, the role-tiered MCP catalog, and the email notification on grant.

Atrium uses Zitadel role grants on the Atrium project as its source of authentication AND authorization. Each role gates a different slice of the admin surface and the MCP catalog. Two of them are virtual — bound per-resource in Atrium's own DB rather than granted in Zitadel.

The full role × capability matrix renders live at /admin/members, backed by lib/permissions-matrix.ts. This page is the prose version + the bits that aren't in the matrix (invite flow, MCP tier, email notifications).

The roles

RoleZitadel keyBluntly
Super adminatrium:super_adminAnything. Manages other roles. The blast-radius role — keep the count small.
Adminatrium:adminManage apps, groups, secrets, scripts, scim. No role grants, no project re-mapping, no app delete.
App publisheratrium:app_publisherApproves apps for rollout — flips a self-registered app from SECRET to discoverable. Nothing else: no groups, secrets, scripts or members. Designed to be granted via a group — see Publishing apps.
App owner(virtual — per-app, via AtriumAppAdmin)Edit basics on apps you own — name, description, environments, contacts, where-hosted. No category, visibility, or Zitadel changes.
Group owner(virtual — per-group, via AtriumGroupAdmin)Manage the groups you own — members, co-owners, child groups. No new top-level groups, no app changes.
Script authoratrium:script_authorWrite/edit Atrium Actions scripts. No app or member access.
Useratrium:userThe default. Sees the launcher; can request access to apps; can mint personal API keys + use the MCP user-tier.

(The atrium:auditor role from earlier docs has been dropped — collapsed into Admin. There's no longer a "look but don't touch" tier.)

app_owner and group_owner are virtual roles — they are not Zitadel roles. A DB table maps users to specific resources (one row per (app, user) or (group, user) pair), so the grant is per-resource rather than instance-wide. Registering an app auto-adds the creator as its first owner.

Everything else in the table IS a real Zitadel role key, and that distinction is load-bearing: only a real key can ride a group grant. Because Atrium is itself a registered app, granting a group the atrium app with a role key gives every member of that group the role — so atrium:app_publisher can be handed to the leadership group instead of person by person.

Per-capability matrix

Rendered live at /admin/members → "What each role can do". Cells:

  • ✓ allowed
  • ⚠ "own" — allowed for resources you specifically own
  • − denied
Capabilitysuperadminownerscriptsuser
View admin pages
Register a new app
Edit name, description, env URLs, contact
Change category or card visibility
Re-map Zitadel project
Delete an app
Manage groups + grants
Read / write secrets
Write Atrium Actions scripts
Trigger scripts
Mint personal API keys
Grant / revoke roles
MCP — see directory tools✓ (user-tier subset)
MCP — see admin tools

Two clarifications worth flagging:

  • API keys are personal and any signed-in user can mint them (the route is POST /api/admin/api-keys despite the path; auth is "any session/key", not super-admin). The key inherits the minter's current rolesSnapshot at every call, so losing admin in Zitadel immediately downgrades the key's catalog. Scoped keys have landed: they carry no roles at all and do only what a scope grants, which is a different model from role inheritance. A legacy fallback still promotes an unscoped key with an empty rolesSnapshot to super-admin, and it is slated for removal rather than something to rely on. Full detail, including the two brokers and their fail-closed guards, is on API keys.
  • MCP catalog is role-tiered. A user-tier MCP key sees list_apps, get_app, list_my_groups, request_app_access. An admin key adds the curation surface. A super-admin key adds the sensitive infra. See MCP for the full per-tier list.

Where each gate is enforced

Defense in depth — page-level + route-level + service-level:

  • Page (/admin/*) — the React layer hides forbidden affordances. Auditor/owner-only views render but mutation buttons disappear.
  • Route (/api/admin/*, /api/groups/*, /api/apps/*) — re-checks the role on every call. The PATCH route on apps takes a bag of fields and silently drops anything the caller can't change (returned in droppedFields for debugging). A stale or malicious client can't bypass the UI.
  • Service (src/lib/admin/*-service.ts) — auth-agnostic. The route applies RBAC, the service runs the write. Same service is reused by the AI agent tools, so the agent path goes through identical validation and audit.

Inviting someone — the actual UX path

/admin/members → search user → pick role(s) → Grant. The flow:

  1. POST /api/admin/admins is super-admin gated.
  2. auditedCreateUserGrant lays a Zitadel user_grant on the Atrium project. AtriumZitadelGrantAuditLog records source = 'admin:members.roles.grant' — this is what makes the grant direct-admin in grant provenance, so the reconciler will never auto-revoke it even if the invitee later joins/leaves groups.
  3. sendRoleGrantedEmail() fires fire-and-forget. The recipient gets a welcome email naming the inviter + listing the granted roles + a CTA to /admin. If the invitee has never signed in to Atrium, the trigger falls back to Zitadel for their email + display name (getUserById), so brand-new invitees still get the email.
  4. The recipient signs in (or back in) — their next session token carries the new role; the gate flips on next page load.

During the rollout window, set ATRIUM_EMAIL_BCC=you@example.com (see runbook) to silently get a copy of every notification email so you can verify the flow without nagging recipients with an explicit cc.

Today's enforcement reality

Read this before treating the matrix as a security boundary.

Only super_admin is enforced server-side today. Every other row in the matrix is intent: it documents how the system is designed to behave, and it drives what the UI offers, but the server does not yet enforce it uniformly. The matrix is kept stable precisely so the UI does not have to change when the server catches up.

That does not make the other roles decorative. Several paths do enforce beyond super-admin, and they are the ones that carry real risk:

  • Admin-gated API routes (requireAdminApi) genuinely refuse non-admins.
  • Script-author routes gate script writes.
  • Per-resource ownership is asserted inside the service and tool layers, so an app owner acting on their app is checked against that specific app, not against a role tier. The AI agent page explains this two-layer model in detail, and the same shape applies to the REST routes.
  • The apps PATCH route drops fields above the caller's tier and reports them in droppedFields.

What you should not assume is that granting atrium:admin produces correctly-scoped access on every gated route, or that a matrix cell reading "no" is a server-side refusal everywhere. Where a distinction matters for security, verify it against the route rather than the table.

Production has multiple super_admin and admin grants on the Atrium project, plus per-app app_owner rows.

Drift surface: /admin/apps/[slug] shows a banner when there are direct-admin or external grants on the app. See groups for the provenance contract behind it.