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