feat(login): synchronize instance roles for Zitadel identity provider (#12568)

# Which Problems Are Solved

- Logins through the new login via a Zitadel identity provider with
`instanceRolesInfo` configured (e.g. support access) create/update the
user,
but the instance member roles from the
`urn:zitadel:iam:org:project:roles`
claim are never assigned — that synchronization only exists in the login
v1
  flow. Support users therefore end up without any permissions.

# How the Problems Are Solved

- New `syncInstanceRolesFromIdpIntent` in the login, called after user
auto-creation and on existing-user logins. It mirrors the v1 filtering:
  roles are only honored for ZITADEL IdPs with `instanceRolesInfo`, when
granted in a configured organization (matched on ID and domain) and
using an
instance role key (`IAM_` prefix). Memberships are written merge-only
via
the v2 `InternalPermissionService` (existing roles are never removed),
and a
  failed sync logs a warning without blocking the login.

# Additional Changes

- Unit tests for the claim-to-role filtering.

# Additional Context

- Part of the "ZITADEL as an Identity Provider" epic: #5127
- Follow-up for PR #xxx (ZITADEL provider sign-in button in the new
login)
This commit is contained in:
Max Peintner
2026-08-12 09:25:12 +02:00
committed by GitHub
parent 11912ac749
commit 790244dd49
6 changed files with 278 additions and 1 deletions
+9
View File
@@ -34,6 +34,7 @@ import { getTranslations } from "next-intl/server";
import { headers } from "next/headers";
import { getFingerprintIdCookie } from "../fingerprint";
import { createNewSessionFromIdpIntent } from "./idp";
import { syncInstanceRolesFromIdpIntent } from "./instance-roles";
const logger = createLogger("idp-intent");
@@ -457,6 +458,10 @@ async function handleUserExists(ctx: IDPHandlerContext): Promise<IDPHandlerResul
}
}
// Synchronize instance member roles for ZITADEL IdPs with instanceRolesInfo
// configured (e.g. support access). Merge-only and never blocks the login.
await syncInstanceRolesFromIdpIntent({ serviceConfig, intent: ctx.intent, userId });
// Create session and handle redirect
logger.debug("Creating session for existing user");
const sessionResult = await createNewSessionFromIdpIntent({
@@ -650,6 +655,10 @@ async function handleAutoCreation(ctx: IDPHandlerContext): Promise<IDPHandlerRes
const newUser = await createUser({ serviceConfig, request: createUserRequest });
logger.info("User auto-created successfully, creating session");
// Synchronize instance member roles for ZITADEL IdPs with instanceRolesInfo
// configured (e.g. support access). Merge-only and never blocks the login.
await syncInstanceRolesFromIdpIntent({ serviceConfig, intent, userId: newUser.id });
// Create session for newly created user
const sessionResult = await createNewSessionFromIdpIntent({
userId: newUser.id,
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { instanceRolesFromClaim } from "./instance-roles";
const CLAIM = "urn:zitadel:iam:org:project:roles";
const rolesInfo = [{ organizationId: "org-1", organizationDomain: "support.example.com" }];
describe("instanceRolesFromClaim", () => {
it("returns roles granted in a configured organization", () => {
const raw = { [CLAIM]: { IAM_OWNER_VIEWER: { "org-1": "support.example.com" } } };
expect(instanceRolesFromClaim(raw, rolesInfo)).toEqual(["IAM_OWNER_VIEWER"]);
});
it("returns multiple matching roles sorted deterministically", () => {
const raw = {
[CLAIM]: {
IAM_OWNER_VIEWER: { "org-1": "support.example.com" },
IAM_ORG_MANAGER: { "org-1": "support.example.com" },
},
};
expect(instanceRolesFromClaim(raw, rolesInfo)).toEqual(["IAM_ORG_MANAGER", "IAM_OWNER_VIEWER"]);
});
it("ignores roles granted in an unconfigured organization", () => {
const raw = { [CLAIM]: { IAM_OWNER_VIEWER: { "other-org": "other.example.com" } } };
expect(instanceRolesFromClaim(raw, rolesInfo)).toEqual([]);
});
it("requires both organization id and domain to match", () => {
const wrongDomain = { [CLAIM]: { IAM_OWNER_VIEWER: { "org-1": "evil.example.com" } } };
const wrongOrg = { [CLAIM]: { IAM_OWNER_VIEWER: { "org-2": "support.example.com" } } };
expect(instanceRolesFromClaim(wrongDomain, rolesInfo)).toEqual([]);
expect(instanceRolesFromClaim(wrongOrg, rolesInfo)).toEqual([]);
});
it("ignores roles without the IAM_ prefix", () => {
const raw = {
[CLAIM]: {
PROJECT_OWNER: { "org-1": "support.example.com" },
SUPPORT_HERO: { "org-1": "support.example.com" },
},
};
expect(instanceRolesFromClaim(raw, rolesInfo)).toEqual([]);
});
it("matches when any of multiple configured organizations grants the role", () => {
const info = [
{ organizationId: "org-1", organizationDomain: "support.example.com" },
{ organizationId: "org-2", organizationDomain: "second.example.com" },
];
const raw = { [CLAIM]: { IAM_OWNER_VIEWER: { "org-2": "second.example.com" } } };
expect(instanceRolesFromClaim(raw, info)).toEqual(["IAM_OWNER_VIEWER"]);
});
it("returns empty for a missing or malformed claim", () => {
expect(instanceRolesFromClaim(undefined, rolesInfo)).toEqual([]);
expect(instanceRolesFromClaim(null, rolesInfo)).toEqual([]);
expect(instanceRolesFromClaim({}, rolesInfo)).toEqual([]);
expect(instanceRolesFromClaim({ [CLAIM]: "not-an-object" }, rolesInfo)).toEqual([]);
expect(instanceRolesFromClaim({ [CLAIM]: ["IAM_OWNER_VIEWER"] }, rolesInfo)).toEqual([]);
expect(instanceRolesFromClaim({ [CLAIM]: { IAM_OWNER_VIEWER: "org-1" } }, rolesInfo)).toEqual([]);
expect(instanceRolesFromClaim({ [CLAIM]: { IAM_OWNER_VIEWER: { "org-1": 42 } } }, rolesInfo)).toEqual([]);
});
});
+181
View File
@@ -0,0 +1,181 @@
import { createLogger } from "@/lib/logger";
import { createServiceForHost } from "@/lib/service";
import { getIDPByID, getInstanceId, ServiceConfig } from "@/lib/zitadel";
import { Client, Code, ConnectError } from "@zitadel/client";
import type { InstanceRolesInfo } from "@zitadel/proto/zitadel/idp/v2/idp_pb";
import { InternalPermissionService } from "@zitadel/proto/zitadel/internal_permission/v2/internal_permission_service_pb";
const logger = createLogger("instance-roles");
const ZITADEL_PROJECT_ROLES_CLAIM = "urn:zitadel:iam:org:project:roles";
const IAM_ROLE_PREFIX = "IAM_";
type SyncParams = {
serviceConfig: ServiceConfig;
intent: {
idpInformation?:
| {
idpId: string;
rawInformation?: unknown;
}
| undefined;
};
userId: string;
};
/**
* Extracts the instance member roles a user should hold from the ZITADEL
* project-roles claim ({role: {orgId: orgDomain}}), honoring only roles that
* were granted in one of the organizations configured in the ZITADEL IdP's
* instanceRolesInfo (matched on organization id AND domain) and that use an
* instance role key (IAM_ prefix).
*
* Mirrors the filtering of the login v1 flow (external_provider_handler.go).
*/
export function instanceRolesFromClaim(
rawInformation: unknown,
rolesInfo: Pick<InstanceRolesInfo, "organizationId" | "organizationDomain">[],
): string[] {
const raw = rawInformation as Record<string, unknown> | undefined | null;
const claim = raw?.[ZITADEL_PROJECT_ROLES_CLAIM];
if (!claim || typeof claim !== "object" || Array.isArray(claim)) {
return [];
}
const roles: string[] = [];
for (const [role, orgs] of Object.entries(claim as Record<string, unknown>)) {
if (!role.startsWith(IAM_ROLE_PREFIX)) {
continue;
}
if (!orgs || typeof orgs !== "object" || Array.isArray(orgs)) {
continue;
}
const granted = Object.entries(orgs as Record<string, unknown>).some(
([orgId, orgDomain]) =>
typeof orgDomain === "string" &&
rolesInfo.some((info) => info.organizationId === orgId && info.organizationDomain === orgDomain),
);
if (granted) {
roles.push(role);
}
}
// The claim is a map, so sort to keep the resulting role set deterministic
// (mirrors the login v1 flow).
return roles.sort();
}
/**
* Synchronizes instance member roles after a login via a ZITADEL identity
* provider that has instanceRolesInfo configured (e.g. the support-access IdP):
* roles from the token's project-roles claim are added to the user's instance
* membership. Merge-only — roles the user already holds are never removed.
*
* Never throws: role synchronization must not block the login; a failed sync
* is retried implicitly on the next login.
*/
export async function syncInstanceRolesFromIdpIntent({ serviceConfig, intent, userId }: SyncParams): Promise<void> {
const idpId = intent.idpInformation?.idpId;
// Tracked outside the try so failure logs can state which roles were being
// granted. Deliberately never log rawInformation - it carries profile PII;
// the filtered role keys are all support needs.
let attemptedRoles: string[] = [];
try {
if (!idpId || !userId) {
return;
}
const idp = await getIDPByID({ serviceConfig, id: idpId });
const config = idp?.config?.config;
if (config?.case !== "zitadel") {
return;
}
const rolesInfo = config.value.instanceRolesInfo;
if (!rolesInfo?.length) {
return;
}
// Instance-wide role grants may only originate from an instance-scoped IdP.
// An organization-scoped provider must never confer instance membership, even
// if its instanceRolesInfo is somehow populated (e.g. stale data): honoring it
// would let an org owner escalate themselves to instance-level roles.
// Mirrors the login v1 guard (external_provider_handler.go) and fails closed.
const instanceId = await getInstanceId({ serviceConfig });
if (!instanceId || idp?.details?.resourceOwner !== instanceId) {
logger.warn("Instance role sync skipped: IdP with instanceRolesInfo is not instance-scoped", { idpId });
return;
}
const grantedRoles = instanceRolesFromClaim(intent.idpInformation?.rawInformation, rolesInfo);
if (!grantedRoles.length) {
return;
}
attemptedRoles = grantedRoles;
const permissionService: Client<typeof InternalPermissionService> = await createServiceForHost(
InternalPermissionService,
serviceConfig,
);
const instanceResource = { resource: { case: "instance" as const, value: true } };
// The claim originates from an external instance whose role set may differ
// from this one. Unlike login v1 (which drops unknown role keys and grants
// the rest), the v2 API rejects the ENTIRE write if any key is unknown -
// in that case no roles are granted; see the error handling below.
const { administrators } = await permissionService.listAdministrators(
{
filters: [
{ filter: { case: "inUserIdsFilter", value: { ids: [userId] } } },
{ filter: { case: "resource", value: { resource: { case: "instance", value: true } } } },
],
},
{},
);
const existing = administrators?.find((administrator) => administrator.resource?.case === "instance");
if (!existing) {
await permissionService.createAdministrator({ userId, resource: instanceResource, roles: grantedRoles }, {});
logger.info("Added instance administrator from ZITADEL IdP roles", { userId, roles: grantedRoles });
return;
}
// Merge-only: retain roles granted elsewhere, never remove any.
// Known limitation: the v2 API has no merge-only write, so this is a
// read-modify-write - ListAdministrators reads from the projection and
// UpdateAdministrator replaces the full role set. A role granted elsewhere
// between read and write is lost by the replace, and projection lag widens
// that window. This is weaker than v1, which merges inside the command
// layer on the eventstore (EnsureInstanceMemberRolesFromLogin) and has no
// such window. We accept this: the window is small, claim-derived roles
// are restored on the next login, and the login must never block on
// membership writes. If a second consumer of role sync shows up, that is
// the point to revisit (ideally by moving the sync into core).
const merged = Array.from(new Set([...existing.roles, ...grantedRoles]));
if (merged.length === existing.roles.length) {
return;
}
attemptedRoles = merged;
await permissionService.updateAdministrator({ userId, resource: instanceResource, roles: merged }, {});
logger.info("Updated instance administrator from ZITADEL IdP roles", { userId, roles: merged });
} catch (error) {
// Never block the login, but keep permanent failures diagnosable: a
// silently skipped sync manifests as "support user has no permissions".
const context = { userId, idpId, roles: attemptedRoles };
if (error instanceof ConnectError && error.code === Code.PermissionDenied) {
logger.error(
"Instance role sync failed permanently: service user lacks permission to manage instance administrators (iam.member.write)",
{ ...context, code: error.code, message: error.message },
);
return;
}
if (error instanceof ConnectError && error.code === Code.InvalidArgument) {
logger.error(
"Instance role sync failed permanently: administrator write rejected, likely a role key unknown to this instance",
{ ...context, code: error.code, message: error.message },
);
return;
}
logger.warn("Could not synchronize instance roles from IdP intent", { ...context, error });
}
}
+3 -1
View File
@@ -1,5 +1,6 @@
import { createClientFor } from "@zitadel/client";
import { IdentityProviderService } from "@zitadel/proto/zitadel/idp/v2/idp_service_pb";
import { InternalPermissionService } from "@zitadel/proto/zitadel/internal_permission/v2/internal_permission_service_pb";
import { OIDCService } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb";
import { OrganizationService } from "@zitadel/proto/zitadel/org/v2/org_service_pb";
import { SAMLService } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb";
@@ -17,7 +18,8 @@ type ServiceClass =
| typeof SessionService
| typeof OIDCService
| typeof SettingsService
| typeof SAMLService;
| typeof SAMLService
| typeof InternalPermissionService;
export async function createServiceForHost<T extends ServiceClass>(service: T, serviceConfig: ServiceConfig) {
let token;
+20
View File
@@ -154,6 +154,26 @@ export async function getBrandingSettings({
);
}
/**
* Resolves the ID of the instance the login is serving. The default login
* settings (queried without organization context) are always owned by the
* instance, so the response details carry the instance ID. Cached per
* instance like the other settings lookups.
*/
export async function getInstanceId({ serviceConfig }: WithServiceConfig) {
const fetcher = async () => {
const settingsService: Client<typeof SettingsService> = await createServiceForHost(SettingsService, serviceConfig);
return settingsService.getLoginSettings({ ctx: makeReqCtx(undefined) }, {}).then((resp) => resp.details?.resourceOwner);
};
return freshCache(
instanceCacheKey(serviceConfig, "getInstanceId"),
fetcher,
getTTLForKey("getLoginSettings", defaultCacheTTL),
);
}
export async function getLoginSettings({
serviceConfig,
organization,
+2
View File
@@ -1,6 +1,7 @@
import { create } from "@bufbuild/protobuf";
import { FeatureService } from "@zitadel/proto/zitadel/feature/v2/feature_service_pb.js";
import { IdentityProviderService } from "@zitadel/proto/zitadel/idp/v2/idp_service_pb.js";
import { InternalPermissionService } from "@zitadel/proto/zitadel/internal_permission/v2/internal_permission_service_pb.js";
import { RequestContextSchema } from "@zitadel/proto/zitadel/object/v2/object_pb.js";
import { OIDCService } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb.js";
import { OrganizationService } from "@zitadel/proto/zitadel/org/v2/org_service_pb.js";
@@ -19,6 +20,7 @@ export const createSAMLServiceClient = createClientFor(SAMLService);
export const createOrganizationServiceClient = createClientFor(OrganizationService);
export const createFeatureServiceClient = createClientFor(FeatureService);
export const createIdpServiceClient = createClientFor(IdentityProviderService);
export const createInternalPermissionServiceClient = createClientFor(InternalPermissionService);
export function makeReqCtx(orgId: string | undefined) {
return create(RequestContextSchema, {