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:
51
packages/editor-ui/src/rbac/checks/__tests__/hasRole.test.ts
Normal file
51
packages/editor-ui/src/rbac/checks/__tests__/hasRole.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
27
packages/editor-ui/src/rbac/checks/__tests__/isGuest.test.ts
Normal file
27
packages/editor-ui/src/rbac/checks/__tests__/isGuest.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
19
packages/editor-ui/src/rbac/checks/__tests__/isValid.test.ts
Normal file
19
packages/editor-ui/src/rbac/checks/__tests__/isValid.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
16
packages/editor-ui/src/rbac/checks/hasRole.ts
Normal file
16
packages/editor-ui/src/rbac/checks/hasRole.ts
Normal 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;
|
||||
};
|
||||
20
packages/editor-ui/src/rbac/checks/hasScope.ts
Normal file
20
packages/editor-ui/src/rbac/checks/hasScope.ts
Normal 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,
|
||||
);
|
||||
};
|
||||
6
packages/editor-ui/src/rbac/checks/index.ts
Normal file
6
packages/editor-ui/src/rbac/checks/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './hasRole';
|
||||
export * from './hasScope';
|
||||
export * from './isAuthenticated';
|
||||
export * from './isEnterpriseFeatureEnabled';
|
||||
export * from './isGuest';
|
||||
export * from './isValid';
|
||||
7
packages/editor-ui/src/rbac/checks/isAuthenticated.ts
Normal file
7
packages/editor-ui/src/rbac/checks/isAuthenticated.ts
Normal 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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
7
packages/editor-ui/src/rbac/checks/isGuest.ts
Normal file
7
packages/editor-ui/src/rbac/checks/isGuest.ts
Normal 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;
|
||||
};
|
||||
5
packages/editor-ui/src/rbac/checks/isValid.ts
Normal file
5
packages/editor-ui/src/rbac/checks/isValid.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { RBACPermissionCheck, CustomPermissionOptions } from '@/types/rbac';
|
||||
|
||||
export const isValid: RBACPermissionCheck<CustomPermissionOptions> = (fn) => {
|
||||
return fn ? fn() : false;
|
||||
};
|
||||
Reference in New Issue
Block a user