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:
152
packages/editor-ui/src/stores/__tests__/rbac.store.test.ts
Normal file
152
packages/editor-ui/src/stores/__tests__/rbac.store.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
113
packages/editor-ui/src/stores/rbac.store.ts
Normal file
113
packages/editor-ui/src/stores/rbac.store.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user