mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-11 12:57: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[] = [];
|
let newShareeIds: string[] = [];
|
||||||
|
|
||||||
await Db.transaction(async (trx) => {
|
await Db.transaction(async (trx) => {
|
||||||
const currentPersonalProjectIDs = credential.shared
|
const currentProjectIds = credential.shared
|
||||||
.filter((sc) => sc.role === 'credential:user')
|
.filter((sc) => sc.role === 'credential:user')
|
||||||
.map((sc) => sc.projectId);
|
.map((sc) => sc.projectId);
|
||||||
const newPersonalProjectIds = shareWithIds;
|
const newProjectIds = shareWithIds;
|
||||||
|
|
||||||
const toShare = utils.rightDiff(
|
const toShare = utils.rightDiff([currentProjectIds, (id) => id], [newProjectIds, (id) => id]);
|
||||||
[currentPersonalProjectIDs, (id) => id],
|
|
||||||
[newPersonalProjectIds, (id) => id],
|
|
||||||
);
|
|
||||||
const toUnshare = utils.rightDiff(
|
const toUnshare = utils.rightDiff(
|
||||||
[newPersonalProjectIds, (id) => id],
|
[newProjectIds, (id) => id],
|
||||||
[currentPersonalProjectIDs, (id) => id],
|
[currentProjectIds, (id) => id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteResult = await trx.delete(SharedCredentials, {
|
const deleteResult = await trx.delete(SharedCredentials, {
|
||||||
credentialsId: credentialId,
|
credentialsId: credentialId,
|
||||||
projectId: In(toUnshare),
|
projectId: In(toUnshare),
|
||||||
});
|
});
|
||||||
await this.enterpriseCredentialsService.shareWithProjects(credential, toShare, trx);
|
await this.enterpriseCredentialsService.shareWithProjects(req.user, credential, toShare, trx);
|
||||||
|
|
||||||
if (deleteResult.affected) {
|
if (deleteResult.affected) {
|
||||||
amountRemoved = deleteResult.affected;
|
amountRemoved = deleteResult.affected;
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { Project } from '@/databases/entities/Project';
|
||||||
import { ProjectService } from '@/services/project.service';
|
import { ProjectService } from '@/services/project.service';
|
||||||
import { TransferCredentialError } from '@/errors/response-errors/transfer-credential.error';
|
import { TransferCredentialError } from '@/errors/response-errors/transfer-credential.error';
|
||||||
import { SharedCredentials } from '@/databases/entities/SharedCredentials';
|
import { SharedCredentials } from '@/databases/entities/SharedCredentials';
|
||||||
|
import { RoleService } from '@/services/role.service';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class EnterpriseCredentialsService {
|
export class EnterpriseCredentialsService {
|
||||||
|
@ -20,9 +21,11 @@ export class EnterpriseCredentialsService {
|
||||||
private readonly ownershipService: OwnershipService,
|
private readonly ownershipService: OwnershipService,
|
||||||
private readonly credentialsService: CredentialsService,
|
private readonly credentialsService: CredentialsService,
|
||||||
private readonly projectService: ProjectService,
|
private readonly projectService: ProjectService,
|
||||||
|
private readonly roleService: RoleService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async shareWithProjects(
|
async shareWithProjects(
|
||||||
|
user: User,
|
||||||
credential: CredentialsEntity,
|
credential: CredentialsEntity,
|
||||||
shareWithIds: string[],
|
shareWithIds: string[],
|
||||||
entityManager?: EntityManager,
|
entityManager?: EntityManager,
|
||||||
|
@ -30,13 +33,29 @@ export class EnterpriseCredentialsService {
|
||||||
const em = entityManager ?? this.sharedCredentialsRepository.manager;
|
const em = entityManager ?? this.sharedCredentialsRepository.manager;
|
||||||
|
|
||||||
const projects = await em.find(Project, {
|
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
|
const newSharedCredentials = projects.map((project) =>
|
||||||
// We filter by role === 'project:personalOwner' above and there should
|
|
||||||
// always only be one owner.
|
|
||||||
.map((project) =>
|
|
||||||
this.sharedCredentialsRepository.create({
|
this.sharedCredentialsRepository.create({
|
||||||
credentialsId: credential.id,
|
credentialsId: credential.id,
|
||||||
role: 'credential:user',
|
role: 'credential:user',
|
||||||
|
|
|
@ -90,6 +90,19 @@ export class CredentialsService {
|
||||||
let credentials = await this.credentialsRepository.findMany(options.listQueryOptions);
|
let credentials = await this.credentialsRepository.findMany(options.listQueryOptions);
|
||||||
|
|
||||||
if (isDefaultSelect) {
|
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));
|
credentials = credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -130,6 +143,20 @@ export class CredentialsService {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isDefaultSelect) {
|
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));
|
credentials = credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -151,4 +151,13 @@ export class SharedCredentialsRepository extends Repository<SharedCredentials> {
|
||||||
})
|
})
|
||||||
)?.project;
|
)?.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:delete',
|
||||||
'credential:list',
|
'credential:list',
|
||||||
'credential:move',
|
'credential:move',
|
||||||
|
'credential:share',
|
||||||
'project:list',
|
'project:list',
|
||||||
'project:read',
|
'project:read',
|
||||||
'project:update',
|
'project:update',
|
||||||
|
|
|
@ -48,6 +48,7 @@ let memberPersonalProject: Project;
|
||||||
let anotherMember: User;
|
let anotherMember: User;
|
||||||
let anotherMemberPersonalProject: Project;
|
let anotherMemberPersonalProject: Project;
|
||||||
let authOwnerAgent: SuperAgentTest;
|
let authOwnerAgent: SuperAgentTest;
|
||||||
|
let authMemberAgent: SuperAgentTest;
|
||||||
let authAnotherMemberAgent: SuperAgentTest;
|
let authAnotherMemberAgent: SuperAgentTest;
|
||||||
let saveCredential: SaveCredentialFunction;
|
let saveCredential: SaveCredentialFunction;
|
||||||
const mailer = mockInstance(UserManagementMailer);
|
const mailer = mockInstance(UserManagementMailer);
|
||||||
|
@ -73,6 +74,7 @@ beforeEach(async () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
authOwnerAgent = testServer.authAgentFor(owner);
|
authOwnerAgent = testServer.authAgentFor(owner);
|
||||||
|
authMemberAgent = testServer.authAgentFor(member);
|
||||||
authAnotherMemberAgent = testServer.authAgentFor(anotherMember);
|
authAnotherMemberAgent = testServer.authAgentFor(anotherMember);
|
||||||
|
|
||||||
saveCredential = affixRoleToSaveCredential('credential:owner');
|
saveCredential = affixRoleToSaveCredential('credential:owner');
|
||||||
|
@ -978,6 +980,128 @@ describe('PUT /credentials/:id/share', () => {
|
||||||
|
|
||||||
config.set('userManagement.emails.mode', 'smtp');
|
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', () => {
|
describe('PUT /:credentialId/transfer', () => {
|
||||||
|
|
|
@ -142,7 +142,13 @@ describe('GET /credentials', () => {
|
||||||
// Team cred
|
// Team cred
|
||||||
expect(cred1.id).toBe(savedCredential1.id);
|
expect(cred1.id).toBe(savedCredential1.id);
|
||||||
expect(cred1.scopes).toEqual(
|
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
|
// Shared cred
|
||||||
|
@ -389,6 +395,21 @@ describe('GET /credentials', () => {
|
||||||
expect(response2.body.data).toHaveLength(0);
|
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 () => {
|
test('should return all credentials in a team project that member is part of', async () => {
|
||||||
const teamProjectWithMember = await createTeamProject('Team Project With member', owner);
|
const teamProjectWithMember = await createTeamProject('Team Project With member', owner);
|
||||||
void (await linkUserToProject(member, teamProjectWithMember, 'project:editor'));
|
void (await linkUserToProject(member, teamProjectWithMember, 'project:editor'));
|
||||||
|
|
Loading…
Reference in a new issue