feat: Add admin-managed instance credentials (#34364)

This commit is contained in:
Albert Alises 2026-07-25 16:22:22 +02:00 committed by GitHub
parent 6d66056058
commit 7d8426f055
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
95 changed files with 4640 additions and 667 deletions

View File

@ -10,4 +10,5 @@ export class CreateCredentialDto extends Z.class({
uiContext: z.string().optional(),
isGlobal: z.boolean().optional(),
isResolvable: z.boolean().optional(),
usageScope: z.enum(['project', 'instance']).optional(),
}) {}

View File

@ -1423,6 +1423,7 @@ export interface InstanceAiAdminSettingsResponse {
daytonaCredentialId: string | null;
n8nSandboxCredentialId: string | null;
searchCredentialId: string | null;
modelCredentialId: string | null;
localGatewayDisabled: boolean;
browserUseEnabled: boolean;
}
@ -1439,6 +1440,7 @@ export class InstanceAiAdminSettingsUpdateRequest extends Z.class({
daytonaCredentialId: z.string().nullable().optional(),
n8nSandboxCredentialId: z.string().nullable().optional(),
searchCredentialId: z.string().nullable().optional(),
modelCredentialId: z.string().nullable().optional(),
localGatewayDisabled: z.boolean().optional(),
browserUseEnabled: z.boolean().optional(),
}) {}

View File

@ -171,7 +171,7 @@ describe('DbConnection', () => {
beforeEach(() => {
Container.set(DbLockService, dbLockService);
dbLockService.withLock.mockImplementation(async (_lockId, fn) => await fn(tx));
dbLockService.withLock.mockImplementation(async (_lockId, fn) => await fn(tx, {}));
tx.query.mockResolvedValue([]);
vi.mocked(MigrationExecutor).mockImplementation(function () {
return migrationExecutor;

View File

@ -5,6 +5,8 @@ import { WithTimestampsAndStringId } from './abstract-entity';
import type { SharedCredentials } from './shared-credentials';
import type { ICredentialsDb } from './types-db';
export type CredentialUsageScope = 'project' | 'instance';
@Entity()
export class CredentialsEntity extends WithTimestampsAndStringId implements ICredentialsDb {
@Column({ length: 128 })
@ -61,6 +63,9 @@ export class CredentialsEntity extends WithTimestampsAndStringId implements ICre
@Column({ type: 'varchar', nullable: true })
resolverId: string | null;
@Column({ type: 'varchar', length: 16, default: 'project' })
usageScope: CredentialUsageScope;
toJSON() {
const { shared, ...rest } = this;
return rest;

View File

@ -13,7 +13,7 @@ import {
CredentialDependency,
type CredentialDependencyType,
} from './credential-dependency-entity';
import { CredentialsEntity } from './credentials-entity';
import { CredentialsEntity, type CredentialUsageScope } from './credentials-entity';
import { DeploymentKey } from './deployment-key';
import { EvaluationCollection } from './evaluation-collection.ee';
import { EvaluationConfig } from './evaluation-config.ee';
@ -24,6 +24,7 @@ import type { ExecutionDataStorageLocation } from './execution-entity';
import { ExecutionMetadata } from './execution-metadata';
import { Folder } from './folder';
import { FolderTagMapping } from './folder-tag-mapping';
import { InstanceCredentialAssignment } from './instance-credential-assignment';
import { InvalidAuthToken } from './invalid-auth-token';
import { ProcessedData } from './processed-data';
import { Project } from './project';
@ -89,6 +90,7 @@ export {
AgentEvalRating,
type AgentEvalVote,
InvalidAuthToken,
InstanceCredentialAssignment,
AiBuilderTemporaryWorkflow,
ProcessedData,
Settings,
@ -101,6 +103,7 @@ export {
WebhookEntity,
AuthIdentity,
CredentialsEntity,
type CredentialUsageScope,
CredentialDependency,
type CredentialDependencyType,
DeploymentKey,
@ -166,6 +169,7 @@ export const entities = {
AgentEvalResult,
AgentEvalRating,
InvalidAuthToken,
InstanceCredentialAssignment,
AiBuilderTemporaryWorkflow,
ProcessedData,
Settings,

View File

@ -0,0 +1,26 @@
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryColumn,
Relation,
} from '@n8n/typeorm';
import { WithTimestamps } from './abstract-entity';
import type { CredentialsEntity } from './credentials-entity';
@Entity({ name: 'instance_credential_assignment' })
export class InstanceCredentialAssignment extends WithTimestamps {
@PrimaryColumn({ type: 'varchar', length: 128 })
credentialUseId: string;
@Column({ type: 'varchar', length: 36 })
@Index()
credentialId: string;
@ManyToOne('CredentialsEntity', { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'credentialId' })
credential: Relation<CredentialsEntity>;
}

View File

@ -18,7 +18,7 @@ import type {
} from 'n8n-workflow';
import { z } from 'zod';
import type { CredentialsEntity } from './credentials-entity';
import type { CredentialUsageScope, CredentialsEntity } from './credentials-entity';
import type { ExecutionDataStorageLocation } from './execution-entity';
import type { Folder } from './folder';
import type { Project } from './project';
@ -103,6 +103,7 @@ export interface ICredentialsDb extends ICredentialsBase, ICredentialsEncrypted
isGlobal?: boolean;
isResolvable?: boolean;
isManaged?: boolean;
usageScope?: CredentialUsageScope;
}
export interface IExecutionResponse extends IExecutionBase {

View File

@ -1,20 +1,122 @@
import { Container } from '@n8n/di';
import type { SelectQueryBuilder } from '@n8n/typeorm';
import { In } from '@n8n/typeorm';
import type { EntityManager, SelectQueryBuilder } from '@n8n/typeorm';
import { In, Not, QueryFailedError } from '@n8n/typeorm';
import { mock } from 'vitest-mock-extended';
import { CredentialsEntity } from '../../entities';
import { TypeOrmTransaction } from '../../services/typeorm-transaction';
import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager';
import { CredentialsRepository } from '../credentials.repository';
import { InstanceCredentialAssignmentRepository } from '../instance-credential-assignment.repository';
describe('CredentialsRepository', () => {
const entityManager = mockEntityManager(CredentialsEntity);
const credentialsRepository = Container.get(CredentialsRepository);
const assignmentRepository = Container.get(InstanceCredentialAssignmentRepository);
beforeEach(() => {
vi.resetAllMocks();
});
it('finds non-project credentials by ID', async () => {
entityManager.find.mockResolvedValueOnce([]);
await credentialsRepository.findNonProjectCredentialsByIds(['credential-id']);
expect(entityManager.find).toHaveBeenCalledWith(CredentialsEntity, {
where: { id: In(['credential-id']), usageScope: Not('project') },
select: ['id'],
});
});
it('finds only dangling project credentials', async () => {
const queryBuilder = mock<SelectQueryBuilder<CredentialsEntity>>();
queryBuilder.leftJoinAndSelect.mockReturnValue(queryBuilder);
queryBuilder.where.mockReturnValue(queryBuilder);
queryBuilder.andWhere.mockReturnValue(queryBuilder);
queryBuilder.getMany.mockResolvedValue([]);
vi.spyOn(credentialsRepository, 'createQueryBuilder').mockReturnValue(queryBuilder);
await credentialsRepository.findDanglingProjectCredentials();
expect(queryBuilder.andWhere).toHaveBeenCalledWith('credentials.usageScope = :usageScope', {
usageScope: 'project',
});
});
it('uses the operation transaction for instance credential writes', async () => {
const transactionManager = mock<EntityManager>();
const ctx = { trx: new TypeOrmTransaction(transactionManager) };
const credential = mock<CredentialsEntity>({
id: 'credential-id',
usageScope: 'instance',
});
transactionManager.save.mockResolvedValue(credential);
transactionManager.findOneBy.mockResolvedValue(credential);
transactionManager.find.mockResolvedValue([]);
transactionManager.delete.mockRejectedValue(
new QueryFailedError('DELETE', [], new Error('foreign key constraint')),
);
await credentialsRepository.saveInstanceCredential(credential, ctx);
await credentialsRepository.updateInstanceCredential(
credential.id,
{ ...credential, name: 'Updated', type: 'openAiApi', data: 'encrypted' },
ctx,
);
await expect(
credentialsRepository.deleteInstanceCredentialIfUnassigned(credential.id, ctx),
).rejects.toThrow('foreign key constraint');
expect(transactionManager.save).toHaveBeenCalledWith(CredentialsEntity, credential);
expect(transactionManager.update).toHaveBeenCalledWith(
CredentialsEntity,
{ id: credential.id, usageScope: 'instance' },
expect.objectContaining({ name: 'Updated' }),
);
expect(transactionManager.delete).toHaveBeenCalledWith(CredentialsEntity, {
id: credential.id,
usageScope: 'instance',
});
expect(transactionManager.find).toHaveBeenCalledTimes(1);
expect(entityManager.save).not.toHaveBeenCalled();
});
it('keeps instance credentials that are assigned', async () => {
const credential = mock<CredentialsEntity>({ id: 'credential-id', usageScope: 'instance' });
entityManager.findOneBy.mockResolvedValue(credential);
vi.spyOn(assignmentRepository, 'findCredentialUseIds').mockResolvedValue([
'example:primary',
'example:secondary',
]);
await expect(
credentialsRepository.deleteInstanceCredentialIfUnassigned(credential.id),
).resolves.toEqual({
status: 'assigned',
credentialUseIds: ['example:primary', 'example:secondary'],
});
expect(entityManager.delete).not.toHaveBeenCalled();
});
it('reports an assignment created concurrently with deletion', async () => {
const credential = mock<CredentialsEntity>({ id: 'credential-id', usageScope: 'instance' });
entityManager.findOneBy.mockResolvedValue(credential);
vi.spyOn(assignmentRepository, 'findCredentialUseIds')
.mockResolvedValueOnce([])
.mockResolvedValueOnce(['example:primary']);
entityManager.delete.mockRejectedValue(
new QueryFailedError('DELETE', [], new Error('foreign key constraint')),
);
await expect(
credentialsRepository.deleteInstanceCredentialIfUnassigned(credential.id),
).resolves.toEqual({
status: 'assigned',
credentialUseIds: ['example:primary'],
});
});
describe('findManyAndCount', () => {
it('should call findAndCount with options from toFindManyOptions and return [entities, count]', async () => {
const mockCredentials = [

View File

@ -0,0 +1,118 @@
import { Container } from '@n8n/di';
import { In, QueryFailedError, type EntityManager } from '@n8n/typeorm';
import { mock } from 'vitest-mock-extended';
import { CredentialsEntity, InstanceCredentialAssignment } from '../../entities';
import { TypeOrmTransaction } from '../../services/typeorm-transaction';
import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager';
import { InstanceCredentialAssignmentRepository } from '../instance-credential-assignment.repository';
describe('InstanceCredentialAssignmentRepository', () => {
const entityManager = mockEntityManager(InstanceCredentialAssignment);
const repository = Container.get(InstanceCredentialAssignmentRepository);
const credential = mock<CredentialsEntity>({
id: 'credential-id',
name: 'Primary model',
type: 'openAiApi',
usageScope: 'instance',
});
beforeEach(() => {
vi.resetAllMocks();
});
it('finds available credentials by type', async () => {
entityManager.find.mockResolvedValue([credential]);
await expect(repository.findAvailableCredentials(['openAiApi'])).resolves.toEqual([credential]);
expect(entityManager.find).toHaveBeenCalledWith(CredentialsEntity, {
select: ['id', 'name', 'type'],
where: { usageScope: 'instance', type: In(['openAiApi']) },
order: { name: 'ASC' },
});
});
it('assigns an available credential', async () => {
const transactionManager = mock<EntityManager>({
connection: { options: { type: 'postgres' } } as EntityManager['connection'],
});
transactionManager.findOne.mockResolvedValue(credential);
const ctx = { trx: new TypeOrmTransaction(transactionManager) };
await expect(
repository.assignCredential('example:primary', credential.id, ['openAiApi'], ctx),
).resolves.toBe(credential);
expect(transactionManager.findOne).toHaveBeenCalledWith(CredentialsEntity, {
where: {
id: credential.id,
usageScope: 'instance',
type: In(['openAiApi']),
},
lock: { mode: 'pessimistic_write' },
});
expect(transactionManager.upsert).toHaveBeenCalledWith(
InstanceCredentialAssignment,
{
credentialUseId: 'example:primary',
credentialId: credential.id,
updatedAt: expect.any(Date) as Date,
},
['credentialUseId'],
);
});
it('returns null when the credential is deleted before assignment', async () => {
entityManager.findOne.mockResolvedValueOnce(credential).mockResolvedValueOnce(null);
entityManager.upsert.mockRejectedValue(
new QueryFailedError('INSERT', [], new Error('FOREIGN KEY constraint failed')),
);
await expect(
repository.assignCredential('example:primary', credential.id, ['openAiApi']),
).resolves.toBeNull();
});
it('rethrows transient assignment failures', async () => {
entityManager.findOne.mockResolvedValue(credential);
entityManager.upsert.mockRejectedValue(
new QueryFailedError('INSERT', [], new Error('deadlock detected')),
);
await expect(
repository.assignCredential('example:primary', credential.id, ['openAiApi']),
).rejects.toThrow('deadlock detected');
});
it('resolves the assigned credential', async () => {
entityManager.findOneBy.mockResolvedValue(
mock<InstanceCredentialAssignment>({
credentialUseId: 'example:primary',
credentialId: credential.id,
}),
);
entityManager.findOne.mockResolvedValue(credential);
await expect(
repository.findAssignedCredential('example:primary', ['openAiApi']),
).resolves.toEqual({ credentialId: credential.id, credential });
});
it('finds a credential use through the operation transaction', async () => {
const transactionManager = mock<EntityManager>();
const ctx = { trx: new TypeOrmTransaction(transactionManager) };
transactionManager.find.mockResolvedValue([
mock<InstanceCredentialAssignment>({ credentialUseId: 'example:primary' }),
mock<InstanceCredentialAssignment>({ credentialUseId: 'example:secondary' }),
]);
await expect(repository.findCredentialUseIds(credential.id, ctx)).resolves.toEqual([
'example:primary',
'example:secondary',
]);
expect(transactionManager.find).toHaveBeenCalledWith(InstanceCredentialAssignment, {
select: ['credentialUseId'],
where: { credentialId: credential.id },
order: { credentialUseId: 'ASC' },
});
});
});

View File

@ -0,0 +1,30 @@
import { Container } from '@n8n/di';
import type { EntityManager } from '@n8n/typeorm';
import { mock } from 'vitest-mock-extended';
import { Settings } from '../../entities';
import { TypeOrmTransaction } from '../../services/typeorm-transaction';
import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager';
import { SettingsRepository } from '../settings.repository';
describe('SettingsRepository', () => {
mockEntityManager(Settings);
const repository = Container.get(SettingsRepository);
it('upserts a setting with the provided operation context', async () => {
const transactionManager = mock<EntityManager>();
const ctx = { trx: new TypeOrmTransaction(transactionManager) };
await repository.upsertByKey('instanceAi.settings', '{"enabled":true}', true, ctx);
expect(transactionManager.upsert).toHaveBeenCalledWith(
Settings,
{
key: 'instanceAi.settings',
value: '{"enabled":true}',
loadOnStartup: true,
},
['key'],
);
});
});

View File

@ -1,29 +1,119 @@
import { Container, Service } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import type { FindManyOptions, SelectQueryBuilder } from '@n8n/typeorm';
import { DataSource, In, Like, Repository } from '@n8n/typeorm';
import { DataSource, In, Like, Not, QueryFailedError } from '@n8n/typeorm';
import { CredentialsEntity, type User } from '../entities';
import { BaseRepository } from './base-repository';
import {
addCredentialDependencyExistsFilter,
type CredentialDependencyFilter,
} from './credential-dependency.repository';
import { InstanceCredentialAssignmentRepository } from './instance-credential-assignment.repository';
import { SharedCredentialsRepository } from './shared-credentials.repository';
import type { ListQuery } from '../entities/types-db';
import type { ICredentialsDb, ListQuery } from '../entities/types-db';
import type { OperationContext } from '../services/transaction';
@Service()
export class CredentialsRepository extends Repository<CredentialsEntity> {
constructor(dataSource: DataSource) {
export class CredentialsRepository extends BaseRepository<CredentialsEntity> {
constructor(
dataSource: DataSource,
private readonly instanceCredentialAssignmentRepository: InstanceCredentialAssignmentRepository,
) {
super(CredentialsEntity, dataSource.manager);
}
async findStartingWith(credentialName: string) {
return await this.find({
select: ['name'],
where: { name: Like(`${credentialName}%`) },
where: { name: Like(`${credentialName}%`), usageScope: 'project' },
});
}
async findNonProjectCredentialsByIds(ids: string[]): Promise<CredentialsEntity[]> {
return await this.find({
where: { id: In(ids), usageScope: Not('project') },
select: ['id'],
});
}
async findDanglingProjectCredentials(): Promise<CredentialsEntity[]> {
return await this.createQueryBuilder('credentials')
.leftJoinAndSelect('credentials.shared', 'shared')
.where('shared.credentialsId is null')
.andWhere('credentials.usageScope = :usageScope', { usageScope: 'project' })
.getMany();
}
async findInstanceCredentialById(
credentialId: string,
ctx: OperationContext,
): Promise<CredentialsEntity | null> {
return await this.managerFor(ctx).findOneBy(CredentialsEntity, {
id: credentialId,
usageScope: 'instance',
});
}
async saveInstanceCredential(
credential: CredentialsEntity,
ctx: OperationContext,
): Promise<CredentialsEntity> {
return await this.managerFor(ctx).save(CredentialsEntity, credential);
}
async updateInstanceCredential(
credentialId: string,
data: Pick<ICredentialsDb, 'id' | 'name' | 'type' | 'data'>,
ctx: OperationContext,
): Promise<CredentialsEntity | null> {
const manager = this.managerFor(ctx);
await manager.update(CredentialsEntity, { id: credentialId, usageScope: 'instance' }, data);
return await manager.findOneBy(CredentialsEntity, {
id: credentialId,
usageScope: 'instance',
});
}
async deleteInstanceCredentialIfUnassigned(
credentialId: string,
ctx: OperationContext = {},
): Promise<
| { status: 'deleted' }
| { status: 'notFound' }
| { status: 'assigned'; credentialUseIds: string[] }
> {
const manager = this.managerFor(ctx);
const credential = await manager.findOneBy(CredentialsEntity, {
id: credentialId,
usageScope: 'instance',
});
if (!credential) return { status: 'notFound' };
const credentialUseIds = await this.instanceCredentialAssignmentRepository.findCredentialUseIds(
credentialId,
ctx,
);
if (credentialUseIds.length > 0) return { status: 'assigned', credentialUseIds };
try {
const result = await manager.delete(CredentialsEntity, {
id: credentialId,
usageScope: 'instance',
});
return result.affected === 0 ? { status: 'notFound' } : { status: 'deleted' };
} catch (error) {
if (error instanceof QueryFailedError && !ctx.trx) {
const concurrentCredentialUseIds =
await this.instanceCredentialAssignmentRepository.findCredentialUseIds(credentialId, ctx);
if (concurrentCredentialUseIds.length > 0) {
return { status: 'assigned', credentialUseIds: concurrentCredentialUseIds };
}
}
throw error;
}
}
async findMany(
listQueryOptions?: ListQuery.Options & {
includeData?: boolean;
@ -39,7 +129,7 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
findManyOptions.where = { ...findManyOptions.where, id: In(credentialIds) };
}
return await this.find(findManyOptions);
return await this.find(this.onlyProjectCredentials(findManyOptions));
}
async findManyAndCount(
@ -57,7 +147,14 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
findManyOptions.where = { ...findManyOptions.where, id: In(credentialIds) };
}
return await this.findAndCount(findManyOptions);
return await this.findAndCount(this.onlyProjectCredentials(findManyOptions));
}
private onlyProjectCredentials(
findManyOptions: FindManyOptions<CredentialsEntity>,
): FindManyOptions<CredentialsEntity> {
findManyOptions.where = { ...findManyOptions.where, usageScope: 'project' };
return findManyOptions;
}
private toFindManyOptions(
@ -172,7 +269,9 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
}
async getManyByIds(ids: string[], { withSharings } = { withSharings: false }) {
const findManyOptions: FindManyOptions<CredentialsEntity> = { where: { id: In(ids) } };
const findManyOptions: FindManyOptions<CredentialsEntity> = {
where: { id: In(ids), usageScope: 'project' },
};
if (withSharings) {
findManyOptions.relations = {
@ -212,6 +311,7 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
findManyOptions.where = {
...findManyOptions.where,
isGlobal: true,
usageScope: 'project',
...(type ? { type: Like(`%${type}%`) } : {}),
};
return await this.find(findManyOptions);
@ -226,6 +326,7 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
const qb = this.createQueryBuilder('credential');
qb.where('credential.isGlobal = :isGlobal', { isGlobal: true });
qb.andWhere('credential.usageScope = :usageScope', { usageScope: 'project' });
if (type) {
qb.andWhere('credential.type LIKE :type', { type: `%${type}%` });
}
@ -259,7 +360,10 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
* Find all credentials that are owned by a personal project.
*/
async findAllPersonalCredentials(): Promise<CredentialsEntity[]> {
return await this.findBy({ shared: { project: { type: 'personal' } } });
return await this.findBy({
usageScope: 'project',
shared: { project: { type: 'personal' } },
});
}
/**
@ -271,6 +375,7 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
*/
async findAllCredentialsForWorkflow(workflowId: string): Promise<CredentialsEntity[]> {
return await this.findBy({
usageScope: 'project',
shared: { project: { sharedWorkflows: { workflowId } } },
});
}
@ -282,7 +387,7 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
* are part of this project.
*/
async findAllCredentialsForProject(projectId: string): Promise<CredentialsEntity[]> {
return await this.findBy({ shared: { projectId } });
return await this.findBy({ usageScope: 'project', shared: { projectId } });
}
/**
@ -294,7 +399,7 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
type: string,
projectId: string,
): Promise<CredentialsEntity[]> {
return await this.findBy({ name, type, shared: { projectId } });
return await this.findBy({ name, type, usageScope: 'project', shared: { projectId } });
}
/**
@ -360,6 +465,7 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
} = {},
): SelectQueryBuilder<CredentialsEntity> {
const qb = this.createQueryBuilder('credential');
qb.andWhere('credential.usageScope = :usageScope', { usageScope: 'project' });
if (options.filters?.dependency) {
addCredentialDependencyExistsFilter(qb, options.filters.dependency);

View File

@ -35,6 +35,7 @@ export { FolderRepository } from './folder.repository';
export { FolderTagMappingRepository } from './folder-tag-mapping.repository';
export { ScopeRepository } from './scope.repository';
export { InvalidAuthTokenRepository } from './invalid-auth-token.repository';
export { InstanceCredentialAssignmentRepository } from './instance-credential-assignment.repository';
export { LicenseMetricsRepository } from './license-metrics.repository';
export { ProjectRelationRepository } from './project-relation.repository';
export { ProjectRepository, type ProjectListOptions } from './project.repository';

View File

@ -0,0 +1,118 @@
import { Service } from '@n8n/di';
import { DataSource, In, QueryFailedError, type EntityManager } from '@n8n/typeorm';
import { CredentialsEntity, InstanceCredentialAssignment } from '../entities';
import { BaseRepository } from './base-repository';
import type { OperationContext } from '../services/transaction';
@Service()
export class InstanceCredentialAssignmentRepository extends BaseRepository<InstanceCredentialAssignment> {
constructor(dataSource: DataSource) {
super(InstanceCredentialAssignment, dataSource.manager);
}
async findAvailableCredentials(
credentialTypes: readonly string[],
ctx: OperationContext = {},
): Promise<CredentialsEntity[]> {
return await this.managerFor(ctx).find(CredentialsEntity, {
select: ['id', 'name', 'type'],
where: {
usageScope: 'instance',
type: In([...credentialTypes]),
},
order: { name: 'ASC' },
});
}
async assignCredential(
credentialUseId: string,
credentialId: string,
credentialTypes: readonly string[],
ctx: OperationContext = {},
): Promise<CredentialsEntity | null> {
const manager = this.managerFor(ctx);
const credential = await this.findCredential(
credentialId,
credentialTypes,
manager,
ctx.trx !== undefined,
);
if (!credential) return null;
try {
await manager.upsert(
InstanceCredentialAssignment,
{ credentialUseId, credentialId, updatedAt: new Date() },
['credentialUseId'],
);
} catch (error) {
// Distinguish a concurrent deletion from a transient database failure.
if (error instanceof QueryFailedError) {
const stillExists = await this.findCredential(credentialId, credentialTypes, manager)
.then((found) => Boolean(found))
.catch(() => true);
if (!stillExists) return null;
}
throw error;
}
return credential;
}
async clearCredential(credentialUseId: string, ctx: OperationContext = {}): Promise<void> {
const manager = this.managerFor(ctx);
await manager.delete(InstanceCredentialAssignment, { credentialUseId });
}
async findAssignedCredentialId(
credentialUseId: string,
ctx: OperationContext = {},
): Promise<string | null> {
const manager = this.managerFor(ctx);
const assignment = await manager.findOneBy(InstanceCredentialAssignment, { credentialUseId });
return assignment?.credentialId ?? null;
}
async findCredentialUseIds(credentialId: string, ctx: OperationContext = {}): Promise<string[]> {
const assignments = await this.managerFor(ctx).find(InstanceCredentialAssignment, {
select: ['credentialUseId'],
where: { credentialId },
order: { credentialUseId: 'ASC' },
});
return assignments.map(({ credentialUseId }) => credentialUseId);
}
async findAssignedCredential(
credentialUseId: string,
credentialTypes: readonly string[],
ctx: OperationContext = {},
): Promise<{ credentialId: string; credential: CredentialsEntity | null } | null> {
const manager = this.managerFor(ctx);
const assignment = await manager.findOneBy(InstanceCredentialAssignment, { credentialUseId });
if (!assignment) return null;
return {
credentialId: assignment.credentialId,
credential: await this.findCredential(assignment.credentialId, credentialTypes, manager),
};
}
private async findCredential(
credentialId: string,
credentialTypes: readonly string[],
manager: EntityManager,
lock = false,
): Promise<CredentialsEntity | null> {
return await manager.findOne(CredentialsEntity, {
where: {
id: credentialId,
usageScope: 'instance',
type: In([...credentialTypes]),
},
...(lock && manager.connection.options.type === 'postgres'
? { lock: { mode: 'pessimistic_write' as const } }
: {}),
});
}
}

View File

@ -1,11 +1,13 @@
import { Service } from '@n8n/di';
import type { EntityManager } from '@n8n/typeorm';
import { DataSource, In, Like, Repository } from '@n8n/typeorm';
import { DataSource, In, Like } from '@n8n/typeorm';
import { Settings } from '../entities';
import { BaseRepository } from './base-repository';
import type { OperationContext } from '../services/transaction';
@Service()
export class SettingsRepository extends Repository<Settings> {
export class SettingsRepository extends BaseRepository<Settings> {
constructor(dataSource: DataSource) {
super(Settings, dataSource.manager);
}
@ -14,6 +16,20 @@ export class SettingsRepository extends Repository<Settings> {
const manager = em ?? this.manager;
return await manager.findOneBy(Settings, { key });
}
async findByKeyInContext(key: string, ctx: OperationContext): Promise<Settings | null> {
return await this.managerFor(ctx).findOneBy(Settings, { key });
}
async upsertByKey(
key: string,
value: string,
loadOnStartup: boolean,
ctx: OperationContext,
): Promise<void> {
await this.managerFor(ctx).upsert(Settings, { key, value, loadOnStartup }, ['key']);
}
async findByKeys(keys: string[]): Promise<Settings[]> {
return await this.findBy({ key: In(keys) });
}

View File

@ -5,6 +5,7 @@ import { OperationalError } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import { DbLockService } from '../db-lock.service';
import { TypeOrmTransaction } from '../typeorm-transaction';
describe('DbLockService', () => {
const mockTx = mock<EntityManager>();
@ -33,7 +34,9 @@ describe('DbLockService', () => {
expect(result).toBe('result');
expect(mockTx.query).toHaveBeenCalledWith('SELECT pg_advisory_xact_lock($1)', [1001]);
expect(fn).toHaveBeenCalledWith(mockTx);
expect(fn).toHaveBeenCalledWith(mockTx, {
trx: expect.any(TypeOrmTransaction) as TypeOrmTransaction,
});
});
it('should skip advisory lock on SQLite and still execute fn', async () => {
@ -44,7 +47,9 @@ describe('DbLockService', () => {
expect(result).toBe('result');
expect(mockTx.query).not.toHaveBeenCalled();
expect(fn).toHaveBeenCalledWith(mockTx);
expect(fn).toHaveBeenCalledWith(mockTx, {
trx: expect.any(TypeOrmTransaction) as TypeOrmTransaction,
});
});
it('should set lock_timeout when timeoutMs is provided', async () => {
@ -112,7 +117,9 @@ describe('DbLockService', () => {
'SELECT pg_advisory_xact_lock($1, $2)',
[1005, -12345],
);
expect(fn).toHaveBeenCalledWith(mockTx);
expect(fn).toHaveBeenCalledWith(mockTx, {
trx: expect.any(TypeOrmTransaction) as TypeOrmTransaction,
});
});
it('should include both keys in the timeout error message when subKey is provided', async () => {
@ -177,6 +184,18 @@ describe('DbLockService', () => {
});
});
describe('withLockContext', () => {
it('provides an opaque context for the locked transaction', async () => {
databaseConfig.type = 'sqlite';
const fn = vi.fn().mockResolvedValue('result');
await expect(service.withLockContext(1001, fn)).resolves.toBe('result');
expect(fn).toHaveBeenCalledWith({
trx: expect.any(TypeOrmTransaction) as TypeOrmTransaction,
});
});
});
describe('tryWithLock', () => {
it('should execute fn when lock is acquired', async () => {
databaseConfig.type = 'postgresdb';

View File

@ -5,6 +5,9 @@ import { DataSource, QueryFailedError } from '@n8n/typeorm';
import type { EntityManager } from '@n8n/typeorm';
import { OperationalError } from 'n8n-workflow';
import type { OperationContext } from './transaction';
import { TypeOrmTransaction } from './typeorm-transaction';
/**
* Centrally managed advisory lock IDs. Every Postgres advisory lock
* used in the application MUST be registered here to prevent collisions.
@ -16,6 +19,7 @@ export const enum DbLock {
WORKFLOW_REVIEW_REQUEST_CREATE = 1004,
MIGRATIONS = 1005,
EVAL_COLLECTION_RERUN = 1006,
INSTANCE_AI_SETTINGS = 1007,
/** Reserved for integration tests — never use in production code */
TEST = 9999,
}
@ -123,13 +127,15 @@ export class DbLockService {
*/
async withLock<T>(
lockId: DbLock,
fn: (tx: EntityManager) => Promise<T>,
fn: (tx: EntityManager, ctx: OperationContext) => Promise<T>,
options?: WithLockOptions,
): Promise<T> {
if (this.databaseConfig.type !== 'postgresdb') {
const release = await this.acquireLock(lockId, options?.timeoutMs, options?.subKey);
try {
return await this.dataSource.manager.transaction(async (tx) => await fn(tx));
return await this.dataSource.manager.transaction(
async (tx) => await fn(tx, { trx: new TypeOrmTransaction(tx) }),
);
} finally {
release();
}
@ -165,10 +171,18 @@ export class DbLockService {
}
throw error;
}
return await fn(tx);
return await fn(tx, { trx: new TypeOrmTransaction(tx) });
});
}
async withLockContext<T>(
lockId: DbLock,
fn: (ctx: OperationContext) => Promise<T>,
options?: { timeoutMs?: number },
): Promise<T> {
return await this.withLock(lockId, async (_manager, ctx) => await fn(ctx), options);
}
/**
* Execute `fn` inside a database transaction, but only if the advisory
* lock (Postgres) or in-process mutex (SQLite) can be acquired immediately.

View File

@ -34,6 +34,7 @@ export type PubSubEventName =
| 'reload-source-control-config'
| 'reload-mcp-registry'
| 'reload-otel-config'
| 'reload-instance-ai-settings'
| 'cancel-test-run'
| 'cancel-collection'
| 'agent-chat-integration-changed'

View File

@ -31,6 +31,7 @@ exports[`Scope Information > ensure scopes are defined correctly 1`] = `
"credential:move",
"credential:connect",
"credential:createEndUser",
"credential:manageInstance",
"credential:create",
"credential:read",
"credential:update",

View File

@ -15,6 +15,7 @@ export const RESOURCES = {
'move',
'connect',
'createEndUser',
'manageInstance',
...DEFAULT_OPERATIONS,
] as const,
externalSecretsProvider: ['sync', ...DEFAULT_OPERATIONS] as const,

View File

@ -29,6 +29,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
'credential:move',
'credential:connect',
'credential:createEndUser',
'credential:manageInstance',
'community:register',
'communityPackage:install',
'communityPackage:uninstall',

View File

@ -103,6 +103,11 @@ export const scopeInformation: Partial<Record<Scope, ScopeInformation>> = {
description:
"Allows creating, deleting, and changing the type of end-user credentials, which resolve to each user's own connection.",
},
'credential:manageInstance': {
displayName: 'Manage provider connections',
description:
'Allows creating, updating, and deleting provider connections used by instance-level features. These connections are not available in workflows.',
},
'insights:read': {
displayName: 'Read Insights',
description: 'Allows reading insights data.',

View File

@ -101,6 +101,21 @@ describe('CredentialsHelper', () => {
credentialsHelper.getCredentials({ id: '1', name: 'foo' }, 'bar'),
).rejects.toThrow(errorMessage);
});
test('rejects credentials that are not available to workflows', async () => {
credentialsRepository.findOneByOrFail.mockResolvedValueOnce(
mock<CredentialsEntity>({
id: '1',
name: 'Instance credential',
type: 'bar',
usageScope: 'instance',
}),
);
await expect(
credentialsHelper.getCredentials({ id: '1', name: 'foo' }, 'bar'),
).rejects.toThrow('This credential cannot be used in workflows');
});
});
describe('applyDefaultsAndOverwrites', () => {
@ -555,6 +570,7 @@ describe('CredentialsHelper', () => {
name: 'Test OAuth2 Credential',
type: 'oAuth2Api',
data: cipher.encrypt(existingCredentialData),
usageScope: 'project',
};
credentialsRepository.findOneByOrFail.mockResolvedValue(
@ -659,6 +675,7 @@ describe('CredentialsHelper', () => {
data: cipher.encrypt(existingCredentialData),
isResolvable: true,
resolverId: 'resolver-123',
usageScope: 'project',
} as CredentialsEntity;
credentialsRepository.findOneByOrFail.mockResolvedValue(mockCredentialEntity);
@ -708,6 +725,7 @@ describe('CredentialsHelper', () => {
data: cipher.encrypt(existingCredentialData),
isResolvable: true,
resolverId: null,
usageScope: 'project',
} as unknown as CredentialsEntity;
credentialsRepository.findOneByOrFail.mockResolvedValue(mockCredentialEntity);
@ -762,6 +780,7 @@ describe('CredentialsHelper', () => {
data: cipher.encrypt(existingCredentialData),
isResolvable: true,
resolverId: 'resolver-123',
usageScope: 'project',
} as CredentialsEntity;
credentialsRepository.findOneByOrFail.mockResolvedValue(mockCredentialEntity);
@ -813,6 +832,7 @@ describe('CredentialsHelper', () => {
data: cipher.encrypt(existingCredentialData),
isResolvable: true,
resolverId: 'resolver-123',
usageScope: 'project',
} as CredentialsEntity;
credentialsRepository.findOneByOrFail.mockResolvedValue(mockCredentialEntity);
@ -1012,6 +1032,7 @@ describe('CredentialsHelper', () => {
type: 'testApi',
data: cipher.encrypt({ apiKey: 'test' }),
isResolvable: false,
usageScope: 'project',
} as CredentialsEntity;
beforeEach(() => {
@ -1097,6 +1118,7 @@ describe('CredentialsHelper', () => {
type: credentialType,
data: cipher.encrypt({ apiKey: 'static-key' }),
isResolvable: false,
usageScope: 'project',
} as CredentialsEntity;
beforeEach(() => {
@ -1555,6 +1577,7 @@ describe('CredentialsHelper', () => {
data: cipher.encrypt(credentialDataA),
isResolvable: false,
resolverId: null,
usageScope: 'project',
} as CredentialsEntity;
const credEntityB = {
@ -1564,6 +1587,7 @@ describe('CredentialsHelper', () => {
data: cipher.encrypt(credentialDataB),
isResolvable: false,
resolverId: null,
usageScope: 'project',
} as CredentialsEntity;
const additionalData = {

View File

@ -1,10 +1,13 @@
import {
CredentialsEntity,
DbLock,
DbLockService,
Project,
User,
SharedCredentials,
ProjectRepository,
GLOBAL_OWNER_ROLE,
type OperationContext,
} from '@n8n/db';
import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di';
@ -15,10 +18,11 @@ import fs from 'fs';
import omit from 'lodash/omit';
import pick from 'lodash/pick';
import { Cipher } from 'n8n-core';
import { jsonParse, UserError } from 'n8n-workflow';
import { jsonParse, UserError, type ICredentialDataDecryptedObject } from 'n8n-workflow';
import { z } from 'zod';
import { UM_FIX_INSTRUCTION } from '@/constants';
import { CredentialsService } from '@/credentials/credentials.service';
import { BaseCommand } from '../base-command';
@ -62,6 +66,9 @@ type ImportableCredentialProperty = Exclude<
'shared' | 'toJSON' | 'generateId' | 'setUpdateDate'
>;
const isCredentialData = (data: unknown): data is ICredentialDataDecryptedObject =>
typeof data === 'object' && data !== null && !Array.isArray(data);
@Command({
name: 'import:credentials',
description: 'Import credentials',
@ -77,8 +84,6 @@ type ImportableCredentialProperty = Exclude<
flagsSchema,
})
export class ImportCredentialsCommand extends BaseCommand<z.infer<typeof flagsSchema>> {
private transactionManager: EntityManager;
async run(): Promise<void> {
const { flags } = this;
@ -118,22 +123,27 @@ export class ImportCredentialsCommand extends BaseCommand<z.infer<typeof flagsSc
exclude,
});
const { manager: dbManager } = Container.get(ProjectRepository);
await dbManager.transaction(async (transactionManager) => {
this.transactionManager = transactionManager;
await Container.get(DbLockService).withLock(
DbLock.INSTANCE_AI_SETTINGS,
async (transactionManager, ctx) => {
const project = await this.getProject(transactionManager, flags.userId, flags.projectId);
const project = await this.getProject(flags.userId, flags.projectId);
const result = await this.checkRelations(
transactionManager,
credentials,
flags.projectId,
flags.userId,
);
const result = await this.checkRelations(credentials, flags.projectId, flags.userId);
if (!result.success) {
throw new UserError(result.message);
}
if (!result.success) {
throw new UserError(result.message);
}
for (const credential of credentials) {
await this.storeCredential(credential, project);
}
});
for (const credential of credentials) {
await this.storeCredential(transactionManager, credential, project, ctx);
}
},
);
this.reportSuccess(credentials.length);
}
@ -151,19 +161,75 @@ export class ImportCredentialsCommand extends BaseCommand<z.infer<typeof flagsSc
);
}
private async storeCredential(credential: Partial<CredentialsEntity>, project: Project) {
const result = await this.transactionManager.upsert(CredentialsEntity, credential, ['id']);
private async storeCredential(
transactionManager: EntityManager,
credential: Partial<CredentialsEntity>,
project: Project,
ctx: OperationContext,
) {
// UsageScope is instance-local state; imports never change it for existing credentials.
let existing: Pick<CredentialsEntity, 'id' | 'type' | 'usageScope'> | null = null;
if (credential.id) {
existing = await transactionManager.findOne(CredentialsEntity, {
where: { id: credential.id },
select: ['id', 'usageScope', 'type'],
});
if (existing) {
credential.usageScope = existing.usageScope;
if (
existing.usageScope === 'instance' &&
credential.type !== undefined &&
credential.type !== existing.type
) {
throw new UserError(
'Provider connection type cannot be changed. Create a new connection instead.',
);
}
}
}
credential.usageScope ??= 'project';
const sharingExists = await this.transactionManager.existsBy(SharedCredentials, {
credentialsId: credential.id,
if (credential.usageScope === 'instance') {
if (
credential.isGlobal ||
credential.isResolvable ||
credential.isManaged ||
credential.resolvableAllowFallback ||
credential.resolverId
) {
throw new UserError(
'Provider connections cannot be global, managed, or dynamically resolved',
);
}
Object.assign(credential, {
isGlobal: false,
isResolvable: false,
isManaged: false,
resolvableAllowFallback: false,
resolverId: null,
});
await this.validateInstanceCredentialData(transactionManager, credential, existing, ctx);
}
const result = await transactionManager.upsert(CredentialsEntity, credential, ['id']);
const credentialsId = credential.id ?? (result.identifiers[0].id as string);
if (credential.usageScope === 'instance') {
// Instance credentials are instance-owned and must not retain project sharing rows.
await transactionManager.delete(SharedCredentials, { credentialsId });
return;
}
const sharingExists = await transactionManager.existsBy(SharedCredentials, {
credentialsId,
role: 'credential:owner',
});
if (!sharingExists) {
await this.transactionManager.upsert(
await transactionManager.upsert(
SharedCredentials,
{
credentialsId: result.identifiers[0].id as string,
credentialsId,
role: 'credential:owner',
projectId: project.id,
},
@ -172,7 +238,48 @@ export class ImportCredentialsCommand extends BaseCommand<z.infer<typeof flagsSc
}
}
private async validateInstanceCredentialData(
transactionManager: EntityManager,
credential: Partial<CredentialsEntity>,
existing: Pick<CredentialsEntity, 'id' | 'type' | 'usageScope'> | null,
ctx: OperationContext,
) {
let data: unknown = credential.data;
if (data === undefined && credential.id) {
data = (
await transactionManager.findOne(CredentialsEntity, {
where: { id: credential.id },
select: { data: true },
})
)?.data;
}
if (data === undefined) return;
if (data === null || data === '') {
throw new UserError('Provider connection data cannot be empty');
}
const decrypted =
typeof data === 'string'
? jsonParse<unknown>(await Container.get(Cipher).decryptV2(data))
: data;
if (!isCredentialData(decrypted)) {
throw new UserError('Provider connection data must be a JSON object');
}
const credentialsService = Container.get(CredentialsService);
if (existing?.usageScope === 'instance') {
await credentialsService.validateInstanceCredentialUpdate(
existing,
decrypted,
undefined,
ctx,
);
} else {
credentialsService.validateInstanceCredentialData(decrypted);
}
}
private async checkRelations(
transactionManager: EntityManager,
credentials: Array<Pick<Partial<CredentialsEntity>, 'id'>>,
projectId?: string,
userId?: string,
@ -190,11 +297,14 @@ export class ImportCredentialsCommand extends BaseCommand<z.infer<typeof flagsSc
continue;
}
if (!(await this.credentialExists(credential.id))) {
if (!(await this.credentialExists(transactionManager, credential.id))) {
continue;
}
const { user, project: ownerProject } = await this.getCredentialOwner(credential.id);
const { user, project: ownerProject } = await this.getCredentialOwner(
transactionManager,
credential.id,
);
if (!ownerProject) {
continue;
@ -267,7 +377,7 @@ export class ImportCredentialsCommand extends BaseCommand<z.infer<typeof flagsSc
return await Promise.all(
credentials.map(async (credential) => {
const filteredCredential = this.filterCredentialProperties(credential, include, exclude);
if (typeof filteredCredential.data === 'object') {
if (isCredentialData(filteredCredential.data)) {
// plain data / decrypted input. Should be encrypted first.
filteredCredential.data = await cipher.encryptV2(filteredCredential.data);
}
@ -350,19 +460,20 @@ export class ImportCredentialsCommand extends BaseCommand<z.infer<typeof flagsSc
isResolvable: true,
resolvableAllowFallback: true,
resolverId: true,
usageScope: true,
} satisfies Record<ImportableCredentialProperty, true>;
return property in importableProperties;
}
private async getCredentialOwner(credentialsId: string) {
const sharedCredential = await this.transactionManager.findOne(SharedCredentials, {
private async getCredentialOwner(transactionManager: EntityManager, credentialsId: string) {
const sharedCredential = await transactionManager.findOne(SharedCredentials, {
where: { credentialsId, role: 'credential:owner' },
relations: { project: true },
});
if (sharedCredential && sharedCredential.project.type === 'personal') {
const user = await this.transactionManager.findOneByOrFail(User, {
const user = await transactionManager.findOneByOrFail(User, {
projectRelations: {
role: { slug: PROJECT_OWNER_ROLE_SLUG },
projectId: sharedCredential.projectId,
@ -375,17 +486,17 @@ export class ImportCredentialsCommand extends BaseCommand<z.infer<typeof flagsSc
return {};
}
private async credentialExists(credentialId: string) {
return await this.transactionManager.existsBy(CredentialsEntity, { id: credentialId });
private async credentialExists(transactionManager: EntityManager, credentialId: string) {
return await transactionManager.existsBy(CredentialsEntity, { id: credentialId });
}
private async getProject(userId?: string, projectId?: string) {
private async getProject(transactionManager: EntityManager, userId?: string, projectId?: string) {
if (projectId) {
return await this.transactionManager.findOneByOrFail(Project, { id: projectId });
return await transactionManager.findOneByOrFail(Project, { id: projectId });
}
if (!userId) {
const owner = await this.transactionManager.findOneBy(User, {
const owner = await transactionManager.findOneBy(User, {
role: {
slug: GLOBAL_OWNER_ROLE.slug,
},
@ -398,7 +509,7 @@ export class ImportCredentialsCommand extends BaseCommand<z.infer<typeof flagsSc
return await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
userId,
this.transactionManager,
transactionManager,
);
}
}

View File

@ -1,4 +1,3 @@
import type { CredentialsEntity } from '@n8n/db';
import {
User,
CredentialsRepository,
@ -39,11 +38,8 @@ export class Reset extends BaseCommand {
await Container.get(UserRepository).deleteAllExcept(owner);
await Container.get(UserRepository).save(Object.assign(owner, defaultUserProps));
const danglingCredentials: CredentialsEntity[] = await Container.get(CredentialsRepository)
.createQueryBuilder('credentials')
.leftJoinAndSelect('credentials.shared', 'shared')
.where('shared.credentialsId is null')
.getMany();
const danglingCredentials =
await Container.get(CredentialsRepository).findDanglingProjectCredentials();
const newSharedCredentials = danglingCredentials.map((credentials) =>
Container.get(SharedCredentialsRepository).create({
credentials,

View File

@ -33,6 +33,7 @@ import {
NodeHelpers,
Workflow,
UnexpectedError,
UserError,
isExpression,
} from 'n8n-workflow';
@ -308,6 +309,11 @@ export class CredentialsHelper extends ICredentialsHelper {
throw error;
}
// Keep non-project credentials blocked even if an earlier access check is bypassed.
if (credential.usageScope !== 'project') {
throw new UserError('This credential cannot be used in workflows');
}
return credential;
}

View File

@ -62,6 +62,9 @@ describe('CredentialsController', () => {
mock(), // externalSecretsConfig
mock(), // externalSecretsProviderAccessCheckService
mock(), // connectionStatusProxy
mock(), // instanceCredentialAssignmentRepository
mock(), // instanceCredentialUseRegistry
mock(), // dbLockService
);
// Spy on methods that need to be mocked in tests
@ -69,6 +72,7 @@ describe('CredentialsController', () => {
// for isChangingExternalSecretExpression and validateExternalSecretsPermissions
let decryptSpy: MockInstance;
let createEncryptedDataSpy: MockInstance;
let prepareUpdateDataSpy: MockInstance;
let getCredentialScopesSpy: MockInstance;
let updateSpy: MockInstance;
let createUnmanagedCredentialSpy: MockInstance;
@ -102,6 +106,7 @@ describe('CredentialsController', () => {
vi.resetAllMocks();
decryptSpy = vi.spyOn(credentialsService, 'decrypt');
createEncryptedDataSpy = vi.spyOn(credentialsService, 'createEncryptedData');
prepareUpdateDataSpy = vi.spyOn(credentialsService, 'prepareUpdateData');
getCredentialScopesSpy = vi.spyOn(credentialsService, 'getCredentialScopes');
updateSpy = vi.spyOn(credentialsService, 'update');
createUnmanagedCredentialSpy = vi.spyOn(credentialsService, 'createUnmanagedCredential');
@ -355,6 +360,89 @@ describe('CredentialsController', () => {
});
});
it('should accept unchanged false flags when editing an instance credential', async () => {
const instanceCredential = mock<CredentialsEntity>({
...existingCredential,
usageScope: 'instance',
shared: [],
});
const ownerReq = {
user: { id: 'owner-id', role: GLOBAL_OWNER_ROLE },
params: { credentialId },
body: {
name: 'Updated Credential',
type: 'apiKey',
data: { apiKey: 'updated-key' },
isGlobal: false,
isResolvable: false,
},
} as unknown as CredentialRequest.Update;
credentialsFinderService.findCredentialForUser.mockResolvedValue(instanceCredential);
prepareUpdateDataSpy.mockResolvedValue(ownerReq.body);
updateSpy.mockResolvedValue(instanceCredential);
await expect(credentialsController.updateCredentials(ownerReq)).resolves.toBeDefined();
expect(credentialsFinderService.findCredentialForUser).toHaveBeenCalledWith(
credentialId,
ownerReq.user,
['credential:update'],
{ includeInstanceCredentials: true },
);
});
it.each([{ isGlobal: true }, { isResolvable: true }])(
'should reject converting an instance credential with %o',
async (flag) => {
const instanceCredential = mock<CredentialsEntity>({
...existingCredential,
usageScope: 'instance',
shared: [],
});
const ownerReq = {
user: { id: 'owner-id', role: GLOBAL_OWNER_ROLE },
params: { credentialId },
body: {
name: 'Updated Credential',
type: 'apiKey',
data: { apiKey: 'updated-key' },
isGlobal: false,
isResolvable: false,
...flag,
},
} as unknown as CredentialRequest.Update;
credentialsFinderService.findCredentialForUser.mockResolvedValue(instanceCredential);
await expect(credentialsController.updateCredentials(ownerReq)).rejects.toThrow(
BadRequestError,
);
expect(updateSpy).not.toHaveBeenCalled();
},
);
it('should reject changing the type of an instance credential', async () => {
const instanceCredential = mock<CredentialsEntity>({
...existingCredential,
type: 'apiKey',
usageScope: 'instance',
shared: [],
});
const ownerReq = {
user: { id: 'owner-id', role: GLOBAL_OWNER_ROLE },
params: { credentialId },
body: {
name: 'Updated Credential',
type: 'httpHeaderAuth',
data: { name: 'x-api-key', value: 'secret' },
},
} as unknown as CredentialRequest.Update;
credentialsFinderService.findCredentialForUser.mockResolvedValue(instanceCredential);
await expect(credentialsController.updateCredentials(ownerReq)).rejects.toThrow(
'Provider connection type cannot be changed',
);
expect(prepareUpdateDataSpy).not.toHaveBeenCalled();
});
it('should emit "credentials-updated" with jweEnabled true when JWE is enabled in payload', async () => {
// ARRANGE
const ownerReq = {

View File

@ -47,6 +47,24 @@ describe('EnterpriseCredentialsService', () => {
vi.resetAllMocks();
});
describe('shareWithProjects', () => {
it('rejects credentials that are not available to workflows', async () => {
const manager = {
exists: vi.fn().mockResolvedValue(false),
find: vi.fn(),
save: vi.fn(),
};
// @ts-expect-error - Mocking manager for testing
sharedCredentialsRepository.manager = manager;
await expect(
service.shareWithProjects(mock<User>(), 'credential-id', ['project-id']),
).rejects.toThrow('Credential not found');
expect(manager.find).not.toHaveBeenCalled();
});
});
/**
* Helper function to mock the transaction manager for credential transfer tests
*/
@ -265,6 +283,7 @@ describe('EnterpriseCredentialsService', () => {
credentialId,
user,
['credential:connect'],
{ includeInstanceCredentials: true },
);
expect(result).toHaveProperty('data', redacted);
});
@ -297,6 +316,7 @@ describe('EnterpriseCredentialsService', () => {
credentialId,
user,
['credential:connect'],
{ includeInstanceCredentials: true },
);
expect(result).not.toHaveProperty('data');
});

View File

@ -1,16 +1,18 @@
import type { Logger } from '@n8n/backend-common';
import type {
CredentialsEntity,
CredentialsRepository,
ICredentialsDb,
InstanceCredentialAssignmentRepository,
SharedCredentialsRepository,
ProjectRepository,
UserRepository,
User,
SharedCredentials,
ListQueryDb,
DbLockService,
} from '@n8n/db';
import { GLOBAL_OWNER_ROLE, GLOBAL_MEMBER_ROLE } from '@n8n/db';
import { CredentialsEntity, DbLock, GLOBAL_OWNER_ROLE, GLOBAL_MEMBER_ROLE } from '@n8n/db';
import type { EntityManager } from '@n8n/typeorm';
import { CREDENTIAL_ERRORS, CredentialDataError, Credentials, type ErrorReporter } from 'n8n-core';
import {
CREDENTIAL_BLANKING_VALUE,
@ -27,6 +29,7 @@ import type { CredentialConnectionStatusProxy } from '@/credentials/credential-c
import type { CredentialDependencyService } from '@/credentials/credential-dependency.service';
import type { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { CredentialsService } from '@/credentials/credentials.service';
import type { InstanceCredentialUseRegistry } from '@/credentials/instance-credential-use.registry';
import * as validation from '@/credentials/validation';
import type { CredentialsHelper } from '@/credentials-helper';
import { CredentialNotFoundError } from '@/errors/credential-not-found.error';
@ -81,6 +84,9 @@ describe('CredentialsService', () => {
const externalSecretsConfig = mock<ExternalSecretsConfig>();
const externalSecretsProviderAccessCheckService = mock<SecretsProviderAccessCheckService>();
const connectionStatusProxy = mock<CredentialConnectionStatusProxy>();
const instanceCredentialAssignmentRepository = mock<InstanceCredentialAssignmentRepository>();
const instanceCredentialUseRegistry = mock<InstanceCredentialUseRegistry>();
const dbLockService = mock<DbLockService>();
const service = new CredentialsService(
credentialsRepository,
@ -101,6 +107,9 @@ describe('CredentialsService', () => {
externalSecretsConfig,
externalSecretsProviderAccessCheckService,
connectionStatusProxy,
instanceCredentialAssignmentRepository,
instanceCredentialUseRegistry,
dbLockService,
);
beforeEach(() => {
@ -111,6 +120,9 @@ describe('CredentialsService', () => {
credentialDependencyService.syncExternalSecretProviderDependenciesForCredential.mockResolvedValue(
undefined,
);
credentialDependencyService.upsertExternalSecretProviderDependenciesForCredential.mockResolvedValue(
undefined,
);
ownershipService.addOwnedByAndSharedWith.mockImplementation((credential: any) => credential);
// Mock the subquery method used by member users and admin users with onlySharedWithMe
credentialsRepository.getManyAndCountWithSharingSubquery.mockResolvedValue({
@ -119,6 +131,7 @@ describe('CredentialsService', () => {
});
// Mock roleService for the new subquery implementation
roleService.rolesWithScope.mockResolvedValue(['project:viewer', 'project:editor']);
instanceCredentialAssignmentRepository.findCredentialUseIds.mockResolvedValue([]);
});
/**
@ -878,11 +891,26 @@ describe('CredentialsService', () => {
expect(credentialsTester.testCredentials).not.toHaveBeenCalled();
});
it('does not expose instance credentials through public API testing', async () => {
credentialsFinderService.findCredentialById.mockResolvedValue(
mock<CredentialsEntity>({
id: 'instance-credential',
usageScope: 'instance',
}),
);
await expect(service.testById(ownerUser.id, 'instance-credential')).rejects.toThrow(
CredentialNotFoundError,
);
expect(credentialsTester.testCredentials).not.toHaveBeenCalled();
});
it('decrypts stored credential and calls credentials tester', async () => {
const storedCredential = mock<CredentialsEntity>({
id: 'credential-id',
name: 'Test Credential',
type: 'githubApi',
usageScope: 'project',
});
const decryptedData = { accessToken: 'secret-token' } as ICredentialDataDecryptedObject;
const testResult = { status: 'OK', message: 'Credential tested successfully' } as const;
@ -909,7 +937,440 @@ describe('CredentialsService', () => {
});
});
describe('getOne', () => {
const instanceCredential = mock<CredentialsEntity>({
id: 'instance-credential',
usageScope: 'instance',
data: 'encrypted-data',
});
it('does not return instance credentials without an explicit opt-in', async () => {
credentialsRepository.findOneBy.mockResolvedValue(instanceCredential);
sharedCredentialsRepository.findOne.mockResolvedValue(null);
await expect(service.getOne(ownerUser, instanceCredential.id, false)).rejects.toThrow(
`Credential with ID "${instanceCredential.id}" could not be found.`,
);
expect(credentialsRepository.findOneBy).not.toHaveBeenCalled();
expect(sharedCredentialsRepository.findOne).toHaveBeenCalledWith({
where: {
credentialsId: instanceCredential.id,
credentials: { usageScope: 'project' },
},
relations: { credentials: true },
});
});
it('returns instance credentials to managers when explicitly requested', async () => {
credentialsRepository.findOneBy.mockResolvedValue(instanceCredential);
await expect(
service.getOne(ownerUser, instanceCredential.id, false, {
includeInstanceCredentials: true,
}),
).resolves.toMatchObject({ id: instanceCredential.id, usageScope: 'instance' });
expect(credentialsRepository.findOneBy).toHaveBeenCalledWith({
id: instanceCredential.id,
usageScope: 'instance',
});
});
});
describe('delete', () => {
it('does not opt generic callers into instance credential access', async () => {
credentialsFinderService.findCredentialForUser.mockResolvedValue(null);
await service.delete(ownerUser, 'credential-id');
expect(credentialsFinderService.findCredentialForUser).toHaveBeenCalledWith(
'credential-id',
ownerUser,
['credential:delete'],
{},
);
expect(credentialsRepository.remove).not.toHaveBeenCalled();
});
it('deletes instance credentials when management access is explicitly requested', async () => {
const credential = mock<CredentialsEntity>({
id: 'instance-credential',
usageScope: 'instance',
isResolvable: false,
});
credentialsFinderService.findCredentialForUser.mockResolvedValue(credential);
credentialsRepository.deleteInstanceCredentialIfUnassigned.mockResolvedValue({
status: 'deleted',
});
await service.delete(ownerUser, credential.id, { includeInstanceCredentials: true });
expect(credentialsFinderService.findCredentialForUser).toHaveBeenCalledWith(
credential.id,
ownerUser,
['credential:delete'],
{ includeInstanceCredentials: true },
);
expect(credentialsRepository.deleteInstanceCredentialIfUnassigned).toHaveBeenCalledWith(
credential.id,
);
expect(externalHooks.run).toHaveBeenCalledWith('credentials.delete', [credential.id]);
});
it('does not delete an instance credential bound to a feature', async () => {
const credential = mock<CredentialsEntity>({
id: 'instance-credential',
usageScope: 'instance',
isResolvable: false,
});
credentialsFinderService.findCredentialForUser.mockResolvedValue(credential);
credentialsRepository.deleteInstanceCredentialIfUnassigned.mockResolvedValue({
status: 'assigned',
credentialUseIds: ['instance-ai:model'],
});
await expect(
service.delete(ownerUser, credential.id, { includeInstanceCredentials: true }),
).rejects.toThrow('instance-ai:model');
expect(externalHooks.run).toHaveBeenCalledWith('credentials.delete', [credential.id]);
});
});
describe('update', () => {
const setRepositoryTransaction = (transactionManager: EntityManager) => {
const transaction = vi.fn(
async (run: (manager: EntityManager) => Promise<unknown>) => await run(transactionManager),
);
Object.defineProperty(credentialsRepository, 'manager', {
configurable: true,
value: { transaction },
writable: true,
});
return transaction;
};
it('serializes instance credential validation and persistence with settings updates', async () => {
const credential = mock<CredentialsEntity>({
id: 'instance-credential',
name: 'Provider connection',
type: 'apiKey',
});
const encrypted = await service.createEncryptedData({
id: credential.id,
name: credential.name,
type: credential.type,
data: { apiKey: 'secret' },
});
const lockTransactionManager = mock<EntityManager>();
lockTransactionManager.findOneBy.mockResolvedValue(credential);
const repositoryTransaction = setRepositoryTransaction(mock<EntityManager>());
let lockHeld = false;
let releaseLock = () => {};
const lockAvailable = new Promise<void>((resolve) => {
releaseLock = resolve;
});
dbLockService.withLock.mockImplementation(async (lockId, run) => {
expect(lockId).toBe(DbLock.INSTANCE_AI_SETTINGS);
expect(externalHooks.run).toHaveBeenCalledWith('credentials.update', [encrypted]);
await lockAvailable;
lockHeld = true;
try {
return await run(lockTransactionManager, {});
} finally {
lockHeld = false;
}
});
instanceCredentialAssignmentRepository.findCredentialUseIds.mockImplementation(async () => {
expect(lockHeld).toBe(true);
return [];
});
const update = service.update(credential.id, encrypted, undefined, {
instanceCredential: credential,
});
await vi.waitFor(() => expect(dbLockService.withLock).toHaveBeenCalledOnce());
expect(lockTransactionManager.update).not.toHaveBeenCalled();
releaseLock();
await expect(update).resolves.toBe(credential);
expect(repositoryTransaction).not.toHaveBeenCalled();
expect(lockTransactionManager.update).toHaveBeenCalledWith(
CredentialsEntity,
credential.id,
encrypted,
);
});
it('keeps project credential updates on their existing transaction path', async () => {
const credential = mock<CredentialsEntity>({ id: 'project-credential' });
const transactionManager = mock<EntityManager>();
transactionManager.findOneBy.mockResolvedValue(credential);
const repositoryTransaction = setRepositoryTransaction(transactionManager);
const encrypted = mock<ICredentialsDb>({
id: credential.id,
name: 'Project credential',
type: 'apiKey',
data: 'encrypted',
});
await expect(service.update(credential.id, encrypted)).resolves.toBe(credential);
expect(repositoryTransaction).toHaveBeenCalledOnce();
expect(dbLockService.withLock).not.toHaveBeenCalled();
expect(transactionManager.update).toHaveBeenCalledWith(
CredentialsEntity,
credential.id,
encrypted,
);
});
});
describe('updateInstanceCredential', () => {
const payload = { name: 'AI Assistant model', type: 'openAiApi', data: { apiKey: 'new-key' } };
const preparedCredential = {
name: payload.name,
type: payload.type,
data: payload.data,
} as unknown as CredentialsEntity;
const ctx = {};
beforeEach(() => {
credentialsRepository.findInstanceCredentialById.mockResolvedValue(
mock<CredentialsEntity>({
id: 'instance-credential',
type: 'openAiApi',
usageScope: 'instance',
}),
);
instanceCredentialAssignmentRepository.findCredentialUseIds.mockResolvedValue([]);
});
it('rejects users without the instance credential management scope', async () => {
await expect(
service.updateInstanceCredential(memberUser, 'instance-credential', payload, ctx),
).rejects.toThrow('You do not have permission to update provider connections');
expect(credentialsRepository.updateInstanceCredential).not.toHaveBeenCalled();
});
it('rejects a credential type change', async () => {
vi.spyOn(service, 'prepareUpdateData').mockResolvedValue(
mock<CredentialsEntity>({ name: payload.name, type: 'anthropicApi' }),
);
await expect(
service.updateInstanceCredential(
ownerUser,
'instance-credential',
{ ...payload, type: 'anthropicApi' },
ctx,
),
).rejects.toThrow('Provider connection type cannot be changed');
expect(credentialsRepository.updateInstanceCredential).not.toHaveBeenCalled();
});
it('runs the update hook unless the caller already ran it', async () => {
const encrypted = await service.createEncryptedData({
id: 'instance-credential',
...payload,
});
vi.spyOn(service, 'prepareUpdateData').mockResolvedValue(preparedCredential);
vi.spyOn(service, 'createEncryptedData').mockResolvedValue(encrypted as never);
credentialsRepository.updateInstanceCredential.mockResolvedValue(
mock<CredentialsEntity>({ id: 'instance-credential' }),
);
await service.updateInstanceCredential(ownerUser, 'instance-credential', payload, ctx);
expect(externalHooks.run).toHaveBeenCalledWith('credentials.update', [encrypted]);
externalHooks.run.mockClear();
await service.updateInstanceCredential(ownerUser, 'instance-credential', payload, ctx, {
skipExternalHooks: true,
});
expect(externalHooks.run).not.toHaveBeenCalled();
expect(credentialsRepository.updateInstanceCredential).toHaveBeenCalledWith(
'instance-credential',
encrypted,
ctx,
);
const hooked = { ...encrypted, name: 'Updated by hook' };
await service.updateInstanceCredential(ownerUser, 'instance-credential', payload, ctx, {
skipExternalHooks: true,
encryptedData: hooked as never,
});
expect(credentialsRepository.updateInstanceCredential).toHaveBeenLastCalledWith(
'instance-credential',
hooked,
ctx,
);
});
it('revalidates a pre-hooked payload before updating', async () => {
vi.spyOn(service, 'prepareUpdateData').mockResolvedValue(preparedCredential);
const encrypted = await service.createEncryptedData({
id: 'instance-credential',
name: payload.name,
type: payload.type,
data: { apiKey: 'rejected-by-use' },
});
instanceCredentialAssignmentRepository.findCredentialUseIds.mockResolvedValue([
'instance-ai:model',
]);
instanceCredentialUseRegistry.get.mockReturnValue({
id: 'instance-ai:model',
credentialTypes: [payload.type],
validate: ({ data }) => {
if (data.apiKey === 'rejected-by-use') throw new Error('Invalid hooked API key');
},
});
await expect(
service.updateInstanceCredential(ownerUser, 'instance-credential', payload, ctx, {
skipExternalHooks: true,
encryptedData: encrypted,
}),
).rejects.toThrow('Invalid hooked API key');
expect(credentialsRepository.updateInstanceCredential).not.toHaveBeenCalled();
});
it('rejects invalid ciphertext from a pre-run hook', async () => {
vi.spyOn(service, 'prepareUpdateData').mockResolvedValue(preparedCredential);
const encrypted = await service.createEncryptedData({
id: 'instance-credential',
...payload,
});
encrypted.data = 'invalid-ciphertext';
await expect(
service.updateInstanceCredential(ownerUser, 'instance-credential', payload, ctx, {
skipExternalHooks: true,
encryptedData: encrypted,
}),
).rejects.toThrow('Provider connection hooks returned invalid credential data');
expect(credentialsRepository.updateInstanceCredential).not.toHaveBeenCalled();
});
it.each([
['ID', { id: 'other-credential' }],
['type', { type: 'anthropicApi' }],
['scope', { isGlobal: true }],
['scope', { isManaged: true }],
['scope', { isResolvable: true }],
['scope', { resolvableAllowFallback: true }],
['scope', { resolverId: 'resolver-id' }],
['scope', { usageScope: 'project' }],
])('rejects a hook that changes provider connection %s', async (invariant, mutation) => {
vi.spyOn(service, 'prepareUpdateData').mockResolvedValue(preparedCredential);
const encrypted = await service.createEncryptedData({
id: 'instance-credential',
...payload,
});
Object.assign(encrypted, mutation);
await expect(
service.updateInstanceCredential(ownerUser, 'instance-credential', payload, ctx, {
skipExternalHooks: true,
encryptedData: encrypted,
}),
).rejects.toThrow(`Provider connection hooks cannot change the credential ${invariant}`);
expect(credentialsRepository.updateInstanceCredential).not.toHaveBeenCalled();
});
});
describe('createInstanceCredential hook validation', () => {
it('revalidates the data after the create hook', async () => {
credentialsHelper.getCredentialsProperties.mockReturnValue([]);
const invalid = await service.createEncryptedData({
id: null,
name: 'Instance Credential',
type: 'apiKey',
data: { apiKey: '={{ $secrets.provider.key }}' },
});
externalHooks.run.mockImplementation(async (_event, hookParameters) => {
const hookData = (hookParameters as [ICredentialsDb] | undefined)?.[0];
if (!hookData) throw new Error('Expected credential hook data');
hookData.data = invalid.data;
});
await expect(
service.createUnmanagedCredential(
{
name: 'Instance Credential',
type: 'apiKey',
data: { apiKey: 'valid' },
usageScope: 'instance',
},
ownerUser,
),
).rejects.toThrow('Provider connections cannot reference project-scoped external secrets');
expect(credentialsRepository.saveInstanceCredential).not.toHaveBeenCalled();
});
});
describe('runInstanceCredentialHooks', () => {
it('returns the payload after the matching hook runs', async () => {
const encrypted = { name: 'AI Assistant model', type: 'openAiApi', data: 'encrypted' };
const createEncryptedDataSpy = vi
.spyOn(service, 'createEncryptedData')
.mockResolvedValue(encrypted as never);
externalHooks.run.mockImplementation(async (_event, hookParameters) => {
const hookData = (hookParameters as [ICredentialsDb] | undefined)?.[0];
if (!hookData) throw new Error('Expected credential hook data');
hookData.name = 'Updated by hook';
});
const result = await service.runInstanceCredentialHooks('create', {
id: null,
name: 'AI Assistant model',
type: 'openAiApi',
data: { apiKey: 'k' },
});
expect(createEncryptedDataSpy).toHaveBeenCalledWith({
id: null,
name: 'AI Assistant model',
type: 'openAiApi',
data: { apiKey: 'k' },
});
expect(externalHooks.run).toHaveBeenCalledWith('credentials.create', [encrypted]);
expect(result).toBe(encrypted);
expect(result.name).toBe('Updated by hook');
});
});
describe('testWithCredentials', () => {
it('tests an unsaved provider connection for an instance credential manager', async () => {
const testResult = { status: 'OK', message: 'Credential tested successfully' } as const;
credentialsFinderService.findCredentialForUser.mockResolvedValue(null);
credentialsTester.testCredentials.mockResolvedValue(testResult);
const payload = {
id: '',
name: 'AI Assistant model',
type: 'openAiApi',
data: { apiKey: 'key' },
};
await expect(service.testWithCredentials(ownerUser, payload)).resolves.toEqual(testResult);
expect(credentialsTester.testCredentials).toHaveBeenCalledWith(
ownerUser.id,
payload.type,
payload,
);
});
it('does not test an unsaved provider connection for other users', async () => {
credentialsFinderService.findCredentialForUser.mockResolvedValue(null);
await expect(
service.testWithCredentials(memberUser, {
id: '',
name: 'AI Assistant model',
type: 'openAiApi',
data: { apiKey: 'key' },
}),
).rejects.toThrow(CredentialNotFoundError);
expect(credentialsTester.testCredentials).not.toHaveBeenCalled();
});
it('throws CredentialNotFoundError when user cannot access credential', async () => {
credentialsFinderService.findCredentialForUser.mockResolvedValue(null);
@ -954,6 +1415,7 @@ describe('CredentialsService', () => {
payload.id,
ownerUser,
['credential:read'],
{ includeInstanceCredentials: true },
);
expect(service.decrypt).toHaveBeenCalledWith(storedCredential, true);
expect(service.unredact).toHaveBeenCalledWith(payload.data, decryptedData, []);
@ -2393,6 +2855,52 @@ describe('CredentialsService', () => {
externalSecretsProviderAccessCheckService.isProviderAvailableInProject,
).toHaveBeenCalledWith('validProvider', 'WHwt9vP3keCUvmB5');
});
it('should reject project-scoped external secrets in instance credentials', async () => {
credentialsHelper.getCredentialsProperties.mockReturnValue([]);
const payload = {
name: 'Test Credential',
type: 'apiKey',
data: {
apiKey: '={{ $secrets.validProvider.bar }}',
},
usageScope: 'instance' as const,
};
await expect(service.createUnmanagedCredential(payload, ownerUser)).rejects.toThrow(
'Provider connections cannot reference project-scoped external secrets',
);
expect(projectRepository.getPersonalProjectForUserOrFail).not.toHaveBeenCalled();
});
it('should reject instance credential creation without the global scope', async () => {
await expect(
service.createUnmanagedCredential(
{
name: 'Instance Credential',
type: 'apiKey',
data: {},
usageScope: 'instance',
},
memberUser,
),
).rejects.toThrow('You do not have permission to create provider connections');
});
it('should reject contradictory instance credential flags', async () => {
await expect(
service.createUnmanagedCredential(
{
name: 'Instance Credential',
type: 'apiKey',
data: {},
usageScope: 'instance',
isGlobal: true,
},
ownerUser,
),
).rejects.toThrow('Provider connections cannot be globally shared');
});
});
});
@ -2662,6 +3170,122 @@ describe('CredentialsService', () => {
await service.prepareUpdateData(ownerUser, payload, existingCredential);
});
it('should reject project-scoped external secrets when updating an instance credential', async () => {
credentialsHelper.getCredentialsProperties.mockReturnValue([]);
const payload = {
name: 'Test Credential',
type: 'apiKey',
data: {
apiKey: '={{ $secrets.validProvider.bar }}',
},
};
const existingCredential = mockExistingCredential({
id: 'instance-credential-id',
name: 'Test Credential',
type: 'apiKey',
data: { apiKey: 'old-key' },
usageScope: 'instance',
shared: [],
});
credentialsRepository.create.mockImplementation((data) => ({ ...data }) as any);
await expect(
service.prepareUpdateData(ownerUser, payload, existingCredential),
).rejects.toThrow('Provider connections cannot reference project-scoped external secrets');
expect(projectRepository.getPersonalProjectForUserOrFail).not.toHaveBeenCalled();
});
});
describe('assigned instance credentials', () => {
const existingCredential = mockExistingCredential({
id: 'instance-credential-id',
name: 'AI Assistant sandbox',
type: 'httpHeaderAuth',
data: {},
usageScope: 'instance',
shared: [],
});
const payload = {
name: 'AI Assistant sandbox',
type: 'httpHeaderAuth',
data: { name: 'Authorization', value: 'secret' },
};
beforeEach(() => {
vi.spyOn(service, 'decrypt').mockResolvedValue({});
credentialsHelper.getCredentialsProperties.mockReturnValue([]);
credentialsRepository.create.mockImplementation((data) => ({ ...data }) as never);
});
it('runs the registered per-use validation with the updated data', async () => {
const validatePrimary = vi.fn();
const validateSecondary = vi.fn();
const operationContext = {};
instanceCredentialAssignmentRepository.findCredentialUseIds.mockResolvedValue([
'instance-ai:sandbox:n8n',
'other-feature:sandbox',
]);
instanceCredentialUseRegistry.get.mockImplementation((credentialUseId) => ({
id: credentialUseId,
credentialTypes: ['httpHeaderAuth'],
validate:
credentialUseId === 'instance-ai:sandbox:n8n' ? validatePrimary : validateSecondary,
}));
await service.prepareUpdateData(ownerUser, payload, existingCredential, {
operationContext,
});
const expectedCredential = {
type: 'httpHeaderAuth',
data: { name: 'Authorization', value: 'secret' },
};
expect(validatePrimary).toHaveBeenCalledWith(expectedCredential);
expect(validateSecondary).toHaveBeenCalledWith(expectedCredential);
expect(instanceCredentialAssignmentRepository.findCredentialUseIds).toHaveBeenCalledWith(
existingCredential.id,
operationContext,
);
});
it('propagates per-use validation failures', async () => {
instanceCredentialAssignmentRepository.findCredentialUseIds.mockResolvedValue([
'instance-ai:sandbox:n8n',
]);
instanceCredentialUseRegistry.get.mockReturnValue({
id: 'instance-ai:sandbox:n8n',
credentialTypes: ['httpHeaderAuth'],
validate: () => {
throw new Error('The credential\'s header name must be "x-api-key"');
},
});
await expect(
service.prepareUpdateData(ownerUser, payload, existingCredential),
).rejects.toThrow(/x-api-key/);
});
it('skips per-use validation for unassigned instance credentials', async () => {
await expect(
service.prepareUpdateData(ownerUser, payload, existingCredential),
).resolves.toBeDefined();
expect(instanceCredentialUseRegistry.get).not.toHaveBeenCalled();
});
it('fails closed when an assigned use is no longer registered', async () => {
instanceCredentialAssignmentRepository.findCredentialUseIds.mockResolvedValue([
'instance-ai:sandbox:n8n',
]);
instanceCredentialUseRegistry.get.mockImplementation(() => {
throw new Error('Unknown instance credential use "instance-ai:sandbox:n8n"');
});
await expect(
service.prepareUpdateData(ownerUser, payload, existingCredential),
).rejects.toThrow('Unknown instance credential use');
});
});
});

View File

@ -0,0 +1,156 @@
import type { Logger } from '@n8n/backend-common';
import type { CredentialsEntity, InstanceCredentialAssignmentRepository } from '@n8n/db';
import { mock } from 'vitest-mock-extended';
import type { CredentialsService } from '../credentials.service';
import { InstanceCredentialBroker } from '../instance-credential-broker';
import { InstanceCredentialUseRegistry } from '../instance-credential-use.registry';
describe('InstanceCredentialBroker', () => {
const credentialUse = {
id: 'example:primary',
credentialTypes: ['openAiApi'],
} as const;
const credentialUseId = credentialUse.id;
const logger = mock<Logger>();
const assignmentRepository = mock<InstanceCredentialAssignmentRepository>();
const credentialsService = mock<CredentialsService>();
const useRegistry = new InstanceCredentialUseRegistry();
useRegistry.register(credentialUse);
const broker = new InstanceCredentialBroker(
logger,
useRegistry,
assignmentRepository,
credentialsService,
);
beforeEach(() => {
vi.resetAllMocks();
});
it('rejects credential use IDs with surrounding whitespace', () => {
const registry = new InstanceCredentialUseRegistry();
expect(() =>
registry.register({ id: ' example:primary', credentialTypes: ['openAiApi'] }),
).toThrow('cannot contain surrounding whitespace');
});
it('rejects unregistered credential uses', async () => {
const unknownUse = { id: 'unknown:use', credentialTypes: ['openAiApi'] } as const;
await expect(broker.listForUse(unknownUse)).rejects.toThrow(
'Unknown instance credential use "unknown:use"',
);
expect(assignmentRepository.findAvailableCredentials).not.toHaveBeenCalled();
});
it('enforces the registered credential types', async () => {
const spoofedUse = { id: credentialUseId, credentialTypes: ['httpHeaderAuth'] } as const;
await broker.listForUse(spoofedUse);
expect(assignmentRepository.findAvailableCredentials).toHaveBeenCalledWith(['openAiApi']);
});
it('lists only credentials allowed for the credential use', async () => {
const credential = mock<CredentialsEntity>({
id: 'credential-id',
name: 'Primary model',
type: 'openAiApi',
usageScope: 'instance',
});
assignmentRepository.findAvailableCredentials.mockResolvedValue([credential]);
await expect(broker.listForUse(credentialUse)).resolves.toEqual([credential]);
expect(assignmentRepository.findAvailableCredentials).toHaveBeenCalledWith(['openAiApi']);
});
it('rejects assignments outside the credential use policy', async () => {
assignmentRepository.assignCredential.mockResolvedValue(null);
await expect(broker.assignForUse(credentialUse, 'workflow-credential')).rejects.toThrow(
'not valid for instance credential use',
);
expect(credentialsService.decrypt).not.toHaveBeenCalled();
});
it('assigns and clears a credential for a credential use', async () => {
const credential = mock<CredentialsEntity>({
id: 'credential-id',
name: 'Primary model',
type: 'openAiApi',
usageScope: 'instance',
});
assignmentRepository.assignCredential.mockResolvedValue(credential);
await expect(broker.assignForUse(credentialUse, credential.id)).resolves.toEqual({
id: credential.id,
name: credential.name,
type: credential.type,
});
expect(assignmentRepository.assignCredential).toHaveBeenCalledWith(
credentialUseId,
credential.id,
['openAiApi'],
undefined,
);
await broker.clearForUse(credentialUse);
expect(assignmentRepository.clearCredential).toHaveBeenCalledWith(credentialUseId, undefined);
});
it('rejects the assignment when the credential is deleted concurrently', async () => {
assignmentRepository.assignCredential.mockResolvedValue(null);
await expect(broker.assignForUse(credentialUse, 'credential-id')).rejects.toThrow(
'not valid for instance credential use',
);
});
it('rethrows transient DB failures instead of blaming the credential', async () => {
assignmentRepository.assignCredential.mockRejectedValue(new Error('deadlock detected'));
await expect(broker.assignForUse(credentialUse, 'credential-id')).rejects.toThrow(
'deadlock detected',
);
});
it('returns no credential when the credential use has no assignment', async () => {
assignmentRepository.findAssignedCredential.mockResolvedValue(null);
await expect(broker.resolveForUse(credentialUse)).resolves.toBeNull();
expect(credentialsService.decrypt).not.toHaveBeenCalled();
});
it('resolves a credential through the shared decrypt path', async () => {
const credential = mock<CredentialsEntity>({
id: 'credential-id',
name: 'Primary model',
type: 'openAiApi',
usageScope: 'instance',
});
assignmentRepository.findAssignedCredential.mockResolvedValue({
credentialId: credential.id,
credential,
});
credentialsService.decrypt.mockResolvedValue({ apiKey: 'secret' });
await expect(broker.resolveForUse(credentialUse)).resolves.toEqual({
id: credential.id,
name: credential.name,
type: credential.type,
data: { apiKey: 'secret' },
});
expect(credentialsService.decrypt).toHaveBeenCalledWith(credential, true);
expect(logger.debug).toHaveBeenCalledWith('Resolved instance credential', {
credentialUseId,
credentialId: credential.id,
});
expect(assignmentRepository.findAssignedCredential).toHaveBeenCalledWith(
credentialUseId,
['openAiApi'],
undefined,
);
});
});

View File

@ -22,7 +22,7 @@ export class CredentialsFinderService {
private async fetchGlobalCredentials(trx?: EntityManager): Promise<CredentialsEntity[]> {
const em = trx ?? this.credentialsRepository.manager;
return await em.find(CredentialsEntity, {
where: { isGlobal: true },
where: { isGlobal: true, usageScope: 'project' },
relations: { shared: true },
});
}
@ -46,13 +46,22 @@ export class CredentialsFinderService {
where: {
id: credentialId,
isGlobal: true,
usageScope: 'project',
},
relations,
});
}
async findCredentialById(credentialId: string): Promise<CredentialsEntity | null> {
return await this.credentialsRepository.findOne({ where: { id: credentialId } });
async findCredentialById(
credentialId: string,
options: { includeInstanceCredentials?: boolean } = {},
): Promise<CredentialsEntity | null> {
return await this.credentialsRepository.findOne({
where: {
id: credentialId,
usageScope: options.includeInstanceCredentials ? In(['project', 'instance']) : 'project',
},
});
}
/**
@ -81,7 +90,10 @@ export class CredentialsFinderService {
* all scopes the user has for the credential using `RoleService.addScopes`.
**/
async findCredentialsForUser(user: User, scopes: Scope[]) {
let where: FindOptionsWhere<CredentialsEntity> = { isGlobal: false };
let where: FindOptionsWhere<CredentialsEntity> = {
isGlobal: false,
usageScope: 'project',
};
if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
const [projectRoles, credentialRoles] = await Promise.all([
@ -121,7 +133,16 @@ export class CredentialsFinderService {
credentialsId: string,
user: User,
scopes: Scope[],
options: { includeInstanceCredentials?: boolean } = {},
): Promise<CredentialsEntity | null> {
if (options.includeInstanceCredentials && hasGlobalScope(user, 'credential:manageInstance')) {
const instanceCredential = await this.credentialsRepository.findOneBy({
id: credentialsId,
usageScope: 'instance',
});
if (instanceCredential) return instanceCredential;
}
let where: FindOptionsWhere<SharedCredentials> = { credentialsId };
if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
@ -152,6 +173,7 @@ export class CredentialsFinderService {
});
if (sharedCredential) {
if (sharedCredential.credentials.usageScope !== 'project') return null;
return sharedCredential.credentials;
}
@ -172,7 +194,9 @@ export class CredentialsFinderService {
trx?: EntityManager,
options?: { includeGlobalCredentials?: boolean },
) {
let where: FindOptionsWhere<SharedCredentials> = {};
let where: FindOptionsWhere<SharedCredentials> = {
credentials: { usageScope: 'project' },
};
if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
const [projectRoles, credentialRoles] = await Promise.all([
@ -180,6 +204,7 @@ export class CredentialsFinderService {
this.roleService.rolesWithScope('credential', scopes),
]);
where = {
...where,
role: In(credentialRoles),
project: {
projectRelations: {
@ -232,7 +257,10 @@ export class CredentialsFinderService {
): Promise<Set<string>> {
if (credentialIds.length === 0) return new Set();
let where: FindOptionsWhere<SharedCredentials> = { credentialsId: In(credentialIds) };
let where: FindOptionsWhere<SharedCredentials> = {
credentialsId: In(credentialIds),
credentials: { usageScope: 'project' },
};
if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
const [projectRoles, credentialRoles] = await Promise.all([
@ -261,7 +289,7 @@ export class CredentialsFinderService {
// Also include global credentials if scopes allow read-only access
if (this.hasGlobalReadOnlyAccess(scopes)) {
const globalCreds = await this.credentialsRepository.find({
where: { id: In(credentialIds), isGlobal: true },
where: { id: In(credentialIds), isGlobal: true, usageScope: 'project' },
select: ['id'],
});
for (const gc of globalCreds) result.add(gc.id);

View File

@ -130,7 +130,9 @@ export class CredentialsController {
// to do so.
query.includeData,
)
: await this.credentialsService.getOne(req.user, credentialId, query.includeData);
: await this.credentialsService.getOne(req.user, credentialId, query.includeData, {
includeInstanceCredentials: true,
});
const scopes = await this.credentialsService.getCredentialScopes(
req.user,
@ -210,6 +212,7 @@ export class CredentialsController {
credentialId,
user,
['credential:update'],
{ includeInstanceCredentials: true },
);
if (!credential) {
@ -226,6 +229,25 @@ export class CredentialsController {
throw new BadRequestError('Managed credentials cannot be updated');
}
if (
credential.usageScope === 'instance' &&
body.type !== undefined &&
body.type !== credential.type
) {
throw new BadRequestError(
'Provider connection type cannot be changed. Create a new connection instead.',
);
}
if (
credential.usageScope === 'instance' &&
(body.isGlobal === true || body.isResolvable === true)
) {
throw new BadRequestError(
'Provider connections cannot be globally shared or converted to end-user credentials',
);
}
// We never want to allow users to change the oauthTokenData
delete body.data?.oauthTokenData;
@ -290,7 +312,10 @@ export class CredentialsController {
body.data
? (preparedCredentialData.data as unknown as ICredentialDataDecryptedObject)
: undefined,
{ deleteUserEntries: isTogglingToStatic || sharedFieldsChanged },
{
deleteUserEntries: isTogglingToStatic || sharedFieldsChanged,
instanceCredential: credential.usageScope === 'instance' ? credential : undefined,
},
);
if (responseData === null) {
@ -384,6 +409,7 @@ export class CredentialsController {
credentialId,
req.user,
['credential:delete'],
{ includeInstanceCredentials: true },
);
if (!credential) {
@ -396,7 +422,9 @@ export class CredentialsController {
);
}
await this.credentialsService.delete(req.user, credential.id);
await this.credentialsService.delete(req.user, credential.id, {
includeInstanceCredentials: true,
});
this.eventService.emit('credentials-deleted', {
user: req.user,

View File

@ -1,6 +1,11 @@
import { LicenseState } from '@n8n/backend-common';
import type { CredentialsEntity, User } from '@n8n/db';
import { Project, SharedCredentials, SharedCredentialsRepository } from '@n8n/db';
import type { User } from '@n8n/db';
import {
CredentialsEntity,
Project,
SharedCredentials,
SharedCredentialsRepository,
} from '@n8n/db';
import { Service } from '@n8n/di';
import { hasGlobalScope } from '@n8n/permissions';
import { In, type EntityManager } from '@n8n/typeorm';
@ -41,6 +46,11 @@ export class EnterpriseCredentialsService {
entityManager?: EntityManager,
) {
const em = entityManager ?? this.sharedCredentialsRepository.manager;
const canShare = await em.exists(CredentialsEntity, {
where: { id: credentialId, usageScope: 'project' },
});
if (!canShare) throw new NotFoundError('Credential not found');
const roles = await this.roleService.rolesWithScope('project', ['project:list']);
let projects = await em.find(Project, {
@ -102,6 +112,7 @@ export class EnterpriseCredentialsService {
// TODO: replace credential:update with credential:decrypt once it lands
// see: https://n8nio.slack.com/archives/C062YRE7EG4/p1708531433206069?thread_ts=1708525972.054149&cid=C062YRE7EG4
['credential:read', 'credential:update'],
{ includeInstanceCredentials: true },
)
: null;
@ -112,9 +123,12 @@ export class EnterpriseCredentialsService {
} else {
// Otherwise try to find them with only the `credential:read` scope. In
// that case we return them without the decrypted data.
credential = await this.credentialsFinderService.findCredentialForUser(credentialId, user, [
'credential:read',
]);
credential = await this.credentialsFinderService.findCredentialForUser(
credentialId,
user,
['credential:read'],
{ includeInstanceCredentials: true },
);
// Connect-capable users of a private credential need the redacted blueprint
// (secrets stay masked) so the UI can detect the OAuth type and render the
@ -122,9 +136,12 @@ export class EnterpriseCredentialsService {
if (
includeDecryptedData &&
credential?.isResolvable &&
(await this.credentialsFinderService.findCredentialForUser(credentialId, user, [
'credential:connect',
]))
(await this.credentialsFinderService.findCredentialForUser(
credentialId,
user,
['credential:connect'],
{ includeInstanceCredentials: true },
))
) {
decryptedData = await this.credentialsService.decrypt(credential);
}

View File

@ -3,13 +3,23 @@ import { Logger } from '@n8n/backend-common';
import {
Project,
CredentialsEntity,
DbLock,
DbLockService,
SharedCredentials,
CredentialsRepository,
InstanceCredentialAssignmentRepository,
ProjectRepository,
SharedCredentialsRepository,
UserRepository,
} from '@n8n/db';
import type { ListQueryDb, SlimProject, User, ICredentialsDb, ScopesField } from '@n8n/db';
import type {
ListQueryDb,
SlimProject,
User,
ICredentialsDb,
ScopesField,
OperationContext,
} from '@n8n/db';
import { Service } from '@n8n/di';
import { hasGlobalScope, PROJECT_OWNER_ROLE_SLUG, type Scope } from '@n8n/permissions';
import {
@ -64,6 +74,8 @@ import {
type CredentialDependencyFilter,
} from './credential-dependency.service';
import { CredentialsFinderService } from './credentials-finder.service';
import { getExternalSecretExpressionPaths } from './external-secrets.utils';
import { InstanceCredentialUseRegistry } from './instance-credential-use.registry';
import {
validateAccessToReferencedSecretProviders,
validateExternalSecretsPermissions,
@ -82,17 +94,40 @@ type PrepareUpdateDataOptions = {
* updated credential blob. Used on StaticPrivate toggle.
*/
clearOauthTokenData?: boolean;
operationContext?: OperationContext;
};
type UpdateOptions = {
/** When true, delete all per-user entries for this credential (Private→Static toggle). */
deleteUserEntries?: boolean;
/** Existing instance credential whose hook-mutated payload must be revalidated. */
instanceCredential?: Pick<CredentialsEntity, 'id' | 'type'>;
};
type CreateCredentialOptions = CreateCredentialDto & {
isManaged: boolean;
};
type InstanceCredentialWriteOptions = {
/** Set when the caller already ran the external hooks before its transaction. */
skipExternalHooks?: boolean;
/** Carries hook mutations into the transaction. */
encryptedData?: ICredentialsDb;
};
type InstanceCredentialHookPayload = ICredentialsDb &
Partial<
Pick<
CredentialsEntity,
| 'isGlobal'
| 'isManaged'
| 'isResolvable'
| 'resolvableAllowFallback'
| 'resolverId'
| 'usageScope'
>
>;
type GetManyCredentialsOptions = {
listQueryOptions: ListQuery.Options;
includeGlobal: boolean;
@ -140,6 +175,9 @@ export class CredentialsService {
private readonly externalSecretsConfig: ExternalSecretsConfig,
private readonly externalSecretsProviderAccessCheckService: SecretsProviderAccessCheckService,
private readonly connectionStatusProxy: CredentialConnectionStatusProxy,
private readonly instanceCredentialAssignmentRepository: InstanceCredentialAssignmentRepository,
private readonly instanceCredentialUseRegistry: InstanceCredentialUseRegistry,
private readonly dbLockService: DbLockService,
) {}
/**
@ -677,7 +715,10 @@ export class CredentialsService {
globalScopes: Scope[],
relations: FindOptionsRelations<SharedCredentials> = { credentials: true },
): Promise<SharedCredentials | null> {
let where: FindOptionsWhere<SharedCredentials> = { credentialsId: credentialId };
let where: FindOptionsWhere<SharedCredentials> = {
credentialsId: credentialId,
credentials: { usageScope: 'project' },
};
if (!hasGlobalScope(user, globalScopes, { mode: 'allOf' })) {
where = {
@ -706,27 +747,6 @@ export class CredentialsService {
): Promise<CredentialsEntity> {
const decryptedData = await this.decrypt(existingCredential, true);
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain -- credential will always have an owner
const projectOwningCredential = existingCredential.shared?.find(
(shared) => shared.role === 'credential:owner',
)!;
await validateExternalSecretsPermissions({
user,
projectId: projectOwningCredential.projectId,
dataToSave: data.data,
decryptedExistingData: decryptedData,
});
if (this.externalSecretsConfig.externalSecretsForProjects && data.data) {
await validateAccessToReferencedSecretProviders(
projectOwningCredential.projectId,
data.data,
this.externalSecretsProviderAccessCheckService,
'update',
);
}
const mergedData = deepCopy(data);
if (mergedData.data) {
mergedData.data = this.unredact(
@ -735,6 +755,37 @@ export class CredentialsService {
this.getCredentialTypeProperties(existingCredential.type),
);
}
if (existingCredential.usageScope === 'instance') {
await this.validateInstanceCredentialUpdate(
existingCredential,
mergedData.data ?? decryptedData,
undefined,
options?.operationContext,
);
} else {
const projectOwningCredential = existingCredential.shared?.find(
(shared) => shared.role === 'credential:owner',
);
if (!projectOwningCredential) {
throw new NotFoundError(`Could not find owner for credential "${existingCredential.id}"`);
}
await validateExternalSecretsPermissions({
user,
projectId: projectOwningCredential.projectId,
dataToSave: data.data,
decryptedExistingData: decryptedData,
});
if (this.externalSecretsConfig.externalSecretsForProjects && data.data) {
await validateAccessToReferencedSecretProviders(
projectOwningCredential.projectId,
data.data,
this.externalSecretsProviderAccessCheckService,
'update',
);
}
}
// This saves us a merge but requires some type casting. These
// types are compatible for this case.
@ -836,7 +887,7 @@ export class CredentialsService {
) {
await this.externalHooks.run('credentials.update', [newCredentialData]);
return await this.credentialsRepository.manager.transaction(async (transactionManager) => {
const persist = async (transactionManager: EntityManager) => {
// Update the credentials in DB
await transactionManager.update(CredentialsEntity, credentialId, newCredentialData);
@ -855,7 +906,100 @@ export class CredentialsService {
// We sadly get nothing back from "update". Neither if it updated a record
// nor the new value. So query now the updated entry.
return await transactionManager.findOneBy(CredentialsEntity, { id: credentialId });
};
if (!options?.instanceCredential) {
return await this.credentialsRepository.manager.transaction(persist);
}
const instanceCredential = options.instanceCredential;
const hookedData = await this.getValidatedInstanceCredentialHookData(
newCredentialData,
instanceCredential.id,
instanceCredential.type,
);
return await this.dbLockService.withLock(
DbLock.INSTANCE_AI_SETTINGS,
async (transactionManager, ctx) => {
await this.validateInstanceCredentialUpdate(instanceCredential, hookedData, undefined, ctx);
return await persist(transactionManager);
},
);
}
/**
* Runs external credential hooks with an encrypted snapshot of the payload.
* Transactional callers suppress hooks on the write and invoke this only
* after the transaction commits.
*/
async runInstanceCredentialHooks(
event: 'create' | 'update',
credential: {
id: string | null;
name: string;
type: string;
data: ICredentialDataDecryptedObject;
},
): Promise<ICredentialsDb> {
const encrypted = await this.createEncryptedData(credential);
await this.externalHooks.run(`credentials.${event}`, [encrypted]);
return encrypted;
}
async updateInstanceCredential(
user: User,
credentialId: string,
data: CredentialRequest.CredentialProperties,
ctx: OperationContext,
options: InstanceCredentialWriteOptions = {},
): Promise<CredentialsEntity> {
if (!hasGlobalScope(user, 'credential:manageInstance')) {
throw new ForbiddenError('You do not have permission to update provider connections');
}
const credential = await this.credentialsRepository.findInstanceCredentialById(
credentialId,
ctx,
);
if (!credential) {
throw new NotFoundError(`Credential with ID "${credentialId}" could not be found.`);
}
const prepared = await this.prepareUpdateData(user, data, credential, {
operationContext: ctx,
});
if (prepared.type !== credential.type) {
throw new BadRequestError(
'Provider connection type cannot be changed. Create a new connection instead.',
);
}
const decryptedData = prepared.data as unknown as ICredentialDataDecryptedObject;
const encrypted =
options.encryptedData ??
(await this.createEncryptedData({
id: credential.id,
name: prepared.name,
type: prepared.type,
data: decryptedData,
}));
if (!options.skipExternalHooks) {
await this.externalHooks.run('credentials.update', [encrypted]);
}
const hookedData = await this.getValidatedInstanceCredentialHookData(
encrypted,
credential.id,
credential.type,
);
await this.validateInstanceCredentialUpdate(credential, hookedData, undefined, ctx);
const updated = await this.credentialsRepository.updateInstanceCredential(
credential.id,
encrypted,
ctx,
);
if (!updated) {
throw new NotFoundError(`Credential with ID "${credentialId}" could not be found.`);
}
return updated;
}
private async resolveOwningProjectIdForNewCredential(
@ -943,13 +1087,16 @@ export class CredentialsService {
* If the user does not have permission to delete the credential this does
* nothing and returns void.
*/
async delete(user: User, credentialId: string) {
await this.externalHooks.run('credentials.delete', [credentialId]);
async delete(
user: User,
credentialId: string,
options: { includeInstanceCredentials?: boolean } = {},
) {
const credential = await this.credentialsFinderService.findCredentialForUser(
credentialId,
user,
['credential:delete'],
options,
);
if (!credential) {
@ -961,6 +1108,19 @@ export class CredentialsService {
await this.sharedCredentialsRepository.findCredentialOwningProject(credentialId);
await this.ensureCanManageEndUserCredential(user, owningProject?.id);
}
await this.externalHooks.run('credentials.delete', [credentialId]);
if (credential.usageScope === 'instance') {
const result = await this.credentialsRepository.deleteInstanceCredentialIfUnassigned(
credential.id,
);
if (result.status === 'assigned') {
throw new BadRequestError(
`This credential is assigned to credential use "${result.credentialUseIds.join(', ')}" and cannot be deleted`,
);
}
return;
}
await this.credentialsRepository.remove(credential);
}
@ -972,7 +1132,8 @@ export class CredentialsService {
async testById(userId: User['id'], credentialId: string) {
const storedCredential = await this.credentialsFinderService.findCredentialById(credentialId);
if (!storedCredential) {
// Dynamic-credential flows only; admins test instance credentials via testWithCredentials
if (!storedCredential || storedCredential.usageScope !== 'project') {
throw new CredentialNotFoundError(credentialId);
}
@ -985,9 +1146,14 @@ export class CredentialsService {
credentials.id,
user,
['credential:read'],
{ includeInstanceCredentials: true },
);
if (!storedCredential) {
if (credentials.id === '' && hasGlobalScope(user, 'credential:manageInstance')) {
this.validateInstanceCredentialData(credentials.data ?? {});
return await this.test(user.id, credentials);
}
throw new CredentialNotFoundError(credentials.id);
}
@ -1217,7 +1383,32 @@ export class CredentialsService {
return mergedData;
}
async getOne(user: User, credentialId: string, includeDecryptedData: boolean) {
async getOne(
user: User,
credentialId: string,
includeDecryptedData: boolean,
options: { includeInstanceCredentials?: boolean } = {},
) {
if (options.includeInstanceCredentials && hasGlobalScope(user, 'credential:manageInstance')) {
const instanceCredential = await this.credentialsRepository.findOneBy({
id: credentialId,
usageScope: 'instance',
});
if (instanceCredential) {
const { data: _, ...rest } = instanceCredential;
if (includeDecryptedData) {
const decryptedData = await this.decrypt(instanceCredential);
// We never want to expose the oauthTokenData to the frontend, but it
// expects it to check if the credential is already connected.
if (decryptedData.oauthTokenData) {
decryptedData.oauthTokenData = true;
}
return { data: decryptedData, ...rest };
}
return { ...rest };
}
}
let sharing: SharedCredentials | null = null;
let decryptedData: ICredentialDataDecryptedObject | null = null;
@ -1373,6 +1564,18 @@ export class CredentialsService {
return await this.createCredential({ ...dto, isManaged: false }, user);
}
async createInstanceCredential(
dto: CreateCredentialDto,
user: User,
ctx: OperationContext,
options: InstanceCredentialWriteOptions = {},
): Promise<CredentialsEntity> {
if (dto.usageScope !== 'instance') {
throw new BadRequestError('Provider connections must use instance usage scope');
}
return await this.persistInstanceCredential({ ...dto, isManaged: false }, user, ctx, options);
}
/**
* Creates an empty credential placeholder for package import. Skips field
* validation so every known type can be stubbed; {@link save} still enforces
@ -1409,6 +1612,11 @@ export class CredentialsService {
user: User,
projectId: string,
): Promise<void> {
this.validateCredentialData(type, data);
await validateExternalSecretsPermissions({ user, projectId, dataToSave: data });
}
private validateCredentialData(type: string, data: ICredentialDataDecryptedObject): void {
// check mandatory fields are present
const credentialProperties = this.credentialsHelper.getCredentialsProperties(type);
for (const property of credentialProperties) {
@ -1433,7 +1641,6 @@ export class CredentialsService {
}
}
}
await validateExternalSecretsPermissions({ user, projectId, dataToSave: data });
this.validateOAuthCredentialUrls(type, data);
}
@ -1507,6 +1714,13 @@ export class CredentialsService {
}
private async createCredential(opts: CreateCredentialOptions, user: User) {
if (opts.usageScope === 'instance') {
const savedCredential = await this.persistInstanceCredential(opts, user, {});
const scopes = await this.getCredentialScopes(user, savedCredential.id);
const { shared, ...credential } = savedCredential;
return { ...credential, scopes };
}
const targetProjectId = await this.resolveOwningProjectIdForNewCredential(user, opts.projectId);
if (opts.isResolvable === true) {
@ -1565,6 +1779,127 @@ export class CredentialsService {
return { ...credential, scopes };
}
private async persistInstanceCredential(
opts: CreateCredentialOptions,
user: User,
ctx: OperationContext,
options: InstanceCredentialWriteOptions = {},
): Promise<CredentialsEntity> {
if (!hasGlobalScope(user, 'credential:manageInstance')) {
throw new ForbiddenError('You do not have permission to create provider connections');
}
if (opts.isGlobal === true) {
throw new BadRequestError('Provider connections cannot be globally shared');
}
if (opts.isResolvable === true || opts.isManaged) {
throw new BadRequestError('Provider connections cannot be end-user or managed credentials');
}
const data = opts.data as ICredentialDataDecryptedObject;
this.validateInstanceCredentialData(data);
this.validateCredentialData(opts.type, data);
const encryptedCredential =
options.encryptedData ??
(await this.createEncryptedData({
id: null,
name: opts.name,
type: opts.type,
data,
}));
if (!options.skipExternalHooks) {
await this.externalHooks.run('credentials.create', [encryptedCredential]);
}
const hookedData = await this.getValidatedInstanceCredentialHookData(
encryptedCredential,
null,
opts.type,
);
this.validateInstanceCredentialData(hookedData);
this.validateCredentialData(opts.type, hookedData);
const credentialEntity = this.credentialsRepository.create({
...encryptedCredential,
isManaged: false,
isResolvable: false,
usageScope: 'instance' as const,
});
const savedCredential = await this.credentialsRepository.saveInstanceCredential(
credentialEntity,
ctx,
);
this.logger.debug('New instance credential created', {
credentialId: savedCredential.id,
ownerId: user.id,
});
return savedCredential;
}
private async getValidatedInstanceCredentialHookData(
credential: InstanceCredentialHookPayload,
expectedId: string | null,
expectedType: string,
): Promise<ICredentialDataDecryptedObject> {
if ((credential.id ?? null) !== expectedId) {
throw new BadRequestError('Provider connection hooks cannot change the credential ID');
}
if (credential.type !== expectedType) {
throw new BadRequestError('Provider connection hooks cannot change the credential type');
}
if (
credential.isGlobal ||
credential.isManaged ||
credential.isResolvable ||
credential.resolvableAllowFallback ||
(credential.resolverId !== undefined && credential.resolverId !== null) ||
(credential.usageScope !== undefined && credential.usageScope !== 'instance')
) {
throw new BadRequestError('Provider connection hooks cannot change the credential scope');
}
try {
return await createCredentialsFromCredentialsEntity(credential).getData();
} catch (error) {
if (error instanceof CredentialDataError) {
throw new BadRequestError('Provider connection hooks returned invalid credential data');
}
throw error;
}
}
validateInstanceCredentialData(data: ICredentialDataDecryptedObject): void {
if (getExternalSecretExpressionPaths(data).length > 0) {
throw new BadRequestError(
'Provider connections cannot reference project-scoped external secrets',
);
}
}
/** An assigned instance credential must keep satisfying every use's policy. */
async validateInstanceCredentialUpdate(
credential: Pick<CredentialsEntity, 'id' | 'type'>,
data: ICredentialDataDecryptedObject,
credentialUseIds?: string[],
ctx: OperationContext = {},
): Promise<void> {
this.validateInstanceCredentialData(data);
const assignedUseIds =
credentialUseIds ??
(await this.instanceCredentialAssignmentRepository.findCredentialUseIds(credential.id, ctx));
for (const credentialUseId of assignedUseIds) {
const credentialUse = this.instanceCredentialUseRegistry.get(credentialUseId);
if (!credentialUse.credentialTypes.includes(credential.type)) {
throw new BadRequestError(
`Credential type "${credential.type}" is not valid for instance credential use "${credentialUseId}"`,
);
}
credentialUse.validate?.({ type: credential.type, data });
}
}
/**
* Build credentials payload ready to pass to credential testing.
*

View File

@ -0,0 +1,108 @@
import { Logger } from '@n8n/backend-common';
import {
CredentialsEntity,
InstanceCredentialAssignmentRepository,
type OperationContext,
} from '@n8n/db';
import { Service } from '@n8n/di';
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error';
import { CredentialsService } from './credentials.service';
import {
InstanceCredentialUseRegistry,
type InstanceCredentialUse,
} from './instance-credential-use.registry';
export type { InstanceCredentialUse } from './instance-credential-use.registry';
type InstanceCredentialSummary = Pick<CredentialsEntity, 'id' | 'name' | 'type'>;
export interface ResolvedInstanceCredential extends InstanceCredentialSummary {
data: ICredentialDataDecryptedObject;
}
@Service()
export class InstanceCredentialBroker {
constructor(
private readonly logger: Logger,
private readonly useRegistry: InstanceCredentialUseRegistry,
private readonly assignmentRepository: InstanceCredentialAssignmentRepository,
private readonly credentialsService: CredentialsService,
) {}
registerUse(credentialUse: InstanceCredentialUse): void {
this.useRegistry.register(credentialUse);
}
async listForUse(credentialUse: InstanceCredentialUse): Promise<InstanceCredentialSummary[]> {
const registeredUse = this.useRegistry.get(credentialUse.id);
return await this.assignmentRepository.findAvailableCredentials(registeredUse.credentialTypes);
}
async assignForUse(
credentialUse: InstanceCredentialUse,
credentialId: string,
ctx?: OperationContext,
): Promise<InstanceCredentialSummary> {
const registeredUse = this.useRegistry.get(credentialUse.id);
const credentialUseId = registeredUse.id;
const credential = await this.assignmentRepository.assignCredential(
credentialUseId,
credentialId,
registeredUse.credentialTypes,
ctx,
);
if (!credential) throw this.invalidCredentialError(credentialUseId, credentialId);
this.logger.debug('Assigned instance credential', { credentialUseId, credentialId });
return { id: credential.id, name: credential.name, type: credential.type };
}
async clearForUse(credentialUse: InstanceCredentialUse, ctx?: OperationContext): Promise<void> {
this.useRegistry.get(credentialUse.id);
await this.assignmentRepository.clearCredential(credentialUse.id, ctx);
}
async getAssignedCredentialId(
credentialUse: InstanceCredentialUse,
ctx?: OperationContext,
): Promise<string | null> {
this.useRegistry.get(credentialUse.id);
return await this.assignmentRepository.findAssignedCredentialId(credentialUse.id, ctx);
}
async resolveForUse(
credentialUse: InstanceCredentialUse,
ctx?: OperationContext,
): Promise<ResolvedInstanceCredential | null> {
const registeredUse = this.useRegistry.get(credentialUse.id);
const credentialUseId = registeredUse.id;
const assignment = await this.assignmentRepository.findAssignedCredential(
credentialUseId,
registeredUse.credentialTypes,
ctx,
);
if (!assignment) return null;
const { credentialId, credential } = assignment;
if (!credential) throw this.invalidCredentialError(credentialUseId, credentialId);
const resolved = {
id: credential.id,
name: credential.name,
type: credential.type,
data: await this.credentialsService.decrypt(credential, true),
};
this.logger.debug('Resolved instance credential', {
credentialUseId,
credentialId: credential.id,
});
return resolved;
}
private invalidCredentialError(credentialUseId: string, credentialId: string) {
return new UnprocessableRequestError(
`Credential "${credentialId}" is not valid for instance credential use "${credentialUseId}"`,
);
}
}

View File

@ -0,0 +1,46 @@
import { Service } from '@n8n/di';
import { UnexpectedError, type ICredentialDataDecryptedObject } from 'n8n-workflow';
export interface InstanceCredentialUse {
readonly id: string;
readonly credentialTypes: readonly string[];
readonly validate?: (credential: {
type: string;
data: ICredentialDataDecryptedObject;
}) => void;
}
@Service()
export class InstanceCredentialUseRegistry {
// Use policies are code-defined; mutable assignments are persisted in the database.
private readonly uses = new Map<string, InstanceCredentialUse>();
register(credentialUse: InstanceCredentialUse) {
if (credentialUse.id.trim().length === 0) {
throw new UnexpectedError('Instance credential use ID cannot be empty');
}
if (credentialUse.id !== credentialUse.id.trim()) {
throw new UnexpectedError('Instance credential use ID cannot contain surrounding whitespace');
}
if (this.uses.has(credentialUse.id)) {
throw new UnexpectedError(
`Instance credential use "${credentialUse.id}" is already registered`,
);
}
if (credentialUse.credentialTypes.length === 0) {
throw new UnexpectedError(
`Instance credential use "${credentialUse.id}" must allow at least one credential type`,
);
}
this.uses.set(credentialUse.id, credentialUse);
}
get(credentialUseId: string): InstanceCredentialUse {
const credentialUse = this.uses.get(credentialUseId);
if (!credentialUse) {
throw new UnexpectedError(`Unknown instance credential use "${credentialUseId}"`);
}
return credentialUse;
}
}

View File

@ -0,0 +1,63 @@
# Instance credentials
Instance credentials are credential rows with `usageScope: 'instance'`. Owners and
admins manage them through the global `credential:manageInstance` scope. They have no
`SharedCredentials` owner row and workflows cannot use them.
`InstanceCredentialBroker` is the only feature-facing API for using them. A
**credential use** is a stable feature-purpose policy (for example `instance-ai:model`)
that identifies trusted backend code, not a user. Project credentials and disallowed
credential types fail closed.
## Integrating a feature
1. **Define each credential use** as an exported const the feature passes to every
broker call, so the feature uses one typed policy object consistently:
```typescript
export const MY_FEATURE_CREDENTIAL_USE: InstanceCredentialUse = {
id: 'my-feature:purpose',
credentialTypes: ['someApi'],
};
```
2. **Register every use during module initialization**, before loading settings or
making broker calls:
```typescript
broker.registerUse(MY_FEATURE_CREDENTIAL_USE);
```
Unregistered use IDs fail closed. The broker always enforces the registered
credential types, even if a caller passes a different policy object with the same ID.
3. **List matching credentials.** Only `id`, `name`, and `type` are returned:
```typescript
await broker.listForUse(MY_FEATURE_CREDENTIAL_USE);
```
4. **Assign or clear the credential through the broker.** The broker validates the
credential usage scope and type before it writes the
`instance_credential_assignment` row:
```typescript
await broker.assignForUse(MY_FEATURE_CREDENTIAL_USE, credentialId);
await broker.clearForUse(MY_FEATURE_CREDENTIAL_USE);
```
5. **Resolve at runtime** with the credential use. The broker reads the assignment,
repeats the policy checks, and decrypts the credential on the server:
```typescript
await broker.resolveForUse(MY_FEATURE_CREDENTIAL_USE);
```
Apply your feature's deployment and license gates before broker calls; the broker
enforces credential policy, not feature availability. The assignment foreign key
prevents credential deletion while it has any assignment. Feature settings should
store only feature data. If that data depends on the credential, update it in the
same transaction as the assignment.
For a complete example, see the `instance-ai:model`, sandbox, and search credential uses
in `packages/cli/src/modules/instance-ai/`.

View File

@ -64,6 +64,21 @@ describe('CredentialsRepository', () => {
});
});
describe('findStartingWith', () => {
it('only searches project credential names', async () => {
entityManager.find.mockResolvedValueOnce([]);
await repository.findStartingWith('API key');
expect(entityManager.find).toHaveBeenCalledWith(
CredentialsEntity,
expect.objectContaining({
where: expect.objectContaining({ usageScope: 'project' }),
}),
);
});
});
describe('findAllGlobalCredentials', () => {
test('should find all global credentials without data by default', async () => {
// ARRANGE
@ -240,6 +255,7 @@ describe('CredentialsRepository', () => {
// ASSERT
expect(entityManager.findBy).toHaveBeenCalledWith(CredentialsEntity, {
usageScope: 'project',
shared: { project: { type: 'personal' } },
});
expect(credentials).toHaveLength(2);
@ -271,6 +287,7 @@ describe('CredentialsRepository', () => {
// ASSERT
expect(entityManager.findBy).toHaveBeenCalledWith(CredentialsEntity, {
usageScope: 'project',
shared: { project: { sharedWorkflows: { workflowId } } },
});
expect(credentials).toHaveLength(2);
@ -303,6 +320,7 @@ describe('CredentialsRepository', () => {
// ASSERT
expect(entityManager.findBy).toHaveBeenCalledWith(CredentialsEntity, {
usageScope: 'project',
shared: { projectId },
});
expect(credentials).toHaveLength(2);

View File

@ -61,6 +61,8 @@ describe('CredentialsPermissionChecker', () => {
node.credentials!.someCredential.id = credentialId;
ownershipService.getWorkflowProjectCached.mockResolvedValueOnce(personalProject);
projectService.findProjectsWorkflowIsIn.mockResolvedValueOnce([personalProject.id]);
credentialsRepository.find.mockResolvedValue([]);
credentialsRepository.findNonProjectCredentialsByIds.mockResolvedValue([]);
});
it('should throw if a node has a credential without an id', async () => {
@ -70,7 +72,6 @@ describe('CredentialsPermissionChecker', () => {
'Node "Test Node" uses invalid credential',
);
expect(projectService.findProjectsWorkflowIsIn).toHaveBeenCalledWith(workflowId);
expect(sharedCredentialsRepository.getFilteredAccessibleCredentials).not.toHaveBeenCalled();
});
@ -109,7 +110,6 @@ describe('CredentialsPermissionChecker', () => {
it('should not throw an error if the workflow has no credentials', async () => {
await expect(permissionChecker.check(workflowId, [])).resolves.not.toThrow();
expect(projectService.findProjectsWorkflowIsIn).toHaveBeenCalledWith(workflowId);
expect(sharedCredentialsRepository.getFilteredAccessibleCredentials).not.toHaveBeenCalled();
});
@ -139,6 +139,42 @@ describe('CredentialsPermissionChecker', () => {
expect(sharedCredentialsRepository.getFilteredAccessibleCredentials).not.toHaveBeenCalled();
});
it('should reject instance credentials even when the home project owner has global scope', async () => {
const projectOwner = mock<User>({ role: GLOBAL_OWNER_ROLE });
ownershipService.getPersonalProjectOwnerCached.mockResolvedValue(projectOwner);
const instanceCredential = mock<CredentialsEntity>({
id: credentialId,
usageScope: 'instance',
});
credentialsRepository.findNonProjectCredentialsByIds.mockResolvedValueOnce([
instanceCredential,
]);
await expect(permissionChecker.check(workflowId, [node])).rejects.toThrow(
'Node "Test Node" does not have access to the credential',
);
expect(credentialsRepository.findNonProjectCredentialsByIds).toHaveBeenCalledWith([
credentialId,
]);
expect(sharedCredentialsRepository.getFilteredAccessibleCredentials).not.toHaveBeenCalled();
});
it('should reject instance credentials for member workflows', async () => {
ownershipService.getPersonalProjectOwnerCached.mockResolvedValueOnce(null);
const instanceCredential = mock<CredentialsEntity>({
id: credentialId,
usageScope: 'instance',
});
credentialsRepository.findNonProjectCredentialsByIds.mockResolvedValueOnce([
instanceCredential,
]);
await expect(permissionChecker.check(workflowId, [node])).rejects.toThrow(
'Node "Test Node" does not have access to the credential',
);
});
it('should allow global credentials for any project', async () => {
ownershipService.getPersonalProjectOwnerCached.mockResolvedValueOnce(null);
sharedCredentialsRepository.getFilteredAccessibleCredentials.mockResolvedValueOnce([]);
@ -159,6 +195,7 @@ describe('CredentialsPermissionChecker', () => {
select: ['id'],
where: {
isGlobal: true,
usageScope: 'project',
},
});
});
@ -175,11 +212,12 @@ describe('CredentialsPermissionChecker', () => {
projectService.findProjectsWorkflowIsIn.mockResolvedValue([teamProject.id]);
ownershipService.getPersonalProjectOwnerCached.mockResolvedValue(null);
sharedCredentialsRepository.getFilteredAccessibleCredentials.mockResolvedValue([]);
credentialsRepository.findNonProjectCredentialsByIds.mockResolvedValue([]);
const globalCredential = mock<CredentialsEntity>({
id: credentialId,
isGlobal: true,
});
credentialsRepository.find.mockResolvedValue([globalCredential]);
credentialsRepository.find.mockResolvedValueOnce([globalCredential]);
await expect(permissionChecker.check(workflowId, [node])).resolves.not.toThrow();
@ -192,6 +230,7 @@ describe('CredentialsPermissionChecker', () => {
select: ['id'],
where: {
isGlobal: true,
usageScope: 'project',
},
});
});
@ -233,6 +272,7 @@ describe('CredentialsPermissionChecker', () => {
ownershipService.getWorkflowProjectCached.mockResolvedValue(teamProject);
ownershipService.getPersonalProjectOwnerCached.mockResolvedValue(null);
projectService.findProjectsWorkflowIsIn.mockResolvedValue([teamProject.id]);
credentialsRepository.findNonProjectCredentialsByIds.mockResolvedValue([]);
});
it('should only check the active credential type for nodes with nodeCredentialType', async () => {
@ -456,5 +496,17 @@ describe('CredentialsPermissionChecker', () => {
await expect(permissionChecker.checkForUser(userId, [node])).resolves.not.toThrow();
expect(credentialsFinderService.findCredentialsForUser).not.toHaveBeenCalled();
});
it('should reject instance credentials before the global-scope shortcut', async () => {
userRepository.findOne.mockResolvedValueOnce(mock<User>({ role: GLOBAL_OWNER_ROLE }));
credentialsRepository.findNonProjectCredentialsByIds.mockResolvedValueOnce([
mock<CredentialsEntity>({ id: credentialId, usageScope: 'instance' }),
]);
await expect(permissionChecker.checkForUser(userId, [node])).rejects.toThrow(
'Node "Test Node" uses a credential you do not have access to',
);
expect(credentialsFinderService.findCredentialsForUser).not.toHaveBeenCalled();
});
});
});

View File

@ -75,6 +75,11 @@ export class CredentialsPermissionChecker {
// Cannot resolve the triggering user - fail closed.
throw new InaccessibleCredentialForUserError(credIdsToNodes[workflowCredIds[0]][0]);
}
const unavailableCredentials =
await this.credentialsRepository.findNonProjectCredentialsByIds(workflowCredIds);
if (unavailableCredentials.length > 0) {
throw new InaccessibleCredentialForUserError(credIdsToNodes[unavailableCredentials[0].id][0]);
}
// A user with instance-wide credential listing can use any credential.
if (hasGlobalScope(user, 'credential:list')) return;
@ -96,6 +101,14 @@ export class CredentialsPermissionChecker {
*/
async check(workflowId: string, nodes: INode[]) {
const homeProject = await this.ownershipService.getWorkflowProjectCached(workflowId);
const credIdsToNodes = this.mapCredIdsToNodes(nodes);
const workflowCredIds = Object.keys(credIdsToNodes);
if (workflowCredIds.length === 0) return;
await this.ensureOnlyWorkflowCredentials(workflowCredIds, credIdsToNodes, homeProject);
const homeProjectOwner = await this.ownershipService.getPersonalProjectOwnerCached(
homeProject.id,
);
@ -109,11 +122,6 @@ export class CredentialsPermissionChecker {
return;
}
const projectIds = await this.projectService.findProjectsWorkflowIsIn(workflowId);
const credIdsToNodes = this.mapCredIdsToNodes(nodes);
const workflowCredIds = Object.keys(credIdsToNodes);
if (workflowCredIds.length === 0) return;
const accessible = await this.sharedCredentialsRepository.getFilteredAccessibleCredentials(
projectIds,
@ -130,6 +138,19 @@ export class CredentialsPermissionChecker {
}
}
private async ensureOnlyWorkflowCredentials(
workflowCredIds: string[],
credIdsToNodes: { [credentialId: string]: INode[] },
homeProject: Project,
) {
const unavailableCredentials =
await this.credentialsRepository.findNonProjectCredentialsByIds(workflowCredIds);
if (unavailableCredentials.length > 0) {
const nodeToFlag = credIdsToNodes[unavailableCredentials[0].id][0];
throw new InaccessibleCredentialError(nodeToFlag, homeProject);
}
}
/**
* Adds global credentials (isGlobal: true) to the set of accessible credentials.
*/
@ -138,7 +159,7 @@ export class CredentialsPermissionChecker {
): Promise<Set<string>> {
const accessibleSet = new Set(accessibleCredentialIds);
const globalCredentials = await this.credentialsRepository.find({
where: { isGlobal: true },
where: { isGlobal: true, usageScope: 'project' },
select: ['id'],
});
for (const globalCred of globalCredentials) {

View File

@ -1,6 +1,12 @@
import { Logger } from '@n8n/backend-common';
import type { InstanceAiConfig } from '@n8n/config';
import type { SettingsRepository, User, UserRepository } from '@n8n/db';
import type {
CredentialsEntity,
DbLockService,
SettingsRepository,
User,
UserRepository,
} from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'vitest-mock-extended';
@ -10,9 +16,14 @@ import type { AiService } from '@/services/ai.service';
import type { UserService } from '@/services/user.service';
import type { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import type { CredentialsService } from '@/credentials/credentials.service';
import type { InstanceCredentialBroker } from '@/credentials/instance-credential-broker';
import { InstanceAiSettingsService } from '../instance-ai-settings.service';
type CredentialOperationContext = NonNullable<
Parameters<InstanceCredentialBroker['clearForUse']>[1]
>;
describe('InstanceAiSettingsService', () => {
const globalConfig = mock<{
instanceAi: InstanceAiConfig;
@ -35,18 +46,21 @@ describe('InstanceAiSettingsService', () => {
} as unknown as InstanceAiConfig,
deployment: { type: 'default' },
});
const operationContext = mock<CredentialOperationContext>();
const dbLockService = mock<DbLockService>();
const settingsRepository = mock<SettingsRepository>();
const userRepository = mock<UserRepository>();
const userService = mock<UserService>();
const aiService = mock<AiService>();
const credentialsService = mock<CredentialsService>();
const credentialsFinderService = mock<CredentialsFinderService>();
const instanceCredentialBroker = mock<InstanceCredentialBroker>();
const eventService = mock<EventService>();
let service: InstanceAiSettingsService;
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
Object.assign(globalConfig.instanceAi, {
sandboxEnabled: false,
sandboxProvider: 'n8n-sandbox',
@ -56,14 +70,27 @@ describe('InstanceAiSettingsService', () => {
browserMcp: false,
});
globalConfig.deployment.type = 'default';
instanceCredentialBroker.listForUse.mockResolvedValue([]);
instanceCredentialBroker.getAssignedCredentialId.mockResolvedValue(null);
settingsRepository.findByKeyInContext.mockImplementation(
async (key) => await settingsRepository.findByKey(key),
);
settingsRepository.upsertByKey.mockImplementation(async (key, value, loadOnStartup) => {
await settingsRepository.upsert({ key, value, loadOnStartup }, ['key']);
});
dbLockService.withLockContext.mockImplementation(async (_lockId, fn) => {
return await fn(operationContext);
});
service = new InstanceAiSettingsService(
globalConfig as never,
dbLockService,
settingsRepository,
userRepository,
userService,
aiService,
credentialsService,
credentialsFinderService,
instanceCredentialBroker,
eventService,
);
});
@ -157,8 +184,8 @@ describe('InstanceAiSettingsService', () => {
settingsRepository.upsert.mockResolvedValue(undefined as never);
});
it('defaults to true', () => {
expect(service.getAdminSettings().mcpAccessEnabled).toBe(true);
it('defaults to true', async () => {
expect((await service.getAdminSettings()).mcpAccessEnabled).toBe(true);
expect(service.isMcpAccessEnabled()).toBe(true);
});
@ -187,7 +214,7 @@ describe('InstanceAiSettingsService', () => {
const result = await service.updateAdminSettings({ mcpAccessEnabled: false });
expect(result.mcpAccessEnabled).toBe(false);
expect(service.getAdminSettings().mcpAccessEnabled).toBe(false);
expect((await service.getAdminSettings()).mcpAccessEnabled).toBe(false);
expect(settingsRepository.upsert).toHaveBeenCalledWith(
expect.objectContaining({
value: expect.stringContaining('"mcpAccessEnabled":false'),
@ -195,6 +222,135 @@ describe('InstanceAiSettingsService', () => {
['key'],
);
});
it('merges an update with the latest persisted settings', async () => {
settingsRepository.findByKey.mockResolvedValue({
key: 'instanceAi.settings',
value: JSON.stringify({ mcpAccessEnabled: false }),
loadOnStartup: true,
} as never);
await service.updateAdminSettings({ permissions: { createWorkflow: 'always_allow' } });
expect(settingsRepository.upsert).toHaveBeenCalledWith(
expect.objectContaining({
value: expect.stringContaining('"mcpAccessEnabled":false'),
}),
['key'],
);
expect(settingsRepository.upsert).toHaveBeenCalledWith(
expect.objectContaining({
value: expect.stringContaining('"createWorkflow":"always_allow"'),
}),
['key'],
);
});
it('does not apply an update when persistence fails', async () => {
settingsRepository.upsert.mockRejectedValue(new Error('write failed'));
await expect(service.updateAdminSettings({ mcpAccessEnabled: false })).rejects.toThrow(
'write failed',
);
expect((await service.getAdminSettings()).mcpAccessEnabled).toBe(true);
});
});
describe('instance model credential', () => {
beforeEach(() => {
aiService.isProxyEnabled.mockReturnValue(false);
settingsRepository.upsert.mockResolvedValue(undefined as never);
});
it('delegates model credential validation to the broker', async () => {
instanceCredentialBroker.assignForUse.mockRejectedValue(
new UnprocessableRequestError('Invalid instance credential'),
);
await expect(service.updateAdminSettings({ modelCredentialId: 'cred-1' })).rejects.toThrow(
'Invalid instance credential',
);
expect(instanceCredentialBroker.assignForUse).toHaveBeenCalledWith(
expect.objectContaining({ id: 'instance-ai:model' }),
'cred-1',
operationContext,
);
});
it('uses the admin credential before per-user credentials', async () => {
const credential = mock<CredentialsEntity>({
id: 'cred-1',
name: 'Admin model',
type: 'openAiApi',
usageScope: 'instance',
});
instanceCredentialBroker.resolveForUse.mockResolvedValue({
id: credential.id,
name: credential.name,
type: credential.type,
data: { apiKey: 'admin-key' },
});
instanceCredentialBroker.assignForUse.mockResolvedValue({
id: credential.id,
name: credential.name,
type: credential.type,
});
await service.updateAdminSettings({ modelCredentialId: credential.id });
const result = await service.resolveModelConfig(
mock<User>({
settings: {
instanceAi: { credentialId: 'user-credential', modelName: 'gpt-4.1' },
},
}),
);
expect(result).toEqual({ id: 'openai/gpt-4.1', url: '', apiKey: 'admin-key' });
expect(instanceCredentialBroker.resolveForUse).toHaveBeenCalledWith(
expect.objectContaining({ id: 'instance-ai:model' }),
);
expect(credentialsFinderService.findCredentialForUser).not.toHaveBeenCalled();
expect(settingsRepository.upsert).toHaveBeenCalledWith(
expect.objectContaining({ value: expect.not.stringContaining('modelCredentialId') }),
['key'],
);
});
it('reads the configured model credential from the broker', async () => {
instanceCredentialBroker.getAssignedCredentialId.mockResolvedValue('cred-1');
await expect(service.getAdminSettings()).resolves.toMatchObject({
modelCredentialId: 'cred-1',
});
});
it('ignores a configured admin credential on cloud', async () => {
const credential = mock<CredentialsEntity>({
id: 'cred-1',
name: 'Admin model',
type: 'openAiApi',
usageScope: 'instance',
});
instanceCredentialBroker.resolveForUse.mockResolvedValue({
id: credential.id,
name: credential.name,
type: credential.type,
data: { apiKey: 'admin-key' },
});
instanceCredentialBroker.assignForUse.mockResolvedValue({
id: credential.id,
name: credential.name,
type: credential.type,
});
await service.updateAdminSettings({ modelCredentialId: credential.id });
vi.clearAllMocks();
globalConfig.deployment.type = 'cloud';
await expect(service.resolveModelConfig(mock<User>())).resolves.toBe('openai/gpt-4');
expect(instanceCredentialBroker.resolveForUse).not.toHaveBeenCalled();
await expect(service.listInstanceModelCredentials()).resolves.toEqual([]);
});
});
describe('executeMcpTool permission', () => {
@ -203,8 +359,10 @@ describe('InstanceAiSettingsService', () => {
settingsRepository.upsert.mockResolvedValue(undefined as never);
});
it('defaults to require_approval', () => {
expect(service.getAdminSettings().permissions.executeMcpTool).toBe('require_approval');
it('defaults to require_approval', async () => {
expect((await service.getAdminSettings()).permissions.executeMcpTool).toBe(
'require_approval',
);
});
it('persists and reflects an update', async () => {
@ -213,7 +371,7 @@ describe('InstanceAiSettingsService', () => {
});
expect(result.permissions.executeMcpTool).toBe('always_allow');
expect(service.getAdminSettings().permissions.executeMcpTool).toBe('always_allow');
expect((await service.getAdminSettings()).permissions.executeMcpTool).toBe('always_allow');
expect(settingsRepository.upsert).toHaveBeenCalledWith(
expect.objectContaining({
value: expect.stringContaining('"executeMcpTool":"always_allow"'),
@ -325,6 +483,12 @@ describe('InstanceAiSettingsService', () => {
});
describe('updateAdminSettings', () => {
it('should reject model credentials on cloud', async () => {
await expect(service.updateAdminSettings({ modelCredentialId: 'cred-1' })).rejects.toThrow(
UnprocessableRequestError,
);
});
it('should reject advanced fields on cloud', async () => {
await expect(service.updateAdminSettings({ mcpServers: '[]' })).rejects.toThrow(
UnprocessableRequestError,

View File

@ -59,6 +59,7 @@ import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { CredentialsService } from '@/credentials/credentials.service';
import type { Push } from '@/push';
import type { Publisher } from '@/scaling/pubsub/publisher.service';
import type { ProjectService } from '@/services/project.service.ee';
import type { UrlService } from '@/services/url.service';
@ -102,6 +103,7 @@ describe('InstanceAiController', () => {
const durableLogMetrics = mock<DurableLogMetrics>();
const moduleRegistry = mock<ModuleRegistry>();
const push = mock<Push>();
const publisher = mock<Publisher>();
const urlService = mock<UrlService>();
const globalConfig = mock<GlobalConfig>({
instanceAi: { gatewayApiKey: 'static-key', durableLog: false },
@ -137,6 +139,7 @@ describe('InstanceAiController', () => {
credentialsService,
projectService,
instanceAiErrorReporter,
publisher,
globalConfig,
);
@ -923,7 +926,7 @@ describe('InstanceAiController', () => {
});
it('should disconnect all gateways when enabled is set to false', async () => {
settingsService.updateAdminSettings.mockResolvedValue({} as never);
settingsService.updateAdminSettings.mockResolvedValue({ enabled: false } as never);
gatewayService.disconnectAllGateways.mockReturnValue(['user-a', 'user-b']);
const payload = { enabled: false } as InstanceAiAdminSettingsUpdateRequest;
@ -940,7 +943,10 @@ describe('InstanceAiController', () => {
});
it('should disconnect all gateways when localGatewayDisabled is set to true', async () => {
settingsService.updateAdminSettings.mockResolvedValue({} as never);
settingsService.updateAdminSettings.mockResolvedValue({
enabled: true,
localGatewayDisabled: true,
} as never);
gatewayService.disconnectAllGateways.mockReturnValue(['user-c']);
const payload = { localGatewayDisabled: true } as InstanceAiAdminSettingsUpdateRequest;
@ -956,7 +962,10 @@ describe('InstanceAiController', () => {
});
it('should not disconnect gateways when enabling features', async () => {
settingsService.updateAdminSettings.mockResolvedValue({} as never);
settingsService.updateAdminSettings.mockResolvedValue({
enabled: true,
localGatewayDisabled: false,
} as never);
const payload = {
enabled: true,
localGatewayDisabled: false,
@ -966,6 +975,25 @@ describe('InstanceAiController', () => {
expect(gatewayService.disconnectAllGateways).not.toHaveBeenCalled();
});
it('should publish settings reloads to other mains', async () => {
settingsService.updateAdminSettings.mockResolvedValue({ enabled: true } as never);
await controller.updateAdminSettings(req, res, { enabled: true });
expect(publisher.publishCommand).toHaveBeenCalledWith({
command: 'reload-instance-ai-settings',
});
});
it('should wait for the settings reload to be published', async () => {
settingsService.updateAdminSettings.mockResolvedValue({ enabled: true } as never);
publisher.publishCommand.mockRejectedValue(new Error('publish failed'));
await expect(controller.updateAdminSettings(req, res, { enabled: true })).rejects.toThrow(
'publish failed',
);
});
});
describe('getUserPreferences', () => {
@ -1030,6 +1058,15 @@ describe('InstanceAiController', () => {
});
});
describe('listInstanceModelCredentials', () => {
it('should require instanceAi:manage scope', () => {
expect(scopeOf('listInstanceModelCredentials')).toEqual({
scope: 'instanceAi:manage',
globalOnly: true,
});
});
});
describe('listThreads', () => {
it('should require instanceAi:message scope', () => {
expect(scopeOf('listThreads')).toEqual({ scope: 'instanceAi:message', globalOnly: true });
@ -1697,6 +1734,7 @@ describe('InstanceAiController — durable-log SSE replay (flag on)', () => {
mock<CredentialsService>(),
mock<ProjectService>(),
mock<InstanceAiErrorReporterService>(),
mock<Publisher>(),
globalConfig,
);

View File

@ -11,15 +11,16 @@ import type {
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import type { InstanceAiConfig, DeploymentConfig } from '@n8n/config';
import { SettingsRepository, UserRepository } from '@n8n/db';
import type { User } from '@n8n/db';
import { DbLock, DbLockService, SettingsRepository, UserRepository } from '@n8n/db';
import type { CredentialsEntity, User } from '@n8n/db';
import { Container, Service } from '@n8n/di';
import type { ModelConfig } from '@n8n/instance-ai';
import type { IUserSettings } from 'n8n-workflow';
import type { ICredentialDataDecryptedObject, IUserSettings } from 'n8n-workflow';
import { jsonParse } from 'n8n-workflow';
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { CredentialsService } from '@/credentials/credentials.service';
import { InstanceCredentialBroker } from '@/credentials/instance-credential-broker';
import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error';
import { EventService } from '@/events/event.service';
import { AiService } from '@/services/ai.service';
@ -55,7 +56,10 @@ const CREDENTIAL_TO_MODEL_PROVIDER: Record<string, string> = {
cohereApi: 'cohere',
};
const SUPPORTED_CREDENTIAL_TYPES = Object.keys(CREDENTIAL_TO_MODEL_PROVIDER);
export const INSTANCE_AI_MODEL_CREDENTIAL_POLICY = {
id: 'instance-ai:model',
credentialTypes: Object.keys(CREDENTIAL_TO_MODEL_PROVIDER),
};
/** Fields that contain the base URL per credential type. */
const URL_FIELD_MAP: Record<string, string> = {
@ -115,12 +119,14 @@ export class InstanceAiSettingsService {
constructor(
globalConfig: GlobalConfig,
private readonly dbLockService: DbLockService,
private readonly settingsRepository: SettingsRepository,
private readonly userRepository: UserRepository,
private readonly userService: UserService,
private readonly aiService: AiService,
private readonly credentialsService: CredentialsService,
private readonly credentialsFinderService: CredentialsFinderService,
private readonly instanceCredentialBroker: InstanceCredentialBroker,
private readonly eventService: EventService,
) {
this.config = globalConfig.instanceAi;
@ -145,14 +151,7 @@ export class InstanceAiSettingsService {
sandboxProvider: this.config.sandboxProvider,
};
const row = await this.settingsRepository.findByKey(ADMIN_SETTINGS_KEY);
if (row) {
const persisted = jsonParse<PersistedAdminSettings>(row.value, {
fallbackValue: {},
});
this.applyAdminSettings(persisted);
}
await this.reloadFromDb();
// Surface the effective sandbox config so operators (and CI) can tell whether env vars
// or a persisted DB setting are in effect — these can silently disagree.
const c = this.config;
@ -174,8 +173,14 @@ export class InstanceAiSettingsService {
// ── Admin settings ────────────────────────────────────────────────────
getAdminSettings(): InstanceAiAdminSettingsResponse {
async getAdminSettings(): Promise<InstanceAiAdminSettingsResponse> {
const c = this.config;
const modelCredentialId =
this.isCloud || this.aiService.isProxyEnabled()
? null
: await this.instanceCredentialBroker.getAssignedCredentialId(
INSTANCE_AI_MODEL_CREDENTIAL_POLICY,
);
return {
enabled: this.enabled,
permissions: { ...this.permissions },
@ -188,6 +193,7 @@ export class InstanceAiSettingsService {
daytonaCredentialId: this.adminDaytonaCredentialId,
n8nSandboxCredentialId: this.adminN8nSandboxCredentialId,
searchCredentialId: this.adminSearchCredentialId,
modelCredentialId,
localGatewayDisabled: this.isLocalGatewayDisabled(),
browserUseEnabled: this.isBrowserUseEnabled(),
};
@ -201,37 +207,52 @@ export class InstanceAiSettingsService {
InstanceAiSettingsService.MANAGED_ADMIN_FIELDS,
this.deploymentLabel(),
);
this.validateAdminSettingsUpdate(update);
const c = this.config;
const previousMcpServers = c.mcpServers;
const previousMcpAccessEnabled = this.mcpAccessEnabled;
if (update.enabled !== undefined) this.enabled = update.enabled;
if (update.permissions) {
this.permissions = { ...this.permissions, ...update.permissions };
if (this.isCloud || this.aiService.isProxyEnabled()) {
this.rejectManagedFields(update, ['modelCredentialId'], this.deploymentLabel());
}
if (update.mcpServers !== undefined) c.mcpServers = update.mcpServers;
if (update.mcpAccessEnabled !== undefined) this.mcpAccessEnabled = update.mcpAccessEnabled;
if (update.sandboxEnabled !== undefined) c.sandboxEnabled = update.sandboxEnabled;
if (update.sandboxProvider !== undefined) c.sandboxProvider = update.sandboxProvider;
if (update.sandboxImage !== undefined) c.sandboxImage = update.sandboxImage;
if (update.sandboxTimeout !== undefined) c.sandboxTimeout = update.sandboxTimeout;
if (update.daytonaCredentialId !== undefined)
this.adminDaytonaCredentialId = update.daytonaCredentialId;
if (update.n8nSandboxCredentialId !== undefined)
this.adminN8nSandboxCredentialId = update.n8nSandboxCredentialId;
if (update.searchCredentialId !== undefined)
this.adminSearchCredentialId = update.searchCredentialId;
if (update.localGatewayDisabled !== undefined)
c.localGatewayDisabled = update.localGatewayDisabled;
if (update.browserUseEnabled !== undefined) c.browserUseEnabled = update.browserUseEnabled;
await this.persistAdminSettings();
const { modelCredentialId, ...settingsUpdate } = update;
const { previous, next } = await this.dbLockService.withLockContext(
DbLock.INSTANCE_AI_SETTINGS,
async (ctx) => {
const persisted = await this.settingsRepository.findByKeyInContext(ADMIN_SETTINGS_KEY, ctx);
const current = this.mergeAdminSettings(
this.snapshotAdminSettings(),
this.parsePersistedAdminSettings(persisted?.value),
);
this.validateAdminSettingsUpdate(settingsUpdate, current);
if (modelCredentialId === null) {
await this.instanceCredentialBroker.clearForUse(INSTANCE_AI_MODEL_CREDENTIAL_POLICY, ctx);
} else if (modelCredentialId !== undefined) {
const credential = await this.instanceCredentialBroker.assignForUse(
INSTANCE_AI_MODEL_CREDENTIAL_POLICY,
modelCredentialId,
ctx,
);
this.ensureCredentialMatchesConfiguredModel(credential.type);
}
this.eventService.emit('instance-ai-settings-updated', {
mcpSettingsChanged:
c.mcpServers !== previousMcpServers || this.mcpAccessEnabled !== previousMcpAccessEnabled,
});
const previous = this.snapshotAdminSettings();
const next = this.mergeAdminSettings(current, settingsUpdate);
await this.settingsRepository.upsertByKey(
ADMIN_SETTINGS_KEY,
JSON.stringify(next),
true,
ctx,
);
return { previous, next };
},
);
this.applyAdminSettings(next);
this.emitSettingsUpdated(previous, next);
return this.getAdminSettings();
return await this.getAdminSettings();
}
async reloadFromDb(): Promise<void> {
const previous = this.snapshotAdminSettings();
const persisted = await this.readPersistedAdminSettings();
this.applyAdminSettings(persisted);
this.emitSettingsUpdated(previous, this.snapshotAdminSettings());
}
// ── User preferences ──────────────────────────────────────────────────
@ -290,7 +311,7 @@ export class InstanceAiSettingsService {
'credential:read',
]);
return allCredentials
.filter((c) => SUPPORTED_CREDENTIAL_TYPES.includes(c.type))
.filter((c) => INSTANCE_AI_MODEL_CREDENTIAL_POLICY.credentialTypes.includes(c.type))
.map((c) => ({
id: c.id,
name: c.name,
@ -299,6 +320,19 @@ export class InstanceAiSettingsService {
}));
}
async listInstanceModelCredentials(): Promise<InstanceAiModelCredential[]> {
if (this.isCloud || this.aiService.isProxyEnabled()) return [];
const instanceCredentials = await this.instanceCredentialBroker.listForUse(
INSTANCE_AI_MODEL_CREDENTIAL_POLICY,
);
return instanceCredentials.map((c) => ({
id: c.id,
name: c.name,
type: c.type,
provider: CREDENTIAL_TO_MODEL_PROVIDER[c.type] ?? 'custom',
}));
}
/** List credentials the user can access that are usable as sandbox/search services. */
async listServiceCredentials(user: User): Promise<InstanceAiModelCredential[]> {
if (this.aiService.isProxyEnabled()) return [];
@ -461,9 +495,15 @@ export class InstanceAiSettingsService {
return prefs.modelName ?? this.extractModelName(this.config.model);
}
/** Resolve the current model configuration for an agent run. */
async resolveModelConfig(user: User): Promise<ModelConfig> {
const prefs = this.readUserPreferences(user);
const modelName = prefs.modelName ?? this.extractModelName(this.config.model);
const adminModelConfig = await this.resolveAdminModelConfig(modelName);
if (adminModelConfig) {
return adminModelConfig;
}
const credentialId = prefs.credentialId ?? null;
if (!credentialId) {
@ -480,17 +520,57 @@ export class InstanceAiSettingsService {
return this.envVarModelConfig();
}
const provider = CREDENTIAL_TO_MODEL_PROVIDER[credential.type];
return (
(await this.buildModelConfigFromCredential(credential, modelName)) ?? this.envVarModelConfig()
);
}
private async resolveAdminModelConfig(modelName: string): Promise<ModelConfig | null> {
if (this.isCloud || this.aiService.isProxyEnabled()) return null;
const resolved = await this.resolveModelCredential();
if (!resolved) return null;
return this.buildModelConfig(resolved.type, resolved.data, modelName);
}
private async resolveModelCredential() {
return await this.instanceCredentialBroker.resolveForUse(INSTANCE_AI_MODEL_CREDENTIAL_POLICY);
}
private ensureCredentialMatchesConfiguredModel(credentialType: string): void {
const slash = this.config.model.indexOf('/');
if (slash < 0) return;
const configuredProvider = this.config.model.slice(0, slash);
const credentialProvider = CREDENTIAL_TO_MODEL_PROVIDER[credentialType];
if (credentialProvider !== configuredProvider) {
throw new UnprocessableRequestError(
`This credential is for "${credentialProvider}" but the configured model "${this.config.model}" requires a "${configuredProvider}" credential. Select a matching credential or set N8N_INSTANCE_AI_MODEL to a "${credentialProvider}" model.`,
);
}
}
private async buildModelConfigFromCredential(
credential: CredentialsEntity,
modelName: string,
): Promise<ModelConfig | null> {
const data = await this.credentialsService.decrypt(credential, true);
return this.buildModelConfig(credential.type, data, modelName);
}
private buildModelConfig(
credentialType: string,
data: ICredentialDataDecryptedObject,
modelName: string,
): ModelConfig | null {
const provider = CREDENTIAL_TO_MODEL_PROVIDER[credentialType];
if (!provider) {
return this.envVarModelConfig();
return null;
}
const data = await this.credentialsService.decrypt(credential, true);
const apiKey = typeof data.apiKey === 'string' ? data.apiKey : '';
const urlField = URL_FIELD_MAP[credential.type];
const urlField = URL_FIELD_MAP[credentialType];
const rawUrl = urlField ? data[urlField] : undefined;
const baseUrl = typeof rawUrl === 'string' ? rawUrl : '';
const modelName = prefs.modelName ?? this.extractModelName(this.config.model);
const id: `${string}/${string}` = `${provider}/${modelName}`;
if (baseUrl) {
@ -551,8 +631,10 @@ export class InstanceAiSettingsService {
}
}
private validateAdminSettingsUpdate(update: InstanceAiAdminSettingsUpdateRequest): void {
const c = this.config;
private validateAdminSettingsUpdate(
update: InstanceAiAdminSettingsUpdateRequest,
current: PersistedAdminSettings,
): void {
const touchesSandboxSettings =
update.sandboxEnabled !== undefined ||
update.sandboxProvider !== undefined ||
@ -567,8 +649,10 @@ export class InstanceAiSettingsService {
// `update.sandboxProvider` is already enum-validated by the request DTO; we only
// need the resolved provider here to enforce the cross-field service-URL rule,
// which spans the request body and env-backed config and can't live in the schema.
const sandboxProvider = update.sandboxProvider ?? normalizeSandboxProvider(c.sandboxProvider);
const sandboxEnabled = update.sandboxEnabled ?? c.sandboxEnabled;
const sandboxProvider = normalizeSandboxProvider(
update.sandboxProvider ?? current.sandboxProvider,
);
const sandboxEnabled = update.sandboxEnabled ?? current.sandboxEnabled ?? false;
const unavailableReason = this.getSandboxUnavailableReason(sandboxEnabled, sandboxProvider);
if (unavailableReason) {
throw new UnprocessableRequestError(unavailableReason);
@ -649,9 +733,9 @@ export class InstanceAiSettingsService {
return user.settings?.instanceAi ?? {};
}
private async persistAdminSettings(): Promise<void> {
private snapshotAdminSettings(): PersistedAdminSettings {
const c = this.config;
const value: PersistedAdminSettings = {
return {
enabled: this.enabled,
permissions: this.permissions,
mcpServers: c.mcpServers,
@ -666,10 +750,44 @@ export class InstanceAiSettingsService {
localGatewayDisabled: c.localGatewayDisabled,
browserUseEnabled: c.browserUseEnabled,
};
}
await this.settingsRepository.upsert(
{ key: ADMIN_SETTINGS_KEY, value: JSON.stringify(value), loadOnStartup: true },
['key'],
private mergeAdminSettings(
base: PersistedAdminSettings,
update: PersistedAdminSettings,
): PersistedAdminSettings {
return {
...base,
...update,
permissions: update.permissions
? { ...(base.permissions ?? DEFAULT_INSTANCE_AI_PERMISSIONS), ...update.permissions }
: base.permissions,
};
}
private async readPersistedAdminSettings(): Promise<PersistedAdminSettings> {
const row = await this.settingsRepository.findByKey(ADMIN_SETTINGS_KEY);
return this.parsePersistedAdminSettings(row?.value);
}
private parsePersistedAdminSettings(value: string | undefined): PersistedAdminSettings {
if (value === undefined) return {};
const settings = jsonParse<PersistedAdminSettings & { modelCredentialId?: string | null }>(
value,
{ fallbackValue: {} },
);
delete settings.modelCredentialId;
return settings;
}
private emitSettingsUpdated(
previous: PersistedAdminSettings,
current: PersistedAdminSettings,
): void {
this.eventService.emit('instance-ai-settings-updated', {
mcpSettingsChanged:
current.mcpServers !== previous.mcpServers ||
current.mcpAccessEnabled !== previous.mcpAccessEnabled,
});
}
}

View File

@ -19,7 +19,11 @@ import {
InstanceAiEvalRestoreThreadRequest,
InstanceAiEvalSeedDataTableRowsRequest,
} from '@n8n/api-types';
import type { InstanceAiAgentNode, InstanceAiEvent } from '@n8n/api-types';
import type {
InstanceAiAdminSettingsResponse,
InstanceAiAgentNode,
InstanceAiEvent,
} from '@n8n/api-types';
import { ModuleRegistry } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { AuthenticatedRequest, User, UserRepository } from '@n8n/db';
@ -32,6 +36,7 @@ import {
Put,
Patch,
Delete,
OnPubSubEvent,
Param,
Body,
Query,
@ -61,6 +66,7 @@ import { ConflictError } from '@/errors/response-errors/conflict.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { Push } from '@/push';
import { Publisher } from '@/scaling/pubsub/publisher.service';
import { ProjectService } from '@/services/project.service.ee';
import { UrlService } from '@/services/url.service';
@ -126,6 +132,7 @@ export class InstanceAiController {
private readonly credentialsService: CredentialsService,
private readonly projectService: ProjectService,
private readonly instanceAiErrorReporter: InstanceAiErrorReporterService,
private readonly publisher: Publisher,
globalConfig: GlobalConfig,
) {
this.gatewayApiKey = globalConfig.instanceAi.gatewayApiKey;
@ -661,7 +668,7 @@ export class InstanceAiController {
@Get('/settings')
@GlobalScope('instanceAi:manage')
async getAdminSettings(_req: AuthenticatedRequest) {
return this.settingsService.getAdminSettings();
return await this.settingsService.getAdminSettings();
}
@Put('/settings')
@ -672,31 +679,41 @@ export class InstanceAiController {
@Body payload: InstanceAiAdminSettingsUpdateRequest,
) {
const result = await this.settingsService.updateAdminSettings(payload);
await this.applyAdminSettingsSideEffects(result);
await this.publisher.publishCommand({ command: 'reload-instance-ai-settings' });
return result;
}
@OnPubSubEvent('reload-instance-ai-settings', { instanceType: 'main' })
async reloadAdminSettings() {
await this.settingsService.reloadFromDb();
await this.applyAdminSettingsSideEffects(await this.settingsService.getAdminSettings());
}
private async applyAdminSettingsSideEffects(settings: InstanceAiAdminSettingsResponse) {
await this.moduleRegistry.refreshModuleSettings('instance-ai');
if (payload.enabled === false || payload.browserUseEnabled === false) {
if (!settings.enabled || !settings.browserUseEnabled) {
await this.browserSessionService.shutdown();
}
if (payload.enabled === false || payload.localGatewayDisabled === true) {
const disconnectedUserIds = this.gatewayService.disconnectAllGateways();
if (disconnectedUserIds.length > 0) {
this.push.sendToUsers(
{
type: 'instanceAiGatewayStateChanged',
data: {
connected: false,
directory: null,
hostIdentifier: null,
toolCategories: [],
},
},
disconnectedUserIds,
);
}
}
if (settings.enabled && !settings.localGatewayDisabled) return;
return result;
const disconnectedUserIds = this.gatewayService.disconnectAllGateways();
if (disconnectedUserIds.length === 0) return;
this.push.sendToUsers(
{
type: 'instanceAiGatewayStateChanged',
data: {
connected: false,
directory: null,
hostIdentifier: null,
toolCategories: [],
},
},
disconnectedUserIds,
);
}
// ── User preferences (per-user, self-service) ──────────────────────────
@ -733,6 +750,12 @@ export class InstanceAiController {
return await this.settingsService.listServiceCredentials(req.user);
}
@Get('/settings/model-credentials')
@GlobalScope('instanceAi:manage')
async listInstanceModelCredentials(_req: AuthenticatedRequest) {
return await this.settingsService.listInstanceModelCredentials();
}
@Get('/threads')
@GlobalScope('instanceAi:message')
async listThreads(req: AuthenticatedRequest) {
@ -1265,7 +1288,7 @@ export class InstanceAiController {
// ── Helpers ──────────────────────────────────────────────────────────────
private assertBrowserChannelEnabled(): void {
if (!this.settingsService.getAdminSettings().browserUseEnabled) {
if (!this.settingsService.isBrowserUseEnabled()) {
throw new ForbiddenError('Browser Use is disabled');
}
}

View File

@ -16,8 +16,15 @@ export class InstanceAiModule implements ModuleInterface {
const logger = Container.get(Logger).scoped('instance-ai');
logger.warn(`${YELLOW}${WARNING_MESSAGE}${CLEAR}`);
const { InstanceAiSettingsService } = await import('./instance-ai-settings.service.js');
await Container.get(InstanceAiSettingsService).loadFromDb();
const { InstanceCredentialBroker } = await import(
'@/credentials/instance-credential-broker.js'
);
const { InstanceAiSettingsService, INSTANCE_AI_MODEL_CREDENTIAL_POLICY } = await import(
'./instance-ai-settings.service.js'
);
const settingsService = Container.get(InstanceAiSettingsService);
Container.get(InstanceCredentialBroker).registerUse(INSTANCE_AI_MODEL_CREDENTIAL_POLICY);
await settingsService.loadFromDb();
await import('./instance-ai.controller.js');
await import('./mcp/instance-ai-mcp-connection.controller.js');

View File

@ -2078,7 +2078,7 @@ export class InstanceAiService {
);
}
const adminSettings = this.settingsService.getAdminSettings();
const adminSettings = await this.settingsService.getAdminSettings();
const localGatewayDisabledGlobally = adminSettings.localGatewayDisabled;
const browserUseEnabledGlobally = adminSettings.browserUseEnabled;
const localGatewayDisabledForUser = await this.settingsService.isLocalGatewayDisabledForUser(

View File

@ -26,6 +26,7 @@ const mockCredentialsService = (
isManaged: false,
isGlobal: false,
isResolvable: false,
usageScope: 'project',
resolverId: null,
resolvableAllowFallback: false,
id,

View File

@ -22,6 +22,7 @@ import {
type WorkflowRepository,
} from '@n8n/db';
import { In } from '@n8n/typeorm';
import type { EntityManager } from '@n8n/typeorm';
import * as fastGlob from 'fast-glob';
import { type InstanceSettings } from 'n8n-core';
import fsp from 'node:fs/promises';
@ -31,6 +32,7 @@ import type { Mock } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { ActiveWorkflowManager } from '@/active-workflow-manager';
import type { CredentialsService } from '@/credentials/credentials.service';
import type { VariablesService } from '@/environments.ee/variables/variables.service.ee';
import type { ExecutionPersistence } from '@/executions/execution-persistence';
import type { DataTableColumnRepository } from '@/modules/data-table/data-table-column.repository';
@ -59,7 +61,10 @@ describe('SourceControlImportService', () => {
const tagRepository = mock<TagRepository>();
const workflowTagMappingRepository = mock<WorkflowTagMappingRepository>();
const userRepository = mock<UserRepository>();
const credentialsRepository = mock<CredentialsRepository>();
const credentialsRepositoryManager = mock<EntityManager>();
const credentialsRepository = mock<CredentialsRepository>({
manager: credentialsRepositoryManager,
});
const sharedCredentialsRepository = mock<SharedCredentialsRepository>();
const mockLogger = mock<Logger>();
const sourceControlContextFactory = mock<SourceControlContextFactory>();
@ -75,6 +80,8 @@ describe('SourceControlImportService', () => {
const dataTableSizeValidator = mock<DataTableSizeValidator>();
const activeWorkflowManager = mock<ActiveWorkflowManager>();
const executionPersistence = mock<ExecutionPersistence>();
const credentialsService = mock<CredentialsService>();
const transactionManager = mock<EntityManager>();
const globalAdminContext = new SourceControlContext(
Object.assign(new User(), { role: GLOBAL_ADMIN_ROLE }),
@ -102,7 +109,7 @@ describe('SourceControlImportService', () => {
workflowRepository,
workflowTagMappingRepository,
workflowService,
mock(),
credentialsService,
mock(),
folderRepository,
mock<InstanceSettings>({ n8nFolder: '/mock/n8n' }),
@ -123,6 +130,19 @@ describe('SourceControlImportService', () => {
beforeEach(() => {
vi.clearAllMocks();
credentialsRepository.find.mockResolvedValue([]);
transactionManager.upsert.mockImplementation(
async (_entity, value, conflictPaths) =>
await credentialsRepository.upsert(value as never, conflictPaths as never),
);
transactionManager.delete.mockImplementation(
async (_entity, criteria) => await sharedCredentialsRepository.delete(criteria as never),
);
credentialsRepositoryManager.transaction.mockImplementation(async (...args) => {
const callback = args.find((arg) => typeof arg === 'function');
if (!callback) throw new Error('Transaction callback is required');
return await callback(transactionManager);
});
sourceControlScopedService.getDataTablesInAdminProjectsFromContextFilter.mockReturnValue({});
});
@ -1376,6 +1396,42 @@ describe('SourceControlImportService', () => {
);
});
it('should skip credentials that are instance credentials locally, for any caller', async () => {
globMock.mockResolvedValue(['/mock/credential1.json']);
fsReadFile.mockResolvedValue(
JSON.stringify({
id: 'cred1',
name: 'Disguised Credential',
type: 'oauth2Api',
data: {},
ownedBy: null,
}),
);
credentialsRepository.find.mockResolvedValue([
{ id: 'cred1', usageScope: 'instance' } as any,
]);
await expect(service.getRemoteCredentialsFromFiles(globalMemberContext)).resolves.toEqual([]);
await expect(service.getRemoteCredentialsFromFiles(globalAdminContext)).resolves.toEqual([]);
});
it('should skip remote credential files flagged as instance credentials', async () => {
globMock.mockResolvedValue(['/mock/credential1.json']);
fsReadFile.mockResolvedValue(
JSON.stringify({
id: 'cred1',
name: 'Provider Connection',
type: 'anthropicApi',
data: {},
ownedBy: null,
usageScope: 'instance',
}),
);
credentialsRepository.find.mockResolvedValue([]);
await expect(service.getRemoteCredentialsFromFiles(globalAdminContext)).resolves.toEqual([]);
});
it('should filter out files without valid credential data', async () => {
globMock.mockResolvedValue(['/mock/invalid.json']);
fsReadFile.mockResolvedValue('{}');
@ -1620,6 +1676,122 @@ describe('SourceControlImportService', () => {
);
});
it('should skip credential files flagged as instance credentials', async () => {
const candidates: SourceControlledFile[] = [
{
file: '/mock/credential_stubs/cred1.json',
id: 'cred1',
name: 'Instance Credential',
type: 'credential',
status: 'modified',
location: 'local',
conflict: false,
updatedAt: '',
},
];
fsReadFile.mockResolvedValue(
JSON.stringify({
id: 'cred1',
name: 'Instance Credential',
type: 'oauth2Api',
data: {},
ownedBy: null,
usageScope: 'instance',
}),
);
credentialsRepository.find.mockResolvedValue([]);
sharedCredentialsRepository.find.mockResolvedValue([]);
const result = await service.importCredentialsFromWorkFolder(candidates, mockUserId);
expect(result).toEqual([]);
expect(credentialsRepository.upsert).not.toHaveBeenCalled();
expect(credentialsRepositoryManager.transaction).not.toHaveBeenCalled();
});
it('should not touch an existing instance credential even when the remote file omits usageScope', async () => {
const candidates: SourceControlledFile[] = [
{
file: '/mock/credential_stubs/cred1.json',
id: 'cred1',
name: 'Instance Credential',
type: 'credential',
status: 'modified',
location: 'local',
conflict: false,
updatedAt: '',
},
];
fsReadFile.mockResolvedValue(
JSON.stringify({
id: 'cred1',
name: 'Instance Credential',
type: 'oauth2Api',
data: {},
ownedBy: null,
}),
);
credentialsRepository.find.mockResolvedValue([
{
id: 'cred1',
name: 'Instance Credential',
type: 'oauth2Api',
data: undefined,
usageScope: 'instance',
} as any,
]);
sharedCredentialsRepository.find.mockResolvedValue([]);
const result = await service.importCredentialsFromWorkFolder(candidates, mockUserId);
expect(result).toEqual([]);
expect(credentialsRepository.upsert).not.toHaveBeenCalled();
expect(sharedCredentialsRepository.delete).not.toHaveBeenCalled();
});
it('should not convert an existing project credential when a remote file declares instance', async () => {
const candidates: SourceControlledFile[] = [
{
file: '/mock/credential_stubs/cred1.json',
id: 'cred1',
name: 'Workflow Credential',
type: 'credential',
status: 'modified',
location: 'local',
conflict: false,
updatedAt: '',
},
];
fsReadFile.mockResolvedValue(
JSON.stringify({
id: 'cred1',
name: 'Workflow Credential',
type: 'oauth2Api',
data: {},
ownedBy: null,
usageScope: 'instance',
}),
);
credentialsRepository.find.mockResolvedValue([
{
id: 'cred1',
name: 'Workflow Credential',
type: 'oauth2Api',
data: undefined,
usageScope: 'project',
} as any,
]);
sharedCredentialsRepository.find.mockResolvedValue([]);
const result = await service.importCredentialsFromWorkFolder(candidates, mockUserId);
expect(result).toEqual([]);
expect(credentialsRepository.upsert).not.toHaveBeenCalled();
});
it('should default resolver fields to false when absent from the stub', async () => {
// Arrange - a stub written before resolver fields were tracked omits them
const candidates: SourceControlledFile[] = [

View File

@ -54,4 +54,19 @@ describe('SourceControlScopedService', () => {
await expect(service.ensureIsAllowedToGetStatus(req)).rejects.toThrow(ForbiddenError);
});
});
describe('getCredentialsInAdminProjectsFromContextFilter', () => {
const context = mock<SourceControlContext>({
user: req.user,
hasAccessToAllProjects: () => true,
});
it('excludes instance credentials regardless of the instance credential scope', () => {
vi.mocked(hasGlobalScope).mockReturnValue(true);
expect(service.getCredentialsInAdminProjectsFromContextFilter(context)).toEqual({
usageScope: 'project',
});
});
});
});

View File

@ -520,12 +520,11 @@ export class SourceControlExportService {
credentialIds,
'credential:owner',
);
let missingIds: string[] = [];
if (credentialsToBeExported.length !== credentialIds.length) {
const foundCredentialIds = credentialsToBeExported.map((e) => e.credentialsId);
missingIds = credentialIds.filter(
(remote) => foundCredentialIds.findIndex((local) => local === remote) === -1,
);
const foundCredentialIds = new Set(credentialsToBeExported.map((e) => e.credentialsId));
if (foundCredentialIds.size !== credentialIds.length) {
missingIds = credentialIds.filter((remote) => !foundCredentialIds.has(remote));
}
await Promise.all(
credentialsToBeExported.map(async (sharing) => {

View File

@ -10,6 +10,7 @@ import type {
WorkflowEntity,
} from '@n8n/db';
import {
CredentialsEntity,
CredentialsRepository,
FolderRepository,
ProjectRelationRepository,
@ -25,7 +26,7 @@ import {
} from '@n8n/db';
import { Service } from '@n8n/di';
import { PROJECT_ADMIN_ROLE_SLUG, PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
import { In, type DataSourceOptions } from '@n8n/typeorm';
import { In, type DataSourceOptions, type EntityManager } from '@n8n/typeorm';
import { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity';
import glob from 'fast-glob';
import isEqual from 'lodash/isEqual';
@ -323,11 +324,29 @@ export class SourceControlImportService {
},
);
const remoteIds = remoteCredentialFilesRead.flatMap((remote) =>
remote?.id ? [remote.id] : [],
);
const localUsageScope = new Map(
remoteIds.length === 0
? []
: (
await this.credentialsRepository.find({
where: { id: In(remoteIds) },
select: ['id', 'usageScope'],
})
).map((local) => [local.id, local.usageScope]),
);
const remoteCredentialFilesParsed = remoteCredentialFilesRead
.filter((remote) => {
if (!remote?.id) {
return false;
}
// Instance credentials (provider connections) are instance-local and never synced
if (remote.usageScope === 'instance' || localUsageScope.get(remote.id) === 'instance') {
return false;
}
const owner = remote.ownedBy;
return (
!owner || context.hasAccessToAllProjects() || context.findAuthorizedProjectByOwner(owner)
@ -969,8 +988,11 @@ export class SourceControlImportService {
where: {
id: In(candidateIds),
},
select: ['id', 'name', 'type', 'data'],
select: ['id', 'name', 'type', 'data', 'usageScope'],
});
const existingCredentialsById = new Map(
existingCredentials.map((credential) => [credential.id, credential]),
);
const existingSharedCredentials = await this.sharedCredentialsRepository.find({
select: ['credentialsId', 'projectId', 'role'],
where: {
@ -979,75 +1001,99 @@ export class SourceControlImportService {
},
});
let importCredentialsResult: Array<{ id: string; name: string; type: string }> = [];
importCredentialsResult = await Promise.all(
candidates.map(async (candidate) => {
this.logger.debug(`Importing credentials file ${candidate.file}`);
const credential = jsonParse<ExportableCredential>(
await fsReadFile(candidate.file, { encoding: 'utf8' }),
);
const existingCredential = existingCredentials.find(
(e) => e.id === credential.id && e.type === credential.type,
);
// Carry the "private"/resolvable nature across environments. resolverId is
// instance-local and handled separately (see IAM-906).
const {
name,
type,
data,
id,
isGlobal = false,
isResolvable = false,
resolvableAllowFallback = false,
} = credential;
const newCredentialObject = new Credentials({ id, name }, type);
if (existingCredential?.data) {
// Credential exists - merge expressions from remote while preserving local plain values
const existingDecrypted = new Credentials(
{ id: existingCredential.id, name: existingCredential.name },
existingCredential.type,
existingCredential.data,
const importCredentialsResult: Array<{ id: string; name: string; type: string } | undefined> =
await Promise.all(
candidates.map(async (candidate) => {
this.logger.debug(`Importing credentials file ${candidate.file}`);
const credential = jsonParse<ExportableCredential>(
await fsReadFile(candidate.file, { encoding: 'utf8' }),
);
const localData = await existingDecrypted.getData();
const mergedData = mergeRemoteCrendetialDataIntoLocalCredentialData({
local: localData,
remote: data,
const existingCredentialById = existingCredentialsById.get(credential.id);
// Instance credentials (provider connections) are instance-local and never synced
if (
credential.usageScope === 'instance' ||
existingCredentialById?.usageScope === 'instance'
) {
this.logger.debug(`Skipping provider connection file ${candidate.file}`);
return undefined;
}
const existingCredential =
existingCredentialById?.type === credential.type ? existingCredentialById : undefined;
// Carry the "private"/resolvable nature across environments. resolverId is
// instance-local and handled separately (see IAM-906).
const {
name,
type,
data,
id,
isGlobal = false,
isResolvable = false,
resolvableAllowFallback = false,
} = credential;
const newCredentialObject = new Credentials({ id, name }, type);
if (existingCredential?.data) {
// Credential exists - merge expressions from remote while preserving local plain values
const existingDecrypted = new Credentials(
{ id: existingCredential.id, name: existingCredential.name },
existingCredential.type,
existingCredential.data,
);
const localData = await existingDecrypted.getData();
const mergedData = mergeRemoteCrendetialDataIntoLocalCredentialData({
local: localData,
remote: data,
});
await newCredentialObject.setData(mergedData);
} else {
// This is a safe guard, in principle remote data should already be sanitized
// This prevents importing invalid data that should have not been synched in the first place
const sanitizedData = sanitizeCredentialData(data);
await newCredentialObject.setData(sanitizedData);
}
const targetOwnerProject = await this.resolveTargetOwnerProject(
credential.ownedBy,
personalProject,
);
this.logger.debug(`Updating credential id ${newCredentialObject.id as string}`);
await this.credentialsRepository.manager.transaction(async (transactionManager) => {
await transactionManager.upsert(
CredentialsEntity,
{
...newCredentialObject,
isGlobal,
isResolvable,
resolvableAllowFallback,
},
['id'],
);
const localOwner = existingSharedCredentials.find(
(c) => c.credentialsId === credential.id && c.role === 'credential:owner',
);
await this.syncResourceOwnership({
resourceId: credential.id,
remoteOwner: credential.ownedBy,
localOwner,
fallbackProject: personalProject,
repository: this.sharedCredentialsRepository,
transactionManager,
targetOwnerProject,
});
});
await newCredentialObject.setData(mergedData);
} else {
// This is a safe guard, in principle remote data should already be sanitized
// This prevents importing invalid data that should have not been synched in the first place
const sanitizedData = sanitizeCredentialData(data);
await newCredentialObject.setData(sanitizedData);
}
this.logger.debug(`Updating credential id ${newCredentialObject.id as string}`);
await this.credentialsRepository.upsert(
{ ...newCredentialObject, isGlobal, isResolvable, resolvableAllowFallback },
['id'],
);
const localOwner = existingSharedCredentials.find(
(c) => c.credentialsId === credential.id && c.role === 'credential:owner',
);
await this.syncResourceOwnership({
resourceId: credential.id,
remoteOwner: credential.ownedBy,
localOwner,
fallbackProject: personalProject,
repository: this.sharedCredentialsRepository,
});
return {
id: newCredentialObject.id as string,
name: newCredentialObject.name,
type: newCredentialObject.type,
};
}),
);
return {
id: newCredentialObject.id as string,
name: newCredentialObject.name,
type: newCredentialObject.type,
};
}),
);
return importCredentialsResult.filter((e) => e !== undefined);
}
@ -1681,7 +1727,9 @@ export class SourceControlImportService {
async deleteCredentialsNotInWorkfolder(user: User, candidates: SourceControlledFile[]) {
for (const candidate of candidates) {
await this.credentialsService.delete(user, candidate.id);
await this.credentialsService.delete(user, candidate.id, {
includeInstanceCredentials: true,
});
}
}
@ -1829,24 +1877,20 @@ export class SourceControlImportService {
localOwner,
fallbackProject,
repository,
transactionManager,
targetOwnerProject,
}: {
resourceId: string;
remoteOwner: RemoteResourceOwner | null | undefined;
localOwner: { projectId: string } | undefined;
fallbackProject: Project;
repository: SharedWorkflowRepository | SharedCredentialsRepository;
transactionManager?: EntityManager;
targetOwnerProject?: Project;
}): Promise<void> {
let targetOwnerProject = await this.findOwnerProjectInLocalDb(remoteOwner ?? undefined);
if (!targetOwnerProject) {
const isSharedResource =
remoteOwner && typeof remoteOwner !== 'string' && remoteOwner.type === 'team';
targetOwnerProject ??= await this.resolveTargetOwnerProject(remoteOwner, fallbackProject);
targetOwnerProject = isSharedResource
? await this.createTeamProject(remoteOwner)
: fallbackProject;
}
const trx = this.workflowRepository.manager;
const trx = transactionManager ?? this.workflowRepository.manager;
// remove old ownership if it changed
const shouldRemoveOldOwner = localOwner && localOwner.projectId !== targetOwnerProject.id;
@ -1858,6 +1902,18 @@ export class SourceControlImportService {
await repository.makeOwner([resourceId], targetOwnerProject.id, trx);
}
private async resolveTargetOwnerProject(
remoteOwner: RemoteResourceOwner | null | undefined,
fallbackProject: Project,
): Promise<Project> {
const targetOwnerProject = await this.findOwnerProjectInLocalDb(remoteOwner ?? undefined);
if (targetOwnerProject) return targetOwnerProject;
const isSharedResource =
remoteOwner && typeof remoteOwner !== 'string' && remoteOwner.type === 'team';
return isSharedResource ? await this.createTeamProject(remoteOwner) : fallbackProject;
}
private async findOwnerProjectInLocalDb(owner: RemoteResourceOwner | IWorkflowToImport['owner']) {
if (!owner) {
return null;

View File

@ -119,7 +119,8 @@ export class SourceControlScopedService {
context: SourceControlContext,
): FindOptionsWhere<CredentialsEntity> {
if (context.hasAccessToAllProjects()) {
return {};
// Instance credentials (provider connections) are instance-local and never synced
return { usageScope: 'project' };
}
return {

View File

@ -1,3 +1,4 @@
import type { CredentialUsageScope } from '@n8n/db';
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
import type { RemoteResourceOwner, StatusResourceOwner } from './resource-owner';
@ -32,6 +33,7 @@ export interface ExportableCredential {
* resolution fails. Travels with `isResolvable`.
*/
resolvableAllowFallback?: boolean;
usageScope?: CredentialUsageScope;
}
export type StatusExportableCredential = ExportableCredential & {

View File

@ -93,7 +93,7 @@ export class WorkflowDependencyQueryService {
const [credentials, workflows, dataTables] = await Promise.all([
maps.allCredIds.size > 0
? this.credentialsRepository.find({
where: { id: In([...maps.allCredIds]) },
where: { id: In([...maps.allCredIds]), usageScope: 'project' },
select: ['id', 'name'],
})
: [],

View File

@ -92,7 +92,7 @@ describe('WorkflowReviewRequestService', () => {
// Feature enabled by default; the disabled path is exercised explicitly.
workflowReviewPolicyService.get.mockResolvedValue({ enabled: true });
// By default, run the critical section against the mocked transaction.
dbLockService.withLock.mockImplementation(async (_id, fn) => await fn(tx));
dbLockService.withLock.mockImplementation(async (_id, fn) => await fn(tx, {}));
collaborationService.broadcastWorkflowReviewStateChanged.mockResolvedValue(undefined);
});
@ -307,7 +307,7 @@ describe('WorkflowReviewRequestService', () => {
mockSuccessfulCreatePath();
let lockResolved = false;
dbLockService.withLock.mockImplementation(async (_id, fn) => {
const result = await fn(tx);
const result = await fn(tx, {});
lockResolved = true;
return result;
});

View File

@ -106,7 +106,7 @@ describe('WorkflowReviewRequestService.updateVersion', () => {
licenseState.isWorkflowReviewsLicensed.mockReturnValue(true);
workflowReviewPolicyService.get.mockResolvedValue({ enabled: true });
// By default, run the critical section against the mocked transaction.
dbLockService.withLock.mockImplementation(async (_id, fn) => await fn(tx));
dbLockService.withLock.mockImplementation(async (_id, fn) => await fn(tx, {}));
collaborationService.broadcastWorkflowReviewStateChanged.mockResolvedValue(undefined);
});
@ -298,7 +298,7 @@ describe('WorkflowReviewRequestService.updateVersion', () => {
mockSuccessfulUpdatePath();
let lockResolved = false;
dbLockService.withLock.mockImplementation(async (_id, fn) => {
const result = await fn(tx);
const result = await fn(tx, {});
lockResolved = true;
return result;
});

View File

@ -282,6 +282,7 @@ describe('OauthService', () => {
'credential-id',
req.user,
['credential:update'],
{ includeInstanceCredentials: true },
);
});
@ -302,6 +303,7 @@ describe('OauthService', () => {
'credential-id',
req.user,
['credential:connect'],
{ includeInstanceCredentials: true },
);
});
});
@ -460,6 +462,20 @@ describe('OauthService', () => {
expect(result).toBeNull();
});
it('should restrict the lookup to project credentials when requested', async () => {
credentialsRepository.findOneBy.mockResolvedValue(null);
const result = await (service as any).getCredentialWithoutUser('1', {
onlyProjectCredentials: true,
});
expect(result).toBeNull();
expect(credentialsRepository.findOneBy).toHaveBeenCalledWith({
id: '1',
usageScope: 'project',
});
});
});
describe('createCsrfState', () => {
@ -531,6 +547,7 @@ describe('OauthService', () => {
'credential-id',
req.user,
['credential:update'],
{ includeInstanceCredentials: true },
);
});

View File

@ -264,7 +264,12 @@ export class OauthService {
// Private credentials are connected per-user, so executing users can authorize
// their own account without edit rights. Shared/static credentials store the
// token on the shared credential itself, so connecting them still requires edit.
const existingCredential = await this.credentialsFinderService.findCredentialById(credentialId);
const existingCredential = await this.credentialsFinderService.findCredentialById(
credentialId,
{
includeInstanceCredentials: true,
},
);
const requiredScope = existingCredential?.isResolvable
? 'credential:connect'
: 'credential:update';
@ -273,6 +278,7 @@ export class OauthService {
credentialId,
req.user,
[requiredScope],
{ includeInstanceCredentials: true },
);
if (!credential) {
@ -427,8 +433,12 @@ export class OauthService {
/** Get a credential without user check */
protected async getCredentialWithoutUser(
credentialId: string,
options: { onlyProjectCredentials?: boolean } = {},
): Promise<CredentialsEntity | null> {
return await this.credentialsRepository.findOneBy({ id: credentialId });
return await this.credentialsRepository.findOneBy({
id: credentialId,
...(options.onlyProjectCredentials ? { usageScope: 'project' as const } : {}),
});
}
/**
@ -549,7 +559,7 @@ export class OauthService {
}
return [
{ ...decoded, ...decryptedState },
await this.getCredentialWithoutUser(decryptedState.cid),
await this.getCredentialWithoutUser(decryptedState.cid, { onlyProjectCredentials: true }),
];
}
@ -569,6 +579,7 @@ export class OauthService {
decryptedState.cid,
req.user,
['credential:update'],
{ includeInstanceCredentials: true },
);
return [{ ...decoded, ...decryptedState }, credential];
@ -733,7 +744,7 @@ export class OauthService {
projectId: string,
): Promise<Record<string, string> | null> {
const credential = await this.credentialsRepository.findOne({
where: { id: credentialId },
where: { id: credentialId, usageScope: 'project' },
relations: { shared: true },
});
if (!credential) return null;

View File

@ -21,6 +21,7 @@ describe('userHasScopes', () => {
let findByWorkflowMock: Mock;
let findByCredentialMock: Mock;
let findByGlobalCredentialMock: Mock;
let instanceCredentialExistsMock: Mock;
let findGlobalCredentialByIdMock: Mock;
let hasGlobalReadOnlyAccessMock: Mock;
let roleServiceMock: Mock;
@ -30,6 +31,7 @@ describe('userHasScopes', () => {
findByWorkflowMock = vi.fn();
findByCredentialMock = vi.fn();
findByGlobalCredentialMock = vi.fn();
instanceCredentialExistsMock = vi.fn();
findGlobalCredentialByIdMock = vi.fn();
hasGlobalReadOnlyAccessMock = vi.fn();
roleServiceMock = vi.fn();
@ -52,6 +54,7 @@ describe('userHasScopes', () => {
CredentialsRepository,
mock<CredentialsRepository>({
findBy: findByGlobalCredentialMock,
existsBy: instanceCredentialExistsMock,
}),
);
@ -93,6 +96,7 @@ describe('userHasScopes', () => {
findByWorkflowMock.mockReset();
findByCredentialMock.mockReset();
findByGlobalCredentialMock.mockReset();
instanceCredentialExistsMock.mockReset();
findGlobalCredentialByIdMock.mockReset();
hasGlobalReadOnlyAccessMock.mockReset();
roleServiceMock.mockReset();
@ -101,6 +105,43 @@ describe('userHasScopes', () => {
mockQueryBuilder.getRawMany.mockResolvedValue([{ id: 'projectId' }]);
});
describe('instance credential management', () => {
it('allows the dedicated global scope through legacy credential route checks', async () => {
instanceCredentialExistsMock.mockResolvedValue(true);
const user = {
id: 'userId',
role: {
...GLOBAL_MEMBER_ROLE,
scopes: [{ slug: 'credential:manageInstance' }],
},
} as unknown as User;
await expect(
userHasScopes(user, ['credential:update'], false, { credentialId: 'instance-cred' }),
).resolves.toBe(true);
expect(instanceCredentialExistsMock).toHaveBeenCalledWith({
id: 'instance-cred',
usageScope: 'instance',
});
});
it('does not use the dedicated scope for sharing operations', async () => {
findByCredentialMock.mockResolvedValue([]);
const user = {
id: 'userId',
role: {
...GLOBAL_MEMBER_ROLE,
scopes: [{ slug: 'credential:manageInstance' }],
},
} as unknown as User;
await expect(
userHasScopes(user, ['credential:share'], false, { credentialId: 'instance-cred' }),
).rejects.toThrow(NotFoundError);
expect(instanceCredentialExistsMock).not.toHaveBeenCalled();
});
});
describe('resource not found scenarios', () => {
it.each<{ type: 'workflow' | 'credential'; id: string }>([
{ type: 'workflow', id: 'workflowId' },

View File

@ -1,6 +1,8 @@
import { ModuleRegistry } from '@n8n/backend-common';
import type { User, EntityManager } from '@n8n/db';
import {
CredentialsEntity,
CredentialsRepository,
Project,
ProjectRepository,
SharedCredentials,
@ -16,6 +18,12 @@ import { CredentialsFinderService } from '@/credentials/credentials-finder.servi
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { RoleService } from '@/services/role.service';
const INSTANCE_CREDENTIAL_MANAGEMENT_SCOPES = new Set<Scope>([
'credential:read',
'credential:update',
'credential:delete',
]);
/**
* Check if a user has the required scopes. The check can be:
*
@ -43,6 +51,24 @@ export async function userHasScopes(
): Promise<boolean> {
if (hasGlobalScope(user, scopes, { mode: 'allOf' })) return true;
if (
credentialId &&
hasGlobalScope(user, 'credential:manageInstance') &&
scopes.every((scope) => INSTANCE_CREDENTIAL_MANAGEMENT_SCOPES.has(scope))
) {
const credentialsRepository = entityManager
? entityManager.getRepository(CredentialsEntity)
: Container.get(CredentialsRepository);
if (
await credentialsRepository.existsBy({
id: credentialId,
usageScope: 'instance',
})
) {
return true;
}
}
if (globalOnly) return false;
// Find which projects the user has access to with the required scopes.

View File

@ -235,7 +235,13 @@ export declare namespace CredentialRequest {
type Create = AuthenticatedRequest<
{},
{},
{ type: string; name: string; data: ICredentialDataDecryptedObject; projectId?: string },
{
type: string;
name: string;
data: ICredentialDataDecryptedObject;
projectId?: string;
isResolvable?: boolean;
},
{}
>;

View File

@ -1,5 +1,10 @@
import type { CredentialsEntity, Project, SharedCredentials, User } from '@n8n/db';
import { CredentialsRepository, GLOBAL_OWNER_ROLE, GLOBAL_MEMBER_ROLE } from '@n8n/db';
import {
CredentialsRepository,
GLOBAL_OWNER_ROLE,
GLOBAL_MEMBER_ROLE,
SharedCredentialsRepository,
} from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'vitest-mock-extended';
import { validate, type Schema } from 'jsonschema';
@ -7,9 +12,15 @@ import { Cipher, CipherAes256GCM, CipherAes256CBC, EncryptionKeyProxy } from 'n8
import type { InstanceSettings } from 'n8n-core';
import type { GenericValue, IDataObject, INodeProperties } from 'n8n-workflow';
import { buildSharedForCredential, toJsonSchema, updateCredential } from '../credentials.service';
import {
buildSharedForCredential,
saveCredential,
toJsonSchema,
updateCredential,
} from '../credentials.service';
import { CredentialsService } from '@/credentials/credentials.service';
import { EventService } from '@/events/event.service';
import { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config';
import { SecretsProviderAccessCheckService } from '@/modules/external-secrets.ee/secret-provider-access-check.service.ee';
import * as checkAccess from '@/permissions.ee/check-access';
@ -103,6 +114,51 @@ describe('CredentialsService', () => {
});
});
describe('saveCredential', () => {
it('forces public API credentials to project usage scope', async () => {
const credentialsService = mock<CredentialsService>();
const sharedCredentialsRepository = mock<SharedCredentialsRepository>();
const eventService = mock<EventService>();
Container.set(CredentialsService, credentialsService);
Container.set(SharedCredentialsRepository, sharedCredentialsRepository);
Container.set(EventService, eventService);
credentialsService.createUnmanagedCredential.mockResolvedValue({
id: 'credential-id',
name: 'Credential',
type: 'testApi',
isManaged: false,
isGlobal: false,
isResolvable: false,
resolvableAllowFallback: false,
resolverId: null,
createdAt: new Date(),
updatedAt: new Date(),
scopes: [],
} as never);
sharedCredentialsRepository.findCredentialOwningProject.mockResolvedValue(undefined);
const payload = {
type: 'testApi',
name: 'Credential',
data: { apiKey: 'secret' },
usageScope: 'instance',
};
await saveCredential(payload, mock<User>());
expect(credentialsService.createUnmanagedCredential).toHaveBeenCalledWith(
{
type: payload.type,
name: payload.name,
data: payload.data,
projectId: undefined,
isResolvable: undefined,
usageScope: 'project',
},
expect.anything(),
);
});
});
describe('toJsonSchema', () => {
it('should create separate conditionals for different values of the same dependant field', () => {
// This test simulates the JWT auth credential scenario where
@ -508,6 +564,9 @@ describe('CredentialsService', () => {
mock(), // externalSecretsConfig
mock(), // externalSecretsProviderAccessCheckService
mock(), // connectionStatusProxy
mock(), // instanceCredentialAssignmentRepository
mock(), // instanceCredentialUseRegistry
mock(), // dbLockService
);
beforeEach(() => {

View File

@ -68,6 +68,7 @@ const credentialsHandlers: CredentialsHandlers = {
select: ['id', 'name', 'type', 'createdAt', 'updatedAt'],
relations: ['shared', 'shared.project'],
order: { createdAt: 'DESC' },
where: { usageScope: 'project' },
});
const data = credentials.map((credential: CredentialsEntity) => {

View File

@ -69,7 +69,7 @@ export function buildSharedForCredential(
export async function getCredential(credentialId: string): Promise<CredentialsEntity | null> {
return await Container.get(CredentialsRepository).findOne({
where: { id: credentialId },
where: { id: credentialId, usageScope: 'project' },
relations: ['shared', 'shared.project'],
});
}
@ -96,12 +96,28 @@ export async function getSharedCredentials(
* resolution, validation, and encryption.
*/
export async function saveCredential(
payload: { type: string; name: string; data: ICredentialDataDecryptedObject; projectId?: string },
payload: {
type: string;
name: string;
data: ICredentialDataDecryptedObject;
projectId?: string;
isResolvable?: boolean;
},
user: User,
): Promise<PublicApiCredentialResponse> {
const { scopes: _scopes, ...credential } = await Container.get(
CredentialsService,
).createUnmanagedCredential({ ...payload, projectId: payload.projectId ?? undefined }, user);
).createUnmanagedCredential(
{
type: payload.type,
name: payload.name,
data: payload.data,
projectId: payload.projectId,
isResolvable: payload.isResolvable,
usageScope: 'project',
},
user,
);
const project = await Container.get(SharedCredentialsRepository).findCredentialOwningProject(
credential.id,

View File

@ -38,6 +38,7 @@ export type PubSubCommandMap = {
'reload-mcp-registry': never;
'reload-otel-config': never;
'reload-instance-ai-settings': never;
// #region Community packages

View File

@ -70,6 +70,7 @@ export namespace PubSub {
export type ReloadSourceControlConfiguration = ToCommand<'reload-source-control-config'>;
export type ReloadMcpRegistry = ToCommand<'reload-mcp-registry'>;
export type ReloadOtelConfig = ToCommand<'reload-otel-config'>;
export type ReloadInstanceAiSettings = ToCommand<'reload-instance-ai-settings'>;
export type CancelTestRun = ToCommand<'cancel-test-run'>;
export type CancelCollection = ToCommand<'cancel-collection'>;
export type AgentChatIntegrationChanged = ToCommand<'agent-chat-integration-changed'>;
@ -111,6 +112,7 @@ export namespace PubSub {
| Commands.ReloadSourceControlConfiguration
| Commands.ReloadMcpRegistry
| Commands.ReloadOtelConfig
| Commands.ReloadInstanceAiSettings
| Commands.CancelTestRun
| Commands.CancelCollection
| Commands.AgentChatIntegrationChanged

View File

@ -110,7 +110,10 @@ export class CredentialsRiskReporter implements RiskReporter {
}
private async getAllExistingCreds() {
const credentials = await this.credentialsRepository.find({ select: ['id', 'name'] });
const credentials = await this.credentialsRepository.find({
select: ['id', 'name'],
where: { usageScope: 'project' },
});
return credentials.map(({ id, name }) => ({ kind: 'credential' as const, id, name }));
}

View File

@ -61,13 +61,117 @@ describe('CredentialsFinderService', () => {
});
});
describe('findCredentialById', () => {
it('queries project credentials by default', async () => {
credentialsRepository.findOne.mockResolvedValueOnce(null);
await credentialsFinderService.findCredentialById('credential-id');
expect(credentialsRepository.findOne).toHaveBeenCalledWith({
where: { id: 'credential-id', usageScope: 'project' },
});
});
it('includes instance credentials only when explicitly requested', async () => {
credentialsRepository.findOne.mockResolvedValueOnce(null);
await credentialsFinderService.findCredentialById('credential-id', {
includeInstanceCredentials: true,
});
expect(credentialsRepository.findOne).toHaveBeenCalledWith({
where: {
id: 'credential-id',
usageScope: In(['project', 'instance']),
},
});
});
});
describe('findCredentialForUser', () => {
const credentialsId = 'cred_123';
const sharedCredential = mock<SharedCredentials>();
sharedCredential.credentials = mock<CredentialsEntity>({ id: credentialsId });
sharedCredential.credentials = mock<CredentialsEntity>({
id: credentialsId,
usageScope: 'project',
});
const owner = mock<User>({ role: GLOBAL_OWNER_ROLE });
const member = mock<User>({ role: GLOBAL_MEMBER_ROLE, id: 'test' });
test('should return instance credentials only when explicitly requested by a manager', async () => {
const instanceCredential = mock<CredentialsEntity>({
id: credentialsId,
usageScope: 'instance',
});
credentialsRepository.findOneBy.mockResolvedValueOnce(instanceCredential);
const credential = await credentialsFinderService.findCredentialForUser(
credentialsId,
owner,
['credential:read' as const],
{ includeInstanceCredentials: true },
);
expect(credentialsRepository.findOneBy).toHaveBeenCalledWith({
id: credentialsId,
usageScope: 'instance',
});
expect(credential).toBe(instanceCredential);
expect(sharedCredentialsRepository.findOne).not.toHaveBeenCalled();
});
test('should not return instance credentials through generic access checks', async () => {
sharedCredentialsRepository.findOne.mockResolvedValueOnce(
mock<SharedCredentials>({
credentials: mock<CredentialsEntity>({
id: credentialsId,
usageScope: 'instance',
}),
}),
);
const credential = await credentialsFinderService.findCredentialForUser(
credentialsId,
owner,
['credential:read' as const],
);
expect(credentialsRepository.findOneBy).not.toHaveBeenCalled();
expect(credential).toBeNull();
});
test('should not return instance credentials to members', async () => {
sharedCredentialsRepository.findOne.mockResolvedValueOnce(null);
credentialsRepository.findOne.mockResolvedValueOnce(null);
const credential = await credentialsFinderService.findCredentialForUser(
credentialsId,
member,
['credential:read' as const],
);
expect(credentialsRepository.findOneBy).not.toHaveBeenCalled();
expect(credential).toBeFalsy();
});
test('should ignore stale sharing rows for instance credentials', async () => {
const staleSharedCredential = mock<SharedCredentials>({
credentials: mock<CredentialsEntity>({
id: credentialsId,
usageScope: 'instance',
}),
});
sharedCredentialsRepository.findOne.mockResolvedValueOnce(staleSharedCredential);
const credential = await credentialsFinderService.findCredentialForUser(
credentialsId,
member,
['credential:read' as const],
);
expect(credential).toBeNull();
});
test('should allow instance owner access to all credentials', async () => {
sharedCredentialsRepository.findOne.mockResolvedValueOnce(sharedCredential);
const credential = await credentialsFinderService.findCredentialForUser(
@ -158,6 +262,7 @@ describe('CredentialsFinderService', () => {
where: {
id: credentialsId,
isGlobal: true,
usageScope: 'project',
},
relations: {
shared: { project: true },
@ -181,6 +286,7 @@ describe('CredentialsFinderService', () => {
where: {
id: credentialsId,
isGlobal: true,
usageScope: 'project',
},
relations: {
shared: { project: true },
@ -284,11 +390,11 @@ describe('CredentialsFinderService', () => {
]);
expect(credentialsRepository.find).toHaveBeenCalledWith({
where: { isGlobal: false },
where: { isGlobal: false, usageScope: 'project' },
relations: { shared: true },
});
expect(credentialsRepository.manager.find).toHaveBeenCalledWith(CredentialsEntity, {
where: { isGlobal: true },
where: { isGlobal: true, usageScope: 'project' },
relations: { shared: true },
});
expect(roleService.rolesWithScope).not.toHaveBeenCalled();
@ -307,6 +413,7 @@ describe('CredentialsFinderService', () => {
expect(credentialsRepository.find).toHaveBeenCalledWith({
where: {
isGlobal: false,
usageScope: 'project',
shared: {
role: In(['credential:owner', 'credential:user']),
project: {
@ -396,7 +503,7 @@ describe('CredentialsFinderService', () => {
expect(result).toEqual(credentials);
// Should call fetchGlobalCredentials when read-only
expect(credentialsRepository.manager.find).toHaveBeenCalledWith(CredentialsEntity, {
where: { isGlobal: true },
where: { isGlobal: true, usageScope: 'project' },
relations: { shared: true },
});
});
@ -418,6 +525,7 @@ describe('CredentialsFinderService', () => {
expect(credentialsRepository.find).toHaveBeenCalledWith({
where: {
isGlobal: false,
usageScope: 'project',
shared: {
role: In(['custom:cred-admin-789']),
project: {
@ -481,7 +589,7 @@ describe('CredentialsFinderService', () => {
]);
expect(sharedCredentialsRepository.findCredentialsWithOptions).toHaveBeenCalledWith(
{},
{ credentials: { usageScope: 'project' } },
undefined,
);
expect(roleService.rolesWithScope).not.toHaveBeenCalled();
@ -504,6 +612,7 @@ describe('CredentialsFinderService', () => {
expect(roleService.rolesWithScope).toHaveBeenCalledWith('credential', ['credential:read']);
expect(sharedCredentialsRepository.findCredentialsWithOptions).toHaveBeenCalledWith(
{
credentials: { usageScope: 'project' },
role: In(['credential:owner', 'credential:user']),
project: {
projectRelations: {
@ -564,7 +673,7 @@ describe('CredentialsFinderService', () => {
);
expect(credentialsRepository.manager.find).toHaveBeenCalledWith(CredentialsEntity, {
where: { isGlobal: true },
where: { isGlobal: true, usageScope: 'project' },
relations: { shared: true },
});
expect(result).toHaveLength(3);
@ -694,7 +803,7 @@ describe('CredentialsFinderService', () => {
);
expect(mockFind).toHaveBeenCalledWith(CredentialsEntity, {
where: { isGlobal: true },
where: { isGlobal: true, usageScope: 'project' },
relations: { shared: true },
});
});
@ -735,7 +844,10 @@ describe('CredentialsFinderService', () => {
expect(roleService.rolesWithScope).not.toHaveBeenCalled();
expect(sharedCredentialsRepository.find).toHaveBeenCalledWith({
select: { credentialsId: true },
where: { credentialsId: In(ids) },
where: {
credentialsId: In(ids),
credentials: { usageScope: 'project' },
},
});
});
@ -757,6 +869,7 @@ describe('CredentialsFinderService', () => {
select: { credentialsId: true },
where: {
credentialsId: In(ids),
credentials: { usageScope: 'project' },
role: In(['credential:owner', 'credential:user']),
project: {
projectRelations: {
@ -788,7 +901,7 @@ describe('CredentialsFinderService', () => {
expect(result).toEqual(new Set(['cred-1', 'global-1']));
expect(credentialsRepository.find).toHaveBeenCalledWith({
where: { id: In(ids), isGlobal: true },
where: { id: In(ids), isGlobal: true, usageScope: 'project' },
select: ['id'],
});
});
@ -944,6 +1057,7 @@ describe('CredentialsFinderService', () => {
expect(credentialsRepository.find).toHaveBeenCalledWith({
where: {
isGlobal: false,
usageScope: 'project',
shared: {
role: In([]),
project: {
@ -996,6 +1110,7 @@ describe('CredentialsFinderService', () => {
expect(credentialsRepository.find).toHaveBeenCalledWith({
where: {
isGlobal: false,
usageScope: 'project',
shared: {
role: In(['project:admin']), // Uses what RoleService returned for credential namespace
project: {
@ -1071,6 +1186,7 @@ describe('CredentialsFinderService', () => {
where: {
id: credentialId,
isGlobal: true,
usageScope: 'project',
},
relations: undefined,
});
@ -1090,6 +1206,7 @@ describe('CredentialsFinderService', () => {
where: {
id: credentialId,
isGlobal: true,
usageScope: 'project',
},
relations,
});
@ -1105,6 +1222,7 @@ describe('CredentialsFinderService', () => {
where: {
id: 'non-existent-id',
isGlobal: true,
usageScope: 'project',
},
relations: undefined,
});

View File

@ -117,6 +117,19 @@ describe('ImportService', () => {
);
});
describe('initRecords', () => {
it('loads only credentials available to workflows', async () => {
vi.mocked(mockCredentialsRepository.find).mockResolvedValue([]);
vi.mocked(mockTagRepository.find).mockResolvedValue([]);
await importService.initRecords();
expect(mockCredentialsRepository.find).toHaveBeenCalledWith({
where: { usageScope: 'project' },
});
});
});
describe('isTableEmpty', () => {
it('should return true for empty table', async () => {
const mockQueryBuilder = {

View File

@ -76,7 +76,9 @@ export class ImportService {
) {}
async initRecords() {
this.dbCredentials = await this.credentialsRepository.find();
this.dbCredentials = await this.credentialsRepository.find({
where: { usageScope: 'project' },
});
this.dbTags = await this.tagRepository.find();
}

View File

@ -314,7 +314,7 @@ export class WorkflowValidationService {
}
const resolvableCredentials = await this.credentialsRepository.find({
where: { id: In([...credentialIds]), isResolvable: true },
where: { id: In([...credentialIds]), isResolvable: true, usageScope: 'project' },
select: ['id', 'name'],
});

View File

@ -365,6 +365,7 @@ export class EnterpriseWorkflowService {
where: {
id: In(Array.from(credentialIdToWorkflowIds.keys())),
isResolvable: true,
usageScope: 'project',
},
select: ['id'],
});

View File

@ -77,6 +77,7 @@ describe('POST /ai/free-credits', () => {
'credential:createEndUser',
'credential:delete',
'credential:list',
'credential:manageInstance',
'credential:move',
'credential:read',
'credential:share',

View File

@ -1,15 +1,25 @@
import { getPersonalProject, mockInstance, testDb } from '@n8n/backend-test-utils';
import { CredentialsEntity, DbLock, DbLockService, InstanceCredentialAssignment } from '@n8n/db';
import { Container } from '@n8n/di';
import * as fs from 'fs';
import { jsonParse } from 'n8n-workflow';
import { nanoid } from 'nanoid';
import * as os from 'os';
import * as path from 'path';
import '@/zod-alias-support';
import { ImportCredentialsCommand } from '@/commands/import/credentials';
import { InstanceCredentialBroker } from '@/credentials/instance-credential-broker';
import type { InstanceCredentialUse } from '@/credentials/instance-credential-use.registry';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { setupTestCommand } from '@test-integration/utils/test-command';
import { getAllCredentials, getAllSharedCredentials } from '../shared/db/credentials';
import {
createCredentials,
encryptCredentialData,
getAllCredentials,
getAllSharedCredentials,
} from '../shared/db/credentials';
import { createMember, createOwner } from '../shared/db/users';
type CredentialFixture = {
@ -24,9 +34,25 @@ const [credentialsFixture] = jsonParse<CredentialFixture[]>(
mockInstance(LoadNodesAndCredentials);
const command = setupTestCommand(ImportCredentialsCommand);
const IMPORT_CREDENTIAL_USE = {
id: 'test:credential-import',
credentialTypes: ['aws'],
validate: ({ data }) => {
if (data.region !== 'us-east-1') throw new Error('Invalid imported provider connection');
},
} satisfies InstanceCredentialUse;
beforeAll(() => {
Container.get(InstanceCredentialBroker).registerUse(IMPORT_CREDENTIAL_USE);
});
beforeEach(async () => {
await testDb.truncate(['CredentialsEntity', 'SharedCredentials', 'User']);
await testDb.truncate([
'InstanceCredentialAssignment',
'CredentialsEntity',
'SharedCredentials',
'User',
]);
});
test('import:credentials should import a credential', async () => {
@ -425,6 +451,140 @@ test('`import:credential --projectId ...` should fail if the credential already
});
});
test('import:credentials should preserve the usage scope of existing instance credentials', async () => {
await createOwner();
await createCredentials({
id: '123',
name: 'instance-model-cred',
type: 'aws',
data: 'encrypted',
usageScope: 'instance',
});
await command.run(['--input=./test/integration/commands/import-credentials/credentials.json']);
const after = {
credentials: await getAllCredentials(),
sharings: await getAllSharedCredentials(),
};
expect(after.credentials).toEqual([
expect.objectContaining({ id: '123', usageScope: 'instance' }),
]);
expect(after.sharings).toEqual([]);
});
test('import:credentials should reject changing an existing provider connection type', async () => {
await createOwner();
await createCredentials({
id: '123',
name: 'instance-model-cred',
type: 'apiKey',
data: 'encrypted',
usageScope: 'instance',
});
await expect(
command.run(['--input=./test/integration/commands/import-credentials/credentials.json']),
).rejects.toThrow('Provider connection type cannot be changed');
});
test('import:credentials should validate an assigned provider connection', async () => {
await createOwner();
const entity = new CredentialsEntity();
Object.assign(entity, {
id: '123',
name: 'instance-model-cred',
type: 'aws',
data: { region: 'us-east-1' },
usageScope: 'instance',
});
await encryptCredentialData(entity);
entity.id = '123';
const credential = await createCredentials(entity);
const dbLockService = Container.get(DbLockService);
await dbLockService.withLock(DbLock.INSTANCE_AI_SETTINGS, async (transactionManager) => {
await transactionManager.insert(InstanceCredentialAssignment, {
credentialUseId: IMPORT_CREDENTIAL_USE.id,
credentialId: credential.id,
});
});
let importSettled = false;
let importPromise: ReturnType<typeof command.run> | undefined;
const withLockSpy = vi.spyOn(dbLockService, 'withLock');
try {
await dbLockService.withLock(DbLock.INSTANCE_AI_SETTINGS, async () => {
importPromise = command.run([
'--input=./test/integration/commands/import-credentials/credentials.json',
]);
void importPromise.then(
() => {
importSettled = true;
},
() => {
importSettled = true;
},
);
await vi.waitFor(() => expect(withLockSpy).toHaveBeenCalledTimes(2));
expect(importSettled).toBe(false);
});
} finally {
withLockSpy.mockRestore();
}
if (!importPromise) throw new Error('Expected credential import to start');
const importError: unknown = await importPromise.then(
() => undefined,
(error: unknown) => error,
);
expect(importError).toBeInstanceOf(Error);
if (!(importError instanceof Error)) throw new Error('Expected credential import to fail');
expect(importError.message).toContain('Invalid imported provider connection');
await expect(getAllCredentials()).resolves.toEqual([
expect.objectContaining({
id: credential.id,
name: 'instance-model-cred',
data: credential.data,
}),
]);
});
test.each([
['null', null],
['empty', ''],
['invalid', 'not-encrypted'],
])('import:credentials should reject %s provider connection data', async (_label, data) => {
await createOwner();
const entity = new CredentialsEntity();
Object.assign(entity, {
id: '123',
name: 'instance-model-cred',
type: 'aws',
data: { region: 'us-east-1' },
usageScope: 'instance',
});
await encryptCredentialData(entity);
const credential = await createCredentials(entity);
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'n8n-credential-import-'));
const inputPath = path.join(temporaryDirectory, 'credentials.json');
fs.writeFileSync(
inputPath,
JSON.stringify([{ id: credential.id, name: 'changed', type: credential.type, data }]),
);
try {
await expect(command.run([`--input=${inputPath}`])).rejects.toThrow();
} finally {
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
}
await expect(getAllCredentials()).resolves.toEqual([
expect.objectContaining({
id: credential.id,
name: 'instance-model-cred',
data: credential.data,
}),
]);
});
test('`import:credential --projectId ... --userId ...` fails explaining that only one of the options can be used at a time', async () => {
await expect(
command.run([

View File

@ -268,6 +268,7 @@ describe('GET /credentials', () => {
'credential:createEndUser',
'credential:delete',
'credential:list',
'credential:manageInstance',
'credential:move',
'credential:read',
'credential:share',
@ -286,6 +287,7 @@ describe('GET /credentials', () => {
'credential:createEndUser',
'credential:delete',
'credential:list',
'credential:manageInstance',
'credential:move',
'credential:read',
'credential:share',
@ -421,6 +423,7 @@ describe('GET /credentials', () => {
'credential:createEndUser',
'credential:delete',
'credential:list',
'credential:manageInstance',
'credential:move',
'credential:read',
'credential:share',
@ -439,6 +442,7 @@ describe('GET /credentials', () => {
'credential:createEndUser',
'credential:delete',
'credential:list',
'credential:manageInstance',
'credential:move',
'credential:read',
'credential:share',
@ -460,6 +464,7 @@ describe('GET /credentials', () => {
'credential:createEndUser',
'credential:delete',
'credential:list',
'credential:manageInstance',
'credential:move',
'credential:read',
'credential:share',
@ -1267,6 +1272,7 @@ describe('PATCH /credentials/:id', () => {
'credential:createEndUser',
'credential:delete',
'credential:list',
'credential:manageInstance',
'credential:move',
'credential:read',
'credential:share',

View File

@ -0,0 +1,194 @@
import { mockInstance, randomCredentialPayload, testDb } from '@n8n/backend-test-utils';
import type { ICredentialsDb, User } from '@n8n/db';
import {
CredentialsEntity,
CredentialsRepository,
InstanceCredentialAssignmentRepository,
SharedCredentialsRepository,
} from '@n8n/db';
import { Container } from '@n8n/di';
import { CredentialsService } from '@/credentials/credentials.service';
import { InstanceCredentialBroker } from '@/credentials/instance-credential-broker';
import type { InstanceCredentialUse } from '@/credentials/instance-credential-use.registry';
import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error';
import { ExternalHooks } from '@/external-hooks';
import {
createCredentials,
decryptCredentialData,
encryptCredentialData,
} from '../shared/db/credentials';
import { createMember, createOwner } from '../shared/db/users';
import type { SuperAgentTest } from '../shared/types';
import { initCredentialsTypes, setupTestServer } from '../shared/utils';
const externalHooks = mockInstance(ExternalHooks);
const testServer = setupTestServer({ endpointGroups: ['credentials'] });
const TEST_CREDENTIAL_USE = {
id: 'test:instance-credential',
credentialTypes: ['httpHeaderAuth'],
validate: ({ data }) => {
if (data.name !== 'x-api-key') throw new UnprocessableRequestError('Invalid header name');
},
} satisfies InstanceCredentialUse;
let owner: User;
let member: User;
let authOwnerAgent: SuperAgentTest;
let authMemberAgent: SuperAgentTest;
beforeAll(async () => {
await initCredentialsTypes();
Container.get(InstanceCredentialBroker).registerUse(TEST_CREDENTIAL_USE);
owner = await createOwner();
member = await createMember();
authOwnerAgent = testServer.authAgentFor(owner);
authMemberAgent = testServer.authAgentFor(member);
});
beforeEach(async () => {
externalHooks.run.mockReset();
await testDb.truncate(['InstanceCredentialAssignment', 'SharedCredentials', 'CredentialsEntity']);
});
async function createInstanceCredential(payload = randomCredentialPayload()) {
const entity = new CredentialsEntity();
Object.assign(entity, payload, { usageScope: 'instance' });
await encryptCredentialData(entity);
return await createCredentials(entity);
}
describe('instance credentials', () => {
test('owner creates one without any sharing rows', async () => {
const payload = { ...randomCredentialPayload(), usageScope: 'instance' };
const response = await authOwnerAgent.post('/credentials').send(payload);
expect(response.statusCode).toBe(200);
const { id } = response.body.data;
await expect(
Container.get(SharedCredentialsRepository).findBy({ credentialsId: id }),
).resolves.toEqual([]);
});
test('member cannot create one', async () => {
const payload = { ...randomCredentialPayload(), usageScope: 'instance' };
await authMemberAgent.post('/credentials').send(payload).expect(403);
});
test('never appear in the credentials list', async () => {
const credential = await createInstanceCredential();
for (const agent of [authOwnerAgent, authMemberAgent]) {
const response = await agent.get('/credentials').expect(200);
const ids = response.body.data.map((c: { id: string }) => c.id);
expect(ids).not.toContain(credential.id);
}
});
test('member cannot read, update, or delete one', async () => {
const credential = await createInstanceCredential();
await authMemberAgent.get(`/credentials/${credential.id}`).expect(404);
await authMemberAgent
.patch(`/credentials/${credential.id}`)
.send(randomCredentialPayload())
.expect(404);
await authMemberAgent.delete(`/credentials/${credential.id}`).expect(404);
});
test('owner reads one without exposing plain secrets', async () => {
const payload = randomCredentialPayload();
const credential = await createInstanceCredential(payload);
const response = await authOwnerAgent
.get(`/credentials/${credential.id}`)
.query({ includeData: true })
.expect(200);
expect(response.body.data.id).toBe(credential.id);
expect(response.body.data.data?.accessToken).not.toBe(payload.data.accessToken);
});
test('owner can delete one', async () => {
const credential = await createInstanceCredential();
await authOwnerAgent.delete(`/credentials/${credential.id}`).expect(200);
});
describe('assigned to an instance credential use', () => {
const sandboxPayload = {
name: 'AI Assistant sandbox',
type: 'httpHeaderAuth',
data: { name: 'x-api-key', value: 'secret' },
};
async function createAssignedSandboxCredential() {
const credential = await createInstanceCredential(sandboxPayload);
await Container.get(InstanceCredentialAssignmentRepository).assignCredential(
TEST_CREDENTIAL_USE.id,
credential.id,
TEST_CREDENTIAL_USE.credentialTypes,
);
return credential;
}
test('owner can update an assigned credential', async () => {
const credential = await createAssignedSandboxCredential();
await authOwnerAgent
.patch(`/credentials/${credential.id}`)
.send({ ...sandboxPayload, data: { name: 'x-api-key', value: 'rotated' } })
.expect(200);
const row = await Container.get(CredentialsRepository).findOneByOrFail({
id: credential.id,
});
expect(await decryptCredentialData(row)).toMatchObject({ value: 'rotated' });
});
test('owner cannot update an assigned credential with data rejected by its use', async () => {
const credential = await createAssignedSandboxCredential();
await authOwnerAgent
.patch(`/credentials/${credential.id}`)
.send({ ...sandboxPayload, data: { name: 'Authorization', value: 'secret' } })
.expect(422);
const row = await Container.get(CredentialsRepository).findOneByOrFail({
id: credential.id,
});
expect(await decryptCredentialData(row)).toMatchObject({ name: 'x-api-key' });
});
test('owner cannot update one when a hook makes it invalid for its use', async () => {
const credential = await createAssignedSandboxCredential();
const invalid = await Container.get(CredentialsService).createEncryptedData({
id: credential.id,
name: credential.name,
type: credential.type,
data: { name: 'Authorization', value: 'hooked' },
});
externalHooks.run.mockImplementation(async (event, hookParameters) => {
if (event !== 'credentials.update') return;
const [hooked] = hookParameters as [ICredentialsDb];
hooked.data = invalid.data;
});
await authOwnerAgent
.patch(`/credentials/${credential.id}`)
.send({ ...sandboxPayload, data: { name: 'x-api-key', value: 'rotated' } })
.expect(422);
const row = await Container.get(CredentialsRepository).findOneByOrFail({
id: credential.id,
});
expect(await decryptCredentialData(row)).toMatchObject({
name: 'x-api-key',
value: 'secret',
});
});
});
});

View File

@ -1,9 +1,16 @@
import { testDb } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import { DbConnectionOptions, DbLock, DbLockService } from '@n8n/db';
import {
CredentialsRepository,
DbConnectionOptions,
DbLock,
DbLockService,
SettingsRepository,
} from '@n8n/db';
import { Container } from '@n8n/di';
import { DataSource } from '@n8n/typeorm';
import { OperationalError, sleep } from 'n8n-workflow';
import { randomUUID } from 'node:crypto';
let dbLockService: DbLockService;
let isPostgres: boolean;
@ -61,6 +68,82 @@ describe('DbLockService', () => {
}),
).rejects.toThrow('rollback me');
});
it('should roll back credential writes when the settings write fails', async () => {
const credentialsRepository = Container.get(CredentialsRepository);
const settingsRepository = Container.get(SettingsRepository);
const oldCredential = await credentialsRepository.save(
credentialsRepository.create({
id: randomUUID(),
name: 'Old provider connection',
type: 'openAiApi',
data: 'old-encrypted',
usageScope: 'instance',
}),
);
const newCredentialId = randomUUID();
const settingsKey = `test.atomic-settings.${randomUUID()}`;
await expect(
dbLockService.withLockContext(DbLock.TEST, async (ctx) => {
await credentialsRepository.saveInstanceCredential(
credentialsRepository.create({
id: newCredentialId,
name: 'Atomic provider connection',
type: 'openAiApi',
data: 'encrypted',
usageScope: 'instance',
}),
ctx,
);
await credentialsRepository.deleteInstanceCredentialIfUnassigned(oldCredential.id, ctx);
await settingsRepository.upsertByKey(settingsKey, '{}', false, ctx);
throw new Error('rollback both');
}),
).rejects.toThrow('rollback both');
expect(await credentialsRepository.findOneBy({ id: oldCredential.id })).not.toBeNull();
expect(await credentialsRepository.findOneBy({ id: newCredentialId })).toBeNull();
expect(await settingsRepository.findByKey(settingsKey)).toBeNull();
});
it('should roll back an in-place credential update when the settings write fails', async () => {
const credentialsRepository = Container.get(CredentialsRepository);
const settingsRepository = Container.get(SettingsRepository);
const credential = await credentialsRepository.save(
credentialsRepository.create({
id: randomUUID(),
name: 'Original provider connection',
type: 'openAiApi',
data: 'old-encrypted',
usageScope: 'instance',
}),
);
const settingsKey = `test.atomic-settings.${randomUUID()}`;
await expect(
dbLockService.withLockContext(DbLock.TEST, async (ctx) => {
await credentialsRepository.updateInstanceCredential(
credential.id,
{
id: credential.id,
name: 'Updated provider connection',
type: credential.type,
data: 'new-encrypted',
},
ctx,
);
await settingsRepository.upsertByKey(settingsKey, '{}', false, ctx);
throw new Error('rollback both');
}),
).rejects.toThrow('rollback both');
expect(await credentialsRepository.findOneByOrFail({ id: credential.id })).toMatchObject({
name: 'Original provider connection',
data: 'old-encrypted',
});
expect(await settingsRepository.findByKey(settingsKey)).toBeNull();
});
});
describe('tryWithLock', () => {

View File

@ -5008,7 +5008,7 @@
"settings.n8nConnect.wallet.topUp": "Top up balance",
"settings.n8nConnect.usage.refresh.tooltip": "Refresh usage records",
"settings.n8nAgent": "AI Assistant",
"settings.n8nAgent.description": "Enable or disable AI Assistant for this instance and control permissions",
"settings.n8nAgent.description": "Configure AI Assistant for everyone on this instance",
"settings.agentBuilder.title": "Agents",
"settings.agentBuilder.description": "Choose which model the agent builder runs on. By default it uses the n8n AI assistant; admins can switch to a custom provider and credential.",
"settings.agentBuilder.section.model": "Builder model",
@ -5036,15 +5036,42 @@
"agents.modelSelector.freeCredits.description": "Get {credits} free OpenAI API credits. Try it with gpt-5-mini.",
"settings.n8nAgent.enable.label": "Enable AI Assistant",
"settings.n8nAgent.enable.description": "Enables the feature for all users in the instance",
"settings.n8nAgent.computerUse.label": "Enable Computer Use for AI Assistant",
"settings.n8nAgent.computerUse.description": "Allows users on this instance to connect AI Assistant to their computer to work with files and the browser",
"settings.n8nAgent.status.label": "AI Assistant status",
"settings.n8nAgent.status.description": "Let users chat with AI Assistant to build and manage workflows",
"settings.n8nAgent.status.enabled": "Enabled",
"settings.n8nAgent.status.manage": "Manage AI Assistant status",
"settings.n8nAgent.status.disable": "Disable",
"settings.n8nAgent.status.disable.title": "Disable AI Assistant?",
"settings.n8nAgent.status.disable.description": "This prevents everyone on this instance from using AI Assistant. Existing conversations won't be deleted. You can turn it back on later.",
"settings.n8nAgent.empty.title": "Build and manage workflows with AI Assistant",
"settings.n8nAgent.empty.description": "Let users ask AI Assistant to create, update, and run workflows across this instance.",
"settings.n8nAgent.empty.enable": "Enable AI Assistant",
"settings.n8nAgent.credentials.label": "AI service credentials",
"settings.n8nAgent.credentials.description": "Connect the model, sandbox, and web search services AI Assistant uses",
"settings.n8nAgent.credentials.title": "AI service credentials",
"settings.n8nAgent.credentials.pageDescription": "Create or select provider connections for the services AI Assistant uses. These connections aren't available in workflows.",
"settings.n8nAgent.credentials.back": "Back to AI Assistant",
"settings.n8nAgent.credentials.edit": "Edit",
"settings.n8nAgent.credentials.model.title": "Model",
"settings.n8nAgent.credentials.model.description": "Choose the model provider credential used for everyone on this instance",
"settings.n8nAgent.credentials.unavailable.title": "Credential settings aren't available",
"settings.n8nAgent.credentials.unavailable.description": "This deployment manages AI Assistant credentials outside n8n.",
"settings.n8nAgent.capabilities.title": "Capabilities",
"settings.n8nAgent.capabilities.description": "Choose which tools users can connect to AI Assistant",
"settings.n8nAgent.computerUse.label": "Computer use",
"settings.n8nAgent.computerUse.description": "Let users connect AI Assistant to their computer to work with files and the browser",
"settings.n8nAgent.computerUse.disabled.warning": "Computer use has been disabled by your administrator",
"settings.n8nAgent.browserUse.label": "Enable Browser Use for AI Assistant",
"settings.n8nAgent.browserUse.description": "Allows users on this instance to connect AI Assistant to their browser to view pages and automate tasks",
"settings.n8nAgent.mcpAccess.label": "Allow users to extend AI Assistant with MCP Servers",
"settings.n8nAgent.mcpAccess.description": "Allows users on this instance to connect AI Assistant to MCP servers from the registry",
"settings.n8nAgent.browserUse.label": "Browser use",
"settings.n8nAgent.browserUse.description": "Let users connect AI Assistant to their browser to view pages and automate tasks",
"settings.n8nAgent.mcpAccess.label": "MCP server access",
"settings.n8nAgent.mcpAccess.description": "Let users extend AI Assistant with MCP servers from the registry",
"settings.n8nAgent.modelCredential.label": "Model credential",
"settings.n8nAgent.modelCredential.description": "Choose a provider credential or create one",
"settings.n8nAgent.modelCredential.placeholder": "Select a credential",
"settings.n8nAgent.modelCredential.none": "Use environment configuration",
"settings.n8nAgent.modelCredential.createNew": "Create credential",
"settings.n8nAgent.permissions.title": "Permissions",
"settings.n8nAgent.permissions.description": "Control which actions need approval before agent executes them. These settings don't grant users any extra permissions",
"settings.n8nAgent.permissions.description": "Choose which actions AI Assistant can take automatically. These settings don't give users any extra permissions",
"settings.n8nAgent.permissions.alwaysAllow": "Always allow",
"settings.n8nAgent.permissions.needsApproval": "Needs approval",
"settings.n8nAgent.permissions.blocked": "Blocked",

View File

@ -677,6 +677,7 @@ export interface NewCredentialsModal extends ModalState {
contextNode?: INodeUi;
hideAskAssistant?: boolean;
appendToBody?: boolean;
usageScope?: 'project' | 'instance';
/** Behavior for the Instance AI credential setup-help button, supplied by the
* surface that opened the modal (an editor capability, or the credentials list).
* Resolves to whether the credential modal should close (false keeps it open for

View File

@ -562,6 +562,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
appendToBody?: boolean;
closeOnSave?: boolean;
instanceAiCredentialHelp?: NewCredentialsModal['instanceAiCredentialHelp'];
usageScope?: NewCredentialsModal['usageScope'];
} = {},
) => {
setActiveId(CREDENTIAL_EDIT_MODAL_KEY, type);
@ -577,6 +578,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
hideAskAssistant: options.hideAskAssistant,
appendToBody: options.appendToBody,
instanceAiCredentialHelp: options.instanceAiCredentialHelp,
usageScope: options.usageScope,
} as NewCredentialsModal;
setMode(CREDENTIAL_EDIT_MODAL_KEY, 'new');
openModal(CREDENTIAL_EDIT_MODAL_KEY);

View File

@ -57,6 +57,11 @@ const { mcpConnectionsExperimentMock, computerUseExperimentMock, browserUseExper
computerUseExperimentMock: vi.fn(),
}));
vi.mock('vue-router', async (importOriginal) => ({
...(await importOriginal()),
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock('@/experiments/instanceAiMcpConnections', () => ({
useInstanceAiMcpConnectionsExperiment: mcpConnectionsExperimentMock,
}));
@ -69,19 +74,7 @@ vi.mock('@/experiments/instanceAiComputerUse', () => ({
useInstanceAiComputerUseExperiment: computerUseExperimentMock,
}));
const renderComponent = createComponentRenderer(SettingsInstanceAiView, {
global: {
stubs: {
// jsdom can't drive element-plus's ElSwitch (it touches a null input ref),
// so stub it with a button that emits the toggled value.
ElSwitch: {
props: ['modelValue', 'disabled'],
template:
'<button type="button" role="switch" :data-test-id="$attrs[\'data-test-id\']" :aria-checked="!!modelValue" :disabled="disabled" @click="$emit(\'update:modelValue\', !modelValue)" />',
},
},
},
});
const renderComponent = createComponentRenderer(SettingsInstanceAiView);
function setModuleSettings(
settingsStore: ReturnType<typeof useSettingsStore>,
@ -135,8 +128,6 @@ describe('SettingsInstanceAiView', () => {
});
describe('env-only config', () => {
// Model, search, sandbox and advanced options are configured via environment
// variables only; the settings page exposes just the enable toggle and permissions.
it.each([
['model', 'instanceAi.settings.section.model'],
['search', 'instanceAi.settings.section.search'],
@ -152,9 +143,9 @@ describe('SettingsInstanceAiView', () => {
expect(getByText('settings.n8nAgent.permissions.title')).toBeVisible();
});
it('renders the Enable toggle', () => {
it('renders the enabled status action', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('n8n-agent-enable-toggle')).toBeVisible();
expect(getByTestId('n8n-agent-status-menu')).toBeVisible();
});
});

View File

@ -3,6 +3,7 @@ import { INSTANCE_AI_THREAD_SOURCES, type InstanceAiThreadSource } from '@n8n/ap
export const INSTANCE_AI_VIEW = 'InstanceAi';
export const INSTANCE_AI_THREAD_VIEW = 'InstanceAiThread';
export const INSTANCE_AI_SETTINGS_VIEW = 'InstanceAiSettings';
export const INSTANCE_AI_CREDENTIALS_SETTINGS_VIEW = 'InstanceAiCredentialsSettings';
export const INSTANCE_AI_PROJECT_ID_QUERY = 'projectId';
/** Entry-point source carried into the empty view when a hand-off can't create a thread yet. */
export const INSTANCE_AI_SOURCE_QUERY = 'source';

View File

@ -45,3 +45,9 @@ export async function fetchServiceCredentials(
): Promise<InstanceAiModelCredential[]> {
return await makeRestApiRequest(context, 'GET', '/instance-ai/settings/service-credentials');
}
export async function fetchInstanceModelCredentials(
context: IRestApiContext,
): Promise<InstanceAiModelCredential[]> {
return await makeRestApiRequest(context, 'GET', '/instance-ai/settings/model-credentials');
}

View File

@ -11,6 +11,7 @@ import {
updatePreferences,
fetchModelCredentials,
fetchServiceCredentials,
fetchInstanceModelCredentials,
} from './instanceAi.settings.api';
import { hasPermission } from '@/app/utils/rbac/permissions';
import {
@ -51,6 +52,7 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
const preferences = ref<InstanceAiUserPreferencesResponse | null>(null);
const credentials = ref<InstanceAiModelCredential[]>([]);
const serviceCredentials = ref<InstanceAiModelCredential[]>([]);
const instanceModelCredentials = ref<InstanceAiModelCredential[]>([]);
const draft = reactive<InstanceAiAdminSettingsUpdateRequest>({});
const preferencesDraft = reactive<InstanceAiUserPreferencesUpdateRequest>({});
@ -174,17 +176,22 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
const [s, p] = await Promise.all(promises);
settings.value = s;
preferences.value = p;
if (!isProxyEnabled.value) {
if (!isProxyEnabled.value && !isCloudManaged.value) {
const credPromises: [
Promise<InstanceAiModelCredential[]>,
Promise<InstanceAiModelCredential[]>,
Promise<InstanceAiModelCredential[]>,
] = [
fetchModelCredentials(rootStore.restApiContext),
canManage.value ? fetchServiceCredentials(rootStore.restApiContext) : Promise.resolve([]),
canManage.value
? fetchInstanceModelCredentials(rootStore.restApiContext)
: Promise.resolve([]),
];
const [c, sc] = await Promise.all(credPromises);
const [c, sc, imc] = await Promise.all(credPromises);
credentials.value = c;
serviceCredentials.value = sc;
instanceModelCredentials.value = imc;
}
clearDraft();
} catch {
@ -603,6 +610,15 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
}
}
async function refreshInstanceModelCredentials(): Promise<void> {
if (isProxyEnabled.value || !canManage.value) return;
try {
instanceModelCredentials.value = await fetchInstanceModelCredentials(
rootStore.restApiContext,
);
} catch {}
}
async function refreshModuleSettings(): Promise<void> {
const promises: Array<Promise<unknown>> = [settingsStore.getModuleSettings()];
if (!preferences.value) {
@ -621,6 +637,7 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
preferences,
credentials,
serviceCredentials,
instanceModelCredentials,
draft,
preferencesDraft,
isLoading,
@ -665,6 +682,7 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
fetchSetupCommand,
clearSetupCommand,
refreshCredentials,
refreshInstanceModelCredentials,
refreshModuleSettings,
// Browser Use (direct channel)
browserConnected,

View File

@ -9,6 +9,7 @@ import {
INSTANCE_AI_VIEW,
INSTANCE_AI_THREAD_VIEW,
INSTANCE_AI_SETTINGS_VIEW,
INSTANCE_AI_CREDENTIALS_SETTINGS_VIEW,
INSTANCE_AI_NEW_VIEW,
} from './constants';
import {
@ -22,6 +23,8 @@ const InstanceAiView = async () => await import('./InstanceAiView.vue');
const InstanceAiEmptyView = async () => await import('./InstanceAiEmptyView.vue');
const InstanceAiThreadView = async () => await import('./InstanceAiThreadView.vue');
const SettingsInstanceAiView = async () => await import('./views/SettingsInstanceAiView.vue');
const SettingsInstanceAiCredentialsView = async () =>
await import('./views/SettingsInstanceAiCredentialsView.vue');
const ComputerUseSetupModal = async () =>
await import('./components/modals/ComputerUseSetupModal.vue');
const BrowserUseSetupModal = async () =>
@ -129,6 +132,24 @@ export const InstanceAiModule: FrontendModuleDescription = {
},
},
},
{
path: 'assistant/credentials',
name: INSTANCE_AI_CREDENTIALS_SETTINGS_VIEW,
component: SettingsInstanceAiCredentialsView,
meta: {
layout: 'settings',
middleware: ['authenticated', 'rbac', 'custom'],
middlewareOptions: {
rbac: {
scope: ['instanceAi:manage', 'credential:manageInstance'],
options: { mode: 'allOf' },
},
},
telemetry: {
pageCategory: 'settings',
},
},
},
// Permanent redirect from the legacy `/settings/instance-ai` path.
{
path: 'instance-ai',

View File

@ -0,0 +1,214 @@
<script lang="ts" setup>
import { computed, onMounted, watch } from 'vue';
import {
N8nButton,
N8nDropdownMenu,
N8nEmptyState,
N8nIcon,
N8nLoading,
N8nOption,
N8nSelect,
N8nSettingsLayout,
N8nSettingsPageHeader,
N8nSettingsRow,
N8nSettingsRowGroup,
N8nSettingsSection,
type DropdownMenuItemProps,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useRouter } from 'vue-router';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useUIStore } from '@/app/stores/ui.store';
import { CREDENTIAL_EDIT_MODAL_KEY } from '@/features/credentials/credentials.constants';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { INSTANCE_AI_SETTINGS_VIEW } from '../constants';
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
const i18n = useI18n();
const documentTitle = useDocumentTitle();
const router = useRouter();
const uiStore = useUIStore();
const credentialsStore = useCredentialsStore();
const store = useInstanceAiSettingsStore();
const INSTANCE_MODEL_CREDENTIAL_TYPES = [
'openAiApi',
'anthropicApi',
'googlePalmApi',
'ollamaApi',
'groqApi',
'deepSeekApi',
'mistralCloudApi',
'xAiApi',
'openRouterApi',
'cohereApi',
] as const;
function credentialTypeLabel(type: string) {
return credentialsStore.getCredentialTypeByName(type)?.displayName ?? type;
}
const createModelCredentialItems = computed<Array<DropdownMenuItemProps<string>>>(() =>
INSTANCE_MODEL_CREDENTIAL_TYPES.map((type) => ({ id: type, label: credentialTypeLabel(type) })),
);
const canConfigure = computed(
() => store.canManage && !store.isProxyEnabled && !store.isCloudManaged,
);
const selectedModelCredentialId = computed(() => {
if (store.draft.modelCredentialId !== undefined) return store.draft.modelCredentialId ?? '';
return store.settings?.modelCredentialId ?? '';
});
let creatingModelCredential = false;
onMounted(() => {
documentTitle.set(i18n.baseText('settings.n8nAgent.credentials.title'));
void store.fetch();
});
function goBack() {
void router.push({ name: INSTANCE_AI_SETTINGS_VIEW });
}
function handleModelCredentialChange(value: string | number | boolean | null) {
store.setField('modelCredentialId', value ? String(value) : null);
void store.save();
}
function handleCreateModelCredential(credentialType: string) {
creatingModelCredential = true;
uiStore.openNewCredential(
credentialType,
false,
false,
undefined,
undefined,
undefined,
undefined,
{ usageScope: 'instance', closeOnSave: true },
);
}
function editModelCredential() {
if (selectedModelCredentialId.value) {
uiStore.openExistingCredential(selectedModelCredentialId.value, { hideAskAssistant: true });
}
}
watch(
() => uiStore.isModalActiveById[CREDENTIAL_EDIT_MODAL_KEY],
async (isOpen, wasOpen) => {
if (!wasOpen || isOpen || !canConfigure.value) return;
const previousIds = new Set(store.instanceModelCredentials.map((credential) => credential.id));
await store.refreshInstanceModelCredentials();
if (!creatingModelCredential) return;
creatingModelCredential = false;
const newCredential = store.instanceModelCredentials.find(
(credential) => !previousIds.has(credential.id),
);
if (newCredential) handleModelCredentialChange(newCredential.id);
},
);
</script>
<template>
<N8nSettingsLayout
show-back
:back-label="i18n.baseText('settings.n8nAgent.credentials.back')"
data-test-id="n8n-agent-credentials-settings"
@back="goBack"
>
<N8nSettingsPageHeader
:title="i18n.baseText('settings.n8nAgent.credentials.title')"
:description="i18n.baseText('settings.n8nAgent.credentials.pageDescription')"
:show-docs-link="false"
/>
<N8nLoading v-if="store.isLoading" :rows="3" :shrink-last="false" />
<N8nSettingsSection
v-else-if="canConfigure"
:title="i18n.baseText('settings.n8nAgent.credentials.model.title')"
:description="i18n.baseText('settings.n8nAgent.credentials.model.description')"
>
<N8nSettingsRowGroup>
<N8nSettingsRow
:title="i18n.baseText('settings.n8nAgent.modelCredential.label')"
:description="i18n.baseText('settings.n8nAgent.modelCredential.description')"
:action-max-width="false"
>
<template #action>
<div :class="$style.credentialControls">
<N8nSelect
:class="$style.credentialSelect"
:model-value="selectedModelCredentialId"
size="medium"
:disabled="store.isSaving"
:placeholder="i18n.baseText('settings.n8nAgent.modelCredential.placeholder')"
data-test-id="n8n-agent-model-credential-select"
@update:model-value="handleModelCredentialChange"
>
<N8nOption
value=""
:label="i18n.baseText('settings.n8nAgent.modelCredential.none')"
/>
<N8nOption
v-for="credential in store.instanceModelCredentials"
:key="credential.id"
:value="credential.id"
:label="`${credential.name} · ${credentialTypeLabel(credential.type)}`"
/>
</N8nSelect>
<N8nButton
v-if="selectedModelCredentialId"
variant="outline"
size="medium"
:label="i18n.baseText('settings.n8nAgent.credentials.edit')"
:disabled="store.isSaving"
data-test-id="n8n-agent-model-credential-edit"
@click="editModelCredential"
/>
<N8nDropdownMenu
:items="createModelCredentialItems"
placement="bottom-end"
data-test-id="n8n-agent-model-credential-create"
@select="handleCreateModelCredential"
>
<template #trigger>
<N8nButton variant="outline" size="medium" :disabled="store.isSaving">
{{ i18n.baseText('settings.n8nAgent.modelCredential.createNew') }}
<N8nIcon icon="chevron-down" size="small" />
</N8nButton>
</template>
</N8nDropdownMenu>
</div>
</template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
</N8nSettingsSection>
<N8nEmptyState
v-else
:icon="{ type: 'icon', value: 'key-round' }"
:heading="i18n.baseText('settings.n8nAgent.credentials.unavailable.title')"
:description="i18n.baseText('settings.n8nAgent.credentials.unavailable.description')"
/>
</N8nSettingsLayout>
</template>
<style lang="scss" module>
.credentialControls {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing--2xs);
flex-wrap: wrap;
}
.credentialSelect {
width: 15rem;
}
</style>

View File

@ -1,19 +1,40 @@
<script lang="ts" setup>
import { onMounted, computed } from 'vue';
import { N8nButton, N8nHeading, N8nIcon, N8nOption, N8nSelect, N8nText } from '@n8n/design-system';
import { ElSwitch } from 'element-plus';
import { useI18n } from '@n8n/i18n';
import { computed, onMounted } from 'vue';
import {
N8nButton,
N8nDropdownMenu,
N8nEmptyState,
N8nIcon,
N8nLoading,
N8nOption,
N8nSelect,
N8nSettingsLayout,
N8nSettingsPageHeader,
N8nSettingsRow,
N8nSettingsRowConfigure,
N8nSettingsRowGroup,
N8nSettingsSection,
N8nSwitch,
type DropdownMenuItemProps,
type EmptyStateIconCards,
} from '@n8n/design-system';
import type { InstanceAiPermissions, InstanceAiPermissionMode } from '@n8n/api-types';
import { type BaseTextKey, useI18n } from '@n8n/i18n';
import { useRouter } from 'vue-router';
import { MODAL_CONFIRM } from '@/app/constants';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useInstanceAiMcpConnectionsExperiment } from '@/experiments/instanceAiMcpConnections';
import { useMessage } from '@/app/composables/useMessage';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useInstanceAiBrowserUseExperiment } from '@/experiments/instanceAiBrowserUse';
import { useInstanceAiComputerUseExperiment } from '@/experiments/instanceAiComputerUse';
import type { InstanceAiPermissions, InstanceAiPermissionMode } from '@n8n/api-types';
import type { BaseTextKey } from '@n8n/i18n';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useInstanceAiMcpConnectionsExperiment } from '@/experiments/instanceAiMcpConnections';
import { INSTANCE_AI_CREDENTIALS_SETTINGS_VIEW } from '../constants';
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
const i18n = useI18n();
const documentTitle = useDocumentTitle();
const message = useMessage();
const router = useRouter();
const settingsStore = useSettingsStore();
const store = useInstanceAiSettingsStore();
@ -23,6 +44,33 @@ const { isFeatureEnabled: isBrowserUseEnabled } = useInstanceAiBrowserUseExperim
const { isFeatureEnabled: isComputerUseExperimentEnabled } = useInstanceAiComputerUseExperiment();
const isAdmin = computed(() => store.canManage);
const isEnabled = computed(
() => store.settings?.enabled ?? settingsStore.moduleSettings?.['instance-ai']?.enabled ?? false,
);
const isMcpAccessEnabled = computed(() => store.settings?.mcpAccessEnabled ?? true);
const showCredentialsRow = computed(
() => isAdmin.value && !store.isProxyEnabled && !store.isCloudManaged,
);
const showCapabilitiesSection = computed(
() =>
isComputerUseExperimentEnabled.value ||
isBrowserUseEnabled.value ||
isMcpConnectionsExperimentEnabled.value,
);
const emptyStateIcon: EmptyStateIconCards = {
type: 'cards',
center: 'sparkles',
sides: ['workflow', 'message-square', 'search', 'bot'],
};
const disableMenuItems: Array<DropdownMenuItemProps<string>> = [
{
id: 'disable',
label: i18n.baseText('settings.n8nAgent.status.disable'),
icon: { type: 'icon', value: 'power' },
},
];
const PERMISSION_OPTIONS: InstanceAiPermissionMode[] = [
'require_approval',
@ -71,34 +119,45 @@ const permissionKeys: Array<{
},
];
const isMcpAccessEnabled = computed(() => store.settings?.mcpAccessEnabled ?? true);
const isEnabled = computed(
() => store.settings?.enabled ?? settingsStore.moduleSettings?.['instance-ai']?.enabled ?? false,
);
onMounted(() => {
documentTitle.set(i18n.baseText('settings.n8nAgent'));
void store.fetch();
});
function handleEnabledToggle(value: string | number | boolean) {
store.setField('enabled', Boolean(value));
async function handleEnable() {
await store.persistEnabled(true);
}
async function handleStatusAction(action: string) {
if (action !== 'disable') return;
const confirmed = await message.confirm(
i18n.baseText('settings.n8nAgent.status.disable.description'),
{
title: i18n.baseText('settings.n8nAgent.status.disable.title'),
confirmButtonText: i18n.baseText('settings.n8nAgent.status.disable'),
cancelButtonText: i18n.baseText('generic.cancel'),
},
);
if (confirmed === MODAL_CONFIRM) await store.persistEnabled(false);
}
function openCredentialsSettings() {
void router.push({ name: INSTANCE_AI_CREDENTIALS_SETTINGS_VIEW });
}
function handleComputerUseToggle(value: boolean) {
store.setField('localGatewayDisabled', !value);
void store.save();
}
function handleComputerUseToggle(value: string | number | boolean) {
store.setField('localGatewayDisabled', !Boolean(value));
function handleBrowserUseToggle(value: boolean) {
store.setField('browserUseEnabled', value);
void store.save();
}
function handleBrowserUseToggle(value: string | number | boolean) {
store.setField('browserUseEnabled', Boolean(value));
void store.save();
}
function handleMcpAccessToggle(value: string | number | boolean) {
store.setField('mcpAccessEnabled', Boolean(value));
function handleMcpAccessToggle(value: boolean) {
store.setField('mcpAccessEnabled', value);
void store.save();
}
@ -109,158 +168,184 @@ function handlePermissionChange(key: keyof InstanceAiPermissions, value: Instanc
</script>
<template>
<div :class="$style.container" data-test-id="n8n-agent-settings">
<header :class="$style.header">
<N8nHeading :class="$style.pageTitle" size="xlarge" class="mb-2xs">
{{ i18n.baseText('settings.n8nAgent') }}
</N8nHeading>
<N8nText size="medium" color="text-light">
{{ i18n.baseText('settings.n8nAgent.description') }}
</N8nText>
</header>
<N8nSettingsLayout data-test-id="n8n-agent-settings">
<N8nSettingsPageHeader
:title="i18n.baseText('settings.n8nAgent')"
:description="i18n.baseText('settings.n8nAgent.description')"
:show-docs-link="false"
/>
<div v-if="store.isLoading" :class="$style.loading">
<N8nIcon icon="spinner" spin />
</div>
<N8nLoading v-if="store.isLoading" :rows="3" :shrink-last="false" />
<N8nEmptyState
v-else-if="!isEnabled"
:icon="emptyStateIcon"
:heading="i18n.baseText('settings.n8nAgent.empty.title')"
:description="i18n.baseText('settings.n8nAgent.empty.description')"
:button-text="isAdmin ? i18n.baseText('settings.n8nAgent.empty.enable') : undefined"
button-variant="solid"
@click:button="handleEnable"
/>
<template v-else>
<template v-if="isAdmin">
<div :class="$style.card">
<div :class="$style.sectionBlock">
<div :class="$style.enableSection">
<N8nHeading tag="h2" size="small">
{{ i18n.baseText('settings.n8nAgent.enable.label') }}
</N8nHeading>
<div :class="$style.switchRow">
<span :class="$style.switchDescription">
{{ i18n.baseText('settings.n8nAgent.enable.description') }}
</span>
<ElSwitch
:model-value="isEnabled"
:disabled="store.isSaving"
data-test-id="n8n-agent-enable-toggle"
@update:model-value="handleEnabledToggle"
/>
</div>
</div>
</div>
</div>
</template>
<N8nSettingsSection v-if="isAdmin">
<N8nSettingsRowGroup>
<N8nSettingsRow
:title="i18n.baseText('settings.n8nAgent.status.label')"
:description="i18n.baseText('settings.n8nAgent.status.description')"
>
<template #action>
<N8nDropdownMenu
:items="disableMenuItems"
placement="bottom-end"
data-test-id="n8n-agent-status-menu"
@select="handleStatusAction"
>
<template #trigger>
<N8nButton
variant="outline"
size="medium"
:disabled="store.isSaving"
:aria-label="i18n.baseText('settings.n8nAgent.status.manage')"
>
<span :class="$style.statusLabel">
<span :class="$style.statusDot" aria-hidden="true" />
{{ i18n.baseText('settings.n8nAgent.status.enabled') }}
<N8nIcon icon="chevron-down" size="small" />
</span>
</N8nButton>
</template>
<template #item-leading="{ item }">
<N8nIcon
v-if="item.icon?.type === 'icon'"
:class="$style.danger"
:icon="item.icon.value"
size="small"
/>
</template>
<template #item-label="{ item }">
<span :class="$style.danger">{{ item.label }}</span>
</template>
</N8nDropdownMenu>
</template>
</N8nSettingsRow>
<template v-if="isEnabled">
<div v-if="isAdmin && isComputerUseExperimentEnabled" :class="$style.card">
<div :class="$style.settingsRow">
<div :class="$style.settingsRowLeft">
<span :class="$style.settingsRowLabel">
{{ i18n.baseText('settings.n8nAgent.computerUse.label') }}
</span>
<span :class="$style.settingsRowDescription">
{{ i18n.baseText('settings.n8nAgent.computerUse.description') }}
</span>
</div>
<ElSwitch
:model-value="!(store.settings?.localGatewayDisabled ?? false)"
:disabled="store.isSaving"
data-test-id="n8n-agent-computer-use-toggle"
@update:model-value="handleComputerUseToggle"
/>
</div>
</div>
<N8nSettingsRow
v-if="showCredentialsRow"
:title="i18n.baseText('settings.n8nAgent.credentials.label')"
:description="i18n.baseText('settings.n8nAgent.credentials.description')"
clickable
data-test-id="n8n-agent-credentials-row"
@click="openCredentialsSettings"
>
<template #action>
<N8nSettingsRowConfigure />
</template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
</N8nSettingsSection>
<div v-if="isAdmin && isBrowserUseEnabled" :class="$style.card">
<div :class="$style.settingsRow">
<div :class="$style.settingsRowLeft">
<span :class="$style.settingsRowLabel">
{{ i18n.baseText('settings.n8nAgent.browserUse.label') }}
</span>
<span :class="$style.settingsRowDescription">
{{ i18n.baseText('settings.n8nAgent.browserUse.description') }}
</span>
</div>
<ElSwitch
:model-value="store.settings?.browserUseEnabled ?? true"
:disabled="store.isSaving"
data-test-id="n8n-agent-browser-use-toggle"
@update:model-value="handleBrowserUseToggle"
/>
</div>
</div>
<div v-if="isAdmin && isMcpConnectionsExperimentEnabled" :class="$style.card">
<div :class="[$style.settingsRow, { [$style.settingsRowBorder]: isMcpAccessEnabled }]">
<div :class="$style.settingsRowLeft">
<span :class="$style.settingsRowLabel">
{{ i18n.baseText('settings.n8nAgent.mcpAccess.label') }}
</span>
<span :class="$style.settingsRowDescription">
{{ i18n.baseText('settings.n8nAgent.mcpAccess.description') }}
</span>
</div>
<ElSwitch
:model-value="isMcpAccessEnabled"
:disabled="store.isSaving"
data-test-id="n8n-agent-mcp-access-toggle"
@update:model-value="handleMcpAccessToggle"
/>
</div>
<div v-if="isMcpAccessEnabled" :class="$style.settingsRow">
<div :class="$style.settingsRowLeft">
<span :class="$style.settingsRowLabel">
{{ i18n.baseText('settings.n8nAgent.permissions.executeMcpTool') }}
</span>
</div>
<N8nSelect
:class="$style.permissionSelect"
:model-value="store.getPermission('executeMcpTool')"
size="small"
:disabled="store.isSaving"
data-test-id="n8n-agent-permission-executeMcpTool"
@update:model-value="
handlePermissionChange('executeMcpTool', $event as InstanceAiPermissionMode)
"
>
<N8nOption
v-for="option in MCP_TOOL_PERMISSION_OPTIONS"
:key="option"
:value="option"
:label="i18n.baseText(PERMISSION_OPTION_LABEL[option])"
<N8nSettingsSection
v-if="isAdmin && showCapabilitiesSection"
:title="i18n.baseText('settings.n8nAgent.capabilities.title')"
:description="i18n.baseText('settings.n8nAgent.capabilities.description')"
>
<N8nSettingsRowGroup>
<N8nSettingsRow
v-if="isComputerUseExperimentEnabled"
:title="i18n.baseText('settings.n8nAgent.computerUse.label')"
:description="i18n.baseText('settings.n8nAgent.computerUse.description')"
>
<template #action>
<N8nSwitch
:model-value="!(store.settings?.localGatewayDisabled ?? false)"
:disabled="store.isSaving"
:aria-label="i18n.baseText('settings.n8nAgent.computerUse.label')"
data-test-id="n8n-agent-computer-use-toggle"
@update:model-value="handleComputerUseToggle"
/>
</N8nSelect>
</div>
</div>
</template>
</N8nSettingsRow>
<template v-if="isAdmin">
<div :class="$style.permissionsHeader">
<N8nHeading :class="$style.sectionTitle" tag="h3" size="medium">
{{ i18n.baseText('settings.n8nAgent.permissions.title') }}
</N8nHeading>
<N8nText size="medium" color="text-light">
{{ i18n.baseText('settings.n8nAgent.permissions.description') }}
</N8nText>
</div>
<N8nSettingsRow
v-if="isBrowserUseEnabled"
:title="i18n.baseText('settings.n8nAgent.browserUse.label')"
:description="i18n.baseText('settings.n8nAgent.browserUse.description')"
>
<template #action>
<N8nSwitch
:model-value="store.settings?.browserUseEnabled ?? true"
:disabled="store.isSaving"
:aria-label="i18n.baseText('settings.n8nAgent.browserUse.label')"
data-test-id="n8n-agent-browser-use-toggle"
@update:model-value="handleBrowserUseToggle"
/>
</template>
</N8nSettingsRow>
<div :class="$style.card">
<div
v-for="(perm, index) in permissionKeys"
:key="perm.key"
:class="[
$style.settingsRow,
{ [$style.settingsRowBorder]: index < permissionKeys.length - 1 },
]"
>
<div :class="$style.settingsRowLeft">
<span :class="$style.settingsRowLabel">
{{ i18n.baseText(perm.labelKey) }}
</span>
</div>
<N8nSettingsRow
v-if="isMcpConnectionsExperimentEnabled"
:title="i18n.baseText('settings.n8nAgent.mcpAccess.label')"
:description="i18n.baseText('settings.n8nAgent.mcpAccess.description')"
>
<template #action>
<N8nSwitch
:model-value="isMcpAccessEnabled"
:disabled="store.isSaving"
:aria-label="i18n.baseText('settings.n8nAgent.mcpAccess.label')"
data-test-id="n8n-agent-mcp-access-toggle"
@update:model-value="handleMcpAccessToggle"
/>
</template>
</N8nSettingsRow>
<N8nSettingsRow
v-if="isMcpConnectionsExperimentEnabled && isMcpAccessEnabled"
:title="i18n.baseText('settings.n8nAgent.permissions.executeMcpTool')"
>
<template #action>
<N8nSelect
:class="$style.permissionSelect"
:model-value="store.getPermission(perm.key)"
:model-value="store.getPermission('executeMcpTool')"
size="small"
:disabled="store.isSaving"
:data-test-id="`n8n-agent-permission-${perm.key}`"
data-test-id="n8n-agent-permission-executeMcpTool"
@update:model-value="
handlePermissionChange(perm.key, $event as InstanceAiPermissionMode)
handlePermissionChange('executeMcpTool', $event as InstanceAiPermissionMode)
"
>
<N8nOption
v-for="option in MCP_TOOL_PERMISSION_OPTIONS"
:key="option"
:value="option"
:label="i18n.baseText(PERMISSION_OPTION_LABEL[option])"
/>
</N8nSelect>
</template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
</N8nSettingsSection>
<N8nSettingsSection
v-if="isAdmin"
:title="i18n.baseText('settings.n8nAgent.permissions.title')"
:description="i18n.baseText('settings.n8nAgent.permissions.description')"
>
<N8nSettingsRowGroup>
<N8nSettingsRow
v-for="permission in permissionKeys"
:key="permission.key"
:title="i18n.baseText(permission.labelKey)"
>
<template #action>
<N8nSelect
:class="$style.permissionSelect"
:model-value="store.getPermission(permission.key)"
size="small"
:disabled="store.isSaving"
:data-test-id="`n8n-agent-permission-${permission.key}`"
@update:model-value="
handlePermissionChange(permission.key, $event as InstanceAiPermissionMode)
"
>
<N8nOption
@ -270,150 +355,33 @@ function handlePermissionChange(key: keyof InstanceAiPermissions, value: Instanc
:label="i18n.baseText(PERMISSION_OPTION_LABEL[option])"
/>
</N8nSelect>
</div>
</div>
</template>
</template>
<div v-if="store.isDirty" :class="$style.footer">
<N8nButton
type="secondary"
:label="i18n.baseText('generic.cancel')"
@click="store.reset()"
/>
<N8nButton
:label="i18n.baseText('settings.personal.save')"
:loading="store.isSaving"
@click="store.save()"
/>
</div>
</template>
</N8nSettingsRow>
</N8nSettingsRowGroup>
</N8nSettingsSection>
</template>
</div>
</N8nSettingsLayout>
</template>
<style lang="scss" module>
.container {
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
max-width: 720px;
margin: 0 auto;
padding-bottom: var(--spacing--2xl);
}
.pageTitle {
font-weight: var(--font-weight--medium);
line-height: 26px;
color: var(--color--text--shade-1);
}
.header {
display: flex;
flex-direction: column;
margin-bottom: var(--spacing--xs);
}
.loading {
display: flex;
.statusLabel {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--spacing--2xl);
color: var(--color--text--tint-1);
gap: var(--spacing--3xs);
}
.card {
border: var(--border);
border-radius: var(--radius);
overflow: hidden;
.statusDot {
width: var(--spacing--2xs);
height: var(--spacing--2xs);
border-radius: var(--radius--full);
background: var(--text-color--success);
}
.sectionBlock {
padding: var(--spacing--sm);
background: var(--color--background--light-3);
}
.settingsRow {
display: flex;
align-items: center;
justify-content: space-between;
padding-left: var(--spacing--sm);
padding-right: var(--spacing--sm);
min-height: 64px;
background: var(--color--background--light-3);
}
.settingsRowBorder {
position: relative;
&::after {
content: '';
position: absolute;
bottom: 0;
left: var(--spacing--sm);
right: var(--spacing--sm);
height: 1px;
background: var(--color--foreground);
}
}
.settingsRowLeft {
display: flex;
flex-direction: column;
gap: var(--spacing--4xs);
}
.settingsRowLabel {
font-size: var(--font-size--sm);
font-weight: var(--font-weight--medium);
line-height: 20px;
color: var(--color--text--shade-1);
}
.settingsRowDescription {
font-size: var(--font-size--2xs);
color: var(--color--text--tint-1);
}
.sectionTitle {
font-weight: var(--font-weight--medium);
line-height: 24px;
color: var(--color--text--shade-1);
}
.permissionsHeader {
display: flex;
flex-direction: column;
gap: var(--spacing--4xs);
margin-top: var(--spacing--xs);
.danger {
color: var(--text-color--danger);
}
.permissionSelect {
width: 178px;
flex-shrink: 0;
}
.enableSection {
display: flex;
flex-direction: column;
gap: var(--spacing--xs);
}
.switchRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing--4xs) 0;
}
.switchDescription {
font-size: var(--font-size--2xs);
color: var(--color--text--tint-1);
}
.footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing--2xs);
padding-top: var(--spacing--sm);
width: 11rem;
}
</style>

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, useTemplateRef } from 'vue';
import type { IUpdateInformation } from '@/Interface';
import type { IUpdateInformation, NewCredentialsModal } from '@/Interface';
import type { ICredentialsResponse } from '../../credentials.types';
import type {
@ -255,11 +255,21 @@ const closeOnSave = computed<boolean>(() => {
return isCredentialModalState(modalState) && modalState.closeOnSave === true;
});
const presetUsageScope = computed<NewCredentialsModal['usageScope']>(() => {
if (props.mode !== 'new') return undefined;
const modalState = uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY];
return isCredentialModalState(modalState) ? modalState.usageScope : undefined;
});
const appendToBody = computed<boolean>(() => {
const modalState = uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY];
return isCredentialModalState(modalState) && modalState.appendToBody === true;
});
const isInstanceCredential = computed(
() => presetUsageScope.value === 'instance' || currentCredential.value?.usageScope === 'instance',
);
const sidebarItems = computed(() => {
const menuItems: IMenuItem[] = [
{
@ -267,11 +277,15 @@ const sidebarItems = computed(() => {
label: i18n.baseText('credentialEdit.credentialEdit.connection'),
position: 'top',
},
{
id: 'sharing',
label: i18n.baseText('credentialEdit.credentialEdit.sharing'),
position: 'top',
},
...(isInstanceCredential.value
? []
: [
{
id: 'sharing',
label: i18n.baseText('credentialEdit.credentialEdit.sharing'),
position: 'top',
} satisfies IMenuItem,
]),
{
id: 'details',
label: i18n.baseText('credentialEdit.credentialEdit.details'),
@ -647,6 +661,9 @@ async function saveCredential(): Promise<ICredentialsResponse | null> {
const isNewCredential = props.mode === 'new' && !credentialId.value;
if (isNewCredential) {
if (presetUsageScope.value) {
credentialDetails.usageScope = presetUsageScope.value;
}
credential = await createCredential(credentialDetails, projectsStore.currentProject);
} else {
if (settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing]) {
@ -1361,7 +1378,7 @@ const { width } = useElementSize(credNameRef);
:credential-permissions="credentialPermissions"
:mode="mode"
:selected-credential="selectedCredential"
:is-private-credentials-enabled="isPrivateCredentialsEnabled"
:is-private-credentials-enabled="isPrivateCredentialsEnabled && !isInstanceCredential"
:is-resolvable="isResolvable"
:connected-by-me="connectedByMe"
:is-new-credential="isNewCredential"

View File

@ -8,6 +8,7 @@ import { computed, onMounted, ref } from 'vue';
import { CREDENTIAL_SELECT_MODAL_KEY } from '../credentials.constants';
import Modal from '@/app/components/Modal.vue';
import { useI18n } from '@n8n/i18n';
import type { NewCredentialsModal } from '@/Interface';
import { N8nButton, N8nIcon, N8nOption, N8nSelect } from '@n8n/design-system';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
@ -28,6 +29,11 @@ const instanceAiCredentialHelp = useInstanceAiCredentialHelp();
const searchQuery = ref('');
const presetUsageScope = computed<NewCredentialsModal['usageScope']>(() => {
const data = uiStore.modalsById[CREDENTIAL_SELECT_MODAL_KEY]?.data;
return data?.usageScope === 'instance' ? 'instance' : undefined;
});
onMounted(async () => {
try {
await credentialsStore.fetchCredentialTypes(false);
@ -76,6 +82,7 @@ function openCredentialType() {
undefined,
{
instanceAiCredentialHelp: instanceAiCredentialHelp(),
usageScope: presetUsageScope.value,
},
);

View File

@ -89,7 +89,12 @@ const slackNode: INodeUi = {
credentials: {},
};
function createCredential(overrides: { id: string; name: string; type: string }) {
function createCredential(overrides: {
id: string;
name: string;
type: string;
usageScope?: 'project' | 'instance';
}) {
return {
...overrides,
isManaged: false,
@ -212,6 +217,21 @@ describe('useNodeCredentialOptions', () => {
).toEqual(['httpBasicAuth']);
});
it('excludes instance credentials from node options', () => {
credentialsStore.state.credentials['instance-cred'] = createCredential({
id: 'instance-cred',
name: 'Instance Slack Token',
type: 'slackApi',
usageScope: 'instance',
});
const { credentialTypesNodeDescriptionDisplayed } = setupOptions();
expect(
credentialTypesNodeDescriptionDisplayed.value[0].options.map((option) => option.id),
).not.toContain('instance-cred');
});
it('disables mixed credential behavior when override is set', () => {
const slackApiDescription = slackNodeType.credentials[0];
const { showMixedCredentials } = setupOptions('slackApi');

View File

@ -69,6 +69,8 @@ export function useNodeCredentialOptions(
);
});
options = options.filter((option) => (option.usageScope ?? 'project') === 'project');
if (node.value?.type === HTTP_REQUEST_NODE_TYPE) {
options = options.filter((option) => !option.isManaged);
}

View File

@ -263,6 +263,7 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
};
const upsertCredential = (credential: ICredentialsResponse) => {
if (credential.usageScope === 'instance') return;
if (credential.id) {
state.value.credentials = {
...state.value.credentials,
@ -361,6 +362,7 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
uiContext,
isGlobal: data.isGlobal,
isResolvable: data.isResolvable,
usageScope: data.usageScope,
});
if (data?.homeProject && !credential.homeProject) {

View File

@ -16,6 +16,7 @@ export interface ICredentialsResponse extends ICredentialsEncrypted {
isManaged: boolean;
isGlobal?: boolean;
isResolvable?: boolean;
usageScope?: 'project' | 'instance';
/** Whether the current user has personally connected this credential. Set on resolvable credentials only. */
connectedByMe?: boolean;
/** Total number of users connected to this credential. Set on resolvable credentials only. */

View File

@ -164,7 +164,10 @@ describe('CredentialsView', () => {
projectsStore.currentProject = createTestProject({ scopes: ['credential:create'] });
const { rerender } = renderComponent();
await rerender({ credentialId: 'create' });
expect(uiStore.openModal).toHaveBeenCalledWith(CREDENTIAL_SELECT_MODAL_KEY);
expect(uiStore.openModalWithData).toHaveBeenCalledWith({
name: CREDENTIAL_SELECT_MODAL_KEY,
data: {},
});
});
it('should not show the modal on the route if the user has no scope to create credential in the project', async () => {

View File

@ -195,7 +195,8 @@ const onFilter = (resource: Resource, newFilters: BaseFilters, matches: boolean)
const maybeCreateCredential = () => {
if (props.credentialId === 'create') {
if (projectPermissions.value.credential.create) {
uiStore.openModal(CREDENTIAL_SELECT_MODAL_KEY);
// Modal data persists across opens, so clear the instance-only preset.
uiStore.openModalWithData({ name: CREDENTIAL_SELECT_MODAL_KEY, data: {} });
} else {
void router.replace({ name: VIEWS.HOMEPAGE });
}

View File

@ -172,6 +172,7 @@ export interface ICredentialsDecrypted<T extends object = ICredentialDataDecrypt
sharedWithProjects?: ProjectSharingData[];
isGlobal?: boolean;
isResolvable?: boolean;
usageScope?: 'project' | 'instance';
}
export interface ICredentialsEncrypted {