feat: RBAC (#8922)
Signed-off-by: Oleg Ivaniv <me@olegivaniv.com> Co-authored-by: Val <68596159+valya@users.noreply.github.com> Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in> Co-authored-by: Valya Bullions <valya@n8n.io> Co-authored-by: Danny Martini <danny@n8n.io> Co-authored-by: Danny Martini <despair.blue@gmail.com> Co-authored-by: Iván Ovejero <ivov.src@gmail.com> Co-authored-by: Omar Ajoue <krynble@gmail.com> Co-authored-by: oleg <me@olegivaniv.com> Co-authored-by: Michael Kret <michael.k@radency.com> Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com> Co-authored-by: Elias Meire <elias@meire.dev> Co-authored-by: Giulio Andreini <andreini@netseven.it> Co-authored-by: Giulio Andreini <g.andreini@gmail.com> Co-authored-by: Ayato Hayashi <go12limchangyong@gmail.com>
This commit is contained in:
@@ -21,7 +21,7 @@ import { MfaService } from '@/Mfa/mfa.service';
|
||||
import { Logger } from '@/Logger';
|
||||
import { AuthError } from '@/errors/response-errors/auth.error';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { ApplicationError } from 'n8n-workflow';
|
||||
import { UserRepository } from '@/databases/repositories/user.repository';
|
||||
|
||||
@@ -130,7 +130,7 @@ export class AuthController {
|
||||
inviterId,
|
||||
inviteeId,
|
||||
});
|
||||
throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
|
||||
throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
|
||||
}
|
||||
|
||||
if (!inviterId || !inviteeId) {
|
||||
|
||||
@@ -6,10 +6,10 @@ import { UserRepository } from '@db/repositories/user.repository';
|
||||
import { ActiveWorkflowManager } from '@/ActiveWorkflowManager';
|
||||
import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus';
|
||||
import { License } from '@/License';
|
||||
import { LICENSE_FEATURES, inE2ETests } from '@/constants';
|
||||
import { LICENSE_FEATURES, LICENSE_QUOTAS, UNLIMITED_LICENSE_QUOTA, inE2ETests } from '@/constants';
|
||||
import { Patch, Post, RestController } from '@/decorators';
|
||||
import type { UserSetupPayload } from '@/requests';
|
||||
import type { BooleanLicenseFeature, IPushDataType } from '@/Interfaces';
|
||||
import type { BooleanLicenseFeature, IPushDataType, NumericLicenseFeature } from '@/Interfaces';
|
||||
import { MfaService } from '@/Mfa/mfa.service';
|
||||
import { Push } from '@/push';
|
||||
import { CacheService } from '@/services/cache/cache.service';
|
||||
@@ -25,21 +25,23 @@ if (!inE2ETests) {
|
||||
const tablesToTruncate = [
|
||||
'auth_identity',
|
||||
'auth_provider_sync_history',
|
||||
'event_destinations',
|
||||
'shared_workflow',
|
||||
'shared_credentials',
|
||||
'webhook_entity',
|
||||
'workflows_tags',
|
||||
'credentials_entity',
|
||||
'tag_entity',
|
||||
'workflow_statistics',
|
||||
'workflow_entity',
|
||||
'event_destinations',
|
||||
'execution_entity',
|
||||
'settings',
|
||||
'installed_packages',
|
||||
'installed_nodes',
|
||||
'installed_packages',
|
||||
'project',
|
||||
'project_relation',
|
||||
'settings',
|
||||
'shared_credentials',
|
||||
'shared_workflow',
|
||||
'tag_entity',
|
||||
'user',
|
||||
'variables',
|
||||
'webhook_entity',
|
||||
'workflow_entity',
|
||||
'workflow_statistics',
|
||||
'workflows_tags',
|
||||
];
|
||||
|
||||
type ResetRequest = Request<
|
||||
@@ -81,21 +83,35 @@ export class E2EController {
|
||||
[LICENSE_FEATURES.MULTIPLE_MAIN_INSTANCES]: false,
|
||||
[LICENSE_FEATURES.WORKER_VIEW]: false,
|
||||
[LICENSE_FEATURES.ADVANCED_PERMISSIONS]: false,
|
||||
[LICENSE_FEATURES.PROJECT_ROLE_ADMIN]: false,
|
||||
[LICENSE_FEATURES.PROJECT_ROLE_EDITOR]: false,
|
||||
[LICENSE_FEATURES.PROJECT_ROLE_VIEWER]: false,
|
||||
};
|
||||
|
||||
private numericFeatures: Record<NumericLicenseFeature, number> = {
|
||||
[LICENSE_QUOTAS.TRIGGER_LIMIT]: -1,
|
||||
[LICENSE_QUOTAS.VARIABLES_LIMIT]: -1,
|
||||
[LICENSE_QUOTAS.USERS_LIMIT]: -1,
|
||||
[LICENSE_QUOTAS.WORKFLOW_HISTORY_PRUNE_LIMIT]: -1,
|
||||
[LICENSE_QUOTAS.TEAM_PROJECT_LIMIT]: 0,
|
||||
};
|
||||
|
||||
constructor(
|
||||
license: License,
|
||||
private readonly settingsRepo: SettingsRepository,
|
||||
private readonly userRepo: UserRepository,
|
||||
private readonly workflowRunner: ActiveWorkflowManager,
|
||||
private readonly mfaService: MfaService,
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly push: Push,
|
||||
private readonly passwordUtility: PasswordUtility,
|
||||
private readonly eventBus: MessageEventBus,
|
||||
private readonly userRepository: UserRepository,
|
||||
) {
|
||||
license.isFeatureEnabled = (feature: BooleanLicenseFeature) =>
|
||||
this.enabledFeatures[feature] ?? false;
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
license.getFeatureValue<NumericLicenseFeature> = (feature: NumericLicenseFeature) =>
|
||||
this.numericFeatures[feature] ?? UNLIMITED_LICENSE_QUOTA;
|
||||
}
|
||||
|
||||
@Post('/reset', { skipAuth: true })
|
||||
@@ -119,6 +135,12 @@ export class E2EController {
|
||||
this.enabledFeatures[feature] = enabled;
|
||||
}
|
||||
|
||||
@Patch('/quota', { skipAuth: true })
|
||||
setQuota(req: Request<{}, {}, { feature: NumericLicenseFeature; value: number }>) {
|
||||
const { value, feature } = req.body;
|
||||
this.numericFeatures[feature] = value;
|
||||
}
|
||||
|
||||
@Patch('/queue-mode', { skipAuth: true })
|
||||
async setQueueMode(req: Request<{}, {}, { enabled: boolean }>) {
|
||||
const { enabled } = req.body;
|
||||
@@ -163,34 +185,34 @@ export class E2EController {
|
||||
members: UserSetupPayload[],
|
||||
admin: UserSetupPayload,
|
||||
) {
|
||||
const instanceOwner = this.userRepo.create({
|
||||
id: uuid(),
|
||||
...owner,
|
||||
password: await this.passwordUtility.hash(owner.password),
|
||||
role: 'global:owner',
|
||||
});
|
||||
|
||||
if (owner?.mfaSecret && owner.mfaRecoveryCodes?.length) {
|
||||
const { encryptedRecoveryCodes, encryptedSecret } =
|
||||
this.mfaService.encryptSecretAndRecoveryCodes(owner.mfaSecret, owner.mfaRecoveryCodes);
|
||||
instanceOwner.mfaSecret = encryptedSecret;
|
||||
instanceOwner.mfaRecoveryCodes = encryptedRecoveryCodes;
|
||||
owner.mfaSecret = encryptedSecret;
|
||||
owner.mfaRecoveryCodes = encryptedRecoveryCodes;
|
||||
}
|
||||
|
||||
const adminUser = this.userRepo.create({
|
||||
id: uuid(),
|
||||
...admin,
|
||||
password: await this.passwordUtility.hash(admin.password),
|
||||
role: 'global:admin',
|
||||
});
|
||||
const userCreatePromises = [
|
||||
this.userRepository.createUserWithProject({
|
||||
id: uuid(),
|
||||
...owner,
|
||||
password: await this.passwordUtility.hash(owner.password),
|
||||
role: 'global:owner',
|
||||
}),
|
||||
];
|
||||
|
||||
const users = [];
|
||||
|
||||
users.push(instanceOwner, adminUser);
|
||||
userCreatePromises.push(
|
||||
this.userRepository.createUserWithProject({
|
||||
id: uuid(),
|
||||
...admin,
|
||||
password: await this.passwordUtility.hash(admin.password),
|
||||
role: 'global:admin',
|
||||
}),
|
||||
);
|
||||
|
||||
for (const { password, ...payload } of members) {
|
||||
users.push(
|
||||
this.userRepo.create({
|
||||
userCreatePromises.push(
|
||||
this.userRepository.createUserWithProject({
|
||||
id: uuid(),
|
||||
...payload,
|
||||
password: await this.passwordUtility.hash(password),
|
||||
@@ -199,7 +221,7 @@ export class E2EController {
|
||||
);
|
||||
}
|
||||
|
||||
await this.userRepo.insert(users);
|
||||
await Promise.all(userCreatePromises);
|
||||
|
||||
await this.settingsRepo.update(
|
||||
{ key: 'userManagement.isInstanceOwnerSetUp' },
|
||||
|
||||
@@ -15,7 +15,7 @@ import { PostHogClient } from '@/posthog';
|
||||
import type { User } from '@/databases/entities/User';
|
||||
import { UserRepository } from '@db/repositories/user.repository';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { InternalHooks } from '@/InternalHooks';
|
||||
import { ExternalHooks } from '@/ExternalHooks';
|
||||
|
||||
@@ -55,7 +55,7 @@ export class InvitationController {
|
||||
this.logger.debug(
|
||||
'Request to send email invite(s) to user(s) failed because the user limit quota has been reached',
|
||||
);
|
||||
throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
|
||||
throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
|
||||
}
|
||||
|
||||
if (!config.getEnv('userManagement.isInstanceOwnerSetUp')) {
|
||||
@@ -98,7 +98,7 @@ export class InvitationController {
|
||||
}
|
||||
|
||||
if (invite.role === 'global:admin' && !this.license.isAdvancedPermissionsLicensed()) {
|
||||
throw new UnauthorizedError(
|
||||
throw new ForbiddenError(
|
||||
'Cannot invite admin user without advanced permissions. Please upgrade to a license that includes this feature.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export abstract class AbstractOAuthController {
|
||||
const credential = await this.sharedCredentialsRepository.findCredentialForUser(
|
||||
credentialId,
|
||||
req.user,
|
||||
['credential:read'],
|
||||
);
|
||||
|
||||
if (!credential) {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { InternalHooks } from '@/InternalHooks';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error';
|
||||
import { UserRepository } from '@/databases/repositories/user.repository';
|
||||
@@ -76,7 +76,7 @@ export class PasswordResetController {
|
||||
this.logger.debug(
|
||||
'Request to send password reset email failed because the user limit was reached',
|
||||
);
|
||||
throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
|
||||
throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
|
||||
}
|
||||
if (
|
||||
isSamlCurrentAuthenticationMethod() &&
|
||||
@@ -88,7 +88,7 @@ export class PasswordResetController {
|
||||
this.logger.debug(
|
||||
'Request to send password reset email failed because login is handled by SAML',
|
||||
);
|
||||
throw new UnauthorizedError(
|
||||
throw new ForbiddenError(
|
||||
'Login is handled by SAML. Please contact your Identity Provider to reset your password.',
|
||||
);
|
||||
}
|
||||
@@ -163,7 +163,7 @@ export class PasswordResetController {
|
||||
'Request to resolve password token failed because the user limit was reached',
|
||||
{ userId: user.id },
|
||||
);
|
||||
throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
|
||||
throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
|
||||
}
|
||||
|
||||
this.logger.info('Reset-password token resolved successfully', { userId: user.id });
|
||||
|
||||
221
packages/cli/src/controllers/project.controller.ts
Normal file
221
packages/cli/src/controllers/project.controller.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import type { Project } from '@db/entities/Project';
|
||||
import {
|
||||
Get,
|
||||
Post,
|
||||
GlobalScope,
|
||||
RestController,
|
||||
Licensed,
|
||||
Patch,
|
||||
ProjectScope,
|
||||
Delete,
|
||||
} from '@/decorators';
|
||||
import { ProjectRequest } from '@/requests';
|
||||
import {
|
||||
ProjectService,
|
||||
TeamProjectOverQuotaError,
|
||||
UnlicensedProjectRoleError,
|
||||
} from '@/services/project.service';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { combineScopes } from '@n8n/permissions';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
import { RoleService } from '@/services/role.service';
|
||||
import { ProjectRepository } from '@/databases/repositories/project.repository';
|
||||
import { In, Not } from '@n8n/typeorm';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { InternalHooks } from '@/InternalHooks';
|
||||
|
||||
@RestController('/projects')
|
||||
export class ProjectController {
|
||||
constructor(
|
||||
private readonly projectsService: ProjectService,
|
||||
private readonly roleService: RoleService,
|
||||
private readonly projectRepository: ProjectRepository,
|
||||
private readonly internalHooks: InternalHooks,
|
||||
) {}
|
||||
|
||||
@Get('/')
|
||||
async getAllProjects(req: ProjectRequest.GetAll): Promise<Project[]> {
|
||||
return await this.projectsService.getAccessibleProjects(req.user);
|
||||
}
|
||||
|
||||
@Get('/count')
|
||||
async getProjectCounts() {
|
||||
return await this.projectsService.getProjectCounts();
|
||||
}
|
||||
|
||||
@Post('/')
|
||||
@GlobalScope('project:create')
|
||||
// Using admin as all plans that contain projects should allow admins at the very least
|
||||
@Licensed('feat:projectRole:admin')
|
||||
async createProject(req: ProjectRequest.Create): Promise<Project> {
|
||||
try {
|
||||
const project = await this.projectsService.createTeamProject(req.body.name, req.user);
|
||||
|
||||
void this.internalHooks.onTeamProjectCreated({
|
||||
user_id: req.user.id,
|
||||
role: req.user.role,
|
||||
});
|
||||
|
||||
return project;
|
||||
} catch (e) {
|
||||
if (e instanceof TeamProjectOverQuotaError) {
|
||||
throw new BadRequestError(e.message);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Get('/my-projects')
|
||||
async getMyProjects(
|
||||
req: ProjectRequest.GetMyProjects,
|
||||
): Promise<ProjectRequest.GetMyProjectsResponse> {
|
||||
const relations = await this.projectsService.getProjectRelationsForUser(req.user);
|
||||
const otherTeamProject = req.user.hasGlobalScope('project:read')
|
||||
? await this.projectRepository.findBy({
|
||||
type: 'team',
|
||||
id: Not(In(relations.map((pr) => pr.projectId))),
|
||||
})
|
||||
: [];
|
||||
|
||||
const results: ProjectRequest.GetMyProjectsResponse = [];
|
||||
|
||||
for (const pr of relations) {
|
||||
const result: ProjectRequest.GetMyProjectsResponse[number] = Object.assign(
|
||||
this.projectRepository.create(pr.project),
|
||||
{
|
||||
role: pr.role,
|
||||
scopes: req.query.includeScopes ? ([] as Scope[]) : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
if (result.scopes) {
|
||||
result.scopes.push(
|
||||
...combineScopes({
|
||||
global: this.roleService.getRoleScopes(req.user.role),
|
||||
project: this.roleService.getRoleScopes(pr.role),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
for (const project of otherTeamProject) {
|
||||
const result: ProjectRequest.GetMyProjectsResponse[number] = Object.assign(
|
||||
this.projectRepository.create(project),
|
||||
{
|
||||
// If the user has the global `project:read` scope then they may not
|
||||
// own this relationship in that case we use the global user role
|
||||
// instead of the relation role, which is for another user.
|
||||
role: req.user.role,
|
||||
scopes: req.query.includeScopes ? [] : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
if (result.scopes) {
|
||||
result.scopes.push(
|
||||
...combineScopes({ global: this.roleService.getRoleScopes(req.user.role) }),
|
||||
);
|
||||
}
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// Deduplicate and sort scopes
|
||||
for (const result of results) {
|
||||
if (result.scopes) {
|
||||
result.scopes = [...new Set(result.scopes)].sort();
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
@Get('/personal')
|
||||
async getPersonalProject(req: ProjectRequest.GetPersonalProject) {
|
||||
const project = await this.projectsService.getPersonalProject(req.user);
|
||||
if (!project) {
|
||||
throw new NotFoundError('Could not find a personal project for this user');
|
||||
}
|
||||
const scopes: Scope[] = [
|
||||
...combineScopes({
|
||||
global: this.roleService.getRoleScopes(req.user.role),
|
||||
project: this.roleService.getRoleScopes('project:personalOwner'),
|
||||
}),
|
||||
];
|
||||
return {
|
||||
...project,
|
||||
scopes,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('/:projectId')
|
||||
@ProjectScope('project:read')
|
||||
async getProject(req: ProjectRequest.Get): Promise<ProjectRequest.ProjectWithRelations> {
|
||||
const [{ id, name, type }, relations] = await Promise.all([
|
||||
this.projectsService.getProject(req.params.projectId),
|
||||
this.projectsService.getProjectRelations(req.params.projectId),
|
||||
]);
|
||||
const myRelation = relations.find((r) => r.userId === req.user.id);
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
relations: relations.map((r) => ({
|
||||
id: r.user.id,
|
||||
email: r.user.email,
|
||||
firstName: r.user.firstName,
|
||||
lastName: r.user.lastName,
|
||||
role: r.role,
|
||||
})),
|
||||
scopes: [
|
||||
...combineScopes({
|
||||
global: this.roleService.getRoleScopes(req.user.role),
|
||||
...(myRelation ? { project: this.roleService.getRoleScopes(myRelation.role) } : {}),
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@Patch('/:projectId')
|
||||
@ProjectScope('project:update')
|
||||
async updateProject(req: ProjectRequest.Update) {
|
||||
if (req.body.name) {
|
||||
await this.projectsService.updateProject(req.body.name, req.params.projectId);
|
||||
}
|
||||
if (req.body.relations) {
|
||||
try {
|
||||
await this.projectsService.syncProjectRelations(req.params.projectId, req.body.relations);
|
||||
} catch (e) {
|
||||
if (e instanceof UnlicensedProjectRoleError) {
|
||||
throw new BadRequestError(e.message);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
void this.internalHooks.onTeamProjectUpdated({
|
||||
user_id: req.user.id,
|
||||
role: req.user.role,
|
||||
members: req.body.relations.map(({ userId, role }) => ({ user_id: userId, role })),
|
||||
project_id: req.params.projectId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Delete('/:projectId')
|
||||
@ProjectScope('project:delete')
|
||||
async deleteProject(req: ProjectRequest.Delete) {
|
||||
await this.projectsService.deleteProject(req.user, req.params.projectId, {
|
||||
migrateToProject: req.query.transferId,
|
||||
});
|
||||
|
||||
void this.internalHooks.onTeamProjectDeleted({
|
||||
user_id: req.user.id,
|
||||
role: req.user.role,
|
||||
project_id: req.params.projectId,
|
||||
removal_type: req.query.transferId !== undefined ? 'transfer' : 'delete',
|
||||
target_project_id: req.query.transferId,
|
||||
});
|
||||
}
|
||||
}
|
||||
22
packages/cli/src/controllers/role.controller.ts
Normal file
22
packages/cli/src/controllers/role.controller.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Get, RestController } from '@/decorators';
|
||||
import { type AllRoleTypes, 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),
|
||||
})),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@ import { plainToInstance } from 'class-transformer';
|
||||
|
||||
import { AuthService } from '@/auth/auth.service';
|
||||
import { User } from '@db/entities/User';
|
||||
import { SharedCredentials } from '@db/entities/SharedCredentials';
|
||||
import { SharedWorkflow } from '@db/entities/SharedWorkflow';
|
||||
import { GlobalScope, Delete, Get, RestController, Patch, Licensed } from '@/decorators';
|
||||
import {
|
||||
ListQuery,
|
||||
@@ -11,7 +9,6 @@ import {
|
||||
UserRoleChangePayload,
|
||||
UserSettingsUpdatePayload,
|
||||
} from '@/requests';
|
||||
import { ActiveWorkflowManager } from '@/ActiveWorkflowManager';
|
||||
import type { PublicUser, ITelemetryUserDeletionData } from '@/Interfaces';
|
||||
import { AuthIdentity } from '@db/entities/AuthIdentity';
|
||||
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
|
||||
@@ -20,12 +17,17 @@ import { UserRepository } from '@db/repositories/user.repository';
|
||||
import { UserService } from '@/services/user.service';
|
||||
import { listQueryMiddleware } from '@/middlewares';
|
||||
import { Logger } from '@/Logger';
|
||||
import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ExternalHooks } from '@/ExternalHooks';
|
||||
import { InternalHooks } from '@/InternalHooks';
|
||||
import { validateEntity } from '@/GenericHelpers';
|
||||
import { ProjectRepository } from '@/databases/repositories/project.repository';
|
||||
import { Project } from '@/databases/entities/Project';
|
||||
import { WorkflowService } from '@/workflows/workflow.service';
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import { ProjectService } from '@/services/project.service';
|
||||
|
||||
@RestController('/users')
|
||||
export class UsersController {
|
||||
@@ -36,9 +38,12 @@ export class UsersController {
|
||||
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
|
||||
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly activeWorkflowManager: ActiveWorkflowManager,
|
||||
private readonly authService: AuthService,
|
||||
private readonly userService: UserService,
|
||||
private readonly projectRepository: ProjectRepository,
|
||||
private readonly workflowService: WorkflowService,
|
||||
private readonly credentialsService: CredentialsService,
|
||||
private readonly projectService: ProjectService,
|
||||
) {}
|
||||
|
||||
static ERROR_MESSAGES = {
|
||||
@@ -151,131 +156,92 @@ export class UsersController {
|
||||
|
||||
const { transferId } = req.query;
|
||||
|
||||
if (transferId === idToDelete) {
|
||||
const userToDelete = await this.userRepository.findOneBy({ id: idToDelete });
|
||||
|
||||
if (!userToDelete) {
|
||||
throw new NotFoundError(
|
||||
'Request to delete a user failed because the user to delete was not found in DB',
|
||||
);
|
||||
}
|
||||
|
||||
const personalProjectToDelete = await this.projectRepository.getPersonalProjectForUserOrFail(
|
||||
userToDelete.id,
|
||||
);
|
||||
|
||||
if (transferId === personalProjectToDelete.id) {
|
||||
throw new BadRequestError(
|
||||
'Request to delete a user failed because the user to delete and the transferee are the same user',
|
||||
);
|
||||
}
|
||||
|
||||
const userIds = transferId ? [transferId, idToDelete] : [idToDelete];
|
||||
|
||||
const users = await this.userRepository.findManyByIds(userIds);
|
||||
|
||||
if (!users.length || (transferId && users.length !== 2)) {
|
||||
throw new NotFoundError(
|
||||
'Request to delete a user failed because the ID of the user to delete and/or the ID of the transferee were not found in DB',
|
||||
);
|
||||
}
|
||||
|
||||
const userToDelete = users.find((user) => user.id === req.params.id) as User;
|
||||
|
||||
const telemetryData: ITelemetryUserDeletionData = {
|
||||
user_id: req.user.id,
|
||||
target_user_old_status: userToDelete.isPending ? 'invited' : 'active',
|
||||
target_user_id: idToDelete,
|
||||
migration_strategy: transferId ? 'transfer_data' : 'delete_data',
|
||||
};
|
||||
|
||||
telemetryData.migration_strategy = transferId ? 'transfer_data' : 'delete_data';
|
||||
|
||||
if (transferId) {
|
||||
telemetryData.migration_user_id = transferId;
|
||||
}
|
||||
const transfereePersonalProject = await this.projectRepository.findOneBy({ id: transferId });
|
||||
|
||||
if (transferId) {
|
||||
const transferee = users.find((user) => user.id === transferId);
|
||||
|
||||
await this.userService.getManager().transaction(async (transactionManager) => {
|
||||
// Get all workflow ids belonging to user to delete
|
||||
const sharedWorkflowIds = await transactionManager
|
||||
.getRepository(SharedWorkflow)
|
||||
.find({
|
||||
select: ['workflowId'],
|
||||
where: { userId: userToDelete.id, role: 'workflow:owner' },
|
||||
})
|
||||
.then((sharedWorkflows) => sharedWorkflows.map(({ workflowId }) => workflowId));
|
||||
|
||||
// Prevents issues with unique key constraints since user being assigned
|
||||
// workflows and credentials might be a sharee
|
||||
await this.sharedWorkflowRepository.deleteByIds(
|
||||
transactionManager,
|
||||
sharedWorkflowIds,
|
||||
transferee,
|
||||
if (!transfereePersonalProject) {
|
||||
throw new NotFoundError(
|
||||
'Request to delete a user failed because the transferee project was not found in DB',
|
||||
);
|
||||
}
|
||||
|
||||
// Transfer ownership of owned workflows
|
||||
await transactionManager.update(
|
||||
SharedWorkflow,
|
||||
{ user: userToDelete, role: 'workflow:owner' },
|
||||
{ user: transferee },
|
||||
);
|
||||
|
||||
// Now do the same for creds
|
||||
|
||||
// Get all workflow ids belonging to user to delete
|
||||
const sharedCredentialIds = await transactionManager
|
||||
.getRepository(SharedCredentials)
|
||||
.find({
|
||||
select: ['credentialsId'],
|
||||
where: { userId: userToDelete.id, role: 'credential:owner' },
|
||||
})
|
||||
.then((sharedCredentials) => sharedCredentials.map(({ credentialsId }) => credentialsId));
|
||||
|
||||
// Prevents issues with unique key constraints since user being assigned
|
||||
// workflows and credentials might be a sharee
|
||||
await this.sharedCredentialsRepository.deleteByIds(
|
||||
transactionManager,
|
||||
sharedCredentialIds,
|
||||
transferee,
|
||||
);
|
||||
|
||||
// Transfer ownership of owned credentials
|
||||
await transactionManager.update(
|
||||
SharedCredentials,
|
||||
{ user: userToDelete, role: 'credential:owner' },
|
||||
{ user: transferee },
|
||||
);
|
||||
|
||||
await transactionManager.delete(AuthIdentity, { userId: userToDelete.id });
|
||||
|
||||
// This will remove all shared workflows and credentials not owned
|
||||
await transactionManager.delete(User, { id: userToDelete.id });
|
||||
const transferee = await this.userRepository.findOneByOrFail({
|
||||
projectRelations: {
|
||||
projectId: transfereePersonalProject.id,
|
||||
role: 'project:personalOwner',
|
||||
},
|
||||
});
|
||||
|
||||
void this.internalHooks.onUserDeletion({
|
||||
user: req.user,
|
||||
telemetryData,
|
||||
publicApi: false,
|
||||
telemetryData.migration_user_id = transferee.id;
|
||||
|
||||
await this.userService.getManager().transaction(async (trx) => {
|
||||
await this.workflowService.transferAll(
|
||||
personalProjectToDelete.id,
|
||||
transfereePersonalProject.id,
|
||||
trx,
|
||||
);
|
||||
await this.credentialsService.transferAll(
|
||||
personalProjectToDelete.id,
|
||||
transfereePersonalProject.id,
|
||||
trx,
|
||||
);
|
||||
});
|
||||
await this.externalHooks.run('user.deleted', [await this.userService.toPublic(userToDelete)]);
|
||||
return { success: true };
|
||||
|
||||
await this.projectService.clearCredentialCanUseExternalSecretsCache(
|
||||
transfereePersonalProject.id,
|
||||
);
|
||||
}
|
||||
|
||||
const [ownedSharedWorkflows, ownedSharedCredentials] = await Promise.all([
|
||||
this.sharedWorkflowRepository.find({
|
||||
relations: ['workflow'],
|
||||
where: { userId: userToDelete.id, role: 'workflow:owner' },
|
||||
select: { workflowId: true },
|
||||
where: { projectId: personalProjectToDelete.id, role: 'workflow:owner' },
|
||||
}),
|
||||
this.sharedCredentialsRepository.find({
|
||||
relations: ['credentials'],
|
||||
where: { userId: userToDelete.id, role: 'credential:owner' },
|
||||
relations: { credentials: true },
|
||||
where: { projectId: personalProjectToDelete.id, role: 'credential:owner' },
|
||||
}),
|
||||
]);
|
||||
|
||||
await this.userService.getManager().transaction(async (transactionManager) => {
|
||||
const ownedWorkflows = await Promise.all(
|
||||
ownedSharedWorkflows.map(async ({ workflow }) => {
|
||||
if (workflow.active) {
|
||||
// deactivate before deleting
|
||||
await this.activeWorkflowManager.remove(workflow.id);
|
||||
}
|
||||
return workflow;
|
||||
}),
|
||||
);
|
||||
await transactionManager.remove(ownedWorkflows);
|
||||
await transactionManager.remove(ownedSharedCredentials.map(({ credentials }) => credentials));
|
||||
const ownedCredentials = ownedSharedCredentials.map(({ credentials }) => credentials);
|
||||
|
||||
await transactionManager.delete(AuthIdentity, { userId: userToDelete.id });
|
||||
await transactionManager.delete(User, { id: userToDelete.id });
|
||||
for (const { workflowId } of ownedSharedWorkflows) {
|
||||
await this.workflowService.delete(userToDelete, workflowId);
|
||||
}
|
||||
|
||||
for (const credential of ownedCredentials) {
|
||||
await this.credentialsService.delete(credential);
|
||||
}
|
||||
|
||||
await this.userService.getManager().transaction(async (trx) => {
|
||||
await trx.delete(AuthIdentity, { userId: userToDelete.id });
|
||||
await trx.delete(Project, { id: personalProjectToDelete.id });
|
||||
await trx.delete(User, { id: userToDelete.id });
|
||||
});
|
||||
|
||||
void this.internalHooks.onUserDeletion({
|
||||
@@ -285,6 +251,7 @@ export class UsersController {
|
||||
});
|
||||
|
||||
await this.externalHooks.run('user.deleted', [await this.userService.toPublic(userToDelete)]);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -308,11 +275,11 @@ export class UsersController {
|
||||
}
|
||||
|
||||
if (req.user.role === 'global:admin' && targetUser.role === 'global:owner') {
|
||||
throw new UnauthorizedError(NO_ADMIN_ON_OWNER);
|
||||
throw new ForbiddenError(NO_ADMIN_ON_OWNER);
|
||||
}
|
||||
|
||||
if (req.user.role === 'global:owner' && targetUser.role === 'global:owner') {
|
||||
throw new UnauthorizedError(NO_OWNER_ON_OWNER);
|
||||
throw new ForbiddenError(NO_OWNER_ON_OWNER);
|
||||
}
|
||||
|
||||
await this.userService.update(targetUser.id, { role: payload.newRoleName });
|
||||
@@ -324,6 +291,13 @@ export class UsersController {
|
||||
public_api: false,
|
||||
});
|
||||
|
||||
const projects = await this.projectService.getUserOwnedOrAdminProjects(targetUser.id);
|
||||
await Promise.all(
|
||||
projects.map(
|
||||
async (p) => await this.projectService.clearCredentialCanUseExternalSecretsCache(p.id),
|
||||
),
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,13 +29,15 @@ export class WorkflowStatisticsController {
|
||||
*/
|
||||
// TODO: move this into a new decorator `@ValidateWorkflowPermission`
|
||||
@Middleware()
|
||||
async hasWorkflowAccess(req: StatisticsRequest.GetOne, res: Response, next: NextFunction) {
|
||||
async hasWorkflowAccess(req: StatisticsRequest.GetOne, _res: Response, next: NextFunction) {
|
||||
const { user } = req;
|
||||
const workflowId = req.params.id;
|
||||
|
||||
const hasAccess = await this.sharedWorkflowRepository.hasAccess(workflowId, user);
|
||||
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [
|
||||
'workflow:read',
|
||||
]);
|
||||
|
||||
if (hasAccess) {
|
||||
if (workflow) {
|
||||
next();
|
||||
} else {
|
||||
this.logger.verbose('User attempted to read a workflow without permissions', {
|
||||
|
||||
Reference in New Issue
Block a user