mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
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:
@@ -5,8 +5,13 @@ import type {
|
||||
ICredentialType,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
import { CREDENTIAL_EMPTY_VALUE, deepCopy, NodeHelpers } from 'n8n-workflow';
|
||||
import type { FindOptionsWhere } from '@n8n/typeorm';
|
||||
import { ApplicationError, CREDENTIAL_EMPTY_VALUE, deepCopy, NodeHelpers } from 'n8n-workflow';
|
||||
import {
|
||||
In,
|
||||
type EntityManager,
|
||||
type FindOptionsRelations,
|
||||
type FindOptionsWhere,
|
||||
} from '@n8n/typeorm';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
import * as Db from '@/Db';
|
||||
import type { ICredentialsDb } from '@/Interfaces';
|
||||
@@ -25,6 +30,12 @@ import { CredentialsRepository } from '@db/repositories/credentials.repository';
|
||||
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
|
||||
import { Service } from 'typedi';
|
||||
import { CredentialsTester } from '@/services/credentials-tester.service';
|
||||
import { ProjectRepository } from '@/databases/repositories/project.repository';
|
||||
import { ProjectService } from '@/services/project.service';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import type { ProjectRelation } from '@/databases/entities/ProjectRelation';
|
||||
import { RoleService } from '@/services/role.service';
|
||||
|
||||
export type CredentialsGetSharedOptions =
|
||||
| { allowGlobalScope: true; globalScope: Scope }
|
||||
@@ -40,62 +51,129 @@ export class CredentialsService {
|
||||
private readonly credentialsTester: CredentialsTester,
|
||||
private readonly externalHooks: ExternalHooks,
|
||||
private readonly credentialTypes: CredentialTypes,
|
||||
private readonly projectRepository: ProjectRepository,
|
||||
private readonly projectService: ProjectService,
|
||||
private readonly roleService: RoleService,
|
||||
) {}
|
||||
|
||||
async get(where: FindOptionsWhere<ICredentialsDb>, options?: { relations: string[] }) {
|
||||
return await this.credentialsRepository.findOne({
|
||||
relations: options?.relations,
|
||||
where,
|
||||
});
|
||||
}
|
||||
|
||||
async getMany(
|
||||
user: User,
|
||||
options: { listQueryOptions?: ListQuery.Options; onlyOwn?: boolean } = {},
|
||||
options: {
|
||||
listQueryOptions?: ListQuery.Options;
|
||||
onlyOwn?: boolean;
|
||||
includeScopes?: string;
|
||||
} = {},
|
||||
) {
|
||||
const returnAll = user.hasGlobalScope('credential:list') && !options.onlyOwn;
|
||||
const isDefaultSelect = !options.listQueryOptions?.select;
|
||||
|
||||
if (returnAll) {
|
||||
const credentials = await this.credentialsRepository.findMany(options.listQueryOptions);
|
||||
|
||||
return isDefaultSelect
|
||||
? credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c))
|
||||
: credentials;
|
||||
let projectRelations: ProjectRelation[] | undefined = undefined;
|
||||
if (options.includeScopes) {
|
||||
projectRelations = await this.projectService.getProjectRelationsForUser(user);
|
||||
if (options.listQueryOptions?.filter?.projectId && user.hasGlobalScope('credential:list')) {
|
||||
// Only instance owners and admins have the credential:list scope
|
||||
// Those users should be able to use _all_ credentials within their workflows.
|
||||
// TODO: Change this so we filter by `workflowId` in this case. Require a slight FE change
|
||||
const projectRelation = projectRelations.find(
|
||||
(relation) => relation.projectId === options.listQueryOptions?.filter?.projectId,
|
||||
);
|
||||
if (projectRelation?.role === 'project:personalOwner') {
|
||||
// Will not affect team projects as these have admins, not owners.
|
||||
delete options.listQueryOptions?.filter?.projectId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ids = await this.sharedCredentialsRepository.getAccessibleCredentialIds([user.id]);
|
||||
if (returnAll) {
|
||||
let credentials = await this.credentialsRepository.findMany(options.listQueryOptions);
|
||||
|
||||
const credentials = await this.credentialsRepository.findMany(
|
||||
if (isDefaultSelect) {
|
||||
credentials = credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c));
|
||||
}
|
||||
|
||||
if (options.includeScopes) {
|
||||
credentials = credentials.map((c) =>
|
||||
this.roleService.addScopes(c, user, projectRelations!),
|
||||
);
|
||||
}
|
||||
|
||||
credentials.forEach((c) => {
|
||||
// @ts-expect-error: This is to emulate the old behaviour of removing the shared
|
||||
// field as part of `addOwnedByAndSharedWith`. We need this field in `addScopes`
|
||||
// though. So to avoid leaking the information we just delete it.
|
||||
delete c.shared;
|
||||
});
|
||||
|
||||
return credentials;
|
||||
}
|
||||
|
||||
// If the workflow is part of a personal project we want to show the credentials the user making the request has access to, not the credentials the user owning the workflow has access to.
|
||||
if (typeof options.listQueryOptions?.filter?.projectId === 'string') {
|
||||
const project = await this.projectService.getProject(
|
||||
options.listQueryOptions.filter.projectId,
|
||||
);
|
||||
if (project?.type === 'personal') {
|
||||
const currentUsersPersonalProject = await this.projectService.getPersonalProject(user);
|
||||
options.listQueryOptions.filter.projectId = currentUsersPersonalProject?.id;
|
||||
}
|
||||
}
|
||||
|
||||
const ids = await this.sharedCredentialsRepository.getCredentialIdsByUserAndRole([user.id], {
|
||||
scopes: ['credential:read'],
|
||||
});
|
||||
|
||||
let credentials = await this.credentialsRepository.findMany(
|
||||
options.listQueryOptions,
|
||||
ids, // only accessible credentials
|
||||
);
|
||||
|
||||
return isDefaultSelect
|
||||
? credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c))
|
||||
: credentials;
|
||||
if (isDefaultSelect) {
|
||||
credentials = credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c));
|
||||
}
|
||||
|
||||
if (options.includeScopes) {
|
||||
credentials = credentials.map((c) => this.roleService.addScopes(c, user, projectRelations!));
|
||||
}
|
||||
|
||||
credentials.forEach((c) => {
|
||||
// @ts-expect-error: This is to emulate the old behaviour of removing the shared
|
||||
// field as part of `addOwnedByAndSharedWith`. We need this field in `addScopes`
|
||||
// though. So to avoid leaking the information we just delete it.
|
||||
delete c.shared;
|
||||
});
|
||||
|
||||
return credentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the sharing that matches a user and a credential.
|
||||
*/
|
||||
// TODO: move to SharedCredentialsService
|
||||
async getSharing(
|
||||
user: User,
|
||||
credentialId: string,
|
||||
options: CredentialsGetSharedOptions,
|
||||
relations: string[] = ['credentials'],
|
||||
globalScopes: Scope[],
|
||||
relations: FindOptionsRelations<SharedCredentials> = { credentials: true },
|
||||
): Promise<SharedCredentials | null> {
|
||||
const where: FindOptionsWhere<SharedCredentials> = { credentialsId: credentialId };
|
||||
let where: FindOptionsWhere<SharedCredentials> = { credentialsId: credentialId };
|
||||
|
||||
// Omit user from where if the requesting user has relevant
|
||||
// global credential permissions. This allows the user to
|
||||
// access credentials they don't own.
|
||||
if (!options.allowGlobalScope || !user.hasGlobalScope(options.globalScope)) {
|
||||
where.userId = user.id;
|
||||
where.role = 'credential:owner';
|
||||
if (!user.hasGlobalScope(globalScopes, { mode: 'allOf' })) {
|
||||
where = {
|
||||
...where,
|
||||
role: 'credential:owner',
|
||||
project: {
|
||||
projectRelations: {
|
||||
role: 'project:personalOwner',
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return await this.sharedCredentialsRepository.findOne({ where, relations });
|
||||
return await this.sharedCredentialsRepository.findOne({
|
||||
where,
|
||||
relations,
|
||||
});
|
||||
}
|
||||
|
||||
async prepareCreateData(
|
||||
@@ -128,7 +206,7 @@ export class CredentialsService {
|
||||
await validateEntity(updateData);
|
||||
|
||||
// Do not overwrite the oauth data else data like the access or refresh token would get lost
|
||||
// everytime anybody changes anything on the credentials even if it is just the name.
|
||||
// every time anybody changes anything on the credentials even if it is just the name.
|
||||
if (decryptedData.oauthTokenData) {
|
||||
// @ts-ignore
|
||||
updateData.data.oauthTokenData = decryptedData.oauthTokenData;
|
||||
@@ -165,7 +243,12 @@ export class CredentialsService {
|
||||
return await this.credentialsRepository.findOneBy({ id: credentialId });
|
||||
}
|
||||
|
||||
async save(credential: CredentialsEntity, encryptedData: ICredentialsDb, user: User) {
|
||||
async save(
|
||||
credential: CredentialsEntity,
|
||||
encryptedData: ICredentialsDb,
|
||||
user: User,
|
||||
projectId?: string,
|
||||
) {
|
||||
// To avoid side effects
|
||||
const newCredential = new CredentialsEntity();
|
||||
Object.assign(newCredential, credential, encryptedData);
|
||||
@@ -177,12 +260,31 @@ export class CredentialsService {
|
||||
|
||||
savedCredential.data = newCredential.data;
|
||||
|
||||
const newSharedCredential = new SharedCredentials();
|
||||
const project =
|
||||
projectId === undefined
|
||||
? await this.projectRepository.getPersonalProjectForUserOrFail(user.id)
|
||||
: await this.projectService.getProjectWithScope(
|
||||
user,
|
||||
projectId,
|
||||
['credential:create'],
|
||||
transactionManager,
|
||||
);
|
||||
|
||||
Object.assign(newSharedCredential, {
|
||||
if (typeof projectId === 'string' && project === null) {
|
||||
throw new BadRequestError(
|
||||
"You don't have the permissions to save the workflow in this project.",
|
||||
);
|
||||
}
|
||||
|
||||
// Safe guard in case the personal project does not exist for whatever reason.
|
||||
if (project === null) {
|
||||
throw new ApplicationError('No personal project found');
|
||||
}
|
||||
|
||||
const newSharedCredential = this.sharedCredentialsRepository.create({
|
||||
role: 'credential:owner',
|
||||
user,
|
||||
credentials: savedCredential,
|
||||
projectId: project.id,
|
||||
});
|
||||
|
||||
await transactionManager.save<SharedCredentials>(newSharedCredential);
|
||||
@@ -295,4 +397,134 @@ export class CredentialsService {
|
||||
this.unredactRestoreValues(mergedData, savedData);
|
||||
return mergedData;
|
||||
}
|
||||
|
||||
async getOne(user: User, credentialId: string, includeDecryptedData: boolean) {
|
||||
let sharing: SharedCredentials | null = null;
|
||||
let decryptedData: ICredentialDataDecryptedObject | null = null;
|
||||
|
||||
sharing = includeDecryptedData
|
||||
? // Try to get the credential with `credential:update` scope, which
|
||||
// are required for decrypting the data.
|
||||
await this.getSharing(user, credentialId, [
|
||||
'credential:read',
|
||||
// TODO: Enable this once the scope exists and has been added to the
|
||||
// global:owner role.
|
||||
// 'credential:decrypt',
|
||||
])
|
||||
: null;
|
||||
|
||||
if (sharing) {
|
||||
// Decrypt the data if we found the credential with the `credential:update`
|
||||
// scope.
|
||||
decryptedData = this.redact(this.decrypt(sharing.credentials), sharing.credentials);
|
||||
} else {
|
||||
// Otherwise try to find them with only the `credential:read` scope. In
|
||||
// that case we return them without the decrypted data.
|
||||
sharing = await this.getSharing(user, credentialId, ['credential:read']);
|
||||
}
|
||||
|
||||
if (!sharing) {
|
||||
throw new NotFoundError(`Credential with ID "${credentialId}" could not be found.`);
|
||||
}
|
||||
|
||||
const { credentials: credential } = sharing;
|
||||
|
||||
const { data: _, ...rest } = credential;
|
||||
|
||||
if (decryptedData) {
|
||||
return { data: decryptedData, ...rest };
|
||||
}
|
||||
return { ...rest };
|
||||
}
|
||||
|
||||
async getCredentialScopes(user: User, credentialId: string): Promise<Scope[]> {
|
||||
const userProjectRelations = await this.projectService.getProjectRelationsForUser(user);
|
||||
const shared = await this.sharedCredentialsRepository.find({
|
||||
where: {
|
||||
projectId: In([...new Set(userProjectRelations.map((pr) => pr.projectId))]),
|
||||
credentialsId: credentialId,
|
||||
},
|
||||
});
|
||||
return this.roleService.combineResourceScopes('credential', user, shared, userProjectRelations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfers all credentials owned by a project to another one.
|
||||
* This has only been tested for personal projects. It may need to be amended
|
||||
* for team projects.
|
||||
**/
|
||||
async transferAll(fromProjectId: string, toProjectId: string, trx?: EntityManager) {
|
||||
trx = trx ?? this.credentialsRepository.manager;
|
||||
|
||||
// Get all shared credentials for both projects.
|
||||
const allSharedCredentials = await trx.findBy(SharedCredentials, {
|
||||
projectId: In([fromProjectId, toProjectId]),
|
||||
});
|
||||
|
||||
const sharedCredentialsOfFromProject = allSharedCredentials.filter(
|
||||
(sc) => sc.projectId === fromProjectId,
|
||||
);
|
||||
|
||||
// For all credentials that the from-project owns transfer the ownership
|
||||
// to the to-project.
|
||||
// This will override whatever relationship the to-project already has to
|
||||
// the resources at the moment.
|
||||
const ownedCredentialIds = sharedCredentialsOfFromProject
|
||||
.filter((sc) => sc.role === 'credential:owner')
|
||||
.map((sc) => sc.credentialsId);
|
||||
|
||||
await this.sharedCredentialsRepository.makeOwner(ownedCredentialIds, toProjectId, trx);
|
||||
|
||||
// Delete the relationship to the from-project.
|
||||
await this.sharedCredentialsRepository.deleteByIds(ownedCredentialIds, fromProjectId, trx);
|
||||
|
||||
// Transfer relationships that are not `credential:owner`.
|
||||
// This will NOT override whatever relationship the to-project already has
|
||||
// to the resource at the moment.
|
||||
const sharedCredentialIdsOfTransferee = allSharedCredentials
|
||||
.filter((sc) => sc.projectId === toProjectId)
|
||||
.map((sc) => sc.credentialsId);
|
||||
|
||||
// All resources that are shared with the from-project, but not with the
|
||||
// to-project.
|
||||
const sharedCredentialsToTransfer = sharedCredentialsOfFromProject.filter(
|
||||
(sc) =>
|
||||
sc.role !== 'credential:owner' &&
|
||||
!sharedCredentialIdsOfTransferee.includes(sc.credentialsId),
|
||||
);
|
||||
|
||||
await trx.insert(
|
||||
SharedCredentials,
|
||||
sharedCredentialsToTransfer.map((sc) => ({
|
||||
credentialsId: sc.credentialsId,
|
||||
projectId: toProjectId,
|
||||
role: sc.role,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
replaceCredentialContentsForSharee(
|
||||
user: User,
|
||||
credential: CredentialsEntity,
|
||||
decryptedData: ICredentialDataDecryptedObject,
|
||||
mergedCredentials: ICredentialsDecrypted,
|
||||
) {
|
||||
credential.shared.forEach((sharedCredentials) => {
|
||||
if (sharedCredentials.role === 'credential:owner') {
|
||||
if (sharedCredentials.project.type === 'personal') {
|
||||
// Find the owner of this personal project
|
||||
sharedCredentials.project.projectRelations.forEach((projectRelation) => {
|
||||
if (
|
||||
projectRelation.role === 'project:personalOwner' &&
|
||||
projectRelation.user.id !== user.id
|
||||
) {
|
||||
// If we realize that the current user does not own this credential
|
||||
// We replace the payload with the stored decrypted data
|
||||
mergedCredentials.data = decryptedData;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user