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:
कारतोफ्फेलस्क्रिप्ट™
2025-05-06 15:11:05 +02:00
committed by GitHub
parent cdcd059248
commit 2bb190349b
85 changed files with 1011 additions and 775 deletions

View File

@@ -18,7 +18,6 @@ export { passwordSchema } from './schemas/password.schema';
export type {
ProjectType,
ProjectIcon,
ProjectRole,
ProjectRelation,
} from './schemas/project.schema';

View File

@@ -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([
{

View File

@@ -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,

View File

@@ -7,5 +7,5 @@
"tsBuildInfoFile": "dist/build.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"exclude": ["test/**", "src/**/__tests__/**"]
"exclude": ["src/**/__tests__/**"]
}

View File

@@ -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" }
]
}

View File

@@ -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;

View File

@@ -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')

View File

@@ -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')

View File

@@ -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 & {

View File

@@ -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;

View File

@@ -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" }
]
}

View File

@@ -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" }
]
}

View File

@@ -20,6 +20,9 @@
"files": [
"dist/**/*"
],
"dependencies": {
"zod": "catalog:"
},
"devDependencies": {
"@n8n/typescript-config": "workspace:*"
}

View 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);
});
});

View 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();
});
});

View File

@@ -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());
}

View File

@@ -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,

View File

@@ -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));
}

View File

@@ -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';

View 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),
};

View 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;

View File

@@ -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'];

View File

@@ -1,4 +1,4 @@
import type { Scope } from './types.ee';
import type { Scope } from '../../types.ee';
export const GLOBAL_OWNER_SCOPES: Scope[] = [
'annotationTag:create',

View File

@@ -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:

View File

@@ -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',

View 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']);

View File

@@ -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

View File

@@ -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));
},
);
});
});

View File

@@ -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([]);
});
});

View File

@@ -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([]);
});
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});
});

View File

@@ -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']);
});
});
});

View 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());
}

View File

@@ -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] ?? [];

View 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;
}

View 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);
};

View 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));
};

View 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),
);
});
}

View File

@@ -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);
});
});

View File

@@ -7,5 +7,5 @@
"tsBuildInfoFile": "dist/build.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"exclude": ["test/**"]
"exclude": ["src/**/__tests__/**"]
}

View File

@@ -9,5 +9,5 @@
},
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo"
},
"include": ["src/**/*.ts", "test/**/*.ts"]
"include": ["src/**/*.ts"]
}

View File

@@ -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);

View File

@@ -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 },

View File

@@ -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) } : {}),
}),
],
};

View File

@@ -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();
}
}

View File

@@ -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,

View File

@@ -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')),
},
}),
},

View File

@@ -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',

View File

@@ -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';

View File

@@ -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()

View File

@@ -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';

View File

@@ -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';

View File

@@ -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();

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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)),
});
}

View File

@@ -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';

View File

@@ -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;
};

View File

@@ -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);
});
});

View File

@@ -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(() => {

View File

@@ -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]);
}

View File

@@ -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,

View File

@@ -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();

View File

@@ -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();

View File

@@ -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,

View File

@@ -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: {

View File

@@ -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) => {

View File

@@ -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';

View File

@@ -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);
}
});

View File

@@ -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,
},
];

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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');
};

View File

@@ -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],

View File

@@ -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;

View File

@@ -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'),
);

View File

@@ -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',

View File

@@ -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>>;
};

View File

@@ -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
View File

@@ -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:*