mirror of
https://github.com/zitadel/zitadel.git
synced 2026-08-17 16:35:14 -05:00
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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user