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: Add AND mask to hasScope #8379

Merged
merged 1 commit into from
Jan 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
20 changes: 18 additions & 2 deletions packages/@n8n/permissions/src/hasScope.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,41 @@
import type { Scope, ScopeLevels, GlobalScopes, ScopeOptions } from './types';
import type { Scope, ScopeLevels, GlobalScopes, ScopeOptions, MaskLevels } from './types';

export function hasScope(
scope: Scope | Scope[],
userScopes: GlobalScopes,
masks?: MaskLevels,
options?: ScopeOptions,
): boolean;
export function hasScope(
scope: Scope | Scope[],
userScopes: ScopeLevels,
masks?: MaskLevels,
options?: ScopeOptions,
): boolean;
export function hasScope(
scope: Scope | Scope[],
userScopes: GlobalScopes | ScopeLevels,
masks?: MaskLevels,
options: ScopeOptions = { mode: 'oneOf' },
): boolean {
if (!Array.isArray(scope)) {
scope = [scope];
}

const userScopeSet = new Set(Object.values(userScopes).flat());
const maskedScopes: GlobalScopes | ScopeLevels = Object.fromEntries(
Object.entries(userScopes).map((e) => [e[0], [...e[1]]]),
) as GlobalScopes | ScopeLevels;

if (masks?.sharing) {
if ('project' in maskedScopes) {
maskedScopes.project = maskedScopes.project.filter((v) => masks.sharing.includes(v));
}
if ('resource' in maskedScopes) {
maskedScopes.resource = maskedScopes.resource.filter((v) => masks.sharing.includes(v));
}
}

const userScopeSet = new Set(Object.values(maskedScopes).flat());

if (options.mode === 'allOf') {
return !!scope.length && scope.every((s) => userScopeSet.has(s));
Expand Down
5 changes: 5 additions & 0 deletions packages/@n8n/permissions/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,10 @@ export type ProjectScopes = GetScopeLevel<'project'>;
export type ResourceScopes = GetScopeLevel<'resource'>;
export type ScopeLevels = GlobalScopes & (ProjectScopes | (ProjectScopes & ResourceScopes));

export type MaskLevel = 'sharing';
export type GetMaskLevel<T extends MaskLevel> = Record<T, Scope[]>;
export type SharingMasks = GetMaskLevel<'sharing'>;
export type MaskLevels = SharingMasks;

export type ScopeMode = 'oneOf' | 'allOf';
export type ScopeOptions = { mode: ScopeMode };
126 changes: 126 additions & 0 deletions packages/@n8n/permissions/test/hasScope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ describe('hasScope', () => {
{
global: memberPermissions,
},
undefined,
{ mode: 'oneOf' },
),
).toBe(true);
Expand All @@ -43,6 +44,7 @@ describe('hasScope', () => {
{
global: memberPermissions,
},
undefined,
{ mode: 'allOf' },
),
).toBe(true);
Expand All @@ -53,6 +55,7 @@ describe('hasScope', () => {
{
global: memberPermissions,
},
undefined,
{ mode: 'oneOf' },
),
).toBe(false);
Expand All @@ -63,6 +66,7 @@ describe('hasScope', () => {
{
global: memberPermissions,
},
undefined,
{ mode: 'allOf' },
),
).toBe(false);
Expand Down Expand Up @@ -95,6 +99,7 @@ describe('hasScope', () => {
{
global: ownerPermissions,
},
undefined,
{ mode: 'allOf' },
),
).toBe(true);
Expand All @@ -105,6 +110,7 @@ describe('hasScope', () => {
{
global: memberPermissions,
},
undefined,
{ mode: 'allOf' },
),
).toBe(false);
Expand All @@ -115,6 +121,7 @@ describe('hasScope', () => {
{
global: memberPermissions,
},
undefined,
{ mode: 'allOf' },
),
).toBe(false);
Expand All @@ -125,8 +132,127 @@ describe('hasScope', () => {
{
global: memberPermissions,
},
undefined,
{ mode: 'allOf' },
),
).toBe(false);
});
});

describe('hasScope masking', () => {
test('should return true without mask when scopes present', () => {
expect(
hasScope('workflow:read', {
global: ['user:list'],
project: ['workflow:read'],
resource: [],
}),
).toBe(true);
});

test('should return false without mask when scopes are not present', () => {
expect(
hasScope('workflow:update', {
global: ['user:list'],
project: ['workflow:read'],
resource: [],
}),
).toBe(false);
});

test('should return false when mask does not include scope but scopes list does contain required scope', () => {
expect(
hasScope(
'workflow:update',
{
global: ['user:list'],
project: ['workflow:read', 'workflow:update'],
resource: [],
},
{
sharing: ['workflow:read'],
},
),
).toBe(false);
});

test('should return true when mask does include scope and scope list includes scope', () => {
expect(
hasScope(
'workflow:update',
{
global: ['user:list'],
project: ['workflow:read', 'workflow:update'],
resource: [],
},
{
sharing: ['workflow:read', 'workflow:update'],
},
),
).toBe(true);
});

test('should return true when mask does include scope and scopes list includes scope on multiple levels', () => {
expect(
hasScope(
'workflow:update',
{
global: ['user:list'],
project: ['workflow:read', 'workflow:update'],
resource: ['workflow:update'],
},
{
sharing: ['workflow:read', 'workflow:update'],
},
),
).toBe(true);
});

test('should not mask out global scopes', () => {
expect(
hasScope(
'workflow:update',
{
global: ['workflow:read', 'workflow:update'],
project: ['workflow:read'],
resource: ['workflow:read'],
},
{
sharing: ['workflow:read'],
},
),
).toBe(true);
});

test('should return false when scope is not in mask or scope list', () => {
expect(
hasScope(
'workflow:update',
{
global: ['workflow:read'],
project: ['workflow:read'],
resource: ['workflow:read'],
},
{
sharing: ['workflow:read'],
},
),
).toBe(false);
});

test('should return false when scope is in mask or not scope list', () => {
expect(
hasScope(
'workflow:update',
{
global: ['workflow:read'],
project: ['workflow:read'],
resource: ['workflow:read'],
},
{
sharing: ['workflow:read', 'workflow:update'],
},
),
).toBe(false);
});
});
1 change: 1 addition & 0 deletions packages/cli/src/databases/entities/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export class User extends WithTimestamps implements IUser {
{
global: this.globalScopes,
},
undefined,
scopeOptions,
);
}
Expand Down
4 changes: 4 additions & 0 deletions packages/editor-ui/src/stores/__tests__/rbac.store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ describe('RBAC store', () => {
resource: [],
},
undefined,
undefined,
);
});

Expand All @@ -105,6 +106,7 @@ describe('RBAC store', () => {
resource: [],
},
undefined,
undefined,
);
});

Expand All @@ -123,6 +125,7 @@ describe('RBAC store', () => {
resource: expect.arrayContaining([newScope]),
},
undefined,
undefined,
);
});

Expand All @@ -146,6 +149,7 @@ describe('RBAC store', () => {
resource: expect.arrayContaining([newScope]),
},
undefined,
undefined,
);
});
});
Expand Down
1 change: 1 addition & 0 deletions packages/editor-ui/src/stores/rbac.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => {
? scopesByResourceId.value[context.resourceType][context.resourceId]
: [],
},
undefined,
options,
);
}
Expand Down