mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00: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 { headers } from "next/headers";
|
||||||
import { getFingerprintIdCookie } from "../fingerprint";
|
import { getFingerprintIdCookie } from "../fingerprint";
|
||||||
import { createNewSessionFromIdpIntent } from "./idp";
|
import { createNewSessionFromIdpIntent } from "./idp";
|
||||||
|
import { syncInstanceRolesFromIdpIntent } from "./instance-roles";
|
||||||
|
|
||||||
const logger = createLogger("idp-intent");
|
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
|
// Create session and handle redirect
|
||||||
logger.debug("Creating session for existing user");
|
logger.debug("Creating session for existing user");
|
||||||
const sessionResult = await createNewSessionFromIdpIntent({
|
const sessionResult = await createNewSessionFromIdpIntent({
|
||||||
@@ -650,6 +655,10 @@ async function handleAutoCreation(ctx: IDPHandlerContext): Promise<IDPHandlerRes
|
|||||||
const newUser = await createUser({ serviceConfig, request: createUserRequest });
|
const newUser = await createUser({ serviceConfig, request: createUserRequest });
|
||||||
logger.info("User auto-created successfully, creating session");
|
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
|
// Create session for newly created user
|
||||||
const sessionResult = await createNewSessionFromIdpIntent({
|
const sessionResult = await createNewSessionFromIdpIntent({
|
||||||
userId: newUser.id,
|
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 { createClientFor } from "@zitadel/client";
|
||||||
import { IdentityProviderService } from "@zitadel/proto/zitadel/idp/v2/idp_service_pb";
|
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 { OIDCService } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb";
|
||||||
import { OrganizationService } from "@zitadel/proto/zitadel/org/v2/org_service_pb";
|
import { OrganizationService } from "@zitadel/proto/zitadel/org/v2/org_service_pb";
|
||||||
import { SAMLService } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb";
|
import { SAMLService } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb";
|
||||||
@@ -17,7 +18,8 @@ type ServiceClass =
|
|||||||
| typeof SessionService
|
| typeof SessionService
|
||||||
| typeof OIDCService
|
| typeof OIDCService
|
||||||
| typeof SettingsService
|
| typeof SettingsService
|
||||||
| typeof SAMLService;
|
| typeof SAMLService
|
||||||
|
| typeof InternalPermissionService;
|
||||||
|
|
||||||
export async function createServiceForHost<T extends ServiceClass>(service: T, serviceConfig: ServiceConfig) {
|
export async function createServiceForHost<T extends ServiceClass>(service: T, serviceConfig: ServiceConfig) {
|
||||||
let token;
|
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({
|
export async function getLoginSettings({
|
||||||
serviceConfig,
|
serviceConfig,
|
||||||
organization,
|
organization,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { FeatureService } from "@zitadel/proto/zitadel/feature/v2/feature_service_pb.js";
|
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 { 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 { RequestContextSchema } from "@zitadel/proto/zitadel/object/v2/object_pb.js";
|
||||||
import { OIDCService } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_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";
|
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 createOrganizationServiceClient = createClientFor(OrganizationService);
|
||||||
export const createFeatureServiceClient = createClientFor(FeatureService);
|
export const createFeatureServiceClient = createClientFor(FeatureService);
|
||||||
export const createIdpServiceClient = createClientFor(IdentityProviderService);
|
export const createIdpServiceClient = createClientFor(IdentityProviderService);
|
||||||
|
export const createInternalPermissionServiceClient = createClientFor(InternalPermissionService);
|
||||||
|
|
||||||
export function makeReqCtx(orgId: string | undefined) {
|
export function makeReqCtx(orgId: string | undefined) {
|
||||||
return create(RequestContextSchema, {
|
return create(RequestContextSchema, {
|
||||||
|
|||||||
Reference in New Issue
Block a user