mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
feat(core): Enable credential creation per project in public API (#28240)
This commit is contained in:
parent
a9950c182a
commit
8cd75d2f2d
16 changed files with 282 additions and 104 deletions
|
|
@ -246,6 +246,11 @@ export {
|
|||
type CommunityPackageResponse,
|
||||
} from './schemas/community-package.schema';
|
||||
|
||||
export {
|
||||
publicApiCreatedCredentialSchema,
|
||||
type PublicApiCreatedCredential,
|
||||
} from './schemas/credential-created.schema';
|
||||
|
||||
export {
|
||||
instanceAiEventTypeSchema,
|
||||
instanceAiRunStatusSchema,
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
{}
|
||||
>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue