diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index 9ac57bb2e3af3..40f2aa8d43219 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -291,25 +291,22 @@ export class CredentialsController { let newShareeIds: string[] = []; await Db.transaction(async (trx) => { - const currentPersonalProjectIDs = credential.shared + const currentProjectIds = credential.shared .filter((sc) => sc.role === 'credential:user') .map((sc) => sc.projectId); - const newPersonalProjectIds = shareWithIds; + const newProjectIds = shareWithIds; - const toShare = utils.rightDiff( - [currentPersonalProjectIDs, (id) => id], - [newPersonalProjectIds, (id) => id], - ); + const toShare = utils.rightDiff([currentProjectIds, (id) => id], [newProjectIds, (id) => id]); const toUnshare = utils.rightDiff( - [newPersonalProjectIds, (id) => id], - [currentPersonalProjectIDs, (id) => id], + [newProjectIds, (id) => id], + [currentProjectIds, (id) => id], ); const deleteResult = await trx.delete(SharedCredentials, { credentialsId: credentialId, projectId: In(toUnshare), }); - await this.enterpriseCredentialsService.shareWithProjects(credential, toShare, trx); + await this.enterpriseCredentialsService.shareWithProjects(req.user, credential, toShare, trx); if (deleteResult.affected) { amountRemoved = deleteResult.affected; diff --git a/packages/cli/src/credentials/credentials.service.ee.ts b/packages/cli/src/credentials/credentials.service.ee.ts index 981ecc5d59fc9..44fc614cbd63d 100644 --- a/packages/cli/src/credentials/credentials.service.ee.ts +++ b/packages/cli/src/credentials/credentials.service.ee.ts @@ -12,6 +12,7 @@ import { Project } from '@/databases/entities/Project'; import { ProjectService } from '@/services/project.service'; import { TransferCredentialError } from '@/errors/response-errors/transfer-credential.error'; import { SharedCredentials } from '@/databases/entities/SharedCredentials'; +import { RoleService } from '@/services/role.service'; @Service() export class EnterpriseCredentialsService { @@ -20,9 +21,11 @@ export class EnterpriseCredentialsService { private readonly ownershipService: OwnershipService, private readonly credentialsService: CredentialsService, private readonly projectService: ProjectService, + private readonly roleService: RoleService, ) {} async shareWithProjects( + user: User, credential: CredentialsEntity, shareWithIds: string[], entityManager?: EntityManager, @@ -30,19 +33,35 @@ export class EnterpriseCredentialsService { const em = entityManager ?? this.sharedCredentialsRepository.manager; const projects = await em.find(Project, { - where: { id: In(shareWithIds), type: 'personal' }, + where: [ + { + id: In(shareWithIds), + 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') + ? {} + : { + projectRelations: { + userId: user.id, + role: In(this.roleService.rolesWithScope('project', 'project:list')), + }, + }), + }, + { + id: In(shareWithIds), + type: 'personal', + }, + ], }); - 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, - role: 'credential:user', - projectId: project.id, - }), - ); + const newSharedCredentials = projects.map((project) => + this.sharedCredentialsRepository.create({ + credentialsId: credential.id, + role: 'credential:user', + projectId: project.id, + }), + ); return await em.save(newSharedCredentials); } diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index 0ecfd31877bf2..01517c960adad 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -90,6 +90,19 @@ export class CredentialsService { let credentials = await this.credentialsRepository.findMany(options.listQueryOptions); if (isDefaultSelect) { + // Since we're filtering using project ID as part of the relation, + // we end up filtering out all the other relations, meaning that if + // it's shared to a project, it won't be able to find the home project. + // To solve this, we have to get all the relation now, even though + // we're deleting them later. + if ((options.listQueryOptions?.filter?.shared as { projectId?: string })?.projectId) { + const relations = await this.sharedCredentialsRepository.getAllRelationsForCredentials( + credentials.map((c) => c.id), + ); + credentials.forEach((c) => { + c.shared = relations.filter((r) => r.credentialsId === c.id); + }); + } credentials = credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c)); } @@ -130,6 +143,20 @@ export class CredentialsService { ); if (isDefaultSelect) { + // Since we're filtering using project ID as part of the relation, + // we end up filtering out all the other relations, meaning that if + // it's shared to a project, it won't be able to find the home project. + // To solve this, we have to get all the relation now, even though + // we're deleting them later. + if ((options.listQueryOptions?.filter?.shared as { projectId?: string })?.projectId) { + const relations = await this.sharedCredentialsRepository.getAllRelationsForCredentials( + credentials.map((c) => c.id), + ); + credentials.forEach((c) => { + c.shared = relations.filter((r) => r.credentialsId === c.id); + }); + } + credentials = credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c)); } diff --git a/packages/cli/src/databases/repositories/sharedCredentials.repository.ts b/packages/cli/src/databases/repositories/sharedCredentials.repository.ts index 8d2d1fa7af586..03acc24823ac5 100644 --- a/packages/cli/src/databases/repositories/sharedCredentials.repository.ts +++ b/packages/cli/src/databases/repositories/sharedCredentials.repository.ts @@ -151,4 +151,13 @@ export class SharedCredentialsRepository extends Repository { }) )?.project; } + + async getAllRelationsForCredentials(credentialIds: string[]) { + return await this.find({ + where: { + credentialsId: In(credentialIds), + }, + relations: ['project'], + }); + } } diff --git a/packages/cli/src/permissions/project-roles.ts b/packages/cli/src/permissions/project-roles.ts index 96e4dfff8a475..d05507c47c46c 100644 --- a/packages/cli/src/permissions/project-roles.ts +++ b/packages/cli/src/permissions/project-roles.ts @@ -20,6 +20,7 @@ export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [ 'credential:delete', 'credential:list', 'credential:move', + 'credential:share', 'project:list', 'project:read', 'project:update', diff --git a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts index df8a562a76768..6b74dd843c303 100644 --- a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts +++ b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts @@ -48,6 +48,7 @@ let memberPersonalProject: Project; let anotherMember: User; let anotherMemberPersonalProject: Project; let authOwnerAgent: SuperAgentTest; +let authMemberAgent: SuperAgentTest; let authAnotherMemberAgent: SuperAgentTest; let saveCredential: SaveCredentialFunction; const mailer = mockInstance(UserManagementMailer); @@ -73,6 +74,7 @@ beforeEach(async () => { ); authOwnerAgent = testServer.authAgentFor(owner); + authMemberAgent = testServer.authAgentFor(member); authAnotherMemberAgent = testServer.authAgentFor(anotherMember); saveCredential = affixRoleToSaveCredential('credential:owner'); @@ -978,6 +980,128 @@ describe('PUT /credentials/:id/share', () => { config.set('userManagement.emails.mode', 'smtp'); }); + + test('member should be able to share from personal project to team project that member has access to', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); + + const testProject = await createTeamProject(); + await linkUserToProject(member, testProject, 'project:editor'); + + const response = await authMemberAgent + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: [testProject.id] }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toBeUndefined(); + + const shares = await getCredentialSharings(savedCredential); + const testShare = shares.find((s) => s.projectId === testProject.id); + expect(testShare).not.toBeUndefined(); + expect(testShare?.role).toBe('credential:user'); + }); + + test('member should be able to share from team project to personal project', async () => { + const testProject = await createTeamProject(undefined, member); + + const savedCredential = await saveCredential(randomCredentialPayload(), { + project: testProject, + }); + + const response = await authMemberAgent + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: [anotherMemberPersonalProject.id] }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toBeUndefined(); + + const shares = await getCredentialSharings(savedCredential); + const testShare = shares.find((s) => s.projectId === anotherMemberPersonalProject.id); + expect(testShare).not.toBeUndefined(); + expect(testShare?.role).toBe('credential:user'); + }); + + test('member should be able to share from team project to team project that member has access to', async () => { + const testProject = await createTeamProject(undefined, member); + const testProject2 = await createTeamProject(); + await linkUserToProject(member, testProject2, 'project:editor'); + + const savedCredential = await saveCredential(randomCredentialPayload(), { + project: testProject, + }); + + const response = await authMemberAgent + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: [testProject2.id] }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toBeUndefined(); + + const shares = await getCredentialSharings(savedCredential); + const testShare = shares.find((s) => s.projectId === testProject2.id); + expect(testShare).not.toBeUndefined(); + expect(testShare?.role).toBe('credential:user'); + }); + + test('admins should be able to share from any team project to any team project ', async () => { + const testProject = await createTeamProject(); + const testProject2 = await createTeamProject(); + + const savedCredential = await saveCredential(randomCredentialPayload(), { + project: testProject, + }); + + const response = await authOwnerAgent + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: [testProject2.id] }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toBeUndefined(); + + const shares = await getCredentialSharings(savedCredential); + const testShare = shares.find((s) => s.projectId === testProject2.id); + expect(testShare).not.toBeUndefined(); + expect(testShare?.role).toBe('credential:user'); + }); + + test("admins should be able to share from any team project to any user's personal project ", async () => { + const testProject = await createTeamProject(); + + const savedCredential = await saveCredential(randomCredentialPayload(), { + project: testProject, + }); + + const response = await authOwnerAgent + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: [memberPersonalProject.id] }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toBeUndefined(); + + const shares = await getCredentialSharings(savedCredential); + const testShare = shares.find((s) => s.projectId === memberPersonalProject.id); + expect(testShare).not.toBeUndefined(); + expect(testShare?.role).toBe('credential:user'); + }); + + test('admins should be able to share from any personal project to any team project ', async () => { + const testProject = await createTeamProject(); + + const savedCredential = await saveCredential(randomCredentialPayload(), { + user: member, + }); + + const response = await authOwnerAgent + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: [testProject.id] }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toBeUndefined(); + + const shares = await getCredentialSharings(savedCredential); + const testShare = shares.find((s) => s.projectId === testProject.id); + expect(testShare).not.toBeUndefined(); + expect(testShare?.role).toBe('credential:user'); + }); }); describe('PUT /:credentialId/transfer', () => { diff --git a/packages/cli/test/integration/credentials/credentials.api.test.ts b/packages/cli/test/integration/credentials/credentials.api.test.ts index 54de47b16e948..cd461b3dafe66 100644 --- a/packages/cli/test/integration/credentials/credentials.api.test.ts +++ b/packages/cli/test/integration/credentials/credentials.api.test.ts @@ -142,7 +142,13 @@ describe('GET /credentials', () => { // Team cred expect(cred1.id).toBe(savedCredential1.id); expect(cred1.scopes).toEqual( - ['credential:move', 'credential:read', 'credential:update', 'credential:delete'].sort(), + [ + 'credential:move', + 'credential:read', + 'credential:update', + 'credential:share', + 'credential:delete', + ].sort(), ); // Shared cred @@ -389,6 +395,21 @@ describe('GET /credentials', () => { expect(response2.body.data).toHaveLength(0); }); + test('should return homeProject when filtering credentials by projectId', async () => { + const project = await createTeamProject(undefined, member); + const credential = await saveCredential(payload(), { user: owner, role: 'credential:owner' }); + await shareCredentialWithProjects(credential, [project]); + + const response: GetAllResponse = await testServer + .authAgentFor(member) + .get('/credentials') + .query(`filter={ "projectId": "${project.id}" }`) + .expect(200); + + expect(response.body.data).toHaveLength(1); + expect(response.body.data[0].homeProject).not.toBeNull(); + }); + test('should return all credentials in a team project that member is part of', async () => { const teamProjectWithMember = await createTeamProject('Team Project With member', owner); void (await linkUserToProject(member, teamProjectWithMember, 'project:editor'));