mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00:00
chore(core): Use roles from database in global roles (#18768)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import type { entities } from '@n8n/db';
|
||||
import { DbConnection, DbConnectionOptions } from '@n8n/db';
|
||||
import { AuthRolesService, DbConnection, DbConnectionOptions } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { DataSourceOptions } from '@n8n/typeorm';
|
||||
import { DataSource as Connection } from '@n8n/typeorm';
|
||||
@@ -50,6 +50,8 @@ export async function init() {
|
||||
const dbConnection = Container.get(DbConnection);
|
||||
await dbConnection.init();
|
||||
await dbConnection.migrate();
|
||||
|
||||
await Container.get(AuthRolesService).init();
|
||||
}
|
||||
|
||||
export function isReady() {
|
||||
|
||||
30
packages/@n8n/db/src/constants.ts
Normal file
30
packages/@n8n/db/src/constants.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { GLOBAL_SCOPE_MAP, type GlobalRole } from '@n8n/permissions';
|
||||
|
||||
import type { Role } from 'entities';
|
||||
|
||||
export function buildInRoleToRoleObject(role: GlobalRole): Role {
|
||||
return {
|
||||
slug: role,
|
||||
displayName: role,
|
||||
scopes: GLOBAL_SCOPE_MAP[role].map((scope) => {
|
||||
return {
|
||||
slug: scope,
|
||||
displayName: scope,
|
||||
description: null,
|
||||
};
|
||||
}),
|
||||
systemRole: true,
|
||||
roleType: 'global',
|
||||
description: `Built-in global role with ${role} permissions.`,
|
||||
};
|
||||
}
|
||||
|
||||
export const GLOBAL_OWNER_ROLE = buildInRoleToRoleObject('global:owner');
|
||||
export const GLOBAL_ADMIN_ROLE = buildInRoleToRoleObject('global:admin');
|
||||
export const GLOBAL_MEMBER_ROLE = buildInRoleToRoleObject('global:member');
|
||||
|
||||
export const GLOBAL_ROLES: Record<GlobalRole, Role> = {
|
||||
'global:owner': GLOBAL_OWNER_ROLE,
|
||||
'global:admin': GLOBAL_ADMIN_ROLE,
|
||||
'global:member': GLOBAL_MEMBER_ROLE,
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GlobalRole, Scope } from '@n8n/permissions';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
import type { FindOperator } from '@n8n/typeorm';
|
||||
import type express from 'express';
|
||||
import type {
|
||||
@@ -105,7 +105,7 @@ export interface PublicUser {
|
||||
passwordResetToken?: string;
|
||||
createdAt: Date;
|
||||
isPending: boolean;
|
||||
role?: GlobalRole;
|
||||
role?: string;
|
||||
globalScopes?: Scope[];
|
||||
signInType: AuthProviderType;
|
||||
disabled: boolean;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthPrincipal, GlobalRole } from '@n8n/permissions';
|
||||
import type { AuthPrincipal } from '@n8n/permissions';
|
||||
import {
|
||||
AfterLoad,
|
||||
AfterUpdate,
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
BeforeInsert,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
} from '@n8n/typeorm';
|
||||
import type { IUser, IUserSettings } from 'n8n-workflow';
|
||||
|
||||
@@ -16,9 +18,11 @@ import { JsonColumn, WithTimestamps } from './abstract-entity';
|
||||
import type { ApiKey } from './api-key';
|
||||
import type { AuthIdentity } from './auth-identity';
|
||||
import type { ProjectRelation } from './project-relation';
|
||||
import { Role } from './role';
|
||||
import type { SharedCredentials } from './shared-credentials';
|
||||
import type { SharedWorkflow } from './shared-workflow';
|
||||
import type { IPersonalizationSurveyAnswers } from './types-db';
|
||||
import { GLOBAL_OWNER_ROLE } from '../constants';
|
||||
import { isValidEmail } from '../utils/is-valid-email';
|
||||
import { lowerCaser, objectRetriever } from '../utils/transformers';
|
||||
|
||||
@@ -53,8 +57,9 @@ export class User extends WithTimestamps implements IUser, AuthPrincipal {
|
||||
@JsonColumn({ nullable: true })
|
||||
settings: IUserSettings | null;
|
||||
|
||||
@Column({ type: String })
|
||||
role: GlobalRole;
|
||||
@ManyToOne(() => Role)
|
||||
@JoinColumn({ name: 'roleSlug', referencedColumnName: 'slug' })
|
||||
role: Role;
|
||||
|
||||
@OneToMany('AuthIdentity', 'user')
|
||||
authIdentities: AuthIdentity[];
|
||||
@@ -108,7 +113,7 @@ export class User extends WithTimestamps implements IUser, AuthPrincipal {
|
||||
@AfterLoad()
|
||||
@AfterUpdate()
|
||||
computeIsPending(): void {
|
||||
this.isPending = this.password === null && this.role !== 'global:owner';
|
||||
this.isPending = this.password === null && this.role?.slug !== GLOBAL_OWNER_ROLE.slug;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
|
||||
@@ -16,6 +16,7 @@ export { separate } from './utils/separate';
|
||||
export { sql } from './utils/sql';
|
||||
export { idStringifier, lowerCaser, objectRetriever, sqlite } from './utils/transformers';
|
||||
|
||||
export * from './constants';
|
||||
export * from './entities';
|
||||
export * from './entities/types-db';
|
||||
export { NoXss } from './utils/validators/no-xss.validator';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { GlobalRole } from '@n8n/permissions';
|
||||
import { getApiKeyScopesForRole } from '@n8n/permissions';
|
||||
|
||||
import { GLOBAL_ROLES } from '../../constants';
|
||||
import { ApiKey } from '../../entities';
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
@@ -26,7 +27,10 @@ export class AddScopesColumnToApiKeys1742918400000 implements ReversibleMigratio
|
||||
);
|
||||
|
||||
for (const { id, role } of apiKeysWithRoles) {
|
||||
const scopes = getApiKeyScopesForRole(role);
|
||||
const dbRole = GLOBAL_ROLES[role];
|
||||
const scopes = getApiKeyScopesForRole({
|
||||
role: dbRole,
|
||||
});
|
||||
await queryRunner.manager.update(ApiKey, { id }, { scopes });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
/*
|
||||
* This migration removes the old 'role' column from the 'user' table
|
||||
* and ensures that all users have a valid role set in the 'roleSlug' column.
|
||||
* It also ensures that the 'roleSlug' column is correctly populated with the
|
||||
* values from the 'role' column before dropping it.
|
||||
* This is a reversible migration, allowing the role column to be restored if needed.
|
||||
*/
|
||||
export class RemoveOldRoleColumn1750252139170 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { dropColumns }, escape, runQuery }: MigrationContext) {
|
||||
const roleTableName = escape.tableName('role');
|
||||
const userTableName = escape.tableName('user');
|
||||
const slugColumn = escape.columnName('slug');
|
||||
const roleColumn = escape.columnName('role');
|
||||
const roleSlugColumn = escape.columnName('roleSlug');
|
||||
|
||||
// Fallback to 'global:member' for users that do not have a correct role set
|
||||
// This should not happen in a correctly set up system, but we want to ensure
|
||||
// that all users have a role set, before we add the foreign key constraint
|
||||
await runQuery(
|
||||
`UPDATE ${userTableName} SET ${roleSlugColumn} = 'global:member', ${roleColumn} = 'global:member' WHERE NOT EXISTS (SELECT 1 FROM ${roleTableName} WHERE ${slugColumn} = ${roleColumn})`,
|
||||
);
|
||||
|
||||
await runQuery(
|
||||
`UPDATE ${userTableName} SET ${roleSlugColumn} = ${roleColumn} WHERE ${roleColumn} != ${roleSlugColumn}`,
|
||||
);
|
||||
|
||||
await dropColumns('user', ['role']);
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { addColumns, column }, escape, runQuery }: MigrationContext) {
|
||||
const userTableName = escape.tableName('user');
|
||||
const roleColumn = escape.columnName('role');
|
||||
const roleSlugColumn = escape.columnName('roleSlug');
|
||||
|
||||
await addColumns('user', [column('role').varchar(128).default("'global:member'").notNull]);
|
||||
|
||||
await runQuery(
|
||||
`UPDATE ${userTableName} SET ${roleColumn} = ${roleSlugColumn} WHERE ${roleSlugColumn} != ${roleColumn}`,
|
||||
);
|
||||
|
||||
// Fallback to 'global:member' for users that do not have a correct role set
|
||||
await runQuery(
|
||||
`UPDATE ${userTableName} SET ${roleColumn} = 'global:member' WHERE NOT EXISTS (SELECT 1 FROM role WHERE slug = ${roleColumn})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,7 @@ import { AddLastActiveAtColumnToUser1750252139166 } from '../common/175025213916
|
||||
import { AddScopeTables1750252139166 } from '../common/1750252139166-AddScopeTables';
|
||||
import { AddRolesTables1750252139167 } from '../common/1750252139167-AddRolesTables';
|
||||
import { LinkRoleToUserTable1750252139168 } from '../common/1750252139168-LinkRoleToUserTable';
|
||||
import { RemoveOldRoleColumn1750252139170 } from '../common/1750252139170-RemoveOldRoleColumn';
|
||||
import { AddInputsOutputsToTestCaseExecution1752669793000 } from '../common/1752669793000-AddInputsOutputsToTestCaseExecution';
|
||||
import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables';
|
||||
import type { Migration } from '../migration-types';
|
||||
@@ -193,4 +194,5 @@ export const mysqlMigrations: Migration[] = [
|
||||
LinkRoleToUserTable1750252139168,
|
||||
AddInputsOutputsToTestCaseExecution1752669793000,
|
||||
CreateDataStoreTables1754475614601,
|
||||
RemoveOldRoleColumn1750252139170,
|
||||
];
|
||||
|
||||
@@ -92,6 +92,7 @@ import { AddLastActiveAtColumnToUser1750252139166 } from '../common/175025213916
|
||||
import { AddScopeTables1750252139166 } from '../common/1750252139166-AddScopeTables';
|
||||
import { AddRolesTables1750252139167 } from '../common/1750252139167-AddRolesTables';
|
||||
import { LinkRoleToUserTable1750252139168 } from '../common/1750252139168-LinkRoleToUserTable';
|
||||
import { RemoveOldRoleColumn1750252139170 } from '../common/1750252139170-RemoveOldRoleColumn';
|
||||
import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables';
|
||||
import type { Migration } from '../migration-types';
|
||||
|
||||
@@ -191,4 +192,5 @@ export const postgresMigrations: Migration[] = [
|
||||
LinkRoleToUserTable1750252139168,
|
||||
AddInputsOutputsToTestCaseExecution1752669793000,
|
||||
CreateDataStoreTables1754475614601,
|
||||
RemoveOldRoleColumn1750252139170,
|
||||
];
|
||||
|
||||
@@ -88,6 +88,7 @@ import { AddLastActiveAtColumnToUser1750252139166 } from '../common/175025213916
|
||||
import { AddScopeTables1750252139166 } from '../common/1750252139166-AddScopeTables';
|
||||
import { AddRolesTables1750252139167 } from '../common/1750252139167-AddRolesTables';
|
||||
import { LinkRoleToUserTable1750252139168 } from '../common/1750252139168-LinkRoleToUserTable';
|
||||
import { RemoveOldRoleColumn1750252139170 } from '../common/1750252139170-RemoveOldRoleColumn';
|
||||
import { AddInputsOutputsToTestCaseExecution1752669793000 } from '../common/1752669793000-AddInputsOutputsToTestCaseExecution';
|
||||
import { CreateDataStoreTables1754475614601 } from '../common/1754475614601-CreateDataStoreTables';
|
||||
import type { Migration } from '../migration-types';
|
||||
@@ -185,6 +186,7 @@ const sqliteMigrations: Migration[] = [
|
||||
LinkRoleToUserTable1750252139168,
|
||||
AddInputsOutputsToTestCaseExecution1752669793000,
|
||||
CreateDataStoreTables1754475614601,
|
||||
RemoveOldRoleColumn1750252139170,
|
||||
];
|
||||
|
||||
export { sqliteMigrations };
|
||||
|
||||
@@ -16,7 +16,11 @@ export class ProjectRelationRepository extends Repository<ProjectRelation> {
|
||||
projectId: In(projectIds),
|
||||
role: 'project:personalOwner',
|
||||
},
|
||||
relations: { user: true },
|
||||
relations: {
|
||||
user: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -55,19 +55,20 @@ export class UserRepository extends Repository<User> {
|
||||
email,
|
||||
password: Not(IsNull()),
|
||||
},
|
||||
relations: ['authIdentities'],
|
||||
relations: ['authIdentities', 'role'],
|
||||
});
|
||||
}
|
||||
|
||||
/** Counts the number of users in each role, e.g. `{ admin: 2, member: 6, owner: 1 }` */
|
||||
async countUsersByRole() {
|
||||
const escapedRoleSlug = this.manager.connection.driver.escape('roleSlug');
|
||||
const rows = (await this.createQueryBuilder()
|
||||
.select(['role', 'COUNT(role) as count'])
|
||||
.groupBy('role')
|
||||
.execute()) as Array<{ role: string; count: string }>;
|
||||
.select([escapedRoleSlug, `COUNT(${escapedRoleSlug}) as count`])
|
||||
.groupBy(escapedRoleSlug)
|
||||
.execute()) as Array<{ roleSlug: string; count: string }>;
|
||||
return rows.reduce(
|
||||
(acc, row) => {
|
||||
acc[row.role] = parseInt(row.count, 10);
|
||||
acc[row.roleSlug] = parseInt(row.count, 10);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
@@ -91,20 +92,25 @@ export class UserRepository extends Repository<User> {
|
||||
const createInner = async (entityManager: EntityManager) => {
|
||||
const newUser = entityManager.create(User, user);
|
||||
const savedUser = await entityManager.save<User>(newUser);
|
||||
const userWithRole = await entityManager.findOne(User, {
|
||||
where: { id: savedUser.id },
|
||||
relations: ['role'],
|
||||
});
|
||||
if (!userWithRole) throw new Error('Failed to create user!');
|
||||
const savedProject = await entityManager.save<Project>(
|
||||
entityManager.create(Project, {
|
||||
type: 'personal',
|
||||
name: savedUser.createPersonalProjectName(),
|
||||
name: userWithRole.createPersonalProjectName(),
|
||||
}),
|
||||
);
|
||||
await entityManager.save<ProjectRelation>(
|
||||
entityManager.create(ProjectRelation, {
|
||||
projectId: savedProject.id,
|
||||
userId: savedUser.id,
|
||||
userId: userWithRole.id,
|
||||
role: 'project:personalOwner',
|
||||
}),
|
||||
);
|
||||
return { user: savedUser, project: savedProject };
|
||||
return { user: userWithRole, project: savedProject };
|
||||
};
|
||||
if (transactionManager) {
|
||||
return await createInner(transactionManager);
|
||||
@@ -127,6 +133,7 @@ export class UserRepository extends Repository<User> {
|
||||
project: { sharedWorkflows: { workflowId, role: 'workflow:owner' } },
|
||||
},
|
||||
},
|
||||
relations: ['role'],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -143,6 +150,7 @@ export class UserRepository extends Repository<User> {
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
relations: ['role'],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -286,6 +294,8 @@ export class UserRepository extends Repository<User> {
|
||||
this.applyUserListExpand(queryBuilder, expand);
|
||||
this.applyUserListPagination(queryBuilder, take, skip);
|
||||
this.applyUserListSort(queryBuilder, sortBy);
|
||||
queryBuilder.leftJoinAndSelect('user.role', 'role');
|
||||
queryBuilder.leftJoinAndSelect('role.scopes', 'scopes');
|
||||
|
||||
return queryBuilder;
|
||||
}
|
||||
|
||||
@@ -6,21 +6,39 @@ describe('Redactable Decorator', () => {
|
||||
class TestClass {
|
||||
@Redactable()
|
||||
methodWithUser(arg: {
|
||||
user: { id: string; email?: string; firstName?: string; lastName?: string; role: string };
|
||||
user: {
|
||||
id: string;
|
||||
email?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
role: { slug: string };
|
||||
};
|
||||
}) {
|
||||
return arg;
|
||||
}
|
||||
|
||||
@Redactable('inviter')
|
||||
methodWithInviter(arg: {
|
||||
inviter: { id: string; email?: string; firstName?: string; lastName?: string; role: string };
|
||||
inviter: {
|
||||
id: string;
|
||||
email?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
role: { slug: string };
|
||||
};
|
||||
}) {
|
||||
return arg;
|
||||
}
|
||||
|
||||
@Redactable('invitee')
|
||||
methodWithInvitee(arg: {
|
||||
invitee: { id: string; email?: string; firstName?: string; lastName?: string; role: string };
|
||||
invitee: {
|
||||
id: string;
|
||||
email?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
role: { slug: string };
|
||||
};
|
||||
}) {
|
||||
return arg;
|
||||
}
|
||||
@@ -29,7 +47,13 @@ describe('Redactable Decorator', () => {
|
||||
methodWithMultipleArgs(
|
||||
firstArg: { something: string },
|
||||
secondArg: {
|
||||
user: { id: string; email?: string; firstName?: string; lastName?: string; role: string };
|
||||
user: {
|
||||
id: string;
|
||||
email?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
role: { slug: string };
|
||||
};
|
||||
},
|
||||
) {
|
||||
return { firstArg, secondArg };
|
||||
@@ -69,7 +93,7 @@ describe('Redactable Decorator', () => {
|
||||
email: 'test@example.com',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
role: 'admin',
|
||||
role: { slug: 'admin' },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -91,7 +115,7 @@ describe('Redactable Decorator', () => {
|
||||
email: 'test@example.com',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
role: 'admin',
|
||||
role: { slug: 'admin' },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -113,7 +137,7 @@ describe('Redactable Decorator', () => {
|
||||
email: 'test@example.com',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
role: 'admin',
|
||||
role: { slug: 'admin' },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -132,7 +156,7 @@ describe('Redactable Decorator', () => {
|
||||
const input = {
|
||||
user: {
|
||||
id: '123',
|
||||
role: 'admin',
|
||||
role: { slug: 'admin' },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -153,7 +177,7 @@ describe('Redactable Decorator', () => {
|
||||
user: {
|
||||
id: '123',
|
||||
email: 'test@example.com',
|
||||
role: 'admin',
|
||||
role: { slug: 'admin' },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -182,7 +206,7 @@ describe('Redactable Decorator', () => {
|
||||
user: {
|
||||
id: '123',
|
||||
email: 'test@example.com',
|
||||
role: 'admin',
|
||||
role: { slug: 'admin' },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ type UserLike = {
|
||||
email?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
role: string;
|
||||
role: {
|
||||
slug: string;
|
||||
};
|
||||
};
|
||||
|
||||
export class RedactableError extends UnexpectedError {
|
||||
@@ -22,7 +24,7 @@ function toRedactable(userLike: UserLike) {
|
||||
_email: userLike.email,
|
||||
_firstName: userLike.firstName,
|
||||
_lastName: userLike.lastName,
|
||||
globalRole: userLike.role,
|
||||
globalRole: userLike.role.slug,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,8 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = `
|
||||
"workflow:share",
|
||||
"workflow:execute",
|
||||
"workflow:move",
|
||||
"workflow:activate",
|
||||
"workflow:deactivate",
|
||||
"workflow:create",
|
||||
"workflow:read",
|
||||
"workflow:update",
|
||||
@@ -121,6 +123,14 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = `
|
||||
"dataStore:writeRow",
|
||||
"dataStore:listProject",
|
||||
"dataStore:*",
|
||||
"execution:delete",
|
||||
"execution:read",
|
||||
"execution:list",
|
||||
"execution:get",
|
||||
"execution:*",
|
||||
"workflowTags:update",
|
||||
"workflowTags:list",
|
||||
"workflowTags:*",
|
||||
"*",
|
||||
]
|
||||
`;
|
||||
|
||||
@@ -22,11 +22,13 @@ export const RESOURCES = {
|
||||
user: ['resetPassword', 'changeRole', 'enforceMfa', ...DEFAULT_OPERATIONS] as const,
|
||||
variable: [...DEFAULT_OPERATIONS] as const,
|
||||
workersView: ['manage'] as const,
|
||||
workflow: ['share', 'execute', 'move', ...DEFAULT_OPERATIONS] as const,
|
||||
workflow: ['share', 'execute', 'move', 'activate', 'deactivate', ...DEFAULT_OPERATIONS] as const,
|
||||
folder: [...DEFAULT_OPERATIONS, 'move'] as const,
|
||||
insights: ['list'] as const,
|
||||
oidc: ['manage'] as const,
|
||||
dataStore: [...DEFAULT_OPERATIONS, 'readRow', 'writeRow', 'listProject'] as const,
|
||||
execution: ['delete', 'read', 'list', 'get'] as const,
|
||||
workflowTags: ['update', 'list'] as const,
|
||||
} as const;
|
||||
|
||||
export const API_KEY_RESOURCES = {
|
||||
|
||||
@@ -13,7 +13,7 @@ export { hasGlobalScope } from './utilities/has-global-scope.ee';
|
||||
export { combineScopes } from './utilities/combine-scopes.ee';
|
||||
export { rolesWithScope } from './utilities/roles-with-scope.ee';
|
||||
export { getGlobalScopes } from './utilities/get-global-scopes.ee';
|
||||
export { getRoleScopes } from './utilities/get-role-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';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ApiKeyScope, GlobalRole } from './types.ee';
|
||||
import { isApiKeyScope, type ApiKeyScope, type AuthPrincipal, type GlobalRole } from './types.ee';
|
||||
|
||||
export const OWNER_API_KEY_SCOPES: ApiKeyScope[] = [
|
||||
'user:read',
|
||||
@@ -65,14 +65,46 @@ export const MEMBER_API_KEY_SCOPES: ApiKeyScope[] = [
|
||||
'credential:delete',
|
||||
];
|
||||
|
||||
/**
|
||||
* 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',
|
||||
'workflow:create',
|
||||
'workflow:read',
|
||||
'workflow:update',
|
||||
'workflow:delete',
|
||||
'workflow:list',
|
||||
'workflow:move',
|
||||
'workflow:activate',
|
||||
'workflow:deactivate',
|
||||
'execution:delete',
|
||||
'execution:read',
|
||||
'execution:list',
|
||||
'credential:create',
|
||||
'credential:move',
|
||||
'credential:delete',
|
||||
];
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
export const getApiKeyScopesForRole = (role: GlobalRole) => {
|
||||
return MAP_ROLE_SCOPES[role];
|
||||
export const getApiKeyScopesForRole = (user: AuthPrincipal) => {
|
||||
return [
|
||||
...new Set(
|
||||
user.role.scopes
|
||||
.map((scope) => scope.slug)
|
||||
.concat(API_KEY_SCOPES_FOR_IMPLICIT_PERSONAL_PROJECT)
|
||||
.filter(isApiKeyScope),
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
export const getOwnerOnlyApiKeyScopes = () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RESOURCES } from './constants.ee';
|
||||
import type { Scope, ScopeInformation } from './types.ee';
|
||||
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]) => [
|
||||
@@ -11,8 +11,18 @@ function buildResourceScopes() {
|
||||
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>> = {
|
||||
'annotationTag:create': {
|
||||
displayName: 'Create Annotation Tag',
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
teamRoleSchema,
|
||||
workflowSharingRoleSchema,
|
||||
} from './schemas.ee';
|
||||
import { ALL_API_KEY_SCOPES } from './scope-information';
|
||||
|
||||
export type ScopeInformation = {
|
||||
displayName: string;
|
||||
@@ -76,12 +77,21 @@ export type AllRolesMap = {
|
||||
workflow: Array<RoleObject<WorkflowSharingRole>>;
|
||||
};
|
||||
|
||||
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: GlobalRole;
|
||||
role: DbRole;
|
||||
};
|
||||
|
||||
// #region Public API
|
||||
@@ -101,4 +111,9 @@ type AllApiKeyScopesObject = {
|
||||
|
||||
export type ApiKeyScope = AllApiKeyScopesObject[PublicApiKeyResources];
|
||||
|
||||
export function isApiKeyScope(scope: Scope): scope is ApiKeyScope {
|
||||
// We are casting with as for runtime type checking
|
||||
return ALL_API_KEY_SCOPES.has(scope as ApiKeyScope);
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { GLOBAL_SCOPE_MAP } from '../../roles/role-maps.ee';
|
||||
import type { GlobalRole } from '../../types.ee';
|
||||
import { getGlobalScopes } from '../get-global-scopes.ee';
|
||||
import { createAuthPrincipal } from './utils';
|
||||
|
||||
describe('getGlobalScopes', () => {
|
||||
test.each(['global:owner', 'global:admin', 'global:member'] as const)(
|
||||
'should return correct scopes for %s',
|
||||
(role) => {
|
||||
const scopes = getGlobalScopes({ 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({ role: 'non:existent' as GlobalRole });
|
||||
const scopes = getGlobalScopes(createAuthPrincipal('non:existent'));
|
||||
|
||||
expect(scopes).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ describe('permissions', () => {
|
||||
externalSecretsProvider: {},
|
||||
externalSecret: {},
|
||||
eventBusDestination: {},
|
||||
execution: {},
|
||||
ldap: {},
|
||||
license: {},
|
||||
logStreaming: {},
|
||||
@@ -29,6 +30,7 @@ describe('permissions', () => {
|
||||
variable: {},
|
||||
workersView: {},
|
||||
workflow: {},
|
||||
workflowTags: {},
|
||||
folder: {},
|
||||
insights: {},
|
||||
dataStore: {},
|
||||
@@ -130,6 +132,8 @@ describe('permissions', () => {
|
||||
list: true,
|
||||
},
|
||||
dataStore: {},
|
||||
execution: {},
|
||||
workflowTags: {},
|
||||
};
|
||||
|
||||
expect(getResourcePermissions(scopes)).toEqual(permissionRecord);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { GlobalRole, Scope } from '../../types.ee';
|
||||
import { hasGlobalScope } from '../has-global-scope.ee';
|
||||
import { createAuthPrincipal } from './utils';
|
||||
|
||||
describe('hasGlobalScope', () => {
|
||||
describe('single scope checks', () => {
|
||||
@@ -11,7 +12,7 @@ describe('hasGlobalScope', () => {
|
||||
] as Array<{ role: GlobalRole; scope: Scope; expected: boolean }>)(
|
||||
'$role with $scope -> $expected',
|
||||
({ role, scope, expected }) => {
|
||||
expect(hasGlobalScope({ role }, scope)).toBe(expected);
|
||||
expect(hasGlobalScope(createAuthPrincipal(role), scope)).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -19,7 +20,7 @@ describe('hasGlobalScope', () => {
|
||||
describe('multiple scopes', () => {
|
||||
test('oneOf mode (default)', () => {
|
||||
expect(
|
||||
hasGlobalScope({ role: 'global:member' }, [
|
||||
hasGlobalScope(createAuthPrincipal('global:member'), [
|
||||
'tag:create',
|
||||
'user:list',
|
||||
// a member cannot create users
|
||||
@@ -31,7 +32,7 @@ describe('hasGlobalScope', () => {
|
||||
test('allOf mode', () => {
|
||||
expect(
|
||||
hasGlobalScope(
|
||||
{ role: 'global:member' },
|
||||
createAuthPrincipal('global:member'),
|
||||
[
|
||||
'tag:create',
|
||||
'user:list',
|
||||
@@ -45,6 +46,6 @@ describe('hasGlobalScope', () => {
|
||||
});
|
||||
|
||||
test('edge cases', () => {
|
||||
expect(hasGlobalScope({ role: 'global:owner' }, [])).toBe(false);
|
||||
expect(hasGlobalScope(createAuthPrincipal('global:owner'), [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
40
packages/@n8n/permissions/src/utilities/__tests__/utils.ts
Normal file
40
packages/@n8n/permissions/src/utilities/__tests__/utils.ts
Normal file
@@ -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,
|
||||
};
|
||||
}) || [],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { GLOBAL_SCOPE_MAP } from '../roles/role-maps.ee';
|
||||
import type { AuthPrincipal } from '../types.ee';
|
||||
|
||||
/**
|
||||
@@ -6,4 +5,5 @@ import type { AuthPrincipal } from '../types.ee';
|
||||
* @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] ?? [];
|
||||
export const getGlobalScopes = (principal: AuthPrincipal) =>
|
||||
principal.role.scopes.map((scope) => scope.slug) ?? [];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ALL_ROLE_MAPS } from '../roles/role-maps.ee';
|
||||
import type { AllRoleTypes, Resource, Scope } from '../types.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)),
|
||||
@@ -10,6 +10,8 @@ export const COMBINED_ROLE_MAP = Object.fromEntries(
|
||||
* @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];
|
||||
@@ -18,3 +20,22 @@ export function getRoleScopes(role: AllRoleTypes, filters?: Resource[]): Scope[]
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getGlobalScopes } from './get-global-scopes.ee';
|
||||
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).
|
||||
@@ -12,6 +12,6 @@ export const hasGlobalScope = (
|
||||
scope: Scope | Scope[],
|
||||
scopeOptions?: ScopeOptions,
|
||||
): boolean => {
|
||||
const global = getGlobalScopes(principal);
|
||||
const global = getAuthPrincipalScopes(principal);
|
||||
return hasScope(scope, { global }, undefined, scopeOptions);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user