mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-11 04:47:29 -08:00
feat: Allow sharing to and from team projects (no-changelog) (#10144)
This commit is contained in:
parent
c9d9245451
commit
697bc90b0b
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
@ -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'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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'));
|
||||
|
|
Loading…
Reference in a new issue