diff --git a/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policy.service.test.ts b/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policy.service.test.ts index da61853dc85..58407d6ba3a 100644 --- a/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policy.service.test.ts +++ b/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policy.service.test.ts @@ -15,7 +15,7 @@ import type { TypeAvailabilityPolicyScopeRepository } from '../database/reposito import type { TypeAvailabilityPolicyRepository } from '../database/repositories/type-availability-policy.repository'; import { TypeAvailabilityPolicy } from '../database/entities/type-availability-policy.entity'; import { TypeAvailabilityPolicyScope } from '../database/entities/type-availability-policy-scope.entity'; -import type { PolicyRule } from '../policy-rule.types'; +import type { PolicyAttachment, PolicyRule } from '../policy-rule.types'; import { LOCAL_READ_MAX_ENTRIES, TypeAvailabilityPolicyService, @@ -166,6 +166,85 @@ describe('TypeAvailabilityPolicyService', () => { }); }); + describe('getAttachments', () => { + it.each([ + [KIND, null], + [KIND, 'project-1'], + [CREDENTIAL_TYPES_KIND, null], + [CREDENTIAL_TYPES_KIND, 'project-1'], + ])('reads %s attachments for project %s in evaluation order', async (kind, projectId) => { + const ctx: OperationContext = {}; + const scope = makeScope({ kind, projectId, version: 7 }); + const normalLate: PolicyAttachment = { + policyId: 'normal-late', + rules: [RULE], + priority: 10, + isFloor: false, + }; + const floorLate: PolicyAttachment = { + policyId: 'floor-late', + rules: [], + priority: 20, + isFloor: true, + }; + const normalEarly: PolicyAttachment = { + policyId: 'normal-early', + rules: [], + priority: -5, + isFloor: false, + }; + const floorEarly: PolicyAttachment = { + policyId: 'floor-early', + rules: [RULE], + priority: 0, + isFloor: true, + }; + const stored = [normalLate, floorLate, normalEarly, floorEarly]; + scopeRepository.findScopeByIdAndKind.mockResolvedValue(scope); + attachmentRepository.listAttachmentsForScope.mockResolvedValue(stored); + + const result = await service.getAttachments(kind, scope.id, ctx); + + expect(result).toEqual({ + attachments: [floorEarly, floorLate, normalEarly, normalLate], + version: 7, + }); + expect(stored).toEqual([normalLate, floorLate, normalEarly, floorEarly]); + expect(scopeRepository.findScopeByIdAndKind).toHaveBeenCalledWith(scope.id, kind, ctx); + expect(attachmentRepository.listAttachmentsForScope).toHaveBeenCalledWith(scope.id, ctx); + expect(scopeRepository.findScopeByIdAndKind.mock.invocationCallOrder[0]).toBeLessThan( + attachmentRepository.listAttachmentsForScope.mock.invocationCallOrder[0], + ); + expect(transactionRunner.run).not.toHaveBeenCalled(); + expect(eventService.emit).not.toHaveBeenCalled(); + expect(cacheService.get).not.toHaveBeenCalled(); + expect(cacheService.set).not.toHaveBeenCalled(); + expect(cacheService.deleteMany).not.toHaveBeenCalled(); + }); + + it.each([KIND, CREDENTIAL_TYPES_KIND])('rejects an unknown scope for %s', async (kind) => { + scopeRepository.findScopeByIdAndKind.mockResolvedValue(null); + + await expect(service.getAttachments(kind, 'missing')).rejects.toThrow(NotFoundError); + expect(attachmentRepository.listAttachmentsForScope).not.toHaveBeenCalled(); + }); + + it.each([KIND, CREDENTIAL_TYPES_KIND])( + 'rejects a scope of another kind for %s', + async (kind) => { + const otherKind = kind === KIND ? CREDENTIAL_TYPES_KIND : KIND; + const scope = makeScope({ kind: otherKind }); + scopeRepository.findScopeByIdAndKind.mockImplementation(async (id, requestedKind) => + id === scope.id && requestedKind === scope.kind ? scope : null, + ); + + await expect(service.getAttachments(kind, scope.id)).rejects.toThrow(NotFoundError); + expect(scopeRepository.findScopeByIdAndKind).toHaveBeenCalledWith(scope.id, kind, {}); + expect(attachmentRepository.listAttachmentsForScope).not.toHaveBeenCalled(); + }, + ); + }); + describe('setDefaultAction', () => { it('rejects a delegate defaultAction at project scope before opening a transaction', async () => { await expect( @@ -906,6 +985,42 @@ describe('TypeAvailabilityPolicyService', () => { expect(result.version).toBe(2); }); + it.each([ + [KIND, null], + [KIND, 'project-1'], + [CREDENTIAL_TYPES_KIND, null], + [CREDENTIAL_TYPES_KIND, 'project-1'], + ])('returns %s attachments for project %s in evaluation order', async (kind, projectId) => { + const scope = makeScope({ kind, projectId, version: 1 }); + const stored: PolicyAttachment[] = [ + { policyId: 'normal-late', rules: [RULE], priority: 10, isFloor: false }, + { policyId: 'floor-late', rules: [], priority: 20, isFloor: true }, + { policyId: 'normal-early', rules: [], priority: -5, isFloor: false }, + { policyId: 'floor-early', rules: [RULE], priority: 0, isFloor: true }, + ]; + scopeRepository.findScopeByIdAndKind.mockResolvedValueOnce(scope); + scopeRepository.findScopeById.mockResolvedValueOnce( + makeScope({ kind, projectId, version: 2 }), + ); + policyRepository.findManyByIds.mockResolvedValue([]); + attachmentRepository.listAttachmentsForScope + .mockResolvedValueOnce([]) + .mockResolvedValueOnce(stored); + + const result = await service.replaceAttachments( + kind, + scope.id, + stored.map(({ policyId, priority, isFloor }) => ({ policyId, priority, isFloor })), + scope.version, + 'user-1', + ); + + expect(result).toEqual({ + attachments: [stored[3], stored[1], stored[2], stored[0]], + version: 2, + }); + }); + it('rejects attaching a document with a delegate rule to a project scope, and writes nothing', async () => { scopeRepository.findScopeByIdAndKind.mockResolvedValue(makeScope({ projectId: 'project-1' })); policyRepository.findManyByIds.mockResolvedValue([ diff --git a/packages/cli/src/modules/type-availability-policies/credential-type-policy-instance.controller.ts b/packages/cli/src/modules/type-availability-policies/credential-type-policy-instance.controller.ts index cd5f27da477..1b0d891e49a 100644 --- a/packages/cli/src/modules/type-availability-policies/credential-type-policy-instance.controller.ts +++ b/packages/cli/src/modules/type-availability-policies/credential-type-policy-instance.controller.ts @@ -143,6 +143,17 @@ export class CredentialTypePolicyInstanceController { return { success: true }; } + @Get('/scopes/:scopeId/attachments') + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) + @GlobalScope('credentialTypePolicy:manage') + async getAttachments( + _req: AuthenticatedRequest, + _res: Response, + @Param('scopeId') scopeId: string, + ) { + return await this.service.getAttachments(CREDENTIAL_TYPES_KIND, scopeId); + } + @Put('/scopes/:scopeId/attachments') @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @GlobalScope('credentialTypePolicy:manage') diff --git a/packages/cli/src/modules/type-availability-policies/type-availability-policy-instance.controller.ts b/packages/cli/src/modules/type-availability-policies/type-availability-policy-instance.controller.ts index 32c27af3da6..ee6c4f231ee 100644 --- a/packages/cli/src/modules/type-availability-policies/type-availability-policy-instance.controller.ts +++ b/packages/cli/src/modules/type-availability-policies/type-availability-policy-instance.controller.ts @@ -143,6 +143,17 @@ export class TypeAvailabilityPolicyInstanceController { return { success: true }; } + @Get('/scopes/:scopeId/attachments') + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) + @GlobalScope('nodeTypePolicy:manage') + async getAttachments( + _req: AuthenticatedRequest, + _res: Response, + @Param('scopeId') scopeId: string, + ) { + return await this.service.getAttachments(NODE_TYPES_KIND, scopeId); + } + @Put('/scopes/:scopeId/attachments') @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @GlobalScope('nodeTypePolicy:manage') diff --git a/packages/cli/src/modules/type-availability-policies/type-availability-policy.service.ts b/packages/cli/src/modules/type-availability-policies/type-availability-policy.service.ts index 5d9b21db26f..fc2f74176cd 100644 --- a/packages/cli/src/modules/type-availability-policies/type-availability-policy.service.ts +++ b/packages/cli/src/modules/type-availability-policies/type-availability-policy.service.ts @@ -142,6 +142,11 @@ export type EffectivePolicy = { readonly attachments: readonly PolicyAttachment[]; }; +export type ScopeAttachments = { + readonly attachments: readonly PolicyAttachment[]; + readonly version: number; +}; + /** The allow-all state of a scope with no row, for a composition with no project to read. */ const UNCONFIGURED_PROJECT: Omit = { scopeId: null, @@ -643,6 +648,21 @@ export class TypeAvailabilityPolicyService { return await this.policyRepository.findPageByKind(kind, offset, limit, {}); } + async getAttachments( + kind: string, + scopeId: string, + ctx: OperationContext = {}, + ): Promise { + const scope = await this.scopeRepository.findScopeByIdAndKind(scopeId, kind, ctx); + if (!scope) { + throw new NotFoundError(`Policy scope not found: ${scopeId}`); + } + + const attachments = await this.attachmentRepository.listAttachmentsForScope(scopeId, ctx); + + return { attachments: orderedAttachments(attachments), version: scope.version }; + } + /** * Replaces every attachment on one scope, keyed by `scopeId` rather than `(kind, * projectId)` — a caller replacing attachments already holds a scope id from a prior @@ -665,7 +685,7 @@ export class TypeAvailabilityPolicyService { attachments: readonly AttachmentInput[], expectedVersion: number, updatedBy: string, - ): Promise<{ attachments: readonly PolicyAttachment[]; version: number }> { + ): Promise { assertNoDuplicateAttachmentSlots(attachments); const result = await this.transactionRunner.run({}, async (ctx) => { @@ -734,7 +754,7 @@ export class TypeAvailabilityPolicyService { after: { attachments: result.after, version: result.versionAfter }, }); - return { attachments: result.after, version: result.versionAfter }; + return { attachments: orderedAttachments(result.after), version: result.versionAfter }; } /** diff --git a/packages/cli/src/public-api/v1/controllers/__tests__/credential-type-policies.public.controller.test.ts b/packages/cli/src/public-api/v1/controllers/__tests__/credential-type-policies.public.controller.test.ts index 07aae9803fb..8476959e843 100644 --- a/packages/cli/src/public-api/v1/controllers/__tests__/credential-type-policies.public.controller.test.ts +++ b/packages/cli/src/public-api/v1/controllers/__tests__/credential-type-policies.public.controller.test.ts @@ -43,6 +43,7 @@ describe('CredentialTypePoliciesPublicController route metadata', () => { 'createCredentialTypePolicyDocument', 'deleteCredentialTypePolicyDocument', 'getCredentialTypeInstancePolicy', + 'getCredentialTypePolicyAttachments', 'getCredentialTypePolicyDocument', 'getCredentialTypeProjectPolicy', 'listCredentialTypePolicyDocuments', @@ -119,6 +120,9 @@ describe('CredentialTypePoliciesPublicController with the module disabled', () = await expect(controller.getCredentialTypePolicyDocument(req, res, 'policy-id')).rejects.toThrow( ServiceUnavailableError, ); + await expect( + controller.getCredentialTypePolicyAttachments(req, res, 'scope-id'), + ).rejects.toThrow(ServiceUnavailableError); await expect( controller.deleteCredentialTypePolicyDocument(req, res, 'policy-id'), ).rejects.toThrow(ServiceUnavailableError); @@ -165,6 +169,32 @@ describe('CredentialTypePoliciesPublicController handler bodies', () => { warnings: [{ kind: 'shadowed' as const, ruleId: 'r2', shadowedByRuleId: 'r1' }], }; + it('getCredentialTypePolicyAttachments reads the credential type scope with extends rules', async () => { + const result = { + attachments: [ + { + policyId: 'policy-id', + rules: [ + { + id: 'r1', + action: 'deny' as const, + selector: { kind: 'extends' as const, value: 'oAuth2Api' }, + }, + ], + priority: 5, + isFloor: true, + }, + ], + version: 3, + }; + service.getAttachments.mockResolvedValue(result); + + expect(await controller.getCredentialTypePolicyAttachments(req, res, 'scope-id')).toEqual( + result, + ); + expect(service.getAttachments).toHaveBeenCalledWith(CREDENTIAL_TYPES_KIND, 'scope-id'); + }); + it('getCredentialTypeInstancePolicy reads the null-project scope and maps the response', async () => { service.getEffectivePolicy.mockResolvedValue(effectivePolicy); diff --git a/packages/cli/src/public-api/v1/controllers/__tests__/node-type-policies.public.controller.test.ts b/packages/cli/src/public-api/v1/controllers/__tests__/node-type-policies.public.controller.test.ts index 11b27368a0b..4710dea68a2 100644 --- a/packages/cli/src/public-api/v1/controllers/__tests__/node-type-policies.public.controller.test.ts +++ b/packages/cli/src/public-api/v1/controllers/__tests__/node-type-policies.public.controller.test.ts @@ -40,6 +40,7 @@ describe('NodeTypePoliciesPublicController route metadata', () => { 'createNodeTypePolicyDocument', 'deleteNodeTypePolicyDocument', 'getNodeTypeInstancePolicy', + 'getNodeTypePolicyAttachments', 'getNodeTypePolicyDocument', 'getNodeTypeProjectPolicy', 'listNodeTypePolicyDocuments', @@ -114,6 +115,9 @@ describe('NodeTypePoliciesPublicController with the module disabled', () => { await expect(controller.getNodeTypePolicyDocument(req, res, 'policy-id')).rejects.toThrow( ServiceUnavailableError, ); + await expect(controller.getNodeTypePolicyAttachments(req, res, 'scope-id')).rejects.toThrow( + ServiceUnavailableError, + ); await expect(controller.deleteNodeTypePolicyDocument(req, res, 'policy-id')).rejects.toThrow( ServiceUnavailableError, ); @@ -171,6 +175,35 @@ describe('NodeTypePoliciesPublicController handler bodies', () => { ); }); + it('getNodeTypePolicyAttachments reads the node type scope and maps the response', async () => { + const result = { + attachments: [{ policyId: 'policy-id', rules, priority: 5, isFloor: true }], + version: 3, + }; + service.getAttachments.mockResolvedValue(result); + + expect(await controller.getNodeTypePolicyAttachments(req, res, 'scope-id')).toEqual(result); + expect(service.getAttachments).toHaveBeenCalledWith(NODE_TYPES_KIND, 'scope-id'); + }); + + it('getNodeTypePolicyAttachments rejects an extends rule', async () => { + service.getAttachments.mockResolvedValue({ + attachments: [ + { + policyId: 'policy-id', + rules: [{ id: 'r1', action: 'deny', selector: { kind: 'extends', value: 'oAuth2Api' } }], + priority: 0, + isFloor: false, + }, + ], + version: 3, + }); + + await expect(controller.getNodeTypePolicyAttachments(req, res, 'scope-id')).rejects.toThrow( + 'A node type policy holds an extends rule', + ); + }); + it('getNodeTypeInstancePolicy reads the null-project scope and maps the response', async () => { service.getEffectivePolicy.mockResolvedValue(effectivePolicy); diff --git a/packages/cli/src/public-api/v1/controllers/credential-type-policies.public.controller.ts b/packages/cli/src/public-api/v1/controllers/credential-type-policies.public.controller.ts index 736c0642491..b4d538a720c 100644 --- a/packages/cli/src/public-api/v1/controllers/credential-type-policies.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/credential-type-policies.public.controller.ts @@ -43,6 +43,7 @@ import type { Response } from 'express'; import { NotFoundError, ServiceUnavailableError } from '@n8n/errors'; import { CREDENTIAL_TYPES_KIND } from '@/modules/type-availability-policies/constants'; import type { TypeAvailabilityPolicy } from '@/modules/type-availability-policies/database/entities/type-availability-policy.entity'; +import type { ScopeAttachments } from '@/modules/type-availability-policies/type-availability-policy.service'; import { encodeNextCursor, resolveOffsetPagination, @@ -62,6 +63,18 @@ function toPublicDocument(policy: TypeAvailabilityPolicy): CredentialTypePolicyD }; } +function toPublicAttachments(result: ScopeAttachments): CredentialTypePolicyAttachmentsPublicDto { + return { + attachments: result.attachments.map((attachment) => ({ + policyId: attachment.policyId, + rules: [...attachment.rules], + priority: attachment.priority, + isFloor: attachment.isFloor, + })), + version: result.version, + }; +} + /** * Public API surface for credential type availability policies. Every route delegates to the * same `TypeAvailabilityPolicyService` methods as the internal `/rest` controllers and reuses @@ -331,6 +344,28 @@ export class CredentialTypePoliciesPublicController { await (await this.service()).deletePolicyDocument(CREDENTIAL_TYPES_KIND, policyId, req.user.id); } + @Get('/scopes/:scopeId/attachments') + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) + @ApiKeyScope('credentialTypePolicy:manage') + @GlobalScope('credentialTypePolicy:manage') + @ApiSummary("Preview: Retrieve a scope's attached policy documents") + @ApiDescription( + '**Preview** — breaking changes may still occur without major version bump.\n\nReturns every attached policy document and the scope version. Attachments use evaluation order: floor first, then priority ascending within each group. Use this version when replacing the attachments. `scopeId` comes from `GET /instance` or `GET /projects/{projectId}`, and is null until the scope is first written. This route needs instance-level access, also for a project scope.', + ) + @ApiTags(tags) + @ApiResponse(200, CredentialTypePolicyAttachmentsPublicDto) + @ApiErrorResponse(404) + @ApiErrorResponse(503) + async getCredentialTypePolicyAttachments( + _req: AuthenticatedRequest, + _res: Response, + @Param('scopeId', credentialTypePolicyScopeIdParamSchema) scopeId: string, + ): Promise { + const result = await (await this.service()).getAttachments(CREDENTIAL_TYPES_KIND, scopeId); + + return toPublicAttachments(result); + } + @Put('/scopes/:scopeId/attachments') @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @ApiKeyScope('credentialTypePolicy:manage') @@ -358,14 +393,6 @@ export class CredentialTypePoliciesPublicController { req.user.id, ); - return { - attachments: result.attachments.map((attachment) => ({ - policyId: attachment.policyId, - rules: [...attachment.rules], - priority: attachment.priority, - isFloor: attachment.isFloor, - })), - version: result.version, - }; + return toPublicAttachments(result); } } diff --git a/packages/cli/src/public-api/v1/controllers/node-type-policies.public.controller.ts b/packages/cli/src/public-api/v1/controllers/node-type-policies.public.controller.ts index 80a6ddf9dfe..af0551c9596 100644 --- a/packages/cli/src/public-api/v1/controllers/node-type-policies.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/node-type-policies.public.controller.ts @@ -47,6 +47,7 @@ import { UnexpectedError } from 'n8n-workflow'; import { NotFoundError, ServiceUnavailableError } from '@n8n/errors'; import { NODE_TYPES_KIND } from '@/modules/type-availability-policies/constants'; import type { TypeAvailabilityPolicy } from '@/modules/type-availability-policies/database/entities/type-availability-policy.entity'; +import type { ScopeAttachments } from '@/modules/type-availability-policies/type-availability-policy.service'; import { encodeNextCursor, resolveOffsetPagination, @@ -74,6 +75,18 @@ function toPublicDocument(policy: TypeAvailabilityPolicy): PolicyDocumentPublicD }; } +function toPublicAttachments(result: ScopeAttachments): PolicyAttachmentsPublicDto { + return { + attachments: result.attachments.map((attachment) => ({ + policyId: attachment.policyId, + rules: toNodeTypeRules(attachment.rules), + priority: attachment.priority, + isFloor: attachment.isFloor, + })), + version: result.version, + }; +} + /** * Public API surface for node type availability policies. Every route delegates to the same * `TypeAvailabilityPolicyService` methods as the internal `/rest` controllers and reuses their @@ -340,6 +353,28 @@ export class NodeTypePoliciesPublicController { await (await this.service()).deletePolicyDocument(NODE_TYPES_KIND, policyId, req.user.id); } + @Get('/scopes/:scopeId/attachments') + @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) + @ApiKeyScope('nodeTypePolicy:manage') + @GlobalScope('nodeTypePolicy:manage') + @ApiSummary("Preview: Retrieve a scope's attached policy documents") + @ApiDescription( + '**Preview** — breaking changes may still occur without major version bump.\n\nReturns every attached policy document and the scope version. Attachments use evaluation order: floor first, then priority ascending within each group. Use this version when replacing the attachments. `scopeId` comes from `GET /instance` or `GET /projects/{projectId}`, and is null until the scope is first written. This route needs instance-level access, also for a project scope.', + ) + @ApiTags(tags) + @ApiResponse(200, PolicyAttachmentsPublicDto) + @ApiErrorResponse(404) + @ApiErrorResponse(503) + async getNodeTypePolicyAttachments( + _req: AuthenticatedRequest, + _res: Response, + @Param('scopeId', nodeTypePolicyScopeIdParamSchema) scopeId: string, + ): Promise { + const result = await (await this.service()).getAttachments(NODE_TYPES_KIND, scopeId); + + return toPublicAttachments(result); + } + @Put('/scopes/:scopeId/attachments') @Licensed(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES) @ApiKeyScope('nodeTypePolicy:manage') @@ -367,14 +402,6 @@ export class NodeTypePoliciesPublicController { req.user.id, ); - return { - attachments: result.attachments.map((attachment) => ({ - policyId: attachment.policyId, - rules: toNodeTypeRules(attachment.rules), - priority: attachment.priority, - isFloor: attachment.isFloor, - })), - version: result.version, - }; + return toPublicAttachments(result); } } diff --git a/packages/cli/src/public-api/v1/handlers/credential-type-policies/spec/paths/getCredentialTypePolicyAttachments.generated.yml b/packages/cli/src/public-api/v1/handlers/credential-type-policies/spec/paths/getCredentialTypePolicyAttachments.generated.yml new file mode 100644 index 00000000000..ac3e874f3b6 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/credential-type-policies/spec/paths/getCredentialTypePolicyAttachments.generated.yml @@ -0,0 +1,31 @@ +operationId: getCredentialTypePolicyAttachments +tags: + - CredentialTypePolicy +summary: "Preview: Retrieve a scope's attached policy documents" +description: |- + **Preview** — breaking changes may still occur without major version bump. + + Returns every attached policy document and the scope version. Attachments use evaluation order: floor first, then priority ascending within each group. Use this version when replacing the attachments. `scopeId` comes from `GET /instance` or `GET /projects/{projectId}`, and is null until the scope is first written. This route needs instance-level access, also for a project scope. +x-required-scope: credentialTypePolicy:manage +parameters: + - schema: + type: string + required: true + description: The ID of the credential type policy scope. + name: scopeId + in: path +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + $ref: ../../../../shared/spec/schemas/credentialTypePolicyAttachmentsPublicDto.generated.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + $ref: ../../../../shared/spec/responses/notFound.yml + '503': + $ref: ../../../../shared/spec/responses/serviceUnavailable.yml diff --git a/packages/cli/src/public-api/v1/handlers/credential-type-policies/spec/paths/replaceCredentialTypePolicyAttachments.generated.yml b/packages/cli/src/public-api/v1/handlers/credential-type-policies/spec/paths/replaceCredentialTypePolicyAttachments.generated.yml index 27cfa44eb81..714a79b9469 100644 --- a/packages/cli/src/public-api/v1/handlers/credential-type-policies/spec/paths/replaceCredentialTypePolicyAttachments.generated.yml +++ b/packages/cli/src/public-api/v1/handlers/credential-type-policies/spec/paths/replaceCredentialTypePolicyAttachments.generated.yml @@ -49,89 +49,7 @@ responses: content: application/json: schema: - type: object - properties: - attachments: - type: array - items: - type: object - properties: - policyId: - type: string - rules: - type: array - items: - type: object - properties: - id: - type: string - minLength: 1 - action: - type: string - enum: - - allow - - deny - - delegate - selector: - oneOf: - - type: object - properties: - kind: - type: string - enum: - - name - value: - type: string - minLength: 1 - required: - - kind - - value - - type: object - properties: - kind: - type: string - enum: - - package - value: - type: string - minLength: 1 - required: - - kind - - value - - type: object - properties: - kind: - type: string - enum: - - extends - value: - type: string - minLength: 1 - required: - - kind - - value - description: Matches the named credential type and every type that extends it - type: object - required: - - id - - action - - selector - priority: - type: integer - isFloor: - type: boolean - required: - - policyId - - rules - - priority - - isFloor - version: - type: integer - minimum: 0 - exclusiveMinimum: true - required: - - attachments - - version + $ref: ../../../../shared/spec/schemas/credentialTypePolicyAttachmentsPublicDto.generated.yml '400': $ref: ../../../../shared/spec/responses/badRequest.yml '401': diff --git a/packages/cli/src/public-api/v1/handlers/node-type-policies/spec/paths/getNodeTypePolicyAttachments.generated.yml b/packages/cli/src/public-api/v1/handlers/node-type-policies/spec/paths/getNodeTypePolicyAttachments.generated.yml new file mode 100644 index 00000000000..d5b3a4e3dab --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/node-type-policies/spec/paths/getNodeTypePolicyAttachments.generated.yml @@ -0,0 +1,31 @@ +operationId: getNodeTypePolicyAttachments +tags: + - NodeTypePolicy +summary: "Preview: Retrieve a scope's attached policy documents" +description: |- + **Preview** — breaking changes may still occur without major version bump. + + Returns every attached policy document and the scope version. Attachments use evaluation order: floor first, then priority ascending within each group. Use this version when replacing the attachments. `scopeId` comes from `GET /instance` or `GET /projects/{projectId}`, and is null until the scope is first written. This route needs instance-level access, also for a project scope. +x-required-scope: nodeTypePolicy:manage +parameters: + - schema: + type: string + required: true + description: The ID of the node type policy scope. + name: scopeId + in: path +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + $ref: ../../../../shared/spec/schemas/policyAttachmentsPublicDto.generated.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + $ref: ../../../../shared/spec/responses/notFound.yml + '503': + $ref: ../../../../shared/spec/responses/serviceUnavailable.yml diff --git a/packages/cli/src/public-api/v1/handlers/node-type-policies/spec/paths/replaceNodeTypePolicyAttachments.generated.yml b/packages/cli/src/public-api/v1/handlers/node-type-policies/spec/paths/replaceNodeTypePolicyAttachments.generated.yml index 6cec17206a9..272bd197b0a 100644 --- a/packages/cli/src/public-api/v1/handlers/node-type-policies/spec/paths/replaceNodeTypePolicyAttachments.generated.yml +++ b/packages/cli/src/public-api/v1/handlers/node-type-policies/spec/paths/replaceNodeTypePolicyAttachments.generated.yml @@ -49,76 +49,7 @@ responses: content: application/json: schema: - type: object - properties: - attachments: - type: array - items: - type: object - properties: - policyId: - type: string - rules: - type: array - items: - type: object - properties: - id: - type: string - minLength: 1 - action: - type: string - enum: - - allow - - deny - - delegate - selector: - oneOf: - - type: object - properties: - kind: - type: string - enum: - - name - value: - type: string - minLength: 1 - required: - - kind - - value - - type: object - properties: - kind: - type: string - enum: - - package - value: - type: string - minLength: 1 - required: - - kind - - value - type: object - required: - - id - - action - - selector - priority: - type: integer - isFloor: - type: boolean - required: - - policyId - - rules - - priority - - isFloor - version: - type: integer - minimum: 0 - exclusiveMinimum: true - required: - - attachments - - version + $ref: ../../../../shared/spec/schemas/policyAttachmentsPublicDto.generated.yml '400': $ref: ../../../../shared/spec/responses/badRequest.yml '401': diff --git a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml index 19ae7fa79b5..82d6fd71415 100644 --- a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml +++ b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml @@ -39,6 +39,8 @@ paths: delete: $ref: ./handlers/credential-type-policies/spec/paths/deleteCredentialTypePolicyDocument.generated.yml /credential-type-policies/scopes/{scopeId}/attachments: + get: + $ref: ./handlers/credential-type-policies/spec/paths/getCredentialTypePolicyAttachments.generated.yml put: $ref: ./handlers/credential-type-policies/spec/paths/replaceCredentialTypePolicyAttachments.generated.yml /credentials: @@ -215,6 +217,8 @@ paths: delete: $ref: ./handlers/node-type-policies/spec/paths/deleteNodeTypePolicyDocument.generated.yml /node-type-policies/scopes/{scopeId}/attachments: + get: + $ref: ./handlers/node-type-policies/spec/paths/getNodeTypePolicyAttachments.generated.yml put: $ref: ./handlers/node-type-policies/spec/paths/replaceNodeTypePolicyAttachments.generated.yml /settings/otel: diff --git a/packages/cli/src/public-api/v1/shared/spec/schemas/credentialTypePolicyAttachmentsPublicDto.generated.yml b/packages/cli/src/public-api/v1/shared/spec/schemas/credentialTypePolicyAttachmentsPublicDto.generated.yml new file mode 100644 index 00000000000..c79f4139346 --- /dev/null +++ b/packages/cli/src/public-api/v1/shared/spec/schemas/credentialTypePolicyAttachmentsPublicDto.generated.yml @@ -0,0 +1,83 @@ +type: object +properties: + attachments: + type: array + items: + type: object + properties: + policyId: + type: string + rules: + type: array + items: + type: object + properties: + id: + type: string + minLength: 1 + action: + type: string + enum: + - allow + - deny + - delegate + selector: + oneOf: + - type: object + properties: + kind: + type: string + enum: + - name + value: + type: string + minLength: 1 + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - package + value: + type: string + minLength: 1 + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - extends + value: + type: string + minLength: 1 + required: + - kind + - value + description: Matches the named credential type and every type that extends it + type: object + required: + - id + - action + - selector + priority: + type: integer + isFloor: + type: boolean + required: + - policyId + - rules + - priority + - isFloor + version: + type: integer + minimum: 0 + exclusiveMinimum: true +required: + - attachments + - version diff --git a/packages/cli/src/public-api/v1/shared/spec/schemas/policyAttachmentsPublicDto.generated.yml b/packages/cli/src/public-api/v1/shared/spec/schemas/policyAttachmentsPublicDto.generated.yml new file mode 100644 index 00000000000..91e496b3ee6 --- /dev/null +++ b/packages/cli/src/public-api/v1/shared/spec/schemas/policyAttachmentsPublicDto.generated.yml @@ -0,0 +1,70 @@ +type: object +properties: + attachments: + type: array + items: + type: object + properties: + policyId: + type: string + rules: + type: array + items: + type: object + properties: + id: + type: string + minLength: 1 + action: + type: string + enum: + - allow + - deny + - delegate + selector: + oneOf: + - type: object + properties: + kind: + type: string + enum: + - name + value: + type: string + minLength: 1 + required: + - kind + - value + - type: object + properties: + kind: + type: string + enum: + - package + value: + type: string + minLength: 1 + required: + - kind + - value + type: object + required: + - id + - action + - selector + priority: + type: integer + isFloor: + type: boolean + required: + - policyId + - rules + - priority + - isFloor + version: + type: integer + minimum: 0 + exclusiveMinimum: true +required: + - attachments + - version diff --git a/packages/cli/test/integration/policy/credential-type-policy-instance.controller.test.ts b/packages/cli/test/integration/policy/credential-type-policy-instance.controller.test.ts index a4bc3e4ab4f..4b43a6b8b2b 100644 --- a/packages/cli/test/integration/policy/credential-type-policy-instance.controller.test.ts +++ b/packages/cli/test/integration/policy/credential-type-policy-instance.controller.test.ts @@ -45,10 +45,11 @@ describe('credential type availability policy instance controller RBAC', () => { expect(response.statusCode).toBe(403); }); - test('GET /credential-type-policies/instance rejects a member with 403', async () => { - const response = await testServer - .authAgentFor(member) - .get('/credential-type-policies/instance'); + test.each([ + '/credential-type-policies/instance', + '/credential-type-policies/scopes/does-not-exist/attachments', + ])('GET %s rejects a member with 403', async (path) => { + const response = await testServer.authAgentFor(member).get(path); expect(response.statusCode).toBe(403); }); @@ -71,16 +72,65 @@ describe('credential type availability policy instance controller license gating testServer.license.enable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES); }); - test('rejects an owner with 403 when the license feature is disabled', async () => { + test.each([ + '/credential-type-policies/instance', + '/credential-type-policies/scopes/does-not-exist/attachments', + ])('GET %s rejects an owner with 403 when the license feature is disabled', async (path) => { testServer.license.disable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES); - const response = await testServer.authAgentFor(owner).get('/credential-type-policies/instance'); + const response = await testServer.authAgentFor(owner).get(path); expect(response.statusCode).toBe(403); }); }); describe('credential type availability policy instance controller admin happy path', () => { + test('GET attachments returns rules and a version that PUT accepts without a read audit event', async () => { + const agent = testServer.authAgentFor(owner); + const rule = { id: 'r1', action: 'deny', selector: { kind: 'name', value: 'slackApi' } }; + const instance = await agent.put('/credential-type-policies/instance').send({ + rules: [rule], + defaultAction: 'allow', + version: 0, + }); + expect(instance.statusCode).toBe(200); + const path = `/credential-type-policies/scopes/${instance.body.data.scopeId}/attachments`; + const emitSpy = vi.spyOn(Container.get(EventService), 'emit'); + + const read = await agent.get(path); + + expect(read.statusCode).toBe(200); + expect(read.body.data).toEqual({ + attachments: [{ policyId: expect.any(String), rules: [rule], priority: 0, isFloor: false }], + version: instance.body.data.version, + }); + expect(emitSpy).not.toHaveBeenCalled(); + const updated = await agent + .put(path) + .send({ attachments: [], version: read.body.data.version }); + expect(updated.statusCode).toBe(200); + expect(updated.body.data).toEqual({ attachments: [], version: read.body.data.version + 1 }); + const empty = await agent.get(path); + expect(empty.body.data).toEqual(updated.body.data); + }); + + test('GET attachments returns 404 for an unknown scope and a node type scope', async () => { + const agent = testServer.authAgentFor(owner); + expect( + (await agent.get('/credential-type-policies/scopes/does-not-exist/attachments')).statusCode, + ).toBe(404); + const other = await agent.put('/node-type-policies/instance').send({ + rules: [], + defaultAction: 'allow', + version: 0, + }); + expect(other.statusCode).toBe(200); + expect( + (await agent.get(`/credential-type-policies/scopes/${other.body.data.scopeId}/attachments`)) + .statusCode, + ).toBe(404); + }); + test('PUT /instance persists and fires an audit event carrying the credential-types kind', async () => { const eventService = Container.get(EventService); const emitSpy = vi.spyOn(eventService, 'emit'); diff --git a/packages/cli/test/integration/policy/type-availability-policy-instance.controller.test.ts b/packages/cli/test/integration/policy/type-availability-policy-instance.controller.test.ts index 8e6976ca3cc..48201dd423c 100644 --- a/packages/cli/test/integration/policy/type-availability-policy-instance.controller.test.ts +++ b/packages/cli/test/integration/policy/type-availability-policy-instance.controller.test.ts @@ -61,8 +61,11 @@ describe('node type availability policy instance controller RBAC', () => { expect(response.statusCode).toBe(403); }); - test('GET /node-type-policies/instance rejects a member with 403', async () => { - const response = await testServer.authAgentFor(member).get('/node-type-policies/instance'); + test.each([ + '/node-type-policies/instance', + '/node-type-policies/scopes/does-not-exist/attachments', + ])('GET %s rejects a member with 403', async (path) => { + const response = await testServer.authAgentFor(member).get(path); expect(response.statusCode).toBe(403); }); @@ -129,16 +132,65 @@ describe('node type availability policy instance controller license gating', () testServer.license.enable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES); }); - test('rejects an owner with 403 when the license feature is disabled', async () => { + test.each([ + '/node-type-policies/instance', + '/node-type-policies/scopes/does-not-exist/attachments', + ])('GET %s rejects an owner with 403 when the license feature is disabled', async (path) => { testServer.license.disable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES); - const response = await testServer.authAgentFor(owner).get('/node-type-policies/instance'); + const response = await testServer.authAgentFor(owner).get(path); expect(response.statusCode).toBe(403); }); }); describe('node type availability policy instance controller admin happy path', () => { + test('GET attachments returns rules and a version that PUT accepts without a read audit event', async () => { + const agent = testServer.authAgentFor(owner); + const rule = { id: 'r1', action: 'deny', selector: { kind: 'name', value: 'a.b' } }; + const instance = await agent.put('/node-type-policies/instance').send({ + rules: [rule], + defaultAction: 'allow', + version: 0, + }); + expect(instance.statusCode).toBe(200); + const path = `/node-type-policies/scopes/${instance.body.data.scopeId}/attachments`; + const emitSpy = vi.spyOn(Container.get(EventService), 'emit'); + + const read = await agent.get(path); + + expect(read.statusCode).toBe(200); + expect(read.body.data).toEqual({ + attachments: [{ policyId: expect.any(String), rules: [rule], priority: 0, isFloor: false }], + version: instance.body.data.version, + }); + expect(emitSpy).not.toHaveBeenCalled(); + const updated = await agent + .put(path) + .send({ attachments: [], version: read.body.data.version }); + expect(updated.statusCode).toBe(200); + expect(updated.body.data).toEqual({ attachments: [], version: read.body.data.version + 1 }); + const empty = await agent.get(path); + expect(empty.body.data).toEqual(updated.body.data); + }); + + test('GET attachments returns 404 for an unknown scope and a credential type scope', async () => { + const agent = testServer.authAgentFor(owner); + expect( + (await agent.get('/node-type-policies/scopes/does-not-exist/attachments')).statusCode, + ).toBe(404); + const other = await agent.put('/credential-type-policies/instance').send({ + rules: [], + defaultAction: 'allow', + version: 0, + }); + expect(other.statusCode).toBe(200); + expect( + (await agent.get(`/node-type-policies/scopes/${other.body.data.scopeId}/attachments`)) + .statusCode, + ).toBe(404); + }); + test('PUT /instance persists, bumps version, and fires an audit event', async () => { const eventService = Container.get(EventService); const emitSpy = vi.spyOn(eventService, 'emit'); diff --git a/packages/cli/test/integration/public-api/credential-type-policies.test.ts b/packages/cli/test/integration/public-api/credential-type-policies.test.ts index afa32491bf1..cbfe68cbe38 100644 --- a/packages/cli/test/integration/public-api/credential-type-policies.test.ts +++ b/packages/cli/test/integration/public-api/credential-type-policies.test.ts @@ -89,6 +89,7 @@ describe('credential type policies public API authorization', () => { ['GET', '/credential-type-policies/policies/does-not-exist'], ['PUT', '/credential-type-policies/policies/does-not-exist'], ['DELETE', '/credential-type-policies/policies/does-not-exist'], + ['GET', '/credential-type-policies/scopes/does-not-exist/attachments'], ['PUT', '/credential-type-policies/scopes/does-not-exist/attachments'], ])('%s %s rejects an owner key without the scope with 403', async (method, path) => { const agent = testServer.publicApiAgentFor(unscopedOwner); @@ -120,6 +121,7 @@ describe('credential type policies public API authorization', () => { ['GET', '/credential-type-policies/policies/does-not-exist'], ['PUT', '/credential-type-policies/policies/does-not-exist'], ['DELETE', '/credential-type-policies/policies/does-not-exist'], + ['GET', '/credential-type-policies/scopes/does-not-exist/attachments'], ['PUT', '/credential-type-policies/scopes/does-not-exist/attachments'], ])( '%s %s rejects a project admin with 403 (instance routes are global-only)', @@ -175,12 +177,13 @@ describe('credential type policies public API license gating', () => { testServer.license.enable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES); }); - test('rejects an owner with 403 when the license feature is disabled', async () => { + test.each([ + '/credential-type-policies/instance', + '/credential-type-policies/scopes/does-not-exist/attachments', + ])('GET %s rejects an owner with 403 when the license feature is disabled', async (path) => { testServer.license.disable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES); - const instance = await testServer - .publicApiAgentFor(owner) - .get('/credential-type-policies/instance'); + const instance = await testServer.publicApiAgentFor(owner).get(path); expect(instance.statusCode).toBe(403); const projectPolicy = await testServer @@ -605,22 +608,117 @@ describe('credential type policies public API policy documents', () => { }); describe('credential type policies public API attachments', () => { + test.each(['instance', 'project'])( + 'GET and PUT return every attachment in evaluation order for %s scope', + async (scope) => { + const agent = testServer.publicApiAgentFor(owner); + const effectivePath = + scope === 'instance' ? '/credential-type-policies/instance' : projectRoute(project.id); + const configured = await agent.put(effectivePath).send(ALLOW_ALL); + expect(configured.statusCode).toBe(200); + const path = `/credential-type-policies/scopes/${configured.body.scopeId}/attachments`; + const slots = [ + { priority: 10, isFloor: false }, + { priority: 20, isFloor: true }, + { priority: -5, isFloor: false }, + { priority: 0, isFloor: true }, + ]; + const attachments = []; + for (const [index, slot] of slots.entries()) { + const rules = [{ ...DENY_RULE, id: `r${index}` }]; + const created = await agent.post('/credential-type-policies/policies').send({ rules }); + expect(created.statusCode).toBe(201); + attachments.push({ policyId: created.body.policy.id as string, rules, ...slot }); + } + const replaced = await agent.put(path).send({ + attachments: attachments.map(({ rules: _rules, ...attachment }) => attachment), + version: configured.body.version, + }); + expect(replaced.statusCode).toBe(200); + expect(replaced.body).toEqual({ + attachments: [attachments[3], attachments[1], attachments[2], attachments[0]], + version: configured.body.version + 1, + }); + const emitSpy = vi.spyOn(Container.get(EventService), 'emit'); + + const read = await agent.get(path); + + expect(read.statusCode).toBe(200); + expect(read.body).toEqual(replaced.body); + expect(emitSpy.mock.calls.filter(([event]) => event.startsWith('node-type-policy-'))).toEqual( + [], + ); + const effective = await agent.get(effectivePath); + expect(Object.keys(effective.body).sort()).toEqual([...EFFECTIVE_KEYS].sort()); + const updated = await agent.put(path).send({ attachments: [], version: read.body.version }); + expect(updated.statusCode).toBe(200); + expect(updated.body).toEqual({ attachments: [], version: read.body.version + 1 }); + const empty = await agent.get(path); + expect(empty.statusCode).toBe(200); + expect(empty.body).toEqual(updated.body); + }, + ); + + test('GET returns 404 for an unknown scope and a node type scope', async () => { + const agent = testServer.publicApiAgentFor(owner); + expect( + (await agent.get('/credential-type-policies/scopes/does-not-exist/attachments')).statusCode, + ).toBe(404); + const other = await agent.put('/node-type-policies/instance').send(ALLOW_ALL); + expect(other.statusCode).toBe(200); + expect( + (await agent.get(`/credential-type-policies/scopes/${other.body.scopeId}/attachments`)) + .statusCode, + ).toBe(404); + }); + test('PUT with a stale version returns 409 and preserves the attachments and version', async () => { const agent = testServer.publicApiAgentFor(owner); - const instance = await agent.put('/credential-type-policies/instance').send({ - ...ALLOW_ALL, - rules: [DENY_RULE], - }); + const instance = await agent.put('/credential-type-policies/instance').send(ALLOW_ALL); expect(instance.statusCode).toBe(200); + const created = await agent + .post('/credential-type-policies/policies') + .send({ rules: [DENY_RULE] }); + expect(created.statusCode).toBe(201); + const policyId = created.body.policy.id as string; - const stale = await agent - .put(`/credential-type-policies/scopes/${instance.body.scopeId}/attachments`) - .send({ attachments: [], version: 0 }); + const path = `/credential-type-policies/scopes/${instance.body.scopeId}/attachments`; + const attached = await agent.put(path).send({ + attachments: [{ policyId, priority: 0, isFloor: false }], + version: instance.body.version, + }); + expect(attached.statusCode).toBe(200); + const read = await agent.get(path); + expect(read.statusCode).toBe(200); + expect(read.body).toEqual({ + attachments: [{ policyId, rules: [DENY_RULE], priority: 0, isFloor: false }], + version: instance.body.version + 1, + }); + const replaced = await agent.put(path).send({ + attachments: read.body.attachments.map(({ policyId }: { policyId: string }) => ({ + policyId, + priority: 5, + isFloor: false, + })), + version: read.body.version, + }); + expect(replaced.statusCode).toBe(200); + expect(replaced.body).toEqual({ + attachments: [{ policyId, rules: [DENY_RULE], priority: 5, isFloor: false }], + version: read.body.version + 1, + }); + + const stale = await agent.put(path).send({ attachments: [], version: read.body.version }); expect(stale.statusCode).toBe(409); - const persisted = await agent.get('/credential-type-policies/instance'); - expect(persisted.body.rules).toEqual([DENY_RULE]); - expect(persisted.body.version).toBe(instance.body.version); + const persisted = await agent.get(path); + expect(persisted.statusCode).toBe(200); + expect(persisted.body).toEqual(replaced.body); + const updated = await agent + .put(path) + .send({ attachments: [], version: persisted.body.version }); + expect(updated.statusCode).toBe(200); + expect(updated.body).toEqual({ attachments: [], version: persisted.body.version + 1 }); }); test.each([undefined, -1, 0.5])('PUT rejects invalid version %s with 400', async (version) => { @@ -776,5 +874,10 @@ describe('credential type policies public API extends rules', () => { expect(attachments.body.attachments).toEqual([ { policyId, rules: [EXTENDS_RULE], priority: 1, isFloor: false }, ]); + const readAttachments = await agent.get( + `/credential-type-policies/scopes/${instancePut.body.scopeId}/attachments`, + ); + expect(readAttachments.statusCode).toBe(200); + expect(readAttachments.body).toEqual(attachments.body); }); }); diff --git a/packages/cli/test/integration/public-api/node-type-policies.test.ts b/packages/cli/test/integration/public-api/node-type-policies.test.ts index 7076f8d939f..ce3b197b813 100644 --- a/packages/cli/test/integration/public-api/node-type-policies.test.ts +++ b/packages/cli/test/integration/public-api/node-type-policies.test.ts @@ -85,6 +85,7 @@ describe('node type policies public API authorization', () => { ['GET', '/node-type-policies/policies/does-not-exist'], ['PUT', '/node-type-policies/policies/does-not-exist'], ['DELETE', '/node-type-policies/policies/does-not-exist'], + ['GET', '/node-type-policies/scopes/does-not-exist/attachments'], ['PUT', '/node-type-policies/scopes/does-not-exist/attachments'], ])('%s %s rejects an owner key without the scope with 403', async (method, path) => { const agent = testServer.publicApiAgentFor(unscopedOwner); @@ -116,6 +117,7 @@ describe('node type policies public API authorization', () => { ['GET', '/node-type-policies/policies/does-not-exist'], ['PUT', '/node-type-policies/policies/does-not-exist'], ['DELETE', '/node-type-policies/policies/does-not-exist'], + ['GET', '/node-type-policies/scopes/does-not-exist/attachments'], ['PUT', '/node-type-policies/scopes/does-not-exist/attachments'], ])( '%s %s rejects a project admin with 403 (instance routes are global-only)', @@ -171,10 +173,13 @@ describe('node type policies public API license gating', () => { testServer.license.enable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES); }); - test('rejects an owner with 403 when the license feature is disabled', async () => { + test.each([ + '/node-type-policies/instance', + '/node-type-policies/scopes/does-not-exist/attachments', + ])('GET %s rejects an owner with 403 when the license feature is disabled', async (path) => { testServer.license.disable(LICENSE_FEATURES.TYPE_AVAILABILITY_POLICIES); - const instance = await testServer.publicApiAgentFor(owner).get('/node-type-policies/instance'); + const instance = await testServer.publicApiAgentFor(owner).get(path); expect(instance.statusCode).toBe(403); const projectPolicy = await testServer @@ -574,22 +579,114 @@ describe('node type policies public API policy documents', () => { }); describe('node type policies public API attachments', () => { + test.each(['instance', 'project'])( + 'GET and PUT return every attachment in evaluation order for %s scope', + async (scope) => { + const agent = testServer.publicApiAgentFor(owner); + const effectivePath = + scope === 'instance' ? '/node-type-policies/instance' : projectRoute(project.id); + const configured = await agent.put(effectivePath).send(ALLOW_ALL); + expect(configured.statusCode).toBe(200); + const path = `/node-type-policies/scopes/${configured.body.scopeId}/attachments`; + const slots = [ + { priority: 10, isFloor: false }, + { priority: 20, isFloor: true }, + { priority: -5, isFloor: false }, + { priority: 0, isFloor: true }, + ]; + const attachments = []; + for (const [index, slot] of slots.entries()) { + const rules = [{ ...DENY_RULE, id: `r${index}` }]; + const created = await agent.post('/node-type-policies/policies').send({ rules }); + expect(created.statusCode).toBe(201); + attachments.push({ policyId: created.body.policy.id as string, rules, ...slot }); + } + const replaced = await agent.put(path).send({ + attachments: attachments.map(({ rules: _rules, ...attachment }) => attachment), + version: configured.body.version, + }); + expect(replaced.statusCode).toBe(200); + expect(replaced.body).toEqual({ + attachments: [attachments[3], attachments[1], attachments[2], attachments[0]], + version: configured.body.version + 1, + }); + const emitSpy = vi.spyOn(Container.get(EventService), 'emit'); + + const read = await agent.get(path); + + expect(read.statusCode).toBe(200); + expect(read.body).toEqual(replaced.body); + expect(emitSpy.mock.calls.filter(([event]) => event.startsWith('node-type-policy-'))).toEqual( + [], + ); + const effective = await agent.get(effectivePath); + expect(Object.keys(effective.body).sort()).toEqual([...EFFECTIVE_KEYS].sort()); + const updated = await agent.put(path).send({ attachments: [], version: read.body.version }); + expect(updated.statusCode).toBe(200); + expect(updated.body).toEqual({ attachments: [], version: read.body.version + 1 }); + const empty = await agent.get(path); + expect(empty.statusCode).toBe(200); + expect(empty.body).toEqual(updated.body); + }, + ); + + test('GET returns 404 for an unknown scope and a credential type scope', async () => { + const agent = testServer.publicApiAgentFor(owner); + expect( + (await agent.get('/node-type-policies/scopes/does-not-exist/attachments')).statusCode, + ).toBe(404); + const other = await agent.put('/credential-type-policies/instance').send(ALLOW_ALL); + expect(other.statusCode).toBe(200); + expect( + (await agent.get(`/node-type-policies/scopes/${other.body.scopeId}/attachments`)).statusCode, + ).toBe(404); + }); + test('PUT with a stale version returns 409 and preserves the attachments and version', async () => { const agent = testServer.publicApiAgentFor(owner); - const instance = await agent.put('/node-type-policies/instance').send({ - ...ALLOW_ALL, - rules: [DENY_RULE], - }); + const instance = await agent.put('/node-type-policies/instance').send(ALLOW_ALL); expect(instance.statusCode).toBe(200); + const created = await agent.post('/node-type-policies/policies').send({ rules: [DENY_RULE] }); + expect(created.statusCode).toBe(201); + const policyId = created.body.policy.id as string; - const stale = await agent - .put(`/node-type-policies/scopes/${instance.body.scopeId}/attachments`) - .send({ attachments: [], version: 0 }); + const path = `/node-type-policies/scopes/${instance.body.scopeId}/attachments`; + const attached = await agent.put(path).send({ + attachments: [{ policyId, priority: 0, isFloor: false }], + version: instance.body.version, + }); + expect(attached.statusCode).toBe(200); + const read = await agent.get(path); + expect(read.statusCode).toBe(200); + expect(read.body).toEqual({ + attachments: [{ policyId, rules: [DENY_RULE], priority: 0, isFloor: false }], + version: instance.body.version + 1, + }); + const replaced = await agent.put(path).send({ + attachments: read.body.attachments.map(({ policyId }: { policyId: string }) => ({ + policyId, + priority: 5, + isFloor: false, + })), + version: read.body.version, + }); + expect(replaced.statusCode).toBe(200); + expect(replaced.body).toEqual({ + attachments: [{ policyId, rules: [DENY_RULE], priority: 5, isFloor: false }], + version: read.body.version + 1, + }); + + const stale = await agent.put(path).send({ attachments: [], version: read.body.version }); expect(stale.statusCode).toBe(409); - const persisted = await agent.get('/node-type-policies/instance'); - expect(persisted.body.rules).toEqual([DENY_RULE]); - expect(persisted.body.version).toBe(instance.body.version); + const persisted = await agent.get(path); + expect(persisted.statusCode).toBe(200); + expect(persisted.body).toEqual(replaced.body); + const updated = await agent + .put(path) + .send({ attachments: [], version: persisted.body.version }); + expect(updated.statusCode).toBe(200); + expect(updated.body).toEqual({ attachments: [], version: persisted.body.version + 1 }); }); test.each([undefined, -1, 0.5])('PUT rejects invalid version %s with 400', async (version) => { diff --git a/packages/nodes-base/nodes/N8n/n8n-api-coverage.json b/packages/nodes-base/nodes/N8n/n8n-api-coverage.json index 07c17f672d6..03e6cf11f06 100644 --- a/packages/nodes-base/nodes/N8n/n8n-api-coverage.json +++ b/packages/nodes-base/nodes/N8n/n8n-api-coverage.json @@ -484,6 +484,9 @@ "DELETE /node-type-policies/policies/{policyId}": { "status": "gap" }, + "GET /node-type-policies/scopes/{scopeId}/attachments": { + "status": "gap" + }, "PUT /node-type-policies/scopes/{scopeId}/attachments": { "status": "gap" }, @@ -514,6 +517,9 @@ "DELETE /credential-type-policies/policies/{policyId}": { "status": "gap" }, + "GET /credential-type-policies/scopes/{scopeId}/attachments": { + "status": "gap" + }, "PUT /credential-type-policies/scopes/{scopeId}/attachments": { "status": "gap" }