Documentation menu

Groups

How Atrium groups map to Zitadel grants, the reconciler, and drift detection.

Atrium groups are an organizational layer above Zitadel grants. They don't exist in Zitadel — Zitadel only knows about per-user grants. Atrium owns the group definition, owns the membership, and reflects each membership change as Zitadel mgmt-API calls.

The data model

AtriumGroup       (id, name, description)
   ├── members    AtriumGroupMember (groupId, userId)
   └── grants     AtriumGroupGrant  (groupId, appId, roleKeys[])

A group with two grant mappings says "every member of this group should have these role keys on these apps". Adding a member fans out to N Zitadel createUserGrant calls — one per mapped app.

Why not Zitadel-native groups?

Zitadel has no native groups primitive. The two adjacent abstractions both fail this use case:

  • User metadata — per-user K/V. You could stuff groups: "engineering,oncall" on each user, but you lose atomic group operations, audit trail, descriptions, and apps would have to parse the metadata claim themselves to derive access.
  • Org units (Zitadel Organizations) — those are for multi-tenancy. Spinning up an org per group means duplicate user lists and breaking every role assignment. Wrong shape.

So Atrium owns the abstraction and reflects to grants. The reconciler keeps both sides aligned.

Reconciliation

Defined in lib/groups-reconciler.ts. These operations trigger it:

TriggerEffect
Add a member to a groupReconcile that user across all their groups (idempotent)
Remove a memberSame — reconcile across remaining group set
Change a group's grant mappings (PUT /api/groups/:id/grants)Reconcile every member of that group
Delete a groupStrip mappings, reconcile every member, then delete the row
Cron sweep (env ATRIUM_RECONCILE_CRON=apply / dryRun)Reconcile every user in any group every 6h. See runbook for env.

The reconciler runs in two passes:

Pass 1 — additive. For each project the user's groups expect, ensure the grant exists with the union of all required role-keys. Existing role-keys we didn't add are left alone (could be from another source: Zitadel Console manual add, app-owner direct share, SCIM). Atrium never removes individual role-keys it didn't add.

Pass 2 — orphan handling, provenance-gated. For each Zitadel grant on an "Atrium-managed project" that no group currently justifies:

  • Look up the grant's grantId in AtriumZitadelGrantAuditLog. The most recent successful ADD row tells us how it got there.
  • If the source is group-derived (created by a group operation: groups:*, runtime:groups.*, scheduler:groups-*, admin:groups.*, agent:add_group_member, agent:grant_group_app_role, …) → revoke. Atrium created it for a group that no longer covers this user.
  • If the source is direct-admin (admin:members.roles.grant, admin:access-request:approve, admin:apps.access.grant) → drift. Persistent admin decision; reconciler must NOT auto-revoke.
  • If there's no audit row at all (manual via Zitadel Console / SCIM / pre-audit-log) → drift. External hoheit; reconciler keeps hands off.

A project is "Atrium-managed" iff at least one AtriumGroupGrant references its app.

This provenance gate closes a real bug from the pre-fix era: any direct-admin grant (e.g. an approved access request, an atrium:admin grant via /admin/members) on an app that happened to be covered by some group was getting silently revoked at the next reconciler tick if the recipient was in any unrelated group. With the gate in place, only grants Atrium owns via the group system are revoke-eligible.

Source classification is centralised in lib/zitadel/grant-provenance.ts — the audit-log table is the provenance store (Zitadel's user_grant resource has no metadata API, so per-grant provenance has to live Atrium-side).

Drift detection + surface

Two surfaces:

Per-app drift banner on /admin/apps/[slug]. Renders only when there are direct-admin or external grants on the app's project. Two grouped lists:

  • Added directly via admin — recommendation: consider migrating to a group if recurring.
  • External / manual — recommendation: review and either group-ify or revoke directly in Zitadel.

Backed by GET /api/admin/apps/:slug/drift which classifies every grant on the project via the same provenance lookup.

Cron-sweep diff log. Each scheduled sweep (scheduler:groups-reconcile) logs counts:

[atrium:scheduler] groups-reconcile mode=apply dryRun=false
  total=42 interesting=5
  add=2 update=1 revoke=1 drift=1

drift=N is the count of grants that would have been revoked under the old behaviour but were correctly preserved as direct-admin / external.

The diff returned to callers has these row kinds:

  • add — user is in a group expecting project P; no Zitadel grant exists. Reconciler will create it.
  • update — Zitadel grant exists but missing some expected role keys. Reconciler appends them (never removes).
  • revoke — group-derived orphan. Reconciler revokes.
  • drift — non-group-derived grant on a managed project; reconciler logs it and moves on. Surfaced in the per-app banner.
  • in-sync — happy path; not in the diff list, only in counts.

Members UI

/admin → Groups → expand a row:

  • Add member by email. The server resolves email → Zitadel userId via the Atrium-project grants list. Friendly 404 if the email doesn't belong to anyone with an atrium:user grant — they need that grant before they can be a member.
  • Member list shows display names with email subtitle (joined from Zitadel grants). Falls back to truncated user-id if Zitadel is unreachable; the page degrades gracefully.
  • Delete group in the destructive zone at the bottom. Type the group's name to confirm. Reconciler revokes every member's group-derived grants before the row drops.

Limitations

  • No "convert direct-admin grant to group membership" wizard yet. The drift banner surfaces grants that should probably become group memberships, but you have to hand-walk it: pick a group (or create one), add the user, drop the direct grant. Backlog item.
  • Editing a group's grant mappings (apps + role keys) goes through PUT /api/groups/:id/grants with body { mappings: [{ appId, roleKeys }] }. Per-row add/remove via the agent (grant_group_app_role / revoke_group_app_role) is also wired; bulk UI is queued.
  • Renames are Atrium-only (PATCH /api/groups/:id with { name }). No Zitadel call needed since Zitadel never sees the group.
  • Audit-log-based provenance has a corner case: grants Atrium created BEFORE the audit log existed have no ADD row → they classify as manual-external and the reconciler stops touching them. Conservative direction (no data loss), surfaced in the drift banner so admins can review.