Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(editor): Add routing middleware, permission checks, RBAC store, RBAC component #7702

Merged
merged 27 commits into from
Nov 23, 2023
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ea4123a
feat: add routing middleware, rbac store, rbac component
alexgrozav Nov 13, 2023
25bb007
test: add unit tests for rbac functionality
alexgrozav Nov 14, 2023
0a334c2
feat: replace existing isAuthorized permissions check
alexgrozav Nov 15, 2023
f43648b
test: add tests for permission checks
alexgrozav Nov 15, 2023
21501ef
chore: merge master
alexgrozav Nov 15, 2023
e8b6123
fix: fix /users route permission
alexgrozav Nov 15, 2023
659d93d
feat: code improvements and test coverage increase
alexgrozav Nov 15, 2023
ebb6645
chore: add intermediary const
alexgrozav Nov 15, 2023
449a219
fix: fix tag scope name
alexgrozav Nov 15, 2023
2be9594
fix: remove .only
alexgrozav Nov 15, 2023
14bc6f2
Merge remote-tracking branch 'origin/master' into pay-1005-front-end-…
cstuncsik Nov 16, 2023
4229f24
Merge remote-tracking branch 'origin/master' into pay-1005-front-end-…
cstuncsik Nov 16, 2023
e77a9bd
Merge remote-tracking branch 'origin/master' into pay-1005-front-end-…
cstuncsik Nov 17, 2023
2e918c6
fix: Update permission package
cstuncsik Nov 17, 2023
b57306e
fix: Add RBAC store to permissions
cstuncsik Nov 20, 2023
edb3679
fix: Remove unnecessary permission setting
cstuncsik Nov 20, 2023
04f4c17
fix: Update variables view unit test
cstuncsik Nov 20, 2023
5493903
chore: merge master
alexgrozav Nov 21, 2023
6a6ffef
feat: add init functions
alexgrozav Nov 21, 2023
e93c2f2
fix: Update permissions
cstuncsik Nov 21, 2023
0bd74d3
Merge branch 'pay-1005-front-end-permissions-overhaul' of github.com:…
cstuncsik Nov 21, 2023
ddeeb25
fix: Remove unused import
cstuncsik Nov 21, 2023
fdc3d09
fix: Add back user to variable view test
cstuncsik Nov 21, 2023
34eb0d2
fix: Roll back variable view changes
cstuncsik Nov 21, 2023
6d7c77e
fix: Update permissions
cstuncsik Nov 21, 2023
d403b14
fix: Permission object is not optional in test function
cstuncsik Nov 22, 2023
1eba640
Merge remote-tracking branch 'origin/master' into pay-1005-front-end-…
cstuncsik Nov 23, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -61,6 +60,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 @@ -112,68 +112,21 @@ 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.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 @@ -671,6 +672,7 @@ export interface IUserResponse {
id: string;
createdAt: Date;
};
globalScopes?: Scope[];
personalizationAnswers?: IPersonalizationSurveyVersions | null;
isPending: boolean;
signInType?: SignInType;
Expand Down Expand Up @@ -1670,6 +1672,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',
}

export const enum SignInType {
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
Loading