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:
Csaba Tuncsik
2024-05-17 10:53:15 +02:00
committed by GitHub
parent b1f977ebd0
commit 596c472ecc
292 changed files with 14129 additions and 3989 deletions

View File

@@ -1,41 +1,53 @@
import { deepCopy } from 'n8n-workflow';
import config from '@/config';
import { CredentialsService } from './credentials.service';
import { CredentialRequest, ListQuery } from '@/requests';
import { CredentialRequest } from '@/requests';
import { InternalHooks } from '@/InternalHooks';
import { Logger } from '@/Logger';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { UnauthorizedError } from '@/errors/response-errors/unauthorized.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NamingService } from '@/services/naming.service';
import { License } from '@/License';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
import { OwnershipService } from '@/services/ownership.service';
import { EnterpriseCredentialsService } from './credentials.service.ee';
import { Delete, Get, Licensed, Patch, Post, Put, RestController } from '@/decorators';
import {
Delete,
Get,
Licensed,
Patch,
Post,
Put,
RestController,
ProjectScope,
} from '@/decorators';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { UserManagementMailer } from '@/UserManagement/email';
import * as Db from '@/Db';
import * as utils from '@/utils';
import { listQueryMiddleware } from '@/middlewares';
import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository';
import { In } from '@n8n/typeorm';
import { SharedCredentials } from '@/databases/entities/SharedCredentials';
import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository';
@RestController('/credentials')
export class CredentialsController {
constructor(
private readonly credentialsService: CredentialsService,
private readonly enterpriseCredentialsService: EnterpriseCredentialsService,
private readonly credentialsRepository: CredentialsRepository,
private readonly namingService: NamingService,
private readonly license: License,
private readonly logger: Logger,
private readonly ownershipService: OwnershipService,
private readonly internalHooks: InternalHooks,
private readonly userManagementMailer: UserManagementMailer,
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly projectRelationRepository: ProjectRelationRepository,
) {}
@Get('/', { middlewares: listQueryMiddleware })
async getMany(req: ListQuery.Request) {
async getMany(req: CredentialRequest.GetMany) {
return await this.credentialsService.getMany(req.user, {
listQueryOptions: req.listQueryOptions,
includeScopes: req.query.includeScopes,
});
}
@@ -48,128 +60,73 @@ export class CredentialsController {
};
}
@Get('/:id')
@Get('/:credentialId')
@ProjectScope('credential:read')
async getOne(req: CredentialRequest.Get) {
if (this.license.isSharingEnabled()) {
const { id: credentialId } = req.params;
const includeDecryptedData = req.query.includeData === 'true';
let credential = await this.credentialsRepository.findOne({
where: { id: credentialId },
relations: ['shared', 'shared.user'],
});
if (!credential) {
throw new NotFoundError(
'Could not load the credential. If you think this is an error, ask the owner to share it with you again',
);
}
const userSharing = credential.shared?.find((shared) => shared.user.id === req.user.id);
if (!userSharing && !req.user.hasGlobalScope('credential:read')) {
throw new UnauthorizedError('Forbidden.');
}
credential = this.ownershipService.addOwnedByAndSharedWith(credential);
// Below, if `userSharing` does not exist, it means this credential is being
// fetched by the instance owner or an admin. In this case, they get the full data
if (!includeDecryptedData || userSharing?.role === 'credential:user') {
const { data: _, ...rest } = credential;
return { ...rest };
}
const { data: _, ...rest } = credential;
const decryptedData = this.credentialsService.redact(
this.credentialsService.decrypt(credential),
credential,
const credentials = await this.enterpriseCredentialsService.getOne(
req.user,
req.params.credentialId,
// TODO: editor-ui is always sending this, maybe we can just rely on the
// the scopes and always decrypt the data if the user has the permissions
// to do so.
req.query.includeData === 'true',
);
return { data: decryptedData, ...rest };
const scopes = await this.credentialsService.getCredentialScopes(
req.user,
req.params.credentialId,
);
return { ...credentials, scopes };
}
// non-enterprise
const { id: credentialId } = req.params;
const includeDecryptedData = req.query.includeData === 'true';
const sharing = await this.credentialsService.getSharing(
const credentials = await this.credentialsService.getOne(
req.user,
credentialId,
{ allowGlobalScope: true, globalScope: 'credential:read' },
['credentials'],
req.params.credentialId,
req.query.includeData === 'true',
);
if (!sharing) {
throw new NotFoundError(`Credential with ID "${credentialId}" could not be found.`);
}
const { credentials: credential } = sharing;
const { data: _, ...rest } = credential;
if (!includeDecryptedData) {
return { ...rest };
}
const decryptedData = this.credentialsService.redact(
this.credentialsService.decrypt(credential),
credential,
const scopes = await this.credentialsService.getCredentialScopes(
req.user,
req.params.credentialId,
);
return { data: decryptedData, ...rest };
return { ...credentials, scopes };
}
// TODO: Write at least test cases for the failure paths.
@Post('/test')
async testCredentials(req: CredentialRequest.Test) {
if (this.license.isSharingEnabled()) {
const { credentials } = req.body;
const credentialId = credentials.id;
const { ownsCredential } = await this.enterpriseCredentialsService.isOwned(
req.user,
credentialId,
);
const sharing = await this.enterpriseCredentialsService.getSharing(req.user, credentialId, {
allowGlobalScope: true,
globalScope: 'credential:read',
});
if (!ownsCredential) {
if (!sharing) {
throw new UnauthorizedError('Forbidden');
}
const decryptedData = this.credentialsService.decrypt(sharing.credentials);
Object.assign(credentials, { data: decryptedData });
}
const mergedCredentials = deepCopy(credentials);
if (mergedCredentials.data && sharing?.credentials) {
const decryptedData = this.credentialsService.decrypt(sharing.credentials);
mergedCredentials.data = this.credentialsService.unredact(
mergedCredentials.data,
decryptedData,
);
}
return await this.credentialsService.test(req.user, mergedCredentials);
}
// non-enterprise
const { credentials } = req.body;
const sharing = await this.credentialsService.getSharing(req.user, credentials.id, {
allowGlobalScope: true,
globalScope: 'credential:read',
});
const storedCredential = await this.sharedCredentialsRepository.findCredentialForUser(
credentials.id,
req.user,
['credential:read'],
);
if (!storedCredential) {
throw new ForbiddenError();
}
const mergedCredentials = deepCopy(credentials);
if (mergedCredentials.data && sharing?.credentials) {
const decryptedData = this.credentialsService.decrypt(sharing.credentials);
const decryptedData = this.credentialsService.decrypt(storedCredential);
// When a sharee opens a credential, the fields and the credential data are missing
// so the payload will be empty
// We need to replace the credential contents with the db version if that's the case
// So the credential can be tested properly
this.credentialsService.replaceCredentialContentsForSharee(
req.user,
storedCredential,
decryptedData,
mergedCredentials,
);
if (mergedCredentials.data && storedCredential) {
mergedCredentials.data = this.credentialsService.unredact(
mergedCredentials.data,
decryptedData,
@@ -184,7 +141,12 @@ export class CredentialsController {
const newCredential = await this.credentialsService.prepareCreateData(req.body);
const encryptedData = this.credentialsService.createEncryptedData(null, newCredential);
const credential = await this.credentialsService.save(newCredential, encryptedData, req.user);
const credential = await this.credentialsService.save(
newCredential,
encryptedData,
req.user,
req.body.projectId,
);
void this.internalHooks.onUserCreatedCredentials({
user: req.user,
@@ -194,24 +156,23 @@ export class CredentialsController {
public_api: false,
});
return credential;
const scopes = await this.credentialsService.getCredentialScopes(req.user, credential.id);
return { ...credential, scopes };
}
@Patch('/:id')
@Patch('/:credentialId')
@ProjectScope('credential:update')
async updateCredentials(req: CredentialRequest.Update) {
const { id: credentialId } = req.params;
const { credentialId } = req.params;
const sharing = await this.credentialsService.getSharing(
req.user,
const credential = await this.sharedCredentialsRepository.findCredentialForUser(
credentialId,
{
allowGlobalScope: true,
globalScope: 'credential:update',
},
['credentials'],
req.user,
['credential:update'],
);
if (!sharing) {
if (!credential) {
this.logger.info('Attempt to update credential blocked due to lack of permissions', {
credentialId,
userId: req.user.id,
@@ -221,16 +182,6 @@ export class CredentialsController {
);
}
if (sharing.role !== 'credential:owner' && !req.user.hasGlobalScope('credential:update')) {
this.logger.info('Attempt to update credential blocked due to lack of permissions', {
credentialId,
userId: req.user.id,
});
throw new UnauthorizedError('You can only update credentials owned by you');
}
const { credentials: credential } = sharing;
const decryptedData = this.credentialsService.decrypt(credential);
const preparedCredentialData = await this.credentialsService.prepareUpdateData(
req.body,
@@ -259,24 +210,23 @@ export class CredentialsController {
credential_id: credential.id,
});
return { ...rest };
const scopes = await this.credentialsService.getCredentialScopes(req.user, credential.id);
return { ...rest, scopes };
}
@Delete('/:id')
@Delete('/:credentialId')
@ProjectScope('credential:delete')
async deleteCredentials(req: CredentialRequest.Delete) {
const { id: credentialId } = req.params;
const { credentialId } = req.params;
const sharing = await this.credentialsService.getSharing(
req.user,
const credential = await this.sharedCredentialsRepository.findCredentialForUser(
credentialId,
{
allowGlobalScope: true,
globalScope: 'credential:delete',
},
['credentials'],
req.user,
['credential:delete'],
);
if (!sharing) {
if (!credential) {
this.logger.info('Attempt to delete credential blocked due to lack of permissions', {
credentialId,
userId: req.user.id,
@@ -286,16 +236,6 @@ export class CredentialsController {
);
}
if (sharing.role !== 'credential:owner' && !req.user.hasGlobalScope('credential:delete')) {
this.logger.info('Attempt to delete credential blocked due to lack of permissions', {
credentialId,
userId: req.user.id,
});
throw new UnauthorizedError('You can only remove credentials owned by you');
}
const { credentials: credential } = sharing;
await this.credentialsService.delete(credential);
void this.internalHooks.onUserDeletedCredentials({
@@ -309,9 +249,10 @@ export class CredentialsController {
}
@Licensed('feat:sharing')
@Put('/:id/share')
@Put('/:credentialId/share')
@ProjectScope('credential:share')
async shareCredentials(req: CredentialRequest.Share) {
const { id: credentialId } = req.params;
const { credentialId } = req.params;
const { shareWithIds } = req.body;
if (
@@ -321,59 +262,45 @@ export class CredentialsController {
throw new BadRequestError('Bad request');
}
const isOwnedRes = await this.enterpriseCredentialsService.isOwned(req.user, credentialId);
const { ownsCredential } = isOwnedRes;
let { credential } = isOwnedRes;
if (!ownsCredential || !credential) {
credential = undefined;
// Allow owners/admins to share
if (req.user.hasGlobalScope('credential:share')) {
const sharedRes = await this.enterpriseCredentialsService.getSharing(
req.user,
credentialId,
{
allowGlobalScope: true,
globalScope: 'credential:share',
},
);
credential = sharedRes?.credentials;
}
if (!credential) {
throw new UnauthorizedError('Forbidden');
}
}
const credential = await this.sharedCredentialsRepository.findCredentialForUser(
credentialId,
req.user,
['credential:share'],
);
const ownerIds = (
await this.enterpriseCredentialsService.getSharings(
Db.getConnection().createEntityManager(),
credentialId,
['shared'],
)
)
.filter((e) => e.role === 'credential:owner')
.map((e) => e.userId);
if (!credential) {
throw new ForbiddenError();
}
let amountRemoved: number | null = null;
let newShareeIds: string[] = [];
await Db.transaction(async (trx) => {
// remove all sharings that are not supposed to exist anymore
const { affected } = await this.credentialsRepository.pruneSharings(trx, credentialId, [
...ownerIds,
...shareWithIds,
]);
if (affected) amountRemoved = affected;
const currentPersonalProjectIDs = credential.shared
.filter((sc) => sc.role === 'credential:user')
.map((sc) => sc.projectId);
const newPersonalProjectIds = shareWithIds;
const sharings = await this.enterpriseCredentialsService.getSharings(trx, credentialId);
// extract the new sharings that need to be added
newShareeIds = utils.rightDiff(
[sharings, (sharing) => sharing.userId],
[shareWithIds, (shareeId) => shareeId],
const toShare = utils.rightDiff(
[currentPersonalProjectIDs, (id) => id],
[newPersonalProjectIds, (id) => id],
);
const toUnshare = utils.rightDiff(
[newPersonalProjectIds, (id) => id],
[currentPersonalProjectIDs, (id) => id],
);
if (newShareeIds.length) {
await this.enterpriseCredentialsService.share(trx, credential, newShareeIds);
const deleteResult = await trx.delete(SharedCredentials, {
credentialsId: credentialId,
projectId: In(toUnshare),
});
await this.enterpriseCredentialsService.shareWithProjects(credential, toShare, trx);
if (deleteResult.affected) {
amountRemoved = deleteResult.affected;
}
newShareeIds = toShare;
});
void this.internalHooks.onUserSharedCredentials({
@@ -386,9 +313,14 @@ export class CredentialsController {
sharees_removed: amountRemoved,
});
const projectsRelations = await this.projectRelationRepository.findBy({
projectId: In(newShareeIds),
role: 'project:personalOwner',
});
await this.userManagementMailer.notifyCredentialsShared({
sharer: req.user,
newShareeIds,
newShareeIds: projectsRelations.map((pr) => pr.userId),
credentialsName: credential.name,
});
}

View File

@@ -1,77 +1,94 @@
import type { EntityManager, FindOptionsWhere } from '@n8n/typeorm';
import type { SharedCredentials } from '@db/entities/SharedCredentials';
import { In, type EntityManager } from '@n8n/typeorm';
import type { User } from '@db/entities/User';
import { type CredentialsGetSharedOptions } from './credentials.service';
import { CredentialsService } from './credentials.service';
import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository';
import { UserRepository } from '@/databases/repositories/user.repository';
import { CredentialsEntity } from '@/databases/entities/CredentialsEntity';
import type { CredentialsEntity } from '@/databases/entities/CredentialsEntity';
import { Service } from 'typedi';
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { OwnershipService } from '@/services/ownership.service';
import { Project } from '@/databases/entities/Project';
@Service()
export class EnterpriseCredentialsService {
constructor(
private readonly userRepository: UserRepository,
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly ownershipService: OwnershipService,
private readonly credentialsService: CredentialsService,
) {}
async isOwned(user: User, credentialId: string) {
const sharing = await this.getSharing(user, credentialId, { allowGlobalScope: false }, [
'credentials',
]);
if (!sharing || sharing.role !== 'credential:owner') return { ownsCredential: false };
const { credentials: credential } = sharing;
return { ownsCredential: true, credential };
}
/**
* Retrieve the sharing that matches a user and a credential.
*/
async getSharing(
user: User,
credentialId: string,
options: CredentialsGetSharedOptions,
relations: string[] = ['credentials'],
async shareWithProjects(
credential: CredentialsEntity,
shareWithIds: string[],
entityManager?: EntityManager,
) {
const where: FindOptionsWhere<SharedCredentials> = { credentialsId: credentialId };
const em = entityManager ?? this.sharedCredentialsRepository.manager;
// 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;
}
return await this.sharedCredentialsRepository.findOne({
where,
relations,
});
}
async getSharings(transaction: EntityManager, credentialId: string, relations = ['shared']) {
const credential = await transaction.findOne(CredentialsEntity, {
where: { id: credentialId },
relations,
const projects = await em.find(Project, {
where: { id: In(shareWithIds), type: 'personal' },
});
return credential?.shared ?? [];
}
async share(transaction: EntityManager, credential: CredentialsEntity, shareWithIds: string[]) {
const users = await this.userRepository.getByIds(transaction, shareWithIds);
const newSharedCredentials = users
.filter((user) => !user.isPending)
.map((user) =>
const newSharedCredentials = projects
// We filter by role === 'project:personalOwner' above and there should
// always only be one owner.
.map((project) =>
this.sharedCredentialsRepository.create({
credentialsId: credential.id,
userId: user.id,
role: 'credential:user',
projectId: project.id,
}),
);
return await transaction.save(newSharedCredentials);
return await em.save(newSharedCredentials);
}
async getOne(user: User, credentialId: string, includeDecryptedData: boolean) {
let credential: CredentialsEntity | null = null;
let decryptedData: ICredentialDataDecryptedObject | null = null;
credential = includeDecryptedData
? // Try to get the credential with `credential:update` scope, which
// are required for decrypting the data.
await this.sharedCredentialsRepository.findCredentialForUser(
credentialId,
user,
// TODO: replace credential:update with credential:decrypt once it lands
// see: https://n8nio.slack.com/archives/C062YRE7EG4/p1708531433206069?thread_ts=1708525972.054149&cid=C062YRE7EG4
['credential:read', 'credential:update'],
)
: null;
if (credential) {
// Decrypt the data if we found the credential with the `credential:update`
// scope.
decryptedData = this.credentialsService.redact(
this.credentialsService.decrypt(credential),
credential,
);
} else {
// Otherwise try to find them with only the `credential:read` scope. In
// that case we return them without the decrypted data.
credential = await this.sharedCredentialsRepository.findCredentialForUser(
credentialId,
user,
['credential:read'],
);
}
if (!credential) {
throw new NotFoundError(
'Could not load the credential. If you think this is an error, ask the owner to share it with you again',
);
}
credential = this.ownershipService.addOwnedByAndSharedWith(credential);
const { data: _, ...rest } = credential;
if (decryptedData) {
return { data: decryptedData, ...rest };
}
return { ...rest };
}
}

View File

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