support org mapping on org idp

This commit is contained in:
miloschwartz
2026-04-16 22:12:15 -07:00
parent 707cc4b275
commit 796d14a9e4
8 changed files with 189 additions and 116 deletions

View File

@@ -949,7 +949,7 @@
"defaultMappingsRole": "Default Role Mapping", "defaultMappingsRole": "Default Role Mapping",
"defaultMappingsRoleDescription": "The result of this expression must return the role name as defined in the organization as a string.", "defaultMappingsRoleDescription": "The result of this expression must return the role name as defined in the organization as a string.",
"defaultMappingsOrg": "Default Organization Mapping", "defaultMappingsOrg": "Default Organization Mapping",
"defaultMappingsOrgDescription": "When set, this expression must return the organization ID or true for the user to access that organization. When unset, defining an organization policy for that org is enough: the user is allowed in as long as a valid role mapping can be resolved for them within the organization.", "defaultMappingsOrgDescription": "When set, this expression must return the organization ID or true for the user to access that organization. When unset, defining a role mapping is enough: the user is allowed in as long as a valid role mapping can be resolved for them within the organization.",
"defaultMappingsSubmit": "Save Default Mappings", "defaultMappingsSubmit": "Save Default Mappings",
"orgPoliciesEdit": "Edit Organization Policy", "orgPoliciesEdit": "Edit Organization Policy",
"org": "Organization", "org": "Organization",
@@ -2026,7 +2026,7 @@
}, },
"internationaldomaindetected": "International Domain Detected", "internationaldomaindetected": "International Domain Detected",
"willbestoredas": "Will be stored as:", "willbestoredas": "Will be stored as:",
"roleMappingDescription": "Determine how roles are assigned to users when they sign in when Auto Provision is enabled.", "roleMappingDescription": "Determine how roles are assigned to users when they sign in with this identity provider.",
"selectRole": "Select a Role", "selectRole": "Select a Role",
"roleMappingExpression": "Expression", "roleMappingExpression": "Expression",
"selectRolePlaceholder": "Choose a role", "selectRolePlaceholder": "Choose a role",

View File

@@ -44,6 +44,7 @@ const bodySchema = z.strictObject({
autoProvision: z.boolean().optional(), autoProvision: z.boolean().optional(),
variant: z.enum(["oidc", "google", "azure"]).optional().default("oidc"), variant: z.enum(["oidc", "google", "azure"]).optional().default("oidc"),
roleMapping: z.string().optional(), roleMapping: z.string().optional(),
orgMapping: z.string().nullish(),
tags: z.string().optional() tags: z.string().optional()
}); });
@@ -105,6 +106,7 @@ export async function createOrgOidcIdp(
name, name,
variant, variant,
roleMapping, roleMapping,
orgMapping: orgMappingBody,
tags tags
} = parsedBody.data; } = parsedBody.data;
@@ -152,11 +154,16 @@ export async function createOrgOidcIdp(
variant variant
}); });
const orgMapping =
orgMappingBody !== undefined
? orgMappingBody
: `'${orgId}'`;
await trx.insert(idpOrg).values({ await trx.insert(idpOrg).values({
idpId: idpRes.idpId, idpId: idpRes.idpId,
orgId: orgId, orgId: orgId,
roleMapping: roleMapping || null, roleMapping: roleMapping || null,
orgMapping: `'${orgId}'` orgMapping
}); });
}); });

View File

@@ -47,6 +47,7 @@ const bodySchema = z.strictObject({
scopes: z.string().optional(), scopes: z.string().optional(),
autoProvision: z.boolean().optional(), autoProvision: z.boolean().optional(),
roleMapping: z.string().optional(), roleMapping: z.string().optional(),
orgMapping: z.string().nullish(),
tags: z.string().optional() tags: z.string().optional()
}); });
@@ -110,6 +111,7 @@ export async function updateOrgOidcIdp(
namePath, namePath,
name, name,
roleMapping, roleMapping,
orgMapping,
tags tags
} = parsedBody.data; } = parsedBody.data;
@@ -205,13 +207,20 @@ export async function updateOrgOidcIdp(
.where(eq(idpOidcConfig.idpId, idpId)); .where(eq(idpOidcConfig.idpId, idpId));
} }
const idpOrgPolicyPatch: {
roleMapping?: string;
orgMapping?: string | null;
} = {};
if (roleMapping !== undefined) { if (roleMapping !== undefined) {
// Update IdP-org policy idpOrgPolicyPatch.roleMapping = roleMapping;
}
if (orgMapping !== undefined) {
idpOrgPolicyPatch.orgMapping = orgMapping;
}
if (Object.keys(idpOrgPolicyPatch).length > 0) {
await trx await trx
.update(idpOrg) .update(idpOrg)
.set({ .set(idpOrgPolicyPatch)
roleMapping
})
.where( .where(
and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId)) and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId))
); );

