From 6ab05551487a4ff5f5f2cec85177be512ddf0c69 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sat, 28 Mar 2026 18:09:36 -0700 Subject: [PATCH] respect full rbac feature in auto provisioning --- messages/en-US.json | 1 + server/lib/billing/tierMatrix.ts | 6 +- .../routers/billing/featureLifecycle.ts | 13 +- server/routers/idp/validateOidcCallback.ts | 15 +- .../users/[userId]/access-controls/page.tsx | 5 +- src/components/RoleMappingConfigFields.tsx | 193 ++++++++++++++---- 6 files changed, 178 insertions(+), 55 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 505378b7f..673ce4949 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1956,6 +1956,7 @@ "roleMappingAssignRoles": "Assign Roles", "roleMappingAddMappingRule": "Add Mapping Rule", "roleMappingRawExpressionResultDescription": "Expression must evaluate to a string or string array.", + "roleMappingRawExpressionResultDescriptionSingleRole": "Expression must evaluate to a string (a single role name).", "roleMappingMatchValuePlaceholder": "Match value (for example: admin)", "roleMappingAssignRolesPlaceholderFreeform": "Type role names (exact per org)", "roleMappingBuilderFreeformRowHint": "Role names must match a role in each target organization.", diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index c08bcea71..a66f566a9 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -15,7 +15,8 @@ export enum TierFeature { SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning - SshPam = "sshPam" + SshPam = "sshPam", + FullRbac = "fullRbac" } export const tierMatrix: Record = { @@ -48,5 +49,6 @@ export const tierMatrix: Record = { "enterprise" ], [TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"], - [TierFeature.SshPam]: ["tier1", "tier3", "enterprise"] + [TierFeature.SshPam]: ["tier1", "tier3", "enterprise"], + [TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"] }; diff --git a/server/private/routers/billing/featureLifecycle.ts b/server/private/routers/billing/featureLifecycle.ts index 9536a87f0..330cf6e03 100644 --- a/server/private/routers/billing/featureLifecycle.ts +++ b/server/private/routers/billing/featureLifecycle.ts @@ -26,9 +26,10 @@ import { orgs, resources, roles, - siteResources + siteResources, + userOrgRoles } from "@server/db"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; /** * Get the maximum allowed retention days for a given tier @@ -291,6 +292,10 @@ async function disableFeature( await disableSshPam(orgId); break; + case TierFeature.FullRbac: + await disableFullRbac(orgId); + break; + default: logger.warn( `Unknown feature ${feature} for org ${orgId}, skipping` @@ -326,6 +331,10 @@ async function disableSshPam(orgId: string): Promise { ); } +async function disableFullRbac(orgId: string): Promise { + logger.info(`Disabled full RBAC for org ${orgId}`); +} + async function disableLoginPageBranding(orgId: string): Promise { const [existingBranding] = await db .select() diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index 7f39aa38d..4de52f530 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -36,6 +36,7 @@ import { usageService } from "@server/lib/billing/usageService"; import { build } from "@server/build"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { isSubscribed } from "#dynamic/lib/isSubscribed"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { assignUserToOrg, @@ -415,7 +416,15 @@ export async function validateOidcCallback( roleMappingResult ); - if (!roleNames.length) { + const supportsMultiRole = await isLicensedOrSubscribed( + org.orgId, + tierMatrix.fullRbac + ); + const effectiveRoleNames = supportsMultiRole + ? roleNames + : roleNames.slice(0, 1); + + if (!effectiveRoleNames.length) { logger.error("Role mapping returned no valid roles", { roleMappingResult }); @@ -428,14 +437,14 @@ export async function validateOidcCallback( .where( and( eq(roles.orgId, org.orgId), - inArray(roles.name, roleNames) + inArray(roles.name, effectiveRoleNames) ) ); if (!roleRes.length) { logger.error("No mapped roles found in organization", { orgId: org.orgId, - roleNames + roleNames: effectiveRoleNames }); continue; } diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx index c9ed7d561..eb280f2f3 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx @@ -69,10 +69,7 @@ export default function AccessControlsPage() { const t = useTranslations(); const { isPaidUser, hasSaasSubscription, hasEnterpriseLicense } = usePaidStatus(); - const multiRoleFeatureTiers = Array.from( - new Set([...tierMatrix.sshPam, ...tierMatrix.orgOidc]) - ); - const isPaid = isPaidUser(multiRoleFeatureTiers); + const isPaid = isPaidUser(tierMatrix.fullRbac); const supportsMultipleRolesPerUser = isPaid; const showMultiRolePaywallMessage = !env.flags.disableEnterpriseFeatures && diff --git a/src/components/RoleMappingConfigFields.tsx b/src/components/RoleMappingConfigFields.tsx index deb2cc9ac..12790d4aa 100644 --- a/src/components/RoleMappingConfigFields.tsx +++ b/src/components/RoleMappingConfigFields.tsx @@ -5,13 +5,17 @@ import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; import { Button } from "@app/components/ui/button"; import { Input } from "@app/components/ui/input"; import { useTranslations } from "next-intl"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Tag, TagInput } from "@app/components/tags/tag-input"; import { createMappingBuilderRule, MappingBuilderRule, RoleMappingMode } from "@app/lib/idpRoleMapping"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { build } from "@server/build"; export type RoleMappingRoleOption = { roleId: number; @@ -52,10 +56,17 @@ export default function RoleMappingConfigFields({ showFreeformRoleNamesHint = false }: RoleMappingConfigFieldsProps) { const t = useTranslations(); + const { env } = useEnvContext(); + const { isPaidUser } = usePaidStatus(); const [activeFixedRoleTagIndex, setActiveFixedRoleTagIndex] = useState< number | null >(null); + const supportsMultipleRolesPerUser = isPaidUser(tierMatrix.fullRbac); + const showSingleRoleDisclaimer = + !env.flags.disableEnterpriseFeatures && + !isPaidUser(tierMatrix.fullRbac); + const restrictToOrgRoles = roles.length > 0; const roleOptions = useMemo( @@ -67,13 +78,40 @@ export default function RoleMappingConfigFields({ [roles] ); + useEffect(() => { + if ( + !supportsMultipleRolesPerUser && + mappingBuilderRules.length > 1 + ) { + onMappingBuilderRulesChange([mappingBuilderRules[0]]); + } + }, [ + supportsMultipleRolesPerUser, + mappingBuilderRules, + onMappingBuilderRulesChange + ]); + + useEffect(() => { + if (!supportsMultipleRolesPerUser && fixedRoleNames.length > 1) { + onFixedRoleNamesChange([fixedRoleNames[0]]); + } + }, [ + supportsMultipleRolesPerUser, + fixedRoleNames, + onFixedRoleNamesChange + ]); + const fixedRadioId = `${fieldIdPrefix}-fixed-roles-mode`; const builderRadioId = `${fieldIdPrefix}-mapping-builder-mode`; const rawRadioId = `${fieldIdPrefix}-raw-expression-mode`; + const mappingBuilderShowsRemoveColumn = + supportsMultipleRolesPerUser || mappingBuilderRules.length > 1; + /** Same template on header + rows so 1fr/1.75fr columns line up (auto third col differs per row otherwise). */ - const mappingRulesGridClass = - "md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.75fr)_6rem] md:gap-x-3"; + const mappingRulesGridClass = mappingBuilderShowsRemoveColumn + ? "md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.75fr)_6rem] md:gap-x-3" + : "md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.75fr)] md:gap-x-3"; return (
@@ -119,6 +157,13 @@ export default function RoleMappingConfigFields({
+ {showSingleRoleDisclaimer && ( + + {build === "saas" + ? t("singleRolePerUserPlanNotice") + : t("singleRolePerUserEditionNotice")} + + )} {roleMappingMode === "fixedRoles" && ( @@ -129,19 +174,37 @@ export default function RoleMappingConfigFields({ text: name }))} setTags={(nextTags) => { + const prevTags = fixedRoleNames.map((name) => ({ + id: name, + text: name + })); const next = typeof nextTags === "function" - ? nextTags( - fixedRoleNames.map((name) => ({ - id: name, - text: name - })) - ) + ? nextTags(prevTags) : nextTags; - onFixedRoleNamesChange([ + let names = [ ...new Set(next.map((tag) => tag.text)) - ]); + ]; + + if (!supportsMultipleRolesPerUser) { + if ( + names.length === 0 && + fixedRoleNames.length > 0 + ) { + onFixedRoleNamesChange([ + fixedRoleNames[ + fixedRoleNames.length - 1 + ]! + ]); + return; + } + if (names.length > 1) { + names = [names[names.length - 1]!]; + } + } + + onFixedRoleNamesChange(names); }} activeTagIndex={activeFixedRoleTagIndex} setActiveTagIndex={setActiveFixedRoleTagIndex} @@ -191,7 +254,9 @@ export default function RoleMappingConfigFields({ {t("roleMappingAssignRoles")} - + {mappingBuilderShowsRemoveColumn ? ( + + ) : null} {mappingBuilderRules.map((rule, index) => ( @@ -204,6 +269,10 @@ export default function RoleMappingConfigFields({ showFreeformRoleNamesHint={ showFreeformRoleNamesHint } + supportsMultipleRolesPerUser={ + supportsMultipleRolesPerUser + } + showRemoveButton={mappingBuilderShowsRemoveColumn} rule={rule} onChange={(nextRule) => { const nextRules = mappingBuilderRules.map( @@ -227,18 +296,20 @@ export default function RoleMappingConfigFields({ ))} - + {supportsMultipleRolesPerUser ? ( + + ) : null} )} @@ -250,7 +321,11 @@ export default function RoleMappingConfigFields({ placeholder={t("roleMappingExpressionPlaceholder")} /> - {t("roleMappingRawExpressionResultDescription")} + {supportsMultipleRolesPerUser + ? t("roleMappingRawExpressionResultDescription") + : t( + "roleMappingRawExpressionResultDescriptionSingleRole" + )} )} @@ -265,6 +340,8 @@ function BuilderRuleRow({ showFreeformRoleNamesHint, fieldIdPrefix, mappingRulesGridClass, + supportsMultipleRolesPerUser, + showRemoveButton, onChange, onRemove }: { @@ -274,6 +351,8 @@ function BuilderRuleRow({ showFreeformRoleNamesHint: boolean; fieldIdPrefix: string; mappingRulesGridClass: string; + supportsMultipleRolesPerUser: boolean; + showRemoveButton: boolean; onChange: (rule: MappingBuilderRule) => void; onRemove: () => void; }) { @@ -311,20 +390,44 @@ function BuilderRuleRow({ text: name }))} setTags={(nextTags) => { + const prevRoleTags = rule.roleNames.map( + (name) => ({ + id: name, + text: name + }) + ); const next = typeof nextTags === "function" - ? nextTags( - rule.roleNames.map((name) => ({ - id: name, - text: name - })) - ) + ? nextTags(prevRoleTags) : nextTags; + + let names = [ + ...new Set(next.map((tag) => tag.text)) + ]; + + if (!supportsMultipleRolesPerUser) { + if ( + names.length === 0 && + rule.roleNames.length > 0 + ) { + onChange({ + ...rule, + roleNames: [ + rule.roleNames[ + rule.roleNames.length - 1 + ]! + ] + }); + return; + } + if (names.length > 1) { + names = [names[names.length - 1]!]; + } + } + onChange({ ...rule, - roleNames: [ - ...new Set(next.map((tag) => tag.text)) - ] + roleNames: names }); }} activeTagIndex={activeTagIndex} @@ -351,16 +454,18 @@ function BuilderRuleRow({

)} -
- -
+ {showRemoveButton ? ( +
+ +
+ ) : null} ); }