respect full rbac feature in auto provisioning

This commit is contained in:
miloschwartz
2026-03-28 18:09:36 -07:00
parent c6f269b3fa
commit 6ab0555148
6 changed files with 178 additions and 55 deletions

View File

@@ -1956,6 +1956,7 @@
"roleMappingAssignRoles": "Assign Roles", "roleMappingAssignRoles": "Assign Roles",
"roleMappingAddMappingRule": "Add Mapping Rule", "roleMappingAddMappingRule": "Add Mapping Rule",
"roleMappingRawExpressionResultDescription": "Expression must evaluate to a string or string array.", "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)", "roleMappingMatchValuePlaceholder": "Match value (for example: admin)",
"roleMappingAssignRolesPlaceholderFreeform": "Type role names (exact per org)", "roleMappingAssignRolesPlaceholderFreeform": "Type role names (exact per org)",
"roleMappingBuilderFreeformRowHint": "Role names must match a role in each target organization.", "roleMappingBuilderFreeformRowHint": "Role names must match a role in each target organization.",

View File

@@ -15,7 +15,8 @@ export enum TierFeature {
SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration
PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration
AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning
SshPam = "sshPam" SshPam = "sshPam",
FullRbac = "fullRbac"
} }
export const tierMatrix: Record<TierFeature, Tier[]> = { export const tierMatrix: Record<TierFeature, Tier[]> = {
@@ -48,5 +49,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
"enterprise" "enterprise"
], ],
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"], [TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"],
[TierFeature.SshPam]: ["tier1", "tier3", "enterprise"] [TierFeature.SshPam]: ["tier1", "tier3", "enterprise"],
[TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"]
}; };

View File