View File

@@ -97,7 +97,8 @@ export default function GeneralPage() {
emailPath: z.string().nullable().optional(), emailPath: z.string().nullable().optional(),
namePath: z.string().nullable().optional(), namePath: z.string().nullable().optional(),
scopes: z.string().min(1, { message: t("idpScopeRequired") }), scopes: z.string().min(1, { message: t("idpScopeRequired") }),
autoProvision: z.boolean().default(false) autoProvision: z.boolean().default(false),
orgMapping: z.string().optional()
}); });
// Google form schema (simplified) // Google form schema (simplified)
@@ -109,7 +110,8 @@ export default function GeneralPage() {
.min(1, { message: t("idpClientSecretRequired") }), .min(1, { message: t("idpClientSecretRequired") }),
roleMapping: z.string().nullable().optional(), roleMapping: z.string().nullable().optional(),
roleId: z.number().nullable().optional(), roleId: z.number().nullable().optional(),
autoProvision: z.boolean().default(false) autoProvision: z.boolean().default(false),
orgMapping: z.string().optional()
}); });
// Azure form schema (simplified with tenant ID) // Azure form schema (simplified with tenant ID)
@@ -122,7 +124,8 @@ export default function GeneralPage() {
tenantId: z.string().min(1, { message: t("idpTenantIdRequired") }), tenantId: z.string().min(1, { message: t("idpTenantIdRequired") }),
roleMapping: z.string().nullable().optional(), roleMapping: z.string().nullable().optional(),
roleId: z.number().nullable().optional(), roleId: z.number().nullable().optional(),
autoProvision: z.boolean().default(false) autoProvision: z.boolean().default(false),
orgMapping: z.string().optional()
}); });
type OidcFormValues = z.infer<typeof OidcFormSchema>; type OidcFormValues = z.infer<typeof OidcFormSchema>;
@@ -160,7 +163,8 @@ export default function GeneralPage() {
autoProvision: true, autoProvision: true,
roleMapping: null, roleMapping: null,
roleId: null, roleId: null,
tenantId: "" tenantId: "",
orgMapping: ""
} }
}); });
@@ -227,7 +231,8 @@ 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: null roleId: null,
orgMapping: data.idpOrg?.orgMapping ?? ""
}; };
// Add variant-specific fields // Add variant-specific fields
@@ -344,12 +349,14 @@ export default function GeneralPage() {
} }
// Build payload based on variant // Build payload based on variant
const orgMappingTrimmed = data.orgMapping?.trim() ?? "";
let payload: any = { let payload: any = {
name: data.name, name: data.name,
clientId: data.clientId, clientId: data.clientId,
clientSecret: data.clientSecret, clientSecret: data.clientSecret,
autoProvision: data.autoProvision, autoProvision: data.autoProvision,
roleMapping: roleMappingExpression roleMapping: roleMappingExpression,
orgMapping: orgMappingTrimmed === "" ? null : orgMappingTrimmed
}; };
// Add variant-specific fields // Add variant-specific fields
@@ -532,6 +539,10 @@ export default function GeneralPage() {
} }
rawExpression={rawRoleExpression} rawExpression={rawRoleExpression}
onRawExpressionChange={setRawRoleExpression} onRawExpressionChange={setRawRoleExpression}
orgMappingField={{
control: form.control,
name: "orgMapping"
}}
/> />
</form> </form>
</Form> </Form>

View File

@@ -91,7 +91,8 @@ export default function Page() {
tenantId: z.string().optional(), tenantId: z.string().optional(),
autoProvision: z.boolean().default(false), autoProvision: z.boolean().default(false),
roleMapping: z.string().nullable().optional(), roleMapping: z.string().nullable().optional(),
roleId: z.number().nullable().optional() roleId: z.number().nullable().optional(),
orgMapping: z.string().optional()
}); });
type CreateIdpFormValues = z.infer<typeof createIdpFormSchema>; type CreateIdpFormValues = z.infer<typeof createIdpFormSchema>;
@@ -112,7 +113,8 @@ export default function Page() {
tenantId: "", tenantId: "",
autoProvision: false, autoProvision: false,
roleMapping: null, roleMapping: null,
roleId: null roleId: null,
orgMapping: ""
} }
}); });
@@ -177,7 +179,7 @@ export default function Page() {
return; return;
} }
const payload = { const payload: Record<string, unknown> = {
name: data.name, name: data.name,
clientId: data.clientId, clientId: data.clientId,
clientSecret: data.clientSecret, clientSecret: data.clientSecret,
@@ -191,6 +193,10 @@ export default function Page() {
scopes: data.scopes, scopes: data.scopes,
variant: data.type variant: data.type
}; };
const trimmedOrgMapping = data.orgMapping?.trim();
if (trimmedOrgMapping) {
payload.orgMapping = trimmedOrgMapping;
}
// Use the appropriate endpoint based on provider type // Use the appropriate endpoint based on provider type
const endpoint = "oidc"; const endpoint = "oidc";
@@ -336,6 +342,10 @@ export default function Page() {
} }
rawExpression={rawRoleExpression} rawExpression={rawRoleExpression}
onRawExpressionChange={setRawRoleExpression} onRawExpressionChange={setRawRoleExpression}
orgMappingField={{
control: form.control,
name: "orgMapping"
}}
/> />
</form> </form>
</Form> </Form>

View File

@@ -20,7 +20,6 @@ import {
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@@ -63,7 +62,7 @@ import {
SettingsSectionForm SettingsSectionForm
} from "@app/components/Settings"; } from "@app/components/Settings";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields"; import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget";
import { import {
compileRoleMappingExpression, compileRoleMappingExpression,
createMappingBuilderRule, createMappingBuilderRule,
@@ -499,9 +498,17 @@ export default function PoliciesPage() {
id="policy-default-mappings-form" id="policy-default-mappings-form"
className="space-y-6" className="space-y-6"
> >
<RoleMappingConfigFields <AutoProvisionConfigWidget
fieldIdPrefix="admin-idp-default-role" showAutoProvisionSwitch={false}
showFreeformRoleNamesHint={true} autoProvision={true}
onAutoProvisionChange={() => {}}
orgMappingField={{
control: defaultMappingsForm.control,
name: "defaultOrgMapping",
labelKey: "defaultMappingsOrg"
}}
roleMappingFieldIdPrefix="admin-idp-default-role"
showFreeformRoleNamesHint
roleMappingMode={defaultRoleMappingMode} roleMappingMode={defaultRoleMappingMode}
onRoleMappingModeChange={ onRoleMappingModeChange={
setDefaultRoleMappingMode setDefaultRoleMappingMode
@@ -528,27 +535,6 @@ export default function PoliciesPage() {
setDefaultRawRoleExpression setDefaultRawRoleExpression
} }
/> />
<FormField
control={defaultMappingsForm.control}
name="defaultOrgMapping"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("defaultMappingsOrg")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"defaultMappingsOrgDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form> </form>
</Form> </Form>
<SettingsSectionFooter> <SettingsSectionFooter>
@@ -687,9 +673,15 @@ export default function PoliciesPage() {
)} )}
/> />
<RoleMappingConfigFields <AutoProvisionConfigWidget
fieldIdPrefix="admin-idp-policy-role" showAutoProvisionSwitch={false}
showFreeformRoleNamesHint={false} autoProvision={true}
onAutoProvisionChange={() => {}}
orgMappingField={{
control: form.control,
name: "orgMapping"
}}
roleMappingFieldIdPrefix="admin-idp-policy-role"
roleMappingMode={policyRoleMappingMode} roleMappingMode={policyRoleMappingMode}
onRoleMappingModeChange={ onRoleMappingModeChange={
setPolicyRoleMappingMode setPolicyRoleMappingMode
@@ -716,27 +708,6 @@ export default function PoliciesPage() {
setPolicyRawRoleExpression setPolicyRawRoleExpression
} }
/> />
<FormField
control={form.control}
name="orgMapping"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("orgMappingPathOptional")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"defaultMappingsOrgDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form> </form>
</Form> </Form>
</CredenzaBody> </CredenzaBody>

View File

@@ -1,19 +1,33 @@
"use client"; "use client";
import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription"; import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { FormDescription } from "@app/components/ui/form"; import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import { useTranslations } from "next-intl"; import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { MappingBuilderRule, RoleMappingMode } from "@app/lib/idpRoleMapping";
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 { MappingBuilderRule, RoleMappingMode } from "@app/lib/idpRoleMapping"; import { useTranslations } from "next-intl";
import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields"; import type { Control } from "react-hook-form";
type Role = { type Role = {
roleId: number; roleId: number;
name: string; name: string;
}; };
export type IdpOrgMappingFieldBinding = {
control: unknown;
name: string;
labelKey?: string;
};
type AutoProvisionConfigWidgetProps = { type AutoProvisionConfigWidgetProps = {
autoProvision: boolean; autoProvision: boolean;
onAutoProvisionChange: (checked: boolean) => void; onAutoProvisionChange: (checked: boolean) => void;
@@ -28,6 +42,11 @@ type AutoProvisionConfigWidgetProps = {
onMappingBuilderRulesChange: (rules: MappingBuilderRule[]) => void; onMappingBuilderRulesChange: (rules: MappingBuilderRule[]) => void;
rawExpression: string; rawExpression: string;
onRawExpressionChange: (expression: string) => void; onRawExpressionChange: (expression: string) => void;
orgMappingField: IdpOrgMappingFieldBinding;
showAutoProvisionSwitch?: boolean;
roleMappingFieldIdPrefix?: string;
showFreeformRoleNamesHint?: boolean;
autoProvisionSwitchId?: string;
}; };
export default function AutoProvisionConfigWidget({ export default function AutoProvisionConfigWidget({
@@ -43,27 +62,50 @@ export default function AutoProvisionConfigWidget({
mappingBuilderRules, mappingBuilderRules,
onMappingBuilderRulesChange, onMappingBuilderRulesChange,
rawExpression, rawExpression,
onRawExpressionChange onRawExpressionChange,
orgMappingField,
showAutoProvisionSwitch = true,
roleMappingFieldIdPrefix = "org-idp-auto-provision",
showFreeformRoleNamesHint = false,
autoProvisionSwitchId = "auto-provision-toggle"
}: AutoProvisionConfigWidgetProps) { }: AutoProvisionConfigWidgetProps) {
const t = useTranslations(); const t = useTranslations();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const showMappingTabs = showAutoProvisionSwitch === false || autoProvision;
const orgMappingLabelKey =
orgMappingField.labelKey ?? "orgMappingPathOptional";
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{showAutoProvisionSwitch && (
<div className="mb-4"> <div className="mb-4">
<SwitchInput <SwitchInput
id="auto-provision-toggle" id={autoProvisionSwitchId}
label={t("idpAutoProvisionUsers")} label={t("idpAutoProvisionUsers")}
defaultChecked={autoProvision} defaultChecked={autoProvision}
onCheckedChange={onAutoProvisionChange} onCheckedChange={onAutoProvisionChange}
disabled={!isPaidUser(tierMatrix.autoProvisioning)} disabled={!isPaidUser(tierMatrix.autoProvisioning)}
/> />
</div> </div>
)}
{autoProvision && ( {showMappingTabs && (
<HorizontalTabs
clientSide
defaultTab={0}
items={[
{ title: t("roleMapping"), href: "#" },
{ title: t("orgMapping"), href: "#" }
]}
>
<div className="space-y-4 mt-4 p-1">
<RoleMappingConfigFields <RoleMappingConfigFields
fieldIdPrefix="org-idp-auto-provision" fieldIdPrefix={roleMappingFieldIdPrefix}
showFreeformRoleNamesHint={false} showFreeformRoleNamesHint={
showFreeformRoleNamesHint
}
roleMappingMode={roleMappingMode} roleMappingMode={roleMappingMode}
onRoleMappingModeChange={onRoleMappingModeChange} onRoleMappingModeChange={onRoleMappingModeChange}
roles={roles} roles={roles}
@@ -74,10 +116,41 @@ export default function AutoProvisionConfigWidget({
onMappingBuilderClaimPathChange onMappingBuilderClaimPathChange
} }
mappingBuilderRules={mappingBuilderRules} mappingBuilderRules={mappingBuilderRules}
onMappingBuilderRulesChange={onMappingBuilderRulesChange} onMappingBuilderRulesChange={
onMappingBuilderRulesChange
}
rawExpression={rawExpression} rawExpression={rawExpression}
onRawExpressionChange={onRawExpressionChange} onRawExpressionChange={onRawExpressionChange}
/> />
</div>
<div className="space-y-4 mt-4 p-1">
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("defaultMappingsOrgDescription")}
</p>
<FormField
control={
orgMappingField.control as Control<any>
}
name={orgMappingField.name}
render={({ field }) => (
<FormItem>
<FormLabel>
{t(orgMappingLabelKey)}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder="e.g., ends_with(email, '@organization.com')"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</HorizontalTabs>
)} )}
</div> </div>
); );

View File

@@ -79,10 +79,7 @@ export default function RoleMappingConfigFields({
); );
useEffect(() => { useEffect(() => {
if ( if (!supportsMultipleRolesPerUser && mappingBuilderRules.length > 1) {
!supportsMultipleRolesPerUser &&
mappingBuilderRules.length > 1
) {
onMappingBuilderRulesChange([mappingBuilderRules[0]]); onMappingBuilderRulesChange([mappingBuilderRules[0]]);
} }
}, [ }, [
@@ -95,11 +92,7 @@ export default function RoleMappingConfigFields({
if (!supportsMultipleRolesPerUser && fixedRoleNames.length > 1) { if (!supportsMultipleRolesPerUser && fixedRoleNames.length > 1) {
onFixedRoleNamesChange([fixedRoleNames[0]]); onFixedRoleNamesChange([fixedRoleNames[0]]);
} }
}, [ }, [supportsMultipleRolesPerUser, fixedRoleNames, onFixedRoleNamesChange]);
supportsMultipleRolesPerUser,
fixedRoleNames,
onFixedRoleNamesChange
]);
const fixedRadioId = `${fieldIdPrefix}-fixed-roles-mode`; const fixedRadioId = `${fieldIdPrefix}-fixed-roles-mode`;
const builderRadioId = `${fieldIdPrefix}-mapping-builder-mode`; const builderRadioId = `${fieldIdPrefix}-mapping-builder-mode`;
@@ -116,7 +109,6 @@ export default function RoleMappingConfigFields({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<FormLabel className="mb-2">{t("roleMapping")}</FormLabel>
<FormDescription className="mb-4"> <FormDescription className="mb-4">
{t("roleMappingDescription")} {t("roleMappingDescription")}
</FormDescription> </FormDescription>
@@ -272,7 +264,9 @@ export default function RoleMappingConfigFields({
supportsMultipleRolesPerUser={ supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser supportsMultipleRolesPerUser
} }
showRemoveButton={mappingBuilderShowsRemoveColumn} showRemoveButton={
mappingBuilderShowsRemoveColumn
}
rule={rule} rule={rule}
onChange={(nextRule) => { onChange={(nextRule) => {
const nextRules = mappingBuilderRules.map( const nextRules = mappingBuilderRules.map(
@@ -390,12 +384,10 @@ function BuilderRuleRow({
text: name text: name
}))} }))}
setTags={(nextTags) => { setTags={(nextTags) => {
const prevRoleTags = rule.roleNames.map( const prevRoleTags = rule.roleNames.map((name) => ({
(name) => ({
id: name, id: name,
text: name text: name
}) }));
);
const next = const next =
typeof nextTags === "function" typeof nextTags === "function"
? nextTags(prevRoleTags) ? nextTags(prevRoleTags)