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 { export type {
ProjectType, ProjectType,
ProjectIcon, ProjectIcon,
ProjectRole,
ProjectRelation, ProjectRelation,
} from './schemas/project.schema'; } from './schemas/project.schema';

View File

@@ -2,7 +2,6 @@ import {
projectNameSchema, projectNameSchema,
projectTypeSchema, projectTypeSchema,
projectIconSchema, projectIconSchema,
projectRoleSchema,
projectRelationSchema, projectRelationSchema,
} from '../project.schema'; } 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', () => { describe('projectRelationSchema', () => {
test.each([ test.each([
{ {

View File

@@ -1,3 +1,4 @@
import { projectRoleSchema } from '@n8n/permissions';
import { z } from 'zod'; import { z } from 'zod';
export const projectNameSchema = z.string().min(1).max(255); 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 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({ export const projectRelationSchema = z.object({
userId: z.string(), userId: z.string(),
role: projectRoleSchema, role: projectRoleSchema,

View File

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

View File

@@ -6,5 +6,10 @@
"baseUrl": "src", "baseUrl": "src",
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo" "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 { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import { WithTimestamps } from './abstract-entity'; import { WithTimestamps } from './abstract-entity';
@@ -7,7 +8,7 @@ import { User } from './user';
@Entity() @Entity()
export class ProjectRelation extends WithTimestamps { export class ProjectRelation extends WithTimestamps {
@Column({ type: 'varchar' }) @Column({ type: 'varchar' })
role: 'project:personalOwner' | 'project:admin' | 'project:editor' | 'project:viewer'; role: ProjectRole;
@ManyToOne('User', 'projectRelations') @ManyToOne('User', 'projectRelations')
user: User; user: User;

View File

@@ -1,13 +1,13 @@
import { CredentialSharingRole } from '@n8n/permissions';
import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm'; import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import { WithTimestamps } from './abstract-entity'; import { WithTimestamps } from './abstract-entity';
import { CredentialsEntity } from './credentials-entity'; import { CredentialsEntity } from './credentials-entity';
import { Project } from './project'; import { Project } from './project';
import { CredentialSharingRole } from './types-db';
@Entity() @Entity()
export class SharedCredentials extends WithTimestamps { export class SharedCredentials extends WithTimestamps {
@Column() @Column({ type: 'varchar' })
role: CredentialSharingRole; role: CredentialSharingRole;
@ManyToOne('CredentialsEntity', 'shared') @ManyToOne('CredentialsEntity', 'shared')

View File

@@ -1,13 +1,13 @@
import { WorkflowSharingRole } from '@n8n/permissions';
import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm'; import { Column, Entity, ManyToOne, PrimaryColumn } from '@n8n/typeorm';
import { WithTimestamps } from './abstract-entity'; import { WithTimestamps } from './abstract-entity';
import { Project } from './project'; import { Project } from './project';
import { WorkflowSharingRole } from './types-db';
import { WorkflowEntity } from './workflow-entity'; import { WorkflowEntity } from './workflow-entity';
@Entity() @Entity()
export class SharedWorkflow extends WithTimestamps { export class SharedWorkflow extends WithTimestamps {
@Column() @Column({ type: 'varchar' })
role: WorkflowSharingRole; role: WorkflowSharingRole;
@ManyToOne('WorkflowEntity', 'shared') @ManyToOne('WorkflowEntity', 'shared')

View File

@@ -269,10 +269,6 @@ export const enum StatisticsNames {
dataLoaded = 'data_loaded', 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 AuthProviderType = 'ldap' | 'email' | 'saml'; // | 'google';
export type FolderWithWorkflowAndSubFolderCount = Folder & { export type FolderWithWorkflowAndSubFolderCount = Folder & {

View File

@@ -1,5 +1,5 @@
import { hasScope, type ScopeOptions, type Scope, GlobalRole } from '@n8n/permissions'; import type { AuthPrincipal } from '@n8n/permissions';
import { GLOBAL_OWNER_SCOPES, GLOBAL_MEMBER_SCOPES, GLOBAL_ADMIN_SCOPES } from '@n8n/permissions'; import { GlobalRole } from '@n8n/permissions';
import { import {
AfterLoad, AfterLoad,
AfterUpdate, AfterUpdate,
@@ -25,14 +25,8 @@ import { lowerCaser, objectRetriever } from '../utils/transformers';
import { NoUrl } from '../utils/validators/no-url.validator'; import { NoUrl } from '../utils/validators/no-url.validator';
import { NoXss } from '../utils/validators/no-xss.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() @Entity()
export class User extends WithTimestamps implements IUser { export class User extends WithTimestamps implements IUser, AuthPrincipal {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@@ -113,31 +107,6 @@ export class User extends WithTimestamps implements IUser {
this.isPending = this.password === null && this.role !== 'global:owner'; 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() { toJSON() {
const { password, ...rest } = this; const { password, ...rest } = this;
return rest; return rest;

View File

@@ -11,5 +11,12 @@
// remove all options below this line // remove all options below this line
"strictPropertyInitialization": false "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, "experimentalDecorators": true,
"emitDecoratorMetadata": 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": [ "files": [
"dist/**/*" "dist/**/*"
], ],
"dependencies": {
"zod": "catalog:"
},
"devDependencies": { "devDependencies": {
"@n8n/typescript-config": "workspace:*" "@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 DEFAULT_OPERATIONS = ['create', 'read', 'update', 'delete', 'list'] as const;
export const RESOURCES = { export const RESOURCES = {
annotationTag: [...DEFAULT_OPERATIONS] as const, annotationTag: [...DEFAULT_OPERATIONS] as const,
auditLogs: ['manage'] 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 type * from './types.ee';
export * from './constants.ee'; export * from './constants.ee';
export * from './hasScope.ee';
export * from './combineScopes.ee'; export * from './roles/scopes/global-scopes.ee';
export * from './global-roles.ee'; export * from './roles/role-maps.ee';
export * from './project-roles.ee'; export * from './roles/all-roles';
export * from './resource-roles.ee';
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[] = [ export const GLOBAL_OWNER_SCOPES: Scope[] = [
'annotationTag:create', '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: * Diff between admin in personal project and admin in other projects:

View File

@@ -1,14 +1,4 @@
import type { Scope } from './types.ee'; 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'];
export const WORKFLOW_SHARING_OWNER_SCOPES: Scope[] = [ export const WORKFLOW_SHARING_OWNER_SCOPES: Scope[] = [
'workflow:read', '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 Resource = keyof typeof RESOURCES;
export type ResourceScope< /** A permission scope for a specific resource + operation combination */
type ResourceScope<
R extends Resource, R extends Resource,
Operation extends (typeof RESOURCES)[R][number] = (typeof RESOURCES)[R][number], Operation extends (typeof RESOURCES)[R][number] = (typeof RESOURCES)[R][number],
> = `${R}:${Operation}`; > = `${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. // This is purely an intermediary type.
// If we tried to do use `ResourceScope<Resource>` directly we'd end // If we tried to do use `ResourceScope<Resource>` directly we'd end
@@ -16,26 +29,57 @@ type AllScopesObject = {
[R in Resource]: ResourceScope<R>; [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 ScopeLevels = {
export type GetScopeLevel<T extends ScopeLevel> = Record<T, Scope[]>; global: Scope[];
export type GlobalScopes = GetScopeLevel<'global'>; project?: Scope[];
export type ProjectScopes = GetScopeLevel<'project'>; resource?: Scope[];
export type ResourceScopes = GetScopeLevel<'resource'>; };
export type ScopeLevels = GlobalScopes & (ProjectScopes | (ProjectScopes & ResourceScopes));
export type MaskLevel = 'sharing'; export type MaskLevels = {
export type GetMaskLevel<T extends MaskLevel> = Record<T, Scope[]>; sharing: Scope[];
export type SharingMasks = GetMaskLevel<'sharing'>; };
export type MaskLevels = SharingMasks;
export type ScopeMode = 'oneOf' | 'allOf'; export type ScopeOptions = { mode: 'oneOf' | 'allOf' };
export type ScopeOptions = { mode: ScopeMode };
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, R extends PublicApiKeyResources,
Operation extends (typeof API_KEY_RESOURCES)[R][number] = (typeof API_KEY_RESOURCES)[R][number], Operation extends (typeof API_KEY_RESOURCES)[R][number] = (typeof API_KEY_RESOURCES)[R][number],
> = `${R}:${Operation}`; > = `${R}:${Operation}`;
@@ -49,5 +93,4 @@ type AllApiKeyScopesObject = {
export type ApiKeyScope = AllApiKeyScopesObject[PublicApiKeyResources]; export type ApiKeyScope = AllApiKeyScopesObject[PublicApiKeyResources];
export type GlobalRole = 'global:owner' | 'global:admin' | 'global:member'; // #endregion
export type AssignableRole = Exclude<GlobalRole, 'global:owner'>;

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" "tsBuildInfoFile": "dist/build.tsbuildinfo"
}, },
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"exclude": ["test/**"] "exclude": ["src/**/__tests__/**"]
} }

View File

@@ -9,5 +9,5 @@
}, },
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo" "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(); const isWithinUsersLimit = this.license.isWithinUsersLimit();
if ( if (
config.getEnv('userManagement.isInstanceOwnerSetUp') && config.getEnv('userManagement.isInstanceOwnerSetUp') &&
!user.isOwner && user.role !== 'global:owner' &&
!isWithinUsersLimit !isWithinUsersLimit
) { ) {
throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);

View File

@@ -4,6 +4,7 @@ import {
ResolvePasswordTokenQueryDto, ResolvePasswordTokenQueryDto,
} from '@n8n/api-types'; } from '@n8n/api-types';
import { Body, Get, Post, Query, RestController } from '@n8n/decorators'; import { Body, Get, Post, Query, RestController } from '@n8n/decorators';
import { hasGlobalScope } from '@n8n/permissions';
import { Response } from 'express'; import { Response } from 'express';
import { Logger } from 'n8n-core'; 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 // User should just be able to reset password if one is already present
const user = await this.userRepository.findNonShellUser(email); 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( this.logger.debug(
'Request to send password reset email failed because the user limit was reached', 'Request to send password reset email failed because the user limit was reached',
); );
throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
} }
if ( if (
isSamlCurrentAuthenticationMethod() && isSamlCurrentAuthenticationMethod() &&
!( !(
user?.hasGlobalScope('user:resetPassword') === true || user &&
user?.settings?.allowSSOManualLogin === true (hasGlobalScope(user, 'user:resetPassword') || user.settings?.allowSSOManualLogin === true)
) )
) { ) {
this.logger.debug( this.logger.debug(
@@ -84,8 +90,8 @@ export class PasswordResetController {
); );
} }
const ldapIdentity = user?.authIdentities?.find((i) => i.providerType === 'ldap'); const ldapIdentity = user.authIdentities?.find((i) => i.providerType === 'ldap');
if (!user?.password || (ldapIdentity && user.disabled)) { if (!user.password || (ldapIdentity && user.disabled)) {
this.logger.debug( this.logger.debug(
'Request to send password reset email failed because no user was found for the provided email', 'Request to send password reset email failed because no user was found for the provided email',
{ invalidEmail: email }, { invalidEmail: email },
@@ -140,7 +146,7 @@ export class PasswordResetController {
const user = await this.authService.resolvePasswordResetToken(token); const user = await this.authService.resolvePasswordResetToken(token);
if (!user) throw new NotFoundError(''); if (!user) throw new NotFoundError('');
if (!user?.isOwner && !this.license.isWithinUsersLimit()) { if (user.role !== 'global:owner' && !this.license.isWithinUsersLimit()) {
this.logger.debug( this.logger.debug(
'Request to resolve password token failed because the user limit was reached', 'Request to resolve password token failed because the user limit was reached',
{ userId: user.id }, { userId: user.id },

View File

@@ -13,7 +13,7 @@ import {
Param, Param,
Query, Query,
} from '@n8n/decorators'; } from '@n8n/decorators';
import { combineScopes } from '@n8n/permissions'; import { combineScopes, getRoleScopes, hasGlobalScope } from '@n8n/permissions';
import type { Scope } from '@n8n/permissions'; import type { Scope } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In, Not } from '@n8n/typeorm'; import { In, Not } from '@n8n/typeorm';
@@ -30,13 +30,11 @@ import {
TeamProjectOverQuotaError, TeamProjectOverQuotaError,
UnlicensedProjectRoleError, UnlicensedProjectRoleError,
} from '@/services/project.service.ee'; } from '@/services/project.service.ee';
import { RoleService } from '@/services/role.service';
@RestController('/projects') @RestController('/projects')
export class ProjectController { export class ProjectController {
constructor( constructor(
private readonly projectsService: ProjectService, private readonly projectsService: ProjectService,
private readonly roleService: RoleService,
private readonly projectRepository: ProjectRepository, private readonly projectRepository: ProjectRepository,
private readonly eventService: EventService, private readonly eventService: EventService,
) {} ) {}
@@ -69,8 +67,8 @@ export class ProjectController {
role: 'project:admin', role: 'project:admin',
scopes: [ scopes: [
...combineScopes({ ...combineScopes({
global: this.roleService.getRoleScopes(req.user.role), global: getRoleScopes(req.user.role),
project: this.roleService.getRoleScopes('project:admin'), project: getRoleScopes('project:admin'),
}), }),
], ],
}; };
@@ -88,7 +86,7 @@ export class ProjectController {
_res: Response, _res: Response,
): Promise<ProjectRequest.GetMyProjectsResponse> { ): Promise<ProjectRequest.GetMyProjectsResponse> {
const relations = await this.projectsService.getProjectRelationsForUser(req.user); 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({ ? await this.projectRepository.findBy({
type: 'team', type: 'team',
id: Not(In(relations.map((pr) => pr.projectId))), id: Not(In(relations.map((pr) => pr.projectId))),
@@ -106,8 +104,8 @@ export class ProjectController {
if (result.scopes) { if (result.scopes) {
result.scopes.push( result.scopes.push(
...combineScopes({ ...combineScopes({
global: this.roleService.getRoleScopes(req.user.role), global: getRoleScopes(req.user.role),
project: this.roleService.getRoleScopes(pr.role), project: getRoleScopes(pr.role),
}), }),
); );
} }
@@ -128,9 +126,7 @@ export class ProjectController {
); );
if (result.scopes) { if (result.scopes) {
result.scopes.push( result.scopes.push(...combineScopes({ global: getRoleScopes(req.user.role) }));
...combineScopes({ global: this.roleService.getRoleScopes(req.user.role) }),
);
} }
results.push(result); results.push(result);
@@ -154,8 +150,8 @@ export class ProjectController {
} }
const scopes: Scope[] = [ const scopes: Scope[] = [
...combineScopes({ ...combineScopes({
global: this.roleService.getRoleScopes(req.user.role), global: getRoleScopes(req.user.role),
project: this.roleService.getRoleScopes('project:personalOwner'), project: getRoleScopes('project:personalOwner'),
}), }),
]; ];
return { return {
@@ -191,8 +187,8 @@ export class ProjectController {
})), })),
scopes: [ scopes: [
...combineScopes({ ...combineScopes({
global: this.roleService.getRoleScopes(req.user.role), global: getRoleScopes(req.user.role),
...(myRelation ? { project: this.roleService.getRoleScopes(myRelation.role) } : {}), ...(myRelation ? { project: getRoleScopes(myRelation.role) } : {}),
}), }),
], ],
}; };

View File

@@ -1,23 +1,13 @@
import { Get, RestController } from '@n8n/decorators'; import { Get, RestController } from '@n8n/decorators';
import { type AllRoleTypes, RoleService } from '@/services/role.service'; import { RoleService } from '@/services/role.service';
@RestController('/roles') @RestController('/roles')
export class RoleController { export class RoleController {
constructor(private readonly roleService: RoleService) {} constructor(private readonly roleService: RoleService) {}
@Get('/') @Get('/')
async getAllRoles() { getAllRoles() {
return Object.fromEntries( return this.roleService.getAllRoles();
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),
})),
]),
);
} }
} }

View File

@@ -1,21 +1,19 @@
import type { ProjectRole } from '@n8n/api-types'; import type { CredentialsEntity, SharedCredentials, User } from '@n8n/db';
import type { CredentialsEntity, SharedCredentials, CredentialSharingRole, User } from '@n8n/db';
import { CredentialsRepository } from '@n8n/db'; import { CredentialsRepository } from '@n8n/db';
import { Service } from '@n8n/di'; 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 // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm'; import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm'; import { In } from '@n8n/typeorm';
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
import { RoleService } from '@/services/role.service';
@Service() @Service()
export class CredentialsFinderService { export class CredentialsFinderService {
constructor( constructor(
private readonly sharedCredentialsRepository: SharedCredentialsRepository, private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly roleService: RoleService,
private readonly credentialsRepository: CredentialsRepository, private readonly credentialsRepository: CredentialsRepository,
) {} ) {}
@@ -29,9 +27,9 @@ export class CredentialsFinderService {
async findCredentialsForUser(user: User, scopes: Scope[]) { async findCredentialsForUser(user: User, scopes: Scope[]) {
let where: FindOptionsWhere<CredentialsEntity> = {}; let where: FindOptionsWhere<CredentialsEntity> = {};
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) { if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes); const projectRoles = rolesWithScope('project', scopes);
const credentialRoles = this.roleService.rolesWithScope('credential', scopes); const credentialRoles = rolesWithScope('credential', scopes);
where = { where = {
...where, ...where,
shared: { shared: {
@@ -53,9 +51,9 @@ export class CredentialsFinderService {
async findCredentialForUser(credentialsId: string, user: User, scopes: Scope[]) { async findCredentialForUser(credentialsId: string, user: User, scopes: Scope[]) {
let where: FindOptionsWhere<SharedCredentials> = { credentialsId }; let where: FindOptionsWhere<SharedCredentials> = { credentialsId };
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) { if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes); const projectRoles = rolesWithScope('project', scopes);
const credentialRoles = this.roleService.rolesWithScope('credential', scopes); const credentialRoles = rolesWithScope('credential', scopes);
where = { where = {
...where, ...where,
role: In(credentialRoles), role: In(credentialRoles),
@@ -85,9 +83,9 @@ export class CredentialsFinderService {
async findAllCredentialsForUser(user: User, scopes: Scope[], trx?: EntityManager) { async findAllCredentialsForUser(user: User, scopes: Scope[], trx?: EntityManager) {
let where: FindOptionsWhere<SharedCredentials> = {}; let where: FindOptionsWhere<SharedCredentials> = {};
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) { if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes); const projectRoles = rolesWithScope('project', scopes);
const credentialRoles = this.roleService.rolesWithScope('credential', scopes); const credentialRoles = rolesWithScope('credential', scopes);
where = { where = {
role: In(credentialRoles), role: In(credentialRoles),
project: { project: {
@@ -115,13 +113,9 @@ export class CredentialsFinderService {
trx?: EntityManager, trx?: EntityManager,
) { ) {
const projectRoles = const projectRoles =
'scopes' in options 'scopes' in options ? rolesWithScope('project', options.scopes) : options.projectRoles;
? this.roleService.rolesWithScope('project', options.scopes)
: options.projectRoles;
const credentialRoles = const credentialRoles =
'scopes' in options 'scopes' in options ? rolesWithScope('credential', options.scopes) : options.credentialRoles;
? this.roleService.rolesWithScope('credential', options.scopes)
: options.credentialRoles;
const sharings = await this.sharedCredentialsRepository.findCredentialsByRoles( const sharings = await this.sharedCredentialsRepository.findCredentialsByRoles(
userIds, userIds,

View File

@@ -1,6 +1,7 @@
import { Project, SharedCredentials } from '@n8n/db'; import { Project, SharedCredentials } from '@n8n/db';
import type { CredentialsEntity, User } from '@n8n/db'; import type { CredentialsEntity, User } from '@n8n/db';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { hasGlobalScope, rolesWithScope } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In, type EntityManager } from '@n8n/typeorm'; import { In, type EntityManager } from '@n8n/typeorm';
import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; 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 { TransferCredentialError } from '@/errors/response-errors/transfer-credential.error';
import { OwnershipService } from '@/services/ownership.service'; import { OwnershipService } from '@/services/ownership.service';
import { ProjectService } from '@/services/project.service.ee'; import { ProjectService } from '@/services/project.service.ee';
import { RoleService } from '@/services/role.service';
import { CredentialsFinderService } from './credentials-finder.service'; import { CredentialsFinderService } from './credentials-finder.service';
import { CredentialsService } from './credentials.service'; import { CredentialsService } from './credentials.service';
@@ -22,7 +22,6 @@ export class EnterpriseCredentialsService {
private readonly ownershipService: OwnershipService, private readonly ownershipService: OwnershipService,
private readonly credentialsService: CredentialsService, private readonly credentialsService: CredentialsService,
private readonly projectService: ProjectService, private readonly projectService: ProjectService,
private readonly roleService: RoleService,
private readonly credentialsFinderService: CredentialsFinderService, private readonly credentialsFinderService: CredentialsFinderService,
) {} ) {}
@@ -41,12 +40,12 @@ export class EnterpriseCredentialsService {
type: 'team', type: 'team',
// if user can see all projects, don't check project access // if user can see all projects, don't check project access
// if they can't, find projects they can list // if they can't, find projects they can list
...(user.hasGlobalScope('project:list') ...(hasGlobalScope(user, 'project:list')
? {} ? {}
: { : {
projectRelations: { projectRelations: {
userId: user.id, 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 type { Project, User, ICredentialsDb, ScopesField } from '@n8n/db';
import { CredentialsEntity, SharedCredentials, CredentialsRepository } from '@n8n/db'; import { CredentialsEntity, SharedCredentials, CredentialsRepository } from '@n8n/db';
import { Service } from '@n8n/di'; 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 // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { import {
In, In,
@@ -79,7 +79,7 @@ export class CredentialsService {
onlySharedWithMe?: boolean; onlySharedWithMe?: boolean;
} = {}, } = {},
) { ) {
const returnAll = user.hasGlobalScope('credential:list'); const returnAll = hasGlobalScope(user, 'credential:list');
const isDefaultSelect = !listQueryOptions.select; const isDefaultSelect = !listQueryOptions.select;
const projectId = const projectId =
typeof listQueryOptions.filter?.projectId === 'string' 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 // 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. // project has global read permissions it can use all personal credentials.
const user = await this.userRepository.findPersonalOwnerForWorkflow(workflowId); const user = await this.userRepository.findPersonalOwnerForWorkflow(workflowId);
if (user?.hasGlobalScope('credential:read')) { if (user && hasGlobalScope(user, 'credential:read')) {
return await this.credentialsRepository.findAllPersonalCredentials(); return await this.credentialsRepository.findAllPersonalCredentials();
} }
@@ -269,7 +269,7 @@ export class CredentialsService {
// read permissions then all workflows in that project can use all // read permissions then all workflows in that project can use all
// credentials of all personal projects. // credentials of all personal projects.
const user = await this.userRepository.findPersonalOwnerForProject(projectId); const user = await this.userRepository.findPersonalOwnerForProject(projectId);
if (user?.hasGlobalScope('credential:read')) { if (user && hasGlobalScope(user, 'credential:read')) {
return await this.credentialsRepository.findAllPersonalCredentials(); return await this.credentialsRepository.findAllPersonalCredentials();
} }
@@ -289,7 +289,7 @@ export class CredentialsService {
): Promise<SharedCredentials | null> { ): Promise<SharedCredentials | null> {
let where: FindOptionsWhere<SharedCredentials> = { credentialsId: credentialId }; let where: FindOptionsWhere<SharedCredentials> = { credentialsId: credentialId };
if (!user.hasGlobalScope(globalScopes, { mode: 'allOf' })) { if (!hasGlobalScope(user, globalScopes, { mode: 'allOf' })) {
where = { where = {
...where, ...where,
role: 'credential:owner', role: 'credential:owner',

View File

@@ -1,6 +1,6 @@
import type { ProjectRole } from '@n8n/api-types';
import { generateNanoId } from '@n8n/db'; import { generateNanoId } from '@n8n/db';
import type { User } from '@n8n/db'; import type { User } from '@n8n/db';
import type { ProjectRole } from '@n8n/permissions';
import { UserError } from 'n8n-workflow'; import { UserError } from 'n8n-workflow';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';

View File

@@ -1,6 +1,6 @@
import type { ProjectRole } from '@n8n/api-types';
import { ProjectRelation } from '@n8n/db'; import { ProjectRelation } from '@n8n/db';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import type { ProjectRole } from '@n8n/permissions';
import { DataSource, In, Repository } from '@n8n/typeorm'; import { DataSource, In, Repository } from '@n8n/typeorm';
@Service() @Service()

View File

@@ -1,7 +1,7 @@
import type { ProjectRole } from '@n8n/api-types';
import { SharedCredentials } from '@n8n/db'; import { SharedCredentials } from '@n8n/db';
import type { Project, CredentialSharingRole } from '@n8n/db'; import type { Project } from '@n8n/db';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import type { CredentialSharingRole, ProjectRole } from '@n8n/permissions';
import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm'; import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm';
import { DataSource, In, Not, Repository } from '@n8n/typeorm'; import { DataSource, In, Not, Repository } from '@n8n/typeorm';

View File

@@ -1,6 +1,7 @@
import { SharedWorkflow } from '@n8n/db'; import { SharedWorkflow } from '@n8n/db';
import type { Project, WorkflowSharingRole } from '@n8n/db'; import type { Project } from '@n8n/db';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import type { WorkflowSharingRole } from '@n8n/permissions';
import { DataSource, Repository, In, Not } from '@n8n/typeorm'; import { DataSource, Repository, In, Not } from '@n8n/typeorm';
import type { EntityManager, FindManyOptions, FindOptionsWhere } 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 () => { it('should skip credential checks if the home project owner has global scope', async () => {
const projectOwner = mock<User>({ const projectOwner = mock<User>({ role: 'global:owner' });
hasGlobalScope: (scope) => scope === 'credential:list',
});
ownershipService.getPersonalProjectOwnerCached.mockResolvedValueOnce(projectOwner); ownershipService.getPersonalProjectOwnerCached.mockResolvedValueOnce(projectOwner);
await expect(permissionChecker.check(workflowId, [node])).resolves.not.toThrow(); await expect(permissionChecker.check(workflowId, [node])).resolves.not.toThrow();

View File

@@ -1,5 +1,6 @@
import type { Project } from '@n8n/db'; import type { Project } from '@n8n/db';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { hasGlobalScope } from '@n8n/permissions';
import type { INode } from 'n8n-workflow'; import type { INode } from 'n8n-workflow';
import { UserError } from 'n8n-workflow'; import { UserError } from 'n8n-workflow';
@@ -45,7 +46,11 @@ export class CredentialsPermissionChecker {
const homeProjectOwner = await this.ownershipService.getPersonalProjectOwnerCached( const homeProjectOwner = await this.ownershipService.getPersonalProjectOwnerCached(
homeProject.id, 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 // Workflow belongs to a project by a user with privileges
// so all credentials are usable. Skip credential checks. // so all credentials are usable. Skip credential checks.
return; return;

View File

@@ -1,5 +1,5 @@
import type { ICredentialsBase, IExecutionBase, IExecutionDb, ITagBase } from '@n8n/db'; 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 { Application } from 'express';
import type { import type {
ExecutionError, ExecutionError,
@@ -207,7 +207,7 @@ export interface ILicensePostResponse extends ILicenseReadResponse {
export interface Invitation { export interface Invitation {
email: string; email: string;
role: AssignableRole; role: AssignableGlobalRole;
} }
export interface N8nApp { export interface N8nApp {

View File

@@ -1,6 +1,6 @@
import type { User } from '@n8n/db'; import type { User } from '@n8n/db';
import { Container } from '@n8n/di'; 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 // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm'; import { In } from '@n8n/typeorm';
import { UnexpectedError } from 'n8n-workflow'; import { UnexpectedError } from 'n8n-workflow';
@@ -8,7 +8,6 @@ import { UnexpectedError } from 'n8n-workflow';
import { ProjectRepository } from '@/databases/repositories/project.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository';
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.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: * Check if a user has the required scopes. The check can be:
@@ -28,15 +27,14 @@ export async function userHasScopes(
projectId, projectId,
}: { credentialId?: string; workflowId?: string; projectId?: string } /* only one */, }: { credentialId?: string; workflowId?: string; projectId?: string } /* only one */,
): Promise<boolean> { ): Promise<boolean> {
if (user.hasGlobalScope(scopes, { mode: 'allOf' })) return true; if (hasGlobalScope(user, scopes, { mode: 'allOf' })) return true;
if (globalOnly) return false; if (globalOnly) return false;
// Find which project roles are defined to contain the required scopes. // Find which project roles are defined to contain the required scopes.
// Then find projects having this user and having those project roles. // Then find projects having this user and having those project roles.
const roleService = Container.get(RoleService); const projectRoles = rolesWithScope('project', scopes);
const projectRoles = roleService.rolesWithScope('project', scopes);
const userProjectIds = ( const userProjectIds = (
await Container.get(ProjectRepository).find({ await Container.get(ProjectRepository).find({
where: { where: {
@@ -57,7 +55,7 @@ export async function userHasScopes(
return await Container.get(SharedCredentialsRepository).existsBy({ return await Container.get(SharedCredentialsRepository).existsBy({
credentialsId: credentialId, credentialsId: credentialId,
projectId: In(userProjectIds), 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({ return await Container.get(SharedWorkflowRepository).existsBy({
workflowId, workflowId,
projectId: In(userProjectIds), 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 { 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 { WorkflowEntity, WorkflowTagMapping, SharedWorkflow } from '@n8n/db';
import { Container } from '@n8n/di'; 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 type { WorkflowId } from 'n8n-workflow';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; 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 { 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 express from 'express';
import type { import type {
ICredentialDataDecryptedObject, ICredentialDataDecryptedObject,
@@ -137,7 +137,7 @@ export declare namespace UserRequest {
email: string; email: string;
inviteAcceptUrl?: string; inviteAcceptUrl?: string;
emailSent: boolean; emailSent: boolean;
role: AssignableRole; role: AssignableGlobalRole;
}; };
error?: string; error?: string;
}; };

View File

@@ -43,21 +43,19 @@ describe('ActiveWorkflowsService', () => {
}); });
it('should return all workflow ids when user has full access', async () => { 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); const ids = await service.getAllActiveIdsFor(user);
expect(ids).toEqual(['2', '3', '4']); expect(ids).toEqual(['2', '3', '4']);
expect(user.hasGlobalScope).toHaveBeenCalledWith('workflow:list');
expect(sharedWorkflowRepository.getSharedWorkflowIds).not.toHaveBeenCalled(); expect(sharedWorkflowRepository.getSharedWorkflowIds).not.toHaveBeenCalled();
}); });
it('should filter out workflow ids that the user does not have access to', async () => { 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']); sharedWorkflowRepository.getSharedWorkflowIds.mockResolvedValue(['3']);
const ids = await service.getAllActiveIdsFor(user); const ids = await service.getAllActiveIdsFor(user);
expect(ids).toEqual(['3']); expect(ids).toEqual(['3']);
expect(user.hasGlobalScope).toHaveBeenCalledWith('workflow:list');
expect(sharedWorkflowRepository.getSharedWorkflowIds).toHaveBeenCalledWith(activeIds); expect(sharedWorkflowRepository.getSharedWorkflowIds).toHaveBeenCalledWith(activeIds);
}); });
}); });

View File

@@ -2,8 +2,6 @@ import { SharedCredentials } from '@n8n/db';
import type { CredentialsEntity } from '@n8n/db'; import type { CredentialsEntity } from '@n8n/db';
import type { User } from '@n8n/db'; import type { User } from '@n8n/db';
import { Container } from '@n8n/di'; 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 { In } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
@@ -19,19 +17,11 @@ describe('CredentialsFinderService', () => {
const sharedCredential = mock<SharedCredentials>(); const sharedCredential = mock<SharedCredentials>();
sharedCredential.credentials = mock<CredentialsEntity>({ id: credentialsId }); sharedCredential.credentials = mock<CredentialsEntity>({ id: credentialsId });
const owner = mock<User>({ const owner = mock<User>({
isOwner: true, role: 'global:owner',
hasGlobalScope: (scope) =>
hasScope(scope, {
global: GLOBAL_OWNER_SCOPES,
}),
}); });
const member = mock<User>({ const member = mock<User>({
isOwner: false, role: 'global:member',
id: 'test', id: 'test',
hasGlobalScope: (scope) =>
hasScope(scope, {
global: GLOBAL_MEMBER_SCOPES,
}),
}); });
beforeEach(() => { beforeEach(() => {

View File

@@ -1,5 +1,6 @@
import type { User } from '@n8n/db'; import type { User } from '@n8n/db';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { hasGlobalScope } from '@n8n/permissions';
import { Logger } from 'n8n-core'; import { Logger } from 'n8n-core';
import { ActivationErrorsService } from '@/activation-errors.service'; import { ActivationErrorsService } from '@/activation-errors.service';
@@ -28,7 +29,7 @@ export class ActiveWorkflowsService {
const activationErrors = await this.activationErrorsService.getAll(); const activationErrors = await this.activationErrorsService.getAll();
const activeWorkflowIds = await this.workflowRepository.getActiveIds(); const activeWorkflowIds = await this.workflowRepository.getActiveIds();
const hasFullAccess = user.hasGlobalScope('workflow:list'); const hasFullAccess = hasGlobalScope(user, 'workflow:list');
if (hasFullAccess) { if (hasFullAccess) {
return activeWorkflowIds.filter((workflowId) => !activationErrors[workflowId]); 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 { UNLIMITED_LICENSE_QUOTA } from '@n8n/constants';
import type { User } from '@n8n/db'; import type { User } from '@n8n/db';
import { Project, ProjectRelation } from '@n8n/db'; import { Project, ProjectRelation } from '@n8n/db';
import { Container, Service } from '@n8n/di'; 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 // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { FindOptionsWhere, EntityManager } from '@n8n/typeorm'; import type { FindOptionsWhere, EntityManager } from '@n8n/typeorm';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // 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 { License } from '@/license';
import { CacheService } from './cache/cache.service'; import { CacheService } from './cache/cache.service';
import { RoleService } from './role.service';
export class TeamProjectOverQuotaError extends UserError { export class TeamProjectOverQuotaError extends UserError {
constructor(limit: number) { constructor(limit: number) {
@@ -42,7 +41,6 @@ export class ProjectService {
private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly projectRepository: ProjectRepository, private readonly projectRepository: ProjectRepository,
private readonly projectRelationRepository: ProjectRelationRepository, private readonly projectRelationRepository: ProjectRelationRepository,
private readonly roleService: RoleService,
private readonly sharedCredentialsRepository: SharedCredentialsRepository, private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
private readonly license: License, private readonly license: License,
@@ -168,7 +166,7 @@ export class ProjectService {
async getAccessibleProjects(user: User): Promise<Project[]> { async getAccessibleProjects(user: User): Promise<Project[]> {
// This user is probably an admin, show them everything // 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.find();
} }
return await this.projectRepository.getAccessibleProjects(user.id); return await this.projectRepository.getAccessibleProjects(user.id);
@@ -234,7 +232,7 @@ export class ProjectService {
const existing = project.projectRelations.find((pr) => pr.userId === r.userId); 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 // We don't throw an error if the user already exists with that role so
// existing projects continue working as is. // 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); throw new UnlicensedProjectRoleError(r.role);
} }
} }
@@ -246,6 +244,19 @@ export class ProjectService {
await this.clearCredentialCanUseExternalSecretsCache(projectId); 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) { async clearCredentialCanUseExternalSecretsCache(projectId: string) {
const shares = await this.sharedCredentialsRepository.find({ const shares = await this.sharedCredentialsRepository.find({
where: { where: {
@@ -293,8 +304,8 @@ export class ProjectService {
id: projectId, id: projectId,
}; };
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) { if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes); const projectRoles = rolesWithScope('project', scopes);
where = { where = {
...where, ...where,

View File

@@ -1,159 +1,30 @@
import type { ProjectRole } from '@n8n/api-types';
import type { import type {
CredentialsEntity, CredentialsEntity,
CredentialSharingRole,
SharedCredentials, SharedCredentials,
SharedWorkflow, SharedWorkflow,
WorkflowSharingRole,
User, User,
ListQueryDb, ListQueryDb,
ScopesField, ScopesField,
ProjectRelation, ProjectRelation,
} from '@n8n/db'; } from '@n8n/db';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import type { GlobalRole, Resource, Scope } from '@n8n/permissions'; import type { AllRoleTypes, Scope } from '@n8n/permissions';
import { import { ALL_ROLES, combineScopes, getRoleScopes } from '@n8n/permissions';
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 { UnexpectedError } from 'n8n-workflow'; import { UnexpectedError } from 'n8n-workflow';
import { License } from '@/license'; 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() @Service()
export class RoleService { export class RoleService {
constructor(private readonly license: License) {} constructor(private readonly license: License) {}
rolesWithScope(namespace: 'global', scopes: Scope | Scope[]): GlobalRole[]; getAllRoles() {
rolesWithScope(namespace: 'project', scopes: Scope | Scope[]): ProjectRole[]; Object.values(ALL_ROLES).forEach((entries) => {
rolesWithScope(namespace: 'credential', scopes: Scope | Scope[]): CredentialSharingRole[]; entries.forEach((entry) => {
rolesWithScope(namespace: 'workflow', scopes: Scope | Scope[]): WorkflowSharingRole[]; entry.licensed = this.isRoleLicensed(entry.role);
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),
);
}); });
} return ALL_ROLES;
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());
} }
addScopes( addScopes(
@@ -192,9 +63,7 @@ export class RoleService {
| ListQueryDb.Workflow.WithScopes | ListQueryDb.Workflow.WithScopes
| ListQueryDb.Credentials.WithScopes; | ListQueryDb.Credentials.WithScopes;
Object.assign(entity, { entity.scopes = [];
scopes: [],
});
if (shared === undefined) { if (shared === undefined) {
return entity; return entity;
@@ -220,7 +89,7 @@ export class RoleService {
shared: SharedCredentials[] | SharedWorkflow[], shared: SharedCredentials[] | SharedWorkflow[],
userProjectRelations: ProjectRelation[], userProjectRelations: ProjectRelation[],
): Scope[] { ): Scope[] {
const globalScopes = this.getRoleScopes(user.role, [type]); const globalScopes = getRoleScopes(user.role, [type]);
const scopesSet: Set<Scope> = new Set(globalScopes); const scopesSet: Set<Scope> = new Set(globalScopes);
for (const sharedEntity of shared) { for (const sharedEntity of shared) {
const pr = userProjectRelations.find( const pr = userProjectRelations.find(
@@ -228,9 +97,9 @@ export class RoleService {
); );
let projectScopes: Scope[] = []; let projectScopes: Scope[] = [];
if (pr) { 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( const mergedScopes = combineScopes(
{ {
global: globalScopes, global: globalScopes,
@@ -243,7 +112,8 @@ export class RoleService {
return [...scopesSet].sort(); return [...scopesSet].sort();
} }
isRoleLicensed(role: AllRoleTypes) { private isRoleLicensed(role: AllRoleTypes) {
// TODO: move this info into FrontendSettings
switch (role) { switch (role) {
case 'project:admin': case 'project:admin':
return this.license.isProjectRoleAdminLicensed(); return this.license.isProjectRoleAdminLicensed();

View File

@@ -2,7 +2,7 @@ import type { RoleChangeRequestDto } from '@n8n/api-types';
import { User } from '@n8n/db'; import { User } from '@n8n/db';
import type { PublicUser } from '@n8n/db'; import type { PublicUser } from '@n8n/db';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import type { AssignableRole } from '@n8n/permissions'; import { getGlobalScopes, type AssignableGlobalRole } from '@n8n/permissions';
import { Logger } from 'n8n-core'; import { Logger } from 'n8n-core';
import type { IUserSettings } from 'n8n-workflow'; import type { IUserSettings } from 'n8n-workflow';
import { UnexpectedError } from 'n8n-workflow'; import { UnexpectedError } from 'n8n-workflow';
@@ -71,6 +71,7 @@ export class UserService {
let publicUser: PublicUser = { let publicUser: PublicUser = {
...rest, ...rest,
signInType: ldapIdentity ? 'ldap' : 'email', signInType: ldapIdentity ? 'ldap' : 'email',
isOwner: user.role === 'global:owner',
}; };
if (options?.withInviteUrl && !options?.inviterId) { if (options?.withInviteUrl && !options?.inviterId) {
@@ -85,8 +86,9 @@ export class UserService {
publicUser = await this.addFeatureFlags(publicUser, options.posthog); publicUser = await this.addFeatureFlags(publicUser, options.posthog);
} }
// TODO: resolve these directly in the frontend
if (options?.withScopes) { if (options?.withScopes) {
publicUser.globalScopes = user.globalScopes; publicUser.globalScopes = getGlobalScopes(user);
} }
return publicUser; return publicUser;
@@ -123,7 +125,7 @@ export class UserService {
private async sendEmails( private async sendEmails(
owner: User, owner: User,
toInviteUsers: { [key: string]: string }, toInviteUsers: { [key: string]: string },
role: AssignableRole, role: AssignableGlobalRole,
) { ) {
const domain = this.urlService.getInstanceBaseUrl(); const domain = this.urlService.getInstanceBaseUrl();

View File

@@ -1,20 +1,16 @@
import type { SharedWorkflow, User } from '@n8n/db'; import type { SharedWorkflow, User } from '@n8n/db';
import { Service } from '@n8n/di'; 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 // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm'; import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm'; import { In } from '@n8n/typeorm';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import { RoleService } from '@/services/role.service';
@Service() @Service()
export class WorkflowFinderService { export class WorkflowFinderService {
constructor( constructor(private readonly sharedWorkflowRepository: SharedWorkflowRepository) {}
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly roleService: RoleService,
) {}
async findWorkflowForUser( async findWorkflowForUser(
workflowId: string, workflowId: string,
@@ -28,9 +24,9 @@ export class WorkflowFinderService {
) { ) {
let where: FindOptionsWhere<SharedWorkflow> = {}; let where: FindOptionsWhere<SharedWorkflow> = {};
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) { if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes); const projectRoles = rolesWithScope('project', scopes);
const workflowRoles = this.roleService.rolesWithScope('workflow', scopes); const workflowRoles = rolesWithScope('workflow', scopes);
where = { where = {
role: In(workflowRoles), role: In(workflowRoles),
@@ -60,9 +56,9 @@ export class WorkflowFinderService {
async findAllWorkflowsForUser(user: User, scopes: Scope[]) { async findAllWorkflowsForUser(user: User, scopes: Scope[]) {
let where: FindOptionsWhere<SharedWorkflow> = {}; let where: FindOptionsWhere<SharedWorkflow> = {};
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) { if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes); const projectRoles = rolesWithScope('project', scopes);
const workflowRoles = this.roleService.rolesWithScope('workflow', scopes); const workflowRoles = rolesWithScope('workflow', scopes);
where = { where = {
...where, ...where,

View File

@@ -1,7 +1,12 @@
import type { ProjectRole } from '@n8n/api-types'; import type { User } from '@n8n/db';
import type { WorkflowSharingRole, User } from '@n8n/db';
import { Service } from '@n8n/di'; 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 // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm'; import { In } from '@n8n/typeorm';
@@ -33,7 +38,7 @@ export class WorkflowSharingService {
async getSharedWorkflowIds(user: User, options: ShareWorkflowOptions): Promise<string[]> { async getSharedWorkflowIds(user: User, options: ShareWorkflowOptions): Promise<string[]> {
const { projectId } = options; const { projectId } = options;
if (user.hasGlobalScope('workflow:read')) { if (hasGlobalScope(user, 'workflow:read')) {
const sharedWorkflows = await this.sharedWorkflowRepository.find({ const sharedWorkflows = await this.sharedWorkflowRepository.find({
select: ['workflowId'], select: ['workflowId'],
...(projectId && { where: { projectId } }), ...(projectId && { where: { projectId } }),
@@ -42,13 +47,9 @@ export class WorkflowSharingService {
} }
const projectRoles = const projectRoles =
'scopes' in options 'scopes' in options ? rolesWithScope('project', options.scopes) : options.projectRoles;
? this.roleService.rolesWithScope('project', options.scopes)
: options.projectRoles;
const workflowRoles = const workflowRoles =
'scopes' in options 'scopes' in options ? rolesWithScope('workflow', options.scopes) : options.workflowRoles;
? this.roleService.rolesWithScope('workflow', options.scopes)
: options.workflowRoles;
const sharedWorkflows = await this.sharedWorkflowRepository.find({ const sharedWorkflows = await this.sharedWorkflowRepository.find({
where: { where: {

View File

@@ -9,8 +9,6 @@ export function assertReturnedUserProps(user: User) {
expect(user.personalizationAnswers).toBeNull(); expect(user.personalizationAnswers).toBeNull();
expect(user.password).toBeUndefined(); expect(user.password).toBeUndefined();
expect(user.isPending).toBe(false); expect(user.isPending).toBe(false);
expect(user.globalScopes).toBeDefined();
expect(user.globalScopes).not.toHaveLength(0);
} }
export const assertStoredUserProps = (user: User) => { 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 { Project } from '@n8n/db';
import type { User } from '@n8n/db'; import type { User } from '@n8n/db';
import type { ListQueryDb } from '@n8n/db'; import type { ListQueryDb } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import type { ProjectRole } from '@n8n/permissions';
import { In } from '@n8n/typeorm'; import { In } from '@n8n/typeorm';
import config from '@/config'; import config from '@/config';

View File

@@ -1,7 +1,6 @@
import type { ProjectRole } from '@n8n/api-types';
import type { Project } from '@n8n/db'; import type { Project } from '@n8n/db';
import { Container } from '@n8n/di'; 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 { EntityNotFoundError } from '@n8n/typeorm';
import { ActiveWorkflowManager } from '@/active-workflow-manager'; 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 { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import { getWorkflowById } from '@/public-api/v1/handlers/workflows/workflows.service'; import { getWorkflowById } from '@/public-api/v1/handlers/workflows/workflows.service';
import { CacheService } from '@/services/cache/cache.service'; import { CacheService } from '@/services/cache/cache.service';
import { RoleService } from '@/services/role.service';
import { createFolder } from '@test-integration/db/folders'; import { createFolder } from '@test-integration/db/folders';
import { import {
@@ -391,7 +389,7 @@ describe('POST /projects/', () => {
await findProject(respProject.id); await findProject(respProject.id);
}).not.toThrow(); }).not.toThrow();
expect(resp.body.data.role).toBe('project:admin'); 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); expect(resp.body.data.scopes).toContain(scope);
} }
}); });

View File

@@ -1,10 +1,11 @@
import type { ProjectRole } from '@n8n/api-types'; import { getRoleScopes } from '@n8n/permissions';
import type { CredentialSharingRole } from '@n8n/db'; import type {
import type { WorkflowSharingRole } from '@n8n/db'; GlobalRole,
import { Container } from '@n8n/di'; ProjectRole,
import type { GlobalRole, Scope } from '@n8n/permissions'; CredentialSharingRole,
WorkflowSharingRole,
import { RoleService } from '@/services/role.service'; Scope,
} from '@n8n/permissions';
import { createMember } from './shared/db/users'; import { createMember } from './shared/db/users';
import type { SuperAgentTest } from './shared/types'; import type { SuperAgentTest } from './shared/types';
@@ -49,19 +50,19 @@ beforeAll(async () => {
{ {
name: 'Owner', name: 'Owner',
role: 'global:owner', role: 'global:owner',
scopes: Container.get(RoleService).getRoleScopes('global:owner'), scopes: getRoleScopes('global:owner'),
licensed: true, licensed: true,
}, },
{ {
name: 'Admin', name: 'Admin',
role: 'global:admin', role: 'global:admin',
scopes: Container.get(RoleService).getRoleScopes('global:admin'), scopes: getRoleScopes('global:admin'),
licensed: false, licensed: false,
}, },
{ {
name: 'Member', name: 'Member',
role: 'global:member', role: 'global:member',
scopes: Container.get(RoleService).getRoleScopes('global:member'), scopes: getRoleScopes('global:member'),
licensed: true, licensed: true,
}, },
]; ];
@@ -69,19 +70,19 @@ beforeAll(async () => {
{ {
name: 'Project Owner', name: 'Project Owner',
role: 'project:personalOwner', role: 'project:personalOwner',
scopes: Container.get(RoleService).getRoleScopes('project:personalOwner'), scopes: getRoleScopes('project:personalOwner'),
licensed: true, licensed: true,
}, },
{ {
name: 'Project Admin', name: 'Project Admin',
role: 'project:admin', role: 'project:admin',
scopes: Container.get(RoleService).getRoleScopes('project:admin'), scopes: getRoleScopes('project:admin'),
licensed: false, licensed: false,
}, },
{ {
name: 'Project Editor', name: 'Project Editor',
role: 'project:editor', role: 'project:editor',
scopes: Container.get(RoleService).getRoleScopes('project:editor'), scopes: getRoleScopes('project:editor'),
licensed: false, licensed: false,
}, },
]; ];
@@ -89,13 +90,13 @@ beforeAll(async () => {
{ {
name: 'Credential Owner', name: 'Credential Owner',
role: 'credential:owner', role: 'credential:owner',
scopes: Container.get(RoleService).getRoleScopes('credential:owner'), scopes: getRoleScopes('credential:owner'),
licensed: true, licensed: true,
}, },
{ {
name: 'Credential User', name: 'Credential User',
role: 'credential:user', role: 'credential:user',
scopes: Container.get(RoleService).getRoleScopes('credential:user'), scopes: getRoleScopes('credential:user'),
licensed: true, licensed: true,
}, },
]; ];
@@ -103,13 +104,13 @@ beforeAll(async () => {
{ {
name: 'Workflow Owner', name: 'Workflow Owner',
role: 'workflow:owner', role: 'workflow:owner',
scopes: Container.get(RoleService).getRoleScopes('workflow:owner'), scopes: getRoleScopes('workflow:owner'),
licensed: true, licensed: true,
}, },
{ {
name: 'Workflow Editor', name: 'Workflow Editor',
role: 'workflow:editor', role: 'workflow:editor',
scopes: Container.get(RoleService).getRoleScopes('workflow:editor'), scopes: getRoleScopes('workflow:editor'),
licensed: true, licensed: true,
}, },
]; ];

View File

@@ -1,6 +1,5 @@
import type { ProjectRole } from '@n8n/api-types';
import { Container } from '@n8n/di'; 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 { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository';
import { ProjectRepository } from '@/databases/repositories/project.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository';

View File

@@ -1,10 +1,10 @@
import type { Project } from '@n8n/db'; import type { Project } from '@n8n/db';
import type { CredentialSharingRole } from '@n8n/db';
import type { User } from '@n8n/db'; import type { User } from '@n8n/db';
import type { ICredentialsDb } from '@n8n/db'; import type { ICredentialsDb } from '@n8n/db';
import { CredentialsEntity } from '@n8n/db'; import { CredentialsEntity } from '@n8n/db';
import { CredentialsRepository } from '@n8n/db'; import { CredentialsRepository } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import type { CredentialSharingRole } from '@n8n/permissions';
import { ProjectRepository } from '@/databases/repositories/project.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository';
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.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 { Project } from '@n8n/db';
import type { User } from '@n8n/db'; import type { User } from '@n8n/db';
import type { ProjectRelation } from '@n8n/db'; import type { ProjectRelation } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import type { ProjectRole } from '@n8n/permissions';
import { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository'; import { ProjectRelationRepository } from '@/databases/repositories/project-relation.repository';
import { ProjectRepository } from '@/databases/repositories/project.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> { export async function createUser(attributes: Partial<User> = {}): Promise<User> {
const userInstance = await newUser(attributes); const userInstance = await newUser(attributes);
const { user } = await Container.get(UserRepository).createUserWithProject(userInstance); const { user } = await Container.get(UserRepository).createUserWithProject(userInstance);
user.computeIsOwner();
return user; return user;
} }

View File

@@ -1,8 +1,9 @@
import { Project } from '@n8n/db'; import { Project } from '@n8n/db';
import { User } 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 type { IWorkflowDb } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import type { WorkflowSharingRole } from '@n8n/permissions';
import type { DeepPartial } from '@n8n/typeorm'; import type { DeepPartial } from '@n8n/typeorm';
import type { IWorkflowBase } from 'n8n-workflow'; import type { IWorkflowBase } from 'n8n-workflow';
import { NodeConnectionTypes } 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 { Project } from '@n8n/db';
import type { User } from '@n8n/db'; import type { User } from '@n8n/db';
import type { WorkflowWithSharingsMetaDataAndCredentials } from '@n8n/db'; import type { WorkflowWithSharingsMetaDataAndCredentials } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import type { ProjectRole } from '@n8n/permissions';
import { ApplicationError, WorkflowActivationError, type INode } from 'n8n-workflow'; import { ApplicationError, WorkflowActivationError, type INode } from 'n8n-workflow';
import { v4 as uuid } from 'uuid'; 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 { IRestApiContext } from '@/Interface';
import type { RoleMap } from '@/types/roles.types';
import { makeRestApiRequest } from '@/utils/apiUtils'; 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'); return await makeRestApiRequest(context, 'GET', '/roles');
}; };

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AllRolesMap } from '@n8n/permissions';
import ProjectSharing from '@/components/Projects/ProjectSharing.vue'; import ProjectSharing from '@/components/Projects/ProjectSharing.vue';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper'; import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
@@ -12,7 +13,6 @@ import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store'; import { useUsersStore } from '@/stores/users.store';
import type { ProjectListItem, ProjectSharingData } from '@/types/projects.types'; import type { ProjectListItem, ProjectSharingData } from '@/types/projects.types';
import { ProjectTypes } from '@/types/projects.types'; import { ProjectTypes } from '@/types/projects.types';
import type { RoleMap } from '@/types/roles.types';
import { splitName } from '@/utils/projects.utils'; import { splitName } from '@/utils/projects.utils';
import type { EventBus } from '@n8n/utils/event-bus'; import type { EventBus } from '@n8n/utils/event-bus';
import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; 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 }) => ({ return rolesStore.processedCredentialRoles.map(({ role, scopes, licensed }) => ({
role, role,
name: credentialRoleTranslations.value[role], name: credentialRoleTranslations.value[role],

View File

@@ -1,9 +1,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { AllRolesMap } from '@n8n/permissions';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import type { ProjectListItem, ProjectSharingData } from '@/types/projects.types'; import type { ProjectListItem, ProjectSharingData } from '@/types/projects.types';
import ProjectSharingInfo from '@/components/Projects/ProjectSharingInfo.vue'; import ProjectSharingInfo from '@/components/Projects/ProjectSharingInfo.vue';
import type { RoleMap } from '@/types/roles.types';
import { sortByProperty } from '@n8n/utils/sort/sortByProperty'; import { sortByProperty } from '@n8n/utils/sort/sortByProperty';
const locale = useI18n(); const locale = useI18n();
@@ -11,7 +11,7 @@ const locale = useI18n();
type Props = { type Props = {
projects: ProjectListItem[]; projects: ProjectListItem[];
homeProject?: ProjectSharingData; homeProject?: ProjectSharingData;
roles?: RoleMap['workflow' | 'credential' | 'project']; roles?: AllRolesMap['workflow' | 'credential' | 'project'];
readonly?: boolean; readonly?: boolean;
static?: boolean; static?: boolean;
placeholder?: string; 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 { defineStore } from 'pinia';
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import * as rolesApi from '@/api/roles.api'; import * as rolesApi from '@/api/roles.api';
@@ -7,7 +7,7 @@ import { useRootStore } from './root.store';
export const useRolesStore = defineStore('roles', () => { export const useRolesStore = defineStore('roles', () => {
const rootStore = useRootStore(); const rootStore = useRootStore();
const roles = ref<RoleMap>({ const roles = ref<AllRolesMap>({
global: [], global: [],
project: [], project: [],
credential: [], credential: [],
@@ -22,7 +22,7 @@ export const useRolesStore = defineStore('roles', () => {
() => new Map(projectRoleOrder.value.map((role, idx) => [role, idx])), () => new Map(projectRoleOrder.value.map((role, idx) => [role, idx])),
); );
const processedProjectRoles = computed<RoleMap['project']>(() => const processedProjectRoles = computed<AllRolesMap['project']>(() =>
roles.value.project roles.value.project
.filter((role) => projectRoleOrderMap.value.has(role.role)) .filter((role) => projectRoleOrderMap.value.has(role.role))
.sort( .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'), 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'), 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 { IUserResponse } from '@/Interface';
import type { ProjectRole } from '@/types/roles.types';
export const ProjectTypes = { export const ProjectTypes = {
Personal: 'personal', 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> <script lang="ts" setup>
import type { ProjectRole } from '@n8n/permissions';
import { computed, ref, watch, onBeforeMount, onMounted, nextTick } from 'vue'; import { computed, ref, watch, onBeforeMount, onMounted, nextTick } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { deepCopy } from 'n8n-workflow'; import { deepCopy } from 'n8n-workflow';
@@ -13,7 +14,6 @@ import { VIEWS } from '@/constants';
import ProjectDeleteDialog from '@/components/Projects/ProjectDeleteDialog.vue'; import ProjectDeleteDialog from '@/components/Projects/ProjectDeleteDialog.vue';
import ProjectRoleUpgradeDialog from '@/components/Projects/ProjectRoleUpgradeDialog.vue'; import ProjectRoleUpgradeDialog from '@/components/Projects/ProjectRoleUpgradeDialog.vue';
import { useRolesStore } from '@/stores/roles.store'; import { useRolesStore } from '@/stores/roles.store';
import type { ProjectRole } from '@/types/roles.types';
import { useCloudPlanStore } from '@/stores/cloudPlan.store'; import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { useDocumentTitle } from '@/composables/useDocumentTitle'; 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) 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: packages/@n8n/permissions:
dependencies:
zod:
specifier: 'catalog:'
version: 3.24.1
devDependencies: devDependencies:
'@n8n/typescript-config': '@n8n/typescript-config':
specifier: workspace:* specifier: workspace:*