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,
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 [];
}

View File

@@ -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<RoleMappingMode>("fixedRoles");
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 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() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<PaidFeaturesAlert
tiers={tierMatrix.autoProvisioning}
/>
<PaidFeaturesAlert
tiers={tierMatrix.autoProvisioning}
/>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="general-settings-form"
>
<AutoProvisionConfigWidget
control={form.control}
autoProvision={form.watch(
"autoProvision"
)}
onAutoProvisionChange={(checked) => {
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>
</Form>
</SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="general-settings-form"
>
<AutoProvisionConfigWidget
autoProvision={form.watch("autoProvision")}
onAutoProvisionChange={(checked) => {
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}
/>
</form>
</Form>
</SettingsSectionBody>
</SettingsSection>

View File

@@ -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<RoleMappingMode>("fixedRoles");
const [fixedRoleNames, setFixedRoleNames] = useState<string[]>([]);
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
};
@@ -368,43 +396,44 @@ export default function Page() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<PaidFeaturesAlert
tiers={tierMatrix.autoProvisioning}
/>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<AutoProvisionConfigWidget
control={form.control}
autoProvision={
form.watch(
"autoProvision"
) as boolean
} // is this right?
onAutoProvisionChange={(checked) => {
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>
</Form>
</SettingsSectionForm>
<PaidFeaturesAlert
tiers={tierMatrix.autoProvisioning}
/>
<Form {...form}>
<form
className="space-y-4"
id="create-idp-form"
onSubmit={form.handleSubmit(onSubmit)}
>
<AutoProvisionConfigWidget
autoProvision={
form.watch("autoProvision") as boolean
} // is this right?
onAutoProvisionChange={(checked) => {
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}
/>
</form>
</Form>
</SettingsSectionBody>
</SettingsSection>

View File

@@ -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<T extends FieldValues> = {
control: Control<T>;
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<T>;
roleMappingFieldName: Path<T>;
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<T extends FieldValues>({
control,
export default function AutoProvisionConfigWidget({
autoProvision,
onAutoProvisionChange,
roleMappingMode,
onRoleMappingModeChange,
roles,
roleIdFieldName,
roleMappingFieldName
}: AutoProvisionConfigWidgetProps<T>) {
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 (
<div className="space-y-4">
@@ -81,97 +98,243 @@ export default function AutoProvisionConfigWidget<T extends FieldValues>({
<RadioGroup
value={roleMappingMode}
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">
<RadioGroupItem value="role" id="role-mode" />
<RadioGroupItem
value="fixedRoles"
id="fixed-roles-mode"
/>
<label
htmlFor="role-mode"
htmlFor="fixed-roles-mode"
className="text-sm font-medium"
>
{t("selectRole")}
Fixed roles
</label>
</div>
<div className="flex items-center space-x-2">
<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"
/>
<label
htmlFor="expression-mode"
className="text-sm font-medium"
>
{t("roleMappingExpression")}
Raw expression
</label>
</div>
</RadioGroup>
</div>
{roleMappingMode === "role" ? (
<FormField
control={control}
name={roleIdFieldName}
render={({ field }) => (
<FormItem>
<Select
onValueChange={(value) =>
field.onChange(Number(value))
}
value={field.value?.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"selectRolePlaceholder"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
<SelectItem
key={role.roleId}
value={role.roleId.toString()}
>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
{t("selectRoleDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
) : (
<FormField
control={control}
name={roleMappingFieldName}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
defaultValue={field.value || ""}
value={field.value || ""}
placeholder={t(
"roleMappingExpressionPlaceholder"
)}
/>
</FormControl>
<FormDescription>
{t("roleMappingExpressionDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{roleMappingMode === "fixedRoles" && (
<div className="space-y-2">
<TagInput
tags={fixedRoleNames.map((name) => ({
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"
/>
<FormDescription>
Assign the same role set to every auto-provisioned
user.
</FormDescription>
</div>
)}
{roleMappingMode === "mappingBuilder" && (
<div className="space-y-4 rounded-md border p-3">
<div className="space-y-2">
<FormLabel>Claim path</FormLabel>
<Input
value={mappingBuilderClaimPath}
onChange={(e) =>
onMappingBuilderClaimPathChange(
e.target.value
)
}
placeholder="groups"
/>
<FormDescription>
Path in the token payload that contains source
values (for example, groups).
</FormDescription>
</div>
<div className="space-y-3">
<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>
);
}
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))];
}