diff --git a/packages/@n8n/api-types/package.json b/packages/@n8n/api-types/package.json index c14e189922..299986a5c9 100644 --- a/packages/@n8n/api-types/package.json +++ b/packages/@n8n/api-types/package.json @@ -27,6 +27,6 @@ "dependencies": { "xss": "catalog:", "zod": "catalog:", - "zod-class": "0.0.15" + "zod-class": "0.0.16" } } diff --git a/packages/@n8n/api-types/src/dto/credentials/__tests__/credentials-get-many-request.dto.test.ts b/packages/@n8n/api-types/src/dto/credentials/__tests__/credentials-get-many-request.dto.test.ts new file mode 100644 index 0000000000..0fa074b97d --- /dev/null +++ b/packages/@n8n/api-types/src/dto/credentials/__tests__/credentials-get-many-request.dto.test.ts @@ -0,0 +1,55 @@ +import { CredentialsGetManyRequestQuery } from '../credentials-get-many-request.dto'; + +describe('CredentialsGetManyRequestQuery', () => { + describe('should pass validation', () => { + it('with empty object', () => { + const data = {}; + + const result = CredentialsGetManyRequestQuery.safeParse(data); + + expect(result.success).toBe(true); + }); + + test.each([ + { field: 'includeScopes', value: 'true' }, + { field: 'includeScopes', value: 'false' }, + { field: 'includeData', value: 'true' }, + { field: 'includeData', value: 'false' }, + ])('with $field set to $value', ({ field, value }) => { + const data = { [field]: value }; + + const result = CredentialsGetManyRequestQuery.safeParse(data); + + expect(result.success).toBe(true); + }); + + it('with both parameters set', () => { + const data = { + includeScopes: 'true', + includeData: 'true', + }; + + const result = CredentialsGetManyRequestQuery.safeParse(data); + + expect(result.success).toBe(true); + }); + }); + + describe('should fail validation', () => { + test.each([ + { field: 'includeScopes', value: true }, + { field: 'includeScopes', value: false }, + { field: 'includeScopes', value: 'invalid' }, + { field: 'includeData', value: true }, + { field: 'includeData', value: false }, + { field: 'includeData', value: 'invalid' }, + ])('with invalid value $value for $field', ({ field, value }) => { + const data = { [field]: value }; + + const result = CredentialsGetManyRequestQuery.safeParse(data); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe(field); + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/credentials/__tests__/credentials-get-one-request.dto.test.ts b/packages/@n8n/api-types/src/dto/credentials/__tests__/credentials-get-one-request.dto.test.ts new file mode 100644 index 0000000000..274b00b759 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/credentials/__tests__/credentials-get-one-request.dto.test.ts @@ -0,0 +1,52 @@ +import { CredentialsGetOneRequestQuery } from '../credentials-get-one-request.dto'; + +describe('CredentialsGetManyRequestQuery', () => { + describe('should pass validation', () => { + it('with empty object', () => { + const data = {}; + + const result = CredentialsGetOneRequestQuery.safeParse(data); + + expect(result.success).toBe(true); + // defaults to false + expect(result.data?.includeData).toBe(false); + }); + + test.each([ + { field: 'includeData', value: 'true' }, + { field: 'includeData', value: 'false' }, + ])('with $field set to $value', ({ field, value }) => { + const data = { [field]: value }; + + const result = CredentialsGetOneRequestQuery.safeParse(data); + + expect(result.success).toBe(true); + }); + + it('with both parameters set', () => { + const data = { + includeScopes: 'true', + includeData: 'true', + }; + + const result = CredentialsGetOneRequestQuery.safeParse(data); + + expect(result.success).toBe(true); + }); + }); + + describe('should fail validation', () => { + test.each([ + { field: 'includeData', value: true }, + { field: 'includeData', value: false }, + { field: 'includeData', value: 'invalid' }, + ])('with invalid value $value for $field', ({ field, value }) => { + const data = { [field]: value }; + + const result = CredentialsGetOneRequestQuery.safeParse(data); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe(field); + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/credentials/credentials-get-many-request.dto.ts b/packages/@n8n/api-types/src/dto/credentials/credentials-get-many-request.dto.ts new file mode 100644 index 0000000000..47332ca7f9 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/credentials/credentials-get-many-request.dto.ts @@ -0,0 +1,22 @@ +import { Z } from 'zod-class'; + +import { booleanFromString } from '../../schemas/booleanFromString'; + +export class CredentialsGetManyRequestQuery extends Z.class({ + /** + * Adds the `scopes` field to each credential which includes all scopes the + * requesting user has in relation to the credential, e.g. + * ['credential:read', 'credential:update'] + */ + includeScopes: booleanFromString.optional(), + + /** + * Adds the decrypted `data` field to each credential. + * + * It only does this for credentials for which the user has the + * `credential:update` scope. + * + * This switches `includeScopes` to true to be able to check for the scopes + */ + includeData: booleanFromString.optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/credentials/credentials-get-one-request.dto.ts b/packages/@n8n/api-types/src/dto/credentials/credentials-get-one-request.dto.ts new file mode 100644 index 0000000000..ad790014e8 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/credentials/credentials-get-one-request.dto.ts @@ -0,0 +1,13 @@ +import { Z } from 'zod-class'; + +import { booleanFromString } from '../../schemas/booleanFromString'; + +export class CredentialsGetOneRequestQuery extends Z.class({ + /** + * Adds the decrypted `data` field to each credential. + * + * It only does this for credentials for which the user has the + * `credential:update` scope. + */ + includeData: booleanFromString.optional().default('false'), +}) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index e2642746c7..ea725e2ec5 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -29,3 +29,5 @@ export { UserUpdateRequestDto } from './user/user-update-request.dto'; export { CommunityRegisteredRequestDto } from './license/community-registered-request.dto'; export { VariableListRequestDto } from './variables/variables-list-request.dto'; +export { CredentialsGetOneRequestQuery } from './credentials/credentials-get-one-request.dto'; +export { CredentialsGetManyRequestQuery } from './credentials/credentials-get-many-request.dto'; diff --git a/packages/@n8n/api-types/src/schemas/booleanFromString.ts b/packages/@n8n/api-types/src/schemas/booleanFromString.ts new file mode 100644 index 0000000000..bcc9e8133c --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/booleanFromString.ts @@ -0,0 +1,3 @@ +import { z } from 'zod'; + +export const booleanFromString = z.enum(['true', 'false']).transform((value) => value === 'true'); diff --git a/packages/cli/src/credentials/__tests__/credentials.service.test.ts b/packages/cli/src/credentials/__tests__/credentials.service.test.ts index 8df0605983..526acfc17d 100644 --- a/packages/cli/src/credentials/__tests__/credentials.service.test.ts +++ b/packages/cli/src/credentials/__tests__/credentials.service.test.ts @@ -1,5 +1,6 @@ import { mock } from 'jest-mock-extended'; import { nanoId, date } from 'minifaker'; +import { Credentials } from 'n8n-core'; import { CREDENTIAL_EMPTY_VALUE, type ICredentialType } from 'n8n-workflow'; import { CREDENTIAL_BLANKING_VALUE } from '@/constants'; @@ -30,6 +31,7 @@ describe('CredentialsService', () => { }, ], }); + const credentialTypes = mock(); const service = new CredentialsService( mock(), @@ -61,7 +63,7 @@ describe('CredentialsService', () => { csrfSecret: 'super-secret', }; - credentialTypes.getByName.calledWith(credential.type).mockReturnValue(credType); + credentialTypes.getByName.calledWith(credential.type).mockReturnValueOnce(credType); const redactedData = service.redact(decryptedData, credential); @@ -137,4 +139,60 @@ describe('CredentialsService', () => { }); }); }); + + describe('decrypt', () => { + it('should redact sensitive values by default', () => { + // ARRANGE + const data = { + clientId: 'abc123', + clientSecret: 'sensitiveSecret', + accessToken: '', + oauthTokenData: 'super-secret', + csrfSecret: 'super-secret', + }; + const credential = mock({ + id: '123', + name: 'Test Credential', + type: 'oauth2', + }); + jest.spyOn(Credentials.prototype, 'getData').mockReturnValueOnce(data); + credentialTypes.getByName.calledWith(credential.type).mockReturnValueOnce(credType); + + // ACT + const redactedData = service.decrypt(credential); + + // ASSERT + expect(redactedData).toEqual({ + clientId: 'abc123', + clientSecret: CREDENTIAL_BLANKING_VALUE, + accessToken: CREDENTIAL_EMPTY_VALUE, + oauthTokenData: CREDENTIAL_BLANKING_VALUE, + csrfSecret: CREDENTIAL_BLANKING_VALUE, + }); + }); + + it('should return sensitive values if `includeRawData` is true', () => { + // ARRANGE + const data = { + clientId: 'abc123', + clientSecret: 'sensitiveSecret', + accessToken: '', + oauthTokenData: 'super-secret', + csrfSecret: 'super-secret', + }; + const credential = mock({ + id: '123', + name: 'Test Credential', + type: 'oauth2', + }); + jest.spyOn(Credentials.prototype, 'getData').mockReturnValueOnce(data); + credentialTypes.getByName.calledWith(credential.type).mockReturnValueOnce(credType); + + // ACT + const redactedData = service.decrypt(credential, true); + + // ASSERT + expect(redactedData).toEqual(data); + }); + }); }); diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index 934e91f64d..4cc0b500f2 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -1,3 +1,4 @@ +import { CredentialsGetManyRequestQuery, CredentialsGetOneRequestQuery } from '@n8n/api-types'; import { GlobalConfig } from '@n8n/config'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In } from '@n8n/typeorm'; @@ -19,6 +20,7 @@ import { RestController, ProjectScope, } from '@/decorators'; +import { Param, Query } from '@/decorators/args'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; @@ -49,10 +51,15 @@ export class CredentialsController { ) {} @Get('/', { middlewares: listQueryMiddleware }) - async getMany(req: CredentialRequest.GetMany) { + async getMany( + req: CredentialRequest.GetMany, + _res: unknown, + @Query query: CredentialsGetManyRequestQuery, + ) { const credentials = await this.credentialsService.getMany(req.user, { listQueryOptions: req.listQueryOptions, - includeScopes: req.query.includeScopes, + includeScopes: query.includeScopes, + includeData: query.includeData, }); credentials.forEach((c) => { // @ts-expect-error: This is to emulate the old behavior of removing the shared @@ -82,21 +89,22 @@ export class CredentialsController { @Get('/:credentialId') @ProjectScope('credential:read') - async getOne(req: CredentialRequest.Get) { + async getOne( + req: CredentialRequest.Get, + _res: unknown, + @Param('credentialId') credentialId: string, + @Query query: CredentialsGetOneRequestQuery, + ) { const { shared, ...credential } = this.license.isSharingEnabled() ? await this.enterpriseCredentialsService.getOne( req.user, - req.params.credentialId, + credentialId, // TODO: editor-ui is always sending this, maybe we can just rely on the // the scopes and always decrypt the data if the user has the permissions // to do so. - req.query.includeData === 'true', + query.includeData, ) - : await this.credentialsService.getOne( - req.user, - req.params.credentialId, - req.query.includeData === 'true', - ); + : await this.credentialsService.getOne(req.user, credentialId, query.includeData); const scopes = await this.credentialsService.getCredentialScopes( req.user, diff --git a/packages/cli/src/credentials/credentials.service.ee.ts b/packages/cli/src/credentials/credentials.service.ee.ts index 274feff81b..949748600d 100644 --- a/packages/cli/src/credentials/credentials.service.ee.ts +++ b/packages/cli/src/credentials/credentials.service.ee.ts @@ -87,10 +87,7 @@ export class EnterpriseCredentialsService { if (credential) { // Decrypt the data if we found the credential with the `credential:update` // scope. - decryptedData = this.credentialsService.redact( - this.credentialsService.decrypt(credential), - credential, - ); + decryptedData = this.credentialsService.decrypt(credential); } else { // Otherwise try to find them with only the `credential:read` scope. In // that case we return them without the decrypted data. diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index 84398b5475..e7e0447417 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -38,6 +38,7 @@ import type { CredentialRequest, ListQuery } from '@/requests'; import { CredentialsTester } from '@/services/credentials-tester.service'; import { OwnershipService } from '@/services/ownership.service'; import { ProjectService } from '@/services/project.service.ee'; +import type { ScopesField } from '@/services/role.service'; import { RoleService } from '@/services/role.service'; export type CredentialsGetSharedOptions = @@ -62,33 +63,47 @@ export class CredentialsService { async getMany( user: User, - options: { - listQueryOptions?: ListQuery.Options; - includeScopes?: string; + { + listQueryOptions = {}, + includeScopes = false, + includeData = false, + }: { + listQueryOptions?: ListQuery.Options & { includeData?: boolean }; + includeScopes?: boolean; + includeData?: boolean; } = {}, ) { const returnAll = user.hasGlobalScope('credential:list'); - const isDefaultSelect = !options.listQueryOptions?.select; + const isDefaultSelect = !listQueryOptions.select; + + if (includeData) { + // We need the scopes to check if we're allowed to include the decrypted + // data. + // Only if the user has the `credential:update` scope the user is allowed + // to get the data. + includeScopes = true; + listQueryOptions.includeData = true; + } let projectRelations: ProjectRelation[] | undefined = undefined; - if (options.includeScopes) { + if (includeScopes) { projectRelations = await this.projectService.getProjectRelationsForUser(user); - if (options.listQueryOptions?.filter?.projectId && user.hasGlobalScope('credential:list')) { + if (listQueryOptions.filter?.projectId && user.hasGlobalScope('credential:list')) { // Only instance owners and admins have the credential:list scope // Those users should be able to use _all_ credentials within their workflows. // TODO: Change this so we filter by `workflowId` in this case. Require a slight FE change const projectRelation = projectRelations.find( - (relation) => relation.projectId === options.listQueryOptions?.filter?.projectId, + (relation) => relation.projectId === listQueryOptions.filter?.projectId, ); if (projectRelation?.role === 'project:personalOwner') { // Will not affect team projects as these have admins, not owners. - delete options.listQueryOptions?.filter?.projectId; + delete listQueryOptions.filter?.projectId; } } } if (returnAll) { - let credentials = await this.credentialsRepository.findMany(options.listQueryOptions); + let credentials = await this.credentialsRepository.findMany(listQueryOptions); if (isDefaultSelect) { // Since we're filtering using project ID as part of the relation, @@ -96,7 +111,7 @@ export class CredentialsService { // it's shared to a project, it won't be able to find the home project. // To solve this, we have to get all the relation now, even though // we're deleting them later. - if ((options.listQueryOptions?.filter?.shared as { projectId?: string })?.projectId) { + if ((listQueryOptions.filter?.shared as { projectId?: string })?.projectId) { const relations = await this.sharedCredentialsRepository.getAllRelationsForCredentials( credentials.map((c) => c.id), ); @@ -107,23 +122,32 @@ export class CredentialsService { credentials = credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c)); } - if (options.includeScopes) { + if (includeScopes) { credentials = credentials.map((c) => this.roleService.addScopes(c, user, projectRelations!), ); } + if (includeData) { + credentials = credentials.map((c: CredentialsEntity & ScopesField) => { + return { + ...c, + data: c.scopes.includes('credential:update') ? this.decrypt(c) : undefined, + } as unknown as CredentialsEntity; + }); + } + return credentials; } - // If the workflow is part of a personal project we want to show the credentials the user making the request has access to, not the credentials the user owning the workflow has access to. - if (typeof options.listQueryOptions?.filter?.projectId === 'string') { - const project = await this.projectService.getProject( - options.listQueryOptions.filter.projectId, - ); + // If the workflow is part of a personal project we want to show the + // credentials the user making the request has access to, not the + // credentials the user owning the workflow has access to. + if (typeof listQueryOptions.filter?.projectId === 'string') { + const project = await this.projectService.getProject(listQueryOptions.filter.projectId); if (project?.type === 'personal') { const currentUsersPersonalProject = await this.projectService.getPersonalProject(user); - options.listQueryOptions.filter.projectId = currentUsersPersonalProject?.id; + listQueryOptions.filter.projectId = currentUsersPersonalProject?.id; } } @@ -132,7 +156,7 @@ export class CredentialsService { }); let credentials = await this.credentialsRepository.findMany( - options.listQueryOptions, + listQueryOptions, ids, // only accessible credentials ); @@ -142,7 +166,7 @@ export class CredentialsService { // it's shared to a project, it won't be able to find the home project. // To solve this, we have to get all the relation now, even though // we're deleting them later. - if ((options.listQueryOptions?.filter?.shared as { projectId?: string })?.projectId) { + if ((listQueryOptions.filter?.shared as { projectId?: string })?.projectId) { const relations = await this.sharedCredentialsRepository.getAllRelationsForCredentials( credentials.map((c) => c.id), ); @@ -154,10 +178,19 @@ export class CredentialsService { credentials = credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c)); } - if (options.includeScopes) { + if (includeScopes) { credentials = credentials.map((c) => this.roleService.addScopes(c, user, projectRelations!)); } + if (includeData) { + credentials = credentials.map((c: CredentialsEntity & ScopesField) => { + return { + ...c, + data: c.scopes.includes('credential:update') ? this.decrypt(c) : undefined, + } as unknown as CredentialsEntity; + }); + } + return credentials; } @@ -308,9 +341,18 @@ export class CredentialsService { return newCredentialData; } - decrypt(credential: CredentialsEntity) { + /** + * Decrypts the credentials data and redacts the content by default. + * + * If `includeRawData` is set to true it will not redact the data. + */ + decrypt(credential: CredentialsEntity, includeRawData = false) { const coreCredential = createCredentialsFromCredentialsEntity(credential); - return coreCredential.getData(); + const data = coreCredential.getData(); + if (includeRawData) { + return data; + } + return this.redact(data, credential); } async update(credentialId: string, newCredentialData: ICredentialsDb) { @@ -500,7 +542,7 @@ export class CredentialsService { if (sharing) { // Decrypt the data if we found the credential with the `credential:update` // scope. - decryptedData = this.redact(this.decrypt(sharing.credentials), sharing.credentials); + decryptedData = this.decrypt(sharing.credentials); } else { // Otherwise try to find them with only the `credential:read` scope. In // that case we return them without the decrypted data. diff --git a/packages/cli/src/databases/repositories/__tests__/credentials.repository.test.ts b/packages/cli/src/databases/repositories/__tests__/credentials.repository.test.ts new file mode 100644 index 0000000000..439b806a9a --- /dev/null +++ b/packages/cli/src/databases/repositories/__tests__/credentials.repository.test.ts @@ -0,0 +1,50 @@ +import { mock } from 'jest-mock-extended'; +import { Container } from 'typedi'; + +import { CredentialsEntity } from '@/databases/entities/credentials-entity'; +import { mockEntityManager } from '@test/mocking'; + +import { CredentialsRepository } from '../credentials.repository'; + +const entityManager = mockEntityManager(CredentialsEntity); +const repository = Container.get(CredentialsRepository); + +describe('findMany', () => { + const credentialsId = 'cred_123'; + const credential = mock({ id: credentialsId }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('return `data` property if `includeData:true` and select is using the record syntax', async () => { + // ARRANGE + entityManager.find.mockResolvedValueOnce([credential]); + + // ACT + const credentials = await repository.findMany({ includeData: true, select: { id: true } }); + + // ASSERT + expect(credentials).toHaveLength(1); + expect(credentials[0]).toHaveProperty('data'); + }); + + test('return `data` property if `includeData:true` and select is using the record syntax', async () => { + // ARRANGE + entityManager.find.mockResolvedValueOnce([credential]); + + // ACT + const credentials = await repository.findMany({ + includeData: true, + //TODO: fix this + // The function's type does not support this but this is what it + // actually gets from the service because the middlewares are typed + // loosely. + select: ['id'] as never, + }); + + // ASSERT + expect(credentials).toHaveLength(1); + expect(credentials[0]).toHaveProperty('data'); + }); +}); diff --git a/packages/cli/src/databases/repositories/credentials.repository.ts b/packages/cli/src/databases/repositories/credentials.repository.ts index e5aecf1069..5b88a4ed87 100644 --- a/packages/cli/src/databases/repositories/credentials.repository.ts +++ b/packages/cli/src/databases/repositories/credentials.repository.ts @@ -25,7 +25,10 @@ export class CredentialsRepository extends Repository { }); } - async findMany(listQueryOptions?: ListQuery.Options, credentialIds?: string[]) { + async findMany( + listQueryOptions?: ListQuery.Options & { includeData?: boolean }, + credentialIds?: string[], + ) { const findManyOptions = this.toFindManyOptions(listQueryOptions); if (credentialIds) { @@ -35,7 +38,7 @@ export class CredentialsRepository extends Repository { return await this.find(findManyOptions); } - private toFindManyOptions(listQueryOptions?: ListQuery.Options) { + private toFindManyOptions(listQueryOptions?: ListQuery.Options & { includeData?: boolean }) { const findManyOptions: FindManyOptions = {}; type Select = Array; @@ -74,6 +77,14 @@ export class CredentialsRepository extends Repository { findManyOptions.relations = defaultRelations; } + if (listQueryOptions.includeData) { + if (Array.isArray(findManyOptions.select)) { + findManyOptions.select.push('data'); + } else { + findManyOptions.select.data = true; + } + } + return findManyOptions; } diff --git a/packages/cli/test/integration/credentials/credentials.api.test.ts b/packages/cli/test/integration/credentials/credentials.api.test.ts index eb9712fefd..2b3ee73b58 100644 --- a/packages/cli/test/integration/credentials/credentials.api.test.ts +++ b/packages/cli/test/integration/credentials/credentials.api.test.ts @@ -226,6 +226,161 @@ describe('GET /credentials', () => { } }); + test('should return data when ?includeData=true', async () => { + // ARRANGE + const [actor, otherMember] = await createManyUsers(2, { + role: 'global:member', + }); + + const teamProjectViewer = await createTeamProject(undefined); + await linkUserToProject(actor, teamProjectViewer, 'project:viewer'); + const teamProjectEditor = await createTeamProject(undefined); + await linkUserToProject(actor, teamProjectEditor, 'project:editor'); + + const [ + // should have data + ownedCredential, + // should not have + sharedCredential, + // should not have data + teamCredentialAsViewer, + // should have data + teamCredentialAsEditor, + ] = await Promise.all([ + saveCredential(randomCredentialPayload(), { user: actor, role: 'credential:owner' }), + saveCredential(randomCredentialPayload(), { user: otherMember, role: 'credential:owner' }), + saveCredential(randomCredentialPayload(), { + project: teamProjectViewer, + role: 'credential:owner', + }), + saveCredential(randomCredentialPayload(), { + project: teamProjectEditor, + role: 'credential:owner', + }), + ]); + await shareCredentialWithUsers(sharedCredential, [actor]); + + // ACT + const response = await testServer + .authAgentFor(actor) + .get('/credentials') + .query({ includeData: true }); + + // ASSERT + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(4); + + const creds = response.body.data as Array; + const ownedCred = creds.find((c) => c.id === ownedCredential.id)!; + const sharedCred = creds.find((c) => c.id === sharedCredential.id)!; + const teamCredAsViewer = creds.find((c) => c.id === teamCredentialAsViewer.id)!; + const teamCredAsEditor = creds.find((c) => c.id === teamCredentialAsEditor.id)!; + + expect(ownedCred.id).toBe(ownedCredential.id); + expect(ownedCred.data).toBeDefined(); + expect(ownedCred.scopes).toEqual( + [ + 'credential:move', + 'credential:read', + 'credential:update', + 'credential:share', + 'credential:delete', + ].sort(), + ); + + expect(sharedCred.id).toBe(sharedCredential.id); + expect(sharedCred.data).not.toBeDefined(); + expect(sharedCred.scopes).toEqual(['credential:read'].sort()); + + expect(teamCredAsViewer.id).toBe(teamCredentialAsViewer.id); + expect(teamCredAsViewer.data).not.toBeDefined(); + expect(teamCredAsViewer.scopes).toEqual(['credential:read'].sort()); + + expect(teamCredAsEditor.id).toBe(teamCredentialAsEditor.id); + expect(teamCredAsEditor.data).toBeDefined(); + expect(teamCredAsEditor.scopes).toEqual( + ['credential:read', 'credential:update', 'credential:delete'].sort(), + ); + }); + + test('should return data when ?includeData=true for owners', async () => { + // ARRANGE + const teamProjectViewer = await createTeamProject(undefined); + + const [ + // should have data + ownedCredential, + // should have data + sharedCredential, + // should have data + teamCredentialAsViewer, + ] = await Promise.all([ + saveCredential(randomCredentialPayload(), { user: owner, role: 'credential:owner' }), + saveCredential(randomCredentialPayload(), { user: member, role: 'credential:owner' }), + saveCredential(randomCredentialPayload(), { + project: teamProjectViewer, + role: 'credential:owner', + }), + ]); + + // ACT + const response = await testServer + .authAgentFor(owner) + .get('/credentials') + .query({ includeData: true }); + + // ASSERT + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(3); + + const creds = response.body.data as Array; + const ownedCred = creds.find((c) => c.id === ownedCredential.id)!; + const sharedCred = creds.find((c) => c.id === sharedCredential.id)!; + const teamCredAsViewer = creds.find((c) => c.id === teamCredentialAsViewer.id)!; + + expect(ownedCred.id).toBe(ownedCredential.id); + expect(ownedCred.data).toBeDefined(); + expect(ownedCred.scopes).toEqual( + [ + 'credential:move', + 'credential:read', + 'credential:update', + 'credential:share', + 'credential:delete', + 'credential:create', + 'credential:list', + ].sort(), + ); + + expect(sharedCred.id).toBe(sharedCredential.id); + expect(sharedCred.data).toBeDefined(); + expect(sharedCred.scopes).toEqual( + [ + 'credential:move', + 'credential:read', + 'credential:update', + 'credential:share', + 'credential:delete', + 'credential:create', + 'credential:list', + ].sort(), + ); + + expect(teamCredAsViewer.id).toBe(teamCredentialAsViewer.id); + expect(teamCredAsViewer.data).toBeDefined(); + expect(teamCredAsViewer.scopes).toEqual( + [ + 'credential:move', + 'credential:read', + 'credential:update', + 'credential:share', + 'credential:delete', + 'credential:create', + 'credential:list', + ].sort(), + ); + }); + describe('should return', () => { test('all credentials for owner', async () => { const { id: id1 } = await saveCredential(payload(), { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcc3dfdf43..14b8680478 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -272,8 +272,8 @@ importers: specifier: 'catalog:' version: 3.23.8 zod-class: - specifier: 0.0.15 - version: 0.0.15(zod@3.23.8) + specifier: 0.0.16 + version: 0.0.16(zod@3.23.8) devDependencies: '@n8n/config': specifier: workspace:* @@ -434,7 +434,7 @@ importers: version: 3.666.0(@aws-sdk/client-sts@3.666.0) '@getzep/zep-cloud': specifier: 1.0.12 - version: 1.0.12(@langchain/core@0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13)(langchain@0.3.6(e4rnrwhosnp2xiru36mqgdy2bu)) + version: 1.0.12(@langchain/core@0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13)(langchain@0.3.6(4axcxpjbcq5bce7ff6ajxrpp4i)) '@getzep/zep-js': specifier: 0.9.0 version: 0.9.0 @@ -461,7 +461,7 @@ importers: version: 0.3.1(@aws-sdk/client-sso-oidc@3.666.0(@aws-sdk/client-sts@3.666.0))(@langchain/core@0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13) '@langchain/community': specifier: 0.3.15 - version: 0.3.15(vc5hvyy27o4cmm4jplsptc2fqm) + version: 0.3.15(v4qhcw25bevfr6xzz4fnsvjiqe) '@langchain/core': specifier: 'catalog:' version: 0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)) @@ -548,7 +548,7 @@ importers: version: 23.0.1 langchain: specifier: 0.3.6 - version: 0.3.6(e4rnrwhosnp2xiru36mqgdy2bu) + version: 0.3.6(4axcxpjbcq5bce7ff6ajxrpp4i) lodash: specifier: 'catalog:' version: 4.17.21 @@ -13393,8 +13393,8 @@ packages: resolution: {integrity: sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg==} engines: {node: '>=10'} - zod-class@0.0.15: - resolution: {integrity: sha512-CD5B4e9unKPj1hiy7JOSwRV01WqbEBkFOlhws0C9s9wB0FSpECOnlKXOAkjo9tKYX2enQsXWyyOIBNPPNUHWRA==} + zod-class@0.0.16: + resolution: {integrity: sha512-3A1l81VEUOxvSTGoNPsU4fTUY9CKin/HSySnXT3bIc+TJTDGCPbzSPE8W1VvwXqyzHEIWK608eFZja2uew9Ivw==} peerDependencies: zod: ^3 @@ -15690,7 +15690,7 @@ snapshots: '@gar/promisify@1.1.3': optional: true - '@getzep/zep-cloud@1.0.12(@langchain/core@0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13)(langchain@0.3.6(e4rnrwhosnp2xiru36mqgdy2bu))': + '@getzep/zep-cloud@1.0.12(@langchain/core@0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13)(langchain@0.3.6(4axcxpjbcq5bce7ff6ajxrpp4i))': dependencies: form-data: 4.0.0 node-fetch: 2.7.0(encoding@0.1.13) @@ -15699,7 +15699,7 @@ snapshots: zod: 3.23.8 optionalDependencies: '@langchain/core': 0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)) - langchain: 0.3.6(e4rnrwhosnp2xiru36mqgdy2bu) + langchain: 0.3.6(4axcxpjbcq5bce7ff6ajxrpp4i) transitivePeerDependencies: - encoding @@ -16163,7 +16163,7 @@ snapshots: - aws-crt - encoding - '@langchain/community@0.3.15(vc5hvyy27o4cmm4jplsptc2fqm)': + '@langchain/community@0.3.15(v4qhcw25bevfr6xzz4fnsvjiqe)': dependencies: '@ibm-cloud/watsonx-ai': 1.1.2 '@langchain/core': 0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)) @@ -16173,7 +16173,7 @@ snapshots: flat: 5.0.2 ibm-cloud-sdk-core: 5.1.0 js-yaml: 4.1.0 - langchain: 0.3.6(e4rnrwhosnp2xiru36mqgdy2bu) + langchain: 0.3.6(4axcxpjbcq5bce7ff6ajxrpp4i) langsmith: 0.2.3(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)) uuid: 10.0.0 zod: 3.23.8 @@ -16186,7 +16186,7 @@ snapshots: '@aws-sdk/client-s3': 3.666.0 '@aws-sdk/credential-provider-node': 3.666.0(@aws-sdk/client-sso-oidc@3.666.0(@aws-sdk/client-sts@3.666.0))(@aws-sdk/client-sts@3.666.0) '@azure/storage-blob': 12.18.0(encoding@0.1.13) - '@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13)(langchain@0.3.6(e4rnrwhosnp2xiru36mqgdy2bu)) + '@getzep/zep-cloud': 1.0.12(@langchain/core@0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13)(langchain@0.3.6(4axcxpjbcq5bce7ff6ajxrpp4i)) '@getzep/zep-js': 0.9.0 '@google-ai/generativelanguage': 2.6.0(encoding@0.1.13) '@google-cloud/storage': 7.12.1(encoding@0.1.13) @@ -19470,14 +19470,6 @@ snapshots: transitivePeerDependencies: - debug - axios@1.7.4(debug@4.3.7): - dependencies: - follow-redirects: 1.15.6(debug@4.3.7) - form-data: 4.0.0 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - axios@1.7.7: dependencies: follow-redirects: 1.15.6(debug@4.3.6) @@ -22329,7 +22321,7 @@ snapshots: '@types/debug': 4.1.12 '@types/node': 18.16.16 '@types/tough-cookie': 4.0.2 - axios: 1.7.4(debug@4.3.7) + axios: 1.7.4 camelcase: 6.3.0 debug: 4.3.7 dotenv: 16.4.5 @@ -22339,7 +22331,7 @@ snapshots: isstream: 0.1.2 jsonwebtoken: 9.0.2 mime-types: 2.1.35 - retry-axios: 2.6.0(axios@1.7.4) + retry-axios: 2.6.0(axios@1.7.4(debug@4.3.7)) tough-cookie: 4.1.3 transitivePeerDependencies: - supports-color @@ -23340,7 +23332,7 @@ snapshots: kuler@2.0.0: {} - langchain@0.3.6(e4rnrwhosnp2xiru36mqgdy2bu): + langchain@0.3.6(4axcxpjbcq5bce7ff6ajxrpp4i): dependencies: '@langchain/core': 0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)) '@langchain/openai': 0.3.14(@langchain/core@0.3.19(openai@4.73.1(encoding@0.1.13)(zod@3.23.8)))(encoding@0.1.13) @@ -25710,7 +25702,7 @@ snapshots: ret@0.1.15: {} - retry-axios@2.6.0(axios@1.7.4): + retry-axios@2.6.0(axios@1.7.4(debug@4.3.7)): dependencies: axios: 1.7.4 @@ -27783,7 +27775,7 @@ snapshots: property-expr: 2.0.5 toposort: 2.0.2 - zod-class@0.0.15(zod@3.23.8): + zod-class@0.0.16(zod@3.23.8): dependencies: type-fest: 4.26.1 zod: 3.23.8