From ad7d68d2b443ea4b04a174277830040c072cf2f2 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 26 Mar 2026 21:46:01 -0700 Subject: [PATCH] basic idp mapping builder --- server/routers/idp/validateOidcCallback.ts | 94 +++-- .../(private)/idp/[idpId]/general/page.tsx | 167 +++++---- .../settings/(private)/idp/create/page.tsx | 119 +++--- src/components/AutoProvisionConfigWidget.tsx | 349 +++++++++++++----- src/lib/idpRoleMapping.ts | 266 +++++++++++++ 5 files changed, 755 insertions(+), 240 deletions(-) create mode 100644 src/lib/idpRoleMapping.ts diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index 8714c4d38..86545269b 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -41,6 +41,7 @@ import { assignUserToOrg, removeUserFromOrg } from "@server/lib/userOrg"; +import { unwrapRoleMapping } from "@app/lib/idpRoleMapping"; const ensureTrailingSlash = (url: string): string => { return url; @@ -367,7 +368,7 @@ export async function validateOidcCallback( const defaultRoleMapping = existingIdp.idp.defaultRoleMapping; const defaultOrgMapping = existingIdp.idp.defaultOrgMapping; - const userOrgInfo: { orgId: string; roleId: number }[] = []; + const userOrgInfo: { orgId: string; roleIds: number[] }[] = []; for (const org of allOrgs) { const [idpOrgRes] = await db .select() @@ -379,8 +380,6 @@ export async function validateOidcCallback( ) ); - let roleId: number | undefined = undefined; - const orgMapping = idpOrgRes?.orgMapping || defaultOrgMapping; const hydratedOrgMapping = hydrateOrgMapping( orgMapping, @@ -405,38 +404,47 @@ export async function validateOidcCallback( idpOrgRes?.roleMapping || defaultRoleMapping; if (roleMapping) { logger.debug("Role Mapping", { roleMapping }); - const roleName = jmespath.search(claims, roleMapping); + const roleMappingJmes = unwrapRoleMapping( + roleMapping + ).evaluationExpression; + const roleMappingResult = jmespath.search( + claims, + roleMappingJmes + ); + const roleNames = normalizeRoleMappingResult( + roleMappingResult + ); - if (!roleName) { - logger.error("Role name not found in the ID token", { - roleName + if (!roleNames.length) { + logger.error("Role mapping returned no valid roles", { + roleMappingResult }); continue; } - const [roleRes] = await db + const roleRes = await db .select() .from(roles) .where( and( eq(roles.orgId, org.orgId), - eq(roles.name, roleName) + inArray(roles.name, roleNames) ) ); - if (!roleRes) { - logger.error("Role not found", { + if (!roleRes.length) { + logger.error("No mapped roles found in organization", { orgId: org.orgId, - roleName + roleNames }); continue; } - roleId = roleRes.roleId; + const roleIds = [...new Set(roleRes.map((r) => r.roleId))]; userOrgInfo.push({ orgId: org.orgId, - roleId + roleIds }); } } @@ -584,15 +592,17 @@ export async function validateOidcCallback( const currentRolesInOrg = userRolesInOrgs.filter( (r) => r.orgId === currentOrg.orgId ); - const hasIdpRole = currentRolesInOrg.some( - (r) => r.roleId === newRole.roleId - ); - if (!hasIdpRole) { - await trx.insert(userOrgRoles).values({ - userId: userId!, - orgId: currentOrg.orgId, - roleId: newRole.roleId - }); + for (const roleId of newRole.roleIds) { + const hasIdpRole = currentRolesInOrg.some( + (r) => r.roleId === roleId + ); + if (!hasIdpRole) { + await trx.insert(userOrgRoles).values({ + userId: userId!, + orgId: currentOrg.orgId, + roleId + }); + } } } @@ -606,6 +616,12 @@ export async function validateOidcCallback( if (orgsToAdd.length > 0) { for (const org of orgsToAdd) { + const [initialRoleId, ...additionalRoleIds] = + org.roleIds; + if (!initialRoleId) { + continue; + } + const [fullOrg] = await trx .select() .from(orgs) @@ -618,9 +634,17 @@ export async function validateOidcCallback( userId: userId!, autoProvisioned: true, }, - org.roleId, + initialRoleId, trx ); + + for (const roleId of additionalRoleIds) { + await trx.insert(userOrgRoles).values({ + userId: userId!, + orgId: org.orgId, + roleId + }); + } } } } @@ -745,3 +769,25 @@ function hydrateOrgMapping( } return orgMapping.split("{{orgId}}").join(orgId); } + +function normalizeRoleMappingResult( + result: unknown +): string[] { + if (typeof result === "string") { + const role = result.trim(); + return role ? [role] : []; + } + + if (Array.isArray(result)) { + return [ + ...new Set( + result + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean) + ) + ]; + } + + return []; +} diff --git a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx index 7d4bece1e..9754b07e5 100644 --- a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx @@ -47,6 +47,14 @@ import { ListRolesResponse } from "@server/routers/role"; import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { + compileRoleMappingExpression, + createMappingBuilderRule, + detectRoleMappingConfig, + ensureMappingBuilderRuleIds, + MappingBuilderRule, + RoleMappingMode +} from "@app/lib/idpRoleMapping"; export default function GeneralPage() { const { env } = useEnvContext(); @@ -56,9 +64,15 @@ export default function GeneralPage() { const [loading, setLoading] = useState(false); const [initialLoading, setInitialLoading] = useState(true); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); - const [roleMappingMode, setRoleMappingMode] = useState< - "role" | "expression" - >("role"); + const [roleMappingMode, setRoleMappingMode] = + useState("fixedRoles"); + const [fixedRoleNames, setFixedRoleNames] = useState([]); + const [mappingBuilderClaimPath, setMappingBuilderClaimPath] = + useState("groups"); + const [mappingBuilderRules, setMappingBuilderRules] = useState< + MappingBuilderRule[] + >([createMappingBuilderRule()]); + const [rawRoleExpression, setRawRoleExpression] = useState(""); const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc"); const dashboardRedirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`; @@ -190,34 +204,8 @@ export default function GeneralPage() { // Set the variant setVariant(idpVariant as "oidc" | "google" | "azure"); - // Check if roleMapping matches the basic pattern '{role name}' (simple single role) - // This should NOT match complex expressions like 'Admin' || 'Member' - const isBasicRolePattern = - roleMapping && - typeof roleMapping === "string" && - /^'[^']+'$/.test(roleMapping); - - // Determine if roleMapping is a number (roleId) or matches basic pattern - const isRoleId = - !isNaN(Number(roleMapping)) && roleMapping !== ""; - const isRoleName = isBasicRolePattern; - - // Extract role name from basic pattern for matching - let extractedRoleName = null; - if (isRoleName) { - extractedRoleName = roleMapping.slice(1, -1); // Remove quotes - } - - // Try to find matching role by name if we have a basic pattern - let matchingRoleId = undefined; - if (extractedRoleName && availableRoles.length > 0) { - const matchingRole = availableRoles.find( - (role) => role.name === extractedRoleName - ); - if (matchingRole) { - matchingRoleId = matchingRole.roleId; - } - } + const detectedRoleMappingConfig = + detectRoleMappingConfig(roleMapping); // Extract tenant ID from Azure URLs if present let tenantId = ""; @@ -238,9 +226,7 @@ export default function GeneralPage() { clientSecret: data.idpOidcConfig.clientSecret, autoProvision: data.idp.autoProvision, roleMapping: roleMapping || null, - roleId: isRoleId - ? Number(roleMapping) - : matchingRoleId || null + roleId: null }; // Add variant-specific fields @@ -259,10 +245,18 @@ export default function GeneralPage() { form.reset(formData); - // Set the role mapping mode based on the data - // Default to "expression" unless it's a simple roleId or basic '{role name}' pattern - setRoleMappingMode( - matchingRoleId && isRoleName ? "role" : "expression" + setRoleMappingMode(detectedRoleMappingConfig.mode); + setFixedRoleNames(detectedRoleMappingConfig.fixedRoleNames); + setMappingBuilderClaimPath( + detectedRoleMappingConfig.mappingBuilder.claimPath + ); + setMappingBuilderRules( + ensureMappingBuilderRuleIds( + detectedRoleMappingConfig.mappingBuilder.rules + ) + ); + setRawRoleExpression( + detectedRoleMappingConfig.rawExpression ); } } catch (e) { @@ -327,7 +321,26 @@ export default function GeneralPage() { return; } - const roleName = roles.find((r) => r.roleId === data.roleId)?.name; + const roleMappingExpression = compileRoleMappingExpression({ + mode: roleMappingMode, + fixedRoleNames, + mappingBuilder: { + claimPath: mappingBuilderClaimPath, + rules: mappingBuilderRules + }, + rawExpression: rawRoleExpression + }); + + if (data.autoProvision && !roleMappingExpression) { + toast({ + title: t("error"), + description: + "A role mapping is required when auto-provisioning is enabled.", + variant: "destructive" + }); + setLoading(false); + return; + } // Build payload based on variant let payload: any = { @@ -335,10 +348,7 @@ export default function GeneralPage() { clientId: data.clientId, clientSecret: data.clientSecret, autoProvision: data.autoProvision, - roleMapping: - roleMappingMode === "role" - ? `'${roleName}'` - : data.roleMapping || "" + roleMapping: roleMappingExpression }; // Add variant-specific fields @@ -497,42 +507,43 @@ export default function GeneralPage() { - - + -
- - { - form.setValue( - "autoProvision", - checked - ); - }} - roleMappingMode={roleMappingMode} - onRoleMappingModeChange={(data) => { - setRoleMappingMode(data); - // Clear roleId and roleMapping when mode changes - form.setValue("roleId", null); - form.setValue("roleMapping", null); - }} - roles={roles} - roleIdFieldName="roleId" - roleMappingFieldName="roleMapping" - /> - - -
+
+ + { + form.setValue("autoProvision", checked); + }} + roleMappingMode={roleMappingMode} + onRoleMappingModeChange={(data) => { + setRoleMappingMode(data); + }} + roles={roles} + fixedRoleNames={fixedRoleNames} + onFixedRoleNamesChange={setFixedRoleNames} + mappingBuilderClaimPath={ + mappingBuilderClaimPath + } + onMappingBuilderClaimPathChange={ + setMappingBuilderClaimPath + } + mappingBuilderRules={mappingBuilderRules} + onMappingBuilderRulesChange={ + setMappingBuilderRules + } + rawExpression={rawRoleExpression} + onRawExpressionChange={setRawRoleExpression} + /> + +
diff --git a/src/app/[orgId]/settings/(private)/idp/create/page.tsx b/src/app/[orgId]/settings/(private)/idp/create/page.tsx index 4c783e9b2..2d6985849 100644 --- a/src/app/[orgId]/settings/(private)/idp/create/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/create/page.tsx @@ -42,6 +42,12 @@ import { useParams, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { + compileRoleMappingExpression, + createMappingBuilderRule, + MappingBuilderRule, + RoleMappingMode +} from "@app/lib/idpRoleMapping"; export default function Page() { const { env } = useEnvContext(); @@ -49,9 +55,15 @@ export default function Page() { const router = useRouter(); const [createLoading, setCreateLoading] = useState(false); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); - const [roleMappingMode, setRoleMappingMode] = useState< - "role" | "expression" - >("role"); + const [roleMappingMode, setRoleMappingMode] = + useState("fixedRoles"); + const [fixedRoleNames, setFixedRoleNames] = useState([]); + const [mappingBuilderClaimPath, setMappingBuilderClaimPath] = + useState("groups"); + const [mappingBuilderRules, setMappingBuilderRules] = useState< + MappingBuilderRule[] + >([createMappingBuilderRule()]); + const [rawRoleExpression, setRawRoleExpression] = useState(""); const t = useTranslations(); const { isPaidUser } = usePaidStatus(); @@ -228,7 +240,26 @@ export default function Page() { tokenUrl = tokenUrl?.replace("{{TENANT_ID}}", data.tenantId); } - const roleName = roles.find((r) => r.roleId === data.roleId)?.name; + const roleMappingExpression = compileRoleMappingExpression({ + mode: roleMappingMode, + fixedRoleNames, + mappingBuilder: { + claimPath: mappingBuilderClaimPath, + rules: mappingBuilderRules + }, + rawExpression: rawRoleExpression + }); + + if (data.autoProvision && !roleMappingExpression) { + toast({ + title: t("error"), + description: + "A role mapping is required when auto-provisioning is enabled.", + variant: "destructive" + }); + setCreateLoading(false); + return; + } const payload = { name: data.name, @@ -240,10 +271,7 @@ export default function Page() { emailPath: data.emailPath, namePath: data.namePath, autoProvision: data.autoProvision, - roleMapping: - roleMappingMode === "role" - ? `'${roleName}'` - : data.roleMapping || "", + roleMapping: roleMappingExpression, scopes: data.scopes, variant: data.type }; @@ -363,43 +391,44 @@ export default function Page() { - - -
- - { - form.setValue( - "autoProvision", - checked - ); - }} - roleMappingMode={roleMappingMode} - onRoleMappingModeChange={(data) => { - setRoleMappingMode(data); - // Clear roleId and roleMapping when mode changes - form.setValue("roleId", null); - form.setValue("roleMapping", null); - }} - roles={roles} - roleIdFieldName="roleId" - roleMappingFieldName="roleMapping" - /> - - -
+ +
+ + { + form.setValue("autoProvision", checked); + }} + roleMappingMode={roleMappingMode} + onRoleMappingModeChange={(data) => { + setRoleMappingMode(data); + }} + roles={roles} + fixedRoleNames={fixedRoleNames} + onFixedRoleNamesChange={setFixedRoleNames} + mappingBuilderClaimPath={ + mappingBuilderClaimPath + } + onMappingBuilderClaimPathChange={ + setMappingBuilderClaimPath + } + mappingBuilderRules={mappingBuilderRules} + onMappingBuilderRulesChange={ + setMappingBuilderRules + } + rawExpression={rawRoleExpression} + onRawExpressionChange={setRawRoleExpression} + /> + +
diff --git a/src/components/AutoProvisionConfigWidget.tsx b/src/components/AutoProvisionConfigWidget.tsx index f5979aec3..44ee8c6e0 100644 --- a/src/components/AutoProvisionConfigWidget.tsx +++ b/src/components/AutoProvisionConfigWidget.tsx @@ -1,57 +1,74 @@ "use client"; import { - FormField, - FormItem, FormLabel, - FormControl, - FormDescription, - FormMessage + FormDescription } from "@app/components/ui/form"; import { SwitchInput } from "@app/components/SwitchInput"; import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; +import { Button } from "@app/components/ui/button"; import { Input } from "@app/components/ui/input"; import { useTranslations } from "next-intl"; -import { Control, FieldValues, Path } from "react-hook-form"; +import { useMemo, useState } from "react"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; +import { + createMappingBuilderRule, + MappingBuilderRule, + RoleMappingMode +} from "@app/lib/idpRoleMapping"; type Role = { roleId: number; name: string; }; -type AutoProvisionConfigWidgetProps = { - control: Control; +type AutoProvisionConfigWidgetProps = { autoProvision: boolean; onAutoProvisionChange: (checked: boolean) => void; - roleMappingMode: "role" | "expression"; - onRoleMappingModeChange: (mode: "role" | "expression") => void; + roleMappingMode: RoleMappingMode; + onRoleMappingModeChange: (mode: RoleMappingMode) => void; roles: Role[]; - roleIdFieldName: Path; - roleMappingFieldName: Path; + fixedRoleNames: string[]; + onFixedRoleNamesChange: (roleNames: string[]) => void; + mappingBuilderClaimPath: string; + onMappingBuilderClaimPathChange: (claimPath: string) => void; + mappingBuilderRules: MappingBuilderRule[]; + onMappingBuilderRulesChange: (rules: MappingBuilderRule[]) => void; + rawExpression: string; + onRawExpressionChange: (expression: string) => void; }; -export default function AutoProvisionConfigWidget({ - control, +export default function AutoProvisionConfigWidget({ autoProvision, onAutoProvisionChange, roleMappingMode, onRoleMappingModeChange, roles, - roleIdFieldName, - roleMappingFieldName -}: AutoProvisionConfigWidgetProps) { + fixedRoleNames, + onFixedRoleNamesChange, + mappingBuilderClaimPath, + onMappingBuilderClaimPathChange, + mappingBuilderRules, + onMappingBuilderRulesChange, + rawExpression, + onRawExpressionChange +}: AutoProvisionConfigWidgetProps) { const t = useTranslations(); - const { isPaidUser } = usePaidStatus(); + const [activeFixedRoleTagIndex, setActiveFixedRoleTagIndex] = useState< + number | null + >(null); + + const roleOptions = useMemo( + () => + roles.map((role) => ({ + id: role.name, + text: role.name + })), + [roles] + ); return (
@@ -81,97 +98,243 @@ export default function AutoProvisionConfigWidget({
- +
+ +
+
+
- {roleMappingMode === "role" ? ( - ( - - - - {t("selectRoleDescription")} - - - - )} - /> - ) : ( - ( - - - - - - {t("roleMappingExpressionDescription")} - - - - )} - /> + {roleMappingMode === "fixedRoles" && ( +
+ ({ + id: name, + text: name + }))} + setTags={(nextTags) => { + const next = + typeof nextTags === "function" + ? nextTags( + fixedRoleNames.map((name) => ({ + id: name, + text: name + })) + ) + : nextTags; + + onFixedRoleNamesChange( + [...new Set(next.map((tag) => tag.text))] + ); + }} + activeTagIndex={activeFixedRoleTagIndex} + setActiveTagIndex={setActiveFixedRoleTagIndex} + placeholder="Select one or more roles" + enableAutocomplete={true} + autocompleteOptions={roleOptions} + restrictTagsToAutocompleteOptions={true} + allowDuplicates={false} + sortTags={true} + size="sm" + /> + + Assign the same role set to every auto-provisioned + user. + +
+ )} + + {roleMappingMode === "mappingBuilder" && ( +
+
+ Claim path + + onMappingBuilderClaimPathChange( + e.target.value + ) + } + placeholder="groups" + /> + + Path in the token payload that contains source + values (for example, groups). + +
+ +
+
+ Match value + Assign roles + +
+ + {mappingBuilderRules.map((rule, index) => ( + { + const nextRules = + mappingBuilderRules.map( + (row, i) => + i === index + ? nextRule + : row + ); + onMappingBuilderRulesChange( + nextRules + ); + }} + onRemove={() => { + const nextRules = + mappingBuilderRules.filter( + (_, i) => i !== index + ); + onMappingBuilderRulesChange( + nextRules.length + ? nextRules + : [createMappingBuilderRule()] + ); + }} + /> + ))} +
+ + +
+ )} + + {roleMappingMode === "rawExpression" && ( +
+ + onRawExpressionChange(e.target.value) + } + placeholder={t("roleMappingExpressionPlaceholder")} + /> + + Expression must evaluate to a string or string + array. + +
)} )} ); } + +function BuilderRuleRow({ + rule, + roleOptions, + onChange, + onRemove +}: { + rule: MappingBuilderRule; + roleOptions: Tag[]; + onChange: (rule: MappingBuilderRule) => void; + onRemove: () => void; +}) { + const [activeTagIndex, setActiveTagIndex] = useState(null); + + return ( +
+
+ Match value + + onChange({ + ...rule, + matchValue: e.target.value + }) + } + placeholder="Match value (for example: admin)" + /> +
+
+ Assign roles + ({ id: name, text: name }))} + setTags={(nextTags) => { + const next = + typeof nextTags === "function" + ? nextTags( + rule.roleNames.map((name) => ({ + id: name, + text: name + })) + ) + : nextTags; + onChange({ + ...rule, + roleNames: [...new Set(next.map((tag) => tag.text))] + }); + }} + activeTagIndex={activeTagIndex} + setActiveTagIndex={setActiveTagIndex} + placeholder="Assign roles" + enableAutocomplete={true} + autocompleteOptions={roleOptions} + restrictTagsToAutocompleteOptions={true} + allowDuplicates={false} + sortTags={true} + size="sm" + /> +
+
+ +
+
+ ); +} diff --git a/src/lib/idpRoleMapping.ts b/src/lib/idpRoleMapping.ts new file mode 100644 index 000000000..2b336b3dd --- /dev/null +++ b/src/lib/idpRoleMapping.ts @@ -0,0 +1,266 @@ +export type RoleMappingMode = "fixedRoles" | "mappingBuilder" | "rawExpression"; + +export type MappingBuilderRule = { + /** Stable React list key; not used when compiling JMESPath. */ + id?: string; + matchValue: string; + roleNames: string[]; +}; + +function newMappingBuilderRuleId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + return `rule-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; +} + +export function createMappingBuilderRule(): MappingBuilderRule { + return { + id: newMappingBuilderRuleId(), + matchValue: "", + roleNames: [] + }; +} + +/** Ensures every rule has a stable id (e.g. after loading from the API). */ +export function ensureMappingBuilderRuleIds( + rules: MappingBuilderRule[] +): MappingBuilderRule[] { + return rules.map((rule) => + rule.id ? rule : { ...rule, id: newMappingBuilderRuleId() } + ); +} + +export type MappingBuilderConfig = { + claimPath: string; + rules: MappingBuilderRule[]; +}; + +export type RoleMappingConfig = { + mode: RoleMappingMode; + fixedRoleNames: string[]; + mappingBuilder: MappingBuilderConfig; + rawExpression: string; +}; + +const SINGLE_QUOTED_ROLE_REGEX = /^'([^']+)'$/; +const QUOTED_ROLE_ARRAY_REGEX = /^\[(.*)\]$/; + +/** Stored role mappings created by the mapping builder are prefixed so the UI can restore the builder. */ +export const PANGOLIN_ROLE_MAP_BUILDER_PREFIX = "__PANGOLIN_ROLE_MAP_BUILDER_V1__"; + +const BUILDER_METADATA_SEPARATOR = "\n---\n"; + +export type UnwrappedRoleMapping = { + /** Expression passed to JMESPath (no builder wrapper). */ + evaluationExpression: string; + /** Present when the stored value was saved from the mapping builder. */ + builderState: { claimPath: string; rules: MappingBuilderRule[] } | null; +}; + +/** + * Split stored DB value into evaluation expression and optional builder metadata. + * Legacy values (no prefix) are returned as-is for evaluation. + */ +export function unwrapRoleMapping( + stored: string | null | undefined +): UnwrappedRoleMapping { + const trimmed = stored?.trim() ?? ""; + if (!trimmed.startsWith(PANGOLIN_ROLE_MAP_BUILDER_PREFIX)) { + return { + evaluationExpression: trimmed, + builderState: null + }; + } + + let rest = trimmed.slice(PANGOLIN_ROLE_MAP_BUILDER_PREFIX.length); + if (rest.startsWith("\n")) { + rest = rest.slice(1); + } + + const sepIdx = rest.indexOf(BUILDER_METADATA_SEPARATOR); + if (sepIdx === -1) { + return { + evaluationExpression: trimmed, + builderState: null + }; + } + + const jsonPart = rest.slice(0, sepIdx).trim(); + const inner = rest.slice(sepIdx + BUILDER_METADATA_SEPARATOR.length).trim(); + + try { + const meta = JSON.parse(jsonPart) as { + claimPath?: unknown; + rules?: unknown; + }; + if ( + typeof meta.claimPath === "string" && + Array.isArray(meta.rules) + ) { + const rules: MappingBuilderRule[] = meta.rules.map( + (r: unknown) => { + const row = r as { + matchValue?: unknown; + roleNames?: unknown; + }; + return { + matchValue: + typeof row.matchValue === "string" + ? row.matchValue + : "", + roleNames: Array.isArray(row.roleNames) + ? row.roleNames.filter( + (n): n is string => typeof n === "string" + ) + : [] + }; + } + ); + return { + evaluationExpression: inner, + builderState: { + claimPath: meta.claimPath, + rules: ensureMappingBuilderRuleIds(rules) + } + }; + } + } catch { + /* fall through */ + } + + return { + evaluationExpression: inner.length ? inner : trimmed, + builderState: null + }; +} + +function escapeSingleQuotes(value: string): string { + return value.replace(/'/g, "\\'"); +} + +export function compileRoleMappingExpression(config: RoleMappingConfig): string { + if (config.mode === "rawExpression") { + return config.rawExpression.trim(); + } + + if (config.mode === "fixedRoles") { + const roleNames = dedupeNonEmpty(config.fixedRoleNames); + if (!roleNames.length) { + return ""; + } + + if (roleNames.length === 1) { + return `'${escapeSingleQuotes(roleNames[0])}'`; + } + + return `[${roleNames.map((name) => `'${escapeSingleQuotes(name)}'`).join(", ")}]`; + } + + const claimPath = config.mappingBuilder.claimPath.trim(); + const rules = config.mappingBuilder.rules + .map((rule) => ({ + matchValue: rule.matchValue.trim(), + roleNames: dedupeNonEmpty(rule.roleNames) + })) + .filter((rule) => Boolean(rule.matchValue) && rule.roleNames.length > 0); + + if (!claimPath || !rules.length) { + return ""; + } + + const compiledRules = rules.map((rule) => { + const mappedRoles = `[${rule.roleNames + .map((name) => `'${escapeSingleQuotes(name)}'`) + .join(", ")}]`; + return `contains(${claimPath}, '${escapeSingleQuotes(rule.matchValue)}') && ${mappedRoles} || []`; + }); + + const inner = `[${compiledRules.join(", ")}][]`; + const metadata = { + claimPath, + rules: rules.map((r) => ({ + matchValue: r.matchValue, + roleNames: r.roleNames + })) + }; + + return `${PANGOLIN_ROLE_MAP_BUILDER_PREFIX}\n${JSON.stringify(metadata)}${BUILDER_METADATA_SEPARATOR}${inner}`; +} + +export function detectRoleMappingConfig( + expression: string | null | undefined +): RoleMappingConfig { + const stored = expression?.trim() || ""; + + if (!stored) { + return defaultRoleMappingConfig(); + } + + const { evaluationExpression, builderState } = unwrapRoleMapping(stored); + + if (builderState) { + return { + mode: "mappingBuilder", + fixedRoleNames: [], + mappingBuilder: { + claimPath: builderState.claimPath, + rules: builderState.rules + }, + rawExpression: evaluationExpression + }; + } + + const tail = evaluationExpression.trim(); + + const singleMatch = tail.match(SINGLE_QUOTED_ROLE_REGEX); + if (singleMatch?.[1]) { + return { + mode: "fixedRoles", + fixedRoleNames: [singleMatch[1]], + mappingBuilder: defaultRoleMappingConfig().mappingBuilder, + rawExpression: tail + }; + } + + const arrayMatch = tail.match(QUOTED_ROLE_ARRAY_REGEX); + if (arrayMatch?.[1]) { + const roleNames = arrayMatch[1] + .split(",") + .map((entry) => entry.trim()) + .map((entry) => entry.match(SINGLE_QUOTED_ROLE_REGEX)?.[1] || "") + .filter(Boolean); + + if (roleNames.length > 0) { + return { + mode: "fixedRoles", + fixedRoleNames: roleNames, + mappingBuilder: defaultRoleMappingConfig().mappingBuilder, + rawExpression: tail + }; + } + } + + return { + mode: "rawExpression", + fixedRoleNames: [], + mappingBuilder: defaultRoleMappingConfig().mappingBuilder, + rawExpression: tail + }; +} + +export function defaultRoleMappingConfig(): RoleMappingConfig { + return { + mode: "fixedRoles", + fixedRoleNames: [], + mappingBuilder: { + claimPath: "groups", + rules: [createMappingBuilderRule()] + }, + rawExpression: "" + }; +} + +function dedupeNonEmpty(values: string[]): string[] { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))]; +}