Access requests
The end-to-end lifecycle of a user-initiated app access request, why approval always goes through a group, and why decisions are one-way.
A user who can see an app but not open it gets a one-click Request access button. This page is the full lifecycle behind that button.
The single most important property: approval never lays a direct per-user grant. It always edits a group — either putting the requester in one, or granting the app to one they are already in — and the reconciler projects the real grant into the IdP. Every grant therefore stays owned by a group, revocable and auditable through it, instead of leaving orphan grants that only a hand-edit in Zitadel can undo.
The lifecycle
| Step | Surface | Who |
|---|---|---|
| Request | POST /api/apps/<appId>/request-access | Any signed-in user |
| Review | /admin/access-requests and /admin/access-requests/<id> | Admin |
| Approve | POST /api/admin/access-requests/<id>/approve | Admin |
| Deny | POST /api/admin/access-requests/<id>/deny | Admin |
The request route authenticates by session only: there is no API-key path onto it. The approve and deny routes are likewise session-gated admin routes.
An optional message from the requester is accepted and capped at 1000
characters.
Requesting is deduplicated
A second call from the same user for the same app, while a request is already
PENDING, returns the existing row and reports alreadyPending: true. It
does not create a second row and it does not re-send the notification. Admins get
one notification per request, not one per click.
On first creation, Atrium emails the app's admin with a Review request CTA
deep-linking to /admin/access-requests/<id>. If no email address can be
resolved for that admin the request is still recorded; only the notification is
skipped.
Users can also file a request through the AI agent or
MCP with the user-tier request_app_access tool, which goes through the
same service and the same deduplication.
Approving
Access is a join: a person is in a group, and a group grants an app. The person can open the app when any group containing them grants it. So there are two honest ways to say yes, and the screen offers both columns:
| Approval mode | What happens | Who it affects |
|---|---|---|
| Existing group | Add the requester to a group that already grants the app | The requester only |
| Grant group | Grant the app to a group the requester is already in | Every member of that group |
| New group | Last resort. Mint other-<app> (or other-<app>-<roles> when approving with specific roles) and add the requester | The requester only |
Neither of the first two is more correct in general. Which one is right depends on whether this person is joining a team that has the app, or whether their existing team should have had the app all along, and only the reviewer knows which. Offering only the first forced a reviewer whose real answer was the second to either put the person in a group they do not belong to, or create a group of one.
Both columns end in the same sentence, restated above the confirm button so the reviewer is picking a route, not an outcome.
Left column. Every group that already grants the requested app, each annotated with the role keys it grants, its member count, whether the requester is already a member, and whether the group is directory-managed.
That last flag matters: a group whose roster is mirrored from the directory refuses manual member adds, so it can never be a valid approval target. Offering it would only ever produce a failed approval, which is exactly what used to surface as an unexplained "approval failed" message.
Right column. Every group the requester is already in. "Already in" is transitive, because grants are: membership flows upward through the nesting DAG, so an ancestor of a group the requester sits in is also a valid target, and is usually the one a reviewer means. Ancestor rows say which of the requester's own groups leads there.
A group that already grants the app is shown disabled with the reason rather than hidden, so the reviewer can see it was considered.
Blast radius
The right column is the only place on this screen where approving gives an app to people who did not ask for it. Each option therefore states its reach up front: how many people would gain the app, and their names when the list is short enough to read. An approve screen must not quietly widen access beyond the person it is about.
The count is effective membership, not the direct roster: direct members plus everyone in nested subgroups, de-duplicated by person. See Groups for how that number is computed.
The service refuses a grant-group approval for a group that does not actually
contain the requester. Without that check a mistyped group id would hand the app
to a group of strangers and leave the requester still locked out.
Order of operations
The target group is resolved before the request is marked APPROVED. A bad
target, a missing group or a group that does not actually grant the app, leaves
the request PENDING so the admin can correct it rather than stranding it in a
decided state with nothing provisioned.
The membership add (or, for a Grant group approval, the grant write) is the commit point. From there the reconciler projects the Zitadel grant, and it self-heals on later ticks if the immediate write hiccups. That resilience is the whole reason approvals go through groups.
A Grant group approval never touches the group's roster — the requester is already in it, so adding them would be wrong.
Two identity details worth knowing:
- The membership row carries the requester's numeric Zitadel id. A legacy directory UUID is canonicalised at approval time, because the reconciler grants against the numeric id and would otherwise 404.
- The app must have a Zitadel project. Approving an app without one fails explicitly rather than creating a grant that cannot exist.
Re-approving a request for someone who is already a member is a no-op membership plus a reconcile that finds nothing to change.
Denying
Denial records the deciding admin, the timestamp, and an optional reason capped at 1000 characters. The reason is included in the email the requester receives.
Decisions are one-way
Approving or denying a request that is already APPROVED or DENIED is
rejected. There is no un-approve and no re-decide: the row is the permanent
record of who asked, who decided, when, and why not.
To reverse the effect of an approval, undo the edge it created: remove the person from the group they were added to, or remove the app from the group it was granted to. The group state is the source of truth, so the reconciler revokes the grant on the next pass.
Note the asymmetry — reversing a Grant group approval takes the app away from everyone in that group, not just the requester. To reverse a denial, the user files a new request.
Notifications
| Event | Recipient | Trigger |
|---|---|---|
| Request created | The app's admin | Only on first creation, never on a duplicate click |
| Approved | The requester | Fires after the membership lands |
| Denied | The requester | Includes the denial reason when one was given |
Outcome emails are best-effort: a failure to send does not roll back the membership or the decision. They are transactional in kind, so they are never suppressed by broadcast filtering.
Delivery goes through whichever notifications connector is marked primary.
Related
- Groups for the grant-provenance contract the reconciler honours
- Members and RBAC for who can approve
- Publishing apps for the separate question of app visibility