@@ -26,9 +26,10 @@ import {
orgs, orgs,
resources, resources,
roles, roles,
siteResources siteResources,
userOrgRoles
} from "@server/db"; } from "@server/db";
import { eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
/** /**
* Get the maximum allowed retention days for a given tier * Get the maximum allowed retention days for a given tier
@@ -291,6 +292,10 @@ async function disableFeature(
await disableSshPam(orgId); await disableSshPam(orgId);
break; break;
case TierFeature.FullRbac:
await disableFullRbac(orgId);
break;
default: default:
logger.warn( logger.warn(
`Unknown feature ${feature} for org ${orgId}, skipping` `Unknown feature ${feature} for org ${orgId}, skipping`
@@ -326,6 +331,10 @@ async function disableSshPam(orgId: string): Promise<void> {
); );
} }
async function disableFullRbac(orgId: string): Promise<void> {
logger.info(`Disabled full RBAC for org ${orgId}`);
}
async function disableLoginPageBranding(orgId: string): Promise<void> { async function disableLoginPageBranding(orgId: string): Promise<void> {
const [existingBranding] = await db const [existingBranding] = await db
.select() .select()

View File

@@ -36,6 +36,7 @@ import { usageService } from "@server/lib/billing/usageService";
import { build } from "@server/build"; import { build } from "@server/build";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { isSubscribed } from "#dynamic/lib/isSubscribed"; import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { import {
assignUserToOrg, assignUserToOrg,
@@ -415,7 +416,15 @@ export async function validateOidcCallback(
roleMappingResult 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", { logger.error("Role mapping returned no valid roles", {
roleMappingResult roleMappingResult
}); });
@@ -428,14 +437,14 @@ export async function validateOidcCallback(
.where( .where(
and( and(
eq(roles.orgId, org.orgId), eq(roles.orgId, org.orgId),
inArray(roles.name, roleNames) inArray(roles.name, effectiveRoleNames)
) )
); );
if (!roleRes.length) { if (!roleRes.length) {
logger.error("No mapped roles found in organization", { logger.error("No mapped roles found in organization", {
orgId: org.orgId, orgId: org.orgId,
roleNames roleNames: effectiveRoleNames
}); });
continue; continue;
} }

View File

@@ -69,10 +69,7 @@ export default function AccessControlsPage() {
const t = useTranslations(); const t = useTranslations();
const { isPaidUser, hasSaasSubscription, hasEnterpriseLicense } = const { isPaidUser, hasSaasSubscription, hasEnterpriseLicense } =
usePaidStatus(); usePaidStatus();
const multiRoleFeatureTiers = Array.from( const isPaid = isPaidUser(tierMatrix.fullRbac);
new Set([...tierMatrix.sshPam, ...tierMatrix.orgOidc])
);
const isPaid = isPaidUser(multiRoleFeatureTiers);
const supportsMultipleRolesPerUser = isPaid; const supportsMultipleRolesPerUser = isPaid;
const showMultiRolePaywallMessage = const showMultiRolePaywallMessage =
!env.flags.disableEnterpriseFeatures && !env.flags.disableEnterpriseFeatures &&

View File

@@ -5,13 +5,17 @@ import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
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 { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Tag, TagInput } from "@app/components/tags/tag-input"; import { Tag, TagInput } from "@app/components/tags/tag-input";
import { import {
createMappingBuilderRule, createMappingBuilderRule,
MappingBuilderRule, MappingBuilderRule,
RoleMappingMode RoleMappingMode
} from "@app/lib/idpRoleMapping"; } 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 = { export type RoleMappingRoleOption = {
roleId: number; roleId: number;
@@ -52,10 +56,17 @@ export default function RoleMappingConfigFields({
showFreeformRoleNamesHint = false showFreeformRoleNamesHint = false
}: RoleMappingConfigFieldsProps) { }: RoleMappingConfigFieldsProps) {
const t = useTranslations(); const t = useTranslations();
const { env } = useEnvContext();
const { isPaidUser } = usePaidStatus();
const [activeFixedRoleTagIndex, setActiveFixedRoleTagIndex] = useState< const [activeFixedRoleTagIndex, setActiveFixedRoleTagIndex] = useState<
number | null number | null
>(null); >(null);
const supportsMultipleRolesPerUser = isPaidUser(tierMatrix.fullRbac);
const showSingleRoleDisclaimer =
!env.flags.disableEnterpriseFeatures &&
!isPaidUser(tierMatrix.fullRbac);
const restrictToOrgRoles = roles.length > 0; const restrictToOrgRoles = roles.length > 0;
const roleOptions = useMemo( const roleOptions = useMemo(
@@ -67,13 +78,40 @@ export default function RoleMappingConfigFields({
[roles] [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 fixedRadioId = `${fieldIdPrefix}-fixed-roles-mode`;
const builderRadioId = `${fieldIdPrefix}-mapping-builder-mode`; const builderRadioId = `${fieldIdPrefix}-mapping-builder-mode`;
const rawRadioId = `${fieldIdPrefix}-raw-expression-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). */ /** Same template on header + rows so 1fr/1.75fr columns line up (auto third col differs per row otherwise). */
const mappingRulesGridClass = 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)_6rem] md:gap-x-3"
: "md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.75fr)] md:gap-x-3";
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -119,6 +157,13 @@ export default function RoleMappingConfigFields({
</label> </label>
</div> </div>
</RadioGroup> </RadioGroup>
{showSingleRoleDisclaimer && (
<FormDescription className="mt-3">
{build === "saas"
? t("singleRolePerUserPlanNotice")
: t("singleRolePerUserEditionNotice")}
</FormDescription>
)}
</div> </div>
{roleMappingMode === "fixedRoles" && ( {roleMappingMode === "fixedRoles" && (
@@ -129,19 +174,37 @@ export default function RoleMappingConfigFields({
text: name text: name
}))} }))}
setTags={(nextTags) => { setTags={(nextTags) => {
const prevTags = fixedRoleNames.map((name) => ({
id: name,
text: name
}));
const next = const next =
typeof nextTags === "function" typeof nextTags === "function"
? nextTags( ? nextTags(prevTags)
fixedRoleNames.map((name) => ({
id: name,
text: name
}))
)
: nextTags; : nextTags;
onFixedRoleNamesChange([ let names = [
...new Set(next.map((tag) => tag.text)) ...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} activeTagIndex={activeFixedRoleTagIndex}
setActiveTagIndex={setActiveFixedRoleTagIndex} setActiveTagIndex={setActiveFixedRoleTagIndex}
@@ -191,7 +254,9 @@ export default function RoleMappingConfigFields({
<FormLabel className="min-w-0"> <FormLabel className="min-w-0">
{t("roleMappingAssignRoles")} {t("roleMappingAssignRoles")}
</FormLabel> </FormLabel>
<span aria-hidden className="min-w-0" /> {mappingBuilderShowsRemoveColumn ? (
<span aria-hidden className="min-w-0" />
) : null}
</div> </div>
{mappingBuilderRules.map((rule, index) => ( {mappingBuilderRules.map((rule, index) => (
@@ -204,6 +269,10 @@ export default function RoleMappingConfigFields({
showFreeformRoleNamesHint={ showFreeformRoleNamesHint={
showFreeformRoleNamesHint showFreeformRoleNamesHint
} }
supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser
}
showRemoveButton={mappingBuilderShowsRemoveColumn}
rule={rule} rule={rule}
onChange={(nextRule) => { onChange={(nextRule) => {
const nextRules = mappingBuilderRules.map( const nextRules = mappingBuilderRules.map(
@@ -227,18 +296,20 @@ export default function RoleMappingConfigFields({
))} ))}
</div> </div>
<Button {supportsMultipleRolesPerUser ? (
type="button" <Button
variant="outline" type="button"
onClick={() => { variant="outline"
onMappingBuilderRulesChange([ onClick={() => {
...mappingBuilderRules, onMappingBuilderRulesChange([
createMappingBuilderRule() ...mappingBuilderRules,
]); createMappingBuilderRule()
}} ]);
> }}
{t("roleMappingAddMappingRule")} >
</Button> {t("roleMappingAddMappingRule")}
</Button>
) : null}
</div> </div>
)} )}
@@ -250,7 +321,11 @@ export default function RoleMappingConfigFields({
placeholder={t("roleMappingExpressionPlaceholder")} placeholder={t("roleMappingExpressionPlaceholder")}
/> />
<FormDescription> <FormDescription>
{t("roleMappingRawExpressionResultDescription")} {supportsMultipleRolesPerUser
? t("roleMappingRawExpressionResultDescription")
: t(
"roleMappingRawExpressionResultDescriptionSingleRole"
)}
</FormDescription> </FormDescription>
</div> </div>
)} )}
@@ -265,6 +340,8 @@ function BuilderRuleRow({
showFreeformRoleNamesHint, showFreeformRoleNamesHint,
fieldIdPrefix, fieldIdPrefix,
mappingRulesGridClass, mappingRulesGridClass,
supportsMultipleRolesPerUser,
showRemoveButton,
onChange, onChange,
onRemove onRemove
}: { }: {
@@ -274,6 +351,8 @@ function BuilderRuleRow({
showFreeformRoleNamesHint: boolean; showFreeformRoleNamesHint: boolean;
fieldIdPrefix: string; fieldIdPrefix: string;
mappingRulesGridClass: string; mappingRulesGridClass: string;
supportsMultipleRolesPerUser: boolean;
showRemoveButton: boolean;
onChange: (rule: MappingBuilderRule) => void; onChange: (rule: MappingBuilderRule) => void;
onRemove: () => void; onRemove: () => void;
}) { }) {
@@ -311,20 +390,44 @@ function BuilderRuleRow({
text: name text: name
}))} }))}
setTags={(nextTags) => { setTags={(nextTags) => {
const prevRoleTags = rule.roleNames.map(
(name) => ({
id: name,
text: name
})
);
const next = const next =
typeof nextTags === "function" typeof nextTags === "function"
? nextTags( ? nextTags(prevRoleTags)
rule.roleNames.map((name) => ({
id: name,
text: name
}))
)
: nextTags; : 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({ onChange({
...rule, ...rule,
roleNames: [ roleNames: names
...new Set(next.map((tag) => tag.text))
]
}); });
}} }}
activeTagIndex={activeTagIndex} activeTagIndex={activeTagIndex}
@@ -351,16 +454,18 @@ function BuilderRuleRow({
</p> </p>
)} )}
</div> </div>
<div className="flex min-w-0 justify-end md:justify-start md:pt-0"> {showRemoveButton ? (
<Button <div className="flex min-w-0 justify-end md:justify-start md:pt-0">
type="button" <Button
variant="outline" type="button"
className="h-9 shrink-0 px-2" variant="outline"
onClick={onRemove} className="h-9 shrink-0 px-2"
> onClick={onRemove}
{t("roleMappingRemoveRule")} >
</Button> {t("roleMappingRemoveRule")}
</div> </Button>
</div>
) : null}
</div> </div>
); );
} }