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