Merge branch 'multi-role' of github.com:fosrl/pangolin into multi-role

This commit is contained in:
Owen
2026-03-26 21:47:13 -07:00
5 changed files with 755 additions and 240 deletions

View File

@@ -41,6 +41,7 @@ import {
assignUserToOrg, assignUserToOrg,
removeUserFromOrg removeUserFromOrg
} from "@server/lib/userOrg"; } from "@server/lib/userOrg";
import { unwrapRoleMapping } from "@app/lib/idpRoleMapping";
const ensureTrailingSlash = (url: string): string => { const ensureTrailingSlash = (url: string): string => {
return url; return url;
@@ -367,7 +368,7 @@ export async function validateOidcCallback(
const defaultRoleMapping = existingIdp.idp.defaultRoleMapping; const defaultRoleMapping = existingIdp.idp.defaultRoleMapping;
const defaultOrgMapping = existingIdp.idp.defaultOrgMapping; const defaultOrgMapping = existingIdp.idp.defaultOrgMapping;
const userOrgInfo: { orgId: string; roleId: number }[] = []; const userOrgInfo: { orgId: string; roleIds: number[] }[] = [];
for (const org of allOrgs) { for (const org of allOrgs) {
const [idpOrgRes] = await db const [idpOrgRes] = await db
.select() .select()
@@ -379,8 +380,6 @@ export async function validateOidcCallback(
) )
); );
let roleId: number | undefined = undefined;
const orgMapping = idpOrgRes?.orgMapping || defaultOrgMapping; const orgMapping = idpOrgRes?.orgMapping || defaultOrgMapping;
const hydratedOrgMapping = hydrateOrgMapping( const hydratedOrgMapping = hydrateOrgMapping(
orgMapping, orgMapping,
@@ -405,38 +404,47 @@ export async function validateOidcCallback(
idpOrgRes?.roleMapping || defaultRoleMapping; idpOrgRes?.roleMapping || defaultRoleMapping;
if (roleMapping) { if (roleMapping) {
logger.debug("Role Mapping", { 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) { if (!roleNames.length) {
logger.error("Role name not found in the ID token", { logger.error("Role mapping returned no valid roles", {
roleName roleMappingResult
}); });
continue; continue;
} }
const [roleRes] = await db const roleRes = await db
.select() .select()
.from(roles) .from(roles)
.where( .where(
and( and(
eq(roles.orgId, org.orgId), eq(roles.orgId, org.orgId),
eq(roles.name, roleName) inArray(roles.name, roleNames)
) )
); );
if (!roleRes) { if (!roleRes.length) {
logger.error("Role not found", { logger.error("No mapped roles found in organization", {
orgId: org.orgId, orgId: org.orgId,
roleName roleNames
}); });
continue; continue;
} }
roleId = roleRes.roleId; const roleIds = [...new Set(roleRes.map((r) => r.roleId))];
userOrgInfo.push({ userOrgInfo.push({
orgId: org.orgId, orgId: org.orgId,
roleId roleIds
}); });
} }
} }
@@ -584,15 +592,17 @@ export async function validateOidcCallback(
const currentRolesInOrg = userRolesInOrgs.filter( const currentRolesInOrg = userRolesInOrgs.filter(
(r) => r.orgId === currentOrg.orgId (r) => r.orgId === currentOrg.orgId
); );
const hasIdpRole = currentRolesInOrg.some( for (const roleId of newRole.roleIds) {
(r) => r.roleId === newRole.roleId const hasIdpRole = currentRolesInOrg.some(
); (r) => r.roleId === roleId
if (!hasIdpRole) { );
await trx.insert(userOrgRoles).values({ if (!hasIdpRole) {
userId: userId!, await trx.insert(userOrgRoles).values({
orgId: currentOrg.orgId, userId: userId!,
roleId: newRole.roleId orgId: currentOrg.orgId,
}); roleId
});
}
} }
} }
@@ -606,6 +616,12 @@ export async function validateOidcCallback(
if (orgsToAdd.length > 0) { if (orgsToAdd.length > 0) {
for (const org of orgsToAdd) { for (const org of orgsToAdd) {
const [initialRoleId, ...additionalRoleIds] =
org.roleIds;
if (!initialRoleId) {
continue;
}
const [fullOrg] = await trx const [fullOrg] = await trx
.select() .select()
.from(orgs) .from(orgs)
@@ -618,9 +634,17 @@ export async function validateOidcCallback(
userId: userId!, userId: userId!,
autoProvisioned: true, autoProvisioned: true,
}, },
org.roleId, initialRoleId,
trx 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); 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 [];
}

View File

@@ -47,6 +47,14 @@ import { ListRolesResponse } from "@server/routers/role";
import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget"; import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import {
compileRoleMappingExpression,
createMappingBuilderRule,
detectRoleMappingConfig,
ensureMappingBuilderRuleIds,
MappingBuilderRule,
RoleMappingMode
} from "@app/lib/idpRoleMapping";
export default function GeneralPage() { export default function GeneralPage() {
const { env } = useEnvContext(); const { env } = useEnvContext();
@@ -56,9 +64,15 @@ export default function GeneralPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true); const [initialLoading, setInitialLoading] = useState(true);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [roleMappingMode, setRoleMappingMode] = useState< const [roleMappingMode, setRoleMappingMode] =
"role" | "expression" useState<RoleMappingMode>("fixedRoles");
>("role"); const [fixedRoleNames, setFixedRoleNames] = useState<string[]>([]);
const [mappingBuilderClaimPath, setMappingBuilderClaimPath] =
useState("groups");
const [mappingBuilderRules, setMappingBuilderRules] = useState<
MappingBuilderRule[]
>([createMappingBuilderRule()]);
const [rawRoleExpression, setRawRoleExpression] = useState("");
const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc"); const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc");
const dashboardRedirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`; const dashboardRedirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
@@ -190,34 +204,8 @@ export default function GeneralPage() {
// Set the variant // Set the variant
setVariant(idpVariant as "oidc" | "google" | "azure"); setVariant(idpVariant as "oidc" | "google" | "azure");
// Check if roleMapping matches the basic pattern '{role name}' (simple single role) const detectedRoleMappingConfig =
// This should NOT match complex expressions like 'Admin' || 'Member' detectRoleMappingConfig(roleMapping);
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;
}
}
// Extract tenant ID from Azure URLs if present // Extract tenant ID from Azure URLs if present
let tenantId = ""; let tenantId = "";
@@ -238,9 +226,7 @@ export default function GeneralPage() {
clientSecret: data.idpOidcConfig.clientSecret, clientSecret: data.idpOidcConfig.clientSecret,
autoProvision: data.idp.autoProvision, autoProvision: data.idp.autoProvision,
roleMapping: roleMapping || null, roleMapping: roleMapping || null,
roleId: isRoleId roleId: null
? Number(roleMapping)
: matchingRoleId || null
}; };
// Add variant-specific fields // Add variant-specific fields
@@ -259,10 +245,18 @@ export default function GeneralPage() {
form.reset(formData); form.reset(formData);
// Set the role mapping mode based on the data setRoleMappingMode(detectedRoleMappingConfig.mode);
// Default to "expression" unless it's a simple roleId or basic '{role name}' pattern setFixedRoleNames(detectedRoleMappingConfig.fixedRoleNames);
setRoleMappingMode( setMappingBuilderClaimPath(
matchingRoleId && isRoleName ? "role" : "expression" detectedRoleMappingConfig.mappingBuilder.claimPath
);
setMappingBuilderRules(
ensureMappingBuilderRuleIds(
detectedRoleMappingConfig.mappingBuilder.rules
)
);
setRawRoleExpression(
detectedRoleMappingConfig.rawExpression
); );
} }
} catch (e) { } catch (e) {
@@ -327,7 +321,26 @@ export default function GeneralPage() {
return; 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 // Build payload based on variant
let payload: any = { let payload: any = {
@@ -335,10 +348,7 @@ export default function GeneralPage() {
clientId: data.clientId, clientId: data.clientId,
clientSecret: data.clientSecret, clientSecret: data.clientSecret,
autoProvision: data.autoProvision, autoProvision: data.autoProvision,
roleMapping: roleMapping: roleMappingExpression
roleMappingMode === "role"
? `'${roleName}'`
: data.roleMapping || ""
}; };
// Add variant-specific fields // Add variant-specific fields
@@ -497,42 +507,43 @@ export default function GeneralPage() {
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm> <PaidFeaturesAlert
<PaidFeaturesAlert tiers={tierMatrix.autoProvisioning}
tiers={tierMatrix.autoProvisioning} />
/>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4" className="space-y-4"
id="general-settings-form" id="general-settings-form"
> >
<AutoProvisionConfigWidget <AutoProvisionConfigWidget
control={form.control} autoProvision={form.watch("autoProvision")}
autoProvision={form.watch( onAutoProvisionChange={(checked) => {
"autoProvision" form.setValue("autoProvision", checked);
)} }}
onAutoProvisionChange={(checked) => { roleMappingMode={roleMappingMode}
form.setValue( onRoleMappingModeChange={(data) => {
"autoProvision", setRoleMappingMode(data);
checked }}
); roles={roles}
}} fixedRoleNames={fixedRoleNames}
roleMappingMode={roleMappingMode} onFixedRoleNamesChange={setFixedRoleNames}
onRoleMappingModeChange={(data) => { mappingBuilderClaimPath={
setRoleMappingMode(data); mappingBuilderClaimPath
// Clear roleId and roleMapping when mode changes }
form.setValue("roleId", null); onMappingBuilderClaimPathChange={
form.setValue("roleMapping", null); setMappingBuilderClaimPath
}} }
roles={roles} mappingBuilderRules={mappingBuilderRules}
roleIdFieldName="roleId" onMappingBuilderRulesChange={
roleMappingFieldName="roleMapping" setMappingBuilderRules
/> }
</form> rawExpression={rawRoleExpression}
</Form> onRawExpressionChange={setRawRoleExpression}
</SettingsSectionForm> />
</form>
</Form>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>

View File

@@ -42,6 +42,12 @@ import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import {
compileRoleMappingExpression,
createMappingBuilderRule,
MappingBuilderRule,
RoleMappingMode
} from "@app/lib/idpRoleMapping";
export default function Page() { export default function Page() {
const { env } = useEnvContext(); const { env } = useEnvContext();
@@ -49,9 +55,15 @@ export default function Page() {
const router = useRouter(); const router = useRouter();
const [createLoading, setCreateLoading] = useState(false); const [createLoading, setCreateLoading] = useState(false);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [roleMappingMode, setRoleMappingMode] = useState< const [roleMappingMode, setRoleMappingMode] =
"role" | "expression" useState<RoleMappingMode>("fixedRoles");
>("role"); const [fixedRoleNames, setFixedRoleNames] = useState<string[]>([]);
const [mappingBuilderClaimPath, setMappingBuilderClaimPath] =
useState("groups");
const [mappingBuilderRules, setMappingBuilderRules] = useState<
MappingBuilderRule[]
>([createMappingBuilderRule()]);
const [rawRoleExpression, setRawRoleExpression] = useState("");
const t = useTranslations(); const t = useTranslations();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
@@ -228,7 +240,26 @@ export default function Page() {
tokenUrl = tokenUrl?.replace("{{TENANT_ID}}", data.tenantId); 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 = { const payload = {
name: data.name, name: data.name,
@@ -240,10 +271,7 @@ export default function Page() {
emailPath: data.emailPath, emailPath: data.emailPath,
namePath: data.namePath, namePath: data.namePath,
autoProvision: data.autoProvision, autoProvision: data.autoProvision,
roleMapping: roleMapping: roleMappingExpression,
roleMappingMode === "role"
? `'${roleName}'`
: data.roleMapping || "",
scopes: data.scopes, scopes: data.scopes,
variant: data.type variant: data.type
}; };
@@ -368,43 +396,44 @@ export default function Page() {
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm> <PaidFeaturesAlert
<PaidFeaturesAlert tiers={tierMatrix.autoProvisioning}
tiers={tierMatrix.autoProvisioning} />
/> <Form {...form}>
<Form {...form}> <form
<form className="space-y-4"
className="space-y-4" id="create-idp-form"
id="create-idp-form" onSubmit={form.handleSubmit(onSubmit)}
onSubmit={form.handleSubmit(onSubmit)} >
> <AutoProvisionConfigWidget
<AutoProvisionConfigWidget autoProvision={
control={form.control} form.watch("autoProvision") as boolean
autoProvision={ } // is this right?
form.watch( onAutoProvisionChange={(checked) => {
"autoProvision" form.setValue("autoProvision", checked);
) as boolean }}
} // is this right? roleMappingMode={roleMappingMode}
onAutoProvisionChange={(checked) => { onRoleMappingModeChange={(data) => {
form.setValue( setRoleMappingMode(data);
"autoProvision", }}
checked roles={roles}
); fixedRoleNames={fixedRoleNames}
}} onFixedRoleNamesChange={setFixedRoleNames}
roleMappingMode={roleMappingMode} mappingBuilderClaimPath={
onRoleMappingModeChange={(data) => { mappingBuilderClaimPath
setRoleMappingMode(data); }
// Clear roleId and roleMapping when mode changes onMappingBuilderClaimPathChange={
form.setValue("roleId", null); setMappingBuilderClaimPath
form.setValue("roleMapping", null); }
}} mappingBuilderRules={mappingBuilderRules}
roles={roles} onMappingBuilderRulesChange={
roleIdFieldName="roleId" setMappingBuilderRules
roleMappingFieldName="roleMapping" }
/> rawExpression={rawRoleExpression}
</form> onRawExpressionChange={setRawRoleExpression}
</Form> />
</SettingsSectionForm> </form>
</Form>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>

View File

@@ -1,57 +1,74 @@
"use client"; "use client";
import { import {
FormField,
FormItem,
FormLabel, FormLabel,
FormControl, FormDescription
FormDescription,
FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { import { Button } from "@app/components/ui/button";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { useTranslations } from "next-intl"; 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 { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; 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 = { type Role = {
roleId: number; roleId: number;
name: string; name: string;
}; };
type AutoProvisionConfigWidgetProps<T extends FieldValues> = { type AutoProvisionConfigWidgetProps = {
control: Control<T>;
autoProvision: boolean; autoProvision: boolean;
onAutoProvisionChange: (checked: boolean) => void; onAutoProvisionChange: (checked: boolean) => void;
roleMappingMode: "role" | "expression"; roleMappingMode: RoleMappingMode;
onRoleMappingModeChange: (mode: "role" | "expression") => void; onRoleMappingModeChange: (mode: RoleMappingMode) => void;
roles: Role[]; roles: Role[];
roleIdFieldName: Path<T>; fixedRoleNames: string[];
roleMappingFieldName: Path<T>; 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<T extends FieldValues>({ export default function AutoProvisionConfigWidget({
control,
autoProvision, autoProvision,
onAutoProvisionChange, onAutoProvisionChange,
roleMappingMode, roleMappingMode,
onRoleMappingModeChange, onRoleMappingModeChange,
roles, roles,
roleIdFieldName, fixedRoleNames,
roleMappingFieldName onFixedRoleNamesChange,
}: AutoProvisionConfigWidgetProps<T>) { mappingBuilderClaimPath,
onMappingBuilderClaimPathChange,
mappingBuilderRules,
onMappingBuilderRulesChange,
rawExpression,
onRawExpressionChange
}: AutoProvisionConfigWidgetProps) {
const t = useTranslations(); const t = useTranslations();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const [activeFixedRoleTagIndex, setActiveFixedRoleTagIndex] = useState<
number | null
>(null);
const roleOptions = useMemo(
() =>
roles.map((role) => ({
id: role.name,
text: role.name
})),
[roles]
);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -81,97 +98,243 @@ export default function AutoProvisionConfigWidget<T extends FieldValues>({
<RadioGroup <RadioGroup
value={roleMappingMode} value={roleMappingMode}
onValueChange={onRoleMappingModeChange} onValueChange={onRoleMappingModeChange}
className="flex space-x-6" className="flex flex-wrap gap-x-6 gap-y-2"
> >
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="role" id="role-mode" /> <RadioGroupItem
value="fixedRoles"
id="fixed-roles-mode"
/>
<label <label
htmlFor="role-mode" htmlFor="fixed-roles-mode"
className="text-sm font-medium" className="text-sm font-medium"
> >
{t("selectRole")} Fixed roles
</label> </label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem <RadioGroupItem
value="expression" value="mappingBuilder"
id="mapping-builder-mode"
/>
<label
htmlFor="mapping-builder-mode"
className="text-sm font-medium"
>
Mapping builder
</label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="rawExpression"
id="expression-mode" id="expression-mode"
/> />
<label <label
htmlFor="expression-mode" htmlFor="expression-mode"
className="text-sm font-medium" className="text-sm font-medium"
> >
{t("roleMappingExpression")} Raw expression
</label> </label>
</div> </div>
</RadioGroup> </RadioGroup>
</div> </div>
{roleMappingMode === "role" ? ( {roleMappingMode === "fixedRoles" && (
<FormField <div className="space-y-2">
control={control} <TagInput
name={roleIdFieldName} tags={fixedRoleNames.map((name) => ({
render={({ field }) => ( id: name,
<FormItem> text: name
<Select }))}
onValueChange={(value) => setTags={(nextTags) => {
field.onChange(Number(value)) const next =
} typeof nextTags === "function"
value={field.value?.toString()} ? nextTags(
> fixedRoleNames.map((name) => ({
<FormControl> id: name,
<SelectTrigger> text: name
<SelectValue }))
placeholder={t( )
"selectRolePlaceholder" : nextTags;
)}
/> onFixedRoleNamesChange(
</SelectTrigger> [...new Set(next.map((tag) => tag.text))]
</FormControl> );
<SelectContent> }}
{roles.map((role) => ( activeTagIndex={activeFixedRoleTagIndex}
<SelectItem setActiveTagIndex={setActiveFixedRoleTagIndex}
key={role.roleId} placeholder="Select one or more roles"
value={role.roleId.toString()} enableAutocomplete={true}
> autocompleteOptions={roleOptions}
{role.name} restrictTagsToAutocompleteOptions={true}
</SelectItem> allowDuplicates={false}
))} sortTags={true}
</SelectContent> size="sm"
</Select> />
<FormDescription> <FormDescription>
{t("selectRoleDescription")} Assign the same role set to every auto-provisioned
</FormDescription> user.
<FormMessage /> </FormDescription>
</FormItem> </div>
)} )}
/>
) : ( {roleMappingMode === "mappingBuilder" && (
<FormField <div className="space-y-4 rounded-md border p-3">
control={control} <div className="space-y-2">
name={roleMappingFieldName} <FormLabel>Claim path</FormLabel>
render={({ field }) => ( <Input
<FormItem> value={mappingBuilderClaimPath}
<FormControl> onChange={(e) =>
<Input onMappingBuilderClaimPathChange(
{...field} e.target.value
defaultValue={field.value || ""} )
value={field.value || ""} }
placeholder={t( placeholder="groups"
"roleMappingExpressionPlaceholder" />
)} <FormDescription>
/> Path in the token payload that contains source
</FormControl> values (for example, groups).
<FormDescription> </FormDescription>
{t("roleMappingExpressionDescription")} </div>
</FormDescription>
<FormMessage /> <div className="space-y-3">
</FormItem> <div className="hidden md:grid md:grid-cols-[minmax(220px,1fr)_minmax(340px,2fr)_auto] md:gap-3">
)} <FormLabel>Match value</FormLabel>
/> <FormLabel>Assign roles</FormLabel>
<span />
</div>
{mappingBuilderRules.map((rule, index) => (
<BuilderRuleRow
key={rule.id ?? `mapping-rule-${index}`}
roleOptions={roleOptions}
rule={rule}
onChange={(nextRule) => {
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()]
);
}}
/>
))}
</div>
<Button
type="button"
variant="outline"
onClick={() => {
onMappingBuilderRulesChange([
...mappingBuilderRules,
createMappingBuilderRule()
]);
}}
>
Add mapping rule
</Button>
</div>
)}
{roleMappingMode === "rawExpression" && (
<div className="space-y-2">
<Input
value={rawExpression}
onChange={(e) =>
onRawExpressionChange(e.target.value)
}
placeholder={t("roleMappingExpressionPlaceholder")}
/>
<FormDescription>
Expression must evaluate to a string or string
array.
</FormDescription>
</div>
)} )}
</div> </div>
)} )}
</div> </div>
); );
} }
function BuilderRuleRow({
rule,
roleOptions,
onChange,
onRemove
}: {
rule: MappingBuilderRule;
roleOptions: Tag[];
onChange: (rule: MappingBuilderRule) => void;
onRemove: () => void;
}) {
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
return (
<div className="grid gap-3 rounded-md border p-3 md:grid-cols-[minmax(220px,1fr)_minmax(340px,2fr)_auto] md:items-start">
<div className="space-y-1">
<FormLabel className="text-xs md:hidden">Match value</FormLabel>
<Input
value={rule.matchValue}
onChange={(e) =>
onChange({
...rule,
matchValue: e.target.value
})
}
placeholder="Match value (for example: admin)"
/>
</div>
<div className="space-y-1 min-w-0">
<FormLabel className="text-xs md:hidden">Assign roles</FormLabel>
<TagInput
tags={rule.roleNames.map((name) => ({ 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"
/>
</div>
<div className="flex justify-end md:justify-start">
<Button type="button" variant="ghost" onClick={onRemove}>
Remove
</Button>
</div>
</div>
);
}

266
src/lib/idpRoleMapping.ts Normal file
View File

@@ -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))];
}