refactor(core): Move more code into @n8n/permissions. Add aditional tests and docs (no-changelog) (#15062)

Co-authored-by: Danny Martini <danny@n8n.io>
This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™
2025-05-06 15:11:05 +02:00
committed by GitHub
co-authored by Danny Martini
parent cdcd059248
commit 2bb190349b
85 changed files with 1011 additions and 775 deletions
+1 -1
View File
@@ -113,7 +113,7 @@ export class AuthService {
const isWithinUsersLimit = this.license.isWithinUsersLimit();
if (
config.getEnv('userManagement.isInstanceOwnerSetUp') &&
!user.isOwner &&
user.role !== 'global:owner' &&
!isWithinUsersLimit
) {
throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
@@ -4,6 +4,7 @@ import {
ResolvePasswordTokenQueryDto,
} from '@n8n/api-types';
import { Body, Get, Post, Query, RestController } from '@n8n/decorators';
import { hasGlobalScope } from '@n8n/permissions';
import { Response } from 'express';
import { Logger } from 'n8n-core';
@@ -62,18 +63,23 @@ export class PasswordResetController {
// User should just be able to reset password if one is already present
const user = await this.userRepository.findNonShellUser(email);
if (!user) {
this.logger.debug('No user found in the system');
return;
}
if (!user?.isOwner && !this.license.isWithinUsersLimit()) {
if (user.role !== 'global:owner' && !this.license.isWithinUsersLimit()) {
this.logger.debug(
'Request to send password reset email failed because the user limit was reached',
);
throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
}
if (
isSamlCurrentAuthenticationMethod() &&
!(
user?.hasGlobalScope('user:resetPassword') === true ||
user?.settings?.allowSSOManualLogin === true
user &&
(hasGlobalScope(user, 'user:resetPassword') || user.settings?.allowSSOManualLogin === true)
)
) {
this.logger.debug(
@@ -84,8 +90,8 @@ export class PasswordResetController {
);
}
const ldapIdentity = user?.authIdentities?.find((i) => i.providerType === 'ldap');
if (!user?.password || (ldapIdentity && user.disabled)) {
const ldapIdentity = user.authIdentities?.find((i) => i.providerType === 'ldap');
if (!user.password || (ldapIdentity && user.disabled)) {
this.logger.debug(
'Request to send password reset email failed because no user was found for the provided email',
{ invalidEmail: email },
@@ -140,7 +146,7 @@ export class PasswordResetController {
const user = await this.authService.resolvePasswordResetToken(token);
if (!user) throw new NotFoundError('');
if (!user?.isOwner && !this.license.isWithinUsersLimit()) {
if (user.role !== 'global:owner' && !this.license.isWithinUsersLimit()) {
this.logger.debug(
'Request to resolve password token failed because the user limit was reached',
{ userId: user.id },
@@ -13,7 +13,7 @@ import {
Param,
Query,
} from '@n8n/decorators';
import { combineScopes } from '@n8n/permissions';
import { combineScopes, getRoleScopes, hasGlobalScope } from '@n8n/permissions';
import type { Scope } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In, Not } from '@n8n/typeorm';
@@ -30,13 +30,11 @@ import {
TeamProjectOverQuotaError,
UnlicensedProjectRoleError,
} from '@/services/project.service.ee';
import { RoleService } from '@/services/role.service';
@RestController('/projects')
export class ProjectController {
constructor(
private readonly projectsService: ProjectService,
private readonly roleService: RoleService,
private readonly projectRepository: ProjectRepository,
private readonly eventService: EventService,
) {}
@@ -69,8 +67,8 @@ export class ProjectController {
role: 'project:admin',
scopes: [
...combineScopes({
global: this.roleService.getRoleScopes(req.user.role),
project: this.roleService.getRoleScopes('project:admin'),
global: getRoleScopes(req.user.role),
project: getRoleScopes('project:admin'),
}),
],
};
@@ -88,7 +86,7 @@ export class ProjectController {
_res: Response,
): Promise<ProjectRequest.GetMyProjectsResponse> {
const relations = await this.projectsService.getProjectRelationsForUser(req.user);
const otherTeamProject = req.user.hasGlobalScope('project:read')
const otherTeamProject = hasGlobalScope(req.user, 'project:read')
? await this.projectRepository.findBy({
type: 'team',
id: Not(In(relations.map((pr) => pr.projectId))),
@@ -106,8 +104,8 @@ export class ProjectController {
if (result.scopes) {
result.scopes.push(
...combineScopes({
global: this.roleService.getRoleScopes(req.user.role),
project: this.roleService.getRoleScopes(pr.role),
global: getRoleScopes(req.user.role),
project: getRoleScopes(pr.role),
}),
);
}
@@ -128,9 +126,7 @@ export class ProjectController {
);
if (result.scopes) {
result.scopes.push(
...combineScopes({ global: this.roleService.getRoleScopes(req.user.role) }),
);
result.scopes.push(...combineScopes({ global: getRoleScopes(req.user.role) }));
}
results.push(result);
@@ -154,8 +150,8 @@ export class ProjectController {
}
const scopes: Scope[] = [
...combineScopes({
global: this.roleService.getRoleScopes(req.user.role),
project: this.roleService.getRoleScopes('project:personalOwner'),
global: getRoleScopes(req.user.role),
project: getRoleScopes('project:personalOwner'),
}),
];
return {
@@ -191,8 +187,8 @@ export class ProjectController {
})),
scopes: [
...combineScopes({
global: this.roleService.getRoleScopes(req.user.role),
...(myRelation ? { project: this.roleService.getRoleScopes(myRelation.role) } : {}),
global: getRoleScopes(req.user.role),
...(myRelation ? { project: getRoleScopes(myRelation.role) } : {}),
}),
],
};
@@ -1,23 +1,13 @@
import { Get, RestController } from '@n8n/decorators';
import { type AllRoleTypes, RoleService } from '@/services/role.service';
import { RoleService } from '@/services/role.service';
@RestController('/roles')
export class RoleController {
constructor(private readonly roleService: RoleService) {}
@Get('/')
async getAllRoles() {
return Object.fromEntries(
Object.entries(this.roleService.getRoles()).map((e) => [
e[0],
(e[1] as AllRoleTypes[]).map((r) => ({
name: this.roleService.getRoleName(r),
role: r,
scopes: this.roleService.getRoleScopes(r),
licensed: this.roleService.isRoleLicensed(r),
})),
]),
);
getAllRoles() {
return this.roleService.getAllRoles();
}
}
@@ -1,21 +1,19 @@
import type { ProjectRole } from '@n8n/api-types';
import type { CredentialsEntity, SharedCredentials, CredentialSharingRole, User } from '@n8n/db';
import type { CredentialsEntity, SharedCredentials, User } from '@n8n/db';
import { CredentialsRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import { hasGlobalScope, rolesWithScope } from '@n8n/permissions';
import type { CredentialSharingRole, ProjectRole, Scope } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm';
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
import { RoleService } from '@/services/role.service';
@Service()
export class CredentialsFinderService {
constructor(
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly roleService: RoleService,
private readonly credentialsRepository: CredentialsRepository,
) {}
@@ -29,9 +27,9 @@ export class CredentialsFinderService {
async findCredentialsForUser(user: User, scopes: Scope[]) {
let where: FindOptionsWhere<CredentialsEntity> = {};
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes);
const credentialRoles = this.roleService.rolesWithScope('credential', scopes);
if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
const projectRoles = rolesWithScope('project', scopes);
const credentialRoles = rolesWithScope('credential', scopes);
where = {
...where,
shared: {
@@ -53,9 +51,9 @@ export class CredentialsFinderService {
async findCredentialForUser(credentialsId: string, user: User, scopes: Scope[]) {
let where: FindOptionsWhere<SharedCredentials> = { credentialsId };
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes);
const credentialRoles = this.roleService.rolesWithScope('credential', scopes);
if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
const projectRoles = rolesWithScope('project', scopes);
const credentialRoles = rolesWithScope('credential', scopes);
where = {
...where,
role: In(credentialRoles),
@@ -85,9 +83,9 @@ export class CredentialsFinderService {
async findAllCredentialsForUser(user: User, scopes: Scope[], trx?: EntityManager) {
let where: FindOptionsWhere<SharedCredentials> = {};
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes);
const credentialRoles = this.roleService.rolesWithScope('credential', scopes);
if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
const projectRoles = rolesWithScope('project', scopes);
const credentialRoles = rolesWithScope('credential', scopes);
where = {
role: In(credentialRoles),
project: {
@@ -115,13 +113,9 @@ export class CredentialsFinderService {
trx?: EntityManager,
) {
const projectRoles =
'scopes' in options
? this.roleService.rolesWithScope('project', options.scopes)
: options.projectRoles;
'scopes' in options ? rolesWithScope('project', options.scopes) : options.projectRoles;
const credentialRoles =
'scopes' in options
? this.roleService.rolesWithScope('credential', options.scopes)
: options.credentialRoles;
'scopes' in options ? rolesWithScope('credential', options.scopes) : options.credentialRoles;
const sharings = await this.sharedCredentialsRepository.findCredentialsByRoles(
userIds,
@@ -1,6 +1,7 @@
import { Project, SharedCredentials } from '@n8n/db';
import type { CredentialsEntity, User } from '@n8n/db';
import { Service } from '@n8n/di';
import { hasGlobalScope, rolesWithScope } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In, type EntityManager } from '@n8n/typeorm';
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
@@ -10,7 +11,6 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { TransferCredentialError } from '@/errors/response-errors/transfer-credential.error';
import { OwnershipService } from '@/services/ownership.service';
import { ProjectService } from '@/services/project.service.ee';
import { RoleService } from '@/services/role.service';
import { CredentialsFinderService } from './credentials-finder.service';
import { CredentialsService } from './credentials.service';
@@ -22,7 +22,6 @@ export class EnterpriseCredentialsService {
private readonly ownershipService: OwnershipService,
private readonly credentialsService: CredentialsService,
private readonly projectService: ProjectService,
private readonly roleService: RoleService,
private readonly credentialsFinderService: CredentialsFinderService,
) {}
@@ -41,12 +40,12 @@ export class EnterpriseCredentialsService {
type: 'team',
// if user can see all projects, don't check project access
// if they can't, find projects they can list
...(user.hasGlobalScope('project:list')
...(hasGlobalScope(user, 'project:list')
? {}
: {
projectRelations: {
userId: user.id,
role: In(this.roleService.rolesWithScope('project', 'project:list')),
role: In(rolesWithScope('project', 'project:list')),
},
}),
},
@@ -2,7 +2,7 @@ import type { CreateCredentialDto } from '@n8n/api-types';
import type { Project, User, ICredentialsDb, ScopesField } from '@n8n/db';
import { CredentialsEntity, SharedCredentials, CredentialsRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import { hasGlobalScope, type Scope } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import {
In,
@@ -79,7 +79,7 @@ export class CredentialsService {
onlySharedWithMe?: boolean;
} = {},
) {
const returnAll = user.hasGlobalScope('credential:list');
const returnAll = hasGlobalScope(user, 'credential:list');
const isDefaultSelect = !listQueryOptions.select;
const projectId =
typeof listQueryOptions.filter?.projectId === 'string'
@@ -255,7 +255,7 @@ export class CredentialsService {
// If the workflow is owned by a personal project and the owner of the
// project has global read permissions it can use all personal credentials.
const user = await this.userRepository.findPersonalOwnerForWorkflow(workflowId);
if (user?.hasGlobalScope('credential:read')) {
if (user && hasGlobalScope(user, 'credential:read')) {
return await this.credentialsRepository.findAllPersonalCredentials();
}
@@ -269,7 +269,7 @@ export class CredentialsService {
// read permissions then all workflows in that project can use all
// credentials of all personal projects.
const user = await this.userRepository.findPersonalOwnerForProject(projectId);
if (user?.hasGlobalScope('credential:read')) {
if (user && hasGlobalScope(user, 'credential:read')) {
return await this.credentialsRepository.findAllPersonalCredentials();
}
@@ -289,7 +289,7 @@ export class CredentialsService {
): Promise<SharedCredentials | null> {
let where: FindOptionsWhere<SharedCredentials> = { credentialsId: credentialId };
if (!user.hasGlobalScope(globalScopes, { mode: 'allOf' })) {
if (!hasGlobalScope(user, globalScopes, { mode: 'allOf' })) {
where = {
...where,
role: 'credential:owner',
@@ -1,6 +1,6 @@
import type { ProjectRole } from '@n8n/api-types';
import { generateNanoId } from '@n8n/db';
import type { User } from '@n8n/db';
import type { ProjectRole } from '@n8n/permissions';
import { UserError } from 'n8n-workflow';
import { nanoid } from 'nanoid';
@@ -1,6 +1,6 @@
import type { ProjectRole } from '@n8n/api-types';
import { ProjectRelation } from '@n8n/db';
import { Service } from '@n8n/di';
import type { ProjectRole } from '@n8n/permissions';
import { DataSource, In, Repository } from '@n8n/typeorm';
@Service()
@@ -1,7 +1,7 @@
import type { ProjectRole } from '@n8n/api-types';
import { SharedCredentials } from '@n8n/db';
import type { Project, CredentialSharingRole } from '@n8n/db';
import type { Project } from '@n8n/db';
import { Service } from '@n8n/di';
import type { CredentialSharingRole, ProjectRole } from '@n8n/permissions';
import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm';
import { DataSource, In, Not, Repository } from '@n8n/typeorm';
@@ -1,6 +1,7 @@
import { SharedWorkflow } from '@n8n/db';
import type { Project, WorkflowSharingRole } from '@n8n/db';
import type { Project } from '@n8n/db';
import { Service } from '@n8n/di';
import type { WorkflowSharingRole } from '@n8n/permissions';
import { DataSource, Repository, In, Not } from '@n8n/typeorm';
import type { EntityManager, FindManyOptions, FindOptionsWhere } from '@n8n/typeorm';
@@ -95,9 +95,7 @@ describe('CredentialsPermissionChecker', () => {
});
it('should skip credential checks if the home project owner has global scope', async () => {
const projectOwner = mock<User>({
hasGlobalScope: (scope) => scope === 'credential:list',
});
const projectOwner = mock<User>({ role: 'global:owner' });
ownershipService.getPersonalProjectOwnerCached.mockResolvedValueOnce(projectOwner);
await expect(permissionChecker.check(workflowId, [node])).resolves.not.toThrow();
@@ -1,5 +1,6 @@
import type { Project } from '@n8n/db';
import { Service } from '@n8n/di';
import { hasGlobalScope } from '@n8n/permissions';
import type { INode } from 'n8n-workflow';
import { UserError } from 'n8n-workflow';
@@ -45,7 +46,11 @@ export class CredentialsPermissionChecker {
const homeProjectOwner = await this.ownershipService.getPersonalProjectOwnerCached(
homeProject.id,
);
if (homeProject.type === 'personal' && homeProjectOwner?.hasGlobalScope('credential:list')) {
if (
homeProject.type === 'personal' &&
homeProjectOwner &&
hasGlobalScope(homeProjectOwner, 'credential:list')
) {
// Workflow belongs to a project by a user with privileges
// so all credentials are usable. Skip credential checks.
return;
+2 -2
View File
@@ -1,5 +1,5 @@
import type { ICredentialsBase, IExecutionBase, IExecutionDb, ITagBase } from '@n8n/db';
import type { AssignableRole } from '@n8n/permissions';
import type { AssignableGlobalRole } from '@n8n/permissions';
import type { Application } from 'express';
import type {
ExecutionError,
@@ -207,7 +207,7 @@ export interface ILicensePostResponse extends ILicenseReadResponse {
export interface Invitation {
email: string;
role: AssignableRole;
role: AssignableGlobalRole;
}
export interface N8nApp {
@@ -1,6 +1,6 @@
import type { User } from '@n8n/db';
import { Container } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import { hasGlobalScope, rolesWithScope, type Scope } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm';
import { UnexpectedError } from 'n8n-workflow';
@@ -8,7 +8,6 @@ import { UnexpectedError } from 'n8n-workflow';
import { ProjectRepository } from '@/databases/repositories/project.repository';
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import { RoleService } from '@/services/role.service';
/**
* Check if a user has the required scopes. The check can be:
@@ -28,15 +27,14 @@ export async function userHasScopes(
projectId,
}: { credentialId?: string; workflowId?: string; projectId?: string } /* only one */,
): Promise<boolean> {
if (user.hasGlobalScope(scopes, { mode: 'allOf' })) return true;
if (hasGlobalScope(user, scopes, { mode: 'allOf' })) return true;
if (globalOnly) return false;
// Find which project roles are defined to contain the required scopes.
// Then find projects having this user and having those project roles.
const roleService = Container.get(RoleService);
const projectRoles = roleService.rolesWithScope('project', scopes);
const projectRoles = rolesWithScope('project', scopes);
const userProjectIds = (
await Container.get(ProjectRepository).find({
where: {
@@ -57,7 +55,7 @@ export async function userHasScopes(
return await Container.get(SharedCredentialsRepository).existsBy({
credentialsId: credentialId,
projectId: In(userProjectIds),
role: In(roleService.rolesWithScope('credential', scopes)),
role: In(rolesWithScope('credential', scopes)),
});
}
@@ -65,7 +63,7 @@ export async function userHasScopes(
return await Container.get(SharedWorkflowRepository).existsBy({
workflowId,
projectId: In(userProjectIds),
role: In(roleService.rolesWithScope('workflow', scopes)),
role: In(rolesWithScope('workflow', scopes)),
});
}
@@ -1,8 +1,8 @@
import { GlobalConfig } from '@n8n/config';
import type { Project, WorkflowSharingRole, User } from '@n8n/db';
import type { Project, User } from '@n8n/db';
import { WorkflowEntity, WorkflowTagMapping, SharedWorkflow } from '@n8n/db';
import { Container } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import type { Scope, WorkflowSharingRole } from '@n8n/permissions';
import type { WorkflowId } from 'n8n-workflow';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
+3 -3
View File
@@ -1,6 +1,6 @@
import type { ProjectIcon, ProjectRole, ProjectType } from '@n8n/api-types';
import type { ProjectIcon, ProjectType } from '@n8n/api-types';
import type { Variables, Project, User, ListQueryDb, WorkflowHistory } from '@n8n/db';
import type { AssignableRole, GlobalRole, Scope } from '@n8n/permissions';
import type { AssignableGlobalRole, GlobalRole, ProjectRole, Scope } from '@n8n/permissions';
import type express from 'express';
import type {
ICredentialDataDecryptedObject,
@@ -137,7 +137,7 @@ export declare namespace UserRequest {
email: string;
inviteAcceptUrl?: string;
emailSent: boolean;
role: AssignableRole;
role: AssignableGlobalRole;
};
error?: string;
};
@@ -43,21 +43,19 @@ describe('ActiveWorkflowsService', () => {
});
it('should return all workflow ids when user has full access', async () => {
user.hasGlobalScope.mockReturnValue(true);
user.role = 'global:admin';
const ids = await service.getAllActiveIdsFor(user);
expect(ids).toEqual(['2', '3', '4']);
expect(user.hasGlobalScope).toHaveBeenCalledWith('workflow:list');
expect(sharedWorkflowRepository.getSharedWorkflowIds).not.toHaveBeenCalled();
});
it('should filter out workflow ids that the user does not have access to', async () => {
user.hasGlobalScope.mockReturnValue(false);
user.role = 'global:member';
sharedWorkflowRepository.getSharedWorkflowIds.mockResolvedValue(['3']);
const ids = await service.getAllActiveIdsFor(user);
expect(ids).toEqual(['3']);
expect(user.hasGlobalScope).toHaveBeenCalledWith('workflow:list');
expect(sharedWorkflowRepository.getSharedWorkflowIds).toHaveBeenCalledWith(activeIds);
});
});
@@ -2,8 +2,6 @@ import { SharedCredentials } from '@n8n/db';
import type { CredentialsEntity } from '@n8n/db';
import type { User } from '@n8n/db';
import { Container } from '@n8n/di';
import { hasScope } from '@n8n/permissions';
import { GLOBAL_MEMBER_SCOPES, GLOBAL_OWNER_SCOPES } from '@n8n/permissions';
import { In } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended';
@@ -19,19 +17,11 @@ describe('CredentialsFinderService', () => {
const sharedCredential = mock<SharedCredentials>();
sharedCredential.credentials = mock<CredentialsEntity>({ id: credentialsId });
const owner = mock<User>({
isOwner: true,
hasGlobalScope: (scope) =>
hasScope(scope, {
global: GLOBAL_OWNER_SCOPES,
}),
role: 'global:owner',
});
const member = mock<User>({
isOwner: false,
role: 'global:member',
id: 'test',
hasGlobalScope: (scope) =>
hasScope(scope, {
global: GLOBAL_MEMBER_SCOPES,
}),
});
beforeEach(() => {
@@ -1,5 +1,6 @@
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import { hasGlobalScope } from '@n8n/permissions';
import { Logger } from 'n8n-core';
import { ActivationErrorsService } from '@/activation-errors.service';
@@ -28,7 +29,7 @@ export class ActiveWorkflowsService {
const activationErrors = await this.activationErrorsService.getAll();
const activeWorkflowIds = await this.workflowRepository.getActiveIds();
const hasFullAccess = user.hasGlobalScope('workflow:list');
const hasFullAccess = hasGlobalScope(user, 'workflow:list');
if (hasFullAccess) {
return activeWorkflowIds.filter((workflowId) => !activationErrors[workflowId]);
}
@@ -1,9 +1,9 @@
import type { CreateProjectDto, ProjectRole, ProjectType, UpdateProjectDto } from '@n8n/api-types';
import type { CreateProjectDto, ProjectType, UpdateProjectDto } from '@n8n/api-types';
import { UNLIMITED_LICENSE_QUOTA } from '@n8n/constants';
import type { User } from '@n8n/db';
import { Project, ProjectRelation } from '@n8n/db';
import { Container, Service } from '@n8n/di';
import { type Scope } from '@n8n/permissions';
import { hasGlobalScope, rolesWithScope, type Scope, type ProjectRole } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { FindOptionsWhere, EntityManager } from '@n8n/typeorm';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
@@ -20,7 +20,6 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { License } from '@/license';
import { CacheService } from './cache/cache.service';
import { RoleService } from './role.service';
export class TeamProjectOverQuotaError extends UserError {
constructor(limit: number) {
@@ -42,7 +41,6 @@ export class ProjectService {
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly projectRepository: ProjectRepository,
private readonly projectRelationRepository: ProjectRelationRepository,
private readonly roleService: RoleService,
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly cacheService: CacheService,
private readonly license: License,
@@ -168,7 +166,7 @@ export class ProjectService {
async getAccessibleProjects(user: User): Promise<Project[]> {
// This user is probably an admin, show them everything
if (user.hasGlobalScope('project:read')) {
if (hasGlobalScope(user, 'project:read')) {
return await this.projectRepository.find();
}
return await this.projectRepository.getAccessibleProjects(user.id);
@@ -234,7 +232,7 @@ export class ProjectService {
const existing = project.projectRelations.find((pr) => pr.userId === r.userId);
// We don't throw an error if the user already exists with that role so
// existing projects continue working as is.
if (existing?.role !== r.role && !this.roleService.isRoleLicensed(r.role)) {
if (existing?.role !== r.role && !this.isProjectRoleLicensed(r.role)) {
throw new UnlicensedProjectRoleError(r.role);
}
}
@@ -246,6 +244,19 @@ export class ProjectService {
await this.clearCredentialCanUseExternalSecretsCache(projectId);
}
private isProjectRoleLicensed(role: ProjectRole) {
switch (role) {
case 'project:admin':
return this.license.isProjectRoleAdminLicensed();
case 'project:editor':
return this.license.isProjectRoleEditorLicensed();
case 'project:viewer':
return this.license.isProjectRoleViewerLicensed();
default:
return true;
}
}
async clearCredentialCanUseExternalSecretsCache(projectId: string) {
const shares = await this.sharedCredentialsRepository.find({
where: {
@@ -293,8 +304,8 @@ export class ProjectService {
id: projectId,
};
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes);
if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
const projectRoles = rolesWithScope('project', scopes);
where = {
...where,
+14 -144
View File
@@ -1,159 +1,30 @@
import type { ProjectRole } from '@n8n/api-types';
import type {
CredentialsEntity,
CredentialSharingRole,
SharedCredentials,
SharedWorkflow,
WorkflowSharingRole,
User,
ListQueryDb,
ScopesField,
ProjectRelation,
} from '@n8n/db';
import { Service } from '@n8n/di';
import type { GlobalRole, Resource, Scope } from '@n8n/permissions';
import {
combineScopes,
GLOBAL_ADMIN_SCOPES,
GLOBAL_MEMBER_SCOPES,
GLOBAL_OWNER_SCOPES,
PERSONAL_PROJECT_OWNER_SCOPES,
PROJECT_EDITOR_SCOPES,
PROJECT_VIEWER_SCOPES,
REGULAR_PROJECT_ADMIN_SCOPES,
CREDENTIALS_SHARING_OWNER_SCOPES,
CREDENTIALS_SHARING_USER_SCOPES,
WORKFLOW_SHARING_EDITOR_SCOPES,
WORKFLOW_SHARING_OWNER_SCOPES,
} from '@n8n/permissions';
import type { AllRoleTypes, Scope } from '@n8n/permissions';
import { ALL_ROLES, combineScopes, getRoleScopes } from '@n8n/permissions';
import { UnexpectedError } from 'n8n-workflow';
import { License } from '@/license';
export type RoleNamespace = 'global' | 'project' | 'credential' | 'workflow';
const GLOBAL_SCOPE_MAP: Record<GlobalRole, Scope[]> = {
'global:owner': GLOBAL_OWNER_SCOPES,
'global:admin': GLOBAL_ADMIN_SCOPES,
'global:member': GLOBAL_MEMBER_SCOPES,
};
const PROJECT_SCOPE_MAP: Record<ProjectRole, Scope[]> = {
'project:admin': REGULAR_PROJECT_ADMIN_SCOPES,
'project:personalOwner': PERSONAL_PROJECT_OWNER_SCOPES,
'project:editor': PROJECT_EDITOR_SCOPES,
'project:viewer': PROJECT_VIEWER_SCOPES,
};
const CREDENTIALS_SHARING_SCOPE_MAP: Record<CredentialSharingRole, Scope[]> = {
'credential:owner': CREDENTIALS_SHARING_OWNER_SCOPES,
'credential:user': CREDENTIALS_SHARING_USER_SCOPES,
};
const WORKFLOW_SHARING_SCOPE_MAP: Record<WorkflowSharingRole, Scope[]> = {
'workflow:owner': WORKFLOW_SHARING_OWNER_SCOPES,
'workflow:editor': WORKFLOW_SHARING_EDITOR_SCOPES,
};
interface AllMaps {
global: Record<GlobalRole, Scope[]>;
project: Record<ProjectRole, Scope[]>;
credential: Record<CredentialSharingRole, Scope[]>;
workflow: Record<WorkflowSharingRole, Scope[]>;
}
const ALL_MAPS: AllMaps = {
global: GLOBAL_SCOPE_MAP,
project: PROJECT_SCOPE_MAP,
credential: CREDENTIALS_SHARING_SCOPE_MAP,
workflow: WORKFLOW_SHARING_SCOPE_MAP,
} as const;
const COMBINED_MAP = Object.fromEntries(
Object.values(ALL_MAPS).flatMap((o: Record<string, Scope[]>) => Object.entries(o)),
) as Record<GlobalRole | ProjectRole | CredentialSharingRole | WorkflowSharingRole, Scope[]>;
export interface RoleMap {
global: GlobalRole[];
project: ProjectRole[];
credential: CredentialSharingRole[];
workflow: WorkflowSharingRole[];
}
export type AllRoleTypes = GlobalRole | ProjectRole | WorkflowSharingRole | CredentialSharingRole;
const ROLE_NAMES: Record<
GlobalRole | ProjectRole | WorkflowSharingRole | CredentialSharingRole,
string
> = {
'global:owner': 'Owner',
'global:admin': 'Admin',
'global:member': 'Member',
'project:personalOwner': 'Project Owner',
'project:admin': 'Project Admin',
'project:editor': 'Project Editor',
'project:viewer': 'Project Viewer',
'credential:user': 'Credential User',
'credential:owner': 'Credential Owner',
'workflow:owner': 'Workflow Owner',
'workflow:editor': 'Workflow Editor',
};
// export type ScopesField = { scopes: Scope[] };
@Service()
export class RoleService {
constructor(private readonly license: License) {}
rolesWithScope(namespace: 'global', scopes: Scope | Scope[]): GlobalRole[];
rolesWithScope(namespace: 'project', scopes: Scope | Scope[]): ProjectRole[];
rolesWithScope(namespace: 'credential', scopes: Scope | Scope[]): CredentialSharingRole[];
rolesWithScope(namespace: 'workflow', scopes: Scope | Scope[]): WorkflowSharingRole[];
rolesWithScope(namespace: RoleNamespace, scopes: Scope | Scope[]) {
if (!Array.isArray(scopes)) {
scopes = [scopes];
}
return Object.keys(ALL_MAPS[namespace]).filter((k) => {
return scopes.every((s) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
((ALL_MAPS[namespace] as any)[k] as Scope[]).includes(s),
);
getAllRoles() {
Object.values(ALL_ROLES).forEach((entries) => {
entries.forEach((entry) => {
entry.licensed = this.isRoleLicensed(entry.role);
});
});
}
getRoles(): RoleMap {
return Object.fromEntries(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
Object.entries(ALL_MAPS).map((e) => [e[0], Object.keys(e[1])]),
) as unknown as RoleMap;
}
getRoleName(role: AllRoleTypes): string {
return ROLE_NAMES[role];
}
getRoleScopes(
role: GlobalRole | ProjectRole | WorkflowSharingRole | CredentialSharingRole,
filters?: Resource[],
): Scope[] {
let scopes = COMBINED_MAP[role];
if (filters) {
scopes = scopes.filter((s) => filters.includes(s.split(':')[0] as Resource));
}
return scopes;
}
/**
* Find all distinct scopes in a set of project roles.
*/
getScopesBy(projectRoles: Set<ProjectRole>) {
return [...projectRoles].reduce<Set<Scope>>((acc, projectRole) => {
for (const scope of PROJECT_SCOPE_MAP[projectRole] ?? []) {
acc.add(scope);
}
return acc;
}, new Set());
return ALL_ROLES;
}
addScopes(
@@ -192,9 +63,7 @@ export class RoleService {
| ListQueryDb.Workflow.WithScopes
| ListQueryDb.Credentials.WithScopes;
Object.assign(entity, {
scopes: [],
});
entity.scopes = [];
if (shared === undefined) {
return entity;
@@ -220,7 +89,7 @@ export class RoleService {
shared: SharedCredentials[] | SharedWorkflow[],
userProjectRelations: ProjectRelation[],
): Scope[] {
const globalScopes = this.getRoleScopes(user.role, [type]);
const globalScopes = getRoleScopes(user.role, [type]);
const scopesSet: Set<Scope> = new Set(globalScopes);
for (const sharedEntity of shared) {
const pr = userProjectRelations.find(
@@ -228,9 +97,9 @@ export class RoleService {
);
let projectScopes: Scope[] = [];
if (pr) {
projectScopes = this.getRoleScopes(pr.role);
projectScopes = getRoleScopes(pr.role);
}
const resourceMask = this.getRoleScopes(sharedEntity.role);
const resourceMask = getRoleScopes(sharedEntity.role);
const mergedScopes = combineScopes(
{
global: globalScopes,
@@ -243,7 +112,8 @@ export class RoleService {
return [...scopesSet].sort();
}
isRoleLicensed(role: AllRoleTypes) {
private isRoleLicensed(role: AllRoleTypes) {
// TODO: move this info into FrontendSettings
switch (role) {
case 'project:admin':
return this.license.isProjectRoleAdminLicensed();
+5 -3
View File
@@ -2,7 +2,7 @@ import type { RoleChangeRequestDto } from '@n8n/api-types';
import { User } from '@n8n/db';
import type { PublicUser } from '@n8n/db';
import { Service } from '@n8n/di';
import type { AssignableRole } from '@n8n/permissions';
import { getGlobalScopes, type AssignableGlobalRole } from '@n8n/permissions';
import { Logger } from 'n8n-core';
import type { IUserSettings } from 'n8n-workflow';
import { UnexpectedError } from 'n8n-workflow';
@@ -71,6 +71,7 @@ export class UserService {
let publicUser: PublicUser = {
...rest,
signInType: ldapIdentity ? 'ldap' : 'email',
isOwner: user.role === 'global:owner',
};
if (options?.withInviteUrl && !options?.inviterId) {
@@ -85,8 +86,9 @@ export class UserService {
publicUser = await this.addFeatureFlags(publicUser, options.posthog);
}
// TODO: resolve these directly in the frontend
if (options?.withScopes) {
publicUser.globalScopes = user.globalScopes;
publicUser.globalScopes = getGlobalScopes(user);
}
return publicUser;
@@ -123,7 +125,7 @@ export class UserService {
private async sendEmails(
owner: User,
toInviteUsers: { [key: string]: string },
role: AssignableRole,
role: AssignableGlobalRole,
) {
const domain = this.urlService.getInstanceBaseUrl();
@@ -1,20 +1,16 @@
import type { SharedWorkflow, User } from '@n8n/db';
import { Service } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import { hasGlobalScope, rolesWithScope, type Scope } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm';
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
import { RoleService } from '@/services/role.service';
@Service()
export class WorkflowFinderService {
constructor(
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly roleService: RoleService,
) {}
constructor(private readonly sharedWorkflowRepository: SharedWorkflowRepository) {}
async findWorkflowForUser(
workflowId: string,
@@ -28,9 +24,9 @@ export class WorkflowFinderService {
) {
let where: FindOptionsWhere<SharedWorkflow> = {};
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes);
const workflowRoles = this.roleService.rolesWithScope('workflow', scopes);
if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
const projectRoles = rolesWithScope('project', scopes);
const workflowRoles = rolesWithScope('workflow', scopes);
where = {
role: In(workflowRoles),
@@ -60,9 +56,9 @@ export class WorkflowFinderService {
async findAllWorkflowsForUser(user: User, scopes: Scope[]) {
let where: FindOptionsWhere<SharedWorkflow> = {};
if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) {
const projectRoles = this.roleService.rolesWithScope('project', scopes);
const workflowRoles = this.roleService.rolesWithScope('workflow', scopes);
if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
const projectRoles = rolesWithScope('project', scopes);
const workflowRoles = rolesWithScope('workflow', scopes);
where = {
...where,
@@ -1,7 +1,12 @@
import type { ProjectRole } from '@n8n/api-types';
import type { WorkflowSharingRole, User } from '@n8n/db';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import {
hasGlobalScope,
rolesWithScope,
type ProjectRole,
type WorkflowSharingRole,
type Scope,
} from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm';
@@ -33,7 +38,7 @@ export class WorkflowSharingService {
async getSharedWorkflowIds(user: User, options: ShareWorkflowOptions): Promise<string[]> {
const { projectId } = options;
if (user.hasGlobalScope('workflow:read')) {
if (hasGlobalScope(user, 'workflow:read')) {
const sharedWorkflows = await this.sharedWorkflowRepository.find({
select: ['workflowId'],
...(projectId && { where: { projectId } }),
@@ -42,13 +47,9 @@ export class WorkflowSharingService {
}
const projectRoles =
'scopes' in options
? this.roleService.rolesWithScope('project', options.scopes)
: options.projectRoles;
'scopes' in options ? rolesWithScope('project', options.scopes) : options.projectRoles;
const workflowRoles =
'scopes' in options
? this.roleService.rolesWithScope('workflow', options.scopes)
: options.workflowRoles;
'scopes' in options ? rolesWithScope('workflow', options.scopes) : options.workflowRoles;
const sharedWorkflows = await this.sharedWorkflowRepository.find({
where: {