mirror of
https://github.com/n8n-io/n8n.git
synced 2025-11-20 17:46:34 +00:00
feat: Add support for global credentials (#21700)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
aa17707805
commit
55c3150c11
@ -7,4 +7,5 @@ export class CreateCredentialDto extends Z.class({
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
projectId: z.string().optional(),
|
||||
uiContext: z.string().optional(),
|
||||
isGlobal: z.boolean().optional(),
|
||||
}) {}
|
||||
|
||||
@ -21,4 +21,10 @@ export class CredentialsGetManyRequestQuery extends Z.class({
|
||||
includeData: booleanFromString.optional(),
|
||||
|
||||
onlySharedWithMe: booleanFromString.optional(),
|
||||
|
||||
/**
|
||||
* Includes global credentials (credentials available to all users).
|
||||
* Defaults to false.
|
||||
*/
|
||||
includeGlobal: booleanFromString.optional().default('false'),
|
||||
}) {}
|
||||
|
||||
@ -8,6 +8,7 @@ export type CredentialPayload = {
|
||||
type: string;
|
||||
data: ICredentialDataDecryptedObject;
|
||||
isManaged?: boolean;
|
||||
isGlobal?: boolean;
|
||||
};
|
||||
|
||||
export const randomApiKey = () => `n8n_api_${randomString(40)}`;
|
||||
@ -44,11 +45,13 @@ export const randomEmail = () => `${randomName()}@${randomName()}.${randomTopLev
|
||||
|
||||
export const randomCredentialPayload = ({
|
||||
isManaged = false,
|
||||
}: { isManaged?: boolean } = {}): CredentialPayload => ({
|
||||
isGlobal,
|
||||
}: { isManaged?: boolean; isGlobal?: boolean } = {}): CredentialPayload => ({
|
||||
name: randomName(),
|
||||
type: randomName(),
|
||||
data: { accessToken: randomString(6, 16) },
|
||||
isManaged,
|
||||
isGlobal,
|
||||
});
|
||||
|
||||
export const randomCredentialPayloadWithOauthTokenData = ({
|
||||
|
||||
@ -36,6 +36,12 @@ export class CredentialsEntity extends WithTimestampsAndStringId implements ICre
|
||||
@Column({ default: false })
|
||||
isManaged: boolean;
|
||||
|
||||
/**
|
||||
* Whether the credential is available for use by all users.
|
||||
*/
|
||||
@Column({ default: false })
|
||||
isGlobal: boolean;
|
||||
|
||||
toJSON() {
|
||||
const { shared, ...rest } = this;
|
||||
return rest;
|
||||
|
||||
@ -87,6 +87,7 @@ export interface ICredentialsDb extends ICredentialsBase, ICredentialsEncrypted
|
||||
id: string;
|
||||
name: string;
|
||||
shared?: SharedCredentials[];
|
||||
isGlobal?: boolean;
|
||||
}
|
||||
|
||||
export interface IExecutionResponse extends IExecutionBase {
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||
|
||||
export class AddIsGlobalColumnToCredentialsTable1762771954619 implements ReversibleMigration {
|
||||
async up({ escape, runQuery, isSqlite }: MigrationContext) {
|
||||
const tableName = escape.tableName('credentials_entity');
|
||||
const columnName = escape.columnName('isGlobal');
|
||||
|
||||
const defaultValue = isSqlite ? 0 : 'FALSE';
|
||||
|
||||
await runQuery(
|
||||
`ALTER TABLE ${tableName} ADD COLUMN ${columnName} BOOLEAN NOT NULL DEFAULT ${defaultValue}`,
|
||||
);
|
||||
}
|
||||
|
||||
async down({ escape, runQuery }: MigrationContext) {
|
||||
const tableName = escape.tableName('credentials_entity');
|
||||
const columnName = escape.columnName('isGlobal');
|
||||
|
||||
await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${columnName}`);
|
||||
}
|
||||
}
|
||||
@ -113,6 +113,7 @@ import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000
|
||||
import { AddAttachmentsToChatHubMessages1761773155024 } from '../common/1761773155024-AddAttachmentsToChatHubMessages';
|
||||
import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn';
|
||||
import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords';
|
||||
import { AddIsGlobalColumnToCredentialsTable1762771954619 } from '../common/1762771954619-IsGlobalGlobalColumnToCredentialsTable';
|
||||
import { AddWorkflowHistoryAutoSaveFields1762847206508 } from '../common/1762847206508-AddWorkflowHistoryAutoSaveFields';
|
||||
import { AddActiveVersionIdColumn1763047800000 } from '../common/1763047800000-AddActiveVersionIdColumn';
|
||||
import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar';
|
||||
@ -232,6 +233,7 @@ export const mysqlMigrations: Migration[] = [
|
||||
AddWorkflowDescriptionColumn1762177736257,
|
||||
CreateOAuthEntities1760116750277,
|
||||
BackfillMissingWorkflowHistoryRecords1762763704614,
|
||||
AddIsGlobalColumnToCredentialsTable1762771954619,
|
||||
AddWorkflowHistoryAutoSaveFields1762847206508,
|
||||
AddToolsColumnToChatHubTables1761830340990,
|
||||
ChangeOAuthStateColumnToUnboundedVarchar1763572724000,
|
||||
|
||||
@ -113,6 +113,7 @@ import { AddAttachmentsToChatHubMessages1761773155024 } from '../common/17617731
|
||||
import { AddToolsColumnToChatHubTables1761830340990 } from '../common/1761830340990-AddToolsColumnToChatHubTables';
|
||||
import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn';
|
||||
import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords';
|
||||
import { AddIsGlobalColumnToCredentialsTable1762771954619 } from '../common/1762771954619-IsGlobalGlobalColumnToCredentialsTable';
|
||||
import { AddWorkflowHistoryAutoSaveFields1762847206508 } from '../common/1762847206508-AddWorkflowHistoryAutoSaveFields';
|
||||
import { AddActiveVersionIdColumn1763047800000 } from '../common/1763047800000-AddActiveVersionIdColumn';
|
||||
import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar';
|
||||
@ -232,6 +233,7 @@ export const postgresMigrations: Migration[] = [
|
||||
CreateOAuthEntities1760116750277,
|
||||
BackfillMissingWorkflowHistoryRecords1762763704614,
|
||||
ChangeDefaultForIdInUserTable1762771264000,
|
||||
AddIsGlobalColumnToCredentialsTable1762771954619,
|
||||
AddWorkflowHistoryAutoSaveFields1762847206508,
|
||||
AddToolsColumnToChatHubTables1761830340990,
|
||||
ChangeOAuthStateColumnToUnboundedVarchar1763572724000,
|
||||
|
||||
@ -109,6 +109,7 @@ import { AddAttachmentsToChatHubMessages1761773155024 } from '../common/17617731
|
||||
import { AddToolsColumnToChatHubTables1761830340990 } from '../common/1761830340990-AddToolsColumnToChatHubTables';
|
||||
import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn';
|
||||
import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords';
|
||||
import { AddIsGlobalColumnToCredentialsTable1762771954619 } from '../common/1762771954619-IsGlobalGlobalColumnToCredentialsTable';
|
||||
import { AddWorkflowHistoryAutoSaveFields1762847206508 } from '../common/1762847206508-AddWorkflowHistoryAutoSaveFields';
|
||||
import { AddActiveVersionIdColumn1763047800000 } from '../common/1763047800000-AddActiveVersionIdColumn';
|
||||
import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar';
|
||||
@ -224,6 +225,7 @@ const sqliteMigrations: Migration[] = [
|
||||
AddWorkflowDescriptionColumn1762177736257,
|
||||
CreateOAuthEntities1760116750277,
|
||||
BackfillMissingWorkflowHistoryRecords1762763704614,
|
||||
AddIsGlobalColumnToCredentialsTable1762771954619,
|
||||
AddWorkflowHistoryAutoSaveFields1762847206508,
|
||||
AddToolsColumnToChatHubTables1761830340990,
|
||||
ChangeOAuthStateColumnToUnboundedVarchar1763572724000,
|
||||
|
||||
@ -38,7 +38,15 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
|
||||
type Select = Array<keyof CredentialsEntity>;
|
||||
|
||||
const defaultRelations = ['shared', 'shared.project', 'shared.project.projectRelations'];
|
||||
const defaultSelect: Select = ['id', 'name', 'type', 'isManaged', 'createdAt', 'updatedAt'];
|
||||
const defaultSelect: Select = [
|
||||
'id',
|
||||
'name',
|
||||
'type',
|
||||
'isManaged',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'isGlobal',
|
||||
];
|
||||
|
||||
if (!listQueryOptions) return { select: defaultSelect, relations: defaultRelations };
|
||||
|
||||
@ -133,6 +141,17 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
|
||||
return await this.find(findManyOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all global credentials
|
||||
*/
|
||||
async findAllGlobalCredentials(includeData = false): Promise<CredentialsEntity[]> {
|
||||
const findManyOptions = this.toFindManyOptions({ includeData });
|
||||
|
||||
findManyOptions.where = { ...findManyOptions.where, isGlobal: true };
|
||||
|
||||
return await this.find(findManyOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all credentials that are owned by a personal project.
|
||||
*/
|
||||
|
||||
@ -21,6 +21,7 @@ exports[`Scope Information ensure scopes are defined correctly 1`] = `
|
||||
"communityPackage:manage",
|
||||
"communityPackage:*",
|
||||
"credential:share",
|
||||
"credential:shareGlobally",
|
||||
"credential:move",
|
||||
"credential:create",
|
||||
"credential:read",
|
||||
|
||||
@ -6,7 +6,7 @@ export const RESOURCES = {
|
||||
banner: ['dismiss'] as const,
|
||||
community: ['register'] as const,
|
||||
communityPackage: ['install', 'uninstall', 'update', 'list', 'manage'] as const,
|
||||
credential: ['share', 'move', ...DEFAULT_OPERATIONS] as const,
|
||||
credential: ['share', 'shareGlobally', 'move', ...DEFAULT_OPERATIONS] as const,
|
||||
externalSecretsProvider: ['sync', ...DEFAULT_OPERATIONS] as const,
|
||||
externalSecret: ['list', 'use'] as const,
|
||||
eventBusDestination: ['test', ...DEFAULT_OPERATIONS] as const,
|
||||
|
||||
@ -14,6 +14,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
|
||||
'credential:delete',
|
||||
'credential:list',
|
||||
'credential:share',
|
||||
'credential:shareGlobally',
|
||||
'credential:move',
|
||||
'community:register',
|
||||
'communityPackage:install',
|
||||
|
||||
4
packages/cli/build.log
Normal file
4
packages/cli/build.log
Normal file
@ -0,0 +1,4 @@
|
||||
|
||||
> n8n@1.121.0 build /Users/mutasem/repos/n8n-worktree/global-creds/packages/cli
|
||||
> tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && pnpm run build:data
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { AuthenticatedRequest, SharedCredentialsRepository } from '@n8n/db';
|
||||
import type { AuthenticatedRequest, SharedCredentialsRepository, CredentialsEntity } from '@n8n/db';
|
||||
import { GLOBAL_OWNER_ROLE, GLOBAL_MEMBER_ROLE } from '@n8n/db';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import { createRawProjectData } from '@/__tests__/project.test-data';
|
||||
@ -7,11 +8,14 @@ import type { EventService } from '@/events/event.service';
|
||||
import { createdCredentialsWithScopes, createNewCredentialsPayload } from './credentials.test-data';
|
||||
import { CredentialsController } from '../credentials.controller';
|
||||
import type { CredentialsService } from '../credentials.service';
|
||||
import type { CredentialsFinderService } from '../credentials-finder.service';
|
||||
import type { CredentialRequest } from '@/requests';
|
||||
|
||||
describe('CredentialsController', () => {
|
||||
const eventService = mock<EventService>();
|
||||
const credentialsService = mock<CredentialsService>();
|
||||
const sharedCredentialsRepository = mock<SharedCredentialsRepository>();
|
||||
const credentialsFinderService = mock<CredentialsFinderService>();
|
||||
|
||||
const credentialsController = new CredentialsController(
|
||||
mock(),
|
||||
@ -24,7 +28,7 @@ describe('CredentialsController', () => {
|
||||
sharedCredentialsRepository,
|
||||
mock(),
|
||||
eventService,
|
||||
mock(),
|
||||
credentialsFinderService,
|
||||
);
|
||||
|
||||
let req: AuthenticatedRequest;
|
||||
@ -33,6 +37,10 @@ describe('CredentialsController', () => {
|
||||
req = { user: { id: '123' } } as AuthenticatedRequest;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('createCredentials', () => {
|
||||
it('should create new credentials and emit "credentials-created"', async () => {
|
||||
// Arrange
|
||||
@ -86,4 +94,191 @@ describe('CredentialsController', () => {
|
||||
expect(newApiKey).toEqual(createdCredentials);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCredentials', () => {
|
||||
const credentialId = 'cred-123';
|
||||
const existingCredential = mock<CredentialsEntity>({
|
||||
id: credentialId,
|
||||
name: 'Test Credential',
|
||||
type: 'apiKey',
|
||||
isGlobal: false,
|
||||
isManaged: false,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
credentialsService.decrypt.mockReturnValue({ apiKey: 'test-key' });
|
||||
credentialsService.prepareUpdateData.mockResolvedValue({
|
||||
name: 'Updated Credential',
|
||||
type: 'apiKey',
|
||||
data: { apiKey: 'updated-key' },
|
||||
} as any);
|
||||
credentialsService.createEncryptedData.mockReturnValue({
|
||||
name: 'Updated Credential',
|
||||
type: 'apiKey',
|
||||
data: 'encrypted-data',
|
||||
id: 'cred-123',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as any);
|
||||
credentialsService.getCredentialScopes.mockResolvedValue([
|
||||
'credential:read',
|
||||
'credential:update',
|
||||
] as any);
|
||||
});
|
||||
|
||||
it('should allow owner to set isGlobal to true', async () => {
|
||||
// ARRANGE
|
||||
const ownerReq = {
|
||||
user: { id: 'owner-id', role: GLOBAL_OWNER_ROLE },
|
||||
params: { credentialId },
|
||||
body: {
|
||||
name: 'Updated Credential',
|
||||
type: 'apiKey',
|
||||
data: { apiKey: 'updated-key' },
|
||||
isGlobal: true,
|
||||
},
|
||||
} as unknown as CredentialRequest.Update;
|
||||
|
||||
credentialsFinderService.findCredentialForUser.mockResolvedValue(existingCredential);
|
||||
credentialsService.update.mockResolvedValue({
|
||||
...existingCredential,
|
||||
name: 'Updated Credential',
|
||||
isGlobal: true,
|
||||
});
|
||||
|
||||
// ACT
|
||||
await credentialsController.updateCredentials(ownerReq);
|
||||
|
||||
// ASSERT
|
||||
expect(credentialsService.update).toHaveBeenCalledWith(
|
||||
credentialId,
|
||||
expect.objectContaining({
|
||||
isGlobal: true,
|
||||
}),
|
||||
);
|
||||
expect(eventService.emit).toHaveBeenCalledWith('credentials-updated', {
|
||||
user: ownerReq.user,
|
||||
credentialType: existingCredential.type,
|
||||
credentialId: existingCredential.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow owner to set isGlobal to false', async () => {
|
||||
// ARRANGE
|
||||
const globalCredential = mock<CredentialsEntity>({
|
||||
...existingCredential,
|
||||
isGlobal: true,
|
||||
});
|
||||
const ownerReq = {
|
||||
user: { id: 'owner-id', role: GLOBAL_OWNER_ROLE },
|
||||
params: { credentialId },
|
||||
body: {
|
||||
name: 'Updated Credential',
|
||||
type: 'apiKey',
|
||||
data: { apiKey: 'updated-key' },
|
||||
isGlobal: false,
|
||||
},
|
||||
} as unknown as CredentialRequest.Update;
|
||||
|
||||
credentialsFinderService.findCredentialForUser.mockResolvedValue(globalCredential);
|
||||
credentialsService.update.mockResolvedValue({
|
||||
...globalCredential,
|
||||
isGlobal: false,
|
||||
});
|
||||
|
||||
// ACT
|
||||
await credentialsController.updateCredentials(ownerReq);
|
||||
|
||||
// ASSERT
|
||||
expect(credentialsService.update).toHaveBeenCalledWith(
|
||||
credentialId,
|
||||
expect.objectContaining({
|
||||
isGlobal: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should prevent non-owner from changing isGlobal', async () => {
|
||||
// ARRANGE
|
||||
const memberReq = {
|
||||
user: { id: 'member-id', role: GLOBAL_MEMBER_ROLE },
|
||||
params: { credentialId },
|
||||
body: {
|
||||
name: 'Updated Credential',
|
||||
type: 'apiKey',
|
||||
data: { apiKey: 'updated-key' },
|
||||
isGlobal: true,
|
||||
},
|
||||
} as unknown as CredentialRequest.Update;
|
||||
|
||||
credentialsFinderService.findCredentialForUser.mockResolvedValue(existingCredential);
|
||||
|
||||
// ACT
|
||||
await expect(credentialsController.updateCredentials(memberReq)).rejects.toThrowError(
|
||||
'You do not have permission to change global sharing for credentials',
|
||||
);
|
||||
|
||||
// ASSERT
|
||||
expect(credentialsService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prevent non-owner from changing isGlobal to true', async () => {
|
||||
// ARRANGE
|
||||
const memberReq = {
|
||||
user: { id: 'member-id', role: GLOBAL_MEMBER_ROLE },
|
||||
params: { credentialId },
|
||||
body: {
|
||||
name: 'Updated Credential',
|
||||
type: 'apiKey',
|
||||
data: { apiKey: 'updated-key' },
|
||||
isGlobal: false,
|
||||
},
|
||||
} as unknown as CredentialRequest.Update;
|
||||
|
||||
credentialsFinderService.findCredentialForUser.mockResolvedValue({
|
||||
...existingCredential,
|
||||
isGlobal: true,
|
||||
});
|
||||
|
||||
// ACT
|
||||
await expect(credentialsController.updateCredentials(memberReq)).rejects.toThrowError(
|
||||
'You do not have permission to change global sharing for credentials',
|
||||
);
|
||||
|
||||
// ASSERT
|
||||
expect(credentialsService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update credential without changing isGlobal when not provided', async () => {
|
||||
// ARRANGE
|
||||
const ownerReq = {
|
||||
user: { id: 'owner-id', role: GLOBAL_OWNER_ROLE },
|
||||
params: { credentialId },
|
||||
body: {
|
||||
name: 'Updated Credential',
|
||||
type: 'apiKey',
|
||||
data: { apiKey: 'updated-key' },
|
||||
// isGlobal not provided
|
||||
},
|
||||
} as unknown as CredentialRequest.Update;
|
||||
|
||||
credentialsFinderService.findCredentialForUser.mockResolvedValue(existingCredential);
|
||||
credentialsService.update.mockResolvedValue({
|
||||
...existingCredential,
|
||||
name: 'Updated Credential',
|
||||
});
|
||||
|
||||
// ACT
|
||||
await credentialsController.updateCredentials(ownerReq);
|
||||
|
||||
// ASSERT
|
||||
// Should not include isGlobal in update when not provided
|
||||
expect(credentialsService.update).toHaveBeenCalledWith(
|
||||
credentialId,
|
||||
expect.not.objectContaining({
|
||||
isGlobal: expect.anything(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,5 @@
|
||||
import type { CredentialsEntity, SharedCredentials, User } from '@n8n/db';
|
||||
import { CredentialsRepository, SharedCredentialsRepository } from '@n8n/db';
|
||||
import type { SharedCredentials, User } from '@n8n/db';
|
||||
import { CredentialsEntity, CredentialsRepository, SharedCredentialsRepository } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { hasGlobalScope } from '@n8n/permissions';
|
||||
import type { CredentialSharingRole, ProjectRole, Scope } from '@n8n/permissions';
|
||||
@ -18,6 +18,59 @@ export class CredentialsFinderService {
|
||||
private readonly roleService: RoleService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Fetches global credentials from the database.
|
||||
*/
|
||||
private async fetchGlobalCredentials(trx?: EntityManager): Promise<CredentialsEntity[]> {
|
||||
const em = trx ?? this.credentialsRepository.manager;
|
||||
return await em.find(CredentialsEntity, {
|
||||
where: { isGlobal: true },
|
||||
relations: { shared: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the scopes allow read-only access to global credentials.
|
||||
* Global credentials can be accessed with credential:read scope only.
|
||||
*/
|
||||
hasGlobalReadOnlyAccess(scopes: Scope[]): boolean {
|
||||
return scopes.length === 1 && scopes[0] === 'credential:read';
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a global credential by ID if it exists.
|
||||
*/
|
||||
async findGlobalCredentialById(
|
||||
credentialId: string,
|
||||
relations?: { shared: { project: { projectRelations: { user: boolean } } } },
|
||||
): Promise<CredentialsEntity | null> {
|
||||
return await this.credentialsRepository.findOne({
|
||||
where: {
|
||||
id: credentialId,
|
||||
isGlobal: true,
|
||||
},
|
||||
relations,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges global credentials with the provided credentials list,
|
||||
* deduplicating based on credential ID.
|
||||
*/
|
||||
private mergeAndDeduplicateCredentials<T extends { id: string }>(
|
||||
credentials: T[],
|
||||
globalCredentials: CredentialsEntity[],
|
||||
mapGlobalCredential: (cred: CredentialsEntity) => T | null,
|
||||
): T[] {
|
||||
const credentialIds = new Set(credentials.map((c) => c.id));
|
||||
const newGlobalCreds = globalCredentials
|
||||
.filter((gc) => !credentialIds.has(gc.id))
|
||||
.map(mapGlobalCredential)
|
||||
.filter((mapped): mapped is T => mapped !== null);
|
||||
|
||||
return [...credentials, ...newGlobalCreds];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all credentials that the user has access to taking the scopes into
|
||||
* account.
|
||||
@ -26,7 +79,7 @@ export class CredentialsFinderService {
|
||||
* all scopes the user has for the credential using `RoleService.addScopes`.
|
||||
**/
|
||||
async findCredentialsForUser(user: User, scopes: Scope[]) {
|
||||
let where: FindOptionsWhere<CredentialsEntity> = {};
|
||||
let where: FindOptionsWhere<CredentialsEntity> = { isGlobal: false };
|
||||
|
||||
if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
|
||||
const [projectRoles, credentialRoles] = await Promise.all([
|
||||
@ -47,11 +100,26 @@ export class CredentialsFinderService {
|
||||
};
|
||||
}
|
||||
|
||||
return await this.credentialsRepository.find({ where, relations: { shared: true } });
|
||||
const credentials = await this.credentialsRepository.find({
|
||||
where,
|
||||
relations: { shared: true },
|
||||
});
|
||||
|
||||
// Include global credentials only if the user has read-only access
|
||||
if (this.hasGlobalReadOnlyAccess(scopes)) {
|
||||
const globalCredentials = await this.fetchGlobalCredentials();
|
||||
return [...credentials, ...globalCredentials];
|
||||
}
|
||||
|
||||
return credentials;
|
||||
}
|
||||
|
||||
/** Get a credential if it has been shared with a user */
|
||||
async findCredentialForUser(credentialsId: string, user: User, scopes: Scope[]) {
|
||||
async findCredentialForUser(
|
||||
credentialsId: string,
|
||||
user: User,
|
||||
scopes: Scope[],
|
||||
): Promise<CredentialsEntity | null> {
|
||||
let where: FindOptionsWhere<SharedCredentials> = { credentialsId };
|
||||
|
||||
if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
|
||||
@ -80,12 +148,28 @@ export class CredentialsFinderService {
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!sharedCredential) return null;
|
||||
return sharedCredential.credentials;
|
||||
|
||||
if (sharedCredential) {
|
||||
return sharedCredential.credentials;
|
||||
}
|
||||
|
||||
// Check for global credentials with read-only access
|
||||
if (this.hasGlobalReadOnlyAccess(scopes)) {
|
||||
return await this.findGlobalCredentialById(credentialsId, {
|
||||
shared: { project: { projectRelations: { user: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Get all credentials shared to a user */
|
||||
async findAllCredentialsForUser(user: User, scopes: Scope[], trx?: EntityManager) {
|
||||
async findAllCredentialsForUser(
|
||||
user: User,
|
||||
scopes: Scope[],
|
||||
trx?: EntityManager,
|
||||
options?: { includeGlobalCredentials?: boolean },
|
||||
) {
|
||||
let where: FindOptionsWhere<SharedCredentials> = {};
|
||||
|
||||
if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
|
||||
@ -109,7 +193,31 @@ export class CredentialsFinderService {
|
||||
trx,
|
||||
);
|
||||
|
||||
return sharedCredential.map((sc) => ({ ...sc.credentials, projectId: sc.projectId }));
|
||||
let sharedCredentialsList = sharedCredential.map((sc) => ({
|
||||
...sc.credentials,
|
||||
projectId: sc.projectId,
|
||||
}));
|
||||
|
||||
// Include global credentials if flag is set
|
||||
if (options?.includeGlobalCredentials) {
|
||||
const globalCredentials = await this.fetchGlobalCredentials(trx);
|
||||
sharedCredentialsList = this.mergeAndDeduplicateCredentials(
|
||||
sharedCredentialsList,
|
||||
globalCredentials,
|
||||
(globalCred) => {
|
||||
// For global credentials, use the owner's project ID
|
||||
const ownerSharing = globalCred.shared?.find((s) => s.role === 'credential:owner');
|
||||
const projectId = ownerSharing?.projectId;
|
||||
if (projectId) {
|
||||
return { ...globalCred, projectId };
|
||||
}
|
||||
// Skip credentials without a valid project ID
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return sharedCredentialsList;
|
||||
}
|
||||
|
||||
async getCredentialIdsByUserAndRole(
|
||||
|
||||
@ -25,7 +25,7 @@ import {
|
||||
Param,
|
||||
Query,
|
||||
} from '@n8n/decorators';
|
||||
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
|
||||
import { hasGlobalScope, PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
|
||||
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
||||
import { In } from '@n8n/typeorm';
|
||||
import { deepCopy } from 'n8n-workflow';
|
||||
@ -74,6 +74,7 @@ export class CredentialsController {
|
||||
includeScopes: query.includeScopes,
|
||||
includeData: query.includeData,
|
||||
onlySharedWithMe: query.onlySharedWithMe,
|
||||
includeGlobal: query.includeGlobal,
|
||||
});
|
||||
credentials.forEach((c) => {
|
||||
// @ts-expect-error: This is to emulate the old behavior of removing the shared
|
||||
@ -242,6 +243,18 @@ export class CredentialsController {
|
||||
data: preparedCredentialData.data as unknown as ICredentialDataDecryptedObject,
|
||||
});
|
||||
|
||||
// Update isGlobal if provided in the payload and user has permission
|
||||
const isGlobal = body.isGlobal;
|
||||
if (isGlobal !== undefined && isGlobal !== credential.isGlobal) {
|
||||
const canShareGlobally = hasGlobalScope(req.user, 'credential:shareGlobally');
|
||||
if (!canShareGlobally) {
|
||||
throw new ForbiddenError(
|
||||
'You do not have permission to change global sharing for credentials',
|
||||
);
|
||||
}
|
||||
newCredentialData.isGlobal = isGlobal;
|
||||
}
|
||||
|
||||
const responseData = await this.credentialsService.update(credentialId, newCredentialData);
|
||||
|
||||
if (responseData === null) {
|
||||
|
||||
@ -50,6 +50,7 @@ import { CredentialsTester } from '@/services/credentials-tester.service';
|
||||
import { OwnershipService } from '@/services/ownership.service';
|
||||
import { ProjectService } from '@/services/project.service.ee';
|
||||
import { RoleService } from '@/services/role.service';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
|
||||
export type CredentialsGetSharedOptions =
|
||||
| { allowGlobalScope: true; globalScope: Scope }
|
||||
@ -77,6 +78,40 @@ export class CredentialsService {
|
||||
private readonly credentialsFinderService: CredentialsFinderService,
|
||||
) {}
|
||||
|
||||
private async addGlobalCredentials(
|
||||
credentials: CredentialsEntity[],
|
||||
includeData: boolean,
|
||||
): Promise<CredentialsEntity[]> {
|
||||
const globalCredentials =
|
||||
await this.credentialsRepository.findAllGlobalCredentials(includeData);
|
||||
|
||||
// Merge and deduplicate based on credential ID
|
||||
const credentialIds = new Set(credentials.map((c) => c.id));
|
||||
const newGlobalCreds = globalCredentials.filter((gc) => !credentialIds.has(gc.id));
|
||||
|
||||
return [...credentials, ...newGlobalCreds];
|
||||
}
|
||||
|
||||
async getMany(
|
||||
user: User,
|
||||
options: {
|
||||
listQueryOptions?: ListQuery.Options & { includeData?: boolean };
|
||||
includeScopes?: boolean;
|
||||
includeData: true;
|
||||
onlySharedWithMe?: boolean;
|
||||
includeGlobal?: boolean;
|
||||
},
|
||||
): Promise<Array<ICredentialsDecrypted<ICredentialDataDecryptedObject>>>;
|
||||
async getMany(
|
||||
user: User,
|
||||
options?: {
|
||||
listQueryOptions?: ListQuery.Options & { includeData?: boolean };
|
||||
includeScopes?: boolean;
|
||||
includeData?: boolean;
|
||||
onlySharedWithMe?: boolean;
|
||||
includeGlobal?: boolean;
|
||||
},
|
||||
): Promise<CredentialsEntity[]>;
|
||||
async getMany(
|
||||
user: User,
|
||||
{
|
||||
@ -84,20 +119,55 @@ export class CredentialsService {
|
||||
includeScopes = false,
|
||||
includeData = false,
|
||||
onlySharedWithMe = false,
|
||||
includeGlobal = false,
|
||||
}: {
|
||||
listQueryOptions?: ListQuery.Options & { includeData?: boolean };
|
||||
includeScopes?: boolean;
|
||||
includeData?: boolean;
|
||||
onlySharedWithMe?: boolean;
|
||||
includeGlobal?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
): Promise<Array<ICredentialsDecrypted<ICredentialDataDecryptedObject>> | CredentialsEntity[]> {
|
||||
const returnAll = hasGlobalScope(user, 'credential:list');
|
||||
const isDefaultSelect = !listQueryOptions.select;
|
||||
const projectId =
|
||||
typeof listQueryOptions.filter?.projectId === 'string'
|
||||
? listQueryOptions.filter.projectId
|
||||
: undefined;
|
||||
|
||||
this.applyOnlySharedWithMeFilter(listQueryOptions, onlySharedWithMe, user);
|
||||
|
||||
// Auto-enable includeScopes when includeData is requested
|
||||
if (includeData) {
|
||||
includeScopes = true;
|
||||
listQueryOptions.includeData = true;
|
||||
}
|
||||
|
||||
let credentials: CredentialsEntity[];
|
||||
|
||||
if (returnAll) {
|
||||
credentials = await this.getManyForAdminUser(listQueryOptions, includeGlobal, includeData);
|
||||
} else {
|
||||
credentials = await this.getManyForMemberUser(
|
||||
user,
|
||||
listQueryOptions,
|
||||
includeGlobal,
|
||||
includeData,
|
||||
);
|
||||
}
|
||||
|
||||
return await this.enrichCredentials(
|
||||
credentials,
|
||||
user,
|
||||
isDefaultSelect,
|
||||
includeScopes,
|
||||
includeData,
|
||||
listQueryOptions,
|
||||
onlySharedWithMe,
|
||||
);
|
||||
}
|
||||
|
||||
private applyOnlySharedWithMeFilter(
|
||||
listQueryOptions: ListQuery.Options & { includeData?: boolean },
|
||||
onlySharedWithMe: boolean,
|
||||
user: User,
|
||||
): void {
|
||||
if (onlySharedWithMe) {
|
||||
listQueryOptions.filter = {
|
||||
...listQueryOptions.filter,
|
||||
@ -105,125 +175,174 @@ export class CredentialsService {
|
||||
user,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (includeData) {
|
||||
// We need the scopes to check if we're allowed to include the decrypted
|
||||
// data.
|
||||
// Only if the user has the `credential:update` scope the user is allowed
|
||||
// to get the data.
|
||||
includeScopes = true;
|
||||
listQueryOptions.includeData = true;
|
||||
private async getManyForAdminUser(
|
||||
listQueryOptions: ListQuery.Options & { includeData?: boolean },
|
||||
includeGlobal: boolean,
|
||||
includeData: boolean,
|
||||
): Promise<CredentialsEntity[]> {
|
||||
await this.applyPersonalProjectFilter(listQueryOptions);
|
||||
|
||||
let credentials = await this.credentialsRepository.findMany(listQueryOptions);
|
||||
|
||||
if (includeGlobal) {
|
||||
credentials = await this.addGlobalCredentials(credentials, includeData);
|
||||
}
|
||||
|
||||
if (returnAll) {
|
||||
let project: Project | undefined;
|
||||
|
||||
if (projectId) {
|
||||
try {
|
||||
project = await this.projectService.getProject(projectId);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (project?.type === 'personal') {
|
||||
listQueryOptions.filter = {
|
||||
...listQueryOptions.filter,
|
||||
withRole: 'credential:owner',
|
||||
};
|
||||
}
|
||||
|
||||
let credentials = await this.credentialsRepository.findMany(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 (
|
||||
(listQueryOptions.filter?.shared as { projectId?: string })?.projectId ??
|
||||
onlySharedWithMe
|
||||
) {
|
||||
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));
|
||||
}
|
||||
|
||||
if (includeScopes) {
|
||||
const projectRelations = await this.projectService.getProjectRelationsForUser(user);
|
||||
credentials = credentials.map((c) => this.roleService.addScopes(c, user, projectRelations));
|
||||
}
|
||||
|
||||
if (includeData) {
|
||||
credentials = credentials.map((c: CredentialsEntity & ScopesField) => {
|
||||
const data = c.scopes.includes('credential:update') ? this.decrypt(c) : undefined;
|
||||
// We never want to expose the oauthTokenData to the frontend, but it
|
||||
// expects it to check if the credential is already connected.
|
||||
if (data?.oauthTokenData) {
|
||||
data.oauthTokenData = true;
|
||||
}
|
||||
|
||||
return {
|
||||
...c,
|
||||
data,
|
||||
} as unknown as CredentialsEntity;
|
||||
});
|
||||
}
|
||||
|
||||
return credentials;
|
||||
}
|
||||
return credentials;
|
||||
}
|
||||
|
||||
private async getManyForMemberUser(
|
||||
user: User,
|
||||
listQueryOptions: ListQuery.Options & { includeData?: boolean },
|
||||
includeGlobal: boolean,
|
||||
includeData: boolean,
|
||||
): Promise<CredentialsEntity[]> {
|
||||
const ids = await this.credentialsFinderService.getCredentialIdsByUserAndRole([user.id], {
|
||||
scopes: ['credential:read'],
|
||||
});
|
||||
|
||||
let credentials = await this.credentialsRepository.findMany(
|
||||
listQueryOptions,
|
||||
ids, // only accessible credentials
|
||||
);
|
||||
let credentials = await this.credentialsRepository.findMany(listQueryOptions, ids);
|
||||
|
||||
if (includeGlobal) {
|
||||
credentials = await this.addGlobalCredentials(credentials, includeData);
|
||||
}
|
||||
|
||||
return credentials;
|
||||
}
|
||||
|
||||
private async applyPersonalProjectFilter(
|
||||
listQueryOptions: ListQuery.Options & { includeData?: boolean },
|
||||
): Promise<void> {
|
||||
const projectId =
|
||||
typeof listQueryOptions.filter?.projectId === 'string'
|
||||
? listQueryOptions.filter.projectId
|
||||
: undefined;
|
||||
|
||||
if (!projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let project: Project | undefined;
|
||||
try {
|
||||
project = await this.projectService.getProject(projectId);
|
||||
} catch {}
|
||||
|
||||
if (project?.type === 'personal') {
|
||||
listQueryOptions.filter = {
|
||||
...listQueryOptions.filter,
|
||||
withRole: 'credential:owner',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async enrichCredentials(
|
||||
credentials: CredentialsEntity[],
|
||||
user: User,
|
||||
isDefaultSelect: boolean,
|
||||
includeScopes: boolean,
|
||||
includeData: true,
|
||||
listQueryOptions: ListQuery.Options & { includeData?: boolean },
|
||||
onlySharedWithMe: boolean,
|
||||
): Promise<Array<ICredentialsDecrypted<ICredentialDataDecryptedObject>>>;
|
||||
private async enrichCredentials(
|
||||
credentials: CredentialsEntity[],
|
||||
user: User,
|
||||
isDefaultSelect: boolean,
|
||||
includeScopes: boolean,
|
||||
includeData: boolean,
|
||||
listQueryOptions: ListQuery.Options & { includeData?: boolean },
|
||||
onlySharedWithMe: boolean,
|
||||
): Promise<CredentialsEntity[]>;
|
||||
private async enrichCredentials(
|
||||
credentials: CredentialsEntity[],
|
||||
user: User,
|
||||
isDefaultSelect: boolean,
|
||||
includeScopes: boolean,
|
||||
includeData: boolean,
|
||||
listQueryOptions: ListQuery.Options & { includeData?: boolean },
|
||||
onlySharedWithMe: boolean,
|
||||
): Promise<Array<ICredentialsDecrypted<ICredentialDataDecryptedObject>> | CredentialsEntity[]> {
|
||||
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 (
|
||||
(listQueryOptions.filter?.shared as { projectId?: string })?.projectId ??
|
||||
onlySharedWithMe
|
||||
) {
|
||||
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 = await this.populateSharedRelations(
|
||||
credentials,
|
||||
listQueryOptions,
|
||||
onlySharedWithMe,
|
||||
);
|
||||
}
|
||||
|
||||
if (includeScopes) {
|
||||
const projectRelations = await this.projectService.getProjectRelationsForUser(user);
|
||||
credentials = credentials.map((c) => this.roleService.addScopes(c, user, projectRelations));
|
||||
credentials = await this.addScopesToCredentials(credentials, user);
|
||||
}
|
||||
|
||||
if (includeData) {
|
||||
credentials = credentials.map((c: CredentialsEntity & ScopesField) => {
|
||||
return {
|
||||
...c,
|
||||
data: c.scopes.includes('credential:update') ? this.decrypt(c) : undefined,
|
||||
} as unknown as CredentialsEntity;
|
||||
});
|
||||
return this.addDecryptedDataToCredentials(credentials);
|
||||
}
|
||||
|
||||
return credentials;
|
||||
}
|
||||
|
||||
private async populateSharedRelations(
|
||||
credentials: CredentialsEntity[],
|
||||
listQueryOptions: ListQuery.Options & { includeData?: boolean },
|
||||
onlySharedWithMe: boolean,
|
||||
): Promise<CredentialsEntity[]> {
|
||||
const needsRelations =
|
||||
listQueryOptions.filter?.shared &&
|
||||
typeof listQueryOptions.filter.shared === 'object' &&
|
||||
'projectId' in listQueryOptions.filter.shared
|
||||
? listQueryOptions.filter.shared.projectId
|
||||
: onlySharedWithMe;
|
||||
|
||||
if (needsRelations) {
|
||||
const relations = await this.sharedCredentialsRepository.getAllRelationsForCredentials(
|
||||
credentials.map((c) => c.id),
|
||||
);
|
||||
credentials.forEach((c) => {
|
||||
c.shared = relations.filter((r) => r.credentialsId === c.id);
|
||||
});
|
||||
}
|
||||
|
||||
return credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c));
|
||||
}
|
||||
|
||||
private async addScopesToCredentials(
|
||||
credentials: CredentialsEntity[],
|
||||
user: User,
|
||||
): Promise<CredentialsEntity[]> {
|
||||
const projectRelations = await this.projectService.getProjectRelationsForUser(user);
|
||||
return credentials.map((c) => this.roleService.addScopes(c, user, projectRelations));
|
||||
}
|
||||
|
||||
private addDecryptedDataToCredentials(
|
||||
credentials: CredentialsEntity[],
|
||||
): Array<ICredentialsDecrypted<ICredentialDataDecryptedObject>> {
|
||||
return credentials.map(
|
||||
(
|
||||
c: CredentialsEntity & ScopesField,
|
||||
): ICredentialsDecrypted<ICredentialDataDecryptedObject> => {
|
||||
const data = c.scopes.includes('credential:update') ? this.decrypt(c) : undefined;
|
||||
|
||||
// We never want to expose the oauthTokenData to the frontend, but it
|
||||
// expects it to check if the credential is already connected.
|
||||
if (data?.oauthTokenData) {
|
||||
data.oauthTokenData = true;
|
||||
}
|
||||
|
||||
return {
|
||||
...c,
|
||||
data,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param user The user making the request
|
||||
* @param options.workflowId The workflow that is being edited
|
||||
@ -237,7 +356,7 @@ export class CredentialsService {
|
||||
// necessary to get the scopes
|
||||
const projectRelations = await this.projectService.getProjectRelationsForUser(user);
|
||||
|
||||
// get all credentials the user has access to
|
||||
// get all credentials the user has access to (including global credentials)
|
||||
const allCredentials = await this.credentialsFinderService.findCredentialsForUser(user, [
|
||||
'credential:read',
|
||||
]);
|
||||
@ -250,7 +369,9 @@ export class CredentialsService {
|
||||
|
||||
// the intersection of both is all credentials the user can use in this
|
||||
// workflow or project
|
||||
const intersection = allCredentials.filter((c) => allCredentialsForWorkflow.includes(c.id));
|
||||
const intersection = allCredentials.filter(
|
||||
(c) => allCredentialsForWorkflow.includes(c.id) || c.isGlobal,
|
||||
);
|
||||
|
||||
return intersection
|
||||
.map((c) => this.roleService.addScopes(c, user, projectRelations))
|
||||
@ -260,6 +381,7 @@ export class CredentialsService {
|
||||
type: c.type,
|
||||
scopes: c.scopes,
|
||||
isManaged: c.isManaged,
|
||||
isGlobal: c.isGlobal,
|
||||
}));
|
||||
}
|
||||
|
||||
@ -757,6 +879,18 @@ export class CredentialsService {
|
||||
data: opts.data as ICredentialDataDecryptedObject,
|
||||
});
|
||||
|
||||
// Set isGlobal if provided in the payload and user has permission
|
||||
const isGlobal = opts.isGlobal;
|
||||
if (isGlobal === true) {
|
||||
const canShareGlobally = hasGlobalScope(user, 'credential:shareGlobally');
|
||||
if (!canShareGlobally) {
|
||||
throw new ForbiddenError(
|
||||
'You do not have permission to create globally shared credentials',
|
||||
);
|
||||
}
|
||||
encryptedCredential.isGlobal = isGlobal;
|
||||
}
|
||||
|
||||
const credentialEntity = this.credentialsRepository.create({
|
||||
...encryptedCredential,
|
||||
isManaged: opts.isManaged,
|
||||
|
||||
@ -7,42 +7,279 @@ import { mockEntityManager } from '@test/mocking';
|
||||
const entityManager = mockEntityManager(CredentialsEntity);
|
||||
const repository = Container.get(CredentialsRepository);
|
||||
|
||||
describe('findMany', () => {
|
||||
const credentialsId = 'cred_123';
|
||||
const credential = mock<CredentialsEntity>({ id: credentialsId });
|
||||
|
||||
describe('CredentialsRepository', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
test('return `data` property if `includeData:true` and select is using the record syntax', async () => {
|
||||
// ARRANGE
|
||||
entityManager.find.mockResolvedValueOnce([credential]);
|
||||
describe('findMany', () => {
|
||||
const credentialsId = 'cred_123';
|
||||
const credential = mock<CredentialsEntity>({ id: credentialsId });
|
||||
|
||||
// ACT
|
||||
const credentials = await repository.findMany({ includeData: true, select: { id: true } });
|
||||
test('return `data` property if `includeData:true` and select is using the record syntax', async () => {
|
||||
// ARRANGE
|
||||
entityManager.find.mockResolvedValueOnce([credential]);
|
||||
|
||||
// ASSERT
|
||||
expect(credentials).toHaveLength(1);
|
||||
expect(credentials[0]).toHaveProperty('data');
|
||||
});
|
||||
// ACT
|
||||
const credentials = await repository.findMany({ includeData: true, select: { id: true } });
|
||||
|
||||
test('return `data` property if `includeData:true` and select is using the record syntax', async () => {
|
||||
// ARRANGE
|
||||
entityManager.find.mockResolvedValueOnce([credential]);
|
||||
|
||||
// ACT
|
||||
const credentials = await repository.findMany({
|
||||
includeData: true,
|
||||
//TODO: fix this
|
||||
// The function's type does not support this but this is what it
|
||||
// actually gets from the service because the middlewares are typed
|
||||
// loosely.
|
||||
select: ['id'] as never,
|
||||
// ASSERT
|
||||
expect(credentials).toHaveLength(1);
|
||||
expect(credentials[0]).toHaveProperty('data');
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
expect(credentials).toHaveLength(1);
|
||||
expect(credentials[0]).toHaveProperty('data');
|
||||
test('return `data` property if `includeData:true` and select is using the array syntax', async () => {
|
||||
// ARRANGE
|
||||
entityManager.find.mockResolvedValueOnce([credential]);
|
||||
|
||||
// ACT
|
||||
const credentials = await repository.findMany({
|
||||
includeData: true,
|
||||
//TODO: fix this
|
||||
// The function's type does not support this but this is what it
|
||||
// actually gets from the service because the middlewares are typed
|
||||
// loosely.
|
||||
select: ['id'] as never,
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
expect(credentials).toHaveLength(1);
|
||||
expect(credentials[0]).toHaveProperty('data');
|
||||
});
|
||||
|
||||
test('should include isGlobal in default select', async () => {
|
||||
// ARRANGE
|
||||
entityManager.find.mockResolvedValueOnce([credential]);
|
||||
|
||||
// ACT
|
||||
await repository.findMany();
|
||||
|
||||
// ASSERT
|
||||
expect(entityManager.find).toHaveBeenCalledWith(
|
||||
CredentialsEntity,
|
||||
expect.objectContaining({
|
||||
select: expect.arrayContaining(['isGlobal']),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAllGlobalCredentials', () => {
|
||||
test('should find all global credentials without data by default', async () => {
|
||||
// ARRANGE
|
||||
const globalCred1 = mock<CredentialsEntity>({ id: 'global1', isGlobal: true });
|
||||
const globalCred2 = mock<CredentialsEntity>({ id: 'global2', isGlobal: true });
|
||||
entityManager.find.mockResolvedValueOnce([globalCred1, globalCred2]);
|
||||
|
||||
// ACT
|
||||
const credentials = await repository.findAllGlobalCredentials();
|
||||
|
||||
// ASSERT
|
||||
expect(entityManager.find).toHaveBeenCalledWith(
|
||||
CredentialsEntity,
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ isGlobal: true }),
|
||||
select: expect.arrayContaining([
|
||||
'id',
|
||||
'name',
|
||||
'type',
|
||||
'isManaged',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'isGlobal',
|
||||
]),
|
||||
relations: ['shared', 'shared.project', 'shared.project.projectRelations'],
|
||||
}),
|
||||
);
|
||||
expect(entityManager.find).toHaveBeenCalledWith(
|
||||
CredentialsEntity,
|
||||
expect.not.objectContaining({
|
||||
select: expect.arrayContaining(['data']),
|
||||
}),
|
||||
);
|
||||
expect(credentials).toHaveLength(2);
|
||||
expect(credentials).toEqual([globalCred1, globalCred2]);
|
||||
});
|
||||
|
||||
test('should return empty array when no global credentials exist', async () => {
|
||||
// ARRANGE
|
||||
entityManager.find.mockResolvedValueOnce([]);
|
||||
|
||||
// ACT
|
||||
const credentials = await repository.findAllGlobalCredentials();
|
||||
|
||||
// ASSERT
|
||||
expect(credentials).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should include shared relations for global credentials', async () => {
|
||||
// ARRANGE
|
||||
const globalCred = mock<CredentialsEntity>({
|
||||
id: 'global1',
|
||||
isGlobal: true,
|
||||
shared: [mock()],
|
||||
});
|
||||
entityManager.find.mockResolvedValueOnce([globalCred]);
|
||||
|
||||
// ACT
|
||||
const credentials = await repository.findAllGlobalCredentials();
|
||||
|
||||
// ASSERT
|
||||
expect(entityManager.find).toHaveBeenCalledWith(
|
||||
CredentialsEntity,
|
||||
expect.objectContaining({
|
||||
relations: ['shared', 'shared.project', 'shared.project.projectRelations'],
|
||||
}),
|
||||
);
|
||||
expect(credentials[0].shared).toBeDefined();
|
||||
});
|
||||
|
||||
test('should include data when includeData is true', async () => {
|
||||
// ARRANGE
|
||||
const globalCred = mock<CredentialsEntity>({
|
||||
id: 'global1',
|
||||
isGlobal: true,
|
||||
data: 'encrypted-data',
|
||||
});
|
||||
entityManager.find.mockResolvedValueOnce([globalCred]);
|
||||
|
||||
// ACT
|
||||
const credentials = await repository.findAllGlobalCredentials(true);
|
||||
|
||||
// ASSERT
|
||||
expect(entityManager.find).toHaveBeenCalledWith(
|
||||
CredentialsEntity,
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ isGlobal: true }),
|
||||
select: expect.arrayContaining([
|
||||
'id',
|
||||
'name',
|
||||
'type',
|
||||
'isManaged',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'isGlobal',
|
||||
'data',
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(credentials).toHaveLength(1);
|
||||
expect(credentials[0]).toHaveProperty('data');
|
||||
});
|
||||
|
||||
test('should not include data when includeData is false', async () => {
|
||||
// ARRANGE
|
||||
const globalCred = mock<CredentialsEntity>({
|
||||
id: 'global1',
|
||||
isGlobal: true,
|
||||
});
|
||||
entityManager.find.mockResolvedValueOnce([globalCred]);
|
||||
|
||||
// ACT
|
||||
const credentials = await repository.findAllGlobalCredentials(false);
|
||||
|
||||
// ASSERT
|
||||
expect(entityManager.find).toHaveBeenCalledWith(
|
||||
CredentialsEntity,
|
||||
expect.not.objectContaining({
|
||||
select: expect.arrayContaining(['data']),
|
||||
}),
|
||||
);
|
||||
expect(credentials).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAllPersonalCredentials', () => {
|
||||
test('should find all credentials owned by personal projects', async () => {
|
||||
// ARRANGE
|
||||
const personalCred1 = mock<CredentialsEntity>({ id: 'cred1' });
|
||||
const personalCred2 = mock<CredentialsEntity>({ id: 'cred2' });
|
||||
entityManager.findBy.mockResolvedValueOnce([personalCred1, personalCred2]);
|
||||
|
||||
// ACT
|
||||
const credentials = await repository.findAllPersonalCredentials();
|
||||
|
||||
// ASSERT
|
||||
expect(entityManager.findBy).toHaveBeenCalledWith(CredentialsEntity, {
|
||||
shared: { project: { type: 'personal' } },
|
||||
});
|
||||
expect(credentials).toHaveLength(2);
|
||||
expect(credentials).toEqual([personalCred1, personalCred2]);
|
||||
});
|
||||
|
||||
test('should return empty array when no personal credentials exist', async () => {
|
||||
// ARRANGE
|
||||
entityManager.findBy.mockResolvedValueOnce([]);
|
||||
|
||||
// ACT
|
||||
const credentials = await repository.findAllPersonalCredentials();
|
||||
|
||||
// ASSERT
|
||||
expect(credentials).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAllCredentialsForWorkflow', () => {
|
||||
test('should find all credentials accessible to a workflow', async () => {
|
||||
// ARRANGE
|
||||
const workflowId = 'workflow123';
|
||||
const cred1 = mock<CredentialsEntity>({ id: 'cred1' });
|
||||
const cred2 = mock<CredentialsEntity>({ id: 'cred2' });
|
||||
entityManager.findBy.mockResolvedValueOnce([cred1, cred2]);
|
||||
|
||||
// ACT
|
||||
const credentials = await repository.findAllCredentialsForWorkflow(workflowId);
|
||||
|
||||
// ASSERT
|
||||
expect(entityManager.findBy).toHaveBeenCalledWith(CredentialsEntity, {
|
||||
shared: { project: { sharedWorkflows: { workflowId } } },
|
||||
});
|
||||
expect(credentials).toHaveLength(2);
|
||||
expect(credentials).toEqual([cred1, cred2]);
|
||||
});
|
||||
|
||||
test('should return empty array when workflow has no accessible credentials', async () => {
|
||||
// ARRANGE
|
||||
const workflowId = 'workflow123';
|
||||
entityManager.findBy.mockResolvedValueOnce([]);
|
||||
|
||||
// ACT
|
||||
const credentials = await repository.findAllCredentialsForWorkflow(workflowId);
|
||||
|
||||
// ASSERT
|
||||
expect(credentials).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAllCredentialsForProject', () => {
|
||||
test('should find all credentials in a project', async () => {
|
||||
// ARRANGE
|
||||
const projectId = 'project123';
|
||||
const cred1 = mock<CredentialsEntity>({ id: 'cred1' });
|
||||
const cred2 = mock<CredentialsEntity>({ id: 'cred2' });
|
||||
entityManager.findBy.mockResolvedValueOnce([cred1, cred2]);
|
||||
|
||||
// ACT
|
||||
const credentials = await repository.findAllCredentialsForProject(projectId);
|
||||
|
||||
// ASSERT
|
||||
expect(entityManager.findBy).toHaveBeenCalledWith(CredentialsEntity, {
|
||||
shared: { projectId },
|
||||
});
|
||||
expect(credentials).toHaveLength(2);
|
||||
expect(credentials).toEqual([cred1, cred2]);
|
||||
});
|
||||
|
||||
test('should return empty array when project has no credentials', async () => {
|
||||
// ARRANGE
|
||||
const projectId = 'project123';
|
||||
entityManager.findBy.mockResolvedValueOnce([]);
|
||||
|
||||
// ACT
|
||||
const credentials = await repository.findAllCredentialsForProject(projectId);
|
||||
|
||||
// ASSERT
|
||||
expect(credentials).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -185,6 +185,164 @@ describe('SourceControlExportService', () => {
|
||||
expect(result.missingIds).toHaveLength(1);
|
||||
expect(result.missingIds?.[0]).toBe('cred1');
|
||||
});
|
||||
|
||||
it('should export global credentials with isGlobal flag set to true', async () => {
|
||||
// Arrange
|
||||
const mockGlobalCredential = mock({
|
||||
id: 'global-cred1',
|
||||
name: 'Global Test Credential',
|
||||
type: 'oauth2',
|
||||
data: cipher.encrypt(credentialData),
|
||||
isGlobal: true,
|
||||
});
|
||||
|
||||
sharedCredentialsRepository.findByCredentialIds.mockResolvedValue([
|
||||
mock<SharedCredentials>({
|
||||
credentials: mockGlobalCredential,
|
||||
project: mock({
|
||||
type: 'team',
|
||||
id: 'team1',
|
||||
name: 'Test Team',
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
// Act
|
||||
const result = await service.exportCredentialsToWorkFolder([
|
||||
mock<SourceControlledFile>({ id: 'global-cred1' }),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.files).toHaveLength(1);
|
||||
|
||||
const dataCaptor = captor<string>();
|
||||
expect(fsWriteFile).toHaveBeenCalledWith(
|
||||
'/mock/n8n/git/credential_stubs/global-cred1.json',
|
||||
dataCaptor,
|
||||
);
|
||||
|
||||
const exportedData = JSON.parse(dataCaptor.value);
|
||||
expect(exportedData).toEqual({
|
||||
id: 'global-cred1',
|
||||
name: 'Global Test Credential',
|
||||
type: 'oauth2',
|
||||
data: {
|
||||
authUrl: '',
|
||||
accessTokenUrl: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
},
|
||||
ownedBy: {
|
||||
type: 'team',
|
||||
teamId: 'team1',
|
||||
teamName: 'Test Team',
|
||||
},
|
||||
isGlobal: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should export non-global credentials with isGlobal flag set to false', async () => {
|
||||
// Arrange
|
||||
const mockNonGlobalCredential = mock({
|
||||
id: 'non-global-cred1',
|
||||
name: 'Non-Global Test Credential',
|
||||
type: 'oauth2',
|
||||
data: cipher.encrypt(credentialData),
|
||||
isGlobal: false,
|
||||
});
|
||||
|
||||
sharedCredentialsRepository.findByCredentialIds.mockResolvedValue([
|
||||
mock<SharedCredentials>({
|
||||
credentials: mockNonGlobalCredential,
|
||||
project: mock({
|
||||
type: 'personal',
|
||||
projectRelations: [
|
||||
{
|
||||
role: PROJECT_OWNER_ROLE,
|
||||
user: mock({ email: 'user@example.com' }),
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
// Act
|
||||
const result = await service.exportCredentialsToWorkFolder([
|
||||
mock<SourceControlledFile>({ id: 'non-global-cred1' }),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
expect(result.count).toBe(1);
|
||||
|
||||
const dataCaptor = captor<string>();
|
||||
expect(fsWriteFile).toHaveBeenCalledWith(
|
||||
'/mock/n8n/git/credential_stubs/non-global-cred1.json',
|
||||
dataCaptor,
|
||||
);
|
||||
|
||||
const exportedData = JSON.parse(dataCaptor.value);
|
||||
expect(exportedData).toEqual({
|
||||
id: 'non-global-cred1',
|
||||
name: 'Non-Global Test Credential',
|
||||
type: 'oauth2',
|
||||
data: {
|
||||
authUrl: '',
|
||||
accessTokenUrl: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
},
|
||||
ownedBy: {
|
||||
type: 'personal',
|
||||
personalEmail: 'user@example.com',
|
||||
},
|
||||
isGlobal: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should default isGlobal to false when not specified', async () => {
|
||||
// Arrange
|
||||
const mockCredentialWithoutIsGlobal = mock({
|
||||
id: 'cred-no-flag',
|
||||
name: 'Credential Without Flag',
|
||||
type: 'oauth2',
|
||||
data: cipher.encrypt(credentialData),
|
||||
isGlobal: undefined, // explicitly undefined to test the default
|
||||
});
|
||||
|
||||
sharedCredentialsRepository.findByCredentialIds.mockResolvedValue([
|
||||
mock<SharedCredentials>({
|
||||
credentials: mockCredentialWithoutIsGlobal,
|
||||
project: mock({
|
||||
type: 'personal',
|
||||
projectRelations: [
|
||||
{
|
||||
role: PROJECT_OWNER_ROLE,
|
||||
user: mock({ email: 'user@example.com' }),
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
// Act
|
||||
const result = await service.exportCredentialsToWorkFolder([
|
||||
mock<SourceControlledFile>({ id: 'cred-no-flag' }),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
expect(result.count).toBe(1);
|
||||
|
||||
const dataCaptor = captor<string>();
|
||||
expect(fsWriteFile).toHaveBeenCalledWith(
|
||||
'/mock/n8n/git/credential_stubs/cred-no-flag.json',
|
||||
dataCaptor,
|
||||
);
|
||||
|
||||
const exportedData = JSON.parse(dataCaptor.value);
|
||||
// When isGlobal is undefined, the service defaults it to false via destructuring
|
||||
expect(exportedData.isGlobal).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportTagsToWorkFolder', () => {
|
||||
|
||||
@ -522,6 +522,82 @@ describe('SourceControlImportService', () => {
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should parse global credentials with isGlobal flag set to true', async () => {
|
||||
globMock.mockResolvedValue(['/mock/global-credential.json']);
|
||||
|
||||
const mockGlobalCredentialData = {
|
||||
id: 'global-cred1',
|
||||
name: 'Global Test Credential',
|
||||
type: 'oauth2',
|
||||
isGlobal: true,
|
||||
};
|
||||
|
||||
fsReadFile.mockResolvedValue(JSON.stringify(mockGlobalCredentialData));
|
||||
|
||||
const result = await service.getRemoteCredentialsFromFiles(globalAdminContext);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'global-cred1',
|
||||
name: 'Global Test Credential',
|
||||
type: 'oauth2',
|
||||
isGlobal: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse non-global credentials with isGlobal flag set to false', async () => {
|
||||
globMock.mockResolvedValue(['/mock/non-global-credential.json']);
|
||||
|
||||
const mockNonGlobalCredentialData = {
|
||||
id: 'non-global-cred1',
|
||||
name: 'Non-Global Test Credential',
|
||||
type: 'oauth2',
|
||||
isGlobal: false,
|
||||
};
|
||||
|
||||
fsReadFile.mockResolvedValue(JSON.stringify(mockNonGlobalCredentialData));
|
||||
|
||||
const result = await service.getRemoteCredentialsFromFiles(globalAdminContext);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'non-global-cred1',
|
||||
name: 'Non-Global Test Credential',
|
||||
type: 'oauth2',
|
||||
isGlobal: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should default isGlobal to false when not specified in credential file', async () => {
|
||||
globMock.mockResolvedValue(['/mock/credential-no-flag.json']);
|
||||
|
||||
const mockCredentialDataWithoutFlag = {
|
||||
id: 'cred-no-flag',
|
||||
name: 'Credential Without Flag',
|
||||
type: 'oauth2',
|
||||
// isGlobal not specified
|
||||
};
|
||||
|
||||
fsReadFile.mockResolvedValue(JSON.stringify(mockCredentialDataWithoutFlag));
|
||||
|
||||
const result = await service.getRemoteCredentialsFromFiles(globalAdminContext);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'cred-no-flag',
|
||||
name: 'Credential Without Flag',
|
||||
type: 'oauth2',
|
||||
}),
|
||||
);
|
||||
// isGlobal should default to false (undefined will be treated as false by the service)
|
||||
expect(result[0].isGlobal).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRemoteVariablesFromFile', () => {
|
||||
|
||||
@ -1053,6 +1053,91 @@ describe('getStatus', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isGlobal changes', () => {
|
||||
it('should detect when isGlobal changes from false to true', async () => {
|
||||
const local = createCredential({ isGlobal: false });
|
||||
const remote = createCredential({ isGlobal: true });
|
||||
|
||||
sourceControlImportService.getRemoteCredentialsFromFiles.mockResolvedValue([remote]);
|
||||
sourceControlImportService.getLocalCredentialsFromDb.mockResolvedValue([local]);
|
||||
|
||||
const result = await sourceControlStatusService.getStatus(user, {
|
||||
direction: 'push',
|
||||
verbose: true,
|
||||
preferLocalVersion: false,
|
||||
});
|
||||
|
||||
if (Array.isArray(result)) fail('Expected result to be an object.');
|
||||
expect(result.credModifiedInEither).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should detect when isGlobal changes from true to false', async () => {
|
||||
const local = createCredential({ isGlobal: true });
|
||||
const remote = createCredential({ isGlobal: false });
|
||||
|
||||
sourceControlImportService.getRemoteCredentialsFromFiles.mockResolvedValue([remote]);
|
||||
sourceControlImportService.getLocalCredentialsFromDb.mockResolvedValue([local]);
|
||||
|
||||
const result = await sourceControlStatusService.getStatus(user, {
|
||||
direction: 'push',
|
||||
verbose: true,
|
||||
preferLocalVersion: false,
|
||||
});
|
||||
|
||||
if (Array.isArray(result)) fail('Expected result to be an object.');
|
||||
expect(result.credModifiedInEither).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should detect when isGlobal changes from undefined to true', async () => {
|
||||
const local = createCredential({ isGlobal: undefined });
|
||||
const remote = createCredential({ isGlobal: true });
|
||||
|
||||
sourceControlImportService.getRemoteCredentialsFromFiles.mockResolvedValue([remote]);
|
||||
sourceControlImportService.getLocalCredentialsFromDb.mockResolvedValue([local]);
|
||||
|
||||
const result = await sourceControlStatusService.getStatus(user, {
|
||||
direction: 'push',
|
||||
verbose: true,
|
||||
preferLocalVersion: false,
|
||||
});
|
||||
|
||||
if (Array.isArray(result)) fail('Expected result to be an object.');
|
||||
expect(result.credModifiedInEither).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should not detect changes when isGlobal is the same (both true)', async () => {
|
||||
const credential = createCredential({ isGlobal: true });
|
||||
|
||||
sourceControlImportService.getRemoteCredentialsFromFiles.mockResolvedValue([credential]);
|
||||
sourceControlImportService.getLocalCredentialsFromDb.mockResolvedValue([credential]);
|
||||
|
||||
const result = await sourceControlStatusService.getStatus(user, {
|
||||
direction: 'push',
|
||||
verbose: true,
|
||||
preferLocalVersion: false,
|
||||
});
|
||||
|
||||
if (Array.isArray(result)) fail('Expected result to be an object.');
|
||||
expect(result.credModifiedInEither).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not detect changes when isGlobal is the same (both false/undefined)', async () => {
|
||||
const credential = createCredential({ isGlobal: false });
|
||||
|
||||
sourceControlImportService.getRemoteCredentialsFromFiles.mockResolvedValue([credential]);
|
||||
sourceControlImportService.getLocalCredentialsFromDb.mockResolvedValue([credential]);
|
||||
|
||||
const result = await sourceControlStatusService.getStatus(user, {
|
||||
direction: 'push',
|
||||
verbose: true,
|
||||
preferLocalVersion: false,
|
||||
});
|
||||
|
||||
if (Array.isArray(result)) fail('Expected result to be an object.');
|
||||
expect(result.credModifiedInEither).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not detect as modified when everything is the same', async () => {
|
||||
const credential = createCredential({
|
||||
ownedBy: { type: 'team', projectId: 'team1', projectName: 'Team 1' } as any,
|
||||
|
||||
@ -418,7 +418,7 @@ export class SourceControlExportService {
|
||||
}
|
||||
await Promise.all(
|
||||
credentialsToBeExported.map(async (sharing) => {
|
||||
const { name, type, data, id } = sharing.credentials;
|
||||
const { name, type, data, id, isGlobal = false } = sharing.credentials;
|
||||
const credentials = new Credentials({ id, name }, type, data);
|
||||
|
||||
let owner: RemoteResourceOwner | null = null;
|
||||
@ -455,6 +455,7 @@ export class SourceControlExportService {
|
||||
type,
|
||||
data: this.replaceCredentialData(rest),
|
||||
ownedBy: owner,
|
||||
isGlobal,
|
||||
};
|
||||
|
||||
const filePath = this.getCredentialsPath(id);
|
||||
|
||||
@ -388,6 +388,7 @@ export class SourceControlImportService {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
isGlobal: true,
|
||||
shared: {
|
||||
project: {
|
||||
id: true,
|
||||
@ -418,6 +419,7 @@ export class SourceControlImportService {
|
||||
type: local.type,
|
||||
filename: getCredentialExportPath(local.id, this.credentialExportFolder),
|
||||
ownedBy: remoteOwnerProject ? getOwnerFromProject(remoteOwnerProject) : undefined,
|
||||
isGlobal: local.isGlobal,
|
||||
};
|
||||
}) as StatusExportableCredential[];
|
||||
}
|
||||
@ -787,7 +789,7 @@ export class SourceControlImportService {
|
||||
(e) => e.id === credential.id && e.type === credential.type,
|
||||
);
|
||||
|
||||
const { name, type, data, id } = credential;
|
||||
const { name, type, data, id, isGlobal = false } = credential;
|
||||
const newCredentialObject = new Credentials({ id, name }, type);
|
||||
if (existingCredential?.data) {
|
||||
newCredentialObject.data = existingCredential.data;
|
||||
@ -801,7 +803,7 @@ export class SourceControlImportService {
|
||||
}
|
||||
|
||||
this.logger.debug(`Updating credential id ${newCredentialObject.id as string}`);
|
||||
await this.credentialsRepository.upsert(newCredentialObject, ['id']);
|
||||
await this.credentialsRepository.upsert({ ...newCredentialObject, isGlobal }, ['id']);
|
||||
|
||||
const localOwner = existingSharedCredentials.find(
|
||||
(c) => c.credentialsId === credential.id && c.role === 'credential:owner',
|
||||
|
||||
@ -307,13 +307,14 @@ export class SourceControlStatusService {
|
||||
|
||||
const credModifiedInEither: StatusExportableCredential[] = [];
|
||||
credLocalIds.forEach((local) => {
|
||||
// Compare name, type and owner since those are the synced properties for credentials
|
||||
// Compare name, type, owner and isGlobal since those are the synced properties for credentials
|
||||
const mismatchingCreds = credRemoteIds.find((remote) => {
|
||||
return (
|
||||
remote.id === local.id &&
|
||||
(remote.name !== local.name ||
|
||||
remote.type !== local.type ||
|
||||
hasOwnerChanged(remote.ownedBy, local.ownedBy))
|
||||
hasOwnerChanged(remote.ownedBy, local.ownedBy) ||
|
||||
(remote.isGlobal ?? false) !== (local.isGlobal ?? false))
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -13,6 +13,11 @@ export interface ExportableCredential {
|
||||
* Ownership is mirrored at target instance if user is also present there.
|
||||
*/
|
||||
ownedBy: RemoteResourceOwner | null;
|
||||
|
||||
/**
|
||||
* Whether this credential is globally accessible across all projects.
|
||||
*/
|
||||
isGlobal?: boolean;
|
||||
}
|
||||
|
||||
export type StatusExportableCredential = ExportableCredential & {
|
||||
|
||||
@ -2,6 +2,8 @@ import {
|
||||
type Project,
|
||||
type User,
|
||||
type SharedCredentialsRepository,
|
||||
type CredentialsRepository,
|
||||
type CredentialsEntity,
|
||||
GLOBAL_OWNER_ROLE,
|
||||
} from '@n8n/db';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
@ -14,10 +16,12 @@ import { CredentialsPermissionChecker } from '../credentials-permission-checker'
|
||||
|
||||
describe('CredentialsPermissionChecker', () => {
|
||||
const sharedCredentialsRepository = mock<SharedCredentialsRepository>();
|
||||
const credentialsRepository = mock<CredentialsRepository>();
|
||||
const ownershipService = mock<OwnershipService>();
|
||||
const projectService = mock<ProjectService>();
|
||||
const permissionChecker = new CredentialsPermissionChecker(
|
||||
sharedCredentialsRepository,
|
||||
credentialsRepository,
|
||||
ownershipService,
|
||||
projectService,
|
||||
);
|
||||
@ -63,6 +67,7 @@ describe('CredentialsPermissionChecker', () => {
|
||||
it('should throw if a credential is not accessible', async () => {
|
||||
ownershipService.getPersonalProjectOwnerCached.mockResolvedValueOnce(null);
|
||||
sharedCredentialsRepository.getFilteredAccessibleCredentials.mockResolvedValueOnce([]);
|
||||
credentialsRepository.find.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(permissionChecker.check(workflowId, [node])).rejects.toThrow(
|
||||
'Node "Test Node" does not have access to the credential',
|
||||
@ -87,6 +92,7 @@ describe('CredentialsPermissionChecker', () => {
|
||||
sharedCredentialsRepository.getFilteredAccessibleCredentials.mockResolvedValueOnce([
|
||||
credentialId,
|
||||
]);
|
||||
credentialsRepository.find.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(permissionChecker.check(workflowId, [node])).resolves.not.toThrow();
|
||||
|
||||
@ -106,4 +112,61 @@ describe('CredentialsPermissionChecker', () => {
|
||||
expect(projectService.findProjectsWorkflowIsIn).not.toHaveBeenCalled();
|
||||
expect(sharedCredentialsRepository.getFilteredAccessibleCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow global credentials for any project', async () => {
|
||||
ownershipService.getPersonalProjectOwnerCached.mockResolvedValueOnce(null);
|
||||
sharedCredentialsRepository.getFilteredAccessibleCredentials.mockResolvedValueOnce([]);
|
||||
const globalCredential = mock<CredentialsEntity>({
|
||||
id: credentialId,
|
||||
isGlobal: true,
|
||||
});
|
||||
credentialsRepository.find.mockResolvedValueOnce([globalCredential]);
|
||||
|
||||
await expect(permissionChecker.check(workflowId, [node])).resolves.not.toThrow();
|
||||
|
||||
expect(projectService.findProjectsWorkflowIsIn).toHaveBeenCalledWith(workflowId);
|
||||
expect(sharedCredentialsRepository.getFilteredAccessibleCredentials).toHaveBeenCalledWith(
|
||||
[personalProject.id],
|
||||
[credentialId],
|
||||
);
|
||||
expect(credentialsRepository.find).toHaveBeenCalledWith({
|
||||
select: ['id'],
|
||||
where: {
|
||||
isGlobal: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow global credentials for team projects', async () => {
|
||||
const teamProject = mock<Project>({
|
||||
id: 'team-project',
|
||||
name: 'Team Project',
|
||||
type: 'team',
|
||||
});
|
||||
// Reset and set up new mocks for this test
|
||||
jest.resetAllMocks();
|
||||
ownershipService.getWorkflowProjectCached.mockResolvedValue(teamProject);
|
||||
projectService.findProjectsWorkflowIsIn.mockResolvedValue([teamProject.id]);
|
||||
ownershipService.getPersonalProjectOwnerCached.mockResolvedValue(null);
|
||||
sharedCredentialsRepository.getFilteredAccessibleCredentials.mockResolvedValue([]);
|
||||
const globalCredential = mock<CredentialsEntity>({
|
||||
id: credentialId,
|
||||
isGlobal: true,
|
||||
});
|
||||
credentialsRepository.find.mockResolvedValue([globalCredential]);
|
||||
|
||||
await expect(permissionChecker.check(workflowId, [node])).resolves.not.toThrow();
|
||||
|
||||
expect(projectService.findProjectsWorkflowIsIn).toHaveBeenCalledWith(workflowId);
|
||||
expect(sharedCredentialsRepository.getFilteredAccessibleCredentials).toHaveBeenCalledWith(
|
||||
[teamProject.id],
|
||||
[credentialId],
|
||||
);
|
||||
expect(credentialsRepository.find).toHaveBeenCalledWith({
|
||||
select: ['id'],
|
||||
where: {
|
||||
isGlobal: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { Project } from '@n8n/db';
|
||||
import { SharedCredentialsRepository } from '@n8n/db';
|
||||
import { CredentialsRepository, SharedCredentialsRepository } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { hasGlobalScope } from '@n8n/permissions';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
@ -34,6 +34,7 @@ class InaccessibleCredentialError extends UserError {
|
||||
export class CredentialsPermissionChecker {
|
||||
constructor(
|
||||
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
|
||||
private readonly credentialsRepository: CredentialsRepository,
|
||||
private readonly ownershipService: OwnershipService,
|
||||
private readonly projectService: ProjectService,
|
||||
) {}
|
||||
@ -67,14 +68,33 @@ export class CredentialsPermissionChecker {
|
||||
workflowCredIds,
|
||||
);
|
||||
|
||||
const accessibleSet = await this.addGlobalCredentialsToAccessibleSet(accessible);
|
||||
|
||||
for (const credentialsId of workflowCredIds) {
|
||||
if (!accessible.includes(credentialsId)) {
|
||||
if (!accessibleSet.has(credentialsId)) {
|
||||
const nodeToFlag = credIdsToNodes[credentialsId][0];
|
||||
throw new InaccessibleCredentialError(nodeToFlag, homeProject);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds global credentials (isGlobal: true) to the set of accessible credentials.
|
||||
*/
|
||||
private async addGlobalCredentialsToAccessibleSet(
|
||||
accessibleCredentialIds: string[],
|
||||
): Promise<Set<string>> {
|
||||
const accessibleSet = new Set(accessibleCredentialIds);
|
||||
const globalCredentials = await this.credentialsRepository.find({
|
||||
where: { isGlobal: true },
|
||||
select: ['id'],
|
||||
});
|
||||
for (const globalCred of globalCredentials) {
|
||||
accessibleSet.add(globalCred.id);
|
||||
}
|
||||
return accessibleSet;
|
||||
}
|
||||
|
||||
private mapCredIdsToNodes(nodes: INode[]) {
|
||||
return nodes.reduce<{ [credentialId: string]: INode[] }>((map, node) => {
|
||||
if (node.disabled || !node.credentials) return map;
|
||||
|
||||
@ -0,0 +1,244 @@
|
||||
import type { User } from '@n8n/db';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { EntityManager } from '@n8n/typeorm';
|
||||
import type { INodeCredentials } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
ChatHubCredentialsService,
|
||||
type CredentialWithProjectId,
|
||||
} from '../chat-hub-credentials.service';
|
||||
import type { CredentialsFinderService } from '@/credentials/credentials-finder.service';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import type { ChatHubLLMProvider } from '@n8n/api-types';
|
||||
|
||||
describe('ChatHubCredentialsService', () => {
|
||||
const credentialsFinderService = mock<CredentialsFinderService>();
|
||||
const service = new ChatHubCredentialsService(credentialsFinderService);
|
||||
|
||||
const mockUser = mock<User>({ id: 'user-123' });
|
||||
const mockTrx = mock<EntityManager>();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('ensureCredentials', () => {
|
||||
it('should return credential when user has access and credential is found', async () => {
|
||||
const mockCredential = mock<CredentialWithProjectId>({
|
||||
id: 'cred-123',
|
||||
name: 'OpenAI Credentials',
|
||||
type: 'openAiApi',
|
||||
projectId: 'project-456',
|
||||
});
|
||||
|
||||
const credentials: INodeCredentials = {
|
||||
openAiApi: { id: 'cred-123', name: 'OpenAI Credentials' },
|
||||
};
|
||||
|
||||
credentialsFinderService.findAllCredentialsForUser.mockResolvedValue([mockCredential]);
|
||||
|
||||
const result = await service.ensureCredentials(
|
||||
mockUser,
|
||||
'openai' as ChatHubLLMProvider,
|
||||
credentials,
|
||||
mockTrx,
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockCredential);
|
||||
expect(credentialsFinderService.findAllCredentialsForUser).toHaveBeenCalledWith(
|
||||
mockUser,
|
||||
['credential:read'],
|
||||
mockTrx,
|
||||
{ includeGlobalCredentials: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should include global credentials when fetching credentials', async () => {
|
||||
const mockGlobalCredential = mock<CredentialWithProjectId>({
|
||||
id: 'global-cred-123',
|
||||
name: 'Global OpenAI Credentials',
|
||||
type: 'openAiApi',
|
||||
isGlobal: true,
|
||||
projectId: 'project-global',
|
||||
});
|
||||
|
||||
const credentials: INodeCredentials = {
|
||||
openAiApi: { id: 'global-cred-123', name: 'Global OpenAI Credentials' },
|
||||
};
|
||||
|
||||
credentialsFinderService.findAllCredentialsForUser.mockResolvedValue([mockGlobalCredential]);
|
||||
|
||||
const result = await service.ensureCredentials(
|
||||
mockUser,
|
||||
'openai' as ChatHubLLMProvider,
|
||||
credentials,
|
||||
mockTrx,
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockGlobalCredential);
|
||||
expect(credentialsFinderService.findAllCredentialsForUser).toHaveBeenCalledWith(
|
||||
mockUser,
|
||||
['credential:read'],
|
||||
mockTrx,
|
||||
{ includeGlobalCredentials: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw BadRequestError when no credentials are provided', async () => {
|
||||
const credentials: INodeCredentials = {};
|
||||
|
||||
credentialsFinderService.findAllCredentialsForUser.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
service.ensureCredentials(mockUser, 'openai' as ChatHubLLMProvider, credentials, mockTrx),
|
||||
).rejects.toThrow(BadRequestError);
|
||||
await expect(
|
||||
service.ensureCredentials(mockUser, 'openai' as ChatHubLLMProvider, credentials, mockTrx),
|
||||
).rejects.toThrow('No credentials provided for the selected model provider');
|
||||
});
|
||||
|
||||
it('should throw ForbiddenError when user does not have access to the credential', async () => {
|
||||
const mockCredential = mock<CredentialWithProjectId>({
|
||||
id: 'other-cred-456',
|
||||
name: 'Other Credentials',
|
||||
type: 'openAiApi',
|
||||
projectId: 'project-other',
|
||||
});
|
||||
|
||||
const credentials: INodeCredentials = {
|
||||
openAiApi: { id: 'cred-123', name: 'OpenAI Credentials' },
|
||||
};
|
||||
|
||||
credentialsFinderService.findAllCredentialsForUser.mockResolvedValue([mockCredential]);
|
||||
|
||||
await expect(
|
||||
service.ensureCredentials(mockUser, 'openai' as ChatHubLLMProvider, credentials, mockTrx),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
await expect(
|
||||
service.ensureCredentials(mockUser, 'openai' as ChatHubLLMProvider, credentials, mockTrx),
|
||||
).rejects.toThrow("You don't have access to the provided credentials");
|
||||
});
|
||||
|
||||
it('should handle n8n provider by returning null credential ID', async () => {
|
||||
const credentials: INodeCredentials = {};
|
||||
|
||||
credentialsFinderService.findAllCredentialsForUser.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
service.ensureCredentials(mockUser, 'n8n' as ChatHubLLMProvider, credentials, mockTrx),
|
||||
).rejects.toThrow(BadRequestError);
|
||||
});
|
||||
|
||||
it('should handle custom-agent provider by returning null credential ID', async () => {
|
||||
const credentials: INodeCredentials = {};
|
||||
|
||||
credentialsFinderService.findAllCredentialsForUser.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
service.ensureCredentials(
|
||||
mockUser,
|
||||
'custom-agent' as ChatHubLLMProvider,
|
||||
credentials,
|
||||
mockTrx,
|
||||
),
|
||||
).rejects.toThrow(BadRequestError);
|
||||
});
|
||||
|
||||
it('should return first credential when credential is shared through multiple projects', async () => {
|
||||
const mockCredential = mock<CredentialWithProjectId>({
|
||||
id: 'cred-123',
|
||||
name: 'Shared Credentials',
|
||||
type: 'openAiApi',
|
||||
projectId: 'project-1',
|
||||
});
|
||||
|
||||
const credentials: INodeCredentials = {
|
||||
openAiApi: { id: 'cred-123', name: 'Shared Credentials' },
|
||||
};
|
||||
|
||||
credentialsFinderService.findAllCredentialsForUser.mockResolvedValue([mockCredential]);
|
||||
|
||||
const result = await service.ensureCredentials(
|
||||
mockUser,
|
||||
'openai' as ChatHubLLMProvider,
|
||||
credentials,
|
||||
mockTrx,
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockCredential);
|
||||
expect(result).toHaveProperty('projectId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureCredentialById', () => {
|
||||
it('should return credential when user has access to the credential', async () => {
|
||||
const mockCredential = mock<CredentialWithProjectId>({
|
||||
id: 'cred-123',
|
||||
name: 'OpenAI Credentials',
|
||||
type: 'openAiApi',
|
||||
projectId: 'project-456',
|
||||
});
|
||||
|
||||
credentialsFinderService.findAllCredentialsForUser.mockResolvedValue([mockCredential]);
|
||||
|
||||
const result = await service.ensureCredentialById(mockUser, 'cred-123', mockTrx);
|
||||
|
||||
expect(result).toEqual(mockCredential);
|
||||
expect(credentialsFinderService.findAllCredentialsForUser).toHaveBeenCalledWith(
|
||||
mockUser,
|
||||
['credential:read'],
|
||||
mockTrx,
|
||||
{ includeGlobalCredentials: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should include global credentials when fetching by ID', async () => {
|
||||
const mockGlobalCredential = mock<CredentialWithProjectId>({
|
||||
id: 'global-cred-123',
|
||||
name: 'Global OpenAI Credentials',
|
||||
type: 'openAiApi',
|
||||
isGlobal: true,
|
||||
projectId: 'project-global',
|
||||
});
|
||||
|
||||
credentialsFinderService.findAllCredentialsForUser.mockResolvedValue([mockGlobalCredential]);
|
||||
|
||||
const result = await service.ensureCredentialById(mockUser, 'global-cred-123', mockTrx);
|
||||
|
||||
expect(result).toEqual(mockGlobalCredential);
|
||||
expect(credentialsFinderService.findAllCredentialsForUser).toHaveBeenCalledWith(
|
||||
mockUser,
|
||||
['credential:read'],
|
||||
mockTrx,
|
||||
{ includeGlobalCredentials: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenError when user does not have access to the credential', async () => {
|
||||
const mockCredential = mock<CredentialWithProjectId>({
|
||||
id: 'other-cred-456',
|
||||
name: 'Other Credentials',
|
||||
type: 'openAiApi',
|
||||
projectId: 'project-other',
|
||||
});
|
||||
|
||||
credentialsFinderService.findAllCredentialsForUser.mockResolvedValue([mockCredential]);
|
||||
|
||||
await expect(service.ensureCredentialById(mockUser, 'cred-123', mockTrx)).rejects.toThrow(
|
||||
ForbiddenError,
|
||||
);
|
||||
await expect(service.ensureCredentialById(mockUser, 'cred-123', mockTrx)).rejects.toThrow(
|
||||
"You don't have access to the provided credentials",
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenError when credential is not found', async () => {
|
||||
credentialsFinderService.findAllCredentialsForUser.mockResolvedValue([]);
|
||||
|
||||
await expect(service.ensureCredentialById(mockUser, 'cred-123', mockTrx)).rejects.toThrow(
|
||||
ForbiddenError,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -28,6 +28,7 @@ export class ChatHubCredentialsService {
|
||||
user,
|
||||
['credential:read'],
|
||||
trx,
|
||||
{ includeGlobalCredentials: true },
|
||||
);
|
||||
|
||||
const credentialId = this.pickCredentialId(provider, credentials);
|
||||
@ -52,6 +53,7 @@ export class ChatHubCredentialsService {
|
||||
user,
|
||||
['credential:read'],
|
||||
trx,
|
||||
{ includeGlobalCredentials: true },
|
||||
);
|
||||
|
||||
const credential = allCredentials.find((c) => c.id === credentialId);
|
||||
|
||||
@ -24,6 +24,7 @@ const mockCredentialsService = (
|
||||
type: 'mock',
|
||||
shared: [] as SharedCredentials[],
|
||||
isManaged: false,
|
||||
isGlobal: false,
|
||||
id,
|
||||
// Methods present on entities via WithTimestampsAndStringId mixin
|
||||
generateId() {},
|
||||
|
||||
@ -3,12 +3,14 @@ import {
|
||||
ProjectRepository,
|
||||
SharedCredentialsRepository,
|
||||
SharedWorkflowRepository,
|
||||
CredentialsRepository,
|
||||
type User,
|
||||
} from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { type Scope } from '@n8n/permissions';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { RoleService } from '@/services/role.service';
|
||||
|
||||
@ -17,12 +19,18 @@ import { userHasScopes } from '../check-access';
|
||||
describe('userHasScopes', () => {
|
||||
let findByWorkflowMock: jest.Mock;
|
||||
let findByCredentialMock: jest.Mock;
|
||||
let findByGlobalCredentialMock: jest.Mock;
|
||||
let findGlobalCredentialByIdMock: jest.Mock;
|
||||
let hasGlobalReadOnlyAccessMock: jest.Mock;
|
||||
let roleServiceMock: jest.Mock;
|
||||
let mockQueryBuilder: any;
|
||||
|
||||
beforeAll(() => {
|
||||
findByWorkflowMock = jest.fn();
|
||||
findByCredentialMock = jest.fn();
|
||||
findByGlobalCredentialMock = jest.fn();
|
||||
findGlobalCredentialByIdMock = jest.fn();
|
||||
hasGlobalReadOnlyAccessMock = jest.fn();
|
||||
roleServiceMock = jest.fn();
|
||||
|
||||
Container.set(
|
||||
@ -39,6 +47,21 @@ describe('userHasScopes', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
Container.set(
|
||||
CredentialsRepository,
|
||||
mock<CredentialsRepository>({
|
||||
findBy: findByGlobalCredentialMock,
|
||||
}),
|
||||
);
|
||||
|
||||
Container.set(
|
||||
CredentialsFinderService,
|
||||
mock<CredentialsFinderService>({
|
||||
findGlobalCredentialById: findGlobalCredentialByIdMock,
|
||||
hasGlobalReadOnlyAccess: hasGlobalReadOnlyAccessMock,
|
||||
}),
|
||||
);
|
||||
|
||||
mockQueryBuilder = {
|
||||
innerJoin: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
@ -68,6 +91,9 @@ describe('userHasScopes', () => {
|
||||
jest.clearAllMocks();
|
||||
findByWorkflowMock.mockReset();
|
||||
findByCredentialMock.mockReset();
|
||||
findByGlobalCredentialMock.mockReset();
|
||||
findGlobalCredentialByIdMock.mockReset();
|
||||
hasGlobalReadOnlyAccessMock.mockReset();
|
||||
roleServiceMock.mockReset();
|
||||
|
||||
// Default mock responses
|
||||
@ -235,6 +261,8 @@ describe('userHasScopes', () => {
|
||||
role: 'workflow:owner', // Wrong namespace role
|
||||
},
|
||||
]);
|
||||
hasGlobalReadOnlyAccessMock.mockReturnValue(true);
|
||||
findGlobalCredentialByIdMock.mockResolvedValue(null);
|
||||
|
||||
const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User;
|
||||
const scopes = ['credential:read'] as Scope[];
|
||||
@ -295,6 +323,8 @@ describe('userHasScopes', () => {
|
||||
role: 'credential:owner',
|
||||
},
|
||||
]);
|
||||
hasGlobalReadOnlyAccessMock.mockReturnValue(true);
|
||||
findGlobalCredentialByIdMock.mockResolvedValue(null);
|
||||
|
||||
const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User;
|
||||
const scopes = ['credential:read'] as Scope[];
|
||||
@ -395,6 +425,8 @@ describe('userHasScopes', () => {
|
||||
.mockResolvedValueOnce([
|
||||
{ credentialsId: credentialId2, projectId: 'projectId', role: 'credential:viewer' },
|
||||
]);
|
||||
hasGlobalReadOnlyAccessMock.mockReturnValue(true);
|
||||
findGlobalCredentialByIdMock.mockResolvedValue(null);
|
||||
|
||||
const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User;
|
||||
const scopes = ['credential:read'] as Scope[];
|
||||
@ -409,4 +441,109 @@ describe('userHasScopes', () => {
|
||||
expect(roleServiceMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('global credentials', () => {
|
||||
it('should grant access to global credential when user lacks project access for credential:read', async () => {
|
||||
const credentialId = 'global-cred-123';
|
||||
roleServiceMock.mockResolvedValue(['credential:owner']);
|
||||
mockQueryBuilder.getRawMany.mockResolvedValue([]); // No project access
|
||||
|
||||
// Credential exists but user doesn't have access through projects
|
||||
findByCredentialMock.mockResolvedValue([
|
||||
{
|
||||
credentialsId: credentialId,
|
||||
projectId: 'otherProjectId',
|
||||
role: 'credential:owner',
|
||||
},
|
||||
]);
|
||||
|
||||
// But it's a global credential
|
||||
hasGlobalReadOnlyAccessMock.mockReturnValue(true);
|
||||
findGlobalCredentialByIdMock.mockResolvedValue({ id: credentialId, isGlobal: true });
|
||||
|
||||
const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User;
|
||||
const scopes = ['credential:read'] as Scope[];
|
||||
|
||||
const result = await userHasScopes(user, scopes, false, { credentialId });
|
||||
|
||||
expect(hasGlobalReadOnlyAccessMock).toHaveBeenCalledWith(scopes);
|
||||
expect(findGlobalCredentialByIdMock).toHaveBeenCalledWith(credentialId);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should not grant access to non-global credential when user lacks project access', async () => {
|
||||
const credentialId = 'regular-cred-123';
|
||||
roleServiceMock.mockResolvedValue(['credential:owner']);
|
||||
mockQueryBuilder.getRawMany.mockResolvedValue([]); // No project access
|
||||
|
||||
findByCredentialMock.mockResolvedValue([
|
||||
{
|
||||
credentialsId: credentialId,
|
||||
projectId: 'otherProjectId',
|
||||
role: 'credential:owner',
|
||||
},
|
||||
]);
|
||||
|
||||
// Not a global credential
|
||||
hasGlobalReadOnlyAccessMock.mockReturnValue(true);
|
||||
findGlobalCredentialByIdMock.mockResolvedValue(null);
|
||||
|
||||
const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User;
|
||||
const scopes = ['credential:read'] as Scope[];
|
||||
|
||||
const result = await userHasScopes(user, scopes, false, { credentialId });
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should not check global credentials if does not have global read only access', async () => {
|
||||
const credentialId = 'global-cred-123';
|
||||
roleServiceMock.mockResolvedValue(['credential:owner']);
|
||||
mockQueryBuilder.getRawMany.mockResolvedValue([]); // No project access
|
||||
|
||||
findByCredentialMock.mockResolvedValue([
|
||||
{
|
||||
credentialsId: credentialId,
|
||||
projectId: 'otherProjectId',
|
||||
role: 'credential:owner',
|
||||
},
|
||||
]);
|
||||
|
||||
hasGlobalReadOnlyAccessMock.mockReturnValue(false);
|
||||
|
||||
const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User;
|
||||
const scopes = ['credential:update'] as Scope[];
|
||||
|
||||
const result = await userHasScopes(user, scopes, false, { credentialId });
|
||||
|
||||
// Should not check global credentials for non-read scopes
|
||||
expect(hasGlobalReadOnlyAccessMock).toHaveBeenCalledWith(scopes);
|
||||
expect(findGlobalCredentialByIdMock).not.toHaveBeenCalled();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should not check global credentials when user has valid project access', async () => {
|
||||
const credentialId = 'cred-123';
|
||||
roleServiceMock.mockResolvedValue(['credential:owner']);
|
||||
mockQueryBuilder.getRawMany.mockResolvedValue([{ id: 'projectId' }]);
|
||||
|
||||
findByCredentialMock.mockResolvedValue([
|
||||
{
|
||||
credentialsId: credentialId,
|
||||
projectId: 'projectId',
|
||||
role: 'credential:owner',
|
||||
},
|
||||
]);
|
||||
|
||||
const user = { id: 'userId', scopes: [], role: GLOBAL_MEMBER_ROLE } as unknown as User;
|
||||
const scopes = ['credential:read'] as Scope[];
|
||||
|
||||
const result = await userHasScopes(user, scopes, false, { credentialId });
|
||||
|
||||
// Should not check global credentials when normal access works
|
||||
expect(hasGlobalReadOnlyAccessMock).not.toHaveBeenCalled();
|
||||
expect(findGlobalCredentialByIdMock).not.toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,6 +4,7 @@ import { Container } from '@n8n/di';
|
||||
import { hasGlobalScope, type Scope } from '@n8n/permissions';
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { RoleService } from '@/services/role.service';
|
||||
|
||||
@ -60,9 +61,26 @@ export async function userHasScopes(
|
||||
|
||||
const validRoles = await roleService.rolesWithScope('credential', scopes);
|
||||
|
||||
return credentials.some(
|
||||
const hasValidRoles = credentials.some(
|
||||
(c) => userProjectIds.includes(c.projectId) && validRoles.includes(c.role),
|
||||
);
|
||||
|
||||
if (hasValidRoles) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for global credentials with read-only access
|
||||
const credentialsFinderService = Container.get(CredentialsFinderService);
|
||||
if (credentialsFinderService.hasGlobalReadOnlyAccess(scopes)) {
|
||||
const globalCredential =
|
||||
await credentialsFinderService.findGlobalCredentialById(credentialId);
|
||||
|
||||
if (globalCredential) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (workflowId) {
|
||||
|
||||
@ -72,6 +72,7 @@ export declare namespace CredentialRequest {
|
||||
data: ICredentialDataDecryptedObject;
|
||||
projectId?: string;
|
||||
isManaged?: boolean;
|
||||
isGlobal?: boolean;
|
||||
}>;
|
||||
|
||||
type Get = AuthenticatedRequest<{ credentialId: string }, {}, {}, Record<string, string>>;
|
||||
|
||||
@ -4,8 +4,9 @@ import {
|
||||
type SharedCredentials,
|
||||
CredentialsRepository,
|
||||
SharedCredentialsRepository,
|
||||
CredentialsEntity,
|
||||
} from '@n8n/db';
|
||||
import type { CredentialsEntity, User } from '@n8n/db';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import {
|
||||
PROJECT_ADMIN_ROLE_SLUG,
|
||||
@ -35,6 +36,13 @@ describe('CredentialsFinderService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Setup manager mock for global credentials fetching
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
// @ts-ignore
|
||||
credentialsRepository.manager = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
} as any;
|
||||
|
||||
// Default mock implementation for all tests
|
||||
roleService.rolesWithScope.mockImplementation(async (namespace) => {
|
||||
if (namespace === 'project') {
|
||||
@ -119,8 +127,9 @@ describe('CredentialsFinderService', () => {
|
||||
expect(credential).toEqual(sharedCredential.credentials);
|
||||
});
|
||||
|
||||
test('should return null when no shared credential is found', async () => {
|
||||
test('should return null when no shared credential is found and not global', async () => {
|
||||
sharedCredentialsRepository.findOne.mockResolvedValueOnce(null);
|
||||
credentialsRepository.findOne.mockResolvedValueOnce(null);
|
||||
const credential = await credentialsFinderService.findCredentialForUser(
|
||||
credentialsId,
|
||||
member,
|
||||
@ -148,6 +157,64 @@ describe('CredentialsFinderService', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(credentialsRepository.findOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: credentialsId,
|
||||
isGlobal: true,
|
||||
},
|
||||
relations: {
|
||||
shared: { project: { projectRelations: { user: true } } },
|
||||
},
|
||||
});
|
||||
expect(credential).toEqual(null);
|
||||
});
|
||||
|
||||
test('should return global credential when not shared but is global for credential:read scope', async () => {
|
||||
const globalCredential = mock<CredentialsEntity>({ id: credentialsId, isGlobal: true });
|
||||
sharedCredentialsRepository.findOne.mockResolvedValueOnce(null);
|
||||
credentialsRepository.findOne.mockResolvedValueOnce(globalCredential);
|
||||
|
||||
const credential = await credentialsFinderService.findCredentialForUser(
|
||||
credentialsId,
|
||||
member,
|
||||
['credential:read' as const],
|
||||
);
|
||||
|
||||
expect(credentialsRepository.findOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: credentialsId,
|
||||
isGlobal: true,
|
||||
},
|
||||
relations: {
|
||||
shared: { project: { projectRelations: { user: true } } },
|
||||
},
|
||||
});
|
||||
expect(credential).toEqual(globalCredential);
|
||||
});
|
||||
|
||||
test('should not fallback to global credential for write scopes', async () => {
|
||||
sharedCredentialsRepository.findOne.mockResolvedValueOnce(null);
|
||||
|
||||
const credential = await credentialsFinderService.findCredentialForUser(
|
||||
credentialsId,
|
||||
member,
|
||||
['credential:update' as const],
|
||||
);
|
||||
|
||||
expect(credentialsRepository.findOne).not.toHaveBeenCalled();
|
||||
expect(credential).toEqual(null);
|
||||
});
|
||||
|
||||
test('should not fallback to global credential for multiple scopes', async () => {
|
||||
sharedCredentialsRepository.findOne.mockResolvedValueOnce(null);
|
||||
|
||||
const credential = await credentialsFinderService.findCredentialForUser(
|
||||
credentialsId,
|
||||
member,
|
||||
['credential:read' as const, 'credential:update' as const],
|
||||
);
|
||||
|
||||
expect(credentialsRepository.findOne).not.toHaveBeenCalled();
|
||||
expect(credential).toEqual(null);
|
||||
});
|
||||
|
||||
@ -215,17 +282,20 @@ describe('CredentialsFinderService', () => {
|
||||
|
||||
test('should allow global owner access to all credentials without role filtering', async () => {
|
||||
credentialsRepository.find.mockResolvedValueOnce(credentials);
|
||||
|
||||
const result = await credentialsFinderService.findCredentialsForUser(owner, [
|
||||
'credential:read' as const,
|
||||
]);
|
||||
|
||||
expect(credentialsRepository.find).toHaveBeenCalledWith({
|
||||
where: {},
|
||||
where: { isGlobal: false },
|
||||
relations: { shared: true },
|
||||
});
|
||||
expect(credentialsRepository.manager.find).toHaveBeenCalledWith(CredentialsEntity, {
|
||||
where: { isGlobal: true },
|
||||
relations: { shared: true },
|
||||
});
|
||||
expect(roleService.rolesWithScope).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(credentials);
|
||||
expect(result).toEqual([...credentials]);
|
||||
});
|
||||
|
||||
test('should filter credentials by roles for regular members', async () => {
|
||||
@ -239,6 +309,7 @@ describe('CredentialsFinderService', () => {
|
||||
expect(roleService.rolesWithScope).toHaveBeenCalledWith('credential', ['credential:update']);
|
||||
expect(credentialsRepository.find).toHaveBeenCalledWith({
|
||||
where: {
|
||||
isGlobal: false,
|
||||
shared: {
|
||||
role: In(['credential:owner', 'credential:user']),
|
||||
project: {
|
||||
@ -256,9 +327,89 @@ describe('CredentialsFinderService', () => {
|
||||
},
|
||||
relations: { shared: true },
|
||||
});
|
||||
// Should NOT fetch global credentials for update scope
|
||||
expect(credentialsRepository.manager.find).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(credentials);
|
||||
});
|
||||
|
||||
test('should include global credentials when user has read-only access', async () => {
|
||||
const mockGlobalCredentials = [
|
||||
mock<CredentialsEntity>({ id: 'global1', isGlobal: true }),
|
||||
mock<CredentialsEntity>({ id: 'global2', isGlobal: true }),
|
||||
];
|
||||
credentialsRepository.find.mockResolvedValueOnce(credentials);
|
||||
(credentialsRepository.manager.find as jest.Mock).mockResolvedValueOnce(
|
||||
mockGlobalCredentials,
|
||||
);
|
||||
|
||||
const result = await credentialsFinderService.findCredentialsForUser(member, [
|
||||
'credential:read' as const,
|
||||
]);
|
||||
|
||||
// Should include both non-global (cred1, cred2) and global (global1, global2)
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result.map((c) => c.id)).toEqual(['cred1', 'cred2', 'global1', 'global2']);
|
||||
});
|
||||
|
||||
test('should not include global credentials when user has write access', async () => {
|
||||
const mockGlobalCredentials = [
|
||||
mock<CredentialsEntity>({ id: 'global1', isGlobal: true }),
|
||||
mock<CredentialsEntity>({ id: 'global2', isGlobal: true }),
|
||||
];
|
||||
credentialsRepository.find.mockResolvedValueOnce(credentials);
|
||||
(credentialsRepository.manager.find as jest.Mock).mockResolvedValueOnce(
|
||||
mockGlobalCredentials,
|
||||
);
|
||||
|
||||
const result = await credentialsFinderService.findCredentialsForUser(member, [
|
||||
'credential:update' as const,
|
||||
]);
|
||||
|
||||
// Should only include non-global credentials (cred1, cred2)
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((c) => c.id)).toEqual(['cred1', 'cred2']);
|
||||
// Should not call fetchGlobalCredentials when not read-only
|
||||
expect(credentialsRepository.manager.find).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should not include global credentials when user has multiple scopes', async () => {
|
||||
const mockGlobalCredentials = [
|
||||
mock<CredentialsEntity>({ id: 'global1', isGlobal: true }),
|
||||
mock<CredentialsEntity>({ id: 'global2', isGlobal: true }),
|
||||
];
|
||||
credentialsRepository.find.mockResolvedValueOnce(credentials);
|
||||
(credentialsRepository.manager.find as jest.Mock).mockResolvedValueOnce(
|
||||
mockGlobalCredentials,
|
||||
);
|
||||
|
||||
const result = await credentialsFinderService.findCredentialsForUser(member, [
|
||||
'credential:read' as const,
|
||||
'credential:list' as const,
|
||||
]);
|
||||
|
||||
// Should only include non-global credentials (cred1, cred2)
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((c) => c.id)).toEqual(['cred1', 'cred2']);
|
||||
// Should not call fetchGlobalCredentials when multiple scopes
|
||||
expect(credentialsRepository.manager.find).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle empty global credentials list with read-only access', async () => {
|
||||
credentialsRepository.find.mockResolvedValueOnce(credentials);
|
||||
(credentialsRepository.manager.find as jest.Mock).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await credentialsFinderService.findCredentialsForUser(member, [
|
||||
'credential:read' as const,
|
||||
]);
|
||||
|
||||
expect(result).toEqual(credentials);
|
||||
// Should call fetchGlobalCredentials when read-only
|
||||
expect(credentialsRepository.manager.find).toHaveBeenCalledWith(CredentialsEntity, {
|
||||
where: { isGlobal: true },
|
||||
relations: { shared: true },
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle custom roles in filtering', async () => {
|
||||
roleService.rolesWithScope.mockImplementation(async (namespace) => {
|
||||
if (namespace === 'project') return ['custom:project-lead-456'];
|
||||
@ -275,6 +426,7 @@ describe('CredentialsFinderService', () => {
|
||||
|
||||
expect(credentialsRepository.find).toHaveBeenCalledWith({
|
||||
where: {
|
||||
isGlobal: false,
|
||||
shared: {
|
||||
role: In(['custom:cred-admin-789']),
|
||||
project: {
|
||||
@ -394,6 +546,167 @@ describe('CredentialsFinderService', () => {
|
||||
mockTrx,
|
||||
);
|
||||
});
|
||||
|
||||
test('should include global credentials when includeGlobalCredentials flag is true', async () => {
|
||||
const globalCredential = mock<CredentialsEntity>({
|
||||
id: 'global1',
|
||||
isGlobal: true,
|
||||
shared: [
|
||||
mock<SharedCredentials>({
|
||||
credentialsId: 'global1',
|
||||
role: 'credential:owner',
|
||||
projectId: 'proj-owner',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
sharedCredentialsRepository.findCredentialsWithOptions.mockResolvedValueOnce(
|
||||
sharedCredentials,
|
||||
);
|
||||
credentialsRepository.manager.find = jest.fn().mockResolvedValueOnce([globalCredential]);
|
||||
|
||||
const result = await credentialsFinderService.findAllCredentialsForUser(
|
||||
member,
|
||||
['credential:read' as const],
|
||||
undefined,
|
||||
{ includeGlobalCredentials: true },
|
||||
);
|
||||
|
||||
expect(credentialsRepository.manager.find).toHaveBeenCalledWith(CredentialsEntity, {
|
||||
where: { isGlobal: true },
|
||||
relations: { shared: true },
|
||||
});
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[2]).toEqual({ ...globalCredential, projectId: 'proj-owner' });
|
||||
});
|
||||
|
||||
test('should not include global credentials when includeGlobalCredentials flag is false', async () => {
|
||||
sharedCredentialsRepository.findCredentialsWithOptions.mockResolvedValueOnce(
|
||||
sharedCredentials,
|
||||
);
|
||||
|
||||
const result = await credentialsFinderService.findAllCredentialsForUser(
|
||||
member,
|
||||
['credential:read' as const],
|
||||
undefined,
|
||||
{ includeGlobalCredentials: false },
|
||||
);
|
||||
|
||||
expect(credentialsRepository.manager.find).not.toHaveBeenCalled();
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('should not include global credentials when no options provided', async () => {
|
||||
sharedCredentialsRepository.findCredentialsWithOptions.mockResolvedValueOnce(
|
||||
sharedCredentials,
|
||||
);
|
||||
|
||||
const result = await credentialsFinderService.findAllCredentialsForUser(member, [
|
||||
'credential:read' as const,
|
||||
]);
|
||||
|
||||
expect(credentialsRepository.manager.find).not.toHaveBeenCalled();
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('should skip global credentials without valid projectId', async () => {
|
||||
const globalCredentialWithoutProject = mock<CredentialsEntity>({
|
||||
id: 'global-no-proj',
|
||||
isGlobal: true,
|
||||
shared: [
|
||||
mock<SharedCredentials>({
|
||||
credentialsId: 'global-no-proj',
|
||||
role: 'credential:user',
|
||||
projectId: undefined as any,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const globalCredentialWithProject = mock<CredentialsEntity>({
|
||||
id: 'global-with-proj',
|
||||
isGlobal: true,
|
||||
shared: [
|
||||
mock<SharedCredentials>({
|
||||
credentialsId: 'global-with-proj',
|
||||
role: 'credential:owner',
|
||||
projectId: 'proj-owner',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
sharedCredentialsRepository.findCredentialsWithOptions.mockResolvedValueOnce([]);
|
||||
credentialsRepository.manager.find = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce([globalCredentialWithoutProject, globalCredentialWithProject]);
|
||||
|
||||
const result = await credentialsFinderService.findAllCredentialsForUser(
|
||||
member,
|
||||
['credential:read' as const],
|
||||
undefined,
|
||||
{ includeGlobalCredentials: true },
|
||||
);
|
||||
|
||||
// Should only include the credential with valid projectId
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toEqual('global-with-proj');
|
||||
});
|
||||
|
||||
test('should deduplicate global credentials with shared credentials', async () => {
|
||||
const sharedGlobalCred = mock<SharedCredentials>({
|
||||
credentials: mock<CredentialsEntity>({ id: 'cred1' }),
|
||||
projectId: 'proj1',
|
||||
credentialsId: 'cred1',
|
||||
role: 'credential:owner',
|
||||
});
|
||||
|
||||
const globalCredential = mock<CredentialsEntity>({
|
||||
id: 'cred1', // Same ID as shared credential
|
||||
isGlobal: true,
|
||||
shared: [
|
||||
mock<SharedCredentials>({
|
||||
credentialsId: 'cred1',
|
||||
role: 'credential:owner',
|
||||
projectId: 'proj-owner',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
sharedCredentialsRepository.findCredentialsWithOptions.mockResolvedValueOnce([
|
||||
sharedGlobalCred,
|
||||
]);
|
||||
credentialsRepository.manager.find = jest.fn().mockResolvedValueOnce([globalCredential]);
|
||||
|
||||
const result = await credentialsFinderService.findAllCredentialsForUser(
|
||||
member,
|
||||
['credential:read' as const],
|
||||
undefined,
|
||||
{ includeGlobalCredentials: true },
|
||||
);
|
||||
|
||||
// Should not duplicate cred1
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toEqual('cred1');
|
||||
});
|
||||
|
||||
test('should use transaction manager for fetching global credentials', async () => {
|
||||
const mockTrx = mock<any>();
|
||||
const mockFind = jest.fn().mockResolvedValueOnce([]);
|
||||
mockTrx.find = mockFind;
|
||||
|
||||
sharedCredentialsRepository.findCredentialsWithOptions.mockResolvedValueOnce([]);
|
||||
|
||||
await credentialsFinderService.findAllCredentialsForUser(
|
||||
member,
|
||||
['credential:read' as const],
|
||||
mockTrx,
|
||||
{ includeGlobalCredentials: true },
|
||||
);
|
||||
|
||||
expect(mockFind).toHaveBeenCalledWith(CredentialsEntity, {
|
||||
where: { isGlobal: true },
|
||||
relations: { shared: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCredentialIdsByUserAndRole', () => {
|
||||
@ -518,6 +831,7 @@ describe('CredentialsFinderService', () => {
|
||||
|
||||
expect(credentialsRepository.find).toHaveBeenCalledWith({
|
||||
where: {
|
||||
isGlobal: false,
|
||||
shared: {
|
||||
role: In([]),
|
||||
project: {
|
||||
@ -569,6 +883,7 @@ describe('CredentialsFinderService', () => {
|
||||
|
||||
expect(credentialsRepository.find).toHaveBeenCalledWith({
|
||||
where: {
|
||||
isGlobal: false,
|
||||
shared: {
|
||||
role: In(['project:admin']), // Uses what RoleService returned for credential namespace
|
||||
project: {
|
||||
@ -584,4 +899,127 @@ describe('CredentialsFinderService', () => {
|
||||
expect(result).toEqual(isolationResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasGlobalReadOnlyAccess', () => {
|
||||
test('should return true for single credential:read scope', () => {
|
||||
const result = credentialsFinderService.hasGlobalReadOnlyAccess(['credential:read']);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test('should return false for multiple scopes including credential:read', () => {
|
||||
const result = credentialsFinderService.hasGlobalReadOnlyAccess([
|
||||
'credential:read',
|
||||
'credential:update',
|
||||
]);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false for single non-read scope', () => {
|
||||
const result = credentialsFinderService.hasGlobalReadOnlyAccess(['credential:update']);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false for empty scopes array', () => {
|
||||
const result = credentialsFinderService.hasGlobalReadOnlyAccess([]);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false for credential:delete scope', () => {
|
||||
const result = credentialsFinderService.hasGlobalReadOnlyAccess(['credential:delete']);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false for credential:shareGlobally scope', () => {
|
||||
const result = credentialsFinderService.hasGlobalReadOnlyAccess(['credential:shareGlobally']);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findGlobalCredentialById', () => {
|
||||
const credentialId = 'cred-123';
|
||||
const mockGlobalCredential = mock<CredentialsEntity>({
|
||||
id: credentialId,
|
||||
name: 'Global Test Credential',
|
||||
type: 'testApi',
|
||||
isGlobal: true,
|
||||
});
|
||||
|
||||
test('should find global credential by ID without relations', async () => {
|
||||
credentialsRepository.findOne.mockResolvedValueOnce(mockGlobalCredential);
|
||||
|
||||
const result = await credentialsFinderService.findGlobalCredentialById(credentialId);
|
||||
|
||||
expect(credentialsRepository.findOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: credentialId,
|
||||
isGlobal: true,
|
||||
},
|
||||
relations: undefined,
|
||||
});
|
||||
expect(result).toEqual(mockGlobalCredential);
|
||||
});
|
||||
|
||||
test('should find global credential by ID with relations', async () => {
|
||||
const relations = { shared: { project: { projectRelations: { user: true } } } };
|
||||
credentialsRepository.findOne.mockResolvedValueOnce(mockGlobalCredential);
|
||||
|
||||
const result = await credentialsFinderService.findGlobalCredentialById(
|
||||
credentialId,
|
||||
relations,
|
||||
);
|
||||
|
||||
expect(credentialsRepository.findOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: credentialId,
|
||||
isGlobal: true,
|
||||
},
|
||||
relations,
|
||||
});
|
||||
expect(result).toEqual(mockGlobalCredential);
|
||||
});
|
||||
|
||||
test('should return null when global credential not found', async () => {
|
||||
credentialsRepository.findOne.mockResolvedValueOnce(null);
|
||||
|
||||
const result = await credentialsFinderService.findGlobalCredentialById('non-existent-id');
|
||||
|
||||
expect(credentialsRepository.findOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: 'non-existent-id',
|
||||
isGlobal: true,
|
||||
},
|
||||
relations: undefined,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('should only query for global credentials', async () => {
|
||||
credentialsRepository.findOne.mockResolvedValueOnce(null);
|
||||
|
||||
await credentialsFinderService.findGlobalCredentialById(credentialId);
|
||||
|
||||
expect(credentialsRepository.findOne).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
isGlobal: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle repository errors', async () => {
|
||||
const error = new Error('Database connection failed');
|
||||
credentialsRepository.findOne.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(credentialsFinderService.findGlobalCredentialById(credentialId)).rejects.toThrow(
|
||||
'Database connection failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -246,12 +246,17 @@ export class RoleService {
|
||||
throw new UnexpectedError('Cannot detect if entity is a workflow or credential.');
|
||||
}
|
||||
|
||||
entity.scopes = this.combineResourceScopes(
|
||||
'active' in entity ? 'workflow' : 'credential',
|
||||
user,
|
||||
shared,
|
||||
userProjectRelations,
|
||||
);
|
||||
const entityType = 'active' in entity ? 'workflow' : 'credential';
|
||||
entity.scopes = this.combineResourceScopes(entityType, user, shared, userProjectRelations);
|
||||
|
||||
if (
|
||||
entityType === 'credential' &&
|
||||
'isGlobal' in entity &&
|
||||
entity.isGlobal &&
|
||||
!entity.scopes.includes('credential:read')
|
||||
) {
|
||||
entity.scopes.push('credential:read');
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { ImportWorkflowFromUrlDto } from '@n8n/api-types';
|
||||
import type { AuthenticatedRequest, IExecutionResponse } from '@n8n/db';
|
||||
import type { AuthenticatedRequest, IExecutionResponse, CredentialsEntity, User } from '@n8n/db';
|
||||
import { WorkflowEntity } from '@n8n/db';
|
||||
import axios from 'axios';
|
||||
import type { Response } from 'express';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
@ -8,6 +9,10 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import type { ExecutionService } from '@/executions/execution.service';
|
||||
import type { CredentialsService } from '@/credentials/credentials.service';
|
||||
import type { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee';
|
||||
import type { License } from '@/license';
|
||||
import type { WorkflowRequest } from '../workflow.request';
|
||||
import type { ProjectService } from '@/services/project.service.ee';
|
||||
|
||||
import { WorkflowsController } from '../workflows.controller';
|
||||
@ -171,4 +176,128 @@ describe('WorkflowsController', () => {
|
||||
expect(executionService.getLastSuccessfulExecution).toHaveBeenCalledWith(workflowId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
describe('credential retrieval for workflow creation', () => {
|
||||
it('should include global credentials when checking credential permissions', async () => {
|
||||
/**
|
||||
* Arrange
|
||||
*/
|
||||
const mockUser = mock<User>({ id: 'user-123' });
|
||||
const mockRequest = mock<WorkflowRequest.Create>({
|
||||
user: mockUser,
|
||||
body: {
|
||||
name: 'Test Workflow',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
},
|
||||
});
|
||||
|
||||
const mockGlobalCredential = mock<CredentialsEntity>({
|
||||
id: 'global-cred-123',
|
||||
name: 'Global Credential',
|
||||
type: 'httpBasicAuth',
|
||||
isGlobal: true,
|
||||
});
|
||||
|
||||
const mockPersonalCredential = mock<CredentialsEntity>({
|
||||
id: 'personal-cred-456',
|
||||
name: 'Personal Credential',
|
||||
type: 'httpBasicAuth',
|
||||
isGlobal: false,
|
||||
});
|
||||
|
||||
const credentialsService = mock<CredentialsService>();
|
||||
const enterpriseWorkflowService = mock<EnterpriseWorkflowService>();
|
||||
const license = mock<License>();
|
||||
|
||||
credentialsService.getMany.mockResolvedValue([
|
||||
mockGlobalCredential,
|
||||
mockPersonalCredential,
|
||||
]);
|
||||
license.isSharingEnabled.mockReturnValue(true);
|
||||
|
||||
// Stop execution after credential validation
|
||||
enterpriseWorkflowService.validateCredentialPermissionsToUser.mockImplementation(() => {
|
||||
throw new BadRequestError('Stopping execution for test');
|
||||
});
|
||||
|
||||
controller.credentialsService = credentialsService;
|
||||
controller.enterpriseWorkflowService = enterpriseWorkflowService;
|
||||
controller.license = license;
|
||||
controller.externalHooks = mock();
|
||||
controller.externalHooks.run = jest.fn().mockResolvedValue(undefined);
|
||||
controller.tagRepository = mock();
|
||||
controller.globalConfig = { tags: { disabled: true } };
|
||||
|
||||
/**
|
||||
* Act & Assert
|
||||
*/
|
||||
await expect(controller.create(mockRequest)).rejects.toThrow(BadRequestError);
|
||||
|
||||
/**
|
||||
* Assert - Verify credentials were fetched with includeGlobal: true
|
||||
*/
|
||||
expect(credentialsService.getMany).toHaveBeenCalledWith(mockUser, {
|
||||
includeGlobal: true,
|
||||
});
|
||||
expect(enterpriseWorkflowService.validateCredentialPermissionsToUser).toHaveBeenCalledWith(
|
||||
expect.any(WorkflowEntity),
|
||||
[mockGlobalCredential, mockPersonalCredential],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw BadRequestError when user lacks access to credentials in workflow', async () => {
|
||||
/**
|
||||
* Arrange
|
||||
*/
|
||||
const mockUser = mock<User>({ id: 'user-123' });
|
||||
const mockRequest = mock<WorkflowRequest.Create>({
|
||||
user: mockUser,
|
||||
body: {
|
||||
name: 'Test Workflow',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
},
|
||||
});
|
||||
|
||||
const mockGlobalCredential = mock<CredentialsEntity>({
|
||||
id: 'global-cred-123',
|
||||
name: 'Global Credential',
|
||||
type: 'httpBasicAuth',
|
||||
isGlobal: true,
|
||||
});
|
||||
|
||||
const credentialsService = mock<CredentialsService>();
|
||||
const enterpriseWorkflowService = mock<EnterpriseWorkflowService>();
|
||||
const license = mock<License>();
|
||||
|
||||
credentialsService.getMany.mockResolvedValue([mockGlobalCredential]);
|
||||
license.isSharingEnabled.mockReturnValue(true);
|
||||
enterpriseWorkflowService.validateCredentialPermissionsToUser.mockImplementation(() => {
|
||||
throw new Error('User does not have access');
|
||||
});
|
||||
|
||||
controller.credentialsService = credentialsService;
|
||||
controller.enterpriseWorkflowService = enterpriseWorkflowService;
|
||||
controller.license = license;
|
||||
controller.externalHooks = mock();
|
||||
controller.externalHooks.run = jest.fn().mockResolvedValue(undefined);
|
||||
controller.tagRepository = mock();
|
||||
controller.globalConfig = { tags: { disabled: true } };
|
||||
|
||||
/**
|
||||
* Act & Assert
|
||||
*/
|
||||
await expect(controller.create(mockRequest)).rejects.toThrow(BadRequestError);
|
||||
await expect(controller.create(mockRequest)).rejects.toThrow(
|
||||
'The workflow you are trying to save contains credentials that are not shared with you',
|
||||
);
|
||||
|
||||
expect(credentialsService.getMany).toHaveBeenCalledWith(mockUser, {
|
||||
includeGlobal: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -120,7 +120,9 @@ export class WorkflowsController {
|
||||
// This is a new workflow, so we simply check if the user has access to
|
||||
// all used credentials
|
||||
|
||||
const allCredentials = await this.credentialsService.getMany(req.user);
|
||||
const allCredentials = await this.credentialsService.getMany(req.user, {
|
||||
includeGlobal: true,
|
||||
});
|
||||
|
||||
try {
|
||||
this.enterpriseWorkflowService.validateCredentialPermissionsToUser(
|
||||
|
||||
@ -74,6 +74,7 @@ describe('POST /ai/free-credits', () => {
|
||||
'credential:move',
|
||||
'credential:read',
|
||||
'credential:share',
|
||||
'credential:shareGlobally',
|
||||
'credential:update',
|
||||
].sort(),
|
||||
);
|
||||
|
||||
@ -216,6 +216,7 @@ describe('GET /credentials', () => {
|
||||
'credential:move',
|
||||
'credential:read',
|
||||
'credential:share',
|
||||
'credential:shareGlobally',
|
||||
'credential:update',
|
||||
].sort(),
|
||||
);
|
||||
@ -230,6 +231,7 @@ describe('GET /credentials', () => {
|
||||
'credential:move',
|
||||
'credential:read',
|
||||
'credential:share',
|
||||
'credential:shareGlobally',
|
||||
'credential:update',
|
||||
].sort(),
|
||||
);
|
||||
@ -356,6 +358,7 @@ describe('GET /credentials', () => {
|
||||
'credential:read',
|
||||
'credential:update',
|
||||
'credential:share',
|
||||
'credential:shareGlobally',
|
||||
'credential:delete',
|
||||
'credential:create',
|
||||
'credential:list',
|
||||
@ -370,6 +373,7 @@ describe('GET /credentials', () => {
|
||||
'credential:read',
|
||||
'credential:update',
|
||||
'credential:share',
|
||||
'credential:shareGlobally',
|
||||
'credential:delete',
|
||||
'credential:create',
|
||||
'credential:list',
|
||||
@ -387,6 +391,7 @@ describe('GET /credentials', () => {
|
||||
'credential:read',
|
||||
'credential:update',
|
||||
'credential:share',
|
||||
'credential:shareGlobally',
|
||||
'credential:delete',
|
||||
'credential:create',
|
||||
'credential:list',
|
||||
@ -924,6 +929,57 @@ describe('POST /credentials', () => {
|
||||
message: "You don't have the permissions to save the credential in this project.",
|
||||
});
|
||||
});
|
||||
|
||||
test('should fail when member tries to create credential with isGlobal=true', async () => {
|
||||
const response = await authMemberAgent
|
||||
.post('/credentials')
|
||||
.send({ ...randomCredentialPayload(), isGlobal: true });
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.body.message).toBe(
|
||||
'You do not have permission to create globally shared credentials',
|
||||
);
|
||||
});
|
||||
|
||||
test('should allow owner to create credential with isGlobal=true', async () => {
|
||||
const response = await authOwnerAgent
|
||||
.post('/credentials')
|
||||
.send({ ...randomCredentialPayload(), isGlobal: true });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const credential = await Container.get(CredentialsRepository).findOneByOrFail({
|
||||
id: response.body.data.id,
|
||||
});
|
||||
expect(credential.isGlobal).toBe(true);
|
||||
});
|
||||
|
||||
test('should allow member to create credential with isGlobal=false', async () => {
|
||||
const response = await authMemberAgent
|
||||
.post('/credentials')
|
||||
.send({ ...randomCredentialPayload(), isGlobal: false });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const credential = await Container.get(CredentialsRepository).findOneByOrFail({
|
||||
id: response.body.data.id,
|
||||
});
|
||||
expect(credential.isGlobal).toBe(false);
|
||||
});
|
||||
|
||||
test('should allow member to create credential without passing isGlobal', async () => {
|
||||
const payload = randomCredentialPayload();
|
||||
delete payload.isGlobal;
|
||||
|
||||
const response = await authMemberAgent.post('/credentials').send(payload);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const credential = await Container.get(CredentialsRepository).findOneByOrFail({
|
||||
id: response.body.data.id,
|
||||
});
|
||||
expect(credential.isGlobal).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /credentials/:id', () => {
|
||||
@ -1071,6 +1127,7 @@ describe('PATCH /credentials/:id', () => {
|
||||
'credential:move',
|
||||
'credential:read',
|
||||
'credential:share',
|
||||
'credential:shareGlobally',
|
||||
'credential:update',
|
||||
].sort(),
|
||||
);
|
||||
@ -1296,6 +1353,69 @@ describe('PATCH /credentials/:id', () => {
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
test('should fail when member tries to change isGlobal value', async () => {
|
||||
const savedCredential = await saveCredential(randomCredentialPayload(), {
|
||||
user: member,
|
||||
role: 'credential:owner',
|
||||
});
|
||||
|
||||
const response = await authMemberAgent
|
||||
.patch(`/credentials/${savedCredential.id}`)
|
||||
.send({ ...randomCredentialPayload(), isGlobal: true });
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.body.message).toBe(
|
||||
'You do not have permission to change global sharing for credentials',
|
||||
);
|
||||
});
|
||||
|
||||
test('should allow owner to set isGlobal to true', async () => {
|
||||
const savedCredential = await saveCredential(randomCredentialPayload(), {
|
||||
user: owner,
|
||||
role: 'credential:owner',
|
||||
});
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.patch(`/credentials/${savedCredential.id}`)
|
||||
.send({ ...randomCredentialPayload(), isGlobal: true });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const credential = await Container.get(CredentialsRepository).findOneByOrFail({
|
||||
id: savedCredential.id,
|
||||
});
|
||||
expect(credential.isGlobal).toBe(true);
|
||||
});
|
||||
|
||||
test('should allow member to update credential with same isGlobal value', async () => {
|
||||
const savedCredential = await saveCredential(randomCredentialPayload({ isGlobal: false }), {
|
||||
user: member,
|
||||
role: 'credential:owner',
|
||||
});
|
||||
|
||||
const response = await authMemberAgent
|
||||
.patch(`/credentials/${savedCredential.id}`)
|
||||
.send({ ...randomCredentialPayload(), isGlobal: false });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
test('should allow member to update credential without passing isGlobal', async () => {
|
||||
const savedCredential = await saveCredential(randomCredentialPayload({ isGlobal: false }), {
|
||||
user: member,
|
||||
role: 'credential:owner',
|
||||
});
|
||||
|
||||
const payload = randomCredentialPayload();
|
||||
delete payload.isGlobal;
|
||||
|
||||
const response = await authMemberAgent
|
||||
.patch(`/credentials/${savedCredential.id}`)
|
||||
.send(payload);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /credentials/new', () => {
|
||||
|
||||
@ -421,5 +421,92 @@ describe('SourceControlExportService Integration', () => {
|
||||
expect((exportedCredential.data.connection as any).port).toBe(5432);
|
||||
expect((exportedCredential.data.settings as any).ssl).toBe(true);
|
||||
});
|
||||
|
||||
it('should export global credentials with isGlobal flag set to true', async () => {
|
||||
// Arrange
|
||||
const credentialData = {
|
||||
apiKey: 'global-api-key',
|
||||
apiSecret: 'global-secret',
|
||||
};
|
||||
|
||||
const credential = await createCredentials(
|
||||
{
|
||||
name: 'Global Test Credential',
|
||||
type: 'globalCredentialType',
|
||||
data: Container.get(Cipher).encrypt(credentialData),
|
||||
isGlobal: true,
|
||||
},
|
||||
personalProject,
|
||||
);
|
||||
|
||||
const candidates = [{ id: credential.id }] as SourceControlledFile[];
|
||||
|
||||
// Act
|
||||
const result = await exportService.exportCredentialsToWorkFolder(candidates);
|
||||
|
||||
// Assert
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.files).toHaveLength(1);
|
||||
|
||||
// Verify file write was called
|
||||
expect(mockFsWriteFile).toHaveBeenCalledTimes(1);
|
||||
|
||||
const exportedCredential = getWrittenCredentialData(credential.id);
|
||||
|
||||
// Verify isGlobal flag is exported
|
||||
expect(exportedCredential).toMatchObject({
|
||||
id: credential.id,
|
||||
name: 'Global Test Credential',
|
||||
type: 'globalCredentialType',
|
||||
isGlobal: true,
|
||||
data: {
|
||||
apiKey: '',
|
||||
apiSecret: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Verify isGlobal is explicitly true
|
||||
expect(exportedCredential.isGlobal).toBe(true);
|
||||
});
|
||||
|
||||
it('should export non-global credentials with isGlobal flag set to false', async () => {
|
||||
// Arrange
|
||||
const credentialData = {
|
||||
username: 'test-user',
|
||||
password: 'test-password',
|
||||
};
|
||||
|
||||
const credential = await createCredentials(
|
||||
{
|
||||
name: 'Non-Global Credential',
|
||||
type: 'standardCredentialType',
|
||||
data: Container.get(Cipher).encrypt(credentialData),
|
||||
isGlobal: false,
|
||||
},
|
||||
teamProject,
|
||||
);
|
||||
|
||||
const candidates = [{ id: credential.id }] as SourceControlledFile[];
|
||||
|
||||
// Act
|
||||
const result = await exportService.exportCredentialsToWorkFolder(candidates);
|
||||
|
||||
// Assert
|
||||
expect(result.count).toBe(1);
|
||||
expect(mockFsWriteFile).toHaveBeenCalledTimes(1);
|
||||
|
||||
const exportedCredential = getWrittenCredentialData(credential.id);
|
||||
|
||||
// Verify isGlobal flag is false
|
||||
expect(exportedCredential).toMatchObject({
|
||||
id: credential.id,
|
||||
name: 'Non-Global Credential',
|
||||
type: 'standardCredentialType',
|
||||
isGlobal: false,
|
||||
});
|
||||
|
||||
// Verify isGlobal is explicitly false
|
||||
expect(exportedCredential.isGlobal).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1390,5 +1390,75 @@ describe('SourceControlImportService', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should import global credentials with isGlobal flag set to true', async () => {
|
||||
const importingUser = await getGlobalOwner();
|
||||
|
||||
fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content'));
|
||||
|
||||
const CREDENTIAL_ID = nanoid();
|
||||
|
||||
const stub: ExportableCredential = {
|
||||
id: CREDENTIAL_ID,
|
||||
name: 'Global Test Credential',
|
||||
type: 'globalCredentialType',
|
||||
data: {},
|
||||
ownedBy: null,
|
||||
isGlobal: true,
|
||||
};
|
||||
|
||||
jest.spyOn(utils, 'jsonParse').mockReturnValue(stub);
|
||||
|
||||
cipher.encrypt.mockReturnValue('some-encrypted-data');
|
||||
|
||||
await service.importCredentialsFromWorkFolder(
|
||||
[mock<SourceControlledFile>({ id: CREDENTIAL_ID })],
|
||||
importingUser.id,
|
||||
);
|
||||
|
||||
const importedCredential = await credentialsRepository.findOneBy({
|
||||
id: CREDENTIAL_ID,
|
||||
});
|
||||
|
||||
expect(importedCredential).toBeTruthy();
|
||||
expect(importedCredential?.isGlobal).toBe(true);
|
||||
expect(importedCredential?.name).toBe('Global Test Credential');
|
||||
expect(importedCredential?.type).toBe('globalCredentialType');
|
||||
});
|
||||
|
||||
it('should import non-global credentials with isGlobal flag set to false', async () => {
|
||||
const importingUser = await getGlobalOwner();
|
||||
|
||||
fsp.readFile = jest.fn().mockResolvedValue(Buffer.from('some-content'));
|
||||
|
||||
const CREDENTIAL_ID = nanoid();
|
||||
|
||||
const stub: ExportableCredential = {
|
||||
id: CREDENTIAL_ID,
|
||||
name: 'Standard Credential',
|
||||
type: 'standardCredentialType',
|
||||
data: {},
|
||||
ownedBy: null,
|
||||
isGlobal: false,
|
||||
};
|
||||
|
||||
jest.spyOn(utils, 'jsonParse').mockReturnValue(stub);
|
||||
|
||||
cipher.encrypt.mockReturnValue('some-encrypted-data');
|
||||
|
||||
await service.importCredentialsFromWorkFolder(
|
||||
[mock<SourceControlledFile>({ id: CREDENTIAL_ID })],
|
||||
importingUser.id,
|
||||
);
|
||||
|
||||
const importedCredential = await credentialsRepository.findOneBy({
|
||||
id: CREDENTIAL_ID,
|
||||
});
|
||||
|
||||
expect(importedCredential).toBeTruthy();
|
||||
expect(importedCredential?.isGlobal).toBe(false);
|
||||
expect(importedCredential?.name).toBe('Standard Credential');
|
||||
expect(importedCredential?.type).toBe('standardCredentialType');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -89,6 +89,7 @@ function toExportableCredential(
|
||||
name: cred.name,
|
||||
type: cred.type,
|
||||
ownedBy: resourceOwner,
|
||||
isGlobal: cred.isGlobal ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1161,4 +1162,198 @@ describe('SourceControlService', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isGlobal flag modification detection', () => {
|
||||
let testGlobalOwner: User;
|
||||
let testProject: Project;
|
||||
|
||||
beforeAll(async () => {
|
||||
testGlobalOwner = await createUser({ role: GLOBAL_OWNER_ROLE });
|
||||
testProject = await createTeamProject('TestProjectForGlobal', testGlobalOwner);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globMock.mockClear();
|
||||
fsReadFile.mockClear();
|
||||
});
|
||||
|
||||
const setupMocksForCredential = (
|
||||
credential: CredentialsEntity,
|
||||
remoteCredential: ExportableCredential,
|
||||
) => {
|
||||
const testGitFiles = {
|
||||
[`${SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER}/${credential.id}.json`]: remoteCredential,
|
||||
[SOURCE_CONTROL_TAGS_EXPORT_FILE]: { tags: [], mappings: [] },
|
||||
[SOURCE_CONTROL_FOLDERS_EXPORT_FILE]: { folders: [] },
|
||||
};
|
||||
|
||||
globMock.mockImplementation(async (path, opts) => {
|
||||
if (opts.cwd?.endsWith(SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER)) {
|
||||
return [];
|
||||
} else if (opts.cwd?.endsWith(SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER)) {
|
||||
return Object.keys(testGitFiles).filter((file) =>
|
||||
file.startsWith(SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER),
|
||||
);
|
||||
} else if (path === SOURCE_CONTROL_FOLDERS_EXPORT_FILE) {
|
||||
return [SOURCE_CONTROL_FOLDERS_EXPORT_FILE];
|
||||
} else if (path === SOURCE_CONTROL_TAGS_EXPORT_FILE) {
|
||||
return [SOURCE_CONTROL_TAGS_EXPORT_FILE];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
fsReadFile.mockImplementation(async (file) => {
|
||||
const fileName = basename(file as string);
|
||||
const fullPath = Object.keys(testGitFiles).find((key) => key.endsWith(fileName));
|
||||
if (fullPath) {
|
||||
return Buffer.from(JSON.stringify(testGitFiles[fullPath]));
|
||||
}
|
||||
return Buffer.from('{}');
|
||||
});
|
||||
};
|
||||
|
||||
it('should detect credential as modified when isGlobal changes from false to true', async () => {
|
||||
// Create a test credential with isGlobal: false
|
||||
const credential = await createCredentials(
|
||||
{
|
||||
name: 'Test Credential isGlobal false->true',
|
||||
type: 'testType',
|
||||
data: cipher.encrypt({}),
|
||||
isGlobal: false,
|
||||
},
|
||||
testProject,
|
||||
);
|
||||
|
||||
// Setup: Mock remote credential with isGlobal: true
|
||||
const remoteCredential = toExportableCredential(credential, testProject);
|
||||
remoteCredential.isGlobal = true;
|
||||
setupMocksForCredential(credential, remoteCredential);
|
||||
|
||||
// Act
|
||||
const result = (await service.getStatus(testGlobalOwner, {
|
||||
direction: 'push',
|
||||
preferLocalVersion: true,
|
||||
verbose: false,
|
||||
})) as SourceControlledFile[];
|
||||
|
||||
// Assert
|
||||
const modifiedCredentials = result.filter(
|
||||
(r: SourceControlledFile) => r.type === 'credential' && r.status === 'modified',
|
||||
);
|
||||
|
||||
expect(modifiedCredentials.some((c) => c.id === credential.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect credential as modified when isGlobal changes from true to false', async () => {
|
||||
const credential = await createCredentials(
|
||||
{
|
||||
name: 'Test Credential isGlobal true->false',
|
||||
type: 'testType',
|
||||
data: cipher.encrypt({}),
|
||||
isGlobal: true,
|
||||
},
|
||||
testProject,
|
||||
);
|
||||
|
||||
const remoteCredential = toExportableCredential(credential, testProject);
|
||||
remoteCredential.isGlobal = false;
|
||||
setupMocksForCredential(credential, remoteCredential);
|
||||
|
||||
const result = (await service.getStatus(testGlobalOwner, {
|
||||
direction: 'push',
|
||||
preferLocalVersion: true,
|
||||
verbose: false,
|
||||
})) as SourceControlledFile[];
|
||||
|
||||
const modifiedCredentials = result.filter(
|
||||
(r: SourceControlledFile) => r.type === 'credential' && r.status === 'modified',
|
||||
);
|
||||
|
||||
expect(modifiedCredentials.some((c) => c.id === credential.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT detect credential as modified when isGlobal is undefined vs false', async () => {
|
||||
const credential = await createCredentials(
|
||||
{
|
||||
name: 'Test Credential isGlobal undefined vs false',
|
||||
type: 'testType',
|
||||
data: cipher.encrypt({}),
|
||||
isGlobal: false,
|
||||
},
|
||||
testProject,
|
||||
);
|
||||
|
||||
const remoteCredential = toExportableCredential(credential, testProject);
|
||||
delete remoteCredential.isGlobal;
|
||||
setupMocksForCredential(credential, remoteCredential);
|
||||
|
||||
const result = (await service.getStatus(testGlobalOwner, {
|
||||
direction: 'push',
|
||||
preferLocalVersion: true,
|
||||
verbose: false,
|
||||
})) as SourceControlledFile[];
|
||||
|
||||
const modifiedCredentials = result.filter(
|
||||
(r: SourceControlledFile) => r.type === 'credential' && r.status === 'modified',
|
||||
);
|
||||
|
||||
expect(modifiedCredentials.some((c) => c.id === credential.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect credential as modified when isGlobal changes from undefined to true', async () => {
|
||||
const credential = await createCredentials(
|
||||
{
|
||||
name: 'Test Credential isGlobal undefined->true',
|
||||
type: 'testType',
|
||||
data: cipher.encrypt({}),
|
||||
isGlobal: false,
|
||||
},
|
||||
testProject,
|
||||
);
|
||||
|
||||
const remoteCredential = toExportableCredential(credential, testProject);
|
||||
remoteCredential.isGlobal = true;
|
||||
setupMocksForCredential(credential, remoteCredential);
|
||||
|
||||
const result = (await service.getStatus(testGlobalOwner, {
|
||||
direction: 'push',
|
||||
preferLocalVersion: true,
|
||||
verbose: false,
|
||||
})) as SourceControlledFile[];
|
||||
|
||||
const modifiedCredentials = result.filter(
|
||||
(r: SourceControlledFile) => r.type === 'credential' && r.status === 'modified',
|
||||
);
|
||||
|
||||
expect(modifiedCredentials.some((c) => c.id === credential.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT detect credential as modified when isGlobal is the same', async () => {
|
||||
const credential = await createCredentials(
|
||||
{
|
||||
name: 'Test Credential isGlobal same value',
|
||||
type: 'testType',
|
||||
data: cipher.encrypt({}),
|
||||
isGlobal: true,
|
||||
},
|
||||
testProject,
|
||||
);
|
||||
|
||||
const remoteCredential = toExportableCredential(credential, testProject);
|
||||
remoteCredential.isGlobal = true;
|
||||
setupMocksForCredential(credential, remoteCredential);
|
||||
|
||||
const result = (await service.getStatus(testGlobalOwner, {
|
||||
direction: 'push',
|
||||
preferLocalVersion: true,
|
||||
verbose: false,
|
||||
})) as SourceControlledFile[];
|
||||
|
||||
const modifiedCredentials = result.filter(
|
||||
(r: SourceControlledFile) => r.type === 'credential' && r.status === 'modified',
|
||||
);
|
||||
|
||||
expect(modifiedCredentials.some((c) => c.id === credential.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1397,5 +1397,285 @@ describe('RoleService', () => {
|
||||
roleService.addScopes(mockEntity, user, userProjectRelations);
|
||||
}).toThrow('Cannot detect if entity is a workflow or credential.');
|
||||
});
|
||||
|
||||
it('should add credential:read scope to global credentials', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const user = await createMember();
|
||||
const mockGlobalCredential = {
|
||||
id: 'global-cred-1',
|
||||
name: 'Global Test Credential',
|
||||
type: 'testCredential',
|
||||
isGlobal: true,
|
||||
shared: [
|
||||
{
|
||||
projectId: 'project-1',
|
||||
role: 'credential:owner',
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
const userProjectRelations = [] as any[];
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const result = roleService.addScopes(mockGlobalCredential, user, userProjectRelations);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(result).toHaveProperty('scopes');
|
||||
expect(result.scopes).toContain('credential:read');
|
||||
});
|
||||
|
||||
it('should add credential:read scope to global credentials even when not in initial scopes', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const user = await createMember();
|
||||
const mockGlobalCredential = {
|
||||
id: 'global-cred-2',
|
||||
name: 'Global Test Credential 2',
|
||||
type: 'testCredential',
|
||||
isGlobal: true,
|
||||
shared: [],
|
||||
} as any;
|
||||
const userProjectRelations = [] as any[];
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const result = roleService.addScopes(mockGlobalCredential, user, userProjectRelations);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(result).toHaveProperty('scopes');
|
||||
expect(result.scopes).toContain('credential:read');
|
||||
});
|
||||
|
||||
it('should not duplicate credential:read scope if already present for global credentials', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const user = await createMember();
|
||||
const mockGlobalCredential = {
|
||||
id: 'global-cred-3',
|
||||
name: 'Global Test Credential 3',
|
||||
type: 'testCredential',
|
||||
isGlobal: true,
|
||||
shared: [
|
||||
{
|
||||
projectId: 'project-1',
|
||||
role: 'credential:owner',
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
const userProjectRelations = [] as any[];
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const result = roleService.addScopes(mockGlobalCredential, user, userProjectRelations);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
const readScopeCount = result.scopes.filter((s: string) => s === 'credential:read').length;
|
||||
expect(readScopeCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should not add credential:read scope to non-global credentials', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const user = await createMember();
|
||||
const mockNonGlobalCredential = {
|
||||
id: 'non-global-cred-1',
|
||||
name: 'Non-Global Test Credential',
|
||||
type: 'testCredential',
|
||||
isGlobal: false,
|
||||
shared: [
|
||||
{
|
||||
projectId: 'project-1',
|
||||
role: 'credential:user',
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
const userProjectRelations = [] as any[];
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const result = roleService.addScopes(mockNonGlobalCredential, user, userProjectRelations);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(result).toHaveProperty('scopes');
|
||||
// Should not contain credential:read because the user only has credential:user role
|
||||
// and isGlobal is false
|
||||
expect(result.scopes).not.toContain('credential:read');
|
||||
});
|
||||
|
||||
it('should not add credential:read scope to credentials without isGlobal property', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
const user = await createMember();
|
||||
const mockCredentialWithoutIsGlobal = {
|
||||
id: 'cred-no-flag',
|
||||
name: 'Credential Without isGlobal',
|
||||
type: 'testCredential',
|
||||
// isGlobal not specified
|
||||
shared: [
|
||||
{
|
||||
projectId: 'project-1',
|
||||
role: 'credential:user',
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
const userProjectRelations = [] as any[];
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const result = roleService.addScopes(
|
||||
mockCredentialWithoutIsGlobal,
|
||||
user,
|
||||
userProjectRelations,
|
||||
);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(result).toHaveProperty('scopes');
|
||||
// Should not contain credential:read because the user only has credential:user role
|
||||
// and isGlobal is not specified
|
||||
expect(result.scopes).not.toContain('credential:read');
|
||||
});
|
||||
|
||||
it('should not add credential:read scope to workflow entities even with isGlobal property', async () => {
|
||||
//
|
||||
// ARRANGE
|
||||
//
|
||||
// Note: While workflows typically don't have isGlobal, the implementation
|
||||
// only adds credential:read scope for credential entities, not workflows
|
||||
const user = await createMember();
|
||||
const mockWorkflowWithIsGlobal = {
|
||||
id: 'workflow-1',
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
isGlobal: true,
|
||||
shared: [
|
||||
{
|
||||
projectId: 'project-1',
|
||||
role: 'workflow:editor',
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
const userProjectRelations = [] as any[];
|
||||
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const result = roleService.addScopes(mockWorkflowWithIsGlobal, user, userProjectRelations);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(result).toHaveProperty('scopes');
|
||||
// The implementation only adds credential:read for credential entities (entityType === 'credential')
|
||||
// Workflows should not get credential:read scope even if they have isGlobal: true
|
||||
expect(result.scopes).not.toContain('credential:read');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rolesWithScope', () => {
|
||||
it('should return built-in project roles with given scope', async () => {
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const roles = await roleService.rolesWithScope('project', ['project:read']);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(roles).toBeInstanceOf(Array);
|
||||
expect(roles.length).toBeGreaterThan(0);
|
||||
// Should include built-in project roles that have project:read
|
||||
expect(roles).toContain('project:admin');
|
||||
expect(roles).toContain('project:editor');
|
||||
});
|
||||
|
||||
it('should return built-in credential roles with given scope', async () => {
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const roles = await roleService.rolesWithScope('credential', ['credential:read']);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(roles).toBeInstanceOf(Array);
|
||||
expect(roles.length).toBeGreaterThan(0);
|
||||
// Should include built-in credential roles that have credential:read
|
||||
expect(roles).toContain('credential:owner');
|
||||
expect(roles).toContain('credential:user');
|
||||
});
|
||||
|
||||
it('should handle multiple scopes', async () => {
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const roles = await roleService.rolesWithScope('project', ['project:read', 'project:update']);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(roles).toBeInstanceOf(Array);
|
||||
expect(roles.length).toBeGreaterThan(0);
|
||||
// Project admin should have both scopes
|
||||
expect(roles).toContain('project:admin');
|
||||
});
|
||||
|
||||
it('should handle single scope as string', async () => {
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const roles = await roleService.rolesWithScope('workflow', 'workflow:read');
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(roles).toBeInstanceOf(Array);
|
||||
expect(roles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return empty array when no roles match the scopes', async () => {
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const roles = await roleService.rolesWithScope('project', ['nonexistent:scope' as any]);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(roles).toEqual([]);
|
||||
});
|
||||
|
||||
it('should cache results for repeated calls', async () => {
|
||||
//
|
||||
// ACT
|
||||
//
|
||||
const roles1 = await roleService.rolesWithScope('project', ['project:read']);
|
||||
const roles2 = await roleService.rolesWithScope('project', ['project:read']);
|
||||
|
||||
//
|
||||
// ASSERT
|
||||
//
|
||||
expect(roles1).toEqual(roles2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -3546,6 +3546,7 @@
|
||||
"projects.settings.member.removed.title": "Member removed successfully",
|
||||
"projects.settings.member.remove.error.title": "An error occurred while removing member",
|
||||
"projects.settings.member.added.title": "Member added successfully",
|
||||
"projects.sharing.allUsers": "All users and projects",
|
||||
"projects.sharing.noMatchingProjects": "There are no available projects",
|
||||
"projects.sharing.noMatchingUsers": "No matching users or projects",
|
||||
"projects.sharing.select.placeholder": "Select project or user",
|
||||
@ -3578,6 +3579,8 @@
|
||||
"projects.move.resource.success.message.workflow.withAllCredentials": "The workflow's credentials were shared with the project.",
|
||||
"projects.move.resource.success.message.workflow.withSomeCredentials": "Due to missing permissions not all the workflow's credentials were shared with the project.",
|
||||
"projects.move.resource.success.link": "View {targetProjectName}",
|
||||
"projects.badge.global": "Global",
|
||||
"projects.badge.tooltip.global": "This {resourceTypeLabel} was shared globally with all users and projects",
|
||||
"projects.badge.tooltip.sharedOwned": "This {resourceTypeLabel} is owned by you and shared with {count} users",
|
||||
"projects.badge.tooltip.sharedPersonal": "This {resourceTypeLabel} is owned by {name} and shared with {count} users",
|
||||
"projects.badge.tooltip.personal": "This {resourceTypeLabel} is owned by {name}",
|
||||
|
||||
@ -305,6 +305,7 @@ export type CredentialsResource = BaseResource & {
|
||||
sharedWithProjects?: ProjectSharingData[];
|
||||
readOnly: boolean;
|
||||
needsSetup: boolean;
|
||||
isGlobal?: boolean;
|
||||
};
|
||||
|
||||
// Base resource types that are always available
|
||||
|
||||
@ -91,4 +91,98 @@ describe('ProjectCardBadge', () => {
|
||||
});
|
||||
expect(getByText(truncate(result, 20))).toBeVisible();
|
||||
});
|
||||
|
||||
describe('global badge', () => {
|
||||
it('should show global badge when global prop is true', () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
resource: {
|
||||
homeProject: {
|
||||
id: '1',
|
||||
name: 'Test Project',
|
||||
},
|
||||
} as WorkflowResource,
|
||||
resourceType: ResourceType.Credential,
|
||||
resourceTypeLabel: 'credential',
|
||||
personalProject: {
|
||||
id: '1',
|
||||
} as Project,
|
||||
global: true,
|
||||
},
|
||||
});
|
||||
|
||||
const globalBadge = getByTestId('credential-global-badge');
|
||||
expect(globalBadge).toBeVisible();
|
||||
expect(globalBadge).toHaveTextContent('Global');
|
||||
});
|
||||
|
||||
it('should not show global badge when global prop is false', () => {
|
||||
const { queryByTestId } = renderComponent({
|
||||
props: {
|
||||
resource: {
|
||||
homeProject: {
|
||||
id: '1',
|
||||
name: 'Test Project',
|
||||
},
|
||||
} as WorkflowResource,
|
||||
resourceType: ResourceType.Credential,
|
||||
resourceTypeLabel: 'credential',
|
||||
personalProject: {
|
||||
id: '1',
|
||||
} as Project,
|
||||
global: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByTestId('credential-global-badge')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show global badge when global prop is undefined', () => {
|
||||
const { queryByTestId } = renderComponent({
|
||||
props: {
|
||||
resource: {
|
||||
homeProject: {
|
||||
id: '1',
|
||||
name: 'Test Project',
|
||||
},
|
||||
} as WorkflowResource,
|
||||
resourceType: ResourceType.Credential,
|
||||
resourceTypeLabel: 'credential',
|
||||
personalProject: {
|
||||
id: '1',
|
||||
} as Project,
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByTestId('credential-global-badge')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show both project badge and global badge together', () => {
|
||||
const { getByTestId, getByText } = renderComponent({
|
||||
props: {
|
||||
resource: {
|
||||
homeProject: {
|
||||
id: '1',
|
||||
name: 'Test Project',
|
||||
},
|
||||
} as WorkflowResource,
|
||||
resourceType: ResourceType.Credential,
|
||||
resourceTypeLabel: 'credential',
|
||||
personalProject: {
|
||||
id: '2',
|
||||
} as Project,
|
||||
global: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Project badge should be visible
|
||||
expect(getByTestId('card-badge')).toBeVisible();
|
||||
expect(getByText('Test Project')).toBeVisible();
|
||||
|
||||
// Global badge should also be visible
|
||||
const globalBadge = getByTestId('credential-global-badge');
|
||||
expect(globalBadge).toBeVisible();
|
||||
expect(globalBadge).toHaveTextContent('Global');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -15,6 +15,7 @@ type Props = {
|
||||
resourceTypeLabel: string;
|
||||
personalProject: Project | null;
|
||||
showBadgeBorder?: boolean;
|
||||
global?: boolean;
|
||||
};
|
||||
|
||||
const enum ProjectState {
|
||||
@ -176,6 +177,27 @@ const projectLocation = computed(() => {
|
||||
</template>
|
||||
</N8nTooltip>
|
||||
<slot />
|
||||
|
||||
<N8nTooltip v-if="global" placement="top">
|
||||
<div
|
||||
:class="$style['global-badge']"
|
||||
data-test-id="credential-global-badge"
|
||||
theme="tertiary"
|
||||
bold
|
||||
>
|
||||
<ProjectIcon :icon="{ type: 'icon', value: 'globe' }" :border-less="true" size="mini" />
|
||||
{{ i18n.baseText('projects.badge.global') }}
|
||||
</div>
|
||||
<template #content>
|
||||
{{
|
||||
i18n.baseText('projects.badge.tooltip.global', {
|
||||
interpolate: {
|
||||
resourceTypeLabel: props.resourceTypeLabel,
|
||||
},
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
</N8nTooltip>
|
||||
<N8nTooltip
|
||||
v-if="numberOfMembersInHomeTeamProject"
|
||||
:disabled="!badgeTooltip || numberOfMembersInHomeTeamProject === 0"
|
||||
@ -232,6 +254,13 @@ const projectLocation = computed(() => {
|
||||
line-height: var(--line-height--md);
|
||||
}
|
||||
|
||||
.global-badge {
|
||||
composes: count-badge;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
@ -5,6 +5,24 @@ import { getDropdownItems, getSelectedDropdownValue } from '@/__tests__/utils';
|
||||
import { createProjectListItem, createProjectSharingData } from '../__tests__/utils';
|
||||
import ProjectSharing from './ProjectSharing.vue';
|
||||
import type { AllRolesMap } from '@n8n/permissions';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type * as I18nModule from '@n8n/i18n';
|
||||
|
||||
vi.mock('@n8n/i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof I18nModule>();
|
||||
return {
|
||||
...actual,
|
||||
useI18n: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockBaseText = vi.fn((key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'projects.sharing.allUsers': 'All users and projects',
|
||||
'auth.roles.owner': 'Owner',
|
||||
};
|
||||
return translations[key] || key;
|
||||
});
|
||||
|
||||
const renderComponent = createComponentRenderer(ProjectSharing);
|
||||
|
||||
@ -13,6 +31,11 @@ const teamProjects = Array.from({ length: 3 }, () => createProjectListItem('team
|
||||
const homeProject = createProjectSharingData();
|
||||
|
||||
describe('ProjectSharing', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useI18n).mockReturnValue({
|
||||
baseText: mockBaseText,
|
||||
} as unknown as ReturnType<typeof useI18n>);
|
||||
});
|
||||
it('should render empty select when projects is empty and no selected project existing', async () => {
|
||||
const { getByTestId, queryByTestId } = renderComponent({
|
||||
props: {
|
||||
@ -163,4 +186,151 @@ describe('ProjectSharing', () => {
|
||||
expect(queryByTestId('project-sharing-list-item')).not.toBeInTheDocument();
|
||||
expect(getByTestId('project-sharing-owner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('global sharing', () => {
|
||||
it('should show "All Users" option when canShareGlobally is true', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
projects: personalProjects,
|
||||
modelValue: [],
|
||||
canShareGlobally: true,
|
||||
},
|
||||
});
|
||||
|
||||
const projectSelect = getByTestId('project-sharing-select');
|
||||
const dropdownItems = await getDropdownItems(projectSelect);
|
||||
|
||||
// "All users and projects" should be the first option
|
||||
expect(dropdownItems[0]).toHaveTextContent('All users and projects');
|
||||
// Total items should be projects + "All Users"
|
||||
expect(dropdownItems).toHaveLength(personalProjects.length + 1);
|
||||
});
|
||||
|
||||
it('should not show "All Users" option when canShareGlobally is false', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
projects: personalProjects,
|
||||
modelValue: [],
|
||||
canShareGlobally: false,
|
||||
},
|
||||
});
|
||||
|
||||
const projectSelect = getByTestId('project-sharing-select');
|
||||
const dropdownItems = await getDropdownItems(projectSelect);
|
||||
|
||||
// "All users and projects" should not be present
|
||||
expect(dropdownItems[0]).not.toHaveTextContent('All users and projects');
|
||||
expect(dropdownItems).toHaveLength(personalProjects.length);
|
||||
});
|
||||
|
||||
it('should not show "All Users" option when canShareGlobally is undefined', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
projects: personalProjects,
|
||||
modelValue: [],
|
||||
},
|
||||
});
|
||||
|
||||
const projectSelect = getByTestId('project-sharing-select');
|
||||
const dropdownItems = await getDropdownItems(projectSelect);
|
||||
|
||||
// "All users and projects" should not be present
|
||||
expect(dropdownItems[0]).not.toHaveTextContent('All users and projects');
|
||||
expect(dropdownItems).toHaveLength(personalProjects.length);
|
||||
});
|
||||
|
||||
it('should emit update:shareWithAllUsers when "All Users" is selected', async () => {
|
||||
const { getByTestId, emitted } = renderComponent({
|
||||
props: {
|
||||
projects: personalProjects,
|
||||
modelValue: [],
|
||||
canShareGlobally: true,
|
||||
},
|
||||
});
|
||||
|
||||
const projectSelect = getByTestId('project-sharing-select');
|
||||
const dropdownItems = await getDropdownItems(projectSelect);
|
||||
|
||||
// Select "All Users" (first item)
|
||||
await userEvent.click(dropdownItems[0]);
|
||||
|
||||
expect(emitted()['update:shareWithAllUsers']).toBeTruthy();
|
||||
expect(emitted()['update:shareWithAllUsers']).toEqual([[true]]);
|
||||
});
|
||||
|
||||
it('should show "All Users" in selected list when isSharedGlobally is true', () => {
|
||||
const { getAllByTestId } = renderComponent({
|
||||
props: {
|
||||
projects: personalProjects,
|
||||
modelValue: [],
|
||||
canShareGlobally: true,
|
||||
isSharedGlobally: true,
|
||||
},
|
||||
});
|
||||
|
||||
const listItems = getAllByTestId('project-sharing-list-item');
|
||||
// First item should be "All users and projects"
|
||||
expect(listItems[0]).toHaveTextContent('All users and projects');
|
||||
});
|
||||
|
||||
it('should emit update:shareWithAllUsers with false when "All Users" is removed', async () => {
|
||||
const { getAllByTestId, emitted } = renderComponent({
|
||||
props: {
|
||||
projects: personalProjects,
|
||||
modelValue: [],
|
||||
canShareGlobally: true,
|
||||
isSharedGlobally: true,
|
||||
roles: [
|
||||
{
|
||||
role: 'project:admin',
|
||||
name: 'Admin',
|
||||
},
|
||||
] as unknown as AllRolesMap['workflow' | 'credential' | 'project'],
|
||||
},
|
||||
});
|
||||
|
||||
const listItems = getAllByTestId('project-sharing-list-item');
|
||||
const removeButton = within(listItems[0]).getByTestId('project-sharing-remove');
|
||||
|
||||
await userEvent.click(removeButton);
|
||||
|
||||
expect(emitted()['update:shareWithAllUsers']).toBeTruthy();
|
||||
expect(emitted()['update:shareWithAllUsers']).toEqual([[false]]);
|
||||
});
|
||||
|
||||
it('should not show remove button for "All Users" when canShareGlobally is false', () => {
|
||||
const { getAllByTestId } = renderComponent({
|
||||
props: {
|
||||
projects: personalProjects,
|
||||
modelValue: [],
|
||||
canShareGlobally: false,
|
||||
isSharedGlobally: true,
|
||||
},
|
||||
});
|
||||
|
||||
const listItems = getAllByTestId('project-sharing-list-item');
|
||||
const removeButton = within(listItems[0]).queryByTestId('project-sharing-remove');
|
||||
|
||||
// Remove button should not exist for "All Users" when canShareGlobally is false
|
||||
expect(removeButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show "All Users" in dropdown when already globally shared', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
projects: personalProjects,
|
||||
modelValue: [],
|
||||
canShareGlobally: true,
|
||||
isSharedGlobally: true,
|
||||
},
|
||||
});
|
||||
|
||||
const projectSelect = getByTestId('project-sharing-select');
|
||||
const dropdownItems = await getDropdownItems(projectSelect);
|
||||
|
||||
// "All users and projects" should not be in dropdown when already shared globally
|
||||
expect(dropdownItems[0]).not.toHaveTextContent('All users and projects');
|
||||
expect(dropdownItems).toHaveLength(personalProjects.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -9,8 +9,19 @@ import { ProjectTypes, type ProjectListItem, type ProjectSharingData } from '../
|
||||
import ProjectSharingInfo from './ProjectSharingInfo.vue';
|
||||
|
||||
import { N8nBadge, N8nButton, N8nIcon, N8nOption, N8nSelect, N8nText } from '@n8n/design-system';
|
||||
|
||||
const locale = useI18n();
|
||||
|
||||
const GLOBAL_GROUP: ProjectListItem = {
|
||||
id: 'all_users',
|
||||
name: locale.baseText('projects.sharing.allUsers'),
|
||||
type: 'public',
|
||||
icon: { type: 'icon', value: 'globe' },
|
||||
role: 'member',
|
||||
createdAt: `${Date.now()}`,
|
||||
updatedAt: `${Date.now()}`,
|
||||
};
|
||||
|
||||
type Props = {
|
||||
projects: ProjectListItem[];
|
||||
homeProject?: ProjectSharingData;
|
||||
@ -21,19 +32,32 @@ type Props = {
|
||||
emptyOptionsText?: string;
|
||||
size?: SelectSize;
|
||||
clearable?: boolean;
|
||||
canShareGlobally?: boolean;
|
||||
isSharedGlobally?: boolean;
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const model = defineModel<(ProjectSharingData | null) | ProjectSharingData[]>({
|
||||
required: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
projectAdded: [value: ProjectSharingData];
|
||||
projectRemoved: [value: ProjectSharingData];
|
||||
clear: [];
|
||||
'update:shareWithAllUsers': [value: boolean];
|
||||
}>();
|
||||
|
||||
const selectedProject = ref(Array.isArray(model.value) ? '' : (model.value?.id ?? ''));
|
||||
|
||||
const selectedProjects = computed((): ProjectSharingData[] | null => {
|
||||
if (!Array.isArray(model.value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return props.isSharedGlobally ? [GLOBAL_GROUP, ...model.value] : model.value;
|
||||
});
|
||||
|
||||
const filter = ref('');
|
||||
const selectPlaceholder = computed(
|
||||
() => props.placeholder ?? locale.baseText('projects.sharing.select.placeholder'),
|
||||
@ -50,13 +74,14 @@ const filteredProjects = computed(() =>
|
||||
),
|
||||
);
|
||||
|
||||
const sortedProjects = computed(() =>
|
||||
orderBy(
|
||||
const sortedProjects = computed((): ProjectListItem[] => [
|
||||
...(props.canShareGlobally && !props.isSharedGlobally ? [GLOBAL_GROUP] : []),
|
||||
...orderBy(
|
||||
filteredProjects.value,
|
||||
['type', (project) => project.name?.toLowerCase()],
|
||||
['desc', 'asc'],
|
||||
),
|
||||
);
|
||||
]);
|
||||
|
||||
const projectIcon = computed<IconOrEmoji>(() => {
|
||||
const defaultIcon: IconOrEmoji = { type: 'icon', value: 'layers' };
|
||||
@ -76,6 +101,11 @@ const setFilter = (query: string) => {
|
||||
};
|
||||
|
||||
const onProjectSelected = (projectId: string) => {
|
||||
if (projectId === GLOBAL_GROUP.id) {
|
||||
emit('update:shareWithAllUsers', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const project = props.projects.find((p) => p.id === projectId);
|
||||
|
||||
if (!project) {
|
||||
@ -95,6 +125,12 @@ const onRoleAction = (project: ProjectSharingData, role: string) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (project.id === GLOBAL_GROUP.id && role === 'remove') {
|
||||
emit('update:shareWithAllUsers', false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const index = model.value?.findIndex((p) => p.id === project.id) ?? -1;
|
||||
if (index === -1) {
|
||||
return;
|
||||
@ -151,7 +187,7 @@ watch(
|
||||
<ProjectSharingInfo :project="project" />
|
||||
</N8nOption>
|
||||
</N8nSelect>
|
||||
<ul v-if="Array.isArray(model)" :class="$style.selectedProjects">
|
||||
<ul v-if="selectedProjects" :class="$style.selectedProjects">
|
||||
<li v-if="props.homeProject" :class="$style.project" data-test-id="project-sharing-owner">
|
||||
<ProjectSharingInfo :project="props.homeProject">
|
||||
<N8nBadge theme="tertiary" bold>
|
||||
@ -160,14 +196,18 @@ watch(
|
||||
>
|
||||
</li>
|
||||
<li
|
||||
v-for="project in model"
|
||||
v-for="project in selectedProjects"
|
||||
:key="project.id"
|
||||
:class="$style.project"
|
||||
data-test-id="project-sharing-list-item"
|
||||
>
|
||||
<ProjectSharingInfo :project="project" />
|
||||
<N8nSelect
|
||||
v-if="props.roles?.length && !props.static"
|
||||
v-if="
|
||||
props.roles?.length &&
|
||||
!props.static &&
|
||||
!(project.id === GLOBAL_GROUP.id && !canShareGlobally)
|
||||
"
|
||||
:class="$style.projectRoleSelect"
|
||||
:model-value="props.roles[0]"
|
||||
:disabled="props.readonly"
|
||||
@ -182,7 +222,7 @@ watch(
|
||||
/>
|
||||
</N8nSelect>
|
||||
<N8nButton
|
||||
v-if="!props.static"
|
||||
v-if="!props.static && !(project.id === GLOBAL_GROUP.id && !canShareGlobally)"
|
||||
type="tertiary"
|
||||
native-type="button"
|
||||
square
|
||||
|
||||
@ -0,0 +1,331 @@
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import type { ICredentialsResponse } from '../credentials.types';
|
||||
import * as credentialsApi from '../credentials.api';
|
||||
|
||||
const mockRootStore = {
|
||||
restApiContext: { baseUrl: 'http://localhost:5678', sessionId: 'test-session' },
|
||||
baseUrl: 'http://localhost:5678',
|
||||
};
|
||||
|
||||
const { useRootStore } = vi.hoisted(() => ({
|
||||
useRootStore: vi.fn(() => mockRootStore),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/stores/useRootStore', () => ({
|
||||
useRootStore,
|
||||
}));
|
||||
|
||||
vi.mock('@/app/stores/nodeTypes.store', () => ({
|
||||
useNodeTypesStore: vi.fn(() => ({
|
||||
getNodeType: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/stores/settings.store', () => ({
|
||||
useSettingsStore: vi.fn(() => ({
|
||||
isEnterpriseFeatureEnabled: {
|
||||
sharing: true,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../credentials.api');
|
||||
vi.mock('../credentials.ee.api');
|
||||
|
||||
describe('credentials.store', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
describe('fetchAllCredentials', () => {
|
||||
it('should pass includeGlobal parameter to API when provided', async () => {
|
||||
const { useCredentialsStore } = await import('../credentials.store');
|
||||
const store = useCredentialsStore();
|
||||
|
||||
const mockCredentials: ICredentialsResponse[] = [
|
||||
mock<ICredentialsResponse>({
|
||||
id: 'cred-1',
|
||||
name: 'Personal Credential',
|
||||
type: 'httpBasicAuth',
|
||||
isGlobal: false,
|
||||
}),
|
||||
mock<ICredentialsResponse>({
|
||||
id: 'cred-2',
|
||||
name: 'Global Credential',
|
||||
type: 'httpBasicAuth',
|
||||
isGlobal: true,
|
||||
}),
|
||||
];
|
||||
|
||||
vi.spyOn(credentialsApi, 'getAllCredentials').mockResolvedValue(mockCredentials);
|
||||
|
||||
await store.fetchAllCredentials(undefined, true, false, true);
|
||||
|
||||
expect(credentialsApi.getAllCredentials).toHaveBeenCalledWith(
|
||||
mockRootStore.restApiContext,
|
||||
undefined,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass includeGlobal as true when not provided', async () => {
|
||||
const { useCredentialsStore } = await import('../credentials.store');
|
||||
const store = useCredentialsStore();
|
||||
|
||||
const mockCredentials: ICredentialsResponse[] = [
|
||||
mock<ICredentialsResponse>({
|
||||
id: 'cred-1',
|
||||
name: 'Personal Credential',
|
||||
type: 'httpBasicAuth',
|
||||
isGlobal: false,
|
||||
}),
|
||||
];
|
||||
|
||||
vi.spyOn(credentialsApi, 'getAllCredentials').mockResolvedValue(mockCredentials);
|
||||
|
||||
await store.fetchAllCredentials();
|
||||
|
||||
expect(credentialsApi.getAllCredentials).toHaveBeenCalledWith(
|
||||
mockRootStore.restApiContext,
|
||||
undefined,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should set credentials in store including global credentials', async () => {
|
||||
const { useCredentialsStore } = await import('../credentials.store');
|
||||
const store = useCredentialsStore();
|
||||
|
||||
const mockCredentials: ICredentialsResponse[] = [
|
||||
mock<ICredentialsResponse>({
|
||||
id: 'cred-1',
|
||||
name: 'Personal Credential',
|
||||
type: 'httpBasicAuth',
|
||||
isGlobal: false,
|
||||
}),
|
||||
mock<ICredentialsResponse>({
|
||||
id: 'cred-2',
|
||||
name: 'Global Credential',
|
||||
type: 'httpBasicAuth',
|
||||
isGlobal: true,
|
||||
}),
|
||||
];
|
||||
|
||||
vi.spyOn(credentialsApi, 'getAllCredentials').mockResolvedValue(mockCredentials);
|
||||
|
||||
await store.fetchAllCredentials(undefined, true, false, true);
|
||||
|
||||
expect(store.allCredentials).toHaveLength(2);
|
||||
expect(store.allCredentials.find((c) => c.id === 'cred-2')?.isGlobal).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNewCredential', () => {
|
||||
it('should pass isGlobal parameter to API when creating credential', async () => {
|
||||
const { useCredentialsStore } = await import('../credentials.store');
|
||||
const store = useCredentialsStore();
|
||||
|
||||
const mockCredential = mock<ICredentialsResponse>({
|
||||
id: 'new-cred-1',
|
||||
name: 'New Global Credential',
|
||||
type: 'httpBasicAuth',
|
||||
isGlobal: true,
|
||||
});
|
||||
|
||||
vi.spyOn(credentialsApi, 'createNewCredential').mockResolvedValue(mockCredential);
|
||||
|
||||
await store.createNewCredential(
|
||||
{
|
||||
id: 'new-cred-1',
|
||||
name: 'New Global Credential',
|
||||
type: 'httpBasicAuth',
|
||||
data: {},
|
||||
isGlobal: true,
|
||||
},
|
||||
'project-123',
|
||||
);
|
||||
|
||||
expect(credentialsApi.createNewCredential).toHaveBeenCalledWith(
|
||||
mockRootStore.restApiContext,
|
||||
{
|
||||
name: 'New Global Credential',
|
||||
type: 'httpBasicAuth',
|
||||
data: {},
|
||||
projectId: 'project-123',
|
||||
uiContext: undefined,
|
||||
isGlobal: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should create non-global credential when isGlobal is false', async () => {
|
||||
const { useCredentialsStore } = await import('../credentials.store');
|
||||
const store = useCredentialsStore();
|
||||
|
||||
const mockCredential = mock<ICredentialsResponse>({
|
||||
id: 'new-cred-2',
|
||||
name: 'New Personal Credential',
|
||||
type: 'httpBasicAuth',
|
||||
isGlobal: false,
|
||||
});
|
||||
|
||||
vi.spyOn(credentialsApi, 'createNewCredential').mockResolvedValue(mockCredential);
|
||||
|
||||
await store.createNewCredential(
|
||||
{
|
||||
id: 'new-cred-2',
|
||||
name: 'New Personal Credential',
|
||||
type: 'httpBasicAuth',
|
||||
data: {},
|
||||
isGlobal: false,
|
||||
},
|
||||
'project-123',
|
||||
);
|
||||
|
||||
expect(credentialsApi.createNewCredential).toHaveBeenCalledWith(
|
||||
mockRootStore.restApiContext,
|
||||
{
|
||||
name: 'New Personal Credential',
|
||||
type: 'httpBasicAuth',
|
||||
data: {},
|
||||
projectId: 'project-123',
|
||||
uiContext: undefined,
|
||||
isGlobal: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should create credential without isGlobal when not provided', async () => {
|
||||
const { useCredentialsStore } = await import('../credentials.store');
|
||||
const store = useCredentialsStore();
|
||||
|
||||
const mockCredential = mock<ICredentialsResponse>({
|
||||
id: 'new-cred-3',
|
||||
name: 'New Credential',
|
||||
type: 'httpBasicAuth',
|
||||
});
|
||||
|
||||
vi.spyOn(credentialsApi, 'createNewCredential').mockResolvedValue(mockCredential);
|
||||
|
||||
await store.createNewCredential(
|
||||
{
|
||||
id: 'new-cred-3',
|
||||
name: 'New Credential',
|
||||
type: 'httpBasicAuth',
|
||||
data: {},
|
||||
},
|
||||
'project-123',
|
||||
);
|
||||
|
||||
expect(credentialsApi.createNewCredential).toHaveBeenCalledWith(
|
||||
mockRootStore.restApiContext,
|
||||
{
|
||||
name: 'New Credential',
|
||||
type: 'httpBasicAuth',
|
||||
data: {},
|
||||
projectId: 'project-123',
|
||||
uiContext: undefined,
|
||||
isGlobal: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setCredentialSharedWith', () => {
|
||||
it('should pass isGlobal parameter when setting credential sharing', async () => {
|
||||
const { useCredentialsStore } = await import('../credentials.store');
|
||||
const credentialsEeApi = await import('../credentials.ee.api');
|
||||
const store = useCredentialsStore();
|
||||
|
||||
// Initialize the store with a credential
|
||||
store.state.credentials = {
|
||||
'cred-1': mock<ICredentialsResponse>({
|
||||
id: 'cred-1',
|
||||
name: 'Test Credential',
|
||||
type: 'httpBasicAuth',
|
||||
sharedWithProjects: [],
|
||||
}),
|
||||
};
|
||||
|
||||
vi.spyOn(credentialsEeApi, 'setCredentialSharedWith').mockResolvedValue(
|
||||
mock<ICredentialsResponse>({ id: 'cred-1' }),
|
||||
);
|
||||
|
||||
await store.setCredentialSharedWith({
|
||||
credentialId: 'cred-1',
|
||||
sharedWithProjects: [
|
||||
{
|
||||
id: 'project-1',
|
||||
name: 'Project 1',
|
||||
type: 'team',
|
||||
icon: null,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
isGlobal: true,
|
||||
});
|
||||
|
||||
expect(credentialsEeApi.setCredentialSharedWith).toHaveBeenCalledWith(
|
||||
mockRootStore.restApiContext,
|
||||
'cred-1',
|
||||
{
|
||||
shareWithIds: ['project-1'],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should update credential state with new sharing settings', async () => {
|
||||
const { useCredentialsStore } = await import('../credentials.store');
|
||||
const credentialsEeApi = await import('../credentials.ee.api');
|
||||
const store = useCredentialsStore();
|
||||
|
||||
const initialCredential = mock<ICredentialsResponse>({
|
||||
id: 'cred-1',
|
||||
name: 'Test Credential',
|
||||
type: 'httpBasicAuth',
|
||||
sharedWithProjects: [],
|
||||
});
|
||||
|
||||
store.state.credentials = {
|
||||
'cred-1': initialCredential,
|
||||
};
|
||||
|
||||
vi.spyOn(credentialsEeApi, 'setCredentialSharedWith').mockResolvedValue(
|
||||
mock<ICredentialsResponse>({ id: 'cred-1' }),
|
||||
);
|
||||
|
||||
const newSharing = [
|
||||
{
|
||||
id: 'project-1',
|
||||
name: 'Project 1',
|
||||
type: 'team' as const,
|
||||
icon: null,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'project-2',
|
||||
name: 'Project 2',
|
||||
type: 'team' as const,
|
||||
icon: null,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
await store.setCredentialSharedWith({
|
||||
credentialId: 'cred-1',
|
||||
sharedWithProjects: newSharing,
|
||||
});
|
||||
|
||||
expect(store.state.credentials['cred-1']?.sharedWithProjects).toEqual(newSharing);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -93,4 +93,68 @@ describe('CredentialCard', () => {
|
||||
const heading = getByRole('heading');
|
||||
expect(heading).toHaveTextContent('Read only');
|
||||
});
|
||||
|
||||
describe('global credentials', () => {
|
||||
it('should display global badge when credential has isGlobal true', () => {
|
||||
const data = createCredential({
|
||||
isGlobal: true,
|
||||
homeProject: {
|
||||
name: 'Test Project',
|
||||
},
|
||||
});
|
||||
|
||||
const { getByTestId } = renderComponent({ props: { data } });
|
||||
|
||||
const globalBadge = getByTestId('credential-global-badge');
|
||||
expect(globalBadge).toBeInTheDocument();
|
||||
expect(globalBadge).toHaveTextContent('Global');
|
||||
});
|
||||
|
||||
it('should not display global badge when credential has isGlobal false', () => {
|
||||
const data = createCredential({
|
||||
isGlobal: false,
|
||||
homeProject: {
|
||||
name: 'Test Project',
|
||||
},
|
||||
});
|
||||
|
||||
const { queryByTestId } = renderComponent({ props: { data } });
|
||||
|
||||
expect(queryByTestId('credential-global-badge')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not display global badge when isGlobal is undefined', () => {
|
||||
const data = createCredential({
|
||||
homeProject: {
|
||||
name: 'Test Project',
|
||||
},
|
||||
});
|
||||
|
||||
const { queryByTestId } = renderComponent({ props: { data } });
|
||||
|
||||
expect(queryByTestId('credential-global-badge')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display both project badge and global badge for global credentials', () => {
|
||||
const projectName = 'Test Project';
|
||||
const data = createCredential({
|
||||
isGlobal: true,
|
||||
homeProject: {
|
||||
name: projectName,
|
||||
},
|
||||
});
|
||||
|
||||
const { getByTestId } = renderComponent({ props: { data } });
|
||||
|
||||
// Project badge should be present
|
||||
const projectBadge = getByTestId('card-badge');
|
||||
expect(projectBadge).toBeInTheDocument();
|
||||
expect(projectBadge).toHaveTextContent(projectName);
|
||||
|
||||
// Global badge should also be present
|
||||
const globalBadge = getByTestId('credential-global-badge');
|
||||
expect(globalBadge).toBeInTheDocument();
|
||||
expect(globalBadge).toHaveTextContent('Global');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -166,6 +166,7 @@ function moveResource() {
|
||||
:resource-type-label="resourceTypeLabel"
|
||||
:personal-project="projectsStore.personalProject"
|
||||
:show-badge-border="false"
|
||||
:global="data.isGlobal"
|
||||
/>
|
||||
<N8nActionToggle
|
||||
data-test-id="credential-card-actions"
|
||||
|
||||
@ -115,6 +115,7 @@ const hasUserSpecifiedName = ref(false);
|
||||
const isSharedWithChanged = ref(false);
|
||||
const requiredCredentials = ref(false); // Are credentials required or optional for the node
|
||||
const contentRef = ref<HTMLDivElement>();
|
||||
const isSharedGlobally = ref(false);
|
||||
|
||||
const activeNodeType = computed(() => {
|
||||
const activeNode = ndvStore.activeNode;
|
||||
@ -541,6 +542,10 @@ async function loadCurrentCredential() {
|
||||
}
|
||||
|
||||
credentialName.value = currentCredentials.name;
|
||||
isSharedGlobally.value =
|
||||
'isGlobal' in currentCredentials && typeof currentCredentials.isGlobal === 'boolean'
|
||||
? currentCredentials.isGlobal
|
||||
: false;
|
||||
} catch (error) {
|
||||
toast.showError(
|
||||
error,
|
||||
@ -576,6 +581,11 @@ function onChangeSharedWith(sharedWithProjects: ProjectSharingData[]) {
|
||||
hasUnsavedChanges.value = true;
|
||||
}
|
||||
|
||||
function onShareWithAllUsersUpdate(shareWithAllUsers: boolean) {
|
||||
isSharedGlobally.value = shareWithAllUsers;
|
||||
hasUnsavedChanges.value = true;
|
||||
}
|
||||
|
||||
function onDataChange({ name, value }: IUpdateInformation) {
|
||||
const currentValue = get(credentialData.value, name);
|
||||
if (currentValue === value) {
|
||||
@ -703,6 +713,7 @@ async function saveCredential(): Promise<ICredentialsResponse | null> {
|
||||
name: credentialName.value,
|
||||
type: credentialTypeName.value,
|
||||
data: data as unknown as ICredentialDataDecryptedObject,
|
||||
isGlobal: isSharedGlobally.value,
|
||||
};
|
||||
|
||||
if (
|
||||
@ -1240,8 +1251,10 @@ const { width } = useElementSize(credNameRef);
|
||||
:credential-data="credentialData"
|
||||
:credential-id="credentialId"
|
||||
:credential-permissions="credentialPermissions"
|
||||
:isSharedGlobally="isSharedGlobally"
|
||||
:modal-bus="modalBus"
|
||||
@update:model-value="onChangeSharedWith"
|
||||
@update:share-with-all-users="onShareWithAllUsersUpdate"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="activeTab === 'details' && credentialType" :class="$style.mainContent">
|
||||
|
||||
@ -0,0 +1,314 @@
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import CredentialSharing from './CredentialSharing.ee.vue';
|
||||
import { useUsersStore } from '@/features/settings/users/users.store';
|
||||
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
|
||||
import { useSettingsStore } from '@/app/stores/settings.store';
|
||||
import { useRolesStore } from '@/app/stores/roles.store';
|
||||
import type { ICredentialsResponse } from '../../credentials.types';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import { getDropdownItems } from '@/__tests__/utils';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type * as I18nModule from '@n8n/i18n';
|
||||
|
||||
vi.mock('@n8n/i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof I18nModule>();
|
||||
return {
|
||||
...actual,
|
||||
useI18n: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockBaseText = vi.fn((key: string, options?: { interpolate?: Record<string, string> }) => {
|
||||
const translations: Record<string, string> = {
|
||||
'projects.sharing.allUsers': 'All users and projects',
|
||||
'credentialEdit.credentialSharing.info.owner':
|
||||
'Only users with credential sharing permission can change who this credential is shared with',
|
||||
'credentialEdit.credentialSharing.info.sharee.team': 'Shared by team project',
|
||||
'credentialEdit.credentialSharing.info.sharee.personal': 'Shared by personal project',
|
||||
'credentialEdit.credentialSharing.role.user': 'User',
|
||||
'auth.roles.owner': 'Owner',
|
||||
'contextual.credentials.sharing.unavailable.title': 'Upgrade to collaborate',
|
||||
'contextual.credentials.sharing.unavailable.description':
|
||||
'You can share credentials with others when you upgrade your plan.',
|
||||
'contextual.credentials.sharing.unavailable.button': 'View plans',
|
||||
};
|
||||
|
||||
let text = translations[key] || key;
|
||||
|
||||
// Handle interpolation
|
||||
if (options?.interpolate) {
|
||||
Object.entries(options.interpolate).forEach(([placeholder, value]) => {
|
||||
text = text.replace(`{${placeholder}}`, value);
|
||||
});
|
||||
}
|
||||
|
||||
return text;
|
||||
});
|
||||
|
||||
const renderComponent = createComponentRenderer(CredentialSharing);
|
||||
|
||||
const createCredential = (overrides = {}): ICredentialsResponse => ({
|
||||
id: '1',
|
||||
name: 'Test Credential',
|
||||
type: 'testType',
|
||||
isManaged: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
homeProject: {
|
||||
id: 'project-1',
|
||||
name: 'Test Project',
|
||||
type: 'team',
|
||||
icon: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
sharedWithProjects: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('CredentialSharing.ee', () => {
|
||||
let usersStore: ReturnType<typeof useUsersStore>;
|
||||
let projectsStore: ReturnType<typeof useProjectsStore>;
|
||||
let settingsStore: ReturnType<typeof useSettingsStore>;
|
||||
let rolesStore: ReturnType<typeof useRolesStore>;
|
||||
let isEnterpriseFeatureEnabledSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
const pinia = createTestingPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
usersStore = useUsersStore();
|
||||
projectsStore = useProjectsStore();
|
||||
settingsStore = useSettingsStore();
|
||||
rolesStore = useRolesStore();
|
||||
|
||||
// Mock i18n
|
||||
vi.mocked(useI18n).mockReturnValue({
|
||||
baseText: mockBaseText,
|
||||
} as unknown as ReturnType<typeof useI18n>);
|
||||
|
||||
// Mock store methods
|
||||
vi.spyOn(usersStore, 'fetchUsers').mockResolvedValue();
|
||||
vi.spyOn(projectsStore, 'getAllProjects').mockResolvedValue();
|
||||
vi.spyOn(rolesStore, 'processedCredentialRoles', 'get').mockReturnValue([
|
||||
{
|
||||
slug: 'credential:user',
|
||||
displayName: 'User',
|
||||
description: null,
|
||||
systemRole: false,
|
||||
roleType: 'credential',
|
||||
scopes: [],
|
||||
licensed: true,
|
||||
},
|
||||
]);
|
||||
isEnterpriseFeatureEnabledSpy = vi
|
||||
.spyOn(settingsStore, 'isEnterpriseFeatureEnabled', 'get')
|
||||
.mockReturnValue({
|
||||
sharing: true,
|
||||
ldap: false,
|
||||
saml: false,
|
||||
oidc: false,
|
||||
mfaEnforcement: false,
|
||||
logStreaming: false,
|
||||
advancedExecutionFilters: false,
|
||||
variables: false,
|
||||
sourceControl: false,
|
||||
externalSecrets: false,
|
||||
auditLogs: false,
|
||||
debugInEditor: false,
|
||||
binaryDataS3: false,
|
||||
workerView: false,
|
||||
advancedPermissions: false,
|
||||
apiKeyScopes: false,
|
||||
workflowDiffs: false,
|
||||
provisioning: true,
|
||||
showNonProdBanner: false,
|
||||
projects: {
|
||||
team: {
|
||||
limit: -1,
|
||||
},
|
||||
},
|
||||
customRoles: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render ProjectSharing component when sharing is enabled', () => {
|
||||
const credential = createCredential();
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
credentialId: credential.id,
|
||||
credentialData: {},
|
||||
credentialPermissions: { share: true },
|
||||
credential,
|
||||
modalBus: createEventBus(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByTestId('project-sharing-select')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('canShareGlobally computed property', () => {
|
||||
it('should pass canShareGlobally as true when user has credential:shareGlobally scope', async () => {
|
||||
vi.spyOn(usersStore, 'currentUser', 'get').mockReturnValue({
|
||||
id: '1',
|
||||
email: 'test@example.com',
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
isDefaultUser: false,
|
||||
isPendingUser: false,
|
||||
mfaEnabled: false,
|
||||
globalScopes: ['credential:shareGlobally'],
|
||||
});
|
||||
|
||||
const credential = createCredential();
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
credentialId: credential.id,
|
||||
credentialData: {},
|
||||
credentialPermissions: { share: true },
|
||||
credential,
|
||||
modalBus: createEventBus(),
|
||||
},
|
||||
});
|
||||
|
||||
// When canShareGlobally is true, "All users and projects" option should be available
|
||||
const projectSharingSelect = getByTestId('project-sharing-select');
|
||||
expect(projectSharingSelect).toBeInTheDocument();
|
||||
|
||||
// Open dropdown and verify "All users and projects" option is present
|
||||
const dropdownItems = await getDropdownItems(projectSharingSelect);
|
||||
expect(dropdownItems[0]).toHaveTextContent('All users and projects');
|
||||
});
|
||||
|
||||
it('should pass canShareGlobally as false when user does not have credential:shareGlobally scope', async () => {
|
||||
vi.spyOn(usersStore, 'currentUser', 'get').mockReturnValue({
|
||||
id: '1',
|
||||
email: 'test@example.com',
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
isDefaultUser: false,
|
||||
isPendingUser: false,
|
||||
mfaEnabled: false,
|
||||
globalScopes: [],
|
||||
});
|
||||
|
||||
const credential = createCredential();
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
credentialId: credential.id,
|
||||
credentialData: {},
|
||||
credentialPermissions: { share: true },
|
||||
credential,
|
||||
modalBus: createEventBus(),
|
||||
},
|
||||
});
|
||||
|
||||
// When canShareGlobally is false, "All users and projects" option should NOT be available
|
||||
const projectSharingSelect = getByTestId('project-sharing-select');
|
||||
expect(projectSharingSelect).toBeInTheDocument();
|
||||
|
||||
// Open dropdown and verify "All users and projects" option is NOT present
|
||||
const dropdownItems = await getDropdownItems(projectSharingSelect);
|
||||
// Should have no items or first item should not be "All users and projects"
|
||||
const hasAllUsersOption = Array.from(dropdownItems).some((item) =>
|
||||
item.textContent?.includes('All users and projects'),
|
||||
);
|
||||
expect(hasAllUsersOption).toBe(false);
|
||||
});
|
||||
|
||||
it('should pass canShareGlobally as false when user is undefined', async () => {
|
||||
vi.spyOn(usersStore, 'currentUser', 'get').mockReturnValue(null);
|
||||
|
||||
const credential = createCredential();
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
credentialId: credential.id,
|
||||
credentialData: {},
|
||||
credentialPermissions: { share: true },
|
||||
credential,
|
||||
modalBus: createEventBus(),
|
||||
},
|
||||
});
|
||||
|
||||
// When user is undefined, canShareGlobally should default to false
|
||||
const projectSharingSelect = getByTestId('project-sharing-select');
|
||||
expect(projectSharingSelect).toBeInTheDocument();
|
||||
|
||||
// Open dropdown and verify "All users and projects" option is NOT present
|
||||
const dropdownItems = await getDropdownItems(projectSharingSelect);
|
||||
// Should have no items or no "All users and projects" option
|
||||
const hasAllUsersOption = Array.from(dropdownItems).some((item) =>
|
||||
item.textContent?.includes('All users and projects'),
|
||||
);
|
||||
expect(hasAllUsersOption).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('projects filtering', () => {
|
||||
it('should show upgrade action box when sharing is not enabled', () => {
|
||||
isEnterpriseFeatureEnabledSpy.mockReturnValue({
|
||||
sharing: false,
|
||||
ldap: false,
|
||||
saml: false,
|
||||
oidc: false,
|
||||
mfaEnforcement: false,
|
||||
logStreaming: false,
|
||||
advancedExecutionFilters: false,
|
||||
variables: false,
|
||||
sourceControl: false,
|
||||
externalSecrets: false,
|
||||
auditLogs: false,
|
||||
debugInEditor: false,
|
||||
binaryDataS3: false,
|
||||
workerView: false,
|
||||
advancedPermissions: false,
|
||||
apiKeyScopes: false,
|
||||
workflowDiffs: false,
|
||||
provisioning: true,
|
||||
showNonProdBanner: false,
|
||||
projects: {
|
||||
team: {
|
||||
limit: -1,
|
||||
},
|
||||
},
|
||||
customRoles: false,
|
||||
});
|
||||
|
||||
const credential = createCredential();
|
||||
const { getByText } = renderComponent({
|
||||
props: {
|
||||
credentialId: credential.id,
|
||||
credentialData: {},
|
||||
credentialPermissions: { share: true },
|
||||
credential,
|
||||
modalBus: createEventBus(),
|
||||
},
|
||||
});
|
||||
|
||||
// Should show upgrade message
|
||||
expect(getByText(/upgrade to collaborate/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('readonly state', () => {
|
||||
it('should hide select and show info tip when user lacks share permission', () => {
|
||||
const credential = createCredential();
|
||||
const { queryByTestId, getByText } = renderComponent({
|
||||
props: {
|
||||
credentialId: credential.id,
|
||||
credentialData: {},
|
||||
credentialPermissions: { share: false },
|
||||
credential,
|
||||
modalBus: createEventBus(),
|
||||
},
|
||||
});
|
||||
|
||||
// Select should not be visible when static prop is true
|
||||
expect(queryByTestId('project-sharing-select')).not.toBeInTheDocument();
|
||||
// Info tip should be shown - since credential is team project, shows "Shared by team project"
|
||||
expect(getByText(/shared by team project/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -19,6 +19,7 @@ import { splitName } from '@/features/collaboration/projects/projects.utils';
|
||||
import type { EventBus } from '@n8n/utils/event-bus';
|
||||
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { getResourcePermissions } from '@n8n/permissions';
|
||||
|
||||
import { N8nActionBox, N8nInfoTip } from '@n8n/design-system';
|
||||
type Props = {
|
||||
@ -27,12 +28,14 @@ type Props = {
|
||||
credentialPermissions: PermissionsRecord['credential'];
|
||||
credential?: ICredentialsResponse | ICredentialsDecryptedResponse | null;
|
||||
modalBus: EventBus;
|
||||
isSharedGlobally?: boolean;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { credential: null });
|
||||
const props = withDefaults(defineProps<Props>(), { credential: null, isSharedGlobally: false });
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: ProjectSharingData[]];
|
||||
'update:shareWithAllUsers': [value: boolean];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
@ -105,6 +108,11 @@ const sharingSelectPlaceholder = computed(() =>
|
||||
: i18n.baseText('projects.sharing.select.placeholder.user'),
|
||||
);
|
||||
|
||||
const canShareGlobally = computed(() => {
|
||||
const permissions = getResourcePermissions(usersStore.currentUser?.globalScopes);
|
||||
return permissions.credential?.shareGlobally ?? false;
|
||||
});
|
||||
|
||||
watch(
|
||||
sharedWithProjects,
|
||||
(changedSharedWithProjects) => {
|
||||
@ -162,6 +170,9 @@ function goToUpgrade() {
|
||||
:readonly="!credentialPermissions.share"
|
||||
:static="!credentialPermissions.share"
|
||||
:placeholder="sharingSelectPlaceholder"
|
||||
:can-share-globally="canShareGlobally"
|
||||
:is-shared-globally="isSharedGlobally"
|
||||
@update:share-with-all-users="emit('update:shareWithAllUsers', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -28,12 +28,14 @@ export async function getAllCredentials(
|
||||
filter?: object,
|
||||
includeScopes?: boolean,
|
||||
onlySharedWithMe?: boolean,
|
||||
includeGlobal?: boolean,
|
||||
): Promise<ICredentialsResponse[]> {
|
||||
return await makeRestApiRequest(context, 'GET', '/credentials', {
|
||||
...(includeScopes ? { includeScopes } : {}),
|
||||
includeData: true,
|
||||
...(filter ? { filter } : {}),
|
||||
...(onlySharedWithMe ? { onlySharedWithMe } : {}),
|
||||
...(typeof includeGlobal === 'boolean' ? { includeGlobal } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -267,6 +267,7 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
|
||||
projectId?: string,
|
||||
includeScopes = true,
|
||||
onlySharedWithMe = false,
|
||||
includeGlobal = true,
|
||||
): Promise<ICredentialsResponse[]> => {
|
||||
const filter = {
|
||||
projectId,
|
||||
@ -277,6 +278,7 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
|
||||
isEmpty(filter) ? undefined : filter,
|
||||
includeScopes,
|
||||
onlySharedWithMe,
|
||||
includeGlobal,
|
||||
);
|
||||
setCredentials(credentials);
|
||||
return credentials;
|
||||
@ -326,6 +328,7 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
|
||||
data: data.data ?? {},
|
||||
projectId,
|
||||
uiContext,
|
||||
isGlobal: data.isGlobal,
|
||||
});
|
||||
|
||||
if (data?.homeProject && !credential.homeProject) {
|
||||
@ -400,6 +403,7 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
|
||||
const setCredentialSharedWith = async (payload: {
|
||||
sharedWithProjects: ProjectSharingData[];
|
||||
credentialId: string;
|
||||
isGlobal?: boolean;
|
||||
}): Promise<ICredentialsResponse> => {
|
||||
if (useSettingsStore().isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing]) {
|
||||
await credentialsEeApi.setCredentialSharedWith(
|
||||
|
||||
@ -14,6 +14,7 @@ export interface ICredentialsResponse extends ICredentialsEncrypted {
|
||||
scopes?: Scope[];
|
||||
ownedBy?: Pick<IUserResponse, 'id' | 'firstName' | 'lastName' | 'email'>;
|
||||
isManaged: boolean;
|
||||
isGlobal?: boolean;
|
||||
}
|
||||
|
||||
export interface IUsedCredential {
|
||||
|
||||
@ -89,6 +89,7 @@ const allCredentials = computed<Resource[]>(() =>
|
||||
sharedWithProjects: credential.sharedWithProjects,
|
||||
readOnly: !getResourcePermissions(credential.scopes).credential.update,
|
||||
needsSetup: needsSetup(credential.data),
|
||||
isGlobal: credential.isGlobal,
|
||||
type: credential.type,
|
||||
})),
|
||||
);
|
||||
@ -192,11 +193,17 @@ const initialize = async () => {
|
||||
const isVarsEnabled =
|
||||
useSettingsStore().isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Variables];
|
||||
|
||||
const isPersonalView =
|
||||
!overview.isSharedSubPage &&
|
||||
overview.isProjectsSubPage &&
|
||||
route?.params?.projectId === projectsStore.personalProject?.id;
|
||||
|
||||
const loadPromises = [
|
||||
credentialsStore.fetchAllCredentials(
|
||||
route?.params?.projectId as string | undefined,
|
||||
true,
|
||||
overview.isSharedSubPage,
|
||||
!isPersonalView, // don't include global credentials if personal
|
||||
),
|
||||
credentialsStore.fetchCredentialTypes(false),
|
||||
externalSecretsStore.fetchAllSecrets(),
|
||||
|
||||
@ -203,7 +203,7 @@ test.describe('@isolated', () => {
|
||||
|
||||
await expect(
|
||||
n8n.credentials.credentialModal.getVisibleDropdown().getByTestId('project-sharing-info'),
|
||||
).toHaveCount(3);
|
||||
).toHaveCount(4);
|
||||
|
||||
// Admin can share with self
|
||||
await expect(
|
||||
@ -251,7 +251,7 @@ test.describe('@isolated', () => {
|
||||
await n8n.credentials.credentialModal.getUsersSelect().click();
|
||||
|
||||
const sharingDropdown = n8n.credentials.credentialModal.getVisibleDropdown();
|
||||
await expect(sharingDropdown.locator('li')).toHaveCount(4);
|
||||
await expect(sharingDropdown.locator('li')).toHaveCount(5);
|
||||
await expect(sharingDropdown.getByText('Development')).toBeVisible();
|
||||
|
||||
await sharingDropdown.getByText('Development').click();
|
||||
@ -290,9 +290,9 @@ test.describe('@isolated', () => {
|
||||
await n8n.credentials.credentialModal.getUsersSelect().click();
|
||||
|
||||
const sharingDropdown2 = n8n.credentials.credentialModal.getVisibleDropdown();
|
||||
await expect(sharingDropdown2.locator('li')).toHaveCount(4);
|
||||
await expect(sharingDropdown2.locator('li')).toHaveCount(5);
|
||||
|
||||
await sharingDropdown2.locator('li').first().click();
|
||||
await sharingDropdown2.locator('li').nth(1).click();
|
||||
await n8n.credentials.credentialModal.saveSharing();
|
||||
await n8n.credentials.credentialModal.close();
|
||||
|
||||
|
||||
@ -77,12 +77,14 @@ test.describe('Credential API Operations', () => {
|
||||
expect(foundCredentials).toHaveLength(2);
|
||||
|
||||
const credentialsWithScopes = await api.credentials.getCredentials({
|
||||
includeGlobal: false,
|
||||
includeScopes: true,
|
||||
});
|
||||
expect(credentialsWithScopes[0].scopes).toBeDefined();
|
||||
expect(Array.isArray(credentialsWithScopes[0].scopes)).toBe(true);
|
||||
|
||||
const credentialsWithData = await api.credentials.getCredentials({
|
||||
includeGlobal: false,
|
||||
includeData: true,
|
||||
});
|
||||
const foundWithData = credentialsWithData.filter((c) => createdIds.includes(c.id));
|
||||
|
||||
161
packages/testing/playwright/tests/ui/global-credentials.spec.ts
Normal file
161
packages/testing/playwright/tests/ui/global-credentials.spec.ts
Normal file
@ -0,0 +1,161 @@
|
||||
import { test, expect } from '../../fixtures/base';
|
||||
|
||||
test.describe('Global credentials @isolated', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeAll(async ({ api }) => {
|
||||
await api.resetDatabase();
|
||||
await api.enableFeature('sharing');
|
||||
});
|
||||
|
||||
test('owner should create HTTP header credential and set to global', async ({ n8n }) => {
|
||||
await n8n.api.signin('owner');
|
||||
|
||||
// Navigate to credentials page
|
||||
await n8n.navigate.toCredentials();
|
||||
|
||||
// Create new credential
|
||||
await n8n.credentials.addResource.credential();
|
||||
await n8n.credentials.selectCredentialType('Header Auth');
|
||||
|
||||
// Fill in credential fields
|
||||
await n8n.credentials.credentialModal.fillField('name', 'Authorization');
|
||||
await n8n.credentials.credentialModal.fillField('value', 'Bearer test-token-123');
|
||||
|
||||
// Set credential name
|
||||
await n8n.credentials.credentialModal.getCredentialName().click();
|
||||
await n8n.credentials.credentialModal.getNameInput().fill('Global HTTP Header Cred');
|
||||
|
||||
// Switch to Sharing tab
|
||||
await n8n.credentials.credentialModal.changeTab('Sharing');
|
||||
|
||||
// Share with all users (set to global)
|
||||
await n8n.credentials.credentialModal.getUsersSelect().click();
|
||||
await n8n.credentials.credentialModal.getVisibleDropdown().getByText('All users').click();
|
||||
|
||||
// Save the credential with sharing
|
||||
await n8n.credentials.credentialModal.save();
|
||||
await n8n.credentials.credentialModal.close();
|
||||
|
||||
// Verify credential appears in list with global badge
|
||||
await expect(n8n.credentials.cards.getCredential('Global HTTP Header Cred')).toBeVisible();
|
||||
await expect(
|
||||
n8n.credentials.cards
|
||||
.getCredential('Global HTTP Header Cred')
|
||||
.getByTestId('credential-global-badge'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('member should see global credential in credentials view', async ({ n8n }) => {
|
||||
await n8n.api.signin('member', 0);
|
||||
|
||||
// Navigate to credentials page
|
||||
await n8n.navigate.toCredentials();
|
||||
|
||||
// Verify global credential is visible to member
|
||||
await expect(n8n.credentials.cards.getCredential('Global HTTP Header Cred')).toBeVisible();
|
||||
|
||||
// Verify global badge is displayed
|
||||
await expect(
|
||||
n8n.credentials.cards
|
||||
.getCredential('Global HTTP Header Cred')
|
||||
.getByTestId('credential-global-badge'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('member should execute workflow with HTTP node using global credential', async ({
|
||||
n8n,
|
||||
baseURL,
|
||||
}) => {
|
||||
await n8n.api.signin('member', 0);
|
||||
|
||||
// Create a new workflow
|
||||
await n8n.navigate.toWorkflow('new');
|
||||
await n8n.canvas.setWorkflowName('Test Global Credential Workflow');
|
||||
|
||||
// Add manual trigger and HTTP Request node
|
||||
await n8n.canvas.addNode('Manual Trigger');
|
||||
await n8n.canvas.addNode('HTTP Request');
|
||||
|
||||
await n8n.ndv.fillParameterInput('URL', `${baseURL}/rest/settings`);
|
||||
await n8n.ndv.selectOptionInParameterDropdown('authentication', 'Generic Credential Type');
|
||||
await n8n.ndv.selectOptionInParameterDropdown('genericAuthType', 'Header Auth');
|
||||
|
||||
// Verify global credential is available in the credential select
|
||||
const credentialSelect = n8n.ndv.getCredentialSelect();
|
||||
await credentialSelect.click();
|
||||
|
||||
// Check that global credential appears in dropdown
|
||||
const dropdown = n8n.credentials.credentialModal.getVisibleDropdown();
|
||||
await expect(dropdown.getByText('Global HTTP Header Cred')).toBeVisible();
|
||||
|
||||
// Select the global credential
|
||||
await dropdown.getByText('Global HTTP Header Cred').click();
|
||||
|
||||
// Verify credential is selected
|
||||
await expect(credentialSelect).toHaveValue('Global HTTP Header Cred');
|
||||
|
||||
// Close NDV
|
||||
await n8n.ndv.clickBackToCanvasButton();
|
||||
|
||||
// Save workflow
|
||||
await n8n.canvas.saveWorkflow();
|
||||
|
||||
// Verify workflow saved successfully
|
||||
await expect(n8n.canvas.getWorkflowNameInput()).toHaveValue('Test Global Credential Workflow');
|
||||
|
||||
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
|
||||
'Workflow executed successfully',
|
||||
);
|
||||
});
|
||||
|
||||
test('owner should be able to remove global sharing', async ({ n8n }) => {
|
||||
await n8n.api.signin('owner');
|
||||
|
||||
// Navigate to credentials page
|
||||
await n8n.navigate.toCredentials();
|
||||
|
||||
// Open the global credential
|
||||
await n8n.credentials.cards.getCredential('Global HTTP Header Cred').click();
|
||||
|
||||
// Switch to Sharing tab
|
||||
await n8n.credentials.credentialModal.changeTab('Sharing');
|
||||
|
||||
// Verify "All users" is in the sharing list
|
||||
await expect(
|
||||
n8n.credentials.credentialModal
|
||||
.getModal()
|
||||
.getByTestId('project-sharing-list-item')
|
||||
.filter({ hasText: 'All users' }),
|
||||
).toBeVisible();
|
||||
|
||||
// Remove global sharing by clicking the remove button
|
||||
await n8n.credentials.credentialModal
|
||||
.getModal()
|
||||
.getByTestId('project-sharing-list-item')
|
||||
.filter({ hasText: 'All users' })
|
||||
.getByTestId('project-sharing-remove')
|
||||
.click();
|
||||
|
||||
// Save the changes
|
||||
await n8n.credentials.credentialModal.save();
|
||||
await n8n.credentials.credentialModal.close();
|
||||
|
||||
// Verify global badge is no longer visible
|
||||
await expect(
|
||||
n8n.credentials.cards
|
||||
.getCredential('Global HTTP Header Cred')
|
||||
.getByTestId('credential-global-badge'),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('member should not see credential after global sharing removed', async ({ n8n }) => {
|
||||
await n8n.api.signin('member', 0);
|
||||
|
||||
// Navigate to credentials page
|
||||
await n8n.navigate.toCredentials();
|
||||
|
||||
// Verify credential is no longer visible to member
|
||||
await expect(n8n.credentials.cards.getCredential('Global HTTP Header Cred')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@ -148,6 +148,7 @@ export interface ICredentialsDecrypted<T extends object = ICredentialDataDecrypt
|
||||
data?: T;
|
||||
homeProject?: ProjectSharingData;
|
||||
sharedWithProjects?: ProjectSharingData[];
|
||||
isGlobal?: boolean;
|
||||
}
|
||||
|
||||
export interface ICredentialsEncrypted {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user