feat(core): Enable credential creation per project in public API (#28240)

This commit is contained in:
Sandra Zollner 2026-04-13 14:22:52 +02:00 committed by GitHub
parent a9950c182a
commit 8cd75d2f2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 282 additions and 104 deletions

View file

@ -246,6 +246,11 @@ export {
type CommunityPackageResponse,
} from './schemas/community-package.schema';
export {
publicApiCreatedCredentialSchema,
type PublicApiCreatedCredential,
} from './schemas/credential-created.schema';
export {
instanceAiEventTypeSchema,
instanceAiRunStatusSchema,

View file

@ -0,0 +1,20 @@
import { z } from 'zod';
/**
* Plain credential row after creation
* Used by the public API to validate results from `CredentialsService.createUnmanagedCredential`.
*/
export const publicApiCreatedCredentialSchema = z.object({
id: z.string(),
name: z.string(),
type: z.string(),
isManaged: z.boolean(),
isGlobal: z.boolean(),
isResolvable: z.boolean(),
resolvableAllowFallback: z.boolean(),
resolverId: z.union([z.string(), z.null()]).optional(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
export type PublicApiCreatedCredential = z.infer<typeof publicApiCreatedCredentialSchema>;

View file

@ -1,7 +1,7 @@
import type { CreateCredentialDto } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import type { Project, User, ICredentialsDb, ScopesField } from '@n8n/db';
import {
Project,
CredentialsEntity,
SharedCredentials,
CredentialsRepository,
@ -9,6 +9,7 @@ import {
SharedCredentialsRepository,
UserRepository,
} from '@n8n/db';
import type { User, ICredentialsDb, ScopesField } from '@n8n/db';
import { Service } from '@n8n/di';
import { hasGlobalScope, PROJECT_OWNER_ROLE_SLUG, type Scope } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
@ -713,11 +714,28 @@ export class CredentialsService {
});
}
private async resolveOwningProjectIdForNewCredential(
user: User,
projectId: string | undefined,
entityManager?: EntityManager,
): Promise<string> {
if (projectId !== undefined) {
return projectId;
}
const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail(
user.id,
entityManager,
);
// Chat users are not allowed to create credentials even within their personal project,
// so even though we found the project ensure it gets found via expected scope too.
return personalProject.id;
}
async save(
credential: CredentialsEntity,
encryptedData: ICredentialsDb,
user: User,
projectId?: string,
projectId: string,
decryptedCredentialData?: ICredentialDataDecryptedObject,
) {
// To avoid side effects
@ -728,16 +746,6 @@ export class CredentialsService {
const { manager: dbManager } = this.credentialsRepository;
const result = await dbManager.transaction(async (transactionManager) => {
if (projectId === undefined) {
const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail(
user.id,
transactionManager,
);
// Chat users are not allowed to create credentials even within their personal project,
// so even though we found the project ensure it gets found via expected scope too.
projectId = personalProject.id;
}
const project = await this.projectService.getProjectWithScope(
user,
projectId,
@ -746,7 +754,10 @@ export class CredentialsService {
);
if (project === null) {
throw new BadRequestError(
if (!(await transactionManager.existsBy(Project, { id: projectId }))) {
throw new NotFoundError('Project not found');
}
throw new ForbiddenError(
"You don't have the permissions to save the credential in this project.",
);
}
@ -1052,9 +1063,14 @@ export class CredentialsService {
async getCredentialScopes(user: User, credentialId: string): Promise<Scope[]> {
const userProjectRelations = await this.projectService.getProjectRelationsForUser(user);
const projectIds = [...new Set(userProjectRelations.map((pr) => pr.projectId))];
// Postgres rejects `IN ()`; SQLite tolerates it. Skip the query when there is no project scope.
if (projectIds.length === 0) {
return this.roleService.combineResourceScopes('credential', user, [], userProjectRelations);
}
const shared = await this.sharedCredentialsRepository.find({
where: {
projectId: In([...new Set(userProjectRelations.map((pr) => pr.projectId))]),
projectId: In(projectIds),
credentialsId: credentialId,
},
});
@ -1217,15 +1233,17 @@ export class CredentialsService {
}
private async createCredential(opts: CreateCredentialOptions, user: User) {
const targetProjectId = await this.resolveOwningProjectIdForNewCredential(user, opts.projectId);
await this.checkCredentialData(
opts.type,
opts.data as ICredentialDataDecryptedObject,
user,
opts.projectId ?? '',
targetProjectId,
);
if (this.externalSecretsConfig.externalSecretsForProjects && opts.projectId) {
if (this.externalSecretsConfig.externalSecretsForProjects) {
await validateAccessToReferencedSecretProviders(
opts.projectId,
targetProjectId,
opts.data as ICredentialDataDecryptedObject,
this.externalSecretsProviderAccessCheckService,
'create',
@ -1260,7 +1278,7 @@ export class CredentialsService {
credentialEntity,
encryptedCredential,
user,
opts.projectId,
targetProjectId,
opts.data as ICredentialDataDecryptedObject,
);

View file

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

View file

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { LicenseState } from '@n8n/backend-common';
import type { CredentialsEntity } from '@n8n/db';
import { CredentialsRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { hasGlobalScope } from '@n8n/permissions';
import type express from 'express';
@ -36,7 +37,6 @@ import {
validCursor,
} from '../../shared/middlewares/global.middleware';
import { encodeNextCursor } from '../../shared/services/pagination.service';
import { CredentialsRepository } from '@n8n/db';
export = {
getCredentials: [
@ -100,14 +100,9 @@ export = {
req: CredentialRequest.Create,
res: express.Response,
): Promise<express.Response<Partial<CredentialsEntity>>> => {
try {
const savedCredential = await saveCredential(req.body, req.user);
const savedCredential = await saveCredential(req.body, req.user);
return res.json(sanitizeCredentials(savedCredential));
} catch ({ message, httpStatusCode }) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
return res.status(httpStatusCode ?? 500).json({ message });
}
return res.json(sanitizeCredentials(savedCredential));
},
],
updateCredential: [

View file

@ -1,11 +1,6 @@
import type { User, ICredentialsDb } from '@n8n/db';
import {
CredentialsEntity,
SharedCredentials,
CredentialsRepository,
ProjectRepository,
SharedCredentialsRepository,
} from '@n8n/db';
import { publicApiCreatedCredentialSchema } from '@n8n/api-types';
import type { User, ICredentialsDb, SharedCredentials } from '@n8n/db';
import { CredentialsEntity, CredentialsRepository, SharedCredentialsRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { Credentials } from 'n8n-core';
import {
@ -15,6 +10,7 @@ import {
type IDataObject,
type INodeProperties,
type INodePropertyOptions,
UnexpectedError,
} from 'n8n-workflow';
import { CredentialsService } from '@/credentials/credentials.service';
@ -24,11 +20,10 @@ import {
} from '@/credentials/validation';
import { EventService } from '@/events/event.service';
import { ExternalHooks } from '@/external-hooks';
import type { CredentialRequest } from '@/requests';
import { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config';
import { SecretsProviderAccessCheckService } from '@/modules/external-secrets.ee/secret-provider-access-check.service.ee';
import type { IDependency, IJsonSchema } from '../../../types';
import { SecretsProviderAccessCheckService } from '@/modules/external-secrets.ee/secret-provider-access-check.service.ee';
import { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config';
export class CredentialsIsNotUpdatableError extends BaseError {}
@ -87,57 +82,17 @@ export async function getSharedCredentials(
});
}
export async function createCredential(
properties: CredentialRequest.CredentialProperties,
): Promise<CredentialsEntity> {
const newCredential = new CredentialsEntity();
Object.assign(newCredential, properties);
return newCredential;
}
/**
* Creats a credential in the personal project of the given user.
* Creates a credential via the internal CredentialsService, which handles project
* resolution, validation, and encryption.
*/
export async function saveCredential(
payload: { type: string; name: string; data: ICredentialDataDecryptedObject },
payload: { type: string; name: string; data: ICredentialDataDecryptedObject; projectId?: string },
user: User,
): Promise<CredentialsEntity> {
const credential = await createCredential(payload);
const projectRepository = Container.get(ProjectRepository);
const personalProject = await projectRepository.getPersonalProjectForUserOrFail(user.id);
await validateExternalSecretsPermissions({
user,
projectId: personalProject.id,
dataToSave: payload.data,
});
const encryptedData = await encryptCredential(credential);
Object.assign(credential, encryptedData);
const { manager: dbManager } = projectRepository;
const result = await dbManager.transaction(async (transactionManager) => {
const savedCredential = await transactionManager.save<CredentialsEntity>(credential);
savedCredential.data = credential.data;
const newSharedCredential = new SharedCredentials();
Object.assign(newSharedCredential, {
role: 'credential:owner',
credentials: savedCredential,
projectId: personalProject.id,
});
await transactionManager.save<SharedCredentials>(newSharedCredential);
return savedCredential;
});
await Container.get(ExternalHooks).run('credentials.create', [encryptedData]);
const { scopes: _scopes, ...credential } = await Container.get(
CredentialsService,
).createUnmanagedCredential({ ...payload, projectId: payload.projectId ?? undefined }, user);
const project = await Container.get(SharedCredentialsRepository).findCredentialOwningProject(
credential.id,
@ -147,13 +102,31 @@ export async function saveCredential(
user,
credentialType: credential.type,
credentialId: credential.id,
publicApi: true,
projectId: project?.id,
projectType: project?.type,
publicApi: true,
isDynamic: credential.isResolvable ?? false,
});
return result;
const credentialForApi = {
id: credential.id,
name: credential.name,
type: credential.type,
isManaged: credential.isManaged,
isGlobal: credential.isGlobal,
isResolvable: credential.isResolvable,
resolvableAllowFallback: credential.resolvableAllowFallback,
resolverId: credential.resolverId,
createdAt: credential.createdAt,
updatedAt: credential.updatedAt,
};
const parsed = publicApiCreatedCredentialSchema.safeParse(credentialForApi);
if (!parsed.success) {
throw new UnexpectedError('Credential create response failed validation');
}
return Object.assign(new CredentialsEntity(), parsed.data, { shared: [] });
}
export async function updateCredential(

View file

@ -32,7 +32,7 @@ post:
content:
application/json:
schema:
$ref: '../schemas/credential.yml'
$ref: '../schemas/credentialCreate.yml'
responses:
'200':
description: Operation successful.
@ -41,8 +41,12 @@ post:
schema:
$ref: '../schemas/create-credential-response.yml'
'400':
description: Bad request - invalid credential type or data.
$ref: '../../../../shared/spec/responses/badRequest.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'403':
$ref: '../../../../shared/spec/responses/forbidden.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'
'415':
description: Unsupported media type.

View file

@ -2,6 +2,10 @@ required:
- id
- name
- type
- isManaged
- isGlobal
- isResolvable
- resolvableAllowFallback
- createdAt
- updatedAt
type: object
@ -16,6 +20,32 @@ properties:
type:
type: string
example: githubApi
isManaged:
type: boolean
readOnly: true
description: Whether the credential is managed by n8n (managed credentials cannot be edited via the API).
example: false
isGlobal:
type: boolean
readOnly: true
description: Whether the credential is available for use by all users.
example: false
isResolvable:
type: boolean
readOnly: true
description: Whether the credential can be dynamically resolved by a resolver.
example: false
resolvableAllowFallback:
type: boolean
readOnly: true
description: Whether the credential resolver may fall back to static credentials if dynamic resolution fails.
example: false
resolverId:
type: string
nullable: true
readOnly: true
description: ID of the dynamic credential resolver associated with this credential, if any.
example: null
createdAt:
type: string
format: date-time

View file

@ -0,0 +1,38 @@
required:
- name
- type
- data
type: object
properties:
id:
type: string
readOnly: true
example: R2DjclaysHbqn778
name:
type: string
example: Joe's Github Credentials
type:
type: string
example: githubApi
data:
type: object
writeOnly: true
example: { accessToken: 'ada612vad6fa5df4adf5a5dsf4389adsf76da7s' }
isResolvable:
type: boolean
example: false
description: Whether this credential has resolvable fields
createdAt:
type: string
format: date-time
readOnly: true
example: '2022-04-29T11:02:29.842Z'
updatedAt:
type: string
format: date-time
readOnly: true
example: '2022-04-29T11:02:29.842Z'
projectId:
type: string
description: Project to create the credential in. Defaults to the user's personal project.
example: VmwOO9HeTEj20kxM

View file

@ -564,7 +564,10 @@ export class ProjectService {
};
if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) {
const projectRoles = await this.roleService.rolesWithScope('project', scopes);
// Use the same EntityManager as the project lookup (including when callers pass a
// transaction manager). Otherwise role resolution can open a second pooled connection
// while a transaction already holds a connection
const projectRoles = await this.roleService.rolesWithScope('project', scopes, em);
where = {
...where,

View file

@ -340,7 +340,7 @@ describe('Built-in Role Matrix Testing', () => {
await member3Agent
.post('/credentials')
.send({ ...randomCredentialPayload(), projectId: teamProjectA.id })
.expect(400);
.expect(403);
// Test credential update access (should be forbidden)
await member3Agent

View file

@ -244,7 +244,7 @@ describe('Custom Role Functionality Tests', () => {
await member2Agent
.post('/credentials')
.send({ ...newCredentialPayload, projectId: teamProjectA.id })
.expect(400);
.expect(403);
// Should not be able to update
await member2Agent
@ -361,7 +361,7 @@ describe('Custom Role Functionality Tests', () => {
await member1Agent
.post('/credentials')
.send({ ...newCredentialPayload, projectId: teamProjectB.id })
.expect(400);
.expect(403);
});
test('should validate custom roles with single-scope restrictions work properly', async () => {
@ -770,7 +770,7 @@ describe('Custom Role Functionality Tests', () => {
await member1Agent
.post('/credentials')
.send({ ...newCredentialPayload, projectId: teamProjectA.id })
.expect(400);
.expect(403);
// Test forbidden endpoints: PATCH /credentials/:id (update)
await member1Agent
@ -839,7 +839,7 @@ describe('Custom Role Functionality Tests', () => {
await member3Agent
.post('/credentials')
.send({ ...newCredentialPayload, projectId: teamProjectA.id })
.expect(400);
.expect(403);
// Test forbidden endpoints: PATCH /credentials/:id (update)
await member3Agent

View file

@ -341,12 +341,12 @@ describe('Resource Access Control Matrix Tests', () => {
expect(response.body.data).toBeDefined();
});
test('POST /credentials should return 400', async () => {
test('POST /credentials should return 403', async () => {
const credentialPayload = randomCredentialPayload();
await testUserAgent
.post('/credentials')
.send({ ...credentialPayload, projectId: teamProject.id })
.expect(400);
.expect(403);
});
test('PATCH /credentials/:id should return 403', async () => {

View file

@ -106,7 +106,7 @@ describe('POST /credentials', () => {
.post('/credentials')
.send({ ...randomCredentialPayload(), projectId: teamProject.id });
expect(response.statusCode).toBe(400);
expect(response.statusCode).toBe(403);
expect(response.body.message).toBe(
"You don't have the permissions to save the credential in this project.",
);
@ -123,7 +123,7 @@ describe('POST /credentials', () => {
.post('/credentials')
.send({ ...randomCredentialPayload(), projectId: chatUserPersonalProject.id });
expect(response.statusCode).toBe(400);
expect(response.statusCode).toBe(403);
expect(response.body.message).toBe(
"You don't have the permissions to save the credential in this project.",
);

View file

@ -970,8 +970,8 @@ describe('POST /credentials', () => {
//
// ASSERT
//
.expect(400, {
code: 400,
.expect(403, {
code: 403,
message: "You don't have the permissions to save the credential in this project.",
});
});
@ -986,7 +986,7 @@ describe('POST /credentials', () => {
.post('/credentials')
.send({ ...randomCredentialPayload(), projectId: teamProject.id });
expect(response.statusCode).toBe(400);
expect(response.statusCode).toBe(403);
expect(response.body.message).toBe(
"You don't have the permissions to save the credential in this project.",
);
@ -1013,7 +1013,7 @@ describe('POST /credentials', () => {
.post('/credentials')
.send({ ...randomCredentialPayload() });
expect(response.statusCode).toBe(400);
expect(response.statusCode).toBe(403);
expect(response.body.message).toBe(
"You don't have the permissions to save the credential in this project.",
);
@ -1063,7 +1063,7 @@ describe('POST /credentials', () => {
.post('/credentials')
.send({ ...randomCredentialPayload(), isGlobal: false });
expect(response.statusCode).toBe(400);
expect(response.statusCode).toBe(403);
expect(response.body.message).toBe(
"You don't have the permissions to save the credential in this project.",
);
@ -1089,7 +1089,7 @@ describe('POST /credentials', () => {
delete payload.isGlobal;
const response = await testServer.authAgentFor(chatUser).post('/credentials').send(payload);
expect(response.statusCode).toBe(400);
expect(response.statusCode).toBe(403);
expect(response.body.message).toBe(
"You don't have the permissions to save the credential in this project.",
);

View file

@ -108,6 +108,79 @@ describe('POST /credentials', () => {
}
});
test('should create credential in a team project when projectId is provided', async () => {
const teamProject = await createTeamProject('project', member);
const payload = {
name: 'test credential in project',
type: 'githubApi',
data: {
accessToken: 'abcdefghijklmnopqrstuvwxyz',
user: 'test',
server: 'testServer',
},
};
const response = await authMemberAgent.post('/credentials').send({
...payload,
projectId: teamProject.id,
});
expect(response.statusCode).toBe(200);
const { id, name } = response.body;
expect(name).toBe(payload.name);
const sharedCredential = await Container.get(SharedCredentialsRepository).findOneOrFail({
relations: { credentials: true },
where: {
credentialsId: id,
projectId: teamProject.id,
},
});
expect(sharedCredential.role).toEqual('credential:owner');
expect(sharedCredential.credentials.name).toBe(payload.name);
});
test('should return 404 when projectId does not exist', async () => {
const payload = {
name: 'test credential',
type: 'githubApi',
data: {
accessToken: 'abcdefghijklmnopqrstuvwxyz',
user: 'test',
server: 'testServer',
},
};
const response = await authMemberAgent.post('/credentials').send({
...payload,
projectId: 'non-existing-id',
});
expect(response.statusCode).toBe(404);
});
test('should return 403 when user has no access to the project', async () => {
const teamProject = await createTeamProject('project', owner);
const payload = {
name: 'test credential',
type: 'githubApi',
data: {
accessToken: 'abcdefghijklmnopqrstuvwxyz',
user: 'test',
server: 'testServer',
},
};
const response = await authMemberAgent.post('/credentials').send({
...payload,
projectId: teamProject.id,
});
expect(response.statusCode).toBe(403);
});
test('should create credential with isResolvable set to true', async () => {
const payload = {
name: 'test credential',
@ -176,6 +249,25 @@ describe('POST /credentials', () => {
const credential = await Container.get(CredentialsRepository).findOneByOrFail({ id });
expect(credential.isResolvable).toBe(false);
});
test('should return 400 for external secret reference without projectId when permissions are missing', async () => {
const payload = {
name: 'test credential',
type: 'githubApi',
data: {
accessToken: '={{ $secrets.myApiKey }}',
user: 'test',
server: 'testServer',
},
};
const response = await authMemberAgent.post('/credentials').send(payload);
expect(response.statusCode).toBe(400);
expect(response.body.message).toBe(
'Lacking permissions to reference external secrets in credentials',
);
});
});
describe('GET /credentials', () => {