"Example: auto-grant opes:access (Entra IDP migration)"
Port the v1 prefilRegisterFromEntraId auto-grant to a v2 user.human.added Event.
The v1 prefilRegisterFromEntraId script does two things:
- Maps SAML attributes from Entra IDP into Zitadel user fields
- Auto-grants
opes:accesson opes-hr so the new user can sign into opes
The v2 migration splits these:
- (1) SAML attribute mapping moves to Zitadel's native IDP attribute mapping in the Console (no code) — search docs for "External IDP claims" under Default Settings → Identity Providers
- (2) Auto-grant becomes this script:
Save in /admin/actions:
- Trigger type: EVENT
- Trigger name: user.human.added
// Auto-grant opes:access on every new user creation.
// Idempotent: safely re-runnable (checks existing grants first).
const OPES_HR_PROJECT_ID = '371501187121396417';
const GATE_ROLE = 'opes:access';
const userId = ctx.payload.aggregateID ?? ctx.payload.userID;
if (!userId) {
ctx.log.warn('no userId in event_payload', { payload: ctx.payload });
return null;
}
const grants = await ctx.zitadel.users.getGrants(userId);
const alreadyHas = grants.some(
(g) => g.projectId === OPES_HR_PROJECT_ID && g.roleKeys.includes(GATE_ROLE),
);
if (alreadyHas) {
ctx.log.info('user already has opes:access; skipping', { userId });
return null;
}
await ctx.zitadel.users.appendGrant(userId, OPES_HR_PROJECT_ID, [GATE_ROLE]);
ctx.log.info('granted opes:access', { userId });
return { granted: true };
Difference from v1: scope
The v1 script gates on ctx.v1.externalUser.externalIdpId === "261432977777494558" (only Entra-sourced users). The v2 Event fires for all new users — including console-created and SAML-sourced. Decision point:
- If all OMMAX users SHOULD have opes:access (current effective policy): no gating needed; this v2 version is correct as-is
- If you want to keep Entra-only: add a check via
ctx.zitadel.users.get(userId)to inspect IDP links — but this requires the user-created event to carry that info, which it may not
OMMAX-effective policy is "everyone gets it" today, so the v2 version above is appropriate.
Cutover plan
- Configure SAML attribute mapping in Zitadel UI (Default Settings → Identity Providers → Entra IDP) — this replaces the firstname/lastname/email/department-setting branch of v1
- Save the script above as DRAFT, promote to ACTIVE
- Wire Execution: condition
event: { event: "user.human.added" }→ Targetatrium-actions-events - Test by adding a fresh user in Zitadel — verify the event fires and the script appends the grant
- Disable v1
prefilRegisterFromEntraIdin Zitadel Console - Edge case: any users created during the cutover window may end up missing opes:access. Run a backfill query if needed.
Why this lives in Atrium and not in Zitadel-adjacent infra
Unlike flatRoles (which fires on every token issue and is latency-critical), this fires once per new hire (~weekly). Atrium's runtime is the right home — Atrium being slow doesn't break sign-ins, just delays the welcome grant by minutes.