Skip to content

Commit

Permalink
feat(editor): Add routing middleware, permission checks, RBAC store, …
Browse files Browse the repository at this point in the history
…RBAC component (#7702)

Github issue / Community forum post (link here to close automatically):

---------

Co-authored-by: Csaba Tuncsik <csaba@n8n.io>
  • Loading branch information
alexgrozav and cstuncsik committed Nov 23, 2023
1 parent fdb2c18 commit 67a8891
Show file tree
Hide file tree
Showing 62 changed files with 1,924 additions and 635 deletions.
4 changes: 2 additions & 2 deletions packages/@n8n/permissions/src/hasScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ export function hasScope(
): boolean;
export function hasScope(
scope: Scope | Scope[],
userScopes: unknown,
userScopes: GlobalScopes | ScopeLevels,
options: ScopeOptions = { mode: 'oneOf' },
): boolean {
if (!Array.isArray(scope)) {
scope = [scope];
}

const userScopeSet = new Set(Object.values(userScopes ?? {}).flat());
const userScopeSet = new Set(Object.values(userScopes).flat());

if (options.mode === 'allOf') {
return !!scope.length && scope.every((s) => userScopeSet.has(s));
Expand Down
5 changes: 3 additions & 2 deletions packages/@n8n/permissions/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ export type Resource =

export type ResourceScope<
R extends Resource,
Operations extends string = DefaultOperations,
> = `${R}:${Operations}`;
Operation extends string = DefaultOperations,
> = `${R}:${Operation}`;

export type WildcardScope = `${Resource}:*` | '*';

export type WorkflowScope = ResourceScope<'workflow', DefaultOperations | 'share'>;
Expand Down
4 changes: 3 additions & 1 deletion packages/design-system/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ import {
N8nUserStack,
} from './components';

export const N8nPlugin: Plugin<{}> = {
export interface N8nPluginOptions {}

export const N8nPlugin: Plugin<N8nPluginOptions> = {
install: (app) => {
app.component('n8n-action-box', N8nActionBox);
app.component('n8n-action-dropdown', N8nActionDropdown);
Expand Down
1 change: 1 addition & 0 deletions packages/editor-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@jsplumb/util": "^5.13.2",
"@lezer/common": "^1.0.4",
"@n8n/codemirror-lang-sql": "^1.0.2",
"@n8n/permissions": "workspace:*",
"@vueuse/components": "^10.5.0",
"@vueuse/core": "^10.5.0",
"axios": "^0.21.1",
Expand Down
55 changes: 4 additions & 51 deletions packages/editor-ui/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { mapStores } from 'pinia';
import { extendExternalHooks } from '@/mixins/externalHooks';
import { newVersions } from '@/mixins/newVersions';
import BannerStack from '@/components/banners/BannerStack.vue';
Expand All @@ -62,6 +61,7 @@ import {
import { useHistoryHelper } from '@/composables/useHistoryHelper';
import { useRoute } from 'vue-router';
import { runExternalHook } from '@/utils';
import { initializeAuthenticatedFeatures } from '@/init';
export default defineComponent({
name: 'App',
Expand Down Expand Up @@ -114,69 +114,22 @@ export default defineComponent({
console.log(HIRING_BANNER);
}
},
async initializeCloudData() {
await this.cloudPlanStore.checkForCloudPlanData();
await this.cloudPlanStore.fetchUserCloudAccount();
},
async initializeTemplates() {
if (this.settingsStore.isTemplatesEnabled) {
try {
await this.settingsStore.testTemplatesEndpoint();
} catch (e) {}
}
},
async initializeSourceControl() {
if (this.sourceControlStore.isEnterpriseSourceControlEnabled) {
await this.sourceControlStore.getPreferences();
}
},
async initializeNodeTranslationHeaders() {
if (this.defaultLocale !== 'en') {
await this.nodeTypesStore.getNodeTranslationHeaders();
}
},
async initializeHooks(): Promise<void> {
if (this.settingsStore.isCloudDeployment) {
const { n8nCloudHooks } = await import('@/hooks/cloud');
extendExternalHooks(n8nCloudHooks);
}
},
async onAfterAuthenticate() {
if (this.onAfterAuthenticateInitialized) {
return;
}
if (!this.usersStore.currentUser) {
return;
}
await Promise.all([
this.initializeSourceControl(),
this.initializeTemplates(),
this.initializeNodeTranslationHeaders(),
]);
this.onAfterAuthenticateInitialized = true;
},
},
async mounted() {
this.logHiringBanner();
await this.settingsStore.initialize();
await this.initializeHooks();
await this.initializeCloudData();
void this.checkForNewVersions();
void this.onAfterAuthenticate();
void initializeAuthenticatedFeatures();
void runExternalHook('app.mount');
this.pushStore.pushConnect();
this.loading = false;
},
watch: {
// eslint-disable-next-line @typescript-eslint/naming-convention
async 'usersStore.currentUser'(currentValue, previousValue) {
if (currentValue && !previousValue) {
await this.onAfterAuthenticate();
await initializeAuthenticatedFeatures();
}
},
defaultLocale(newLocale) {
Expand Down
3 changes: 3 additions & 0 deletions packages/editor-ui/src/Interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import type {
import type { BulkCommand, Undoable } from '@/models/history';
import type { PartialBy, TupleToUnion } from '@/utils/typeHelpers';
import type { Component } from 'vue';
import type { Scope } from '@n8n/permissions';
import type { runExternalHook } from '@/utils';

export * from 'n8n-design-system/types';
Expand Down Expand Up @@ -682,6 +683,7 @@ export interface IUserResponse {
id: string;
createdAt: Date;
};
globalScopes?: Scope[];
personalizationAnswers?: IPersonalizationSurveyVersions | null;
isPending: boolean;
signInType?: SignInType;
Expand Down Expand Up @@ -1682,6 +1684,7 @@ export declare namespace Cloud {
}

export interface CloudPlanState {
initialized: boolean;
data: Cloud.PlanData | null;
usage: InstanceUsage | null;
loadingPlan: boolean;
Expand Down
10 changes: 10 additions & 0 deletions packages/editor-ui/src/__tests__/permissions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,14 @@ describe('parsePermissionsTable()', () => {

expect(permissions.canRead).toBe(true);
});

it('should pass permission to test functions', () => {
const permissions = parsePermissionsTable(user, [
{ name: 'canRead', test: (p) => !!p.isInstanceOwner },
{ name: 'canUpdate', test: (p) => !!p.canRead },
]);

expect(permissions.canRead).toBe(true);
expect(permissions.canUpdate).toBe(true);
});
});
8 changes: 6 additions & 2 deletions packages/editor-ui/src/components/MainSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ import {
import { isNavigationFailure } from 'vue-router';
import ExecutionsUsage from '@/components/ExecutionsUsage.vue';
import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue';
import { ROLE } from '@/utils';
import { hasPermission } from '@/rbac/permissions';
export default defineComponent({
name: 'MainSidebar',
Expand Down Expand Up @@ -177,7 +179,9 @@ export default defineComponent({
return accessibleRoute !== null;
},
showUserArea(): boolean {
return this.usersStore.canUserAccessSidebarUserInfo && this.usersStore.currentUser !== null;
return hasPermission(['role'], {
role: [ROLE.Member, ROLE.Owner],
});
},
workflowExecution(): IExecutionResponse | null {
return this.workflowsStore.getWorkflowExecution;
Expand Down Expand Up @@ -347,7 +351,7 @@ export default defineComponent({
};
},
},
mounted() {
async mounted() {
this.basePath = this.rootStore.baseUrl;
if (this.$refs.user) {
void this.$externalHooks().run('mainSidebar.mounted', {
Expand Down
61 changes: 61 additions & 0 deletions packages/editor-ui/src/components/RBAC.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<script lang="ts">
import type { PropType } from 'vue';
import { computed, defineComponent } from 'vue';
import { useRBACStore } from '@/stores/rbac.store';
import type { HasScopeMode, Scope, Resource } from '@n8n/permissions';
import {
inferProjectIdFromRoute,
inferResourceIdFromRoute,
inferResourceTypeFromRoute,
} from '@/utils/rbacUtils';
import { useRoute } from 'vue-router';
export default defineComponent({
props: {
scope: {
type: [String, Array] as PropType<Scope | Scope[]>,
required: true,
},
mode: {
type: String as PropType<HasScopeMode>,
default: 'allOf',
},
resourceType: {
type: String as PropType<Resource>,
default: undefined,
},
resourceId: {
type: String,
default: undefined,
},
projectId: {
type: String,
default: undefined,
},
},
setup(props, { slots }) {
const rbacStore = useRBACStore();
const route = useRoute();
const hasScope = computed(() => {
const projectId = props.projectId ?? inferProjectIdFromRoute(route);
const resourceType = props.resourceType ?? inferResourceTypeFromRoute(route);
const resourceId = resourceType
? props.resourceId ?? inferResourceIdFromRoute(route)
: undefined;
return rbacStore.hasScope(
props.scope,
{
projectId,
resourceType,
resourceId,
},
{ mode: props.mode },
);
});
return () => (hasScope.value ? slots.default?.() : slots.fallback?.());
},
});
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import TagsTableHeader from '@/components/TagsManager/TagsView/TagsTableHeader.v
import TagsTable from '@/components/TagsManager/TagsView/TagsTable.vue';
import { mapStores } from 'pinia';
import { useUsersStore } from '@/stores/users.store';
import { useRBACStore } from '@/stores/rbac.store';
const matches = (name: string, filter: string) =>
name.toLowerCase().trim().includes(filter.toLowerCase().trim());
Expand All @@ -50,7 +51,7 @@ export default defineComponent({
};
},
computed: {
...mapStores(useUsersStore),
...mapStores(useUsersStore, useRBACStore),
isCreateEnabled(): boolean {
return (this.tags || []).length === 0 || this.createEnabled;
},
Expand All @@ -70,7 +71,7 @@ export default defineComponent({
disable: disabled && tag.id !== this.deleteId && tag.id !== this.updateId,
update: disabled && tag.id === this.updateId,
delete: disabled && tag.id === this.deleteId,
canDelete: this.usersStore.canUserDeleteTags,
canDelete: this.rbacStore.hasScope('tag:delete'),
}),
);
Expand Down
50 changes: 50 additions & 0 deletions packages/editor-ui/src/components/__tests__/RBAC.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import RBAC from '@/components/RBAC.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { useRBACStore } from '@/stores/rbac.store';

const renderComponent = createComponentRenderer(RBAC);

vi.mock('vue-router', () => ({
useRoute: vi.fn(() => ({
path: '/workflows',
params: {},
})),
}));

vi.mock('@/stores/rbac.store', () => ({
useRBACStore: vi.fn(),
}));

describe('RBAC', () => {
it('renders default slot when hasScope is true', async () => {
vi.mocked(useRBACStore).mockImplementation(() => ({
hasScope: () => true,
}));

const wrapper = renderComponent({
props: { scope: 'worfklow:list' },
slots: {
default: 'Default Content',
fallback: 'Fallback Content',
},
});

expect(wrapper.getByText('Default Content')).toBeInTheDocument();
});

it('renders fallback slot when hasScope is false', async () => {
vi.mocked(useRBACStore).mockImplementation(() => ({
hasScope: () => false,
}));

const wrapper = renderComponent({
props: { scope: 'worfklow:list' },
slots: {
default: 'Default Content',
fallback: 'Fallback Content',
},
});

expect(wrapper.getByText('Fallback Content')).toBeInTheDocument();
});
});
1 change: 1 addition & 0 deletions packages/editor-ui/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,7 @@ export const enum STORES {
WEBHOOKS = 'webhooks',
HISTORY = 'history',
CLOUD_PLAN = 'cloudPlan',
RBAC = 'rbac',
COLLABORATION = 'collaboration',
PUSH = 'push',
}
Expand Down
12 changes: 12 additions & 0 deletions packages/editor-ui/src/hooks/register.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { extendExternalHooks } from '@/mixins/externalHooks';

let cloudHooksInitialized = false;
export async function initializeCloudHooks() {
if (cloudHooksInitialized) {
return;
}

const { n8nCloudHooks } = await import('@/hooks/cloud');
extendExternalHooks(n8nCloudHooks);
cloudHooksInitialized = true;
}
Loading

0 comments on commit 67a8891

Please sign in to comment.