mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
refactor(core): Move more code into @n8n/permissions. Add aditional tests and docs (no-changelog) (#15062)
Co-authored-by: Danny Martini <danny@n8n.io>
This commit is contained in:
committed by
GitHub
parent
cdcd059248
commit
2bb190349b
@@ -18,7 +18,6 @@ export { passwordSchema } from './schemas/password.schema';
|
||||
export type {
|
||||
ProjectType,
|
||||
ProjectIcon,
|
||||
ProjectRole,
|
||||
ProjectRelation,
|
||||
} from './schemas/project.schema';
|
||||
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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<typeof projectIconSchema>;
|
||||
|
||||
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<typeof projectRoleSchema>;
|
||||
|
||||
export const projectRelationSchema = z.object({
|
||||
userId: z.string(),
|
||||
role: projectRoleSchema,
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"tsBuildInfoFile": "dist/build.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["test/**", "src/**/__tests__/**"]
|
||||
"exclude": ["src/**/__tests__/**"]
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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<GlobalRole, Scope[]> = {
|
||||
'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;
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@n8n/typescript-config": "workspace:*"
|
||||
}
|
||||
|
||||
93
packages/@n8n/permissions/src/__tests__/schemas.test.ts
Normal file
93
packages/@n8n/permissions/src/__tests__/schemas.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
115
packages/@n8n/permissions/src/__tests__/types.test.ts
Normal file
115
packages/@n8n/permissions/src/__tests__/types.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { Scope, ScopeLevels, GlobalScopes, MaskLevels } from './types.ee';
|
||||
|
||||
export function combineScopes(userScopes: GlobalScopes, masks?: MaskLevels): Set<Scope>;
|
||||
export function combineScopes(userScopes: ScopeLevels, masks?: MaskLevels): Set<Scope>;
|
||||
export function combineScopes(
|
||||
userScopes: GlobalScopes | ScopeLevels,
|
||||
masks?: MaskLevels,
|
||||
): Set<Scope> {
|
||||
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());
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
37
packages/@n8n/permissions/src/roles/all-roles.ts
Normal file
37
packages/@n8n/permissions/src/roles/all-roles.ts
Normal file
@@ -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<AllRoleTypes, 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',
|
||||
};
|
||||
|
||||
const mapToRoleObject = <T extends keyof typeof ROLE_NAMES>(roles: Record<T, Scope[]>) =>
|
||||
(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),
|
||||
};
|
||||
56
packages/@n8n/permissions/src/roles/role-maps.ee.ts
Normal file
56
packages/@n8n/permissions/src/roles/role-maps.ee.ts
Normal file
@@ -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<GlobalRole, Scope[]> = {
|
||||
'global:owner': GLOBAL_OWNER_SCOPES,
|
||||
'global:admin': GLOBAL_ADMIN_SCOPES,
|
||||
'global:member': GLOBAL_MEMBER_SCOPES,
|
||||
};
|
||||
|
||||
export const PROJECT_SCOPE_MAP: Record<ProjectRole, Scope[]> = {
|
||||
'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<CredentialSharingRole, Scope[]> = {
|
||||
'credential:owner': CREDENTIALS_SHARING_OWNER_SCOPES,
|
||||
'credential:user': CREDENTIALS_SHARING_USER_SCOPES,
|
||||
};
|
||||
|
||||
export const WORKFLOW_SHARING_SCOPE_MAP: Record<WorkflowSharingRole, Scope[]> = {
|
||||
'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;
|
||||
@@ -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'];
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Scope } from './types.ee';
|
||||
import type { Scope } from '../../types.ee';
|
||||
|
||||
export const GLOBAL_OWNER_SCOPES: Scope[] = [
|
||||
'annotationTag:create',
|
||||
@@ -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:
|
||||
@@ -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',
|
||||
20
packages/@n8n/permissions/src/schemas.ee.ts
Normal file
20
packages/@n8n/permissions/src/schemas.ee.ts
Normal file
@@ -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']);
|
||||
@@ -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<Resource>` directly we'd end
|
||||
@@ -16,26 +29,57 @@ type AllScopesObject = {
|
||||
[R in Resource]: ResourceScope<R>;
|
||||
};
|
||||
|
||||
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<T extends ScopeLevel> = Record<T, Scope[]>;
|
||||
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<T extends MaskLevel> = Record<T, Scope[]>;
|
||||
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<typeof roleNamespaceSchema>;
|
||||
export type GlobalRole = z.infer<typeof globalRoleSchema>;
|
||||
export type AssignableGlobalRole = z.infer<typeof assignableGlobalRoleSchema>;
|
||||
export type CredentialSharingRole = z.infer<typeof credentialSharingRoleSchema>;
|
||||
export type WorkflowSharingRole = z.infer<typeof workflowSharingRoleSchema>;
|
||||
export type ProjectRole = z.infer<typeof projectRoleSchema>;
|
||||
|
||||
export type ApiKeyResourceScope<
|
||||
/** Union of all possible role types in the system */
|
||||
export type AllRoleTypes = GlobalRole | ProjectRole | WorkflowSharingRole | CredentialSharingRole;
|
||||
|
||||
type RoleObject<T extends AllRoleTypes> = {
|
||||
role: T;
|
||||
name: string;
|
||||
scopes: Scope[];
|
||||
licensed: boolean;
|
||||
};
|
||||
|
||||
export type AllRolesMap = {
|
||||
global: Array<RoleObject<GlobalRole>>;
|
||||
project: Array<RoleObject<ProjectRole>>;
|
||||
credential: Array<RoleObject<CredentialSharingRole>>;
|
||||
workflow: Array<RoleObject<WorkflowSharingRole>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<GlobalRole, 'global:owner'>;
|
||||
// #endregion
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
31
packages/@n8n/permissions/src/utilities/combineScopes.ee.ts
Normal file
31
packages/@n8n/permissions/src/utilities/combineScopes.ee.ts
Normal file
@@ -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<Scope> {
|
||||
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());
|
||||
}
|
||||
@@ -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] ?? [];
|
||||
20
packages/@n8n/permissions/src/utilities/getRoleScopes.ee.ts
Normal file
20
packages/@n8n/permissions/src/utilities/getRoleScopes.ee.ts
Normal file
@@ -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<string, Scope[]>) => Object.entries(o)),
|
||||
) as Record<AllRoleTypes, Scope[]>;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
17
packages/@n8n/permissions/src/utilities/hasGlobalScope.ee.ts
Normal file
17
packages/@n8n/permissions/src/utilities/hasGlobalScope.ee.ts
Normal file
@@ -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);
|
||||
};
|
||||
22
packages/@n8n/permissions/src/utilities/hasScope.ee.ts
Normal file
22
packages/@n8n/permissions/src/utilities/hasScope.ee.ts
Normal file
@@ -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));
|
||||
};
|
||||
37
packages/@n8n/permissions/src/utilities/rolesWithScope.ee.ts
Normal file
37
packages/@n8n/permissions/src/utilities/rolesWithScope.ee.ts
Normal file
@@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -7,5 +7,5 @@
|
||||
"tsBuildInfoFile": "dist/build.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["test/**"]
|
||||
"exclude": ["src/**/__tests__/**"]
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
},
|
||||
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
"include": ["src/**/*.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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<ProjectRequest.GetMyProjectsResponse> {
|
||||
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) } : {}),
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CredentialsEntity> = {};
|
||||
|
||||
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<SharedCredentials> = { 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<SharedCredentials> = {};
|
||||
|
||||
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,
|
||||
|
||||
@@ -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')),
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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<SharedCredentials | null> {
|
||||
let where: FindOptionsWhere<SharedCredentials> = { credentialsId: credentialId };
|
||||
|
||||
if (!user.hasGlobalScope(globalScopes, { mode: 'allOf' })) {
|
||||
if (!hasGlobalScope(user, globalScopes, { mode: 'allOf' })) {
|
||||
where = {
|
||||
...where,
|
||||
role: 'credential:owner',
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -95,9 +95,7 @@ describe('CredentialsPermissionChecker', () => {
|
||||
});
|
||||
|
||||
it('should skip credential checks if the home project owner has global scope', async () => {
|
||||
const projectOwner = mock<User>({
|
||||
hasGlobalScope: (scope) => scope === 'credential:list',
|
||||
});
|
||||
const projectOwner = mock<User>({ role: 'global:owner' });
|
||||
ownershipService.getPersonalProjectOwnerCached.mockResolvedValueOnce(projectOwner);
|
||||
|
||||
await expect(permissionChecker.check(workflowId, [node])).resolves.not.toThrow();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<boolean> {
|
||||
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)),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<SharedCredentials>();
|
||||
sharedCredential.credentials = mock<CredentialsEntity>({ id: credentialsId });
|
||||
const owner = mock<User>({
|
||||
isOwner: true,
|
||||
hasGlobalScope: (scope) =>
|
||||
hasScope(scope, {
|
||||
global: GLOBAL_OWNER_SCOPES,
|
||||
}),
|
||||
role: 'global:owner',
|
||||
});
|
||||
const member = mock<User>({
|
||||
isOwner: false,
|
||||
role: 'global:member',
|
||||
id: 'test',
|
||||
hasGlobalScope: (scope) =>
|
||||
hasScope(scope, {
|
||||
global: GLOBAL_MEMBER_SCOPES,
|
||||
}),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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<Project[]> {
|
||||
// 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,
|
||||
|
||||
@@ -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<GlobalRole, Scope[]> = {
|
||||
'global:owner': GLOBAL_OWNER_SCOPES,
|
||||
'global:admin': GLOBAL_ADMIN_SCOPES,
|
||||
'global:member': GLOBAL_MEMBER_SCOPES,
|
||||
};
|
||||
|
||||
const PROJECT_SCOPE_MAP: Record<ProjectRole, Scope[]> = {
|
||||
'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<CredentialSharingRole, Scope[]> = {
|
||||
'credential:owner': CREDENTIALS_SHARING_OWNER_SCOPES,
|
||||
'credential:user': CREDENTIALS_SHARING_USER_SCOPES,
|
||||
};
|
||||
|
||||
const WORKFLOW_SHARING_SCOPE_MAP: Record<WorkflowSharingRole, Scope[]> = {
|
||||
'workflow:owner': WORKFLOW_SHARING_OWNER_SCOPES,
|
||||
'workflow:editor': WORKFLOW_SHARING_EDITOR_SCOPES,
|
||||
};
|
||||
|
||||
interface AllMaps {
|
||||
global: Record<GlobalRole, Scope[]>;
|
||||
project: Record<ProjectRole, Scope[]>;
|
||||
credential: Record<CredentialSharingRole, Scope[]>;
|
||||
workflow: Record<WorkflowSharingRole, Scope[]>;
|
||||
}
|
||||
|
||||
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<string, Scope[]>) => Object.entries(o)),
|
||||
) as Record<GlobalRole | ProjectRole | CredentialSharingRole | WorkflowSharingRole, Scope[]>;
|
||||
|
||||
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<ProjectRole>) {
|
||||
return [...projectRoles].reduce<Set<Scope>>((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<Scope> = 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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<SharedWorkflow> = {};
|
||||
|
||||
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<SharedWorkflow> = {};
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<string[]> {
|
||||
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: {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -39,7 +39,6 @@ export async function newUser(attributes: Partial<User> = {}): Promise<User> {
|
||||
export async function createUser(attributes: Partial<User> = {}): Promise<User> {
|
||||
const userInstance = await newUser(attributes);
|
||||
const { user } = await Container.get(UserRepository).createUserWithProject(userInstance);
|
||||
user.computeIsOwner();
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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<RoleMap> => {
|
||||
export const getRoles = async (context: IRestApiContext): Promise<AllRolesMap> => {
|
||||
return await makeRestApiRequest(context, 'GET', '/roles');
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { AllRolesMap } from '@n8n/permissions';
|
||||
import ProjectSharing from '@/components/Projects/ProjectSharing.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||
@@ -12,7 +13,6 @@ import { useUIStore } from '@/stores/ui.store';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import type { ProjectListItem, ProjectSharingData } from '@/types/projects.types';
|
||||
import { ProjectTypes } from '@/types/projects.types';
|
||||
import type { RoleMap } from '@/types/roles.types';
|
||||
import { splitName } from '@/utils/projects.utils';
|
||||
import type { EventBus } from '@n8n/utils/event-bus';
|
||||
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
|
||||
@@ -82,7 +82,7 @@ const credentialRoleTranslations = computed<Record<string, string>>(() => {
|
||||
};
|
||||
});
|
||||
|
||||
const credentialRoles = computed<RoleMap['credential']>(() => {
|
||||
const credentialRoles = computed<AllRolesMap['credential']>(() => {
|
||||
return rolesStore.processedCredentialRoles.map(({ role, scopes, licensed }) => ({
|
||||
role,
|
||||
name: credentialRoleTranslations.value[role],
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AllRolesMap } from '@n8n/permissions';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import type { ProjectListItem, ProjectSharingData } from '@/types/projects.types';
|
||||
import ProjectSharingInfo from '@/components/Projects/ProjectSharingInfo.vue';
|
||||
import type { RoleMap } from '@/types/roles.types';
|
||||
import { sortByProperty } from '@n8n/utils/sort/sortByProperty';
|
||||
|
||||
const locale = useI18n();
|
||||
@@ -11,7 +11,7 @@ const locale = useI18n();
|
||||
type Props = {
|
||||
projects: ProjectListItem[];
|
||||
homeProject?: ProjectSharingData;
|
||||
roles?: RoleMap['workflow' | 'credential' | 'project'];
|
||||
roles?: AllRolesMap['workflow' | 'credential' | 'project'];
|
||||
readonly?: boolean;
|
||||
static?: boolean;
|
||||
placeholder?: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ProjectRole, RoleMap } from '@/types/roles.types';
|
||||
import type { ProjectRole, AllRolesMap } from '@n8n/permissions';
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import * as rolesApi from '@/api/roles.api';
|
||||
@@ -7,7 +7,7 @@ import { useRootStore } from './root.store';
|
||||
export const useRolesStore = defineStore('roles', () => {
|
||||
const rootStore = useRootStore();
|
||||
|
||||
const roles = ref<RoleMap>({
|
||||
const roles = ref<AllRolesMap>({
|
||||
global: [],
|
||||
project: [],
|
||||
credential: [],
|
||||
@@ -22,7 +22,7 @@ export const useRolesStore = defineStore('roles', () => {
|
||||
() => new Map(projectRoleOrder.value.map((role, idx) => [role, idx])),
|
||||
);
|
||||
|
||||
const processedProjectRoles = computed<RoleMap['project']>(() =>
|
||||
const processedProjectRoles = computed<AllRolesMap['project']>(() =>
|
||||
roles.value.project
|
||||
.filter((role) => projectRoleOrderMap.value.has(role.role))
|
||||
.sort(
|
||||
@@ -32,11 +32,11 @@ export const useRolesStore = defineStore('roles', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const processedCredentialRoles = computed<RoleMap['credential']>(() =>
|
||||
const processedCredentialRoles = computed<AllRolesMap['credential']>(() =>
|
||||
roles.value.credential.filter((role) => role.role !== 'credential:owner'),
|
||||
);
|
||||
|
||||
const processedWorkflowRoles = computed<RoleMap['workflow']>(() =>
|
||||
const processedWorkflowRoles = computed<AllRolesMap['workflow']>(() =>
|
||||
roles.value.workflow.filter((role) => role.role !== 'workflow:owner'),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
import type { Scope, ProjectRole } from '@n8n/permissions';
|
||||
import type { IUserResponse } from '@/Interface';
|
||||
import type { ProjectRole } from '@/types/roles.types';
|
||||
|
||||
export const ProjectTypes = {
|
||||
Personal: 'personal',
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
|
||||
export type GlobalRole = 'global:owner' | 'global:admin' | 'global:member';
|
||||
export type ProjectRole =
|
||||
| 'project:personalOwner'
|
||||
| 'project:admin'
|
||||
| 'project:editor'
|
||||
| 'project:viewer';
|
||||
export type CredentialSharingRole = 'credential:owner' | 'credential:user';
|
||||
export type WorkflowSharingRole = 'workflow:owner' | 'workflow:editor';
|
||||
|
||||
export type RoleObject<
|
||||
T extends GlobalRole | ProjectRole | CredentialSharingRole | WorkflowSharingRole,
|
||||
> = {
|
||||
role: T;
|
||||
name: string;
|
||||
scopes: Scope[];
|
||||
licensed: boolean;
|
||||
};
|
||||
export type RoleMap = {
|
||||
global: Array<RoleObject<GlobalRole>>;
|
||||
project: Array<RoleObject<ProjectRole>>;
|
||||
credential: Array<RoleObject<CredentialSharingRole>>;
|
||||
workflow: Array<RoleObject<WorkflowSharingRole>>;
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ProjectRole } from '@n8n/permissions';
|
||||
import { computed, ref, watch, onBeforeMount, onMounted, nextTick } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { deepCopy } from 'n8n-workflow';
|
||||
@@ -13,7 +14,6 @@ import { VIEWS } from '@/constants';
|
||||
import ProjectDeleteDialog from '@/components/Projects/ProjectDeleteDialog.vue';
|
||||
import ProjectRoleUpgradeDialog from '@/components/Projects/ProjectRoleUpgradeDialog.vue';
|
||||
import { useRolesStore } from '@/stores/roles.store';
|
||||
import type { ProjectRole } from '@/types/roles.types';
|
||||
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||
|
||||
4
pnpm-lock.yaml
generated
4
pnpm-lock.yaml
generated
@@ -919,6 +919,10 @@ importers:
|
||||
version: 8.4.0(@microsoft/api-extractor@7.52.1(@types/node@18.16.16))(jiti@1.21.0)(postcss@8.5.3)(tsx@4.19.3)(typescript@5.8.2)
|
||||
|
||||
packages/@n8n/permissions:
|
||||
dependencies:
|
||||
zod:
|
||||
specifier: 'catalog:'
|
||||
version: 3.24.1
|
||||
devDependencies:
|
||||
'@n8n/typescript-config':
|
||||
specifier: workspace:*
|
||||
|
||||
Reference in New Issue
Block a user