feat(editor): Add routing middleware, permission checks, RBAC store, RBAC component (#7702)

Github issue / Community forum post (link here to close automatically):

---------

Co-authored-by: Csaba Tuncsik <csaba@n8n.io>
This commit is contained in:
Alex Grozav 2023-11-23 13:22:47 +02:00 committed by GitHub
parent fdb2c18ecc
commit 67a88914f2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
62 changed files with 1935 additions and 646 deletions

View file

@ -12,14 +12,14 @@ export function hasScope(
): boolean;
export function hasScope(
scope: Scope | Scope[],
userScopes: unknown,
userScopes: GlobalScopes | ScopeLevels,
options: ScopeOptions = { mode: 'oneOf' },
): boolean {
if (!Array.isArray(scope)) {
scope = [scope];
}
const userScopeSet = new Set(Object.values(userScopes ?? {}).flat());
const userScopeSet = new Set(Object.values(userScopes).flat());
if (options.mode === 'allOf') {
return !!scope.length && scope.every((s) => userScopeSet.has(s));

View file

@ -10,8 +10,9 @@ export type Resource =
export type ResourceScope<
R extends Resource,
Operations extends string = DefaultOperations,
> = `${R}:${Operations}`;
Operation extends string = DefaultOperations,
> = `${R}:${Operation}`;
export type WildcardScope = `${Resource}:*` | '*';
export type WorkflowScope = ResourceScope<'workflow', DefaultOperations | 'share'>;

View file

@ -55,7 +55,9 @@ import {
N8nUserStack,
} from './components';
export const N8nPlugin: Plugin<{}> = {
export interface N8nPluginOptions {}
export const N8nPlugin: Plugin<N8nPluginOptions> = {
install: (app) => {
app.component('n8n-action-box', N8nActionBox);
app.component('n8n-action-dropdown', N8nActionDropdown);

View file

@ -47,6 +47,7 @@
"@jsplumb/util": "^5.13.2",
"@lezer/common": "^1.0.4",
"@n8n/codemirror-lang-sql": "^1.0.2",
"@n8n/permissions": "workspace:*",
"@vueuse/components": "^10.5.0",
"@vueuse/core": "^10.5.0",
"axios": "^0.21.1",

View file

@ -35,7 +35,6 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { mapStores } from 'pinia';
import { extendExternalHooks } from '@/mixins/externalHooks';
import { newVersions } from '@/mixins/newVersions';
import BannerStack from '@/components/banners/BannerStack.vue';
@ -62,6 +61,7 @@ import {
import { useHistoryHelper } from '@/composables/useHistoryHelper';
import { useRoute } from 'vue-router';
import { runExternalHook } from '@/utils';
import { initializeAuthenticatedFeatures } from '@/init';
export default defineComponent({
name: 'App',
@ -114,69 +114,22 @@ export default defineComponent({
console.log(HIRING_BANNER);
}
},
async initializeCloudData() {
await this.cloudPlanStore.checkForCloudPlanData();
await this.cloudPlanStore.fetchUserCloudAccount();
},
async initializeTemplates() {
if (this.settingsStore.isTemplatesEnabled) {
try {
await this.settingsStore.testTemplatesEndpoint();
} catch (e) {}
}
},
async initializeSourceControl() {
if (this.sourceControlStore.isEnterpriseSourceControlEnabled) {
await this.sourceControlStore.getPreferences();
}
},
async initializeNodeTranslationHeaders() {
if (this.defaultLocale !== 'en') {
await this.nodeTypesStore.getNodeTranslationHeaders();
}
},
async initializeHooks(): Promise<void> {
if (this.settingsStore.isCloudDeployment) {
const { n8nCloudHooks } = await import('@/hooks/cloud');
extendExternalHooks(n8nCloudHooks);
}
},
async onAfterAuthenticate() {
if (this.onAfterAuthenticateInitialized) {
return;
}
if (!this.usersStore.currentUser) {
return;
}
await Promise.all([
this.initializeSourceControl(),
this.initializeTemplates(),
this.initializeNodeTranslationHeaders(),
]);
this.onAfterAuthenticateInitialized = true;
},
},
async mounted() {
this.logHiringBanner();
await this.settingsStore.initialize();
await this.initializeHooks();
await this.initializeCloudData();
void this.checkForNewVersions();
void this.onAfterAuthenticate();
void initializeAuthenticatedFeatures();
void runExternalHook('app.mount');
this.pushStore.pushConnect();
this.loading = false;
},
watch: {
// eslint-disable-next-line @typescript-eslint/naming-convention
async 'usersStore.currentUser'(currentValue, previousValue) {
if (currentValue && !previousValue) {
await this.onAfterAuthenticate();
await initializeAuthenticatedFeatures();
}
},
defaultLocale(newLocale) {

View file

@ -49,6 +49,7 @@ import type {
import type { BulkCommand, Undoable } from '@/models/history';
import type { PartialBy, TupleToUnion } from '@/utils/typeHelpers';
import type { Component } from 'vue';
import type { Scope } from '@n8n/permissions';
import type { runExternalHook } from '@/utils';
export * from 'n8n-design-system/types';
@ -682,6 +683,7 @@ export interface IUserResponse {
id: string;
createdAt: Date;
};
globalScopes?: Scope[];
personalizationAnswers?: IPersonalizationSurveyVersions | null;
isPending: boolean;
signInType?: SignInType;
@ -1682,6 +1684,7 @@ export declare namespace Cloud {
}
export interface CloudPlanState {
initialized: boolean;
data: Cloud.PlanData | null;
usage: InstanceUsage | null;
loadingPlan: boolean;

View file

@ -44,4 +44,14 @@ describe('parsePermissionsTable()', () => {
expect(permissions.canRead).toBe(true);
});
it('should pass permission to test functions', () => {
const permissions = parsePermissionsTable(user, [
{ name: 'canRead', test: (p) => !!p.isInstanceOwner },
{ name: 'canUpdate', test: (p) => !!p.canRead },
]);
expect(permissions.canRead).toBe(true);
expect(permissions.canUpdate).toBe(true);
});
});

View file

@ -122,6 +122,8 @@ import {
import { isNavigationFailure } from 'vue-router';
import ExecutionsUsage from '@/components/ExecutionsUsage.vue';
import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue';
import { ROLE } from '@/utils';
import { hasPermission } from '@/rbac/permissions';
export default defineComponent({
name: 'MainSidebar',
@ -177,7 +179,9 @@ export default defineComponent({
return accessibleRoute !== null;
},
showUserArea(): boolean {
return this.usersStore.canUserAccessSidebarUserInfo && this.usersStore.currentUser !== null;
return hasPermission(['role'], {
role: [ROLE.Member, ROLE.Owner],
});
},
workflowExecution(): IExecutionResponse | null {
return this.workflowsStore.getWorkflowExecution;
@ -347,7 +351,7 @@ export default defineComponent({
};
},
},
mounted() {
async mounted() {
this.basePath = this.rootStore.baseUrl;
if (this.$refs.user) {
void this.$externalHooks().run('mainSidebar.mounted', {

View file

@ -0,0 +1,61 @@
<script lang="ts">
import type { PropType } from 'vue';
import { computed, defineComponent } from 'vue';
import { useRBACStore } from '@/stores/rbac.store';
import type { HasScopeMode, Scope, Resource } from '@n8n/permissions';
import {
inferProjectIdFromRoute,
inferResourceIdFromRoute,
inferResourceTypeFromRoute,
} from '@/utils/rbacUtils';
import { useRoute } from 'vue-router';
export default defineComponent({
props: {
scope: {
type: [String, Array] as PropType<Scope | Scope[]>,
required: true,
},
mode: {
type: String as PropType<HasScopeMode>,
default: 'allOf',
},
resourceType: {
type: String as PropType<Resource>,
default: undefined,
},
resourceId: {
type: String,
default: undefined,
},
projectId: {
type: String,
default: undefined,
},
},
setup(props, { slots }) {
const rbacStore = useRBACStore();
const route = useRoute();
const hasScope = computed(() => {
const projectId = props.projectId ?? inferProjectIdFromRoute(route);
const resourceType = props.resourceType ?? inferResourceTypeFromRoute(route);
const resourceId = resourceType
? props.resourceId ?? inferResourceIdFromRoute(route)
: undefined;
return rbacStore.hasScope(
props.scope,
{
projectId,
resourceType,
resourceId,
},
{ mode: props.mode },
);
});
return () => (hasScope.value ? slots.default?.() : slots.fallback?.());
},
});
</script>

View file

@ -30,6 +30,7 @@ import TagsTableHeader from '@/components/TagsManager/TagsView/TagsTableHeader.v
import TagsTable from '@/components/TagsManager/TagsView/TagsTable.vue';
import { mapStores } from 'pinia';
import { useUsersStore } from '@/stores/users.store';
import { useRBACStore } from '@/stores/rbac.store';
const matches = (name: string, filter: string) =>
name.toLowerCase().trim().includes(filter.toLowerCase().trim());
@ -50,7 +51,7 @@ export default defineComponent({
};
},
computed: {
...mapStores(useUsersStore),
...mapStores(useUsersStore, useRBACStore),
isCreateEnabled(): boolean {
return (this.tags || []).length === 0 || this.createEnabled;
},
@ -70,7 +71,7 @@ export default defineComponent({
disable: disabled && tag.id !== this.deleteId && tag.id !== this.updateId,
update: disabled && tag.id === this.updateId,
delete: disabled && tag.id === this.deleteId,
canDelete: this.usersStore.canUserDeleteTags,
canDelete: this.rbacStore.hasScope('tag:delete'),
}),
);

View file

@ -0,0 +1,50 @@
import RBAC from '@/components/RBAC.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { useRBACStore } from '@/stores/rbac.store';
const renderComponent = createComponentRenderer(RBAC);
vi.mock('vue-router', () => ({
useRoute: vi.fn(() => ({
path: '/workflows',
params: {},
})),
}));
vi.mock('@/stores/rbac.store', () => ({
useRBACStore: vi.fn(),
}));
describe('RBAC', () => {
it('renders default slot when hasScope is true', async () => {
vi.mocked(useRBACStore).mockImplementation(() => ({
hasScope: () => true,
}));
const wrapper = renderComponent({
props: { scope: 'worfklow:list' },
slots: {
default: 'Default Content',
fallback: 'Fallback Content',
},
});
expect(wrapper.getByText('Default Content')).toBeInTheDocument();
});
it('renders fallback slot when hasScope is false', async () => {
vi.mocked(useRBACStore).mockImplementation(() => ({
hasScope: () => false,
}));
const wrapper = renderComponent({
props: { scope: 'worfklow:list' },
slots: {
default: 'Default Content',
fallback: 'Fallback Content',
},
});
expect(wrapper.getByText('Fallback Content')).toBeInTheDocument();
});
});

View file

@ -567,6 +567,7 @@ export const enum STORES {
WEBHOOKS = 'webhooks',
HISTORY = 'history',
CLOUD_PLAN = 'cloudPlan',
RBAC = 'rbac',
COLLABORATION = 'collaboration',
PUSH = 'push',
}

View file

@ -0,0 +1,12 @@
import { extendExternalHooks } from '@/mixins/externalHooks';
let cloudHooksInitialized = false;
export async function initializeCloudHooks() {
if (cloudHooksInitialized) {
return;
}
const { n8nCloudHooks } = await import('@/hooks/cloud');
extendExternalHooks(n8nCloudHooks);
cloudHooksInitialized = true;
}

View file

@ -0,0 +1,67 @@
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useRootStore } from '@/stores/n8nRoot.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUsersStore } from '@/stores/users.store';
import { initializeCloudHooks } from '@/hooks/register';
let coreInitialized = false;
let authenticatedFeaturesInitialized = false;
/**
* Initializes the core application stores and hooks
* This is called once, when the first route is loaded.
*/
export async function initializeCore() {
if (coreInitialized) {
return;
}
const settingsStore = useSettingsStore();
const cloudPlanStore = useCloudPlanStore();
const usersStore = useUsersStore();
await settingsStore.initialize();
await usersStore.initialize();
if (settingsStore.isCloudDeployment) {
await Promise.all([cloudPlanStore.initialize(), initializeCloudHooks()]);
}
coreInitialized = true;
}
/**
* Initializes the features of the application that require an authenticated user
*/
export async function initializeAuthenticatedFeatures() {
if (authenticatedFeaturesInitialized) {
return;
}
const usersStore = useUsersStore();
if (!usersStore.currentUser) {
return;
}
const sourceControlStore = useSourceControlStore();
const settingsStore = useSettingsStore();
const rootStore = useRootStore();
const nodeTypesStore = useNodeTypesStore();
if (sourceControlStore.isEnterpriseSourceControlEnabled) {
await sourceControlStore.getPreferences();
}
if (settingsStore.isTemplatesEnabled) {
try {
await settingsStore.testTemplatesEndpoint();
} catch (e) {}
}
if (rootStore.defaultLocale !== 'en') {
await nodeTypesStore.getNodeTranslationHeaders();
}
authenticatedFeaturesInitialized = true;
}

View file

@ -1,27 +1,30 @@
import type { IPermissions } from '@/Interface';
import { isAuthorized } from '@/utils';
import { useUsersStore } from '@/stores/users.store';
import { defineComponent } from 'vue';
import type { RouteLocation } from 'vue-router';
import { hasPermission } from '@/rbac/permissions';
import type { RouteConfig } from '@/types/router';
import type { PermissionTypeOptions } from '@/types/rbac';
export const userHelpers = defineComponent({
methods: {
canUserAccessRouteByName(name: string): boolean {
canUserAccessRouteByName(name: string) {
const route = this.$router.resolve({ name });
return this.canUserAccessRoute(route);
},
canUserAccessCurrentRoute(): boolean {
canUserAccessCurrentRoute() {
return this.canUserAccessRoute(this.$route);
},
canUserAccessRoute(route: RouteLocation): boolean {
const permissions: IPermissions = route.meta?.permissions;
const usersStore = useUsersStore();
const currentUser = usersStore.currentUser;
canUserAccessRoute(route: RouteLocation & RouteConfig) {
const middleware = route.meta?.middleware;
const middlewareOptions = route.meta?.middlewareOptions;
return permissions && isAuthorized(permissions, currentUser);
if (!middleware) {
return true;
}
return hasPermission(middleware, middlewareOptions as PermissionTypeOptions | undefined);
},
},
});

View file

@ -6,7 +6,13 @@
import type { IUser, ICredentialsResponse, IWorkflowDb } from '@/Interface';
import { EnterpriseEditionFeature, PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants';
import { useSettingsStore } from './stores/settings.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useRBACStore } from '@/stores/rbac.store';
/**
* Old permissions implementation
* @deprecated
*/
export const enum UserRole {
InstanceOwner = 'isInstanceOwner',
@ -36,15 +42,20 @@ export const parsePermissionsTable = (
user: IUser | null,
table: IPermissionsTable,
): IPermissions => {
const genericTable = [{ name: UserRole.InstanceOwner, test: () => user?.isOwner }];
const genericTable: IPermissionsTable = [
{ name: UserRole.InstanceOwner, test: () => !!user?.isOwner },
];
return [...genericTable, ...table].reduce((permissions: IPermissions, row) => {
permissions[row.name] = Array.isArray(row.test)
? row.test.some((ability) => permissions[ability])
: (row.test as IPermissionsTableRowTestFn)(permissions);
return [...genericTable, ...table].reduce(
(permissions: IPermissions, row: IPermissionsTableRow) => {
permissions[row.name] = Array.isArray(row.test)
? row.test.some((ability) => permissions[ability])
: row.test(permissions);
return permissions;
}, {});
return permissions;
},
{},
);
};
/**
@ -60,17 +71,12 @@ export const getCredentialPermissions = (user: IUser | null, credential: ICreden
const table: IPermissionsTable = [
{
name: UserRole.ResourceOwner,
test: () =>
!!(credential?.ownedBy && credential.ownedBy.id === user?.id) || !isSharingEnabled,
test: () => !!(credential?.ownedBy?.id === user?.id) || !isSharingEnabled,
},
{
name: UserRole.ResourceSharee,
test: () => !!credential?.sharedWith?.find((sharee) => sharee.id === user?.id),
},
{
name: 'read',
test: [UserRole.ResourceOwner, UserRole.InstanceOwner, UserRole.ResourceSharee],
},
{ name: 'save', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },
{ name: 'updateName', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },
{ name: 'updateConnection', test: [UserRole.ResourceOwner] },
@ -85,6 +91,7 @@ export const getCredentialPermissions = (user: IUser | null, credential: ICreden
export const getWorkflowPermissions = (user: IUser | null, workflow: IWorkflowDb) => {
const settingsStore = useSettingsStore();
const rbacStore = useRBACStore();
const isSharingEnabled = settingsStore.isEnterpriseFeatureEnabled(
EnterpriseEditionFeature.Sharing,
);
@ -93,27 +100,15 @@ export const getWorkflowPermissions = (user: IUser | null, workflow: IWorkflowDb
const table: IPermissionsTable = [
{
name: UserRole.ResourceOwner,
test: () =>
!!(isNewWorkflow || (workflow?.ownedBy && workflow.ownedBy.id === user?.id)) ||
!isSharingEnabled,
test: () => !!(isNewWorkflow || workflow?.ownedBy?.id === user?.id) || !isSharingEnabled,
},
{
name: UserRole.ResourceSharee,
test: () => !!workflow?.sharedWith?.find((sharee) => sharee.id === user?.id),
name: 'updateSharing',
test: (permissions) => rbacStore.hasScope('workflow:update') || !!permissions.isOwner,
},
{
name: 'read',
test: [UserRole.ResourceOwner, UserRole.InstanceOwner, UserRole.ResourceSharee],
},
{ name: 'save', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },
{ name: 'updateName', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },
{ name: 'updateConnection', test: [UserRole.ResourceOwner] },
{ name: 'updateSharing', test: [UserRole.ResourceOwner] },
{ name: 'updateNodeAccess', test: [UserRole.ResourceOwner] },
{ name: 'delete', test: [UserRole.ResourceOwner, UserRole.InstanceOwner] },
{
name: 'use',
test: [UserRole.ResourceOwner, UserRole.InstanceOwner, UserRole.ResourceSharee],
name: 'delete',
test: (permissions) => rbacStore.hasScope('workflow:delete') || !!permissions.isOwner,
},
];
@ -121,20 +116,12 @@ export const getWorkflowPermissions = (user: IUser | null, workflow: IWorkflowDb
};
export const getVariablesPermissions = (user: IUser | null) => {
const rbacStore = useRBACStore();
const table: IPermissionsTable = [
{
name: 'create',
test: [UserRole.InstanceOwner],
},
{
name: 'edit',
test: [UserRole.InstanceOwner],
},
{
name: 'delete',
test: [UserRole.InstanceOwner],
},
{ name: 'use', test: () => true },
{ name: 'create', test: () => rbacStore.hasScope('variable:create') },
{ name: 'edit', test: () => rbacStore.hasScope('variable:update') },
{ name: 'delete', test: () => rbacStore.hasScope('variable:delete') },
{ name: 'use', test: () => rbacStore.hasScope('variable:read') },
];
return parsePermissionsTable(user, table);

View file

@ -6,12 +6,14 @@ import ElementPlus, { ElLoading, ElMessageBox } from 'element-plus';
import { N8nPlugin } from 'n8n-design-system';
import { useMessage } from '@/composables/useMessage';
import EnterpriseEdition from '@/components/EnterpriseEdition.ee.vue';
import RBAC from '@/components/RBAC.vue';
export const GlobalComponentsPlugin: Plugin<{}> = {
install(app) {
const messageService = useMessage();
app.component('enterprise-edition', EnterpriseEdition);
app.component('RBAC', RBAC);
app.use(ElementPlus);
app.use(N8nPlugin);

View file

@ -0,0 +1,63 @@
import { hasPermission } from '@/rbac/permissions';
import * as checks from '@/rbac/checks';
vi.mock('@/rbac/checks', () => ({
hasRole: vi.fn(),
hasScope: vi.fn(),
isGuest: vi.fn(),
isAuthenticated: vi.fn(),
isEnterpriseFeatureEnabled: vi.fn(),
isValid: vi.fn(),
}));
describe('hasPermission()', () => {
it('should return true if all permissions are valid', () => {
vi.mocked(checks.hasRole).mockReturnValue(true);
vi.mocked(checks.hasScope).mockReturnValue(true);
vi.mocked(checks.isGuest).mockReturnValue(true);
vi.mocked(checks.isAuthenticated).mockReturnValue(true);
vi.mocked(checks.isEnterpriseFeatureEnabled).mockReturnValue(true);
vi.mocked(checks.isValid).mockReturnValue(true);
expect(hasPermission(['authenticated', 'custom', 'enterprise', 'guest', 'rbac', 'role'])).toBe(
true,
);
});
it('should return false if any permission is invalid', () => {
vi.mocked(checks.hasRole).mockReturnValue(true);
vi.mocked(checks.isGuest).mockReturnValue(true);
vi.mocked(checks.isAuthenticated).mockReturnValue(true);
vi.mocked(checks.isEnterpriseFeatureEnabled).mockReturnValue(true);
vi.mocked(checks.isValid).mockReturnValue(true);
vi.mocked(checks.hasScope).mockReturnValue(false);
expect(hasPermission(['authenticated', 'custom', 'enterprise', 'guest', 'rbac', 'role'])).toBe(
false,
);
});
it('should return true for a specific valid permission', () => {
vi.mocked(checks.isAuthenticated).mockReturnValue(true);
expect(hasPermission(['authenticated'])).toBe(true);
});
it('should return false for a specific invalid permission', () => {
vi.mocked(checks.isGuest).mockReturnValue(false);
expect(hasPermission(['guest'])).toBe(false);
});
it('should call permission function with given permission options', () => {
const customFn = () => true;
vi.mocked(checks.isValid).mockReturnValue(true);
hasPermission(['custom'], {
custom: customFn,
});
expect(checks.isValid).toHaveBeenCalledWith(customFn);
});
});

View file

@ -0,0 +1,51 @@
import { useUsersStore } from '@/stores/users.store';
import { hasRole } from '@/rbac/checks';
import { ROLE } from '@/utils';
vi.mock('@/stores/users.store', () => ({
useUsersStore: vi.fn(),
}));
describe('Checks', () => {
describe('hasRole', () => {
it('should return true if the user has the specified role', () => {
vi.mocked(useUsersStore).mockReturnValue({
currentUser: {
isDefaultUser: false,
globalRole: { name: ROLE.Owner },
},
} as ReturnType<typeof useUsersStore>);
expect(hasRole([ROLE.Owner])).toBe(true);
});
it('should return false if the user does not have the specified role', () => {
vi.mocked(useUsersStore).mockReturnValue({
currentUser: {
isDefaultUser: false,
globalRole: { name: ROLE.Member },
},
} as ReturnType<typeof useUsersStore>);
expect(hasRole([ROLE.Owner])).toBe(false);
});
it('should return true for default user if checking for default role', () => {
vi.mocked(useUsersStore).mockReturnValue({
currentUser: {
isDefaultUser: true,
},
} as ReturnType<typeof useUsersStore>);
expect(hasRole([ROLE.Default])).toBe(true);
});
it('should return false if there is no current user', () => {
vi.mocked(useUsersStore).mockReturnValue({ currentUser: null } as ReturnType<
typeof useUsersStore
>);
expect(hasRole([ROLE.Owner])).toBe(false);
});
});
});

View file

@ -0,0 +1,54 @@
import { useRBACStore } from '@/stores/rbac.store';
import { hasScope } from '@/rbac/checks/hasScope';
import type { HasScopeOptions } from '@n8n/permissions';
vi.mock('@/stores/rbac.store', () => ({
useRBACStore: vi.fn(),
}));
describe('Checks', () => {
describe('hasScope()', () => {
it('should return true if no scope is provided', () => {
expect(hasScope({})).toBe(true);
});
it('should call rbacStore.hasScope with the correct parameters', () => {
const mockHasScope = vi.fn().mockReturnValue(true);
vi.mocked(useRBACStore).mockReturnValue({
hasScope: mockHasScope,
} as unknown as ReturnType<typeof useRBACStore>);
const scope = 'workflow:read';
const options: HasScopeOptions = { mode: 'allOf' };
const projectId = 'proj123';
const resourceType = 'workflow';
const resourceId = 'res123';
hasScope({ scope, options, projectId, resourceType, resourceId });
expect(mockHasScope).toHaveBeenCalledWith(
scope,
{ projectId, resourceType, resourceId },
options,
);
});
it('should return true if rbacStore.hasScope returns true', () => {
const mockHasScope = vi.fn().mockReturnValue(true);
vi.mocked(useRBACStore).mockReturnValue({ hasScope: mockHasScope } as unknown as ReturnType<
typeof useRBACStore
>);
expect(hasScope({ scope: 'workflow:read' })).toBe(true);
});
it('should return false if rbacStore.hasScope returns false', () => {
const mockHasScope = vi.fn().mockReturnValue(false);
vi.mocked(useRBACStore).mockReturnValue({ hasScope: mockHasScope } as unknown as ReturnType<
typeof useRBACStore
>);
expect(hasScope({ scope: 'workflow:read' })).toBe(false);
});
});
});

View file

@ -0,0 +1,27 @@
import { useUsersStore } from '@/stores/users.store';
import { isAuthenticated } from '@/rbac/checks/isAuthenticated';
vi.mock('@/stores/users.store', () => ({
useUsersStore: vi.fn(),
}));
describe('Checks', () => {
describe('isAuthenticated()', () => {
it('should return true if there is a current user', () => {
const mockUser = { id: 'user123', name: 'Test User' };
vi.mocked(useUsersStore).mockReturnValue({ currentUser: mockUser } as unknown as ReturnType<
typeof useUsersStore
>);
expect(isAuthenticated()).toBe(true);
});
it('should return false if there is no current user', () => {
vi.mocked(useUsersStore).mockReturnValue({ currentUser: null } as ReturnType<
typeof useUsersStore
>);
expect(isAuthenticated()).toBe(false);
});
});
});

View file

@ -0,0 +1,87 @@
import { useSettingsStore } from '@/stores/settings.store';
import { isEnterpriseFeatureEnabled } from '@/rbac/checks/isEnterpriseFeatureEnabled';
import { EnterpriseEditionFeature } from '@/constants';
vi.mock('@/stores/settings.store', () => ({
useSettingsStore: vi.fn(),
}));
describe('Checks', () => {
describe('isEnterpriseFeatureEnabled()', () => {
it('should return true if no feature is provided', () => {
expect(isEnterpriseFeatureEnabled({})).toBe(true);
});
it('should return true if feature is enabled', () => {
vi.mocked(useSettingsStore).mockReturnValue({
isEnterpriseFeatureEnabled: vi
.fn()
.mockImplementation((feature) => feature !== EnterpriseEditionFeature.Variables),
} as unknown as ReturnType<typeof useSettingsStore>);
expect(
isEnterpriseFeatureEnabled({
feature: EnterpriseEditionFeature.Saml,
}),
).toBe(true);
});
it('should return true if all features are enabled in allOf mode', () => {
vi.mocked(useSettingsStore).mockReturnValue({
isEnterpriseFeatureEnabled: vi
.fn()
.mockImplementation((feature) => feature !== EnterpriseEditionFeature.Variables),
} as unknown as ReturnType<typeof useSettingsStore>);
expect(
isEnterpriseFeatureEnabled({
feature: [EnterpriseEditionFeature.Ldap, EnterpriseEditionFeature.Saml],
mode: 'allOf',
}),
).toBe(true);
});
it('should return false if any feature is not enabled in allOf mode', () => {
vi.mocked(useSettingsStore).mockReturnValue({
isEnterpriseFeatureEnabled: vi
.fn()
.mockImplementation((feature) => feature !== EnterpriseEditionFeature.Saml),
} as unknown as ReturnType<typeof useSettingsStore>);
expect(
isEnterpriseFeatureEnabled({
feature: [EnterpriseEditionFeature.Ldap, EnterpriseEditionFeature.Saml],
mode: 'allOf',
}),
).toBe(false);
});
it('should return true if any feature is enabled in oneOf mode', () => {
vi.mocked(useSettingsStore).mockReturnValue({
isEnterpriseFeatureEnabled: vi
.fn()
.mockImplementation((feature) => feature === EnterpriseEditionFeature.Ldap),
} as unknown as ReturnType<typeof useSettingsStore>);
expect(
isEnterpriseFeatureEnabled({
feature: [EnterpriseEditionFeature.Ldap, EnterpriseEditionFeature.Saml],
mode: 'oneOf',
}),
).toBe(true);
});
it('should return false if no features are enabled in anyOf mode', () => {
vi.mocked(useSettingsStore).mockReturnValue({
isEnterpriseFeatureEnabled: vi.fn().mockReturnValue(false),
} as unknown as ReturnType<typeof useSettingsStore>);
expect(
isEnterpriseFeatureEnabled({
feature: [EnterpriseEditionFeature.Ldap, EnterpriseEditionFeature.Saml],
mode: 'oneOf',
}),
).toBe(false);
});
});
});

View file

@ -0,0 +1,27 @@
import { useUsersStore } from '@/stores/users.store';
import { isGuest } from '@/rbac/checks/isGuest';
vi.mock('@/stores/users.store', () => ({
useUsersStore: vi.fn(),
}));
describe('Checks', () => {
describe('isGuest()', () => {
it('should return false if there is a current user', () => {
const mockUser = { id: 'user123', name: 'Test User' };
vi.mocked(useUsersStore).mockReturnValue({ currentUser: mockUser } as unknown as ReturnType<
typeof useUsersStore
>);
expect(isGuest()).toBe(false);
});
it('should return true if there is no current user', () => {
vi.mocked(useUsersStore).mockReturnValue({ currentUser: null } as ReturnType<
typeof useUsersStore
>);
expect(isGuest()).toBe(true);
});
});
});

View file

@ -0,0 +1,19 @@
import { isValid } from '@/rbac/checks/isValid';
describe('Checks', () => {
describe('isValid()', () => {
it('should return true if the provided function returns true', () => {
const mockFn = () => true;
expect(isValid(mockFn)).toBe(true);
});
it('should return false if the provided function returns false', () => {
const mockFn = () => false;
expect(isValid(mockFn)).toBe(false);
});
it('should return false if no function is provided', () => {
expect(isValid(undefined)).toBe(false);
});
});
});

View file

@ -0,0 +1,16 @@
import { useUsersStore } from '@/stores/users.store';
import type { RBACPermissionCheck, RolePermissionOptions } from '@/types/rbac';
import { ROLE } from '@/utils';
import type { IRole } from '@/Interface';
export const hasRole: RBACPermissionCheck<RolePermissionOptions> = (checkRoles) => {
const usersStore = useUsersStore();
const currentUser = usersStore.currentUser;
if (currentUser && checkRoles) {
const userRole = currentUser.isDefaultUser ? ROLE.Default : currentUser.globalRole?.name;
return checkRoles.includes(userRole as IRole);
}
return false;
};

View file

@ -0,0 +1,20 @@
import { useRBACStore } from '@/stores/rbac.store';
import type { RBACPermissionCheck, RBACPermissionOptions } from '@/types/rbac';
export const hasScope: RBACPermissionCheck<RBACPermissionOptions> = (opts) => {
if (!opts?.scope) {
return true;
}
const { projectId, resourceType, resourceId, scope, options } = opts;
const rbacStore = useRBACStore();
return rbacStore.hasScope(
scope,
{
projectId,
resourceType,
resourceId,
},
options,
);
};

View file

@ -0,0 +1,6 @@
export * from './hasRole';
export * from './hasScope';
export * from './isAuthenticated';
export * from './isEnterpriseFeatureEnabled';
export * from './isGuest';
export * from './isValid';

View file

@ -0,0 +1,7 @@
import { useUsersStore } from '@/stores';
import type { RBACPermissionCheck, AuthenticatedPermissionOptions } from '@/types/rbac';
export const isAuthenticated: RBACPermissionCheck<AuthenticatedPermissionOptions> = () => {
const usersStore = useUsersStore();
return !!usersStore.currentUser;
};

View file

@ -0,0 +1,19 @@
import { useSettingsStore } from '@/stores';
import type { RBACPermissionCheck, EnterprisePermissionOptions } from '@/types/rbac';
export const isEnterpriseFeatureEnabled: RBACPermissionCheck<EnterprisePermissionOptions> = (
options,
) => {
if (!options?.feature) {
return true;
}
const features = Array.isArray(options.feature) ? options.feature : [options.feature];
const settingsStore = useSettingsStore();
const mode = options.mode ?? 'allOf';
if (mode === 'allOf') {
return features.every(settingsStore.isEnterpriseFeatureEnabled);
} else {
return features.some(settingsStore.isEnterpriseFeatureEnabled);
}
};

View file

@ -0,0 +1,7 @@
import { useUsersStore } from '@/stores';
import type { RBACPermissionCheck, GuestPermissionOptions } from '@/types/rbac';
export const isGuest: RBACPermissionCheck<GuestPermissionOptions> = () => {
const usersStore = useUsersStore();
return !usersStore.currentUser;
};

View file

@ -0,0 +1,5 @@
import type { RBACPermissionCheck, CustomPermissionOptions } from '@/types/rbac';
export const isValid: RBACPermissionCheck<CustomPermissionOptions> = (fn) => {
return fn ? fn() : false;
};

View file

@ -0,0 +1,20 @@
import type { RouterMiddleware, RouterMiddlewareType, MiddlewareOptions } from '@/types/router';
import { authenticatedMiddleware } from '@/rbac/middleware/authenticated';
import { enterpriseMiddleware } from '@/rbac/middleware/enterprise';
import { guestMiddleware } from '@/rbac/middleware/guest';
import { rbacMiddleware } from '@/rbac/middleware/rbac';
import { roleMiddleware } from '@/rbac/middleware/role';
import { customMiddleware } from '@/rbac/middleware/custom';
type Middleware = {
[key in RouterMiddlewareType]: RouterMiddleware<MiddlewareOptions[key]>;
};
export const middleware: Middleware = {
authenticated: authenticatedMiddleware,
custom: customMiddleware,
enterprise: enterpriseMiddleware,
guest: guestMiddleware,
rbac: rbacMiddleware,
role: roleMiddleware,
};

View file

@ -0,0 +1,60 @@
import { authenticatedMiddleware } from '@/rbac/middleware/authenticated';
import { useUsersStore } from '@/stores/users.store';
import { VIEWS } from '@/constants';
import type { RouteLocationNormalized } from 'vue-router';
vi.mock('@/stores/users.store', () => ({
useUsersStore: vi.fn(),
}));
describe('Middleware', () => {
describe('authenticated', () => {
it('should redirect to signin if no current user is present', async () => {
vi.mocked(useUsersStore).mockReturnValue({ currentUser: null } as ReturnType<
typeof useUsersStore
>);
const nextMock = vi.fn();
const toMock = { query: {} } as RouteLocationNormalized;
const fromMock = {} as RouteLocationNormalized;
await authenticatedMiddleware(toMock, fromMock, nextMock, {});
expect(nextMock).toHaveBeenCalledWith({
name: VIEWS.SIGNIN,
query: { redirect: encodeURIComponent('/') },
});
});
it('should call next with the correct redirect query if present', async () => {
vi.mocked(useUsersStore).mockReturnValue({ currentUser: null } as ReturnType<
typeof useUsersStore
>);
const nextMock = vi.fn();
const toMock = { query: { redirect: '/' } } as unknown as RouteLocationNormalized;
const fromMock = {} as RouteLocationNormalized;
await authenticatedMiddleware(toMock, fromMock, nextMock, {});
expect(nextMock).toHaveBeenCalledWith({
name: VIEWS.SIGNIN,
query: { redirect: '/' },
});
});
it('should allow navigation if a current user is present', async () => {
vi.mocked(useUsersStore).mockReturnValue({ currentUser: { id: '123' } } as ReturnType<
typeof useUsersStore
>);
const nextMock = vi.fn();
const toMock = { query: {} } as RouteLocationNormalized;
const fromMock = {} as RouteLocationNormalized;
await authenticatedMiddleware(toMock, fromMock, nextMock, {});
expect(nextMock).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,35 @@
import { customMiddleware } from '@/rbac/middleware/custom';
import type { RouteLocationNormalized } from 'vue-router';
import { VIEWS } from '@/constants';
describe('Middleware', () => {
describe('custom', () => {
it('should redirect to homepage if validation function returns false', async () => {
const nextMock = vi.fn();
const fn = () => false;
await customMiddleware(
{} as RouteLocationNormalized,
{} as RouteLocationNormalized,
nextMock,
fn,
);
expect(nextMock).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
});
it('should pass if validation function returns true', async () => {
const nextMock = vi.fn();
const fn = () => true;
await customMiddleware(
{} as RouteLocationNormalized,
{} as RouteLocationNormalized,
nextMock,
fn,
);
expect(nextMock).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,98 @@
import { useSettingsStore } from '@/stores/settings.store';
import { VIEWS, EnterpriseEditionFeature } from '@/constants';
import { enterpriseMiddleware } from '@/rbac/middleware/enterprise';
import { type RouteLocationNormalized } from 'vue-router';
import type { EnterprisePermissionOptions } from '@/types/rbac';
vi.mock('@/stores/settings.store', () => ({
useSettingsStore: vi.fn(),
}));
describe('Middleware', () => {
describe('enterprise', () => {
it('should redirect to homepage if none of the required features are enabled in allOf mode', async () => {
vi.mocked(useSettingsStore).mockReturnValue({
isEnterpriseFeatureEnabled: (_) => false,
} as ReturnType<typeof useSettingsStore>);
const nextMock = vi.fn();
const options: EnterprisePermissionOptions = {
feature: [EnterpriseEditionFeature.Saml, EnterpriseEditionFeature.Ldap],
mode: 'allOf',
};
await enterpriseMiddleware(
{} as RouteLocationNormalized,
{} as RouteLocationNormalized,
nextMock,
options,
);
expect(nextMock).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
});
it('should allow navigation if all of the required features are enabled in allOf mode', async () => {
vi.mocked(useSettingsStore).mockReturnValue({
isEnterpriseFeatureEnabled: (feature) =>
[EnterpriseEditionFeature.Saml, EnterpriseEditionFeature.Ldap].includes(feature),
} as ReturnType<typeof useSettingsStore>);
const nextMock = vi.fn();
const options: EnterprisePermissionOptions = {
feature: [EnterpriseEditionFeature.Saml, EnterpriseEditionFeature.Ldap],
mode: 'allOf',
};
await enterpriseMiddleware(
{} as RouteLocationNormalized,
{} as RouteLocationNormalized,
nextMock,
options,
);
expect(nextMock).toHaveBeenCalledTimes(0);
});
it('should redirect to homepage if none of the required features are enabled in oneOf mode', async () => {
vi.mocked(useSettingsStore).mockReturnValue({
isEnterpriseFeatureEnabled: (_) => false,
} as ReturnType<typeof useSettingsStore>);
const nextMock = vi.fn();
const options: EnterprisePermissionOptions = {
feature: [EnterpriseEditionFeature.Saml],
mode: 'oneOf',
};
await enterpriseMiddleware(
{} as RouteLocationNormalized,
{} as RouteLocationNormalized,
nextMock,
options,
);
expect(nextMock).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
});
it('should allow navigation if at least one of the required features is enabled in oneOf mode', async () => {
vi.mocked(useSettingsStore).mockReturnValue({
isEnterpriseFeatureEnabled: (feature) => feature === EnterpriseEditionFeature.Saml,
} as ReturnType<typeof useSettingsStore>);
const nextMock = vi.fn();
const options: EnterprisePermissionOptions = {
feature: [EnterpriseEditionFeature.Saml, EnterpriseEditionFeature.Ldap],
mode: 'oneOf',
};
await enterpriseMiddleware(
{} as RouteLocationNormalized,
{} as RouteLocationNormalized,
nextMock,
options,
);
expect(nextMock).toHaveBeenCalledTimes(0);
});
});
});

View file

@ -0,0 +1,54 @@
import { useUsersStore } from '@/stores/users.store';
import { VIEWS } from '@/constants';
import { guestMiddleware } from '@/rbac/middleware/guest';
import type { RouteLocationNormalized } from 'vue-router';
vi.mock('@/stores/users.store', () => ({
useUsersStore: vi.fn(),
}));
describe('Middleware', () => {
describe('guest', () => {
it('should redirect to given path if current user is present and valid redirect is provided', async () => {
vi.mocked(useUsersStore).mockReturnValue({ currentUser: { id: '123' } } as ReturnType<
typeof useUsersStore
>);
const nextMock = vi.fn();
const toMock = { query: { redirect: '/some-path' } } as unknown as RouteLocationNormalized;
const fromMock = {} as RouteLocationNormalized;
await guestMiddleware(toMock, fromMock, nextMock, {});
expect(nextMock).toHaveBeenCalledWith('/some-path');
});
it('should redirect to homepage if current user is present and no valid redirect', async () => {
vi.mocked(useUsersStore).mockReturnValue({ currentUser: { id: '123' } } as ReturnType<
typeof useUsersStore
>);
const nextMock = vi.fn();
const toMock = { query: {} } as RouteLocationNormalized;
const fromMock = {} as RouteLocationNormalized;
await guestMiddleware(toMock, fromMock, nextMock, {});
expect(nextMock).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
});
it('should allow navigation if no current user is present', async () => {
vi.mocked(useUsersStore).mockReturnValue({ currentUser: null } as ReturnType<
typeof useUsersStore
>);
const nextMock = vi.fn();
const toMock = { query: {} } as RouteLocationNormalized;
const fromMock = {} as RouteLocationNormalized;
await guestMiddleware(toMock, fromMock, nextMock, {});
expect(nextMock).toHaveBeenCalledTimes(0);
});
});
});

View file

@ -0,0 +1,60 @@
import { useRBACStore } from '@/stores/rbac.store';
import { rbacMiddleware } from '@/rbac/middleware/rbac';
import { VIEWS } from '@/constants';
import {
inferProjectIdFromRoute,
inferResourceIdFromRoute,
inferResourceTypeFromRoute,
} from '@/utils/rbacUtils';
import type { RouteLocationNormalized } from 'vue-router';
import type { Scope } from '@n8n/permissions';
vi.mock('@/stores/rbac.store', () => ({
useRBACStore: vi.fn(),
}));
vi.mock('@/utils/rbacUtils', () => ({
inferProjectIdFromRoute: vi.fn(),
inferResourceIdFromRoute: vi.fn(),
inferResourceTypeFromRoute: vi.fn(),
}));
describe('Middleware', () => {
describe('rbac', () => {
it('should redirect to homepage if the user does not have the required scope', async () => {
vi.mocked(useRBACStore).mockReturnValue({
hasScope: vi.fn().mockReturnValue(false),
} as unknown as ReturnType<typeof useRBACStore>);
vi.mocked(inferProjectIdFromRoute).mockReturnValue('123');
vi.mocked(inferResourceTypeFromRoute).mockReturnValue('workflow');
vi.mocked(inferResourceIdFromRoute).mockReturnValue('456');
const nextMock = vi.fn();
const scope: Scope = 'workflow:read';
await rbacMiddleware({} as RouteLocationNormalized, {} as RouteLocationNormalized, nextMock, {
scope,
});
expect(nextMock).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
});
it('should allow navigation if the user has the required scope', async () => {
vi.mocked(useRBACStore).mockReturnValue({
hasScope: vi.fn().mockReturnValue(true),
} as unknown as ReturnType<typeof useRBACStore>);
vi.mocked(inferProjectIdFromRoute).mockReturnValue('123');
vi.mocked(inferResourceTypeFromRoute).mockReturnValue(undefined);
vi.mocked(inferResourceIdFromRoute).mockReturnValue(undefined);
const nextMock = vi.fn();
const scope: Scope = 'workflow:read';
await rbacMiddleware({} as RouteLocationNormalized, {} as RouteLocationNormalized, nextMock, {
scope,
});
expect(nextMock).toHaveBeenCalledTimes(0);
});
});
});

View file

@ -0,0 +1,82 @@
import { roleMiddleware } from '@/rbac/middleware/role';
import { useUsersStore } from '@/stores/users.store';
import { ROLE } from '@/utils';
import type { IUser } from '@/Interface';
import type { RouteLocationNormalized } from 'vue-router';
import { VIEWS } from '@/constants';
vi.mock('@/stores/users.store', () => ({
useUsersStore: vi.fn(),
}));
describe('Middleware', () => {
describe('role', () => {
it('should redirect to homepage if the user does not have the required role', async () => {
vi.mocked(useUsersStore).mockReturnValue({
currentUser: {
isDefaultUser: false,
globalRole: {
id: '123',
createdAt: new Date(),
name: ROLE.Owner,
},
} as IUser,
} as ReturnType<typeof useUsersStore>);
const nextMock = vi.fn();
const role = [ROLE.Default];
await roleMiddleware(
{} as RouteLocationNormalized,
{} as RouteLocationNormalized,
nextMock,
role,
);
expect(nextMock).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
});
it('should redirect to homepage if the user is not logged in', async () => {
vi.mocked(useUsersStore).mockReturnValue({
currentUser: null,
} as ReturnType<typeof useUsersStore>);
const nextMock = vi.fn();
const role = [ROLE.Default];
await roleMiddleware(
{} as RouteLocationNormalized,
{} as RouteLocationNormalized,
nextMock,
role,
);
expect(nextMock).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
});
it('should nor redirect if the user has the required role', async () => {
vi.mocked(useUsersStore).mockReturnValue({
currentUser: {
isDefaultUser: false,
globalRole: {
id: '123',
createdAt: new Date(),
name: ROLE.Owner,
},
} as IUser,
} as ReturnType<typeof useUsersStore>);
const nextMock = vi.fn();
const role = [ROLE.Owner];
await roleMiddleware(
{} as RouteLocationNormalized,
{} as RouteLocationNormalized,
nextMock,
role,
);
expect(nextMock).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,18 @@
import type { RouterMiddleware } from '@/types/router';
import { VIEWS } from '@/constants';
import type { AuthenticatedPermissionOptions } from '@/types/rbac';
import { isAuthenticated } from '@/rbac/checks';
export const authenticatedMiddleware: RouterMiddleware<AuthenticatedPermissionOptions> = async (
to,
from,
next,
) => {
const valid = isAuthenticated();
if (!valid) {
const redirect =
to.query.redirect ??
encodeURIComponent(`${window.location.pathname}${window.location.search}`);
return next({ name: VIEWS.SIGNIN, query: { redirect } });
}
};

View file

@ -0,0 +1,14 @@
import type { CustomMiddlewareOptions, RouterMiddleware } from '@/types/router';
import { VIEWS } from '@/constants';
export const customMiddleware: RouterMiddleware<CustomMiddlewareOptions> = async (
to,
from,
next,
isValid,
) => {
const valid = isValid({ to, from, next });
if (!valid) {
return next({ name: VIEWS.HOMEPAGE });
}
};

View file

@ -0,0 +1,16 @@
import type { RouterMiddleware } from '@/types/router';
import { VIEWS } from '@/constants';
import type { EnterprisePermissionOptions } from '@/types/rbac';
import { isEnterpriseFeatureEnabled } from '@/rbac/checks';
export const enterpriseMiddleware: RouterMiddleware<EnterprisePermissionOptions> = async (
to,
from,
next,
options,
) => {
const valid = isEnterpriseFeatureEnabled(options);
if (!valid) {
return next({ name: VIEWS.HOMEPAGE });
}
};

View file

@ -0,0 +1,16 @@
import type { RouterMiddleware } from '@/types/router';
import { VIEWS } from '@/constants';
import type { GuestPermissionOptions } from '@/types/rbac';
import { isGuest } from '@/rbac/checks';
export const guestMiddleware: RouterMiddleware<GuestPermissionOptions> = async (to, from, next) => {
const valid = isGuest();
if (!valid) {
const redirect = to.query.redirect as string;
if (redirect && redirect.startsWith('/')) {
return next(redirect);
}
return next({ name: VIEWS.HOMEPAGE });
}
};

View file

@ -0,0 +1,25 @@
import type { RouterMiddleware } from '@/types/router';
import { VIEWS } from '@/constants';
import {
inferProjectIdFromRoute,
inferResourceIdFromRoute,
inferResourceTypeFromRoute,
} from '@/utils/rbacUtils';
import type { RBACPermissionOptions } from '@/types/rbac';
import { hasScope } from '@/rbac/checks';
export const rbacMiddleware: RouterMiddleware<RBACPermissionOptions> = async (
to,
from,
next,
{ scope, options },
) => {
const projectId = inferProjectIdFromRoute(to);
const resourceType = inferResourceTypeFromRoute(to);
const resourceId = resourceType ? inferResourceIdFromRoute(to) : undefined;
const valid = hasScope({ scope, projectId, resourceType, resourceId, options });
if (!valid) {
return next({ name: VIEWS.HOMEPAGE });
}
};

View file

@ -0,0 +1,16 @@
import type { RouterMiddleware } from '@/types/router';
import type { RolePermissionOptions } from '@/types/rbac';
import { VIEWS } from '@/constants';
import { hasRole } from '@/rbac/checks';
export const roleMiddleware: RouterMiddleware<RolePermissionOptions> = async (
to,
from,
next,
checkRoles,
) => {
const valid = hasRole(checkRoles);
if (!valid) {
return next({ name: VIEWS.HOMEPAGE });
}
};

View file

@ -0,0 +1,38 @@
import {
hasRole,
hasScope,
isAuthenticated,
isEnterpriseFeatureEnabled,
isGuest,
isValid,
} from '@/rbac/checks';
import type { PermissionType, PermissionTypeOptions, RBACPermissionCheck } from '@/types/rbac';
type Permissions = {
[key in PermissionType]: RBACPermissionCheck<PermissionTypeOptions[key]>;
};
export const permissions: Permissions = {
authenticated: isAuthenticated,
custom: isValid,
enterprise: isEnterpriseFeatureEnabled,
guest: isGuest,
rbac: hasScope,
role: hasRole,
};
export function hasPermission(
permissionNames: PermissionType[],
options?: Partial<PermissionTypeOptions>,
) {
let valid = true;
for (const permissionName of permissionNames) {
const permissionOptions = options?.[permissionName] ?? {};
const permissionFn = permissions[permissionName] as RBACPermissionCheck<unknown>;
valid = valid && permissionFn(permissionOptions);
}
return valid;
}

View file

@ -1,16 +1,23 @@
import { useStorage } from '@/composables/useStorage';
import type { RouteLocation, RouteRecordRaw } from 'vue-router';
import type {
NavigationGuardNext,
RouteLocation,
RouteRecordRaw,
RouteLocationRaw,
RouteLocationNormalized,
} from 'vue-router';
import { createRouter, createWebHistory } from 'vue-router';
import type { IPermissions } from './Interface';
import { isAuthorized, LOGIN_STATUS, ROLE, runExternalHook } from '@/utils';
import { ROLE, runExternalHook } from '@/utils';
import { useSettingsStore } from './stores/settings.store';
import { useUsersStore } from './stores/users.store';
import { useTemplatesStore } from './stores/templates.store';
import { useUIStore } from '@/stores/ui.store';
import { useSSOStore } from './stores/sso.store';
import { EnterpriseEditionFeature, VIEWS } from '@/constants';
import { useTelemetry } from '@/composables';
import { middleware } from '@/rbac/middleware';
import type { RouteConfig, RouterMiddleware } from '@/types/router';
import { initializeCore } from '@/init';
const ChangePasswordView = async () => import('./views/ChangePasswordView.vue');
const ErrorView = async () => import('./views/ErrorView.vue');
@ -51,20 +58,6 @@ const WorkerView = async () => import('./views/WorkerView.vue');
const WorkflowHistory = async () => import('@/views/WorkflowHistory.vue');
const WorkflowOnboardingView = async () => import('@/views/WorkflowOnboardingView.vue');
interface IRouteConfig {
meta: {
nodeView?: boolean;
templatesEnabled?: boolean;
getRedirect?: () => { name: string } | false;
permissions: IPermissions;
telemetry?: {
disabled?: true;
getProperties: (route: RouteLocation) => object;
};
scrollOffset?: number;
};
}
function getTemplatesRedirect(defaultRedirect: VIEWS[keyof VIEWS]) {
const settingsStore = useSettingsStore();
const isTemplatesEnabled: boolean = settingsStore.isTemplatesEnabled;
@ -83,11 +76,7 @@ export const routes = [
return { name: VIEWS.WORKFLOWS };
},
meta: {
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
middleware: ['authenticated'],
},
},
{
@ -109,11 +98,7 @@ export const routes = [
},
},
getRedirect: getTemplatesRedirect,
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
middleware: ['authenticated'],
},
},
{
@ -135,11 +120,7 @@ export const routes = [
};
},
},
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
middleware: ['authenticated'],
},
},
{
@ -165,11 +146,7 @@ export const routes = [
setScrollPosition(pos: number) {
this.scrollOffset = pos;
},
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
middleware: ['authenticated'],
},
},
{
@ -180,11 +157,7 @@ export const routes = [
sidebar: MainSidebar,
},
meta: {
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
middleware: ['authenticated'],
},
},
{
@ -194,13 +167,7 @@ export const routes = [
default: VariablesView,
sidebar: MainSidebar,
},
meta: {
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
},
meta: { middleware: ['authenticated'] },
},
{
path: '/executions',
@ -210,11 +177,7 @@ export const routes = [
sidebar: MainSidebar,
},
meta: {
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
middleware: ['authenticated'],
},
},
{
@ -225,11 +188,7 @@ export const routes = [
sidebar: MainSidebar,
},
meta: {
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
middleware: ['authenticated'],
},
},
{
@ -243,13 +202,10 @@ export const routes = [
meta: {
nodeView: true,
keepWorkflowAlive: true,
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
deny: {
shouldDeny: () =>
!useSettingsStore().isEnterpriseFeatureEnabled(EnterpriseEditionFeature.DebugInEditor),
middleware: ['authenticated', 'enterprise'],
middlewareOptions: {
enterprise: {
feature: [EnterpriseEditionFeature.DebugInEditor],
},
},
},
@ -264,11 +220,7 @@ export const routes = [
},
meta: {
keepWorkflowAlive: true,
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
middleware: ['authenticated'],
},
children: [
{
@ -279,11 +231,7 @@ export const routes = [
},
meta: {
keepWorkflowAlive: true,
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
middleware: ['authenticated'],
},
},
{
@ -294,11 +242,7 @@ export const routes = [
},
meta: {
keepWorkflowAlive: true,
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
middleware: ['authenticated'],
},
},
],
@ -311,15 +255,10 @@ export const routes = [
sidebar: MainSidebar,
},
meta: {
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
deny: {
shouldDeny: () =>
!useSettingsStore().isEnterpriseFeatureEnabled(
EnterpriseEditionFeature.WorkflowHistory,
),
middleware: ['authenticated', 'enterprise'],
middlewareOptions: {
enterprise: {
feature: [EnterpriseEditionFeature.WorkflowHistory],
},
},
},
@ -336,11 +275,7 @@ export const routes = [
templatesEnabled: true,
keepWorkflowAlive: true,
getRedirect: getTemplatesRedirect,
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
middleware: ['authenticated'],
},
},
{
@ -355,11 +290,7 @@ export const routes = [
templatesEnabled: true,
keepWorkflowAlive: true,
getRedirect: () => getTemplatesRedirect(VIEWS.NEW_WORKFLOW),
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
middleware: ['authenticated'],
},
},
{
@ -373,11 +304,7 @@ export const routes = [
meta: {
nodeView: true,
keepWorkflowAlive: true,
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
middleware: ['authenticated'],
},
},
{
@ -387,11 +314,7 @@ export const routes = [
default: NodeView,
},
meta: {
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
middleware: ['authenticated'],
},
},
{
@ -405,11 +328,7 @@ export const routes = [
meta: {
nodeView: true,
keepWorkflowAlive: true,
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
middleware: ['authenticated'],
},
},
{
@ -426,11 +345,7 @@ export const routes = [
telemetry: {
pageCategory: 'auth',
},
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedOut],
},
},
middleware: ['guest'],
},
},
{
@ -443,11 +358,7 @@ export const routes = [
telemetry: {
pageCategory: 'auth',
},
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedOut],
},
},
middleware: ['guest'],
},
},
{
@ -460,11 +371,7 @@ export const routes = [
telemetry: {
pageCategory: 'auth',
},
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
middleware: ['authenticated'],
},
},
{
@ -474,14 +381,13 @@ export const routes = [
default: SetupView,
},
meta: {
middleware: ['role'],
middlewareOptions: {
role: [ROLE.Default],
},
telemetry: {
pageCategory: 'auth',
},
permissions: {
allow: {
role: [ROLE.Default],
},
},
},
},
{
@ -491,14 +397,10 @@ export const routes = [
default: ForgotMyPasswordView,
},
meta: {
middleware: ['guest'],
telemetry: {
pageCategory: 'auth',
},
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedOut],
},
},
},
},
{
@ -508,14 +410,10 @@ export const routes = [
default: ChangePasswordView,
},
meta: {
middleware: ['guest'],
telemetry: {
pageCategory: 'auth',
},
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedOut],
},
},
},
},
{
@ -530,6 +428,16 @@ export const routes = [
settingsView: SettingsUsageAndPlan,
},
meta: {
middleware: ['authenticated', 'custom'],
middlewareOptions: {
custom: () => {
const settingsStore = useSettingsStore();
return !(
settingsStore.settings.hideUsagePage ||
settingsStore.settings.deployment?.type === 'cloud'
);
},
},
telemetry: {
pageCategory: 'settings',
getProperties(route: RouteLocation) {
@ -538,20 +446,6 @@ export const routes = [
};
},
},
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
deny: {
shouldDeny: () => {
const settingsStore = useSettingsStore();
return (
settingsStore.settings.hideUsagePage ||
settingsStore.settings.deployment?.type === 'cloud'
);
},
},
},
},
},
{
@ -561,6 +455,10 @@ export const routes = [
settingsView: SettingsPersonalView,
},
meta: {
middleware: ['authenticated', 'role'],
middlewareOptions: {
role: [ROLE.Owner, ROLE.Member],
},
telemetry: {
pageCategory: 'settings',
getProperties(route: RouteLocation) {
@ -569,14 +467,6 @@ export const routes = [
};
},
},
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
deny: {
role: [ROLE.Default],
},
},
},
},
{
@ -586,6 +476,10 @@ export const routes = [
settingsView: SettingsUsersView,
},
meta: {
middleware: ['authenticated', 'role'],
middlewareOptions: {
role: [ROLE.Owner],
},
telemetry: {
pageCategory: 'settings',
getProperties(route: RouteLocation) {
@ -594,11 +488,6 @@ export const routes = [
};
},
},
permissions: {
allow: {
role: [ROLE.Owner],
},
},
},
},
{
@ -608,6 +497,13 @@ export const routes = [
settingsView: SettingsApiView,
},
meta: {
middleware: ['authenticated', 'custom'],
middlewareOptions: {
custom: () => {
const settingsStore = useSettingsStore();
return settingsStore.isPublicApiEnabled;
},
},
telemetry: {
pageCategory: 'settings',
getProperties(route: RouteLocation) {
@ -616,17 +512,6 @@ export const routes = [
};
},
},
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
deny: {
shouldDeny: () => {
const settingsStore = useSettingsStore();
return !settingsStore.isPublicApiEnabled;
},
},
},
},
},
{
@ -636,6 +521,10 @@ export const routes = [
settingsView: SettingsSourceControl,
},
meta: {
middleware: ['authenticated', 'role'],
middlewareOptions: {
role: [ROLE.Owner],
},
telemetry: {
pageCategory: 'settings',
getProperties(route: RouteLocation) {
@ -644,11 +533,6 @@ export const routes = [
};
},
},
permissions: {
allow: {
role: [ROLE.Owner],
},
},
},
},
{
@ -658,19 +542,18 @@ export const routes = [
settingsView: SettingsExternalSecrets,
},
meta: {
middleware: ['authenticated', 'role'],
middlewareOptions: {
role: [ROLE.Owner],
},
telemetry: {
pageCategory: 'settings',
getProperties(route: Route) {
getProperties(route: RouteLocation) {
return {
feature: 'external-secrets',
};
},
},
permissions: {
allow: {
role: [ROLE.Owner],
},
},
},
},
{
@ -680,6 +563,14 @@ export const routes = [
settingsView: SettingsSso,
},
meta: {
middleware: ['authenticated', 'role', 'custom'],
middlewareOptions: {
custom: () => {
const settingsStore = useSettingsStore();
return !settingsStore.isDesktopDeployment;
},
role: [ROLE.Owner],
},
telemetry: {
pageCategory: 'settings',
getProperties(route: RouteLocation) {
@ -688,17 +579,6 @@ export const routes = [
};
},
},
permissions: {
allow: {
role: [ROLE.Owner],
},
deny: {
shouldDeny: () => {
const settingsStore = useSettingsStore();
return settingsStore.isDesktopDeployment;
},
},
},
},
},
{
@ -708,17 +588,13 @@ export const routes = [
settingsView: SettingsLogStreamingView,
},
meta: {
middleware: ['authenticated', 'role'],
middlewareOptions: {
role: [ROLE.Owner],
},
telemetry: {
pageCategory: 'settings',
},
permissions: {
allow: {
role: [ROLE.Owner],
},
deny: {
role: [ROLE.Member],
},
},
},
},
{
@ -728,11 +604,7 @@ export const routes = [
settingsView: WorkerView,
},
meta: {
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
middleware: ['authenticated'],
},
},
{
@ -742,20 +614,17 @@ export const routes = [
settingsView: SettingsCommunityNodesView,
},
meta: {
middleware: ['authenticated', 'role', 'custom'],
middlewareOptions: {
role: [ROLE.Owner],
custom: () => {
const settingsStore = useSettingsStore();
return settingsStore.isCommunityNodesFeatureEnabled;
},
},
telemetry: {
pageCategory: 'settings',
},
permissions: {
allow: {
role: [ROLE.Owner],
},
deny: {
shouldDeny: () => {
const settingsStore = useSettingsStore();
return !settingsStore.isCommunityNodesFeatureEnabled;
},
},
},
},
},
{
@ -765,6 +634,7 @@ export const routes = [
settingsView: SettingsFakeDoorView,
},
meta: {
middleware: ['authenticated'],
telemetry: {
pageCategory: 'settings',
getProperties(route: RouteLocation) {
@ -773,11 +643,6 @@ export const routes = [
};
},
},
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
},
},
{
@ -787,13 +652,9 @@ export const routes = [
settingsView: SettingsLdapView,
},
meta: {
permissions: {
allow: {
role: [ROLE.Owner],
},
deny: {
role: [ROLE.Member],
},
middleware: ['authenticated', 'role'],
middlewareOptions: {
role: [ROLE.Owner],
},
},
},
@ -804,6 +665,13 @@ export const routes = [
settingsView: SettingsAuditLogs,
},
meta: {
middleware: ['authenticated', 'role', 'custom'],
middlewareOptions: {
custom: () => {
return !!useStorage('audit-logs').value;
},
role: [ROLE.Owner],
},
telemetry: {
pageCategory: 'settings',
getProperties(route: RouteLocation) {
@ -812,14 +680,6 @@ export const routes = [
};
},
},
permissions: {
allow: {
role: [ROLE.Owner],
},
deny: {
shouldDeny: () => !useStorage('audit-logs').value,
},
},
},
},
],
@ -831,25 +691,21 @@ export const routes = [
default: SamlOnboarding,
},
meta: {
middleware: ['authenticated', 'custom'],
middlewareOptions: {
custom: () => {
const settingsStore = useSettingsStore();
const ssoStore = useSSOStore();
return (
ssoStore.isEnterpriseSamlEnabled &&
!settingsStore.isCloudDeployment &&
!settingsStore.isDesktopDeployment
);
},
},
telemetry: {
pageCategory: 'auth',
},
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
deny: {
shouldDeny: () => {
const settingsStore = useSettingsStore();
const ssoStore = useSSOStore();
return (
!ssoStore.isEnterpriseSamlEnabled ||
settingsStore.isCloudDeployment ||
settingsStore.isDesktopDeployment
);
},
},
},
},
},
{
@ -867,21 +723,15 @@ export const routes = [
telemetry: {
disabled: true,
},
permissions: {
allow: {
// TODO: Once custom permissions are merged, this needs to be updated with index validation
loginStatus: [LOGIN_STATUS.LoggedIn, LOGIN_STATUS.LoggedOut],
},
},
},
},
] as Array<RouteRecordRaw & IRouteConfig>;
] as Array<RouteRecordRaw & RouteConfig>;
const router = createRouter({
history: createWebHistory(import.meta.env.DEV ? '/' : window.BASE_PATH ?? '/'),
scrollBehavior(to, from, savedPosition) {
scrollBehavior(to: RouteLocationNormalized & RouteConfig, from, savedPosition) {
// saved position == null means the page is NOT visited from history (back button)
if (savedPosition === null && to.name === VIEWS.TEMPLATES && to.meta) {
if (savedPosition === null && to.name === VIEWS.TEMPLATES && to.meta?.setScrollPosition) {
// for templates view, reset scroll position in this case
to.meta.setScrollPosition(0);
}
@ -889,19 +739,19 @@ const router = createRouter({
routes,
});
router.beforeEach(async (to, from, next) => {
router.beforeEach(async (to: RouteLocationNormalized & RouteConfig, from, next) => {
/**
* Initialize stores before routing
* Initialize application core
* This step executes before first route is loaded and is required for permission checks
*/
const settingsStore = useSettingsStore();
const usersStore = useUsersStore();
await usersStore.initialize();
await initializeCore();
/**
* Redirect to setup page. User should be redirected to this only once
*/
const settingsStore = useSettingsStore();
if (settingsStore.showSetupPage) {
if (to.name === VIEWS.SETUP) {
return next();
@ -914,41 +764,25 @@ router.beforeEach(async (to, from, next) => {
* Verify user permissions for current route
*/
const currentUser = usersStore.currentUser;
const permissions = to.meta?.permissions as IPermissions;
const canUserAccessCurrentRoute = permissions && isAuthorized(permissions, currentUser);
if (canUserAccessCurrentRoute) {
return next();
}
const routeMiddleware = to.meta?.middleware ?? [];
const routeMiddlewareOptions = to.meta?.middlewareOptions ?? {};
for (const middlewareName of routeMiddleware) {
let nextCalled = false;
const middlewareNext = ((location: RouteLocationRaw): void => {
next(location);
nextCalled = true;
}) as NavigationGuardNext;
/**
* If user cannot access the page and is not logged in, redirect to sign in
*/
const middlewareOptions = routeMiddlewareOptions[middlewareName];
const middlewareFn = middleware[middlewareName] as RouterMiddleware<unknown>;
await middlewareFn(to, from, middlewareNext, middlewareOptions);
if (!currentUser) {
const redirect =
to.query.redirect ||
encodeURIComponent(`${window.location.pathname}${window.location.search}`);
return next({ name: VIEWS.SIGNIN, query: { redirect } });
}
/**
* If user cannot access page but is logged in, respect sign in redirect
*/
if (to.name === VIEWS.SIGNIN && typeof to.query.redirect === 'string') {
const redirect = decodeURIComponent(to.query.redirect);
if (redirect.startsWith('/')) {
// protect against phishing
return next(redirect);
if (nextCalled) {
return;
}
}
/**
* Otherwise, redirect to home page
*/
return next({ name: VIEWS.HOMEPAGE });
return next();
});
router.afterEach((to, from) => {

View file

@ -0,0 +1,152 @@
import { createPinia, setActivePinia } from 'pinia';
import { useRBACStore } from '@/stores/rbac.store';
import type { Scope } from '@n8n/permissions';
import { hasScope } from '@n8n/permissions';
vi.mock('@n8n/permissions', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const { hasScope } = await vi.importActual<typeof import('@n8n/permissions')>('@n8n/permissions');
return {
hasScope: vi.fn().mockImplementation(hasScope),
};
});
describe('RBAC store', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
describe('addGlobalScope()', () => {
it('should add global scope', () => {
const newScope = 'example:list' as Scope;
const rbacStore = useRBACStore();
rbacStore.addGlobalScope(newScope);
expect(rbacStore.globalScopes).toContain(newScope);
});
it('should not add global scope if it already exists', () => {
const newScope = 'example:list' as Scope;
const rbacStore = useRBACStore();
rbacStore.addGlobalScope(newScope);
rbacStore.addGlobalScope(newScope);
expect(rbacStore.globalScopes.filter((scope) => scope === newScope)).toHaveLength(1);
});
});
describe('addProjectScope()', () => {
it('should add project scope', () => {
const newScope = 'example:list' as Scope;
const rbacStore = useRBACStore();
rbacStore.addProjectScope(newScope, { projectId: '1' });
expect(rbacStore.scopesByProjectId['1']).toContain(newScope);
});
it('should not add project scope if it already exists', () => {
const newScope = 'example:list' as Scope;
const rbacStore = useRBACStore();
rbacStore.addProjectScope(newScope, { projectId: '1' });
rbacStore.addProjectScope(newScope, { projectId: '1' });
expect(rbacStore.scopesByProjectId['1'].filter((scope) => scope === newScope)).toHaveLength(
1,
);
});
});
describe('addResourceScope()', () => {
it('should add resource scope', () => {
const newScope = 'example:list' as Scope;
const rbacStore = useRBACStore();
rbacStore.addResourceScope(newScope, { resourceId: '1', resourceType: 'variable' });
expect(rbacStore.scopesByResourceId['variable']['1']).toContain(newScope);
});
it('should not add resource scope if it already exists', () => {
const newScope = 'example:list' as Scope;
const rbacStore = useRBACStore();
rbacStore.addResourceScope(newScope, { resourceId: '1', resourceType: 'variable' });
rbacStore.addResourceScope(newScope, { resourceId: '1', resourceType: 'variable' });
expect(
rbacStore.scopesByResourceId['variable']['1'].filter((scope) => scope === newScope),
).toHaveLength(1);
});
});
describe('hasScope()', () => {
it('evaluates global scope correctly', () => {
const newScope = 'example:list' as Scope;
const store = useRBACStore();
store.addGlobalScope(newScope);
const result = store.hasScope(newScope, {});
expect(result).toBe(true);
expect(vi.mocked(hasScope)).toHaveBeenCalledWith(
newScope,
{
global: expect.arrayContaining([newScope]),
project: [],
resource: [],
},
undefined,
);
});
it('evaluates project scope correctly', () => {
const newScope = 'example:list' as Scope;
const store = useRBACStore();
store.addProjectScope(newScope, { projectId: '1' });
const result = store.hasScope(newScope, { projectId: '1' });
expect(result).toBe(true);
expect(vi.mocked(hasScope)).toHaveBeenCalledWith(
newScope,
{
global: expect.any(Array),
project: expect.arrayContaining([newScope]),
resource: [],
},
undefined,
);
});
it('evaluates resource scope correctly', () => {
const newScope = 'example:list' as Scope;
const store = useRBACStore();
store.addResourceScope(newScope, { resourceId: '1', resourceType: 'variable' });
const result = store.hasScope(newScope, { resourceId: '1', resourceType: 'variable' });
expect(result).toBe(true);
expect(vi.mocked(hasScope)).toHaveBeenCalledWith(
newScope,
{
global: expect.any(Array),
project: [],
resource: expect.arrayContaining([newScope]),
},
undefined,
);
});
it('evaluates project and resource scope correctly', () => {
const newScope = 'example:list' as Scope;
const store = useRBACStore();
store.addProjectScope(newScope, { projectId: '1' });
store.addResourceScope(newScope, { resourceId: '1', resourceType: 'variable' });
const result = store.hasScope(newScope, {
projectId: '1',
resourceId: '1',
resourceType: 'variable',
});
expect(result).toBe(true);
expect(vi.mocked(hasScope)).toHaveBeenCalledWith(
newScope,
{
global: expect.any(Array),
project: expect.arrayContaining([newScope]),
resource: expect.arrayContaining([newScope]),
},
undefined,
);
});
});
});

View file

@ -10,6 +10,7 @@ import { DateTime } from 'luxon';
import { CLOUD_TRIAL_CHECK_INTERVAL, STORES } from '@/constants';
const DEFAULT_STATE: CloudPlanState = {
initialized: false,
data: null,
usage: null,
loadingPlan: false,
@ -157,8 +158,20 @@ export const useCloudPlanStore = defineStore(STORES.CLOUD_PLAN, () => {
window.location.href = `https://${adminPanelHost}/login?code=${code}`;
};
const initialize = async () => {
if (state.initialized) {
return;
}
await checkForCloudPlanData();
await fetchUserCloudAccount();
state.initialized = true;
};
return {
state,
initialize,
getOwnerCurrentPlan,
getInstanceCurrentUsage,
usageLeft,

View file

@ -26,5 +26,6 @@ export * from './cloudPlan.store';
export * from './sourceControl.store';
export * from './sso.store';
export * from './auditLogs.store';
export * from './rbac.store';
export * from './collaboration.store';
export * from './pushConnection.store';

View file

@ -0,0 +1,113 @@
import { defineStore } from 'pinia';
import { hasScope as genericHasScope } from '@n8n/permissions';
import type { HasScopeOptions, Scope, Resource } from '@n8n/permissions';
import { ref } from 'vue';
import { STORES } from '@/constants';
import type { IRole } from '@/Interface';
export const useRBACStore = defineStore(STORES.RBAC, () => {
const globalRoles = ref<IRole[]>([]);
const rolesByProjectId = ref<Record<string, string[]>>({});
const globalScopes = ref<Scope[]>([]);
const scopesByProjectId = ref<Record<string, Scope[]>>({});
const scopesByResourceId = ref<Record<Resource, Record<string, Scope[]>>>({
workflow: {},
tag: {},
user: {},
credential: {},
variable: {},
sourceControl: {},
externalSecretsStore: {},
});
function addGlobalRole(role: IRole) {
if (!globalRoles.value.includes(role)) {
globalRoles.value.push(role);
}
}
function hasRole(role: IRole) {
return globalRoles.value.includes(role);
}
function addGlobalScope(scope: Scope) {
if (!globalScopes.value.includes(scope)) {
globalScopes.value.push(scope);
}
}
function setGlobalScopes(scopes: Scope[]) {
globalScopes.value = scopes;
}
function addProjectScope(
scope: Scope,
context: {
projectId: string;
},
) {
if (!scopesByProjectId.value[context.projectId]) {
scopesByProjectId.value[context.projectId] = [];
}
if (!scopesByProjectId.value[context.projectId].includes(scope)) {
scopesByProjectId.value[context.projectId].push(scope);
}
}
function addResourceScope(
scope: Scope,
context: {
resourceType: Resource;
resourceId: string;
},
) {
const scopesByResourceType = scopesByResourceId.value[context.resourceType];
if (!scopesByResourceType[context.resourceId]) {
scopesByResourceType[context.resourceId] = [];
}
if (!scopesByResourceType[context.resourceId].includes(scope)) {
scopesByResourceType[context.resourceId].push(scope);
}
}
function hasScope(
scope: Scope | Scope[],
context?: {
resourceType?: Resource;
resourceId?: string;
projectId?: string;
},
options?: HasScopeOptions,
): boolean {
return genericHasScope(
scope,
{
global: globalScopes.value,
project: context?.projectId ? scopesByProjectId.value[context.projectId] : [],
resource:
context?.resourceType && context?.resourceId
? scopesByResourceId.value[context.resourceType][context.resourceId]
: [],
},
options,
);
}
return {
globalRoles,
rolesByProjectId,
globalScopes,
scopesByProjectId,
scopesByResourceId,
addGlobalRole,
hasRole,
addGlobalScope,
setGlobalScopes,
addProjectScope,
addResourceScope,
hasScope,
};
});

View file

@ -111,7 +111,6 @@ export const useUsageStore = defineStore('usage', () => {
() =>
`${subscriptionAppUrl.value}/manage?token=${managementToken.value}&${commonSubscriptionAppUrlQueryParams.value}`,
),
canUserActivateLicense: computed(() => usersStore.canUserActivateLicense),
isLoading: computed(() => state.loading),
telemetryPayload: computed<UsageTelemetry>(() => ({
instance_id: instanceId.value,

View file

@ -26,9 +26,10 @@ import type {
IUser,
IUserResponse,
IUsersState,
CurrentUserResponse,
} from '@/Interface';
import { getCredentialPermissions } from '@/permissions';
import { getPersonalizedNodeTypes, isAuthorized, PERMISSIONS, ROLE } from '@/utils';
import { getPersonalizedNodeTypes, ROLE } from '@/utils';
import { defineStore } from 'pinia';
import { useRootStore } from './n8nRoot.store';
import { usePostHog } from './posthog.store';
@ -37,6 +38,8 @@ import { useUIStore } from './ui.store';
import { useCloudPlanStore } from './cloudPlan.store';
import { disableMfa, enableMfa, getMfaQR, verifyMfaToken } from '@/api/mfa';
import { confirmEmail, getCloudUserInfo } from '@/api/cloudPlans';
import { useRBACStore } from '@/stores/rbac.store';
import type { Scope } from '@n8n/permissions';
import { inviteUsers, acceptInvitation } from '@/api/invitation';
const isDefaultUser = (user: IUserResponse | null) =>
@ -79,26 +82,6 @@ export const useUsersStore = defineStore(STORES.USERS, {
globalRoleName(): IRole {
return this.currentUser?.globalRole?.name ?? 'default';
},
canUserDeleteTags(): boolean {
return isAuthorized(PERMISSIONS.TAGS.CAN_DELETE_TAGS, this.currentUser);
},
canUserActivateLicense(): boolean {
return isAuthorized(PERMISSIONS.USAGE.CAN_ACTIVATE_LICENSE, this.currentUser);
},
canUserAccessSidebarUserInfo() {
if (this.currentUser) {
const currentUser: IUser = this.currentUser;
return isAuthorized(PERMISSIONS.PRIMARY_MENU.CAN_ACCESS_USER_INFO, currentUser);
}
return false;
},
showUMSetupWarning() {
if (this.currentUser) {
const currentUser: IUser = this.currentUser;
return isAuthorized(PERMISSIONS.USER_SETTINGS.VIEW_UM_SETUP_WARNING, currentUser);
}
return false;
},
personalizedNodeTypes(): string[] {
const user = this.currentUser;
if (!user) {
@ -130,6 +113,19 @@ export const useUsersStore = defineStore(STORES.USERS, {
this.initialized = true;
} catch (e) {}
},
setCurrentUser(user: CurrentUserResponse) {
this.addUsers([user]);
this.currentUserId = user.id;
const defaultScopes: Scope[] = [];
useRBACStore().setGlobalScopes(user.globalScopes || defaultScopes);
usePostHog().init(user.featureFlags);
},
unsetCurrentUser() {
this.currentUserId = null;
this.currentUserCloudInfo = null;
useRBACStore().setGlobalScopes([]);
},
addUsers(users: IUserResponse[]) {
users.forEach((userResponse: IUserResponse) => {
const prevUser = this.users[userResponse.id] || {};
@ -177,10 +173,7 @@ export const useUsersStore = defineStore(STORES.USERS, {
return;
}
this.addUsers([user]);
this.currentUserId = user.id;
usePostHog().init(user.featureFlags);
this.setCurrentUser(user);
},
async loginWithCreds(params: {
email: string;
@ -194,18 +187,14 @@ export const useUsersStore = defineStore(STORES.USERS, {
return;
}
this.addUsers([user]);
this.currentUserId = user.id;
usePostHog().init(user.featureFlags);
this.setCurrentUser(user);
},
async logout(): Promise<void> {
const rootStore = useRootStore();
await logout(rootStore.getRestApiContext);
this.currentUserId = null;
this.unsetCurrentUser();
useCloudPlanStore().reset();
usePostHog().reset();
this.currentUserCloudInfo = null;
useUIStore().clearBannerStack();
},
async createOwner(params: {
@ -218,10 +207,8 @@ export const useUsersStore = defineStore(STORES.USERS, {
const user = await setupOwner(rootStore.getRestApiContext, params);
const settingsStore = useSettingsStore();
if (user) {
this.addUsers([user]);
this.currentUserId = user.id;
this.setCurrentUser(user);
settingsStore.stopShowingSetupPage();
usePostHog().init(user.featureFlags);
}
},
async validateSignupToken(params: {
@ -241,9 +228,7 @@ export const useUsersStore = defineStore(STORES.USERS, {
const rootStore = useRootStore();
const user = await acceptInvitation(rootStore.getRestApiContext, params);
if (user) {
this.addUsers([user]);
this.currentUserId = user.id;
usePostHog().init(user.featureFlags);
this.setCurrentUser(user);
}
},
async sendForgotPasswordEmail(params: { email: string }): Promise<void> {

View file

@ -0,0 +1,33 @@
import type { EnterpriseEditionFeature } from '@/constants';
import type { Resource, HasScopeOptions, Scope } from '@n8n/permissions';
import type { IRole } from '@/Interface';
export type AuthenticatedPermissionOptions = {};
export type CustomPermissionOptions<C = {}> = RBACPermissionCheck<C>;
export type EnterprisePermissionOptions = {
feature?: EnterpriseEditionFeature | EnterpriseEditionFeature[];
mode?: 'oneOf' | 'allOf';
};
export type GuestPermissionOptions = {};
export type RBACPermissionOptions = {
scope?: Scope | Scope[];
projectId?: string;
resourceType?: Resource;
resourceId?: string;
options?: HasScopeOptions;
};
export type RolePermissionOptions = IRole[];
export type PermissionType = 'authenticated' | 'custom' | 'enterprise' | 'guest' | 'rbac' | 'role';
export type PermissionTypeOptions = {
authenticated: AuthenticatedPermissionOptions;
custom: CustomPermissionOptions;
enterprise: EnterprisePermissionOptions;
guest: GuestPermissionOptions;
rbac: RBACPermissionOptions;
role: RolePermissionOptions;
};
export interface RBACPermissionCheck<Options> {
(options?: Options): boolean;
}

View file

@ -0,0 +1,59 @@
import type {
NavigationGuardNext,
NavigationGuardWithThis,
RouteLocationNormalized,
RouteLocation,
} from 'vue-router';
import type { IPermissions } from '@/Interface';
import type {
AuthenticatedPermissionOptions,
CustomPermissionOptions,
EnterprisePermissionOptions,
GuestPermissionOptions,
RBACPermissionOptions,
RolePermissionOptions,
PermissionType,
} from '@/types/rbac';
export type RouterMiddlewareType = PermissionType;
export type CustomMiddlewareOptions = CustomPermissionOptions<{
to: RouteLocationNormalized;
from: RouteLocationNormalized;
next: NavigationGuardNext;
}>;
export type MiddlewareOptions = {
authenticated: AuthenticatedPermissionOptions;
custom: CustomMiddlewareOptions;
enterprise: EnterprisePermissionOptions;
guest: GuestPermissionOptions;
rbac: RBACPermissionOptions;
role: RolePermissionOptions;
};
export interface RouteConfig {
meta: {
nodeView?: boolean;
templatesEnabled?: boolean;
getRedirect?: () => { name: string } | false;
permissions?: IPermissions;
middleware?: RouterMiddlewareType[];
middlewareOptions?: Partial<MiddlewareOptions>;
telemetry?: {
disabled?: true;
getProperties: (route: RouteLocation) => object;
};
scrollOffset?: number;
setScrollPosition?: (position: number) => void;
};
}
export type RouterMiddlewareReturnType = ReturnType<NavigationGuardWithThis<undefined>>;
export interface RouterMiddleware<RouterMiddlewareOptions = {}> {
(
to: RouteLocationNormalized,
from: RouteLocationNormalized,
next: NavigationGuardNext,
options: RouterMiddlewareOptions,
): RouterMiddlewareReturnType;
}

View file

@ -0,0 +1,65 @@
import type { RouteLocationNormalized } from 'vue-router';
import {
inferProjectIdFromRoute,
inferResourceTypeFromRoute,
inferResourceIdFromRoute,
} from '../rbacUtils';
describe('rbacUtils', () => {
describe('inferProjectIdFromRoute()', () => {
it('should infer project ID from route correctly', () => {
const route = { path: '/dashboard/projects/123/settings' } as RouteLocationNormalized;
const projectId = inferProjectIdFromRoute(route);
expect(projectId).toBe('123');
});
it('should return undefined for project ID if not found', () => {
const route = { path: '/dashboard/settings' } as RouteLocationNormalized;
const projectId = inferProjectIdFromRoute(route);
expect(projectId).toBeUndefined();
});
});
describe('inferResourceTypeFromRoute()', () => {
it.each([
['/workflows', 'workflow'],
['/workflows/123', 'workflow'],
['/workflows/123/settings', 'workflow'],
['/credentials', 'credential'],
['/variables', 'variable'],
['/users', 'user'],
['/source-control', 'sourceControl'],
['/external-secrets', 'externalSecretsStore'],
])('should infer resource type from %s correctly to %s', (path, type) => {
const route = { path } as RouteLocationNormalized;
const resourceType = inferResourceTypeFromRoute(route);
expect(resourceType).toBe(type);
});
it('should return undefined for resource type if not found', () => {
const route = { path: '/dashboard/settings' } as RouteLocationNormalized;
const resourceType = inferResourceTypeFromRoute(route);
expect(resourceType).toBeUndefined();
});
});
describe('inferResourceIdFromRoute()', () => {
it('should infer resource ID from params.id', () => {
const route = { params: { id: 'abc123' } } as RouteLocationNormalized;
const resourceId = inferResourceIdFromRoute(route);
expect(resourceId).toBe('abc123');
});
it('should infer resource ID from params.name if id is not present', () => {
const route = { params: { name: 'my-resource' } } as RouteLocationNormalized;
const resourceId = inferResourceIdFromRoute(route);
expect(resourceId).toBe('my-resource');
});
it('should return undefined for resource ID if neither id nor name is present', () => {
const route = { params: {} } as RouteLocationNormalized;
const resourceId = inferResourceIdFromRoute(route);
expect(resourceId).toBeUndefined();
});
});
});

View file

@ -1,67 +0,0 @@
import { beforeAll } from 'vitest';
import { setActivePinia } from 'pinia';
import { merge } from 'lodash-es';
import { isAuthorized, ROLE } from '@/utils';
import { useSettingsStore } from '@/stores/settings.store';
import { useSSOStore } from '@/stores/sso.store';
import type { IUser } from '@/Interface';
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import type { IN8nUISettings } from 'n8n-workflow';
import { createTestingPinia } from '@pinia/testing';
const DEFAULT_SETTINGS: IN8nUISettings = SETTINGS_STORE_DEFAULT_STATE.settings;
const DEFAULT_USER: IUser = {
id: '1',
isPending: false,
isDefaultUser: true,
isOwner: false,
isPendingUser: false,
globalRole: {
name: 'default',
id: '1',
createdAt: new Date(),
},
};
describe('userUtils', () => {
let settingsStore: ReturnType<typeof useSettingsStore>;
let ssoStore: ReturnType<typeof useSSOStore>;
describe('isAuthorized', () => {
beforeAll(() => {
setActivePinia(createTestingPinia());
settingsStore = useSettingsStore();
ssoStore = useSSOStore();
});
// @TODO Move to routes tests in the future
it('should check SSO settings route permissions', () => {
const ssoSettingsPermissions = {
allow: {
role: [ROLE.Owner],
},
deny: {
shouldDeny: () => {
const settingsStore = useSettingsStore();
return settingsStore.isDesktopDeployment;
},
},
};
const user: IUser = merge({}, DEFAULT_USER, {
isDefaultUser: false,
isOwner: true,
globalRole: {
id: '1',
name: 'owner',
createdAt: new Date(),
},
});
settingsStore.setSettings(merge({}, DEFAULT_SETTINGS, { enterprise: { saml: true } }));
expect(isAuthorized(ssoSettingsPermissions, user)).toBe(true);
});
});
});

View file

@ -0,0 +1,32 @@
import type { RouteLocationNormalized } from 'vue-router';
import type { Resource } from '@n8n/permissions';
export function inferProjectIdFromRoute(to: RouteLocationNormalized): string {
const routeParts = to.path.split('/');
const projectsIndex = routeParts.indexOf('projects');
const projectIdIndex = projectsIndex !== -1 ? projectsIndex + 1 : -1;
return routeParts[projectIdIndex];
}
export function inferResourceTypeFromRoute(to: RouteLocationNormalized): Resource | undefined {
const routeParts = to.path.split('/');
const routeMap = {
workflow: 'workflows',
credential: 'credentials',
user: 'users',
variable: 'variables',
sourceControl: 'source-control',
externalSecretsStore: 'external-secrets',
};
for (const resource of Object.keys(routeMap) as Array<keyof typeof routeMap>) {
if (routeParts.includes(routeMap[resource])) {
return resource;
}
}
}
export function inferResourceIdFromRoute(to: RouteLocationNormalized): string | undefined {
return (to.params.id as string | undefined) ?? (to.params.name as string | undefined);
}

View file

@ -61,7 +61,6 @@ import {
CODE_NODE_TYPE,
} from '@/constants';
import type {
IPermissions,
IPersonalizationSurveyAnswersV1,
IPersonalizationSurveyAnswersV2,
IPersonalizationSurveyAnswersV3,
@ -70,7 +69,6 @@ import type {
IUser,
ILogInStatus,
IRole,
IUserPermissions,
} from '@/Interface';
/*
@ -97,89 +95,6 @@ export const LOGIN_STATUS: { LoggedIn: ILogInStatus; LoggedOut: ILogInStatus } =
LoggedOut: 'LoggedOut', // Can only be logged out if UM has been setup
};
export const PERMISSIONS: IUserPermissions = {
TAGS: {
CAN_DELETE_TAGS: {
allow: {
role: [ROLE.Owner],
},
},
},
PRIMARY_MENU: {
CAN_ACCESS_USER_INFO: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
deny: {
role: [ROLE.Default],
},
},
},
USER_SETTINGS: {
VIEW_UM_SETUP_WARNING: {
allow: {
role: [ROLE.Default],
},
},
},
USAGE: {
CAN_ACTIVATE_LICENSE: {
allow: {
role: [ROLE.Owner],
},
},
},
};
/**
* To be authorized, user must pass all deny rules and pass any of the allow rules.
*
*/
export const isAuthorized = (permissions: IPermissions, currentUser: IUser | null): boolean => {
const loginStatus = currentUser ? LOGIN_STATUS.LoggedIn : LOGIN_STATUS.LoggedOut;
// big AND block
// if any of these are false, block user
if (permissions.deny) {
if (permissions.deny.shouldDeny && permissions.deny.shouldDeny()) {
return false;
}
if (permissions.deny.loginStatus && permissions.deny.loginStatus.includes(loginStatus)) {
return false;
}
if (currentUser?.globalRole?.name) {
const role = currentUser.isDefaultUser ? ROLE.Default : currentUser.globalRole.name;
if (permissions.deny.role && permissions.deny.role.includes(role)) {
return false;
}
} else if (permissions.deny.role) {
return false;
}
}
// big OR block
// if any of these are true, allow user
if (permissions.allow) {
if (permissions.allow.shouldAllow && permissions.allow.shouldAllow()) {
return true;
}
if (permissions.allow.loginStatus && permissions.allow.loginStatus.includes(loginStatus)) {
return true;
}
if (currentUser?.globalRole?.name) {
const role = currentUser.isDefaultUser ? ROLE.Default : currentUser.globalRole.name;
if (permissions.allow.role && permissions.allow.role.includes(role)) {
return true;
}
}
}
return false;
};
export function getPersonalizedNodeTypes(
answers:
| IPersonalizationSurveyAnswersV1

View file

@ -8,6 +8,8 @@ import { i18n as locale } from '@/plugins/i18n';
import { useUIStore } from '@/stores';
import { N8N_PRICING_PAGE_URL } from '@/constants';
import { useToast } from '@/composables';
import { ROLE } from '@/utils';
import { hasPermission } from '@/rbac/permissions';
const usageStore = useUsageStore();
const route = useRoute();
@ -26,6 +28,12 @@ const activationKeyModal = ref(false);
const activationKey = ref('');
const activationKeyInput = ref<HTMLInputElement | null>(null);
const canUserActivateLicense = computed(() =>
hasPermission(['role'], {
role: [ROLE.Owner],
}),
);
const showActivationSuccess = () => {
toast.showMessage({
type: 'success',
@ -77,7 +85,7 @@ onMounted(async () => {
}
}
try {
if (!route.query.key && usageStore.canUserActivateLicense) {
if (!route.query.key && canUserActivateLicense.value) {
await usageStore.refreshLicenseManagementToken();
} else {
await usageStore.getLicenseInfo();
@ -184,7 +192,7 @@ const openPricingPage = () => {
<n8n-button
:class="$style.buttonTertiary"
@click="onAddActivationKey"
v-if="usageStore.canUserActivateLicense"
v-if="canUserActivateLicense"
type="tertiary"
size="large"
>

View file

@ -2,7 +2,7 @@
<div :class="$style.container">
<div>
<n8n-heading size="2xlarge">{{ $locale.baseText('settings.users') }}</n8n-heading>
<div :class="$style.buttonContainer" v-if="!usersStore.showUMSetupWarning">
<div :class="$style.buttonContainer" v-if="!showUMSetupWarning">
<n8n-tooltip :disabled="!ssoStore.isSamlLoginEnabled">
<template #content>
<span> {{ $locale.baseText('settings.users.invite.tooltip') }} </span>
@ -70,6 +70,8 @@ import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import { useUsageStore } from '@/stores/usage.store';
import { useSSOStore } from '@/stores/sso.store';
import { hasPermission } from '@/rbac/permissions';
import { ROLE } from '@/utils';
export default defineComponent({
name: 'SettingsUsersView',
@ -80,7 +82,7 @@ export default defineComponent({
};
},
async mounted() {
if (!this.usersStore.showUMSetupWarning) {
if (!this.showUMSetupWarning) {
await this.usersStore.fetchUsers();
}
},
@ -89,6 +91,9 @@ export default defineComponent({
isSharingEnabled() {
return this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing);
},
showUMSetupWarning() {
return hasPermission(['role'], { role: [ROLE.Default] });
},
usersListActions(): IUserListAction[] {
return [
{

View file

@ -1,9 +1,9 @@
import { afterAll, beforeAll, vi } from 'vitest';
import { afterAll, beforeAll } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { waitFor } from '@testing-library/vue';
import { setupServer } from '@/__tests__/server';
import VariablesView from '@/views/VariablesView.vue';
import { useSettingsStore, useUsersStore } from '@/stores';
import { useSettingsStore, useUsersStore, useRBACStore } from '@/stores';
import { createComponentRenderer } from '@/__tests__/render';
import { EnterpriseEditionFeature } from '@/constants';
@ -12,6 +12,7 @@ describe('VariablesView', () => {
let pinia: ReturnType<typeof createPinia>;
let settingsStore: ReturnType<typeof useSettingsStore>;
let usersStore: ReturnType<typeof useUsersStore>;
let rbacStore: ReturnType<typeof useRBACStore>;
const renderComponent = createComponentRenderer(VariablesView);
@ -25,6 +26,7 @@ describe('VariablesView', () => {
settingsStore = useSettingsStore();
usersStore = useUsersStore();
rbacStore = useRBACStore();
await settingsStore.getSettings();
await usersStore.fetchUsers();
await usersStore.loginWithCookie();
@ -41,29 +43,12 @@ describe('VariablesView', () => {
});
describe('should render empty state', () => {
it('when feature is enabled and logged in user is owner', async () => {
settingsStore.settings.enterprise[EnterpriseEditionFeature.Variables] = true;
vi.spyOn(usersStore, 'currentUser', 'get').mockReturnValue({
isOwner: true,
});
it('when feature is disabled and logged in user is not owner', async () => {
settingsStore.settings.enterprise[EnterpriseEditionFeature.Variables] = false;
rbacStore.setGlobalScopes(['variable:read', 'variable:list']);
const { queryByTestId } = renderComponent({ pinia });
await waitFor(() => {
expect(queryByTestId('empty-resources-list')).toBeVisible();
expect(queryByTestId('unavailable-resources-list')).not.toBeInTheDocument();
expect(queryByTestId('cannot-create-variables')).not.toBeInTheDocument();
});
});
it('when feature is disabled and logged in user is owner', async () => {
settingsStore.settings.enterprise[EnterpriseEditionFeature.Variables] = false;
vi.spyOn(usersStore, 'currentUser', 'get').mockReturnValue({
isOwner: true,
});
const { queryByTestId } = renderComponent(VariablesView, { pinia });
await waitFor(() => {
expect(queryByTestId('empty-resources-list')).not.toBeInTheDocument();
expect(queryByTestId('unavailable-resources-list')).toBeVisible();
@ -71,13 +56,49 @@ describe('VariablesView', () => {
});
});
it('when feature is eanbled and logged in user is not owner', async () => {
settingsStore.settings.enterprise[EnterpriseEditionFeature.Variables] = true;
vi.spyOn(usersStore, 'currentUser', 'get').mockReturnValue({
isDefaultUser: true,
});
it('when feature is disabled and logged in user is owner', async () => {
settingsStore.settings.enterprise[EnterpriseEditionFeature.Variables] = false;
rbacStore.setGlobalScopes([
'variable:create',
'variable:read',
'variable:update',
'variable:delete',
'variable:list',
]);
const { queryByTestId } = renderComponent(VariablesView, { pinia });
const { queryByTestId } = renderComponent({ pinia });
await waitFor(() => {
expect(queryByTestId('empty-resources-list')).not.toBeInTheDocument();
expect(queryByTestId('unavailable-resources-list')).toBeVisible();
expect(queryByTestId('cannot-create-variables')).not.toBeInTheDocument();
});
});
it('when feature is enabled and logged in user is owner', async () => {
settingsStore.settings.enterprise[EnterpriseEditionFeature.Variables] = true;
rbacStore.setGlobalScopes([
'variable:create',
'variable:read',
'variable:update',
'variable:delete',
'variable:list',
]);
const { queryByTestId } = renderComponent({ pinia });
await waitFor(() => {
expect(queryByTestId('empty-resources-list')).not.toBeInTheDocument();
expect(queryByTestId('unavailable-resources-list')).not.toBeInTheDocument();
expect(queryByTestId('cannot-create-variables')).not.toBeInTheDocument();
});
});
it('when feature is enabled and logged in user is not owner', async () => {
settingsStore.settings.enterprise[EnterpriseEditionFeature.Variables] = true;
rbacStore.setGlobalScopes(['variable:read', 'variable:list']);
const { queryByTestId } = renderComponent({ pinia });
await waitFor(() => {
expect(queryByTestId('empty-resources-list')).not.toBeInTheDocument();

View file

@ -850,6 +850,9 @@ importers:
'@n8n/codemirror-lang-sql':
specifier: ^1.0.2
version: 1.0.2(@codemirror/view@6.5.1)(@lezer/common@1.1.0)
'@n8n/permissions':
specifier: workspace:*
version: link:../@n8n/permissions
'@vueuse/components':
specifier: ^10.5.0
version: 10.5.0(vue@3.3.4)