diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index da2e138abd..6fabab9a47 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -18,7 +18,6 @@ export { passwordSchema } from './schemas/password.schema'; export type { ProjectType, ProjectIcon, - ProjectRole, ProjectRelation, } from './schemas/project.schema'; diff --git a/packages/@n8n/api-types/src/schemas/__tests__/project.schema.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/project.schema.test.ts index 9a1cf47414..7702c49f02 100644 --- a/packages/@n8n/api-types/src/schemas/__tests__/project.schema.test.ts +++ b/packages/@n8n/api-types/src/schemas/__tests__/project.schema.test.ts @@ -2,7 +2,6 @@ import { projectNameSchema, projectTypeSchema, projectIconSchema, - projectRoleSchema, projectRelationSchema, } from '../project.schema'; @@ -57,19 +56,6 @@ describe('project.schema', () => { }); }); - describe('projectRoleSchema', () => { - test.each([ - { name: 'valid role: project:personalOwner', value: 'project:personalOwner', expected: true }, - { name: 'valid role: project:admin', value: 'project:admin', expected: true }, - { name: 'valid role: project:editor', value: 'project:editor', expected: true }, - { name: 'valid role: project:viewer', value: 'project:viewer', expected: true }, - { name: 'invalid role', value: 'invalid-role', expected: false }, - ])('should validate $name', ({ value, expected }) => { - const result = projectRoleSchema.safeParse(value); - expect(result.success).toBe(expected); - }); - }); - describe('projectRelationSchema', () => { test.each([ { diff --git a/packages/@n8n/api-types/src/schemas/project.schema.ts b/packages/@n8n/api-types/src/schemas/project.schema.ts index 11c6cc2b37..6eb7cb5c8f 100644 --- a/packages/@n8n/api-types/src/schemas/project.schema.ts +++ b/packages/@n8n/api-types/src/schemas/project.schema.ts @@ -1,3 +1,4 @@ +import { projectRoleSchema } from '@n8n/permissions'; import { z } from 'zod'; export const projectNameSchema = z.string().min(1).max(255); @@ -11,14 +12,6 @@ export const projectIconSchema = z.object({ }); export type ProjectIcon = z.infer; -export const projectRoleSchema = z.enum([ - 'project:personalOwner', // personalOwner is only used for personal projects - 'project:admin', - 'project:editor', - 'project:viewer', -]); -export type ProjectRole = z.infer; - export const projectRelationSchema = z.object({ userId: z.string(), role: projectRoleSchema, diff --git a/packages/@n8n/api-types/tsconfig.build.json b/packages/@n8n/api-types/tsconfig.build.json index ad06174279..ee0e3e20fd 100644 --- a/packages/@n8n/api-types/tsconfig.build.json +++ b/packages/@n8n/api-types/tsconfig.build.json @@ -7,5 +7,5 @@ "tsBuildInfoFile": "dist/build.tsbuildinfo" }, "include": ["src/**/*.ts"], - "exclude": ["test/**", "src/**/__tests__/**"] + "exclude": ["src/**/__tests__/**"] } diff --git a/packages/@n8n/api-types/tsconfig.json b/packages/@n8n/api-types/tsconfig.json index 94d5721691..6550c8a03f 100644 --- a/packages/@n8n/api-types/tsconfig.json +++ b/packages/@n8n/api-types/tsconfig.json @@ -6,5 +6,10 @@ "baseUrl": "src", "tsBuildInfoFile": "dist/typecheck.tsbuildinfo" }, - "include": ["src/**/*.ts", "test/**/*.ts"] + "include": ["src/**/*.ts"], + "references": [ + { "path": "../../workflow/tsconfig.build.json" }, + { "path": "../config/tsconfig.build.json" }, + { "path": "../permissions/tsconfig.build.json" } + ] } diff --git a/packages/@n8n/db/src/entities/project-relation.ts b/packages/@n8n/db/src/entities/project-relation.ts index 4d345d417f..c3b5c6de79 100644 --- a/packages/@n8n/db/src/entities/project-relation.ts +++ b/packages/@n8n/db/src/entities/project-relation.ts @@ -1,3 +1,4 @@ +import { ProjectRole } from '@n8n/permissions'; import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm'; import { WithTimestamps } from './abstract-entity'; @@ -7,7 +8,7 @@ import { User } from './user'; @Entity() export class ProjectRelation extends WithTimestamps { @Column({ type: 'varchar' }) - role: 'project:personalOwner' | 'project:admin' | 'project:editor' | 'project:viewer'; + role: ProjectRole; @ManyToOne('User', 'projectRelations') user: User; diff --git a/packages/@n8n/db/src/entities/shared-credentials.ts b/packages/@n8n/db/src/entities/shared-credentials.ts index c121c1a022..2c708f2eb0 100644 --- a/packages/@n8n/db/src/entities/shared-credentials.ts +++ b/packages/@n8n/db/src/entities/shared-credentials.ts @@ -1,13 +1,13 @@ +import { CredentialSharingRole } from '@n8n/permissions'; import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm'; import { WithTimestamps } from './abstract-entity'; import { CredentialsEntity } from './credentials-entity'; import { Project } from './project'; -import { CredentialSharingRole } from './types-db'; @Entity() export class SharedCredentials extends WithTimestamps { - @Column() + @Column({ type: 'varchar' }) role: CredentialSharingRole; @ManyToOne('CredentialsEntity', 'shared') diff --git a/packages/@n8n/db/src/entities/shared-workflow.ts b/packages/@n8n/db/src/entities/shared-workflow.ts index 43d42e57f0..147454f60e 100644 --- a/packages/@n8n/db/src/entities/shared-workflow.ts +++ b/packages/@n8n/db/src/entities/shared-workflow.ts @@ -1,13 +1,13 @@ +import { WorkflowSharingRole } from '@n8n/permissions'; import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm'; import { WithTimestamps } from './abstract-entity'; import { Project } from './project'; -import { WorkflowSharingRole } from './types-db'; import { WorkflowEntity } from './workflow-entity'; @Entity() export class SharedWorkflow extends WithTimestamps { - @Column() + @Column({ type: 'varchar' }) role: WorkflowSharingRole; @ManyToOne('WorkflowEntity', 'shared') diff --git a/packages/@n8n/db/src/entities/types-db.ts b/packages/@n8n/db/src/entities/types-db.ts index b792773a0b..1007757ef9 100644 --- a/packages/@n8n/db/src/entities/types-db.ts +++ b/packages/@n8n/db/src/entities/types-db.ts @@ -269,10 +269,6 @@ export const enum StatisticsNames { dataLoaded = 'data_loaded', } -export type CredentialSharingRole = 'credential:owner' | 'credential:user'; - -export type WorkflowSharingRole = 'workflow:owner' | 'workflow:editor'; - export type AuthProviderType = 'ldap' | 'email' | 'saml'; // | 'google'; export type FolderWithWorkflowAndSubFolderCount = Folder & { diff --git a/packages/@n8n/db/src/entities/user.ts b/packages/@n8n/db/src/entities/user.ts index 729dd32ce4..8d026a8fff 100644 --- a/packages/@n8n/db/src/entities/user.ts +++ b/packages/@n8n/db/src/entities/user.ts @@ -1,5 +1,5 @@ -import { hasScope, type ScopeOptions, type Scope, GlobalRole } from '@n8n/permissions'; -import { GLOBAL_OWNER_SCOPES, GLOBAL_MEMBER_SCOPES, GLOBAL_ADMIN_SCOPES } from '@n8n/permissions'; +import type { AuthPrincipal } from '@n8n/permissions'; +import { GlobalRole } from '@n8n/permissions'; import { AfterLoad, AfterUpdate, @@ -25,14 +25,8 @@ import { lowerCaser, objectRetriever } from '../utils/transformers'; import { NoUrl } from '../utils/validators/no-url.validator'; import { NoXss } from '../utils/validators/no-xss.validator'; -const STATIC_SCOPE_MAP: Record = { - 'global:owner': GLOBAL_OWNER_SCOPES, - 'global:member': GLOBAL_MEMBER_SCOPES, - 'global:admin': GLOBAL_ADMIN_SCOPES, -}; - @Entity() -export class User extends WithTimestamps implements IUser { +export class User extends WithTimestamps implements IUser, AuthPrincipal { @PrimaryGeneratedColumn('uuid') id: string; @@ -113,31 +107,6 @@ export class User extends WithTimestamps implements IUser { this.isPending = this.password === null && this.role !== 'global:owner'; } - /** - * Whether the user is instance owner - */ - isOwner: boolean; - - @AfterLoad() - computeIsOwner(): void { - this.isOwner = this.role === 'global:owner'; - } - - get globalScopes() { - return STATIC_SCOPE_MAP[this.role] ?? []; - } - - hasGlobalScope(scope: Scope | Scope[], scopeOptions?: ScopeOptions): boolean { - return hasScope( - scope, - { - global: this.globalScopes, - }, - undefined, - scopeOptions, - ); - } - toJSON() { const { password, ...rest } = this; return rest; diff --git a/packages/@n8n/db/tsconfig.json b/packages/@n8n/db/tsconfig.json index 118668f557..aa454c3785 100644 --- a/packages/@n8n/db/tsconfig.json +++ b/packages/@n8n/db/tsconfig.json @@ -11,5 +11,12 @@ // remove all options below this line "strictPropertyInitialization": false }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts"], + "references": [ + { "path": "../../core/tsconfig.build.json" }, + { "path": "../../workflow/tsconfig.build.json" }, + { "path": "../config/tsconfig.build.json" }, + { "path": "../di/tsconfig.build.json" }, + { "path": "../permissions/tsconfig.build.json" } + ] } diff --git a/packages/@n8n/decorators/tsconfig.json b/packages/@n8n/decorators/tsconfig.json index eca44d32aa..76880cdc9a 100644 --- a/packages/@n8n/decorators/tsconfig.json +++ b/packages/@n8n/decorators/tsconfig.json @@ -8,5 +8,11 @@ "experimentalDecorators": true, "emitDecoratorMetadata": true }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts"], + "references": [ + { "path": "../../workflow/tsconfig.build.json" }, + { "path": "../constants/tsconfig.build.json" }, + { "path": "../di/tsconfig.build.json" }, + { "path": "../permissions/tsconfig.build.json" } + ] } diff --git a/packages/@n8n/permissions/package.json b/packages/@n8n/permissions/package.json index 699ab9c472..a6d11549de 100644 --- a/packages/@n8n/permissions/package.json +++ b/packages/@n8n/permissions/package.json @@ -20,6 +20,9 @@ "files": [ "dist/**/*" ], + "dependencies": { + "zod": "catalog:" + }, "devDependencies": { "@n8n/typescript-config": "workspace:*" } diff --git a/packages/@n8n/permissions/src/__tests__/schemas.test.ts b/packages/@n8n/permissions/src/__tests__/schemas.test.ts new file mode 100644 index 0000000000..1bfb097ec8 --- /dev/null +++ b/packages/@n8n/permissions/src/__tests__/schemas.test.ts @@ -0,0 +1,93 @@ +import { + roleNamespaceSchema, + globalRoleSchema, + assignableGlobalRoleSchema, + projectRoleSchema, + credentialSharingRoleSchema, + workflowSharingRoleSchema, +} from '../schemas.ee'; + +describe('roleNamespaceSchema', () => { + test.each([ + { name: 'valid namespace: global', value: 'global', expected: true }, + { name: 'valid namespace: project', value: 'project', expected: true }, + { name: 'valid namespace: credential', value: 'credential', expected: true }, + { name: 'valid namespace: workflow', value: 'workflow', expected: true }, + { name: 'invalid namespace', value: 'invalid-namespace', expected: false }, + { name: 'numeric value', value: 123, expected: false }, + { name: 'null value', value: null, expected: false }, + ])('should validate $name', ({ value, expected }) => { + const result = roleNamespaceSchema.safeParse(value); + expect(result.success).toBe(expected); + }); +}); + +describe('globalRoleSchema', () => { + test.each([ + { name: 'valid role: global:owner', value: 'global:owner', expected: true }, + { name: 'valid role: global:admin', value: 'global:admin', expected: true }, + { name: 'valid role: global:member', value: 'global:member', expected: true }, + { name: 'invalid role', value: 'global:invalid', expected: false }, + { name: 'invalid prefix', value: 'invalid:admin', expected: false }, + { name: 'empty string', value: '', expected: false }, + { name: 'undefined value', value: undefined, expected: false }, + ])('should validate $name', ({ value, expected }) => { + const result = globalRoleSchema.safeParse(value); + expect(result.success).toBe(expected); + }); +}); + +describe('assignableGlobalRoleSchema', () => { + test.each([ + { name: 'excluded role: global:owner', value: 'global:owner', expected: false }, + { name: 'valid role: global:admin', value: 'global:admin', expected: true }, + { name: 'valid role: global:member', value: 'global:member', expected: true }, + { name: 'invalid role', value: 'global:invalid', expected: false }, + { name: 'invalid prefix', value: 'invalid:admin', expected: false }, + { name: 'object value', value: {}, expected: false }, + ])('should validate $name', ({ value, expected }) => { + const result = assignableGlobalRoleSchema.safeParse(value); + expect(result.success).toBe(expected); + }); +}); + +describe('projectRoleSchema', () => { + test.each([ + { name: 'valid role: project:personalOwner', value: 'project:personalOwner', expected: true }, + { name: 'valid role: project:admin', value: 'project:admin', expected: true }, + { name: 'valid role: project:editor', value: 'project:editor', expected: true }, + { name: 'valid role: project:viewer', value: 'project:viewer', expected: true }, + { name: 'invalid role', value: 'invalid-role', expected: false }, + ])('should validate $name', ({ value, expected }) => { + const result = projectRoleSchema.safeParse(value); + expect(result.success).toBe(expected); + }); +}); + +describe('credentialSharingRoleSchema', () => { + test.each([ + { name: 'valid role: credential:owner', value: 'credential:owner', expected: true }, + { name: 'valid role: credential:user', value: 'credential:user', expected: true }, + { name: 'invalid role', value: 'credential:admin', expected: false }, + { name: 'invalid prefix', value: 'cred:owner', expected: false }, + { name: 'boolean value', value: true, expected: false }, + { name: 'array value', value: ['credential:owner'], expected: false }, + ])('should validate $name', ({ value, expected }) => { + const result = credentialSharingRoleSchema.safeParse(value); + expect(result.success).toBe(expected); + }); +}); + +describe('workflowSharingRoleSchema', () => { + test.each([ + { name: 'valid role: workflow:owner', value: 'workflow:owner', expected: true }, + { name: 'valid role: workflow:editor', value: 'workflow:editor', expected: true }, + { name: 'invalid role', value: 'workflow:viewer', expected: false }, + { name: 'invalid prefix', value: 'work:owner', expected: false }, + { name: 'undefined value', value: undefined, expected: false }, + { name: 'empty string', value: '', expected: false }, + ])('should validate $name', ({ value, expected }) => { + const result = workflowSharingRoleSchema.safeParse(value); + expect(result.success).toBe(expected); + }); +}); diff --git a/packages/@n8n/permissions/src/__tests__/types.test.ts b/packages/@n8n/permissions/src/__tests__/types.test.ts new file mode 100644 index 0000000000..a849f4e5f8 --- /dev/null +++ b/packages/@n8n/permissions/src/__tests__/types.test.ts @@ -0,0 +1,115 @@ +import type { ApiKeyScope, Scope } from '@/types.ee'; + +// These are a type-level tests, +// that will be catch issues in the `typecheck` step instead of in an actual test run +describe('ApiKeyScope', () => { + test('Valid scopes', () => { + const validScopes: ApiKeyScope[] = [ + 'credential:create', + 'credential:delete', + 'credential:move', + 'execution:delete', + 'execution:get', + 'execution:list', + 'execution:read', + 'project:create', + 'project:delete', + 'project:list', + 'project:update', + 'securityAudit:generate', + 'sourceControl:pull', + 'tag:create', + 'tag:delete', + 'tag:list', + 'tag:read', + 'tag:update', + 'user:changeRole', + 'user:create', + 'user:delete', + 'user:list', + 'user:read', + 'variable:create', + 'variable:delete', + 'variable:list', + 'workflow:activate', + 'workflow:create', + 'workflow:deactivate', + 'workflow:delete', + 'workflow:list', + 'workflow:move', + 'workflow:read', + 'workflow:update', + 'workflowTags:list', + 'workflowTags:update', + ]; + // Useless assertion to avoid disabling noUnusedLocals + expect(validScopes).toBeDefined(); + }); + + test('Invalid scopes', () => { + const invalidScopes: ApiKeyScope[] = [ + // @ts-expect-error - Operations does not exist for workflows + 'workflows:invalid', + // @ts-expect-error - Operations does not exist for credentials + 'credentials:invalid', + // @ts-expect-error - Cross-resource mismatches + 'workflow:pull', + ]; + // Useless assertion to avoid disabling noUnusedLocals + expect(invalidScopes).toBeDefined(); + }); +}); + +// These are a type-level tests, +// that will be catch issues in the `typecheck` step instead of in an actual test run +describe('Scope', () => { + test('Valid scopes', () => { + // non-exhaustive list + const validScopes: Scope[] = [ + 'credential:create', + 'credential:delete', + 'credential:move', + 'ldap:sync', + 'project:create', + 'project:delete', + 'project:list', + 'project:update', + 'securityAudit:generate', + 'sourceControl:pull', + 'tag:create', + 'tag:delete', + 'tag:list', + 'tag:read', + 'tag:update', + 'user:changeRole', + 'user:create', + 'user:delete', + 'user:list', + 'user:read', + 'variable:create', + 'variable:delete', + 'variable:list', + 'workflow:create', + 'workflow:delete', + 'workflow:list', + 'workflow:move', + 'workflow:read', + 'workflow:update', + ]; + // Useless assertion to avoid disabling noUnusedLocals + expect(validScopes).toBeDefined(); + }); + + test('Invalid scopes', () => { + const invalidScopes: Scope[] = [ + // @ts-expect-error - Operations does not exist for workflows + 'workflows:invalid', + // @ts-expect-error - Operations does not exist for credentials + 'credentials:invalid', + // @ts-expect-error - Cross-resource mismatches + 'workflow:resetPassword', + ]; + // Useless assertion to avoid disabling noUnusedLocals + expect(invalidScopes).toBeDefined(); + }); +}); diff --git a/packages/@n8n/permissions/src/combineScopes.ee.ts b/packages/@n8n/permissions/src/combineScopes.ee.ts deleted file mode 100644 index 96a43b940c..0000000000 --- a/packages/@n8n/permissions/src/combineScopes.ee.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { Scope, ScopeLevels, GlobalScopes, MaskLevels } from './types.ee'; - -export function combineScopes(userScopes: GlobalScopes, masks?: MaskLevels): Set; -export function combineScopes(userScopes: ScopeLevels, masks?: MaskLevels): Set; -export function combineScopes( - userScopes: GlobalScopes | ScopeLevels, - masks?: MaskLevels, -): Set { - 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)); - } - } - - return new Set(Object.values(maskedScopes).flat()); -} diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index 71ddfb2bb7..a7ce4dc335 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -1,4 +1,5 @@ export const DEFAULT_OPERATIONS = ['create', 'read', 'update', 'delete', 'list'] as const; + export const RESOURCES = { annotationTag: [...DEFAULT_OPERATIONS] as const, auditLogs: ['manage'] as const, diff --git a/packages/@n8n/permissions/src/hasScope.ee.ts b/packages/@n8n/permissions/src/hasScope.ee.ts deleted file mode 100644 index 81bcbc5175..0000000000 --- a/packages/@n8n/permissions/src/hasScope.ee.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { combineScopes } from './combineScopes.ee'; -import type { Scope, ScopeLevels, GlobalScopes, ScopeOptions, MaskLevels } from './types.ee'; - -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 = combineScopes(userScopes, masks); - - if (options.mode === 'allOf') { - return !!scope.length && scope.every((s) => userScopeSet.has(s)); - } - - return scope.some((s) => userScopeSet.has(s)); -} diff --git a/packages/@n8n/permissions/src/index.ts b/packages/@n8n/permissions/src/index.ts index 0373844c41..6b74cfcfe0 100644 --- a/packages/@n8n/permissions/src/index.ts +++ b/packages/@n8n/permissions/src/index.ts @@ -1,7 +1,15 @@ export type * from './types.ee'; export * from './constants.ee'; -export * from './hasScope.ee'; -export * from './combineScopes.ee'; -export * from './global-roles.ee'; -export * from './project-roles.ee'; -export * from './resource-roles.ee'; + +export * from './roles/scopes/global-scopes.ee'; +export * from './roles/role-maps.ee'; +export * from './roles/all-roles'; + +export { projectRoleSchema } from './schemas.ee'; + +export { hasScope } from './utilities/hasScope.ee'; +export { hasGlobalScope } from './utilities/hasGlobalScope.ee'; +export { combineScopes } from './utilities/combineScopes.ee'; +export { rolesWithScope } from './utilities/rolesWithScope.ee'; +export { getGlobalScopes } from './utilities/getGlobalScopes.ee'; +export { getRoleScopes } from './utilities/getRoleScopes.ee'; diff --git a/packages/@n8n/permissions/src/roles/all-roles.ts b/packages/@n8n/permissions/src/roles/all-roles.ts new file mode 100644 index 0000000000..411c962df2 --- /dev/null +++ b/packages/@n8n/permissions/src/roles/all-roles.ts @@ -0,0 +1,37 @@ +import { + CREDENTIALS_SHARING_SCOPE_MAP, + GLOBAL_SCOPE_MAP, + PROJECT_SCOPE_MAP, + WORKFLOW_SHARING_SCOPE_MAP, +} from './role-maps.ee'; +import type { AllRolesMap, AllRoleTypes, Scope } from '../types.ee'; +import { getRoleScopes } from '../utilities/getRoleScopes.ee'; + +const ROLE_NAMES: Record = { + 'global:owner': 'Owner', + 'global:admin': 'Admin', + 'global:member': 'Member', + 'project:personalOwner': 'Project Owner', + 'project:admin': 'Project Admin', + 'project:editor': 'Project Editor', + 'project:viewer': 'Project Viewer', + 'credential:user': 'Credential User', + 'credential:owner': 'Credential Owner', + 'workflow:owner': 'Workflow Owner', + 'workflow:editor': 'Workflow Editor', +}; + +const mapToRoleObject = (roles: Record) => + (Object.keys(roles) as T[]).map((role) => ({ + role, + name: ROLE_NAMES[role], + scopes: getRoleScopes(role), + licensed: false, + })); + +export const ALL_ROLES: AllRolesMap = { + global: mapToRoleObject(GLOBAL_SCOPE_MAP), + project: mapToRoleObject(PROJECT_SCOPE_MAP), + credential: mapToRoleObject(CREDENTIALS_SHARING_SCOPE_MAP), + workflow: mapToRoleObject(WORKFLOW_SHARING_SCOPE_MAP), +}; diff --git a/packages/@n8n/permissions/src/roles/role-maps.ee.ts b/packages/@n8n/permissions/src/roles/role-maps.ee.ts new file mode 100644 index 0000000000..5315b91a3a --- /dev/null +++ b/packages/@n8n/permissions/src/roles/role-maps.ee.ts @@ -0,0 +1,56 @@ +import { + CREDENTIALS_SHARING_OWNER_SCOPES, + CREDENTIALS_SHARING_USER_SCOPES, +} from './scopes/credential-sharing-scopes.ee'; +import { + GLOBAL_OWNER_SCOPES, + GLOBAL_ADMIN_SCOPES, + GLOBAL_MEMBER_SCOPES, +} from './scopes/global-scopes.ee'; +import { + REGULAR_PROJECT_ADMIN_SCOPES, + PERSONAL_PROJECT_OWNER_SCOPES, + PROJECT_EDITOR_SCOPES, + PROJECT_VIEWER_SCOPES, +} from './scopes/project-scopes.ee'; +import { + WORKFLOW_SHARING_OWNER_SCOPES, + WORKFLOW_SHARING_EDITOR_SCOPES, +} from './scopes/workflow-sharing-scopes.ee'; +import type { + CredentialSharingRole, + GlobalRole, + ProjectRole, + Scope, + WorkflowSharingRole, +} from '../types.ee'; + +export const GLOBAL_SCOPE_MAP: Record = { + 'global:owner': GLOBAL_OWNER_SCOPES, + 'global:admin': GLOBAL_ADMIN_SCOPES, + 'global:member': GLOBAL_MEMBER_SCOPES, +}; + +export const PROJECT_SCOPE_MAP: Record = { + 'project:admin': REGULAR_PROJECT_ADMIN_SCOPES, + 'project:personalOwner': PERSONAL_PROJECT_OWNER_SCOPES, + 'project:editor': PROJECT_EDITOR_SCOPES, + 'project:viewer': PROJECT_VIEWER_SCOPES, +}; + +export const CREDENTIALS_SHARING_SCOPE_MAP: Record = { + 'credential:owner': CREDENTIALS_SHARING_OWNER_SCOPES, + 'credential:user': CREDENTIALS_SHARING_USER_SCOPES, +}; + +export const WORKFLOW_SHARING_SCOPE_MAP: Record = { + 'workflow:owner': WORKFLOW_SHARING_OWNER_SCOPES, + 'workflow:editor': WORKFLOW_SHARING_EDITOR_SCOPES, +}; + +export const ALL_ROLE_MAPS = { + global: GLOBAL_SCOPE_MAP, + project: PROJECT_SCOPE_MAP, + credential: CREDENTIALS_SHARING_SCOPE_MAP, + workflow: WORKFLOW_SHARING_SCOPE_MAP, +} as const; diff --git a/packages/@n8n/permissions/src/roles/scopes/credential-sharing-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/credential-sharing-scopes.ee.ts new file mode 100644 index 0000000000..eecc718e96 --- /dev/null +++ b/packages/@n8n/permissions/src/roles/scopes/credential-sharing-scopes.ee.ts @@ -0,0 +1,11 @@ +import type { Scope } from '../../types.ee'; + +export const CREDENTIALS_SHARING_OWNER_SCOPES: Scope[] = [ + 'credential:read', + 'credential:update', + 'credential:delete', + 'credential:share', + 'credential:move', +]; + +export const CREDENTIALS_SHARING_USER_SCOPES: Scope[] = ['credential:read']; diff --git a/packages/@n8n/permissions/src/global-roles.ee.ts b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts similarity index 97% rename from packages/@n8n/permissions/src/global-roles.ee.ts rename to packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts index 73f6b8a6f3..f6619886f4 100644 --- a/packages/@n8n/permissions/src/global-roles.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts @@ -1,4 +1,4 @@ -import type { Scope } from './types.ee'; +import type { Scope } from '../../types.ee'; export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'annotationTag:create', diff --git a/packages/@n8n/permissions/src/project-roles.ee.ts b/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts similarity index 97% rename from packages/@n8n/permissions/src/project-roles.ee.ts rename to packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts index cd6b4fbef4..91d3ed4904 100644 --- a/packages/@n8n/permissions/src/project-roles.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/project-scopes.ee.ts @@ -1,4 +1,4 @@ -import type { Scope } from './types.ee'; +import type { Scope } from '../../types.ee'; /** * Diff between admin in personal project and admin in other projects: diff --git a/packages/@n8n/permissions/src/resource-roles.ee.ts b/packages/@n8n/permissions/src/roles/scopes/workflow-sharing-scopes.ee.ts similarity index 50% rename from packages/@n8n/permissions/src/resource-roles.ee.ts rename to packages/@n8n/permissions/src/roles/scopes/workflow-sharing-scopes.ee.ts index 36aa44f086..1487846b87 100644 --- a/packages/@n8n/permissions/src/resource-roles.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/workflow-sharing-scopes.ee.ts @@ -1,14 +1,4 @@ -import type { Scope } from './types.ee'; - -export const CREDENTIALS_SHARING_OWNER_SCOPES: Scope[] = [ - 'credential:read', - 'credential:update', - 'credential:delete', - 'credential:share', - 'credential:move', -]; - -export const CREDENTIALS_SHARING_USER_SCOPES: Scope[] = ['credential:read']; +import type { Scope } from '../../types.ee'; export const WORKFLOW_SHARING_OWNER_SCOPES: Scope[] = [ 'workflow:read', diff --git a/packages/@n8n/permissions/src/schemas.ee.ts b/packages/@n8n/permissions/src/schemas.ee.ts new file mode 100644 index 0000000000..2a17457c27 --- /dev/null +++ b/packages/@n8n/permissions/src/schemas.ee.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +export const roleNamespaceSchema = z.enum(['global', 'project', 'credential', 'workflow']); + +export const globalRoleSchema = z.enum(['global:owner', 'global:admin', 'global:member']); + +export const assignableGlobalRoleSchema = globalRoleSchema.exclude([ + 'global:owner', // Owner cannot be changed +]); + +export const projectRoleSchema = z.enum([ + 'project:personalOwner', // personalOwner is only used for personal projects + 'project:admin', + 'project:editor', + 'project:viewer', +]); + +export const credentialSharingRoleSchema = z.enum(['credential:owner', 'credential:user']); + +export const workflowSharingRoleSchema = z.enum(['workflow:owner', 'workflow:editor']); diff --git a/packages/@n8n/permissions/src/types.ee.ts b/packages/@n8n/permissions/src/types.ee.ts index fa79d84801..e08699f074 100644 --- a/packages/@n8n/permissions/src/types.ee.ts +++ b/packages/@n8n/permissions/src/types.ee.ts @@ -1,13 +1,26 @@ -import type { RESOURCES, API_KEY_RESOURCES } from './constants.ee'; +import type { z } from 'zod'; +import type { RESOURCES, API_KEY_RESOURCES } from './constants.ee'; +import type { + assignableGlobalRoleSchema, + credentialSharingRoleSchema, + globalRoleSchema, + projectRoleSchema, + roleNamespaceSchema, + workflowSharingRoleSchema, +} from './schemas.ee'; + +/** Represents a resource that can have permissions applied to it */ export type Resource = keyof typeof RESOURCES; -export type ResourceScope< +/** A permission scope for a specific resource + operation combination */ +type ResourceScope< R extends Resource, Operation extends (typeof RESOURCES)[R][number] = (typeof RESOURCES)[R][number], > = `${R}:${Operation}`; -export type WildcardScope = `${Resource}:*` | '*'; +/** A wildcard scope applies to all operations on a resource or all resources */ +type WildcardScope = `${Resource}:*` | '*'; // This is purely an intermediary type. // If we tried to do use `ResourceScope` directly we'd end @@ -16,26 +29,57 @@ type AllScopesObject = { [R in Resource]: ResourceScope; }; -export type Scope = AllScopesObject[Resource]; +/** A permission scope in the system, either a specific resource:operation or a wildcard */ +export type Scope = AllScopesObject[Resource] | WildcardScope; -export type ScopeLevel = 'global' | 'project' | 'resource'; -export type GetScopeLevel = Record; -export type GlobalScopes = GetScopeLevel<'global'>; -export type ProjectScopes = GetScopeLevel<'project'>; -export type ResourceScopes = GetScopeLevel<'resource'>; -export type ScopeLevels = GlobalScopes & (ProjectScopes | (ProjectScopes & ResourceScopes)); +export type ScopeLevels = { + global: Scope[]; + project?: Scope[]; + resource?: Scope[]; +}; -export type MaskLevel = 'sharing'; -export type GetMaskLevel = Record; -export type SharingMasks = GetMaskLevel<'sharing'>; -export type MaskLevels = SharingMasks; +export type MaskLevels = { + sharing: Scope[]; +}; -export type ScopeMode = 'oneOf' | 'allOf'; -export type ScopeOptions = { mode: ScopeMode }; +export type ScopeOptions = { mode: 'oneOf' | 'allOf' }; -export type PublicApiKeyResources = keyof typeof API_KEY_RESOURCES; +export type RoleNamespace = z.infer; +export type GlobalRole = z.infer; +export type AssignableGlobalRole = z.infer; +export type CredentialSharingRole = z.infer; +export type WorkflowSharingRole = z.infer; +export type ProjectRole = z.infer; -export type ApiKeyResourceScope< +/** Union of all possible role types in the system */ +export type AllRoleTypes = GlobalRole | ProjectRole | WorkflowSharingRole | CredentialSharingRole; + +type RoleObject = { + role: T; + name: string; + scopes: Scope[]; + licensed: boolean; +}; + +export type AllRolesMap = { + global: Array>; + project: Array>; + credential: Array>; + workflow: Array>; +}; + +/** + * Represents an authenticated entity in the system that can have specific permissions via a role. + * @property role - The global role this principal has + */ +export type AuthPrincipal = { + role: GlobalRole; +}; + +// #region Public API +type PublicApiKeyResources = keyof typeof API_KEY_RESOURCES; + +type ApiKeyResourceScope< R extends PublicApiKeyResources, Operation extends (typeof API_KEY_RESOURCES)[R][number] = (typeof API_KEY_RESOURCES)[R][number], > = `${R}:${Operation}`; @@ -49,5 +93,4 @@ type AllApiKeyScopesObject = { export type ApiKeyScope = AllApiKeyScopesObject[PublicApiKeyResources]; -export type GlobalRole = 'global:owner' | 'global:admin' | 'global:member'; -export type AssignableRole = Exclude; +// #endregion diff --git a/packages/@n8n/permissions/src/utilities/__tests__/combineScopes.test.ts b/packages/@n8n/permissions/src/utilities/__tests__/combineScopes.test.ts new file mode 100644 index 0000000000..86f6619d67 --- /dev/null +++ b/packages/@n8n/permissions/src/utilities/__tests__/combineScopes.test.ts @@ -0,0 +1,65 @@ +import type { Scope, ScopeLevels, MaskLevels } from '../../types.ee'; +import { combineScopes } from '../combineScopes.ee'; + +describe('combineScopes', () => { + describe('basic scope combining', () => { + test.each([ + ['single level', { global: ['workflow:read'] }, 1], + [ + 'multiple levels', + { + global: ['user:list'], + project: ['workflow:read'], + }, + 2, + ], + [ + 'duplicates', + { + global: ['workflow:read'], + project: ['workflow:read'], + }, + 1, + ], + ] satisfies Array<[string, ScopeLevels, number]>)('%s', (_, input, expectedSize) => { + expect(combineScopes(input).size).toBe(expectedSize); + }); + }); + + describe('masking behavior', () => { + test.each([ + [ + 'filters project scopes', + { project: ['workflow:read', 'workflow:update'], global: [] }, + { sharing: ['workflow:read'] }, + ['workflow:read'], + ], + [ + 'filters resource scopes', + { resource: ['credential:read', 'credential:update'], global: [] }, + { sharing: ['credential:read'] }, + ['credential:read'], + ], + [ + 'ignores global scopes', + { global: ['user:list'], project: ['workflow:read'] }, + { sharing: [] }, + ['user:list'], + ], + ['handles undefined masks', { global: ['user:list'] }, undefined, ['user:list']], + [ + 'handles empty resource scopes', + { resource: [], global: ['user:list'] }, + { sharing: ['credential:read'] }, + ['user:list'], + ], + ] satisfies Array<[string, ScopeLevels, MaskLevels | undefined, Scope[]]>)( + '%s', + (_, scopes, masks, expected) => { + const result = combineScopes(scopes, masks); + expect(result.size).toBe(expected.length); + expected.forEach((scope) => expect(result.has(scope)).toBe(true)); + }, + ); + }); +}); diff --git a/packages/@n8n/permissions/src/utilities/__tests__/getGlobalScopes.test.ts b/packages/@n8n/permissions/src/utilities/__tests__/getGlobalScopes.test.ts new file mode 100644 index 0000000000..f30ca068f7 --- /dev/null +++ b/packages/@n8n/permissions/src/utilities/__tests__/getGlobalScopes.test.ts @@ -0,0 +1,20 @@ +import { GLOBAL_SCOPE_MAP } from '../../roles/role-maps.ee'; +import type { GlobalRole } from '../../types.ee'; +import { getGlobalScopes } from '../getGlobalScopes.ee'; + +describe('getGlobalScopes', () => { + test.each(['global:owner', 'global:admin', 'global:member'] as const)( + 'should return correct scopes for %s', + (role) => { + const scopes = getGlobalScopes({ role }); + + expect(scopes).toEqual(GLOBAL_SCOPE_MAP[role]); + }, + ); + + test('should return empty array for non-existent role', () => { + const scopes = getGlobalScopes({ role: 'non:existent' as GlobalRole }); + + expect(scopes).toEqual([]); + }); +}); diff --git a/packages/@n8n/permissions/src/utilities/__tests__/getRoleScopes.test.ts b/packages/@n8n/permissions/src/utilities/__tests__/getRoleScopes.test.ts new file mode 100644 index 0000000000..363c214b55 --- /dev/null +++ b/packages/@n8n/permissions/src/utilities/__tests__/getRoleScopes.test.ts @@ -0,0 +1,36 @@ +import type { AllRoleTypes, Resource } from '../../types.ee'; +import { getRoleScopes, COMBINED_ROLE_MAP } from '../getRoleScopes.ee'; + +describe('getRoleScopes', () => { + describe('role scope retrieval', () => { + test.each(['global:owner', 'global:admin', 'project:admin'] satisfies AllRoleTypes[])( + 'should return scopes for %s', + (role) => { + const scopes = getRoleScopes(role); + expect(scopes).toEqual(COMBINED_ROLE_MAP[role]); + }, + ); + }); + + describe('resource filtering', () => { + test.each(['workflow', 'credential', 'user'] satisfies Resource[])( + 'should filter %s scopes', + (resource) => { + const filtered = getRoleScopes('global:owner', [resource]); + expect(filtered.every((s) => s.startsWith(`${resource}:`))).toBe(true); + }, + ); + + test('should handle multiple filters', () => { + const filtered = getRoleScopes('global:owner', ['workflow', 'credential']); + expect(filtered.some((s) => s.startsWith('workflow:'))).toBe(true); + expect(filtered.some((s) => s.startsWith('credential:'))).toBe(true); + expect(filtered.every((s) => !s.startsWith('tag:'))).toBe(true); + expect(filtered.every((s) => !s.startsWith('user:'))).toBe(true); + }); + + test('should return empty array for no matches', () => { + expect(getRoleScopes('global:member', ['nonexistent' as Resource])).toEqual([]); + }); + }); +}); diff --git a/packages/@n8n/permissions/src/utilities/__tests__/hasGlobalScope.test.ts b/packages/@n8n/permissions/src/utilities/__tests__/hasGlobalScope.test.ts new file mode 100644 index 0000000000..4f09e7734f --- /dev/null +++ b/packages/@n8n/permissions/src/utilities/__tests__/hasGlobalScope.test.ts @@ -0,0 +1,50 @@ +import type { GlobalRole, Scope } from '../../types.ee'; +import { hasGlobalScope } from '../hasGlobalScope.ee'; + +describe('hasGlobalScope', () => { + describe('single scope checks', () => { + test.each([ + { role: 'global:owner', scope: 'workflow:create', expected: true }, + { role: 'global:admin', scope: 'user:delete', expected: true }, + { role: 'global:member', scope: 'workflow:read', expected: false }, + { role: 'non:existent', scope: 'workflow:read', expected: false }, + ] as Array<{ role: GlobalRole; scope: Scope; expected: boolean }>)( + '$role with $scope -> $expected', + ({ role, scope, expected }) => { + expect(hasGlobalScope({ role }, scope)).toBe(expected); + }, + ); + }); + + describe('multiple scopes', () => { + test('oneOf mode (default)', () => { + expect( + hasGlobalScope({ role: 'global:member' }, [ + 'tag:create', + 'user:list', + // a member cannot create users + 'user:create', + ]), + ).toBe(true); + }); + + test('allOf mode', () => { + expect( + hasGlobalScope( + { role: 'global:member' }, + [ + 'tag:create', + 'user:list', + // a member cannot create users + 'user:create', + ], + { mode: 'allOf' }, + ), + ).toBe(false); + }); + }); + + test('edge cases', () => { + expect(hasGlobalScope({ role: 'global:owner' }, [])).toBe(false); + }); +}); diff --git a/packages/@n8n/permissions/src/utilities/__tests__/hasScope.test.ts b/packages/@n8n/permissions/src/utilities/__tests__/hasScope.test.ts new file mode 100644 index 0000000000..383da85904 --- /dev/null +++ b/packages/@n8n/permissions/src/utilities/__tests__/hasScope.test.ts @@ -0,0 +1,47 @@ +import type { Scope, ScopeLevels } from '../../types.ee'; +import { hasScope } from '../hasScope.ee'; + +describe('hasScope', () => { + const userScopes: ScopeLevels = { + global: ['user:list'], + project: ['workflow:read', 'workflow:update'], + resource: ['credential:read'], + }; + + describe('scope checking', () => { + test.each([ + ['workflow:read', true], + ['workflow:delete', false], + ['user:list', true], + ] satisfies Array<[Scope, boolean]>)('%s -> %s', (scope, expected) => { + expect(hasScope(scope, userScopes)).toBe(expected); + }); + }); + + describe('masking behavior', () => { + test('filters non-global scopes', () => { + expect(hasScope('workflow:read', userScopes, { sharing: ['workflow:update'] })).toBe(false); + }); + + test('ignores global scopes', () => { + expect(hasScope('user:list', userScopes, { sharing: [] })).toBe(true); + }); + }); + + describe('checking modes', () => { + test('oneOf (default)', () => { + expect(hasScope(['workflow:read', 'invalid:scope'] as Scope[], userScopes)).toBe(true); + }); + + test('allOf', () => { + expect( + hasScope(['workflow:read', 'workflow:update'], userScopes, undefined, { mode: 'allOf' }), + ).toBe(true); + }); + + test('edge cases', () => { + expect(hasScope([], userScopes, undefined, { mode: 'allOf' })).toBe(false); + expect(hasScope([], userScopes, undefined, { mode: 'oneOf' })).toBe(false); + }); + }); +}); diff --git a/packages/@n8n/permissions/src/utilities/__tests__/rolesWithScope.test.ts b/packages/@n8n/permissions/src/utilities/__tests__/rolesWithScope.test.ts new file mode 100644 index 0000000000..4781306348 --- /dev/null +++ b/packages/@n8n/permissions/src/utilities/__tests__/rolesWithScope.test.ts @@ -0,0 +1,27 @@ +import type { GlobalRole, Scope } from '../../types.ee'; +import { rolesWithScope } from '../rolesWithScope.ee'; + +describe('rolesWithScope', () => { + describe('global roles', () => { + test.each([ + ['workflow:create', ['global:owner', 'global:admin']], + ['user:list', ['global:owner', 'global:admin', 'global:member']], + ['invalid:scope', []], + ] as Array<[Scope, GlobalRole[]]>)('%s -> %s', (scope, expected) => { + expect(rolesWithScope('global', scope)).toEqual(expected); + }); + }); + + describe('multiple scopes', () => { + test('returns roles with all scopes', () => { + expect( + rolesWithScope('global', [ + // all global roles have this scope + 'tag:create', + // only owner and admin have this scope + 'user:delete', + ]), + ).toEqual(['global:owner', 'global:admin']); + }); + }); +}); diff --git a/packages/@n8n/permissions/src/utilities/combineScopes.ee.ts b/packages/@n8n/permissions/src/utilities/combineScopes.ee.ts new file mode 100644 index 0000000000..4b46a9f27b --- /dev/null +++ b/packages/@n8n/permissions/src/utilities/combineScopes.ee.ts @@ -0,0 +1,31 @@ +import type { Scope, ScopeLevels, MaskLevels } from '../types.ee'; + +/** + * Combines scopes from different levels into a deduplicated set. + * + * @param userScopes - Scopes organized by level (global, project, resource) + * @param masks - Optional filters for non-global scopes + * @returns Set containing all allowed scopes + * + * @example + * combineScopes({ + * global: ['user:list'], + * project: ['workflow:read'], + * }, { sharing: ['workflow:read'] }); + */ +export function combineScopes(userScopes: ScopeLevels, masks?: MaskLevels): Set { + const maskedScopes: ScopeLevels = Object.fromEntries( + Object.entries(userScopes).map((e) => [e[0], [...e[1]]]), + ) as ScopeLevels; + + if (masks?.sharing) { + if (maskedScopes.project) { + maskedScopes.project = maskedScopes.project.filter((v) => masks.sharing.includes(v)); + } + if (maskedScopes.resource) { + maskedScopes.resource = maskedScopes.resource.filter((v) => masks.sharing.includes(v)); + } + } + + return new Set(Object.values(maskedScopes).flat()); +} diff --git a/packages/@n8n/permissions/src/utilities/getGlobalScopes.ee.ts b/packages/@n8n/permissions/src/utilities/getGlobalScopes.ee.ts new file mode 100644 index 0000000000..aaa443fe34 --- /dev/null +++ b/packages/@n8n/permissions/src/utilities/getGlobalScopes.ee.ts @@ -0,0 +1,9 @@ +import { GLOBAL_SCOPE_MAP } from '../roles/role-maps.ee'; +import type { AuthPrincipal } from '../types.ee'; + +/** + * Gets global scopes for a principal's role. + * @param principal - Contains the role to look up + * @returns Array of scopes for the role, or empty array if not found + */ +export const getGlobalScopes = (principal: AuthPrincipal) => GLOBAL_SCOPE_MAP[principal.role] ?? []; diff --git a/packages/@n8n/permissions/src/utilities/getRoleScopes.ee.ts b/packages/@n8n/permissions/src/utilities/getRoleScopes.ee.ts new file mode 100644 index 0000000000..384bfaa43e --- /dev/null +++ b/packages/@n8n/permissions/src/utilities/getRoleScopes.ee.ts @@ -0,0 +1,20 @@ +import { ALL_ROLE_MAPS } from '../roles/role-maps.ee'; +import type { AllRoleTypes, Resource, Scope } from '../types.ee'; + +export const COMBINED_ROLE_MAP = Object.fromEntries( + Object.values(ALL_ROLE_MAPS).flatMap((o: Record) => Object.entries(o)), +) as Record; + +/** + * Gets scopes for a role, optionally filtered by resource types. + * @param role - The role to look up + * @param filters - Optional resources to filter scopes by + * @returns Array of matching scopes + */ +export function getRoleScopes(role: AllRoleTypes, filters?: Resource[]): Scope[] { + let scopes = COMBINED_ROLE_MAP[role]; + if (filters) { + scopes = scopes.filter((s) => filters.includes(s.split(':')[0] as Resource)); + } + return scopes; +} diff --git a/packages/@n8n/permissions/src/utilities/hasGlobalScope.ee.ts b/packages/@n8n/permissions/src/utilities/hasGlobalScope.ee.ts new file mode 100644 index 0000000000..e91b9b1e31 --- /dev/null +++ b/packages/@n8n/permissions/src/utilities/hasGlobalScope.ee.ts @@ -0,0 +1,17 @@ +import { getGlobalScopes } from './getGlobalScopes.ee'; +import { hasScope } from './hasScope.ee'; +import type { AuthPrincipal, Scope, ScopeOptions } from '../types.ee'; + +/** + * Checks if an auth-principal has specified global scope(s). + * @param principal - The authentication principal to check permissions for + * @param scope - Scope(s) to verify + */ +export const hasGlobalScope = ( + principal: AuthPrincipal, + scope: Scope | Scope[], + scopeOptions?: ScopeOptions, +): boolean => { + const global = getGlobalScopes(principal); + return hasScope(scope, { global }, undefined, scopeOptions); +}; diff --git a/packages/@n8n/permissions/src/utilities/hasScope.ee.ts b/packages/@n8n/permissions/src/utilities/hasScope.ee.ts new file mode 100644 index 0000000000..c688d96a8f --- /dev/null +++ b/packages/@n8n/permissions/src/utilities/hasScope.ee.ts @@ -0,0 +1,22 @@ +import { combineScopes } from './combineScopes.ee'; +import type { Scope, ScopeLevels, ScopeOptions, MaskLevels } from '../types.ee'; + +/** + * Checks if scopes exist in user's permissions. + * @param scope - Scope(s) to check + * @param userScopes - User's permission levels + * @param masks - Optional scope filters + * @param options - Checking mode (default: oneOf) + */ +export const hasScope = ( + scope: Scope | Scope[], + userScopes: ScopeLevels, + masks?: MaskLevels, + options: ScopeOptions = { mode: 'oneOf' }, +): boolean => { + if (!Array.isArray(scope)) scope = [scope]; + const userScopeSet = combineScopes(userScopes, masks); + return options.mode === 'allOf' + ? !!scope.length && scope.every((s) => userScopeSet.has(s)) + : scope.some((s) => userScopeSet.has(s)); +}; diff --git a/packages/@n8n/permissions/src/utilities/rolesWithScope.ee.ts b/packages/@n8n/permissions/src/utilities/rolesWithScope.ee.ts new file mode 100644 index 0000000000..1022d10adb --- /dev/null +++ b/packages/@n8n/permissions/src/utilities/rolesWithScope.ee.ts @@ -0,0 +1,37 @@ +import { ALL_ROLE_MAPS } from '../roles/role-maps.ee'; +import type { + CredentialSharingRole, + GlobalRole, + ProjectRole, + RoleNamespace, + Scope, + WorkflowSharingRole, +} from '../types.ee'; + +/** + * Retrieves roles within a specific namespace that have all the given scopes. + * @param namespace - The role namespace to search in + * @param scopes - Scope(s) to filter by + */ +export function rolesWithScope(namespace: 'global', scopes: Scope | Scope[]): GlobalRole[]; +export function rolesWithScope(namespace: 'project', scopes: Scope | Scope[]): ProjectRole[]; +export function rolesWithScope( + namespace: 'credential', + scopes: Scope | Scope[], +): CredentialSharingRole[]; +export function rolesWithScope( + namespace: 'workflow', + scopes: Scope | Scope[], +): WorkflowSharingRole[]; +export function rolesWithScope(namespace: RoleNamespace, scopes: Scope | Scope[]) { + if (!Array.isArray(scopes)) { + scopes = [scopes]; + } + + return Object.keys(ALL_ROLE_MAPS[namespace]).filter((k) => { + return scopes.every((s) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + ((ALL_ROLE_MAPS[namespace] as any)[k] as Scope[]).includes(s), + ); + }); +} diff --git a/packages/@n8n/permissions/test/hasScope.test.ts b/packages/@n8n/permissions/test/hasScope.test.ts deleted file mode 100644 index b3362e4ea6..0000000000 --- a/packages/@n8n/permissions/test/hasScope.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { hasScope } from '@/hasScope.ee'; -import type { Scope } from '@/types.ee'; - -const ownerPermissions: Scope[] = [ - 'workflow:create', - 'workflow:read', - 'workflow:update', - 'workflow:delete', - 'workflow:list', - 'user:create', - 'user:read', - 'user:update', - 'user:delete', - 'user:list', - 'credential:create', - 'credential:read', - 'credential:update', - 'credential:delete', - 'credential:list', - 'variable:create', - 'variable:read', - 'variable:update', - 'variable:delete', - 'variable:list', -]; -const memberPermissions: Scope[] = ['user:list', 'variable:list', 'variable:read']; - -describe('hasScope', () => { - test('should work with a single permission on both modes with only global scopes', () => { - expect( - hasScope( - 'user:list', - { - global: memberPermissions, - }, - undefined, - { mode: 'oneOf' }, - ), - ).toBe(true); - - expect( - hasScope( - 'user:list', - { - global: memberPermissions, - }, - undefined, - { mode: 'allOf' }, - ), - ).toBe(true); - - expect( - hasScope( - 'workflow:read', - { - global: memberPermissions, - }, - undefined, - { mode: 'oneOf' }, - ), - ).toBe(false); - - expect( - hasScope( - 'workflow:read', - { - global: memberPermissions, - }, - undefined, - { mode: 'allOf' }, - ), - ).toBe(false); - }); - - test('should work with oneOf mode', () => { - expect( - hasScope(['workflow:create', 'workflow:read'], { - global: ownerPermissions, - }), - ).toBe(true); - - expect( - hasScope(['workflow:create', 'workflow:read'], { - global: memberPermissions, - }), - ).toBe(false); - - expect( - hasScope([], { - global: memberPermissions, - }), - ).toBe(false); - }); - - test('should work with allOf mode', () => { - expect( - hasScope( - ['workflow:create', 'workflow:read'], - { - global: ownerPermissions, - }, - undefined, - { mode: 'allOf' }, - ), - ).toBe(true); - - expect( - hasScope( - ['workflow:create', 'workflow:read'], - { - global: memberPermissions, - }, - undefined, - { mode: 'allOf' }, - ), - ).toBe(false); - - expect( - hasScope( - ['workflow:create', 'user:list'], - { - global: memberPermissions, - }, - undefined, - { mode: 'allOf' }, - ), - ).toBe(false); - - expect( - 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); - }); -}); diff --git a/packages/@n8n/permissions/tsconfig.build.json b/packages/@n8n/permissions/tsconfig.build.json index 0794319028..ee0e3e20fd 100644 --- a/packages/@n8n/permissions/tsconfig.build.json +++ b/packages/@n8n/permissions/tsconfig.build.json @@ -7,5 +7,5 @@ "tsBuildInfoFile": "dist/build.tsbuildinfo" }, "include": ["src/**/*.ts"], - "exclude": ["test/**"] + "exclude": ["src/**/__tests__/**"] } diff --git a/packages/@n8n/permissions/tsconfig.json b/packages/@n8n/permissions/tsconfig.json index e669070d9d..7dc230c6fb 100644 --- a/packages/@n8n/permissions/tsconfig.json +++ b/packages/@n8n/permissions/tsconfig.json @@ -9,5 +9,5 @@ }, "tsBuildInfoFile": "dist/typecheck.tsbuildinfo" }, - "include": ["src/**/*.ts", "test/**/*.ts"] + "include": ["src/**/*.ts"] } diff --git a/packages/cli/src/auth/auth.service.ts b/packages/cli/src/auth/auth.service.ts index e53c4ee0b1..a8ebe27d04 100644 --- a/packages/cli/src/auth/auth.service.ts +++ b/packages/cli/src/auth/auth.service.ts @@ -113,7 +113,7 @@ export class AuthService { const isWithinUsersLimit = this.license.isWithinUsersLimit(); if ( config.getEnv('userManagement.isInstanceOwnerSetUp') && - !user.isOwner && + user.role !== 'global:owner' && !isWithinUsersLimit ) { throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); diff --git a/packages/cli/src/controllers/password-reset.controller.ts b/packages/cli/src/controllers/password-reset.controller.ts index 3a874464ac..cf8622128e 100644 --- a/packages/cli/src/controllers/password-reset.controller.ts +++ b/packages/cli/src/controllers/password-reset.controller.ts @@ -4,6 +4,7 @@ import { ResolvePasswordTokenQueryDto, } from '@n8n/api-types'; import { Body, Get, Post, Query, RestController } from '@n8n/decorators'; +import { hasGlobalScope } from '@n8n/permissions'; import { Response } from 'express'; import { Logger } from 'n8n-core'; @@ -62,18 +63,23 @@ export class PasswordResetController { // User should just be able to reset password if one is already present const user = await this.userRepository.findNonShellUser(email); + if (!user) { + this.logger.debug('No user found in the system'); + return; + } - if (!user?.isOwner && !this.license.isWithinUsersLimit()) { + if (user.role !== 'global:owner' && !this.license.isWithinUsersLimit()) { this.logger.debug( 'Request to send password reset email failed because the user limit was reached', ); throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); } + if ( isSamlCurrentAuthenticationMethod() && !( - user?.hasGlobalScope('user:resetPassword') === true || - user?.settings?.allowSSOManualLogin === true + user && + (hasGlobalScope(user, 'user:resetPassword') || user.settings?.allowSSOManualLogin === true) ) ) { this.logger.debug( @@ -84,8 +90,8 @@ export class PasswordResetController { ); } - const ldapIdentity = user?.authIdentities?.find((i) => i.providerType === 'ldap'); - if (!user?.password || (ldapIdentity && user.disabled)) { + const ldapIdentity = user.authIdentities?.find((i) => i.providerType === 'ldap'); + if (!user.password || (ldapIdentity && user.disabled)) { this.logger.debug( 'Request to send password reset email failed because no user was found for the provided email', { invalidEmail: email }, @@ -140,7 +146,7 @@ export class PasswordResetController { const user = await this.authService.resolvePasswordResetToken(token); if (!user) throw new NotFoundError(''); - if (!user?.isOwner && !this.license.isWithinUsersLimit()) { + if (user.role !== 'global:owner' && !this.license.isWithinUsersLimit()) { this.logger.debug( 'Request to resolve password token failed because the user limit was reached', { userId: user.id }, diff --git a/packages/cli/src/controllers/project.controller.ts b/packages/cli/src/controllers/project.controller.ts index e2676f1433..9703fa84db 100644 --- a/packages/cli/src/controllers/project.controller.ts +++ b/packages/cli/src/controllers/project.controller.ts @@ -13,7 +13,7 @@ import { Param, Query, } from '@n8n/decorators'; -import { combineScopes } from '@n8n/permissions'; +import { combineScopes, getRoleScopes, hasGlobalScope } from '@n8n/permissions'; import type { Scope } from '@n8n/permissions'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In, Not } from '@n8n/typeorm'; @@ -30,13 +30,11 @@ import { TeamProjectOverQuotaError, UnlicensedProjectRoleError, } from '@/services/project.service.ee'; -import { RoleService } from '@/services/role.service'; @RestController('/projects') export class ProjectController { constructor( private readonly projectsService: ProjectService, - private readonly roleService: RoleService, private readonly projectRepository: ProjectRepository, private readonly eventService: EventService, ) {} @@ -69,8 +67,8 @@ export class ProjectController { role: 'project:admin', scopes: [ ...combineScopes({ - global: this.roleService.getRoleScopes(req.user.role), - project: this.roleService.getRoleScopes('project:admin'), + global: getRoleScopes(req.user.role), + project: getRoleScopes('project:admin'), }), ], }; @@ -88,7 +86,7 @@ export class ProjectController { _res: Response, ): Promise { const relations = await this.projectsService.getProjectRelationsForUser(req.user); - const otherTeamProject = req.user.hasGlobalScope('project:read') + const otherTeamProject = hasGlobalScope(req.user, 'project:read') ? await this.projectRepository.findBy({ type: 'team', id: Not(In(relations.map((pr) => pr.projectId))), @@ -106,8 +104,8 @@ export class ProjectController { if (result.scopes) { result.scopes.push( ...combineScopes({ - global: this.roleService.getRoleScopes(req.user.role), - project: this.roleService.getRoleScopes(pr.role), + global: getRoleScopes(req.user.role), + project: getRoleScopes(pr.role), }), ); } @@ -128,9 +126,7 @@ export class ProjectController { ); if (result.scopes) { - result.scopes.push( - ...combineScopes({ global: this.roleService.getRoleScopes(req.user.role) }), - ); + result.scopes.push(...combineScopes({ global: getRoleScopes(req.user.role) })); } results.push(result); @@ -154,8 +150,8 @@ export class ProjectController { } const scopes: Scope[] = [ ...combineScopes({ - global: this.roleService.getRoleScopes(req.user.role), - project: this.roleService.getRoleScopes('project:personalOwner'), + global: getRoleScopes(req.user.role), + project: getRoleScopes('project:personalOwner'), }), ]; return { @@ -191,8 +187,8 @@ export class ProjectController { })), scopes: [ ...combineScopes({ - global: this.roleService.getRoleScopes(req.user.role), - ...(myRelation ? { project: this.roleService.getRoleScopes(myRelation.role) } : {}), + global: getRoleScopes(req.user.role), + ...(myRelation ? { project: getRoleScopes(myRelation.role) } : {}), }), ], }; diff --git a/packages/cli/src/controllers/role.controller.ts b/packages/cli/src/controllers/role.controller.ts index 3343e8e3ce..99c1321f93 100644 --- a/packages/cli/src/controllers/role.controller.ts +++ b/packages/cli/src/controllers/role.controller.ts @@ -1,23 +1,13 @@ import { Get, RestController } from '@n8n/decorators'; -import { type AllRoleTypes, RoleService } from '@/services/role.service'; +import { RoleService } from '@/services/role.service'; @RestController('/roles') export class RoleController { constructor(private readonly roleService: RoleService) {} @Get('/') - async getAllRoles() { - return Object.fromEntries( - Object.entries(this.roleService.getRoles()).map((e) => [ - e[0], - (e[1] as AllRoleTypes[]).map((r) => ({ - name: this.roleService.getRoleName(r), - role: r, - scopes: this.roleService.getRoleScopes(r), - licensed: this.roleService.isRoleLicensed(r), - })), - ]), - ); + getAllRoles() { + return this.roleService.getAllRoles(); } } diff --git a/packages/cli/src/credentials/credentials-finder.service.ts b/packages/cli/src/credentials/credentials-finder.service.ts index 19e1d49882..bfb7ed0a7f 100644 --- a/packages/cli/src/credentials/credentials-finder.service.ts +++ b/packages/cli/src/credentials/credentials-finder.service.ts @@ -1,21 +1,19 @@ -import type { ProjectRole } from '@n8n/api-types'; -import type { CredentialsEntity, SharedCredentials, CredentialSharingRole, User } from '@n8n/db'; +import type { CredentialsEntity, SharedCredentials, User } from '@n8n/db'; import { CredentialsRepository } from '@n8n/db'; import { Service } from '@n8n/di'; -import type { Scope } from '@n8n/permissions'; +import { hasGlobalScope, rolesWithScope } from '@n8n/permissions'; +import type { CredentialSharingRole, ProjectRole, Scope } from '@n8n/permissions'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In } from '@n8n/typeorm'; import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; -import { RoleService } from '@/services/role.service'; @Service() export class CredentialsFinderService { constructor( private readonly sharedCredentialsRepository: SharedCredentialsRepository, - private readonly roleService: RoleService, private readonly credentialsRepository: CredentialsRepository, ) {} @@ -29,9 +27,9 @@ export class CredentialsFinderService { async findCredentialsForUser(user: User, scopes: Scope[]) { let where: FindOptionsWhere = {}; - if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) { - const projectRoles = this.roleService.rolesWithScope('project', scopes); - const credentialRoles = this.roleService.rolesWithScope('credential', scopes); + if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) { + const projectRoles = rolesWithScope('project', scopes); + const credentialRoles = rolesWithScope('credential', scopes); where = { ...where, shared: { @@ -53,9 +51,9 @@ export class CredentialsFinderService { async findCredentialForUser(credentialsId: string, user: User, scopes: Scope[]) { let where: FindOptionsWhere = { credentialsId }; - if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) { - const projectRoles = this.roleService.rolesWithScope('project', scopes); - const credentialRoles = this.roleService.rolesWithScope('credential', scopes); + if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) { + const projectRoles = rolesWithScope('project', scopes); + const credentialRoles = rolesWithScope('credential', scopes); where = { ...where, role: In(credentialRoles), @@ -85,9 +83,9 @@ export class CredentialsFinderService { async findAllCredentialsForUser(user: User, scopes: Scope[], trx?: EntityManager) { let where: FindOptionsWhere = {}; - if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) { - const projectRoles = this.roleService.rolesWithScope('project', scopes); - const credentialRoles = this.roleService.rolesWithScope('credential', scopes); + if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) { + const projectRoles = rolesWithScope('project', scopes); + const credentialRoles = rolesWithScope('credential', scopes); where = { role: In(credentialRoles), project: { @@ -115,13 +113,9 @@ export class CredentialsFinderService { trx?: EntityManager, ) { const projectRoles = - 'scopes' in options - ? this.roleService.rolesWithScope('project', options.scopes) - : options.projectRoles; + 'scopes' in options ? rolesWithScope('project', options.scopes) : options.projectRoles; const credentialRoles = - 'scopes' in options - ? this.roleService.rolesWithScope('credential', options.scopes) - : options.credentialRoles; + 'scopes' in options ? rolesWithScope('credential', options.scopes) : options.credentialRoles; const sharings = await this.sharedCredentialsRepository.findCredentialsByRoles( userIds, diff --git a/packages/cli/src/credentials/credentials.service.ee.ts b/packages/cli/src/credentials/credentials.service.ee.ts index 81700be805..da03852a9c 100644 --- a/packages/cli/src/credentials/credentials.service.ee.ts +++ b/packages/cli/src/credentials/credentials.service.ee.ts @@ -1,6 +1,7 @@ import { Project, SharedCredentials } from '@n8n/db'; import type { CredentialsEntity, User } from '@n8n/db'; import { Service } from '@n8n/di'; +import { hasGlobalScope, rolesWithScope } from '@n8n/permissions'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In, type EntityManager } from '@n8n/typeorm'; import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; @@ -10,7 +11,6 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { TransferCredentialError } from '@/errors/response-errors/transfer-credential.error'; import { OwnershipService } from '@/services/ownership.service'; import { ProjectService } from '@/services/project.service.ee'; -import { RoleService } from '@/services/role.service'; import { CredentialsFinderService } from './credentials-finder.service'; import { CredentialsService } from './credentials.service'; @@ -22,7 +22,6 @@ export class EnterpriseCredentialsService { private readonly ownershipService: OwnershipService, private readonly credentialsService: CredentialsService, private readonly projectService: ProjectService, - private readonly roleService: RoleService, private readonly credentialsFinderService: CredentialsFinderService, ) {} @@ -41,12 +40,12 @@ export class EnterpriseCredentialsService { type: 'team', // if user can see all projects, don't check project access // if they can't, find projects they can list - ...(user.hasGlobalScope('project:list') + ...(hasGlobalScope(user, 'project:list') ? {} : { projectRelations: { userId: user.id, - role: In(this.roleService.rolesWithScope('project', 'project:list')), + role: In(rolesWithScope('project', 'project:list')), }, }), }, diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index b716aed302..9310fbb44f 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -2,7 +2,7 @@ import type { CreateCredentialDto } from '@n8n/api-types'; import type { Project, User, ICredentialsDb, ScopesField } from '@n8n/db'; import { CredentialsEntity, SharedCredentials, CredentialsRepository } from '@n8n/db'; import { Service } from '@n8n/di'; -import type { Scope } from '@n8n/permissions'; +import { hasGlobalScope, type Scope } from '@n8n/permissions'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In, @@ -79,7 +79,7 @@ export class CredentialsService { onlySharedWithMe?: boolean; } = {}, ) { - const returnAll = user.hasGlobalScope('credential:list'); + const returnAll = hasGlobalScope(user, 'credential:list'); const isDefaultSelect = !listQueryOptions.select; const projectId = typeof listQueryOptions.filter?.projectId === 'string' @@ -255,7 +255,7 @@ export class CredentialsService { // If the workflow is owned by a personal project and the owner of the // project has global read permissions it can use all personal credentials. const user = await this.userRepository.findPersonalOwnerForWorkflow(workflowId); - if (user?.hasGlobalScope('credential:read')) { + if (user && hasGlobalScope(user, 'credential:read')) { return await this.credentialsRepository.findAllPersonalCredentials(); } @@ -269,7 +269,7 @@ export class CredentialsService { // read permissions then all workflows in that project can use all // credentials of all personal projects. const user = await this.userRepository.findPersonalOwnerForProject(projectId); - if (user?.hasGlobalScope('credential:read')) { + if (user && hasGlobalScope(user, 'credential:read')) { return await this.credentialsRepository.findAllPersonalCredentials(); } @@ -289,7 +289,7 @@ export class CredentialsService { ): Promise { let where: FindOptionsWhere = { credentialsId: credentialId }; - if (!user.hasGlobalScope(globalScopes, { mode: 'allOf' })) { + if (!hasGlobalScope(user, globalScopes, { mode: 'allOf' })) { where = { ...where, role: 'credential:owner', diff --git a/packages/cli/src/databases/migrations/common/1714133768519-CreateProject.ts b/packages/cli/src/databases/migrations/common/1714133768519-CreateProject.ts index 1f4f9c5eb0..b9a9f1a581 100644 --- a/packages/cli/src/databases/migrations/common/1714133768519-CreateProject.ts +++ b/packages/cli/src/databases/migrations/common/1714133768519-CreateProject.ts @@ -1,6 +1,6 @@ -import type { ProjectRole } from '@n8n/api-types'; import { generateNanoId } from '@n8n/db'; import type { User } from '@n8n/db'; +import type { ProjectRole } from '@n8n/permissions'; import { UserError } from 'n8n-workflow'; import { nanoid } from 'nanoid'; diff --git a/packages/cli/src/databases/repositories/project-relation.repository.ts b/packages/cli/src/databases/repositories/project-relation.repository.ts index b8825b29f3..2da4fa5565 100644 --- a/packages/cli/src/databases/repositories/project-relation.repository.ts +++ b/packages/cli/src/databases/repositories/project-relation.repository.ts @@ -1,6 +1,6 @@ -import type { ProjectRole } from '@n8n/api-types'; import { ProjectRelation } from '@n8n/db'; import { Service } from '@n8n/di'; +import type { ProjectRole } from '@n8n/permissions'; import { DataSource, In, Repository } from '@n8n/typeorm'; @Service() diff --git a/packages/cli/src/databases/repositories/shared-credentials.repository.ts b/packages/cli/src/databases/repositories/shared-credentials.repository.ts index 9a21807ba7..cce894ce7e 100644 --- a/packages/cli/src/databases/repositories/shared-credentials.repository.ts +++ b/packages/cli/src/databases/repositories/shared-credentials.repository.ts @@ -1,7 +1,7 @@ -import type { ProjectRole } from '@n8n/api-types'; import { SharedCredentials } from '@n8n/db'; -import type { Project, CredentialSharingRole } from '@n8n/db'; +import type { Project } from '@n8n/db'; import { Service } from '@n8n/di'; +import type { CredentialSharingRole, ProjectRole } from '@n8n/permissions'; import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm'; import { DataSource, In, Not, Repository } from '@n8n/typeorm'; diff --git a/packages/cli/src/databases/repositories/shared-workflow.repository.ts b/packages/cli/src/databases/repositories/shared-workflow.repository.ts index 0ad91fc204..1416e2dc7c 100644 --- a/packages/cli/src/databases/repositories/shared-workflow.repository.ts +++ b/packages/cli/src/databases/repositories/shared-workflow.repository.ts @@ -1,6 +1,7 @@ import { SharedWorkflow } from '@n8n/db'; -import type { Project, WorkflowSharingRole } from '@n8n/db'; +import type { Project } from '@n8n/db'; import { Service } from '@n8n/di'; +import type { WorkflowSharingRole } from '@n8n/permissions'; import { DataSource, Repository, In, Not } from '@n8n/typeorm'; import type { EntityManager, FindManyOptions, FindOptionsWhere } from '@n8n/typeorm'; diff --git a/packages/cli/src/executions/pre-execution-checks/__tests__/credentials-permission-checker.test.ts b/packages/cli/src/executions/pre-execution-checks/__tests__/credentials-permission-checker.test.ts index 5f6a5aece0..b9201f36b8 100644 --- a/packages/cli/src/executions/pre-execution-checks/__tests__/credentials-permission-checker.test.ts +++ b/packages/cli/src/executions/pre-execution-checks/__tests__/credentials-permission-checker.test.ts @@ -95,9 +95,7 @@ describe('CredentialsPermissionChecker', () => { }); it('should skip credential checks if the home project owner has global scope', async () => { - const projectOwner = mock({ - hasGlobalScope: (scope) => scope === 'credential:list', - }); + const projectOwner = mock({ role: 'global:owner' }); ownershipService.getPersonalProjectOwnerCached.mockResolvedValueOnce(projectOwner); await expect(permissionChecker.check(workflowId, [node])).resolves.not.toThrow(); diff --git a/packages/cli/src/executions/pre-execution-checks/credentials-permission-checker.ts b/packages/cli/src/executions/pre-execution-checks/credentials-permission-checker.ts index 9fd6d8da94..ac2fdc3b11 100644 --- a/packages/cli/src/executions/pre-execution-checks/credentials-permission-checker.ts +++ b/packages/cli/src/executions/pre-execution-checks/credentials-permission-checker.ts @@ -1,5 +1,6 @@ import type { Project } from '@n8n/db'; import { Service } from '@n8n/di'; +import { hasGlobalScope } from '@n8n/permissions'; import type { INode } from 'n8n-workflow'; import { UserError } from 'n8n-workflow'; @@ -45,7 +46,11 @@ export class CredentialsPermissionChecker { const homeProjectOwner = await this.ownershipService.getPersonalProjectOwnerCached( homeProject.id, ); - if (homeProject.type === 'personal' && homeProjectOwner?.hasGlobalScope('credential:list')) { + if ( + homeProject.type === 'personal' && + homeProjectOwner && + hasGlobalScope(homeProjectOwner, 'credential:list') + ) { // Workflow belongs to a project by a user with privileges // so all credentials are usable. Skip credential checks. return; diff --git a/packages/cli/src/interfaces.ts b/packages/cli/src/interfaces.ts index 30066ccc94..5ae61d5222 100644 --- a/packages/cli/src/interfaces.ts +++ b/packages/cli/src/interfaces.ts @@ -1,5 +1,5 @@ import type { ICredentialsBase, IExecutionBase, IExecutionDb, ITagBase } from '@n8n/db'; -import type { AssignableRole } from '@n8n/permissions'; +import type { AssignableGlobalRole } from '@n8n/permissions'; import type { Application } from 'express'; import type { ExecutionError, @@ -207,7 +207,7 @@ export interface ILicensePostResponse extends ILicenseReadResponse { export interface Invitation { email: string; - role: AssignableRole; + role: AssignableGlobalRole; } export interface N8nApp { diff --git a/packages/cli/src/permissions.ee/check-access.ts b/packages/cli/src/permissions.ee/check-access.ts index b01ebc6b0d..dfdd94aa51 100644 --- a/packages/cli/src/permissions.ee/check-access.ts +++ b/packages/cli/src/permissions.ee/check-access.ts @@ -1,6 +1,6 @@ import type { User } from '@n8n/db'; import { Container } from '@n8n/di'; -import type { Scope } from '@n8n/permissions'; +import { hasGlobalScope, rolesWithScope, type Scope } from '@n8n/permissions'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In } from '@n8n/typeorm'; import { UnexpectedError } from 'n8n-workflow'; @@ -8,7 +8,6 @@ import { UnexpectedError } from 'n8n-workflow'; import { ProjectRepository } from '@/databases/repositories/project.repository'; import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; -import { RoleService } from '@/services/role.service'; /** * Check if a user has the required scopes. The check can be: @@ -28,15 +27,14 @@ export async function userHasScopes( projectId, }: { credentialId?: string; workflowId?: string; projectId?: string } /* only one */, ): Promise { - if (user.hasGlobalScope(scopes, { mode: 'allOf' })) return true; + if (hasGlobalScope(user, scopes, { mode: 'allOf' })) return true; if (globalOnly) return false; // Find which project roles are defined to contain the required scopes. // Then find projects having this user and having those project roles. - const roleService = Container.get(RoleService); - const projectRoles = roleService.rolesWithScope('project', scopes); + const projectRoles = rolesWithScope('project', scopes); const userProjectIds = ( await Container.get(ProjectRepository).find({ where: { @@ -57,7 +55,7 @@ export async function userHasScopes( return await Container.get(SharedCredentialsRepository).existsBy({ credentialsId: credentialId, projectId: In(userProjectIds), - role: In(roleService.rolesWithScope('credential', scopes)), + role: In(rolesWithScope('credential', scopes)), }); } @@ -65,7 +63,7 @@ export async function userHasScopes( return await Container.get(SharedWorkflowRepository).existsBy({ workflowId, projectId: In(userProjectIds), - role: In(roleService.rolesWithScope('workflow', scopes)), + role: In(rolesWithScope('workflow', scopes)), }); } diff --git a/packages/cli/src/public-api/v1/handlers/workflows/workflows.service.ts b/packages/cli/src/public-api/v1/handlers/workflows/workflows.service.ts index ca6f7a0993..b0cd2eccb8 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/workflows.service.ts +++ b/packages/cli/src/public-api/v1/handlers/workflows/workflows.service.ts @@ -1,8 +1,8 @@ import { GlobalConfig } from '@n8n/config'; -import type { Project, WorkflowSharingRole, User } from '@n8n/db'; +import type { Project, User } from '@n8n/db'; import { WorkflowEntity, WorkflowTagMapping, SharedWorkflow } from '@n8n/db'; import { Container } from '@n8n/di'; -import type { Scope } from '@n8n/permissions'; +import type { Scope, WorkflowSharingRole } from '@n8n/permissions'; import type { WorkflowId } from 'n8n-workflow'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index e60809e20d..9fdee440e9 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -1,6 +1,6 @@ -import type { ProjectIcon, ProjectRole, ProjectType } from '@n8n/api-types'; +import type { ProjectIcon, ProjectType } from '@n8n/api-types'; import type { Variables, Project, User, ListQueryDb, WorkflowHistory } from '@n8n/db'; -import type { AssignableRole, GlobalRole, Scope } from '@n8n/permissions'; +import type { AssignableGlobalRole, GlobalRole, ProjectRole, Scope } from '@n8n/permissions'; import type express from 'express'; import type { ICredentialDataDecryptedObject, @@ -137,7 +137,7 @@ export declare namespace UserRequest { email: string; inviteAcceptUrl?: string; emailSent: boolean; - role: AssignableRole; + role: AssignableGlobalRole; }; error?: string; }; diff --git a/packages/cli/src/services/__tests__/active-workflows.service.test.ts b/packages/cli/src/services/__tests__/active-workflows.service.test.ts index 31d937cd73..6dbe069129 100644 --- a/packages/cli/src/services/__tests__/active-workflows.service.test.ts +++ b/packages/cli/src/services/__tests__/active-workflows.service.test.ts @@ -43,21 +43,19 @@ describe('ActiveWorkflowsService', () => { }); it('should return all workflow ids when user has full access', async () => { - user.hasGlobalScope.mockReturnValue(true); + user.role = 'global:admin'; const ids = await service.getAllActiveIdsFor(user); expect(ids).toEqual(['2', '3', '4']); - expect(user.hasGlobalScope).toHaveBeenCalledWith('workflow:list'); expect(sharedWorkflowRepository.getSharedWorkflowIds).not.toHaveBeenCalled(); }); it('should filter out workflow ids that the user does not have access to', async () => { - user.hasGlobalScope.mockReturnValue(false); + user.role = 'global:member'; sharedWorkflowRepository.getSharedWorkflowIds.mockResolvedValue(['3']); const ids = await service.getAllActiveIdsFor(user); expect(ids).toEqual(['3']); - expect(user.hasGlobalScope).toHaveBeenCalledWith('workflow:list'); expect(sharedWorkflowRepository.getSharedWorkflowIds).toHaveBeenCalledWith(activeIds); }); }); diff --git a/packages/cli/src/services/__tests__/credentials-finder.service.test.ts b/packages/cli/src/services/__tests__/credentials-finder.service.test.ts index 8468210746..e6f7aaca41 100644 --- a/packages/cli/src/services/__tests__/credentials-finder.service.test.ts +++ b/packages/cli/src/services/__tests__/credentials-finder.service.test.ts @@ -2,8 +2,6 @@ import { SharedCredentials } from '@n8n/db'; import type { CredentialsEntity } from '@n8n/db'; import type { User } from '@n8n/db'; import { Container } from '@n8n/di'; -import { hasScope } from '@n8n/permissions'; -import { GLOBAL_MEMBER_SCOPES, GLOBAL_OWNER_SCOPES } from '@n8n/permissions'; import { In } from '@n8n/typeorm'; import { mock } from 'jest-mock-extended'; @@ -19,19 +17,11 @@ describe('CredentialsFinderService', () => { const sharedCredential = mock(); sharedCredential.credentials = mock({ id: credentialsId }); const owner = mock({ - isOwner: true, - hasGlobalScope: (scope) => - hasScope(scope, { - global: GLOBAL_OWNER_SCOPES, - }), + role: 'global:owner', }); const member = mock({ - isOwner: false, + role: 'global:member', id: 'test', - hasGlobalScope: (scope) => - hasScope(scope, { - global: GLOBAL_MEMBER_SCOPES, - }), }); beforeEach(() => { diff --git a/packages/cli/src/services/active-workflows.service.ts b/packages/cli/src/services/active-workflows.service.ts index aacb0e2572..06f57e03fe 100644 --- a/packages/cli/src/services/active-workflows.service.ts +++ b/packages/cli/src/services/active-workflows.service.ts @@ -1,5 +1,6 @@ import type { User } from '@n8n/db'; import { Service } from '@n8n/di'; +import { hasGlobalScope } from '@n8n/permissions'; import { Logger } from 'n8n-core'; import { ActivationErrorsService } from '@/activation-errors.service'; @@ -28,7 +29,7 @@ export class ActiveWorkflowsService { const activationErrors = await this.activationErrorsService.getAll(); const activeWorkflowIds = await this.workflowRepository.getActiveIds(); - const hasFullAccess = user.hasGlobalScope('workflow:list'); + const hasFullAccess = hasGlobalScope(user, 'workflow:list'); if (hasFullAccess) { return activeWorkflowIds.filter((workflowId) => !activationErrors[workflowId]); } diff --git a/packages/cli/src/services/project.service.ee.ts b/packages/cli/src/services/project.service.ee.ts index 8d1d2cb465..20e07953aa 100644 --- a/packages/cli/src/services/project.service.ee.ts +++ b/packages/cli/src/services/project.service.ee.ts @@ -1,9 +1,9 @@ -import type { CreateProjectDto, ProjectRole, ProjectType, UpdateProjectDto } from '@n8n/api-types'; +import type { CreateProjectDto, ProjectType, UpdateProjectDto } from '@n8n/api-types'; import { UNLIMITED_LICENSE_QUOTA } from '@n8n/constants'; import type { User } from '@n8n/db'; import { Project, ProjectRelation } from '@n8n/db'; import { Container, Service } from '@n8n/di'; -import { type Scope } from '@n8n/permissions'; +import { hasGlobalScope, rolesWithScope, type Scope, type ProjectRole } from '@n8n/permissions'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import type { FindOptionsWhere, EntityManager } from '@n8n/typeorm'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import @@ -20,7 +20,6 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { License } from '@/license'; import { CacheService } from './cache/cache.service'; -import { RoleService } from './role.service'; export class TeamProjectOverQuotaError extends UserError { constructor(limit: number) { @@ -42,7 +41,6 @@ export class ProjectService { private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly projectRepository: ProjectRepository, private readonly projectRelationRepository: ProjectRelationRepository, - private readonly roleService: RoleService, private readonly sharedCredentialsRepository: SharedCredentialsRepository, private readonly cacheService: CacheService, private readonly license: License, @@ -168,7 +166,7 @@ export class ProjectService { async getAccessibleProjects(user: User): Promise { // This user is probably an admin, show them everything - if (user.hasGlobalScope('project:read')) { + if (hasGlobalScope(user, 'project:read')) { return await this.projectRepository.find(); } return await this.projectRepository.getAccessibleProjects(user.id); @@ -234,7 +232,7 @@ export class ProjectService { const existing = project.projectRelations.find((pr) => pr.userId === r.userId); // We don't throw an error if the user already exists with that role so // existing projects continue working as is. - if (existing?.role !== r.role && !this.roleService.isRoleLicensed(r.role)) { + if (existing?.role !== r.role && !this.isProjectRoleLicensed(r.role)) { throw new UnlicensedProjectRoleError(r.role); } } @@ -246,6 +244,19 @@ export class ProjectService { await this.clearCredentialCanUseExternalSecretsCache(projectId); } + private isProjectRoleLicensed(role: ProjectRole) { + switch (role) { + case 'project:admin': + return this.license.isProjectRoleAdminLicensed(); + case 'project:editor': + return this.license.isProjectRoleEditorLicensed(); + case 'project:viewer': + return this.license.isProjectRoleViewerLicensed(); + default: + return true; + } + } + async clearCredentialCanUseExternalSecretsCache(projectId: string) { const shares = await this.sharedCredentialsRepository.find({ where: { @@ -293,8 +304,8 @@ export class ProjectService { id: projectId, }; - if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) { - const projectRoles = this.roleService.rolesWithScope('project', scopes); + if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) { + const projectRoles = rolesWithScope('project', scopes); where = { ...where, diff --git a/packages/cli/src/services/role.service.ts b/packages/cli/src/services/role.service.ts index 5a31226689..b706c80c0d 100644 --- a/packages/cli/src/services/role.service.ts +++ b/packages/cli/src/services/role.service.ts @@ -1,159 +1,30 @@ -import type { ProjectRole } from '@n8n/api-types'; import type { CredentialsEntity, - CredentialSharingRole, SharedCredentials, SharedWorkflow, - WorkflowSharingRole, User, ListQueryDb, ScopesField, ProjectRelation, } from '@n8n/db'; import { Service } from '@n8n/di'; -import type { GlobalRole, Resource, Scope } from '@n8n/permissions'; -import { - combineScopes, - GLOBAL_ADMIN_SCOPES, - GLOBAL_MEMBER_SCOPES, - GLOBAL_OWNER_SCOPES, - PERSONAL_PROJECT_OWNER_SCOPES, - PROJECT_EDITOR_SCOPES, - PROJECT_VIEWER_SCOPES, - REGULAR_PROJECT_ADMIN_SCOPES, - CREDENTIALS_SHARING_OWNER_SCOPES, - CREDENTIALS_SHARING_USER_SCOPES, - WORKFLOW_SHARING_EDITOR_SCOPES, - WORKFLOW_SHARING_OWNER_SCOPES, -} from '@n8n/permissions'; +import type { AllRoleTypes, Scope } from '@n8n/permissions'; +import { ALL_ROLES, combineScopes, getRoleScopes } from '@n8n/permissions'; import { UnexpectedError } from 'n8n-workflow'; import { License } from '@/license'; -export type RoleNamespace = 'global' | 'project' | 'credential' | 'workflow'; - -const GLOBAL_SCOPE_MAP: Record = { - 'global:owner': GLOBAL_OWNER_SCOPES, - 'global:admin': GLOBAL_ADMIN_SCOPES, - 'global:member': GLOBAL_MEMBER_SCOPES, -}; - -const PROJECT_SCOPE_MAP: Record = { - 'project:admin': REGULAR_PROJECT_ADMIN_SCOPES, - 'project:personalOwner': PERSONAL_PROJECT_OWNER_SCOPES, - 'project:editor': PROJECT_EDITOR_SCOPES, - 'project:viewer': PROJECT_VIEWER_SCOPES, -}; - -const CREDENTIALS_SHARING_SCOPE_MAP: Record = { - 'credential:owner': CREDENTIALS_SHARING_OWNER_SCOPES, - 'credential:user': CREDENTIALS_SHARING_USER_SCOPES, -}; - -const WORKFLOW_SHARING_SCOPE_MAP: Record = { - 'workflow:owner': WORKFLOW_SHARING_OWNER_SCOPES, - 'workflow:editor': WORKFLOW_SHARING_EDITOR_SCOPES, -}; - -interface AllMaps { - global: Record; - project: Record; - credential: Record; - workflow: Record; -} - -const ALL_MAPS: AllMaps = { - global: GLOBAL_SCOPE_MAP, - project: PROJECT_SCOPE_MAP, - credential: CREDENTIALS_SHARING_SCOPE_MAP, - workflow: WORKFLOW_SHARING_SCOPE_MAP, -} as const; - -const COMBINED_MAP = Object.fromEntries( - Object.values(ALL_MAPS).flatMap((o: Record) => Object.entries(o)), -) as Record; - -export interface RoleMap { - global: GlobalRole[]; - project: ProjectRole[]; - credential: CredentialSharingRole[]; - workflow: WorkflowSharingRole[]; -} -export type AllRoleTypes = GlobalRole | ProjectRole | WorkflowSharingRole | CredentialSharingRole; - -const ROLE_NAMES: Record< - GlobalRole | ProjectRole | WorkflowSharingRole | CredentialSharingRole, - string -> = { - 'global:owner': 'Owner', - 'global:admin': 'Admin', - 'global:member': 'Member', - 'project:personalOwner': 'Project Owner', - 'project:admin': 'Project Admin', - 'project:editor': 'Project Editor', - 'project:viewer': 'Project Viewer', - 'credential:user': 'Credential User', - 'credential:owner': 'Credential Owner', - 'workflow:owner': 'Workflow Owner', - 'workflow:editor': 'Workflow Editor', -}; - -// export type ScopesField = { scopes: Scope[] }; - @Service() export class RoleService { constructor(private readonly license: License) {} - rolesWithScope(namespace: 'global', scopes: Scope | Scope[]): GlobalRole[]; - rolesWithScope(namespace: 'project', scopes: Scope | Scope[]): ProjectRole[]; - rolesWithScope(namespace: 'credential', scopes: Scope | Scope[]): CredentialSharingRole[]; - rolesWithScope(namespace: 'workflow', scopes: Scope | Scope[]): WorkflowSharingRole[]; - rolesWithScope(namespace: RoleNamespace, scopes: Scope | Scope[]) { - if (!Array.isArray(scopes)) { - scopes = [scopes]; - } - - return Object.keys(ALL_MAPS[namespace]).filter((k) => { - return scopes.every((s) => - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access - ((ALL_MAPS[namespace] as any)[k] as Scope[]).includes(s), - ); + getAllRoles() { + Object.values(ALL_ROLES).forEach((entries) => { + entries.forEach((entry) => { + entry.licensed = this.isRoleLicensed(entry.role); + }); }); - } - - getRoles(): RoleMap { - return Object.fromEntries( - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - Object.entries(ALL_MAPS).map((e) => [e[0], Object.keys(e[1])]), - ) as unknown as RoleMap; - } - - getRoleName(role: AllRoleTypes): string { - return ROLE_NAMES[role]; - } - - getRoleScopes( - role: GlobalRole | ProjectRole | WorkflowSharingRole | CredentialSharingRole, - filters?: Resource[], - ): Scope[] { - let scopes = COMBINED_MAP[role]; - if (filters) { - scopes = scopes.filter((s) => filters.includes(s.split(':')[0] as Resource)); - } - return scopes; - } - - /** - * Find all distinct scopes in a set of project roles. - */ - getScopesBy(projectRoles: Set) { - return [...projectRoles].reduce>((acc, projectRole) => { - for (const scope of PROJECT_SCOPE_MAP[projectRole] ?? []) { - acc.add(scope); - } - - return acc; - }, new Set()); + return ALL_ROLES; } addScopes( @@ -192,9 +63,7 @@ export class RoleService { | ListQueryDb.Workflow.WithScopes | ListQueryDb.Credentials.WithScopes; - Object.assign(entity, { - scopes: [], - }); + entity.scopes = []; if (shared === undefined) { return entity; @@ -220,7 +89,7 @@ export class RoleService { shared: SharedCredentials[] | SharedWorkflow[], userProjectRelations: ProjectRelation[], ): Scope[] { - const globalScopes = this.getRoleScopes(user.role, [type]); + const globalScopes = getRoleScopes(user.role, [type]); const scopesSet: Set = new Set(globalScopes); for (const sharedEntity of shared) { const pr = userProjectRelations.find( @@ -228,9 +97,9 @@ export class RoleService { ); let projectScopes: Scope[] = []; if (pr) { - projectScopes = this.getRoleScopes(pr.role); + projectScopes = getRoleScopes(pr.role); } - const resourceMask = this.getRoleScopes(sharedEntity.role); + const resourceMask = getRoleScopes(sharedEntity.role); const mergedScopes = combineScopes( { global: globalScopes, @@ -243,7 +112,8 @@ export class RoleService { return [...scopesSet].sort(); } - isRoleLicensed(role: AllRoleTypes) { + private isRoleLicensed(role: AllRoleTypes) { + // TODO: move this info into FrontendSettings switch (role) { case 'project:admin': return this.license.isProjectRoleAdminLicensed(); diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index fc20d15d4e..168f32710a 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -2,7 +2,7 @@ import type { RoleChangeRequestDto } from '@n8n/api-types'; import { User } from '@n8n/db'; import type { PublicUser } from '@n8n/db'; import { Service } from '@n8n/di'; -import type { AssignableRole } from '@n8n/permissions'; +import { getGlobalScopes, type AssignableGlobalRole } from '@n8n/permissions'; import { Logger } from 'n8n-core'; import type { IUserSettings } from 'n8n-workflow'; import { UnexpectedError } from 'n8n-workflow'; @@ -71,6 +71,7 @@ export class UserService { let publicUser: PublicUser = { ...rest, signInType: ldapIdentity ? 'ldap' : 'email', + isOwner: user.role === 'global:owner', }; if (options?.withInviteUrl && !options?.inviterId) { @@ -85,8 +86,9 @@ export class UserService { publicUser = await this.addFeatureFlags(publicUser, options.posthog); } + // TODO: resolve these directly in the frontend if (options?.withScopes) { - publicUser.globalScopes = user.globalScopes; + publicUser.globalScopes = getGlobalScopes(user); } return publicUser; @@ -123,7 +125,7 @@ export class UserService { private async sendEmails( owner: User, toInviteUsers: { [key: string]: string }, - role: AssignableRole, + role: AssignableGlobalRole, ) { const domain = this.urlService.getInstanceBaseUrl(); diff --git a/packages/cli/src/workflows/workflow-finder.service.ts b/packages/cli/src/workflows/workflow-finder.service.ts index e504c02434..363decd224 100644 --- a/packages/cli/src/workflows/workflow-finder.service.ts +++ b/packages/cli/src/workflows/workflow-finder.service.ts @@ -1,20 +1,16 @@ import type { SharedWorkflow, User } from '@n8n/db'; import { Service } from '@n8n/di'; -import type { Scope } from '@n8n/permissions'; +import { hasGlobalScope, rolesWithScope, type Scope } from '@n8n/permissions'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In } from '@n8n/typeorm'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; -import { RoleService } from '@/services/role.service'; @Service() export class WorkflowFinderService { - constructor( - private readonly sharedWorkflowRepository: SharedWorkflowRepository, - private readonly roleService: RoleService, - ) {} + constructor(private readonly sharedWorkflowRepository: SharedWorkflowRepository) {} async findWorkflowForUser( workflowId: string, @@ -28,9 +24,9 @@ export class WorkflowFinderService { ) { let where: FindOptionsWhere = {}; - if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) { - const projectRoles = this.roleService.rolesWithScope('project', scopes); - const workflowRoles = this.roleService.rolesWithScope('workflow', scopes); + if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) { + const projectRoles = rolesWithScope('project', scopes); + const workflowRoles = rolesWithScope('workflow', scopes); where = { role: In(workflowRoles), @@ -60,9 +56,9 @@ export class WorkflowFinderService { async findAllWorkflowsForUser(user: User, scopes: Scope[]) { let where: FindOptionsWhere = {}; - if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) { - const projectRoles = this.roleService.rolesWithScope('project', scopes); - const workflowRoles = this.roleService.rolesWithScope('workflow', scopes); + if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) { + const projectRoles = rolesWithScope('project', scopes); + const workflowRoles = rolesWithScope('workflow', scopes); where = { ...where, diff --git a/packages/cli/src/workflows/workflow-sharing.service.ts b/packages/cli/src/workflows/workflow-sharing.service.ts index f7a3778989..27f36bab1e 100644 --- a/packages/cli/src/workflows/workflow-sharing.service.ts +++ b/packages/cli/src/workflows/workflow-sharing.service.ts @@ -1,7 +1,12 @@ -import type { ProjectRole } from '@n8n/api-types'; -import type { WorkflowSharingRole, User } from '@n8n/db'; +import type { User } from '@n8n/db'; import { Service } from '@n8n/di'; -import type { Scope } from '@n8n/permissions'; +import { + hasGlobalScope, + rolesWithScope, + type ProjectRole, + type WorkflowSharingRole, + type Scope, +} from '@n8n/permissions'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In } from '@n8n/typeorm'; @@ -33,7 +38,7 @@ export class WorkflowSharingService { async getSharedWorkflowIds(user: User, options: ShareWorkflowOptions): Promise { const { projectId } = options; - if (user.hasGlobalScope('workflow:read')) { + if (hasGlobalScope(user, 'workflow:read')) { const sharedWorkflows = await this.sharedWorkflowRepository.find({ select: ['workflowId'], ...(projectId && { where: { projectId } }), @@ -42,13 +47,9 @@ export class WorkflowSharingService { } const projectRoles = - 'scopes' in options - ? this.roleService.rolesWithScope('project', options.scopes) - : options.projectRoles; + 'scopes' in options ? rolesWithScope('project', options.scopes) : options.projectRoles; const workflowRoles = - 'scopes' in options - ? this.roleService.rolesWithScope('workflow', options.scopes) - : options.workflowRoles; + 'scopes' in options ? rolesWithScope('workflow', options.scopes) : options.workflowRoles; const sharedWorkflows = await this.sharedWorkflowRepository.find({ where: { diff --git a/packages/cli/test/integration/controllers/invitation/assertions.ts b/packages/cli/test/integration/controllers/invitation/assertions.ts index dcc732da80..ed02e3c80b 100644 --- a/packages/cli/test/integration/controllers/invitation/assertions.ts +++ b/packages/cli/test/integration/controllers/invitation/assertions.ts @@ -9,8 +9,6 @@ export function assertReturnedUserProps(user: User) { expect(user.personalizationAnswers).toBeNull(); expect(user.password).toBeUndefined(); expect(user.isPending).toBe(false); - expect(user.globalScopes).toBeDefined(); - expect(user.globalScopes).not.toHaveLength(0); } export const assertStoredUserProps = (user: User) => { diff --git a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts index 1d80435ea0..274f4a5178 100644 --- a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts +++ b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts @@ -1,8 +1,8 @@ -import type { ProjectRole } from '@n8n/api-types'; import type { Project } from '@n8n/db'; import type { User } from '@n8n/db'; import type { ListQueryDb } from '@n8n/db'; import { Container } from '@n8n/di'; +import type { ProjectRole } from '@n8n/permissions'; import { In } from '@n8n/typeorm'; import config from '@/config'; diff --git a/packages/cli/test/integration/project.api.test.ts b/packages/cli/test/integration/project.api.test.ts index 966d5f528a..c1c5dfd960 100644 --- a/packages/cli/test/integration/project.api.test.ts +++ b/packages/cli/test/integration/project.api.test.ts @@ -1,7 +1,6 @@ -import type { ProjectRole } from '@n8n/api-types'; import type { Project } from '@n8n/db'; import { Container } from '@n8n/di'; -import type { GlobalRole, Scope } from '@n8n/permissions'; +import { getRoleScopes, type GlobalRole, type ProjectRole, type Scope } from '@n8n/permissions'; import { EntityNotFoundError } from '@n8n/typeorm'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; @@ -12,7 +11,6 @@ import { SharedCredentialsRepository } from '@/databases/repositories/shared-cre import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import { getWorkflowById } from '@/public-api/v1/handlers/workflows/workflows.service'; import { CacheService } from '@/services/cache/cache.service'; -import { RoleService } from '@/services/role.service'; import { createFolder } from '@test-integration/db/folders'; import { @@ -391,7 +389,7 @@ describe('POST /projects/', () => { await findProject(respProject.id); }).not.toThrow(); expect(resp.body.data.role).toBe('project:admin'); - for (const scope of Container.get(RoleService).getRoleScopes('project:admin')) { + for (const scope of getRoleScopes('project:admin')) { expect(resp.body.data.scopes).toContain(scope); } }); diff --git a/packages/cli/test/integration/role.api.test.ts b/packages/cli/test/integration/role.api.test.ts index 47b2ab1c1d..3d929aa49d 100644 --- a/packages/cli/test/integration/role.api.test.ts +++ b/packages/cli/test/integration/role.api.test.ts @@ -1,10 +1,11 @@ -import type { ProjectRole } from '@n8n/api-types'; -import type { CredentialSharingRole } from '@n8n/db'; -import type { WorkflowSharingRole } from '@n8n/db'; -import { Container } from '@n8n/di'; -import type { GlobalRole, Scope } from '@n8n/permissions'; - -import { RoleService } from '@/services/role.service'; +import { getRoleScopes } from '@n8n/permissions'; +import type { + GlobalRole, + ProjectRole, + CredentialSharingRole, + WorkflowSharingRole, + Scope, +} from '@n8n/permissions'; import { createMember } from './shared/db/users'; import type { SuperAgentTest } from './shared/types'; @@ -49,19 +50,19 @@ beforeAll(async () => { { name: 'Owner', role: 'global:owner', - scopes: Container.get(RoleService).getRoleScopes('global:owner'), + scopes: getRoleScopes('global:owner'), licensed: true, }, { name: 'Admin', role: 'global:admin', - scopes: Container.get(RoleService).getRoleScopes('global:admin'), + scopes: getRoleScopes('global:admin'), licensed: false, }, { name: 'Member', role: 'global:member', - scopes: Container.get(RoleService).getRoleScopes('global:member'), + scopes: getRoleScopes('global:member'), licensed: true, }, ]; @@ -69,19 +70,19 @@ beforeAll(async () => { { name: 'Project Owner', role: 'project:personalOwner', - scopes: Container.get(RoleService).getRoleScopes('project:personalOwner'), + scopes: getRoleScopes('project:personalOwner'), licensed: true, }, { name: 'Project Admin', role: 'project:admin', - scopes: Container.get(RoleService).getRoleScopes('project:admin'), + scopes: getRoleScopes('project:admin'), licensed: false, }, { name: 'Project Editor', role: 'project:editor', - scopes: Container.get(RoleService).getRoleScopes('project:editor'), + scopes: getRoleScopes('project:editor'), licensed: false, }, ]; @@ -89,13 +90,13 @@ beforeAll(async () => { { name: 'Credential Owner', role: 'credential:owner', - scopes: Container.get(RoleService).getRoleScopes('credential:owner'), + scopes: getRoleScopes('credential:owner'), licensed: true, }, { name: 'Credential User', role: 'credential:user', - scopes: Container.get(RoleService).getRoleScopes('credential:user'), + scopes: getRoleScopes('credential:user'), licensed: true, }, ]; @@ -103,13 +104,13 @@ beforeAll(async () => { { name: 'Workflow Owner', role: 'workflow:owner', - scopes: Container.get(RoleService).getRoleScopes('workflow:owner'), + scopes: getRoleScopes('workflow:owner'), licensed: true, }, { name: 'Workflow Editor', role: 'workflow:editor', - scopes: Container.get(RoleService).getRoleScopes('workflow:editor'), + scopes: getRoleScopes('workflow:editor'), licensed: true, }, ]; diff --git a/packages/cli/test/integration/services/project.service.test.ts b/packages/cli/test/integration/services/project.service.test.ts index b01aed3b3b..d709a15586 100644 --- a/packages/cli/test/integration/services/project.service.test.ts +++ b/packages/cli/test/integration/services/project.service.test.ts @@ -1,6 +1,5 @@ -import type { ProjectRole } from '@n8n/api-types'; import { Container } from '@n8n/di'; -import type { Scope } from '@n8n/permissions'; +import type { ProjectRole, Scope } from '@n8n/permissions'; import { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository'; diff --git a/packages/cli/test/integration/shared/db/credentials.ts b/packages/cli/test/integration/shared/db/credentials.ts index 38d32c64ab..91c9255b50 100644 --- a/packages/cli/test/integration/shared/db/credentials.ts +++ b/packages/cli/test/integration/shared/db/credentials.ts @@ -1,10 +1,10 @@ import type { Project } from '@n8n/db'; -import type { CredentialSharingRole } from '@n8n/db'; import type { User } from '@n8n/db'; import type { ICredentialsDb } from '@n8n/db'; import { CredentialsEntity } from '@n8n/db'; import { CredentialsRepository } from '@n8n/db'; import { Container } from '@n8n/di'; +import type { CredentialSharingRole } from '@n8n/permissions'; import { ProjectRepository } from '@/databases/repositories/project.repository'; import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; diff --git a/packages/cli/test/integration/shared/db/projects.ts b/packages/cli/test/integration/shared/db/projects.ts index 3669dab1b9..9d697bb409 100644 --- a/packages/cli/test/integration/shared/db/projects.ts +++ b/packages/cli/test/integration/shared/db/projects.ts @@ -1,8 +1,8 @@ -import type { ProjectRole } from '@n8n/api-types'; import type { Project } from '@n8n/db'; import type { User } from '@n8n/db'; import type { ProjectRelation } from '@n8n/db'; import { Container } from '@n8n/di'; +import type { ProjectRole } from '@n8n/permissions'; import { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository'; diff --git a/packages/cli/test/integration/shared/db/users.ts b/packages/cli/test/integration/shared/db/users.ts index 9b96c53987..4f59a51bb3 100644 --- a/packages/cli/test/integration/shared/db/users.ts +++ b/packages/cli/test/integration/shared/db/users.ts @@ -39,7 +39,6 @@ export async function newUser(attributes: Partial = {}): Promise { export async function createUser(attributes: Partial = {}): Promise { const userInstance = await newUser(attributes); const { user } = await Container.get(UserRepository).createUserWithProject(userInstance); - user.computeIsOwner(); return user; } diff --git a/packages/cli/test/integration/shared/db/workflows.ts b/packages/cli/test/integration/shared/db/workflows.ts index 187534647b..cb707ff34d 100644 --- a/packages/cli/test/integration/shared/db/workflows.ts +++ b/packages/cli/test/integration/shared/db/workflows.ts @@ -1,8 +1,9 @@ import { Project } from '@n8n/db'; import { User } from '@n8n/db'; -import type { SharedWorkflow, WorkflowSharingRole } from '@n8n/db'; +import type { SharedWorkflow } from '@n8n/db'; import type { IWorkflowDb } from '@n8n/db'; import { Container } from '@n8n/di'; +import type { WorkflowSharingRole } from '@n8n/permissions'; import type { DeepPartial } from '@n8n/typeorm'; import type { IWorkflowBase } from 'n8n-workflow'; import { NodeConnectionTypes } from 'n8n-workflow'; diff --git a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts index 6873514915..5f4df95ef6 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts @@ -1,8 +1,8 @@ -import type { ProjectRole } from '@n8n/api-types'; import type { Project } from '@n8n/db'; import type { User } from '@n8n/db'; import type { WorkflowWithSharingsMetaDataAndCredentials } from '@n8n/db'; import { Container } from '@n8n/di'; +import type { ProjectRole } from '@n8n/permissions'; import { ApplicationError, WorkflowActivationError, type INode } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; diff --git a/packages/frontend/editor-ui/src/api/roles.api.ts b/packages/frontend/editor-ui/src/api/roles.api.ts index 79b66b9500..ee578d97b7 100644 --- a/packages/frontend/editor-ui/src/api/roles.api.ts +++ b/packages/frontend/editor-ui/src/api/roles.api.ts @@ -1,7 +1,7 @@ +import type { AllRolesMap } from '@n8n/permissions'; import type { IRestApiContext } from '@/Interface'; -import type { RoleMap } from '@/types/roles.types'; import { makeRestApiRequest } from '@/utils/apiUtils'; -export const getRoles = async (context: IRestApiContext): Promise => { +export const getRoles = async (context: IRestApiContext): Promise => { return await makeRestApiRequest(context, 'GET', '/roles'); }; diff --git a/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue b/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue index 8308322135..5861a44268 100644 --- a/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue +++ b/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue @@ -1,4 +1,5 @@