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 adds the requester to a group, and the reconciler projects the real grant into the IdP. Every grant therefore stays owned by a group, revocable by removing membership and auditable through the group, 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
The admin picks a target group. There is deliberately no "grant directly" option.
| Approval mode | What happens |
|---|---|
| Existing group | Add the requester to a group that already grants the app |
| New group | Last resort. Mint other-<app> (or other-<app>-<roles> when approving with specific roles) and add the requester |
The picker is populated with 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.
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.
Adding the member 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.
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, remove the person from the group they were added to. Membership is the source of truth, so the reconciler revokes the grant on the next pass. 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