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:
63
packages/editor-ui/src/rbac/__tests__/permissions.test.ts
Normal file
63
packages/editor-ui/src/rbac/__tests__/permissions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
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;
|
||||
};
|
||||
20
packages/editor-ui/src/rbac/middleware.ts
Normal file
20
packages/editor-ui/src/rbac/middleware.ts
Normal 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,
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
18
packages/editor-ui/src/rbac/middleware/authenticated.ts
Normal file
18
packages/editor-ui/src/rbac/middleware/authenticated.ts
Normal 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 } });
|
||||
}
|
||||
};
|
||||
14
packages/editor-ui/src/rbac/middleware/custom.ts
Normal file
14
packages/editor-ui/src/rbac/middleware/custom.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
16
packages/editor-ui/src/rbac/middleware/enterprise.ts
Normal file
16
packages/editor-ui/src/rbac/middleware/enterprise.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
16
packages/editor-ui/src/rbac/middleware/guest.ts
Normal file
16
packages/editor-ui/src/rbac/middleware/guest.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
25
packages/editor-ui/src/rbac/middleware/rbac.ts
Normal file
25
packages/editor-ui/src/rbac/middleware/rbac.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
16
packages/editor-ui/src/rbac/middleware/role.ts
Normal file
16
packages/editor-ui/src/rbac/middleware/role.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
38
packages/editor-ui/src/rbac/permissions.ts
Normal file
38
packages/editor-ui/src/rbac/permissions.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user