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}
);
}