feat: Add support for global credentials (#21700)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Mutasem Aldmour 2025-11-20 17:48:49 +01:00 committed by GitHub
parent aa17707805
commit 55c3150c11
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 5388 additions and 187 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -87,6 +87,7 @@ export interface ICredentialsDb extends ICredentialsBase, ICredentialsEncrypted
id: string;
name: string;
shared?: SharedCredentials[];
isGlobal?: boolean;
}
export interface IExecutionResponse extends IExecutionBase {

View File

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

View File

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

View File

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

View File

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

View File

@ -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.
*/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 & {

View File

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

View File

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

View File

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

View File

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

View File

@ -24,6 +24,7 @@ const mockCredentialsService = (
type: 'mock',
shared: [] as SharedCredentials[],
isManaged: false,
isGlobal: false,
id,
// Methods present on entities via WithTimestampsAndStringId mixin
generateId() {},

View File

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

View File

@ -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) {

View File

@ -72,6 +72,7 @@ export declare namespace CredentialRequest {
data: ICredentialDataDecryptedObject;
projectId?: string;
isManaged?: boolean;
isGlobal?: boolean;
}>;
type Get = AuthenticatedRequest<{ credentialId: string }, {}, {}, Record<string, string>>;

View File

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

View File

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

View File

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

View File

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

View File

@ -74,6 +74,7 @@ describe('POST /ai/free-credits', () => {
'credential:move',
'credential:read',
'credential:share',
'credential:shareGlobally',
'credential:update',
].sort(),
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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}",

View File

@ -305,6 +305,7 @@ export type CredentialsResource = BaseResource & {
sharedWithProjects?: ProjectSharingData[];
readOnly: boolean;
needsSetup: boolean;
isGlobal?: boolean;
};
// Base resource types that are always available

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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();
});
});

View File

@ -148,6 +148,7 @@ export interface ICredentialsDecrypted<T extends object = ICredentialDataDecrypt
data?: T;
homeProject?: ProjectSharingData;
sharedWithProjects?: ProjectSharingData[];
isGlobal?: boolean;
}
export interface ICredentialsEncrypted {