chore(core): Use roles from database in global roles (#18768)

This commit is contained in:
Andreas Fitzek
2025-08-26 17:53:46 +02:00
committed by GitHub
parent cff3f4a67e
commit ecad12b77a
117 changed files with 956 additions and 424 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,11 @@ export class ProjectRelationRepository extends Repository<ProjectRelation> {
projectId: In(projectIds),
role: 'project:personalOwner',
},
relations: { user: true },
relations: {
user: {
role: true,
},
},
});
}

View File

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

View File

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

View File

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

View File

@@ -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:*",
"*",
]
`;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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