feat: Allow sharing to and from team projects (no-changelog) (#10144)

This commit is contained in:
Val 2024-08-09 11:59:28 +01:00 committed by GitHub
parent c9d9245451
commit 697bc90b0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 219 additions and 21 deletions

View file

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

View file

@ -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,13 +33,29 @@ 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) =>
const newSharedCredentials = projects.map((project) =>
this.sharedCredentialsRepository.create({
credentialsId: credential.id,
role: 'credential:user',

View file

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

View file

@ -151,4 +151,13 @@ export class SharedCredentialsRepository extends Repository<SharedCredentials> {
})
)?.project;
}
async getAllRelationsForCredentials(credentialIds: string[]) {
return await this.find({
where: {
credentialsId: In(credentialIds),
},
relations: ['project'],
});
}
}

View file

@ -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',

View file

@ -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', () => {

View file

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