first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,12 @@
import { defineConfig } from 'eslint/config';
import { baseConfig } from '@n8n/eslint-config/base';
export default defineConfig(baseConfig, {
rules: {
'unicorn/filename-case': ['error', { case: 'kebabCase' }],
// TODO: Remove this
'import-x/order': 'warn',
'@typescript-eslint/naming-convention': 'warn',
},
});
+2
View File
@@ -0,0 +1,2 @@
/** @type {import('jest').Config} */
module.exports = require('../../../jest.config');
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@n8n/permissions",
"version": "0.52.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
"typecheck": "tsc --noEmit",
"build": "tsc -p tsconfig.build.json",
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint . --quiet",
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:unit": "jest",
"test:dev": "jest --watch"
},
"main": "dist/index.js",
"module": "src/index.ts",
"types": "dist/index.d.ts",
"files": [
"dist/**/*"
],
"dependencies": {
"zod": "catalog:"
},
"devDependencies": {
"@n8n/typescript-config": "workspace:*"
}
}
@@ -0,0 +1,184 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Scope Information ensure scopes are defined correctly 1`] = `
[
"aiAssistant:manage",
"aiAssistant:*",
"annotationTag:create",
"annotationTag:read",
"annotationTag:update",
"annotationTag:delete",
"annotationTag:list",
"annotationTag:*",
"auditLogs:manage",
"auditLogs:*",
"banner:dismiss",
"banner:*",
"community:register",
"community:*",
"communityPackage:install",
"communityPackage:uninstall",
"communityPackage:update",
"communityPackage:list",
"communityPackage:manage",
"communityPackage:*",
"credential:share",
"credential:unshare",
"credential:shareGlobally",
"credential:move",
"credential:create",
"credential:read",
"credential:update",
"credential:delete",
"credential:list",
"credential:*",
"externalSecretsProvider:sync",
"externalSecretsProvider:create",
"externalSecretsProvider:read",
"externalSecretsProvider:update",
"externalSecretsProvider:delete",
"externalSecretsProvider:list",
"externalSecretsProvider:*",
"externalSecret:list",
"externalSecret:*",
"eventBusDestination:test",
"eventBusDestination:create",
"eventBusDestination:read",
"eventBusDestination:update",
"eventBusDestination:delete",
"eventBusDestination:list",
"eventBusDestination:*",
"ldap:sync",
"ldap:manage",
"ldap:*",
"license:manage",
"license:*",
"logStreaming:manage",
"logStreaming:*",
"orchestration:read",
"orchestration:list",
"orchestration:*",
"project:create",
"project:read",
"project:update",
"project:delete",
"project:list",
"project:*",
"saml:manage",
"saml:*",
"securityAudit:generate",
"securityAudit:*",
"securitySettings:manage",
"securitySettings:*",
"sourceControl:pull",
"sourceControl:push",
"sourceControl:manage",
"sourceControl:*",
"tag:create",
"tag:read",
"tag:update",
"tag:delete",
"tag:list",
"tag:*",
"user:resetPassword",
"user:changeRole",
"user:enforceMfa",
"user:generateInviteLink",
"user:create",
"user:read",
"user:update",
"user:delete",
"user:list",
"user:*",
"variable:create",
"variable:read",
"variable:update",
"variable:delete",
"variable:list",
"variable:*",
"projectVariable:create",
"projectVariable:read",
"projectVariable:update",
"projectVariable:delete",
"projectVariable:list",
"projectVariable:*",
"workersView:manage",
"workersView:*",
"workflow:share",
"workflow:unshare",
"workflow:execute",
"workflow:execute-chat",
"workflow:move",
"workflow:activate",
"workflow:deactivate",
"workflow:publish",
"workflow:unpublish",
"workflow:updateRedactionSetting",
"workflow:create",
"workflow:read",
"workflow:update",
"workflow:delete",
"workflow:list",
"workflow:*",
"folder:create",
"folder:read",
"folder:update",
"folder:delete",
"folder:list",
"folder:move",
"folder:*",
"insights:list",
"insights:*",
"oidc:manage",
"oidc:*",
"provisioning:manage",
"provisioning:*",
"dataTable:create",
"dataTable:read",
"dataTable:update",
"dataTable:delete",
"dataTable:list",
"dataTable:readRow",
"dataTable:writeRow",
"dataTable:listProject",
"dataTable:*",
"execution:delete",
"execution:read",
"execution:retry",
"execution:list",
"execution:get",
"execution:reveal",
"execution:*",
"workflowTags:update",
"workflowTags:list",
"workflowTags:*",
"role:manage",
"role:*",
"mcp:manage",
"mcp:oauth",
"mcp:*",
"mcpApiKey:create",
"mcpApiKey:rotate",
"mcpApiKey:*",
"chatHub:manage",
"chatHub:message",
"chatHub:*",
"chatHubAgent:create",
"chatHubAgent:read",
"chatHubAgent:update",
"chatHubAgent:delete",
"chatHubAgent:list",
"chatHubAgent:*",
"breakingChanges:list",
"breakingChanges:*",
"apiKey:manage",
"apiKey:*",
"credentialResolver:create",
"credentialResolver:read",
"credentialResolver:update",
"credentialResolver:delete",
"credentialResolver:list",
"credentialResolver:*",
"*",
]
`;
@@ -0,0 +1,129 @@
import {
PROJECT_ADMIN_ROLE_SLUG,
PROJECT_EDITOR_ROLE_SLUG,
PROJECT_OWNER_ROLE_SLUG,
PROJECT_VIEWER_ROLE_SLUG,
} from '@/constants.ee';
import {
roleNamespaceSchema,
globalRoleSchema,
assignableGlobalRoleSchema,
systemProjectRoleSchema,
credentialSharingRoleSchema,
workflowSharingRoleSchema,
customProjectRoleSchema,
} 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: 'valid role: global:chatUser', value: 'global:chatUser', 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: 'valid role: global:chatUser', value: 'global:chatUser', expected: true },
{ name: 'object value', value: {}, expected: false },
])('should validate $name', ({ value, expected }) => {
const result = assignableGlobalRoleSchema.safeParse(value);
expect(result.success).toBe(expected);
});
});
describe('systemProjectRoleSchema', () => {
test.each([
{
name: `valid role: ${PROJECT_OWNER_ROLE_SLUG}`,
value: PROJECT_OWNER_ROLE_SLUG,
expected: true,
},
{
name: `valid role: ${PROJECT_ADMIN_ROLE_SLUG}`,
value: PROJECT_ADMIN_ROLE_SLUG,
expected: true,
},
{
name: `valid role: ${PROJECT_EDITOR_ROLE_SLUG}`,
value: PROJECT_EDITOR_ROLE_SLUG,
expected: true,
},
{
name: `valid role: ${PROJECT_VIEWER_ROLE_SLUG}`,
value: PROJECT_VIEWER_ROLE_SLUG,
expected: true,
},
{ name: 'invalid role', value: 'invalid-role', expected: false },
])('should validate $name', ({ value, expected }) => {
const result = systemProjectRoleSchema.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);
});
});
describe('customProjectRoleSchema', () => {
test.each([
{ name: 'valid role: custom:role', value: 'custom:role', expected: true },
{ name: 'undefined value', value: undefined, expected: false },
{ name: 'empty string', value: '', expected: false },
{ name: 'system role', value: PROJECT_ADMIN_ROLE_SLUG, expected: false },
])('should validate $name', ({ value, expected }) => {
const result = customProjectRoleSchema.safeParse(value);
expect(result.success).toBe(expected);
});
});
@@ -0,0 +1,7 @@
import { ALL_SCOPES } from '@/scope-information';
describe('Scope Information', () => {
it('ensure scopes are defined correctly', () => {
expect(ALL_SCOPES).toMatchSnapshot();
});
});
@@ -0,0 +1,116 @@
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',
'execution:reveal',
'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();
});
});
@@ -0,0 +1,92 @@
export const DEFAULT_OPERATIONS = ['create', 'read', 'update', 'delete', 'list'] as const;
export const RESOURCES = {
aiAssistant: ['manage'] as const,
annotationTag: [...DEFAULT_OPERATIONS] as const,
auditLogs: ['manage'] as const,
banner: ['dismiss'] as const,
community: ['register'] as const,
communityPackage: ['install', 'uninstall', 'update', 'list', 'manage'] as const,
credential: ['share', 'unshare', 'shareGlobally', 'move', ...DEFAULT_OPERATIONS] as const,
externalSecretsProvider: ['sync', ...DEFAULT_OPERATIONS] as const,
externalSecret: ['list'] as const,
eventBusDestination: ['test', ...DEFAULT_OPERATIONS] as const,
ldap: ['sync', 'manage'] as const,
license: ['manage'] as const,
logStreaming: ['manage'] as const,
orchestration: ['read', 'list'] as const,
project: [...DEFAULT_OPERATIONS] as const,
saml: ['manage'] as const,
securityAudit: ['generate'] as const,
securitySettings: ['manage'] as const,
sourceControl: ['pull', 'push', 'manage'] as const,
tag: [...DEFAULT_OPERATIONS] as const,
user: [
'resetPassword',
'changeRole',
'enforceMfa',
'generateInviteLink',
...DEFAULT_OPERATIONS,
] as const,
variable: [...DEFAULT_OPERATIONS] as const,
projectVariable: [...DEFAULT_OPERATIONS] as const,
workersView: ['manage'] as const,
workflow: [
'share',
'unshare',
'execute',
'execute-chat',
'move',
'activate',
'deactivate',
'publish',
'unpublish',
'updateRedactionSetting',
...DEFAULT_OPERATIONS,
] as const,
folder: [...DEFAULT_OPERATIONS, 'move'] as const,
insights: ['list'] as const,
oidc: ['manage'] as const,
provisioning: ['manage'] as const,
dataTable: [...DEFAULT_OPERATIONS, 'readRow', 'writeRow', 'listProject'] as const,
execution: ['delete', 'read', 'retry', 'list', 'get', 'reveal'] as const,
workflowTags: ['update', 'list'] as const,
role: ['manage'] as const,
mcp: ['manage', 'oauth'] as const,
mcpApiKey: ['create', 'rotate'] as const,
chatHub: ['manage', 'message'] as const,
chatHubAgent: [...DEFAULT_OPERATIONS] as const,
breakingChanges: ['list'] as const,
apiKey: ['manage'] as const,
credentialResolver: [...DEFAULT_OPERATIONS] as const,
} as const;
export const API_KEY_RESOURCES = {
tag: [...DEFAULT_OPERATIONS] as const,
workflow: [...DEFAULT_OPERATIONS, 'move', 'activate', 'deactivate'] as const,
variable: ['create', 'update', 'delete', 'list'] as const,
securityAudit: ['generate'] as const,
project: ['create', 'update', 'delete', 'list'] as const,
user: ['read', 'list', 'create', 'changeRole', 'delete', 'enforceMfa'] as const,
execution: ['delete', 'read', 'retry', 'list', 'get', 'stop'] as const,
credential: ['create', 'update', 'move', 'delete', 'list'] as const,
sourceControl: ['pull'] as const,
workflowTags: ['update', 'list'] as const,
executionTags: ['update', 'list'] as const,
dataTable: ['create', 'read', 'update', 'delete', 'list'] as const,
dataTableRow: ['create', 'read', 'update', 'delete', 'upsert'] as const,
} as const;
export const PROJECT_OWNER_ROLE_SLUG = 'project:personalOwner';
export const PROJECT_ADMIN_ROLE_SLUG = 'project:admin';
export const PROJECT_EDITOR_ROLE_SLUG = 'project:editor';
export const PROJECT_VIEWER_ROLE_SLUG = 'project:viewer';
export const PROJECT_CHAT_USER_ROLE_SLUG = 'project:chatUser';
export const PERSONAL_SPACE_PUBLISHING_SETTING = {
key: 'security.personalSpacePublishing',
scopes: ['workflow:publish'],
};
export const PERSONAL_SPACE_SHARING_SETTING = {
key: 'security.personalSpaceSharing',
scopes: ['workflow:share', 'credential:share'],
};
+28
View File
@@ -0,0 +1,28 @@
export * from './types.ee';
export * from './constants.ee';
export * from './roles/scopes/global-scopes.ee';
export * from './scope-information';
export * from './roles/role-maps.ee';
export * from './roles/all-roles';
export {
systemProjectRoleSchema,
assignableProjectRoleSchema,
assignableGlobalRoleSchema,
projectRoleSchema,
teamRoleSchema,
roleSchema,
type Role,
scopeSchema,
} from './schemas.ee';
export { hasScope } from './utilities/has-scope.ee';
export { hasGlobalScope } from './utilities/has-global-scope.ee';
export { combineScopes } from './utilities/combine-scopes.ee';
export { staticRolesWithScope } from './utilities/static-roles-with-scope.ee';
export { getGlobalScopes } from './utilities/get-global-scopes.ee';
export { getRoleScopes, getAuthPrincipalScopes } from './utilities/get-role-scopes.ee';
export { getResourcePermissions } from './utilities/get-resource-permissions.ee';
export type { PermissionsRecord } from './utilities/get-resource-permissions.ee';
export * from './public-api-permissions.ee';
@@ -0,0 +1,173 @@
import {
isApiKeyScope,
type ApiKeyScope,
type AuthPrincipal,
type GlobalRole,
type Scope,
} from './types.ee';
export const OWNER_API_KEY_SCOPES: ApiKeyScope[] = [
'user:read',
'user:list',
'user:create',
'user:changeRole',
'user:delete',
'user:enforceMfa',
'sourceControl:pull',
'securityAudit:generate',
'project:create',
'project:update',
'project:delete',
'project:list',
'variable:create',
'variable:delete',
'variable:list',
'variable:update',
'tag:create',
'tag:read',
'tag:update',
'tag:delete',
'tag:list',
'workflowTags:update',
'workflowTags:list',
'executionTags:update',
'executionTags:list',
'workflow:create',
'workflow:read',
'workflow:update',
'workflow:delete',
'workflow:list',
'workflow:move',
'workflow:activate',
'workflow:deactivate',
'execution:delete',
'execution:read',
'execution:retry',
'execution:stop',
'execution:list',
'credential:create',
'credential:update',
'credential:move',
'credential:delete',
'credential:list',
'dataTable:create',
'dataTable:read',
'dataTable:update',
'dataTable:delete',
'dataTable:list',
'dataTableRow:create',
'dataTableRow:read',
'dataTableRow:update',
'dataTableRow:delete',
'dataTableRow:upsert',
];
export const ADMIN_API_KEY_SCOPES: ApiKeyScope[] = OWNER_API_KEY_SCOPES;
export const MEMBER_API_KEY_SCOPES: ApiKeyScope[] = [
'tag:create',
'tag:read',
'tag:update',
'tag:list',
'workflowTags:update',
'workflowTags:list',
'executionTags:update',
'executionTags:list',
'workflow:create',
'workflow:read',
'workflow:update',
'workflow:delete',
'workflow:list',
'workflow:move',
'workflow:activate',
'workflow:deactivate',
'execution:delete',
'execution:read',
'execution:retry',
'execution:stop',
'execution:list',
'credential:create',
'credential:update',
'credential:move',
'credential:delete',
'dataTable:create',
'dataTable:read',
'dataTable:update',
'dataTable:delete',
'dataTable:list',
'dataTableRow:create',
'dataTableRow:read',
'dataTableRow:update',
'dataTableRow:delete',
'dataTableRow:upsert',
];
export const CHAT_USER_API_KEY_SCOPES: ApiKeyScope[] = [];
/**
* This is a bit of a mess, because we are handing out scopes in API keys that are only
* valid for the personal project, which is enforced in the public API, because the workflows,
* execution endpoints are limited to the personal project.
* This is a temporary solution until we have a better way to handle personal projects and API key scopes!
*/
export const API_KEY_SCOPES_FOR_IMPLICIT_PERSONAL_PROJECT: ApiKeyScope[] = [
'workflowTags:update',
'workflowTags:list',
'executionTags:update',
'executionTags:list',
'workflow:create',
'workflow:read',
'workflow:update',
'workflow:delete',
'workflow:list',
'workflow:move',
'workflow:activate',
'workflow:deactivate',
'execution:delete',
'execution:read',
'execution:retry',
'execution:stop',
'execution:list',
'credential:create',
'credential:update',
'credential:move',
'credential:delete',
'dataTable:create',
'dataTable:read',
'dataTable:update',
'dataTable:delete',
'dataTable:list',
'dataTableRow:create',
'dataTableRow:read',
'dataTableRow:update',
'dataTableRow:delete',
'dataTableRow:upsert',
];
const MAP_ROLE_SCOPES: Record<GlobalRole, ApiKeyScope[]> = {
'global:owner': OWNER_API_KEY_SCOPES,
'global:admin': ADMIN_API_KEY_SCOPES,
'global:member': MEMBER_API_KEY_SCOPES,
'global:chatUser': CHAT_USER_API_KEY_SCOPES,
};
export const getApiKeyScopesForRole = (user: AuthPrincipal) => {
if (user.role.slug === 'global:chatUser') {
return [];
}
return [
...new Set(
(user.role.scopes.map((scope) => scope.slug) as Array<Scope | ApiKeyScope>)
.concat(API_KEY_SCOPES_FOR_IMPLICIT_PERSONAL_PROJECT)
.filter(isApiKeyScope),
),
];
};
export const getOwnerOnlyApiKeyScopes = () => {
const ownerScopes = new Set<ApiKeyScope>(MAP_ROLE_SCOPES['global:owner']);
const memberScopes = new Set<ApiKeyScope>(MAP_ROLE_SCOPES['global:member']);
memberScopes.forEach((item) => ownerScopes.delete(item));
return Array.from(ownerScopes);
};
@@ -0,0 +1,75 @@
/* eslint-disable @typescript-eslint/naming-convention */
import {
PROJECT_ADMIN_ROLE_SLUG,
PROJECT_EDITOR_ROLE_SLUG,
PROJECT_OWNER_ROLE_SLUG,
PROJECT_VIEWER_ROLE_SLUG,
PROJECT_CHAT_USER_ROLE_SLUG,
} from '../constants.ee';
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/get-role-scopes.ee';
const ROLE_NAMES: Record<AllRoleTypes, string> = {
'global:owner': 'Owner',
'global:admin': 'Admin',
'global:member': 'Member',
'global:chatUser': 'Chat User',
[PROJECT_OWNER_ROLE_SLUG]: 'Project Owner',
[PROJECT_ADMIN_ROLE_SLUG]: 'Project Admin',
[PROJECT_EDITOR_ROLE_SLUG]: 'Project Editor',
[PROJECT_VIEWER_ROLE_SLUG]: 'Project Viewer',
[PROJECT_CHAT_USER_ROLE_SLUG]: 'Project Chat User',
'credential:user': 'Credential User',
'credential:owner': 'Credential Owner',
'workflow:owner': 'Workflow Owner',
'workflow:editor': 'Workflow Editor',
};
const ROLE_DESCRIPTIONS: Record<AllRoleTypes, string> = {
'global:owner': 'Owner',
'global:admin': 'Admin',
'global:member': 'Member',
'global:chatUser': 'Chat User',
[PROJECT_OWNER_ROLE_SLUG]: 'Project Owner',
[PROJECT_ADMIN_ROLE_SLUG]:
'Full control of settings, members, workflows, credentials and executions',
[PROJECT_EDITOR_ROLE_SLUG]: 'Create, edit, and delete workflows, credentials, and executions',
[PROJECT_VIEWER_ROLE_SLUG]: 'Read-only access to workflows, credentials, and executions',
[PROJECT_CHAT_USER_ROLE_SLUG]:
'Chat-only access to chatting with workflows that have n8n Chat enabled',
'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[]>,
roleType: 'global' | 'project' | 'credential' | 'workflow',
) =>
(Object.keys(roles) as T[]).map((role) => ({
slug: role,
displayName: ROLE_NAMES[role],
scopes: getRoleScopes(role),
description: ROLE_DESCRIPTIONS[role],
licensed: false,
systemRole: true,
roleType,
}));
export const ALL_ROLES: AllRolesMap = Object.freeze({
global: mapToRoleObject(GLOBAL_SCOPE_MAP, 'global'),
project: mapToRoleObject(PROJECT_SCOPE_MAP, 'project'),
credential: mapToRoleObject(CREDENTIALS_SHARING_SCOPE_MAP, 'credential'),
workflow: mapToRoleObject(WORKFLOW_SHARING_SCOPE_MAP, 'workflow'),
});
export const isBuiltInRole = (role: string): role is AllRoleTypes => {
return Object.prototype.hasOwnProperty.call(ROLE_NAMES, role);
};
@@ -0,0 +1,61 @@
/* eslint-disable @typescript-eslint/naming-convention */
import type {
CredentialSharingRole,
GlobalRole,
ProjectRole,
Scope,
WorkflowSharingRole,
} from '../types.ee';
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,
GLOBAL_CHAT_USER_SCOPES,
} from './scopes/global-scopes.ee';
import {
REGULAR_PROJECT_ADMIN_SCOPES,
PERSONAL_PROJECT_OWNER_SCOPES,
PROJECT_EDITOR_SCOPES,
PROJECT_VIEWER_SCOPES,
PROJECT_CHAT_USER_SCOPES,
} from './scopes/project-scopes.ee';
import {
WORKFLOW_SHARING_OWNER_SCOPES,
WORKFLOW_SHARING_EDITOR_SCOPES,
} from './scopes/workflow-sharing-scopes.ee';
export const GLOBAL_SCOPE_MAP: Record<GlobalRole, Scope[]> = {
'global:owner': GLOBAL_OWNER_SCOPES,
'global:admin': GLOBAL_ADMIN_SCOPES,
'global:member': GLOBAL_MEMBER_SCOPES,
'global:chatUser': GLOBAL_CHAT_USER_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,
'project:chatUser': PROJECT_CHAT_USER_SCOPES,
};
export const CREDENTIALS_SHARING_SCOPE_MAP: Record<CredentialSharingRole, Scope[]> = {
'credential:owner': CREDENTIALS_SHARING_OWNER_SCOPES,
'credential:user': CREDENTIALS_SHARING_USER_SCOPES,
};
export const WORKFLOW_SHARING_SCOPE_MAP: Record<WorkflowSharingRole, Scope[]> = {
'workflow:owner': WORKFLOW_SHARING_OWNER_SCOPES,
'workflow:editor': WORKFLOW_SHARING_EDITOR_SCOPES,
};
export const ALL_ROLE_MAPS = {
global: GLOBAL_SCOPE_MAP,
project: PROJECT_SCOPE_MAP,
credential: CREDENTIALS_SHARING_SCOPE_MAP,
workflow: WORKFLOW_SHARING_SCOPE_MAP,
} as const;
@@ -0,0 +1,12 @@
import type { Scope } from '../../types.ee';
export const CREDENTIALS_SHARING_OWNER_SCOPES: Scope[] = [
'credential:read',
'credential:update',
'credential:delete',
'credential:share',
'credential:unshare',
'credential:move',
];
export const CREDENTIALS_SHARING_USER_SCOPES: Scope[] = ['credential:read'];
@@ -0,0 +1,168 @@
import type { Scope } from '../../types.ee';
export const GLOBAL_OWNER_SCOPES: Scope[] = [
'aiAssistant:manage',
'annotationTag:create',
'annotationTag:read',
'annotationTag:update',
'annotationTag:delete',
'annotationTag:list',
'auditLogs:manage',
'banner:dismiss',
'credential:create',
'credential:read',
'credential:update',
'credential:delete',
'credential:list',
'credential:share',
'credential:unshare',
'credential:shareGlobally',
'credential:move',
'community:register',
'communityPackage:install',
'communityPackage:uninstall',
'communityPackage:update',
'communityPackage:list',
'eventBusDestination:create',
'eventBusDestination:read',
'eventBusDestination:update',
'eventBusDestination:delete',
'eventBusDestination:list',
'eventBusDestination:test',
'externalSecretsProvider:create',
'externalSecretsProvider:read',
'externalSecretsProvider:update',
'externalSecretsProvider:delete',
'externalSecretsProvider:list',
'externalSecretsProvider:sync',
'externalSecret:list',
'ldap:manage',
'ldap:sync',
'license:manage',
'logStreaming:manage',
'orchestration:read',
'saml:manage',
'securityAudit:generate',
'securitySettings:manage',
'sourceControl:pull',
'sourceControl:push',
'sourceControl:manage',
'tag:create',
'tag:read',
'tag:update',
'tag:delete',
'tag:list',
'user:create',
'user:read',
'user:update',
'user:delete',
'user:list',
'user:resetPassword',
'user:generateInviteLink',
'user:changeRole',
'user:enforceMfa',
'variable:create',
'variable:read',
'variable:update',
'variable:delete',
'variable:list',
'projectVariable:create',
'projectVariable:read',
'projectVariable:update',
'projectVariable:delete',
'projectVariable:list',
'workflow:create',
'workflow:read',
'workflow:update',
'workflow:publish',
'workflow:delete',
'workflow:list',
'workflow:share',
'workflow:unshare',
'workflow:execute',
'workflow:execute-chat',
'workflow:move',
'workflow:updateRedactionSetting',
'workersView:manage',
'project:list',
'project:create',
'project:read',
'project:update',
'project:delete',
'insights:list',
'folder:move',
'folder:read',
'folder:update',
'folder:delete',
'folder:create',
'folder:list',
'oidc:manage',
'provisioning:manage',
'dataTable:create',
'dataTable:delete',
'dataTable:read',
'dataTable:update',
'dataTable:list',
'dataTable:listProject',
'dataTable:readRow',
'dataTable:writeRow',
'role:manage',
'mcp:manage',
'mcp:oauth',
'mcpApiKey:create',
'mcpApiKey:rotate',
'chatHub:manage',
'chatHub:message',
'chatHubAgent:create',
'chatHubAgent:read',
'chatHubAgent:update',
'chatHubAgent:delete',
'chatHubAgent:list',
'breakingChanges:list',
'execution:reveal',
'apiKey:manage',
'credentialResolver:create',
'credentialResolver:read',
'credentialResolver:update',
'credentialResolver:delete',
'credentialResolver:list',
];
export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat();
export const GLOBAL_MEMBER_SCOPES: Scope[] = [
'annotationTag:create',
'annotationTag:read',
'annotationTag:update',
'annotationTag:delete',
'annotationTag:list',
'eventBusDestination:list',
'eventBusDestination:test',
'tag:create',
'tag:read',
'tag:update',
'tag:list',
'user:list',
'variable:list',
'variable:read',
'dataTable:list',
'mcp:oauth',
'mcpApiKey:create',
'mcpApiKey:rotate',
'chatHub:message',
'chatHubAgent:create',
'chatHubAgent:read',
'chatHubAgent:update',
'chatHubAgent:delete',
'chatHubAgent:list',
'apiKey:manage',
];
export const GLOBAL_CHAT_USER_SCOPES: Scope[] = [
'chatHub:message',
'chatHubAgent:create',
'chatHubAgent:read',
'chatHubAgent:update',
'chatHubAgent:delete',
'chatHubAgent:list',
];
@@ -0,0 +1,145 @@
import type { Scope } from '../../types.ee';
/**
* Diff between admin in personal project and admin in other projects:
* - You cannot rename your personal project.
* - You cannot invite people to your personal project.
*/
export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [
'workflow:create',
'workflow:read',
'workflow:update',
'workflow:publish',
'workflow:unpublish',
'workflow:delete',
'workflow:list',
'workflow:execute',
'workflow:execute-chat',
'workflow:move',
'workflow:updateRedactionSetting',
'credential:create',
'credential:read',
'credential:update',
'credential:delete',
'credential:list',
'credential:move',
'credential:share',
'credential:unshare',
'execution:reveal',
'project:list',
'project:read',
'project:update',
'project:delete',
'folder:create',
'folder:read',
'folder:update',
'folder:delete',
'folder:list',
'folder:move',
'sourceControl:push',
'dataTable:create',
'dataTable:delete',
'dataTable:read',
'dataTable:update',
'dataTable:listProject',
'dataTable:readRow',
'dataTable:writeRow',
'projectVariable:list',
'projectVariable:read',
'projectVariable:create',
'projectVariable:update',
'projectVariable:delete',
];
export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [
'workflow:create',
'workflow:read',
'workflow:update',
'workflow:delete',
'workflow:list',
'workflow:execute',
'workflow:execute-chat',
'workflow:move',
'workflow:unpublish',
'workflow:unshare',
'workflow:updateRedactionSetting',
'credential:create',
'credential:read',
'credential:update',
'credential:delete',
'credential:list',
'credential:move',
'credential:unshare',
'execution:reveal',
'project:list',
'project:read',
'folder:create',
'folder:read',
'folder:update',
'folder:delete',
'folder:list',
'folder:move',
'dataTable:create',
'dataTable:delete',
'dataTable:read',
'dataTable:update',
'dataTable:listProject',
'dataTable:readRow',
'dataTable:writeRow',
];
export const PROJECT_EDITOR_SCOPES: Scope[] = [
'workflow:create',
'workflow:read',
'workflow:update',
'workflow:publish',
'workflow:unpublish',
'workflow:delete',
'workflow:list',
'workflow:execute',
'workflow:execute-chat',
'credential:create',
'credential:read',
'credential:update',
'credential:delete',
'credential:list',
'project:list',
'project:read',
'folder:create',
'folder:read',
'folder:update',
'folder:delete',
'folder:list',
'dataTable:create',
'dataTable:delete',
'dataTable:read',
'dataTable:update',
'dataTable:listProject',
'dataTable:readRow',
'dataTable:writeRow',
'projectVariable:list',
'projectVariable:read',
'projectVariable:create',
'projectVariable:update',
'projectVariable:delete',
];
export const PROJECT_VIEWER_SCOPES: Scope[] = [
'credential:list',
'credential:read',
'project:list',
'project:read',
'workflow:list',
'workflow:read',
'workflow:execute-chat',
'folder:read',
'folder:list',
'dataTable:listProject',
'dataTable:read',
'dataTable:readRow',
'projectVariable:list',
'projectVariable:read',
];
export const PROJECT_CHAT_USER_SCOPES: Scope[] = ['workflow:execute-chat'];
@@ -0,0 +1,23 @@
import type { Scope } from '../../types.ee';
export const WORKFLOW_SHARING_OWNER_SCOPES: Scope[] = [
'workflow:read',
'workflow:update',
'workflow:publish',
'workflow:unpublish',
'workflow:delete',
'workflow:execute',
'workflow:share',
'workflow:unshare',
'workflow:move',
'workflow:execute-chat',
];
export const WORKFLOW_SHARING_EDITOR_SCOPES: Scope[] = [
'workflow:read',
'workflow:update',
'workflow:publish',
'workflow:unpublish',
'workflow:execute',
'workflow:execute-chat',
];
@@ -0,0 +1,80 @@
import { z } from 'zod';
import { ALL_SCOPES } from './scope-information';
export const roleNamespaceSchema = z.enum(['global', 'project', 'credential', 'workflow']);
export const globalRoleSchema = z.enum([
'global:owner',
'global:admin',
'global:member',
'global:chatUser',
]);
const customGlobalRoleSchema = z
.string()
.nonempty()
.refine((val) => !globalRoleSchema.safeParse(val).success, {
message: 'This global role value is not assignable',
});
export const assignableGlobalRoleSchema = z.union([
globalRoleSchema.exclude([
'global:owner', // Owner cannot be changed
]),
customGlobalRoleSchema,
]);
export const personalRoleSchema = z.enum([
'project:personalOwner', // personalOwner is only used for personal projects
]);
// Those are the system roles for projects assignable to a user
export const teamRoleSchema = z.enum([
'project:admin',
'project:editor',
'project:viewer',
'project:chatUser',
]);
// Custom project role can be anything but the system roles
export const customProjectRoleSchema = z
.string()
.nonempty()
.refine((val) => !systemProjectRoleSchema.safeParse(val).success, {
message: 'This global role value is not assignable',
});
// Those are all the system roles for projects
export const systemProjectRoleSchema = z.union([personalRoleSchema, teamRoleSchema]);
// Those are the roles that can be assigned to a user for a project (all roles except personalOwner)
export const assignableProjectRoleSchema = z.union([teamRoleSchema, customProjectRoleSchema]);
export const projectRoleSchema = z.union([systemProjectRoleSchema, customProjectRoleSchema]);
export const credentialSharingRoleSchema = z.enum(['credential:owner', 'credential:user']);
export const workflowSharingRoleSchema = z.enum(['workflow:owner', 'workflow:editor']);
const ALL_SCOPES_LOOKUP_SET = new Set(ALL_SCOPES as string[]);
export const scopeSchema = z.string().refine((val) => ALL_SCOPES_LOOKUP_SET.has(val), {
message: 'Invalid scope',
});
export const roleSchema = z.object({
slug: z.string().min(1),
displayName: z.string().min(1),
description: z.string().nullable(),
systemRole: z.boolean(),
roleType: roleNamespaceSchema,
licensed: z.boolean(),
scopes: z.array(scopeSchema),
createdAt: z.date().optional(),
updatedAt: z.date().optional(),
usedByUsers: z.number().optional(),
usedByProjects: z.number().optional(),
});
export type Role = z.infer<typeof roleSchema>;
@@ -0,0 +1,51 @@
import { API_KEY_RESOURCES, RESOURCES } from './constants.ee';
import type { ApiKeyScope, Scope, ScopeInformation } from './types.ee';
function buildResourceScopes() {
const resourceScopes = Object.entries(RESOURCES).flatMap(([resource, operations]) => [
...operations.map((op) => `${resource}:${op}` as const),
`${resource}:*` as const,
]) as Scope[];
resourceScopes.push('*' as const); // Global wildcard
return resourceScopes;
}
function buildApiKeyScopes() {
const apiKeyScopes = Object.entries(API_KEY_RESOURCES).flatMap(([resource, operations]) => [
...operations.map((op) => `${resource}:${op}` as const),
]) as ApiKeyScope[];
return new Set(apiKeyScopes);
}
export const ALL_SCOPES = buildResourceScopes();
export const ALL_API_KEY_SCOPES = buildApiKeyScopes();
export const scopeInformation: Partial<Record<Scope, ScopeInformation>> = {
'aiAssistant:manage': {
displayName: 'Manage AI Usage',
description: 'Allows managing AI Usage settings.',
},
'annotationTag:create': {
displayName: 'Create Annotation Tag',
description: 'Allows creating new annotation tags.',
},
'workflow:publish': {
displayName: 'Publish Workflow',
description: 'Allows publishing workflows.',
},
'workflow:unpublish': {
displayName: 'Unpublish Workflow',
description: 'Allows unpublishing workflows.',
},
'workflow:unshare': {
displayName: 'Unshare Workflow',
description: 'Allows removing workflow shares.',
},
'credential:unshare': {
displayName: 'Unshare Credential',
description: 'Allows removing credential shares.',
},
};
+127
View File
@@ -0,0 +1,127 @@
import type { z } from 'zod';
import type { RESOURCES, API_KEY_RESOURCES } from './constants.ee';
import type {
assignableGlobalRoleSchema,
credentialSharingRoleSchema,
globalRoleSchema,
Role,
systemProjectRoleSchema,
roleNamespaceSchema,
teamRoleSchema,
workflowSharingRoleSchema,
assignableProjectRoleSchema,
} from './schemas.ee';
import { PROJECT_OWNER_ROLE_SLUG } from './constants.ee';
import { ALL_API_KEY_SCOPES } from './scope-information';
export type ScopeInformation = {
displayName: string;
description?: string | null;
};
/** Represents a resource that can have permissions applied to it */
export type Resource = keyof typeof RESOURCES;
/** A permission scope for a specific resource + operation combination */
type ResourceScope<
R extends Resource,
Operation extends (typeof RESOURCES)[R][number] = (typeof RESOURCES)[R][number],
> = `${R}:${Operation}`;
/** A wildcard scope applies to all operations on a resource or all resources */
type WildcardScope = `${Resource}:*` | '*';
// This is purely an intermediary type.
// If we tried to do use `ResourceScope<Resource>` directly we'd end
// up with all resources having all scopes (e.g. `ldap:uninstall`).
type AllScopesObject = {
[R in Resource]: ResourceScope<R>;
};
/** A permission scope in the system, either a specific resource:operation or a wildcard */
export type Scope = AllScopesObject[Resource] | WildcardScope;
export type ScopeLevels = {
global: Scope[];
project?: Scope[];
resource?: Scope[];
};
export type MaskLevels = {
sharing: Scope[];
};
export type ScopeOptions = { mode: 'oneOf' | 'allOf' };
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 TeamProjectRole = z.infer<typeof teamRoleSchema>;
export type ProjectRole = z.infer<typeof systemProjectRoleSchema>;
export type AssignableProjectRole = z.infer<typeof assignableProjectRoleSchema>;
/**
* Type guard for assignable project role slugs.
*
* Custom project roles are supported. We consider any slug that:
* - starts with the `project:` prefix, and
* - is not the personal owner role
* to be an assignable project role.
*/
export function isAssignableProjectRoleSlug(slug: string): slug is AssignableProjectRole {
return slug.startsWith('project:') && slug !== PROJECT_OWNER_ROLE_SLUG;
}
/** Union of all possible role types in the system */
export type AllRoleTypes = GlobalRole | ProjectRole | WorkflowSharingRole | CredentialSharingRole;
export type AllRolesMap = {
global: Role[];
project: Role[];
credential: Role[];
workflow: Role[];
};
export type DbScope = {
slug: Scope;
};
export type DbRole = {
slug: string;
scopes: DbScope[];
};
/**
* 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: DbRole;
};
// #region Public API
type PublicApiKeyResources = keyof typeof API_KEY_RESOURCES;
type ApiKeyResourceScope<
R extends PublicApiKeyResources,
Operation extends (typeof API_KEY_RESOURCES)[R][number] = (typeof API_KEY_RESOURCES)[R][number],
> = `${R}:${Operation}`;
// This is purely an intermediary type.
// If we tried to do use `ResourceScope<Resource>` directly we'd end
// up with all resources having all scopes.
type AllApiKeyScopesObject = {
[R in PublicApiKeyResources]: ApiKeyResourceScope<R>;
};
export type ApiKeyScope = AllApiKeyScopesObject[PublicApiKeyResources];
export function isApiKeyScope(scope: Scope | ApiKeyScope): scope is ApiKeyScope {
// We are casting with as for runtime type checking
return ALL_API_KEY_SCOPES.has(scope as ApiKeyScope);
}
// #endregion
@@ -0,0 +1,65 @@
import type { Scope, ScopeLevels, MaskLevels } from '../../types.ee';
import { combineScopes } from '../combine-scopes.ee';
describe('combineScopes', () => {
describe('basic scope combining', () => {
test.each([
['single level', { global: ['workflow:read'] }, 1],
[
'multiple levels',
{
global: ['user:list'],
project: ['workflow:read'],
},
2,
],
[
'duplicates',
{
global: ['workflow:read'],
project: ['workflow:read'],
},
1,
],
] satisfies Array<[string, ScopeLevels, number]>)('%s', (_, input, expectedSize) => {
expect(combineScopes(input).size).toBe(expectedSize);
});
});
describe('masking behavior', () => {
test.each([
[
'filters project scopes',
{ project: ['workflow:read', 'workflow:update'], global: [] },
{ sharing: ['workflow:read'] },
['workflow:read'],
],
[
'filters resource scopes',
{ resource: ['credential:read', 'credential:update'], global: [] },
{ sharing: ['credential:read'] },
['credential:read'],
],
[
'ignores global scopes',
{ global: ['user:list'], project: ['workflow:read'] },
{ sharing: [] },
['user:list'],
],
['handles undefined masks', { global: ['user:list'] }, undefined, ['user:list']],
[
'handles empty resource scopes',
{ resource: [], global: ['user:list'] },
{ sharing: ['credential:read'] },
['user:list'],
],
] satisfies Array<[string, ScopeLevels, MaskLevels | undefined, Scope[]]>)(
'%s',
(_, scopes, masks, expected) => {
const result = combineScopes(scopes, masks);
expect(result.size).toBe(expected.length);
expected.forEach((scope) => expect(result.has(scope)).toBe(true));
},
);
});
});
@@ -0,0 +1,20 @@
import { GLOBAL_SCOPE_MAP } from '../../roles/role-maps.ee';
import { getGlobalScopes } from '../get-global-scopes.ee';
import { createAuthPrincipal } from './utils';
describe('getGlobalScopes', () => {
test.each(['global:owner', 'global:admin', 'global:member', 'global:chatUser'] as const)(
'should return correct scopes for %s',
(role) => {
const scopes = getGlobalScopes(createAuthPrincipal(role));
expect(scopes).toEqual(GLOBAL_SCOPE_MAP[role]);
},
);
test('should return empty array for non-existent role', () => {
const scopes = getGlobalScopes(createAuthPrincipal('non:existent'));
expect(scopes).toEqual([]);
});
});
@@ -0,0 +1,176 @@
import type { Scope } from '@/types.ee';
import type { PermissionsRecord } from '../get-resource-permissions.ee';
import { getResourcePermissions } from '../get-resource-permissions.ee';
describe('permissions', () => {
it('getResourcePermissions for empty scopes', () => {
expect(getResourcePermissions()).toEqual({
aiAssistant: {},
annotationTag: {},
auditLogs: {},
banner: {},
community: {},
communityPackage: {},
credential: {},
externalSecretsProvider: {},
externalSecret: {},
eventBusDestination: {},
execution: {},
ldap: {},
license: {},
logStreaming: {},
oidc: {},
orchestration: {},
project: {},
saml: {},
provisioning: {},
securityAudit: {},
securitySettings: {},
sourceControl: {},
tag: {},
user: {},
variable: {},
projectVariable: {},
workersView: {},
workflow: {},
workflowTags: {},
folder: {},
insights: {},
dataTable: {},
mcp: {},
mcpApiKey: {},
role: {},
chatHub: {},
chatHubAgent: {},
breakingChanges: {},
apiKey: {},
credentialResolver: {},
});
});
it('getResourcePermissions', () => {
const scopes: Scope[] = [
'credential:create',
'credential:delete',
'credential:list',
'credential:move',
'credential:read',
'credential:share',
'credential:update',
'eventBusDestination:list',
'eventBusDestination:test',
'project:list',
'project:read',
'tag:create',
'tag:list',
'tag:read',
'tag:update',
'user:list',
'variable:list',
'variable:read',
'projectVariable:list',
'projectVariable:read',
'workflow:create',
'workflow:delete',
'workflow:execute',
'workflow:list',
'workflow:move',
'workflow:read',
'workflow:share',
'workflow:update',
'folder:create',
'insights:list',
'breakingChanges:list',
'apiKey:manage',
];
const permissionRecord: PermissionsRecord = {
aiAssistant: {},
annotationTag: {},
auditLogs: {},
banner: {},
community: {},
communityPackage: {},
credential: {
create: true,
delete: true,
list: true,
move: true,
read: true,
share: true,
update: true,
},
eventBusDestination: {
list: true,
test: true,
},
externalSecret: {},
externalSecretsProvider: {},
ldap: {},
license: {},
logStreaming: {},
orchestration: {},
project: {
list: true,
read: true,
},
saml: {},
oidc: {},
provisioning: {},
mcp: {},
mcpApiKey: {},
securityAudit: {},
securitySettings: {},
sourceControl: {},
tag: {
create: true,
list: true,
read: true,
update: true,
},
user: {
list: true,
},
variable: {
list: true,
read: true,
},
projectVariable: {
list: true,
read: true,
},
workersView: {},
workflow: {
create: true,
delete: true,
execute: true,
list: true,
move: true,
read: true,
share: true,
update: true,
},
folder: {
create: true,
},
insights: {
list: true,
},
dataTable: {},
execution: {},
workflowTags: {},
role: {},
chatHub: {},
chatHubAgent: {},
breakingChanges: {
list: true,
},
apiKey: {
manage: true,
},
credentialResolver: {},
};
expect(getResourcePermissions(scopes)).toEqual(permissionRecord);
});
});
@@ -0,0 +1,36 @@
import type { AllRoleTypes, Resource } from '../../types.ee';
import { getRoleScopes, COMBINED_ROLE_MAP } from '../get-role-scopes.ee';
describe('getRoleScopes', () => {
describe('role scope retrieval', () => {
test.each(['global:owner', 'global:admin', 'project:admin'] satisfies AllRoleTypes[])(
'should return scopes for %s',
(role) => {
const scopes = getRoleScopes(role);
expect(scopes).toEqual(COMBINED_ROLE_MAP[role]);
},
);
});
describe('resource filtering', () => {
test.each(['workflow', 'credential', 'user'] satisfies Resource[])(
'should filter %s scopes',
(resource) => {
const filtered = getRoleScopes('global:owner', [resource]);
expect(filtered.every((s) => s.startsWith(`${resource}:`))).toBe(true);
},
);
test('should handle multiple filters', () => {
const filtered = getRoleScopes('global:owner', ['workflow', 'credential']);
expect(filtered.some((s) => s.startsWith('workflow:'))).toBe(true);
expect(filtered.some((s) => s.startsWith('credential:'))).toBe(true);
expect(filtered.every((s) => !s.startsWith('tag:'))).toBe(true);
expect(filtered.every((s) => !s.startsWith('user:'))).toBe(true);
});
test('should return empty array for no matches', () => {
expect(getRoleScopes('global:member', ['nonexistent' as Resource])).toEqual([]);
});
});
});
@@ -0,0 +1,52 @@
import type { GlobalRole, Scope } from '../../types.ee';
import { hasGlobalScope } from '../has-global-scope.ee';
import { createAuthPrincipal } from './utils';
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: 'global:chatUser', 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(createAuthPrincipal(role), scope)).toBe(expected);
},
);
});
describe('multiple scopes', () => {
test('oneOf mode (default)', () => {
expect(
hasGlobalScope(createAuthPrincipal('global:member'), [
'tag:create',
'user:list',
// a member cannot create users
'user:create',
]),
).toBe(true);
});
test('allOf mode', () => {
expect(
hasGlobalScope(
createAuthPrincipal('global:member'),
[
'tag:create',
'user:list',
// a member cannot create users
'user:create',
],
{ mode: 'allOf' },
),
).toBe(false);
});
});
test('edge cases', () => {
expect(hasGlobalScope(createAuthPrincipal('global:owner'), [])).toBe(false);
});
});
@@ -0,0 +1,47 @@
import type { Scope, ScopeLevels } from '../../types.ee';
import { hasScope } from '../has-scope.ee';
describe('hasScope', () => {
const userScopes: ScopeLevels = {
global: ['user:list'],
project: ['workflow:read', 'workflow:update'],
resource: ['credential:read'],
};
describe('scope checking', () => {
test.each([
['workflow:read', true],
['workflow:delete', false],
['user:list', true],
] satisfies Array<[Scope, boolean]>)('%s -> %s', (scope, expected) => {
expect(hasScope(scope, userScopes)).toBe(expected);
});
});
describe('masking behavior', () => {
test('filters non-global scopes', () => {
expect(hasScope('workflow:read', userScopes, { sharing: ['workflow:update'] })).toBe(false);
});
test('ignores global scopes', () => {
expect(hasScope('user:list', userScopes, { sharing: [] })).toBe(true);
});
});
describe('checking modes', () => {
test('oneOf (default)', () => {
expect(hasScope(['workflow:read', 'invalid:scope'] as Scope[], userScopes)).toBe(true);
});
test('allOf', () => {
expect(
hasScope(['workflow:read', 'workflow:update'], userScopes, undefined, { mode: 'allOf' }),
).toBe(true);
});
test('edge cases', () => {
expect(hasScope([], userScopes, undefined, { mode: 'allOf' })).toBe(false);
expect(hasScope([], userScopes, undefined, { mode: 'oneOf' })).toBe(false);
});
});
});
@@ -0,0 +1,28 @@
import type { GlobalRole, Scope } from '../../types.ee';
import { staticRolesWithScope } from '../static-roles-with-scope.ee';
describe('rolesWithScope', () => {
describe('global roles', () => {
test.each([
['workflow:create', ['global:owner', 'global:admin']],
['user:list', ['global:owner', 'global:admin', 'global:member']],
['chatHub:message', ['global:owner', 'global:admin', 'global:member', 'global:chatUser']],
['invalid:scope', []],
] as Array<[Scope, GlobalRole[]]>)('%s -> %s', (scope, expected) => {
expect(staticRolesWithScope('global', scope)).toEqual(expected);
});
});
describe('multiple scopes', () => {
test('returns roles with all scopes', () => {
expect(
staticRolesWithScope('global', [
// all global roles have this scope
'tag:create',
// only owner and admin have this scope
'user:delete',
]),
).toEqual(['global:owner', 'global:admin']);
});
});
});
@@ -0,0 +1,40 @@
import { GLOBAL_SCOPE_MAP } from '@/roles/role-maps.ee';
import { globalRoleSchema } from '@/schemas.ee';
import type { AuthPrincipal, GlobalRole, Scope } from '@/types.ee';
function createBuildInAuthPrincipal(role: GlobalRole): AuthPrincipal {
return {
role: {
slug: role,
scopes:
GLOBAL_SCOPE_MAP[role].map((scope) => {
return {
slug: scope,
};
}) || [],
},
};
}
export function createAuthPrincipal(role: string, scopes: Scope[] = []): AuthPrincipal {
try {
const isGlobalRole = globalRoleSchema.parse(role);
if (isGlobalRole) {
return createBuildInAuthPrincipal(isGlobalRole);
}
} catch (error) {
// If the role is not a valid global role, we proceed
// to create a custom role with the provided scopes.
}
return {
role: {
slug: role,
scopes:
scopes.map((scope) => {
return {
slug: scope,
};
}) || [],
},
};
}
@@ -0,0 +1,31 @@
import type { Scope, ScopeLevels, MaskLevels } from '../types.ee';
/**
* Combines scopes from different levels into a deduplicated set.
*
* @param userScopes - Scopes organized by level (global, project, resource)
* @param masks - Optional filters for non-global scopes
* @returns Set containing all allowed scopes
*
* @example
* combineScopes({
* global: ['user:list'],
* project: ['workflow:read'],
* }, { sharing: ['workflow:read'] });
*/
export function combineScopes(userScopes: ScopeLevels, masks?: MaskLevels): Set<Scope> {
const maskedScopes: ScopeLevels = Object.fromEntries(
Object.entries(userScopes).map((e) => [e[0], [...e[1]]]),
) as ScopeLevels;
if (masks?.sharing) {
if (maskedScopes.project) {
maskedScopes.project = maskedScopes.project.filter((v) => masks.sharing.includes(v));
}
if (maskedScopes.resource) {
maskedScopes.resource = maskedScopes.resource.filter((v) => masks.sharing.includes(v));
}
}
return new Set(Object.values(maskedScopes).flat());
}
@@ -0,0 +1,9 @@
import 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) =>
principal.role.scopes.map((scope) => scope.slug) ?? [];
@@ -0,0 +1,32 @@
import { RESOURCES } from '../constants.ee';
import type { Scope } from '../types.ee';
type ExtractScopePrefixSuffix<T> = T extends `${infer Prefix}:${infer Suffix}`
? [Prefix, Suffix]
: never;
type ActionBooleans<T extends readonly string[]> = {
[K in T[number]]?: boolean;
};
export type PermissionsRecord = {
[K in keyof typeof RESOURCES]: ActionBooleans<(typeof RESOURCES)[K]>;
};
export const getResourcePermissions = (resourceScopes: readonly Scope[] = []): PermissionsRecord =>
Object.keys(RESOURCES).reduce(
(permissions, key) => ({
...permissions,
[key]: resourceScopes.reduce((resourcePermissions, scope) => {
const [prefix, suffix] = scope.split(':') as ExtractScopePrefixSuffix<Scope>;
if (prefix === key) {
return {
...resourcePermissions,
[suffix]: true,
};
}
return resourcePermissions;
}, {}),
}),
{} as PermissionsRecord,
);
@@ -0,0 +1,41 @@
import { ALL_ROLE_MAPS } from '../roles/role-maps.ee';
import type { AllRoleTypes, AuthPrincipal, 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
*
* @deprecated Use the 'getRoleScopes' from the AuthRolesService instead.
*/
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;
}
/**
* Gets scopes for an auth principal, optionally filtered by resource types.
* @param user - The auth principal to search scopes for
* @param filters - Optional resources to filter scopes by
* @returns Array of matching scopes
*/
export function getAuthPrincipalScopes(user: AuthPrincipal, filters?: Resource[]): Scope[] {
if (!user.role) {
const e = new Error('AuthPrincipal does not have a role defined');
console.error('AuthPrincipal does not have a role defined', e);
throw e;
}
let scopes = user.role.scopes.map((s) => s.slug);
if (filters) {
scopes = scopes.filter((s) => filters.includes(s.split(':')[0] as Resource));
}
return scopes;
}
@@ -0,0 +1,17 @@
import { hasScope } from './has-scope.ee';
import type { AuthPrincipal, Scope, ScopeOptions } from '../types.ee';
import { getAuthPrincipalScopes } from './get-role-scopes.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 = getAuthPrincipalScopes(principal);
return hasScope(scope, { global }, undefined, scopeOptions);
};
@@ -0,0 +1,22 @@
import { combineScopes } from './combine-scopes.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));
};
@@ -0,0 +1,24 @@
import { ALL_ROLE_MAPS } from '../roles/role-maps.ee';
import type { RoleNamespace, Scope } from '../types.ee';
/**
* Retrieves roles within a specific namespace that have all the given scopes.
*
* This is only valid for static roles defined in ALL_ROLE_MAPS, with custom roles
* being handled in the RoleService.
*
* @param namespace - The role namespace to search in
* @param scopes - Scope(s) to filter by
*/
export function staticRolesWithScope(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),
);
});
}
@@ -0,0 +1,11 @@
{
"extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.json"],
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/build.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/__tests__/**"]
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "@n8n/typescript-config/tsconfig.common.json",
"compilerOptions": {
"rootDir": ".",
"types": ["node", "jest"],
"baseUrl": "src",
"paths": {
"@/*": ["./*"]
},
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo"
},
"include": ["src/**/*.ts"]
}