diff --git a/apps/login/src/lib/server/idp-intent.ts b/apps/login/src/lib/server/idp-intent.ts index a6ad4f2b8c..11ec2d9752 100644 --- a/apps/login/src/lib/server/idp-intent.ts +++ b/apps/login/src/lib/server/idp-intent.ts @@ -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 { + 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([]); + }); +}); diff --git a/apps/login/src/lib/server/instance-roles.ts b/apps/login/src/lib/server/instance-roles.ts new file mode 100644 index 0000000000..48a4b0d7af --- /dev/null +++ b/apps/login/src/lib/server/instance-roles.ts @@ -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[], +): string[] { + const raw = rawInformation as Record | 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)) { + if (!role.startsWith(IAM_ROLE_PREFIX)) { + continue; + } + if (!orgs || typeof orgs !== "object" || Array.isArray(orgs)) { + continue; + } + const granted = Object.entries(orgs as Record).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 { + 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 = 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 }); + } +} diff --git a/apps/login/src/lib/service.ts b/apps/login/src/lib/service.ts index 31d7f045c4..e4fd194ddc 100644 --- a/apps/login/src/lib/service.ts +++ b/apps/login/src/lib/service.ts @@ -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(service: T, serviceConfig: ServiceConfig) { let token; diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index a1b0b5f681..a4ab2bf2b4 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -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 = 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, diff --git a/packages/zitadel-client/src/v2.ts b/packages/zitadel-client/src/v2.ts index 49cf901734..e2c4bb72d9 100644 --- a/packages/zitadel-client/src/v2.ts +++ b/packages/zitadel-client/src/v2.ts @@ -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, {