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
62 changed files with 1935 additions and 646 deletions

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;
}