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,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> {