mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-27 13:06:37 +00:00
Merge branch 'multi-role' of github.com:fosrl/pangolin into multi-role
This commit is contained in:
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
266
src/lib/idpRoleMapping.ts
Normal 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))];
|
||||
}
|
||||
Reference in New Issue
Block a user