feat(core): Make user role provisioning available on enterprise (#22166)

This commit is contained in:
Konstantin Tieber
2025-11-24 13:21:35 +01:00
committed by GitHub
parent 60a9cbf133
commit 6786915537
9 changed files with 62 additions and 81 deletions
@@ -175,7 +175,7 @@ export class LicenseState {
}
isProvisioningLicensed() {
return this.isLicensed(['feat:saml', 'feat:oidc', 'feat:ldap']);
return this.isLicensed(['feat:saml', 'feat:oidc']);
}
// --------------------
@@ -83,10 +83,6 @@ export const PERSONALIZED_TEMPLATES_V3 = {
variant: 'variant',
};
export const SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT = {
name: '050_sso_jit_provisioning',
};
export const EXPERIMENTS_TO_TRACK = [
EXTRA_TEMPLATE_LINKS_EXPERIMENT.name,
TEMPLATE_ONBOARDING_EXPERIMENT.name,
@@ -96,5 +92,4 @@ export const EXPERIMENTS_TO_TRACK = [
TEMPLATE_RECO_V2.name,
TEMPLATES_DATA_QUALITY_EXPERIMENT.name,
READY_TO_RUN_V2_PART2_EXPERIMENT.name,
SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT.name,
];
@@ -5,10 +5,9 @@ import { SupportedProtocols, useSSOStore } from '../sso.store';
import { useI18n } from '@n8n/i18n';
import { ElCheckbox } from 'element-plus';
import { N8nActionBox, N8nButton, N8nInput, N8nOption, N8nSelect } from '@n8n/design-system';
import { N8nButton, N8nInput, N8nOption, N8nSelect } from '@n8n/design-system';
import { computed, onMounted, ref } from 'vue';
import { useToast } from '@/app/composables/useToast';
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
import { useMessage } from '@/app/composables/useMessage';
import UserRoleProvisioningDropdown, {
type UserRoleProvisioningSetting,
@@ -24,7 +23,6 @@ const ssoStore = useSSOStore();
const telemetry = useTelemetry();
const toast = useToast();
const message = useMessage();
const pageRedirectionHelper = usePageRedirectionHelper();
const instanceId = useRootStore().instanceId;
const savingForm = ref<boolean>(false);
@@ -189,16 +187,12 @@ function sendTrackingEventForUserProvisioning() {
});
}
const goToUpgrade = () => {
void pageRedirectionHelper.goToUpgrade('sso', 'upgrade-sso');
};
onMounted(async () => {
await loadOidcConfig();
});
</script>
<template>
<div v-if="ssoStore.isEnterpriseOidcEnabled">
<div>
<div :class="$style.group">
<label>Redirect URL</label>
<CopyInput
@@ -298,16 +292,5 @@ onMounted(async () => {
</N8nButton>
</div>
</div>
<N8nActionBox
v-else
data-test-id="sso-content-unlicensed"
:class="$style.actionBox"
:button-text="i18n.baseText('settings.sso.actionBox.buttonText')"
@click:button="goToUpgrade"
>
<template #heading>
<span>{{ i18n.baseText('settings.sso.actionBox.title') }}</span>
</template>
</N8nActionBox>
</template>
<style lang="scss" module src="../styles/sso-form.module.scss" />
@@ -6,9 +6,8 @@ import { useI18n } from '@n8n/i18n';
import { captureMessage } from '@sentry/vue';
import { ElCheckbox } from 'element-plus';
import { N8nActionBox, N8nButton, N8nInput, N8nRadioButtons } from '@n8n/design-system';
import { N8nButton, N8nInput, N8nRadioButtons } from '@n8n/design-system';
import { useToast } from '@/app/composables/useToast';
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
import { useMessage } from '@/app/composables/useMessage';
import { computed, onMounted, ref } from 'vue';
import UserRoleProvisioningDropdown, {
@@ -25,7 +24,6 @@ const ssoStore = useSSOStore();
const telemetry = useTelemetry();
const toast = useToast();
const message = useMessage();
const pageRedirectionHelper = usePageRedirectionHelper();
const instanceId = useRootStore().instanceId;
const savingForm = ref<boolean>(false);
@@ -278,16 +276,12 @@ const validateSamlInput = () => {
}
};
const goToUpgrade = () => {
void pageRedirectionHelper.goToUpgrade('sso', 'upgrade-sso');
};
onMounted(async () => {
await loadSamlConfig();
});
</script>
<template>
<div v-if="ssoStore.isEnterpriseSamlEnabled" data-test-id="sso-content-licensed">
<div>
<div :class="$style.group">
<label>{{ i18n.baseText('settings.sso.settings.redirectUrl.label') }}</label>
<CopyInput
@@ -367,18 +361,6 @@ onMounted(async () => {
</N8nButton>
</div>
</div>
<N8nActionBox
v-else
data-test-id="sso-content-unlicensed"
:class="$style.actionBox"
:description="i18n.baseText('settings.sso.actionBox.description')"
:button-text="i18n.baseText('settings.sso.actionBox.buttonText')"
@click:button="goToUpgrade"
>
<template #heading>
<span>{{ i18n.baseText('settings.sso.actionBox.title') }}</span>
</template>
</N8nActionBox>
</template>
<style lang="scss" module src="../styles/sso-form.module.scss" />
@@ -1,13 +1,12 @@
<script lang="ts" setup>
import { SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT } from '@/app/constants';
import type { ProvisioningConfig } from '@n8n/rest-api-client/api/provisioning';
import { N8nOption, N8nSelect } from '@n8n/design-system';
import { onMounted } from 'vue';
import { usePostHog } from '@/app/stores/posthog.store';
import { useUserRoleProvisioningStore } from '../composables/userRoleProvisioning.store';
import { useI18n } from '@n8n/i18n';
import { type SupportedProtocolType } from '../../sso.store';
import { useRBACStore } from '@/app/stores/rbac.store';
export type UserRoleProvisioningSetting =
| 'disabled'
@@ -21,12 +20,8 @@ const { authProtocol } = defineProps<{
}>();
const i18n = useI18n();
const posthogStore = usePostHog();
const userRoleProvisioningStore = useUserRoleProvisioningStore();
const isUserRoleProvisioningFeatureEnabled = posthogStore.isFeatureEnabled(
SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT.name,
);
const canManageUserProvisioning = useRBACStore().hasScope('provisioning:manage');
const handleUserRoleProvisioningChange = (newValue: UserRoleProvisioningSetting) => {
value.value = newValue;
@@ -79,11 +74,11 @@ onMounted(async () => {
});
</script>
<template>
<!-- TODO: also check for 'provisioning:manage' permission scope -->
<div v-if="isUserRoleProvisioningFeatureEnabled" :class="$style.group">
<div :class="$style.group">
<label>{{ i18n.baseText('settings.sso.settings.userRoleProvisioning.label') }}</label>
<N8nSelect
:model-value="value"
:disabled="!canManageUserProvisioning"
data-test-id="oidc-user-role-provisioning"
:class="$style.userRoleProvisioningSelect"
@update:model-value="handleUserRoleProvisioningChange"
@@ -104,7 +104,11 @@ export function useAccessSettingsCsvExport() {
for (const project of user.projectRelations) {
const projectName = escapeCsvValue(project.name ?? '');
const projectId = escapeCsvValue(project.id ?? '');
const projectRole = escapeCsvValue(project.role ?? '');
// In our backend, project roles are stored like this: project:viewer
// But to make the configuration of project role provisioning easier,
// we omit this part of the role value in the SSO config.
const roleValueForProvisioning = project.role.split(':')[1] ?? project.role;
const projectRole = escapeCsvValue(roleValueForProvisioning);
csvRows.push(`${email},${projectName},${projectId},${projectRole}`);
}
}
@@ -1,7 +1,5 @@
import type { Ref } from 'vue';
import { useUserRoleProvisioningStore } from './userRoleProvisioning.store';
import { usePostHog } from '@/app/stores/posthog.store';
import { SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT } from '@/app/constants/experiments';
import type { ProvisioningConfig } from '@n8n/rest-api-client/api/provisioning';
import { type UserRoleProvisioningSetting } from '../components/UserRoleProvisioningDropdown.vue';
@@ -12,7 +10,6 @@ export function useUserRoleProvisioningForm(
userRoleProvisioning: Ref<UserRoleProvisioningSetting>,
) {
const provisioningStore = useUserRoleProvisioningStore();
const posthogStore = usePostHog();
const getUserRoleProvisioningValueFromConfig = (
config?: ProvisioningConfig,
@@ -51,9 +48,6 @@ export function useUserRoleProvisioningForm(
};
const isUserRoleProvisioningChanged = (): boolean => {
if (!posthogStore.isFeatureEnabled(SSO_JUST_IN_TIME_PROVSIONING_EXPERIMENT.name)) {
return false;
}
return (
getUserRoleProvisioningValueFromConfig(provisioningStore.provisioningConfig) !==
userRoleProvisioning.value
@@ -392,10 +392,10 @@ describe('SettingsSso View', () => {
});
ssoStore.saveOidcConfig.mockResolvedValue({ ...oidcConfig, loginEnabled: true });
const { getByTestId, getByRole } = renderView();
const { getByTestId, getByRole, getAllByRole } = renderView();
// Set authProtocol component ref to OIDC
const protocolSelect = getByRole('combobox');
const protocolSelect = getAllByRole('combobox')[0];
expect(protocolSelect).toBeInTheDocument();
await userEvent.click(protocolSelect);
@@ -463,11 +463,11 @@ describe('SettingsSso View', () => {
discoveryEndpoint: '',
});
const { getByTestId, getByRole } = renderView();
const { getByTestId, getByRole, getAllByRole } = renderView();
showError.mockClear();
// Set authProtocol component ref to OIDC
const protocolSelect = getByRole('combobox');
const protocolSelect = getAllByRole('combobox')[0];
expect(protocolSelect).toBeInTheDocument();
await userEvent.click(protocolSelect);
@@ -519,10 +519,10 @@ describe('SettingsSso View', () => {
ssoStore.getSamlConfig.mockResolvedValue(samlConfig);
ssoStore.getOidcConfig.mockResolvedValue(oidcConfig);
const { getByRole } = renderView();
const { getByRole, getAllByRole } = renderView();
// Change protocol selection in dropdown
const protocolSelect = getByRole('combobox');
const protocolSelect = getAllByRole('combobox')[0];
await userEvent.click(protocolSelect);
const dropdown = await waitFor(() => getByRole('listbox'));
@@ -542,10 +542,10 @@ describe('SettingsSso View', () => {
ssoStore.getSamlConfig.mockResolvedValue(samlConfig);
ssoStore.getOidcConfig.mockResolvedValue(oidcConfig);
const { getByRole, getByTestId } = renderView();
const { getByRole, getByTestId, getAllByRole } = renderView();
// Change to SAML protocol in dropdown
const protocolSelect = getByRole('combobox');
const protocolSelect = getAllByRole('combobox')[0];
await userEvent.click(protocolSelect);
const dropdown = await waitFor(() => getByRole('listbox'));
@@ -578,10 +578,10 @@ describe('SettingsSso View', () => {
ssoStore.getOidcConfig.mockResolvedValue(oidcConfig);
ssoStore.saveOidcConfig.mockResolvedValue(oidcConfig);
const { getByRole, getByTestId } = renderView();
const { getByRole, getByTestId, getAllByRole } = renderView();
// Change to OIDC protocol in dropdown
const protocolSelect = getByRole('combobox');
const protocolSelect = getAllByRole('combobox')[0];
await userEvent.click(protocolSelect);
const dropdown = await waitFor(() => getByRole('listbox'));
@@ -641,14 +641,14 @@ describe('SettingsSso View', () => {
ssoStore.getSamlConfig.mockResolvedValue(samlConfig);
ssoStore.getOidcConfig.mockResolvedValue(oidcConfig);
const { getByRole, getByTestId, queryByTestId } = renderView();
const { getByRole, getAllByRole, getByTestId, queryByTestId } = renderView();
// Initially should show SAML content (matching store)
expect(getByTestId('sso-provider-url')).toBeVisible();
expect(queryByTestId('oidc-discovery-endpoint')).not.toBeInTheDocument();
// Change to OIDC in dropdown (local state only)
const protocolSelect = getByRole('combobox');
const protocolSelect = getAllByRole('combobox')[0];
await userEvent.click(protocolSelect);
const dropdown = await waitFor(() => getByRole('listbox'));
@@ -1,16 +1,18 @@
<script lang="ts" setup>
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
import { useSSOStore, SupportedProtocols, type SupportedProtocolType } from '../sso.store';
import { useI18n } from '@n8n/i18n';
import { computed, onMounted, ref } from 'vue';
import { N8nHeading, N8nInfoTip, N8nOption, N8nSelect } from '@n8n/design-system';
import { N8nActionBox, N8nHeading, N8nInfoTip, N8nOption, N8nSelect } from '@n8n/design-system';
import SamlSettingsForm from '../components/SamlSettingsForm.vue';
import OidcSettingsForm from '../components/OidcSettingsForm.vue';
const i18n = useI18n();
const ssoStore = useSSOStore();
const documentTitle = useDocumentTitle();
const pageRedirectionHelper = usePageRedirectionHelper();
const options = computed(() => {
return [
@@ -27,11 +29,19 @@ const options = computed(() => {
];
});
const hasAnySsoEnabled = computed(
() => ssoStore.isEnterpriseSamlEnabled || ssoStore.isEnterpriseOidcEnabled,
);
const authProtocol = ref<SupportedProtocolType>(SupportedProtocols.SAML);
function onAuthProtocolUpdated(value: SupportedProtocolType) {
authProtocol.value = value;
}
const goToUpgrade = () => {
void pageRedirectionHelper.goToUpgrade('sso', 'upgrade-sso');
};
onMounted(() => {
documentTitle.set(i18n.baseText('settings.sso.title'));
ssoStore.initializeSelectedProtocol();
@@ -50,11 +60,7 @@ onMounted(() => {
{{ i18n.baseText('settings.sso.info.link') }}
</a>
</N8nInfoTip>
<div
v-if="ssoStore.isEnterpriseSamlEnabled || ssoStore.isEnterpriseOidcEnabled"
data-test-id="sso-auth-protocol-select"
:class="shared.group"
>
<div v-if="hasAnySsoEnabled" data-test-id="sso-auth-protocol-select" :class="shared.group">
<label>Select Authentication Protocol</label>
<div>
<N8nSelect
@@ -75,12 +81,30 @@ onMounted(() => {
</N8nSelect>
</div>
</div>
<div v-if="authProtocol === SupportedProtocols.SAML">
<div
v-if="ssoStore.isEnterpriseSamlEnabled && authProtocol === SupportedProtocols.SAML"
data-test-id="sso-content-licensed"
>
<SamlSettingsForm />
</div>
<div v-if="authProtocol === SupportedProtocols.OIDC">
<div
v-if="ssoStore.isEnterpriseOidcEnabled && authProtocol === SupportedProtocols.OIDC"
data-test-id="sso-content-licensed"
>
<OidcSettingsForm />
</div>
<N8nActionBox
v-if="!hasAnySsoEnabled"
data-test-id="sso-content-unlicensed"
:class="$style.actionBox"
:description="i18n.baseText('settings.sso.actionBox.description')"
:button-text="i18n.baseText('settings.sso.actionBox.buttonText')"
@click:button="goToUpgrade"
>
<template #heading>
<span>{{ i18n.baseText('settings.sso.actionBox.title') }}</span>
</template>
</N8nActionBox>
</div>
</template>
@@ -90,4 +114,8 @@ onMounted(() => {
.heading {
margin-bottom: var(--spacing--sm);
}
.actionBox {
margin-top: var(--spacing--lg);
}
</style>