mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat: Add policy attachment read endpoints (no-changelog) (#41122)
Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6f3b397a1e
commit
ec074519d9
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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<EffectivePolicy, 'kind'> = {
|
||||
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<ScopeAttachments> {
|
||||
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<ScopeAttachments> {
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<CredentialTypePolicyAttachmentsPublicDto> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PolicyAttachmentsPublicDto> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user