From 0adb887d73586e39cae7e2ecde15c1abcdd97237 Mon Sep 17 00:00:00 2001 From: Julius Jogela Date: Thu, 13 Aug 2026 11:48:18 +0100 Subject: [PATCH 1/7] feat(ipa): implement IPA-132 ruleset and base Operations resource rules Add the IPA-132 (Long-Running Operations) spectral ruleset with the three rules governing the shape of the Operations resource, named after the guideline IDs in the IPA-132 standard: - xgen-IPA-132-operations-endpoint-must-not-be-global: rejects a root-level Operations endpoint such as /api/atlas/v2/operations; every Operations endpoint must have a parent resource in its path. - xgen-IPA-132-operation-must-be-a-read-only-resource: Operations path items may define only get, including custom method paths attached to an Operations endpoint. - xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource: nothing may be nested below operations/{operationId}. No guideline ID exists for this constraint yet; it is implied by the path formats the standard defines. All rules run at error severity with exceptions collected at the path-item level. The path predicates live in the new utils/longRunningOperations.js; the private helpers in the IPA-102 function and operationIdGeneration.js are deliberately left untouched and will be consolidated in a follow-up. The merged spec has no Operations endpoints yet, so the rules report nothing today; the unit tests pin the contract for the first compliant long-running operation. --- ...32OperationMustBeAReadOnlyResource.test.js | 143 ++++++++++++++++++ ...rationsEndpointMustBeALeafResource.test.js | 105 +++++++++++++ ...2OperationsEndpointMustNotBeGlobal.test.js | 107 +++++++++++++ .../utils/longRunningOperations.test.js | 87 +++++++++++ tools/spectral/ipa/ipa-spectral.yaml | 1 + tools/spectral/ipa/rulesets/IPA-132.yaml | 75 +++++++++ tools/spectral/ipa/rulesets/README.md | 55 +++++++ .../IPA132OperationMustBeAReadOnlyResource.js | 43 ++++++ ...32OperationsEndpointMustBeALeafResource.js | 36 +++++ ...IPA132OperationsEndpointMustNotBeGlobal.js | 36 +++++ .../functions/utils/longRunningOperations.js | 89 +++++++++++ 11 files changed, 777 insertions(+) create mode 100644 tools/spectral/ipa/__tests__/IPA132OperationMustBeAReadOnlyResource.test.js create mode 100644 tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustBeALeafResource.test.js create mode 100644 tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustNotBeGlobal.test.js create mode 100644 tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js create mode 100644 tools/spectral/ipa/rulesets/IPA-132.yaml create mode 100644 tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js create mode 100644 tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustBeALeafResource.js create mode 100644 tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustNotBeGlobal.js create mode 100644 tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js diff --git a/tools/spectral/ipa/__tests__/IPA132OperationMustBeAReadOnlyResource.test.js b/tools/spectral/ipa/__tests__/IPA132OperationMustBeAReadOnlyResource.test.js new file mode 100644 index 0000000000..fd419bbad6 --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA132OperationMustBeAReadOnlyResource.test.js @@ -0,0 +1,143 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +testRule('xgen-IPA-132-operation-must-be-a-read-only-resource', [ + { + name: 'valid read-only Operations endpoints', + document: { + paths: { + '/api/atlas/v2/resourceName/operations': { + get: {}, + }, + '/api/atlas/v2/resourceName/operations/{operationId}': { + get: {}, + }, + '/api/atlas/v2/resourceName/{pathParam}/operations': { + get: {}, + }, + '/api/atlas/v2/resourceName/{pathParam}/operations/{operationId}': { + get: {}, + }, + }, + }, + errors: [], + }, + { + name: 'paths that are not Operations endpoints are ignored', + document: { + paths: { + '/api/atlas/v2/resourceName': { + post: {}, + }, + '/api/atlas/v2/resourceName/{pathParam}': { + put: {}, + patch: {}, + delete: {}, + }, + }, + }, + errors: [], + }, + { + name: 'invalid Operations endpoints with mutating methods', + document: { + paths: { + '/api/atlas/v2/resourceName/operations': { + get: {}, + post: {}, + }, + '/api/atlas/v2/resourceName/operations/{operationId}': { + get: {}, + put: {}, + patch: {}, + delete: {}, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-must-be-a-read-only-resource', + message: 'Operations endpoints are read-only and do not allow the post method.', + path: ['paths', '/api/atlas/v2/resourceName/operations', 'post'], + severity: DiagnosticSeverity.Error, + }, + { + code: 'xgen-IPA-132-operation-must-be-a-read-only-resource', + message: 'Operations endpoints are read-only and do not allow the put method.', + path: ['paths', '/api/atlas/v2/resourceName/operations/{operationId}', 'put'], + severity: DiagnosticSeverity.Error, + }, + { + code: 'xgen-IPA-132-operation-must-be-a-read-only-resource', + message: 'Operations endpoints are read-only and do not allow the patch method.', + path: ['paths', '/api/atlas/v2/resourceName/operations/{operationId}', 'patch'], + severity: DiagnosticSeverity.Error, + }, + { + code: 'xgen-IPA-132-operation-must-be-a-read-only-resource', + message: 'Operations endpoints are read-only and do not allow the delete method.', + path: ['paths', '/api/atlas/v2/resourceName/operations/{operationId}', 'delete'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'invalid custom method attached to an Operations endpoint', + document: { + paths: { + '/api/atlas/v2/resourceName/operations/{operationId}:cancel': { + post: {}, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-must-be-a-read-only-resource', + message: 'Operations endpoints are read-only and do not allow the post method.', + path: ['paths', '/api/atlas/v2/resourceName/operations/{operationId}:cancel', 'post'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'invalid Operations endpoints with exceptions', + document: { + paths: { + '/api/atlas/v2/resourceName/operations': { + get: {}, + post: {}, + 'x-xgen-IPA-exception': { + 'xgen-IPA-132-operation-must-be-a-read-only-resource': 'reason', + }, + }, + }, + }, + errors: [], + }, + { + name: 'read-only Operations endpoints do not need an exception', + document: { + paths: { + '/api/atlas/v2/resourceName/operations': { + get: {}, + 'x-xgen-IPA-exception': { + 'xgen-IPA-132-operation-must-be-a-read-only-resource': 'reason', + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-must-be-a-read-only-resource', + message: 'This component adopts the rule and does not need an exception. Please remove the exception.', + path: [ + 'paths', + '/api/atlas/v2/resourceName/operations', + 'x-xgen-IPA-exception', + 'xgen-IPA-132-operation-must-be-a-read-only-resource', + ], + severity: DiagnosticSeverity.Error, + }, + ], + }, +]); diff --git a/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustBeALeafResource.test.js b/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustBeALeafResource.test.js new file mode 100644 index 0000000000..1b0e8fc1e7 --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustBeALeafResource.test.js @@ -0,0 +1,105 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +const ERROR_MESSAGE = + 'Operations endpoints must be leaf resources. An `operations` segment may only be followed by a single operation identifier path parameter.'; + +testRule('xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource', [ + { + name: 'valid leaf Operations endpoints', + document: { + paths: { + '/api/atlas/v2/resourceName/operations': {}, + '/api/atlas/v2/resourceName/operations/{operationId}': {}, + '/api/atlas/v2/resourceName/{pathParam}/operations': {}, + '/api/atlas/v2/resourceName/{pathParam}/operations/{operationId}': {}, + // Root-level Operations endpoints are leaves, their nesting is covered by + // xgen-IPA-132-operations-endpoint-must-not-be-global + '/api/atlas/v2/operations': {}, + // Custom method suffixes are ignored, their methods are covered by + // xgen-IPA-132-operation-must-be-a-read-only-resource + '/api/atlas/v2/resourceName/operations/{operationId}:cancel': {}, + }, + }, + errors: [], + }, + { + name: 'paths without an operations segment are ignored', + document: { + paths: { + '/api/atlas/v2/resourceName': {}, + '/api/atlas/v2/resourceName/{pathParam}': {}, + '/api/atlas/v2/resourceName/{pathParam}/childResource': {}, + }, + }, + errors: [], + }, + { + name: 'invalid paths nested below Operations endpoints', + document: { + paths: { + '/api/atlas/v2/resourceName/operations/subresource': {}, + '/api/atlas/v2/resourceName/operations/{operationId}/subresource': {}, + '/api/atlas/v2/resourceName/operations/{operationId}/{anotherId}': {}, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource', + message: ERROR_MESSAGE, + path: ['paths', '/api/atlas/v2/resourceName/operations/subresource'], + severity: DiagnosticSeverity.Error, + }, + { + code: 'xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource', + message: ERROR_MESSAGE, + path: ['paths', '/api/atlas/v2/resourceName/operations/{operationId}/subresource'], + severity: DiagnosticSeverity.Error, + }, + { + code: 'xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource', + message: ERROR_MESSAGE, + path: ['paths', '/api/atlas/v2/resourceName/operations/{operationId}/{anotherId}'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'invalid paths with exceptions', + document: { + paths: { + '/api/atlas/v2/resourceName/operations/{operationId}/subresource': { + 'x-xgen-IPA-exception': { + 'xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource': 'reason', + }, + }, + }, + }, + errors: [], + }, + { + name: 'leaf Operations endpoints do not need an exception', + document: { + paths: { + '/api/atlas/v2/resourceName/operations/{operationId}': { + 'x-xgen-IPA-exception': { + 'xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource': 'reason', + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource', + message: 'This component adopts the rule and does not need an exception. Please remove the exception.', + path: [ + 'paths', + '/api/atlas/v2/resourceName/operations/{operationId}', + 'x-xgen-IPA-exception', + 'xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource', + ], + severity: DiagnosticSeverity.Error, + }, + ], + }, +]); diff --git a/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustNotBeGlobal.test.js b/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustNotBeGlobal.test.js new file mode 100644 index 0000000000..afd5e0e69f --- /dev/null +++ b/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustNotBeGlobal.test.js @@ -0,0 +1,107 @@ +import testRule from './__helpers__/testRule'; +import { DiagnosticSeverity } from '@stoplight/types'; + +const ERROR_MESSAGE = + 'Operations endpoints must not be standalone, global endpoints with no parent resource in their path.'; + +testRule('xgen-IPA-132-operations-endpoint-must-not-be-global', [ + { + name: 'valid nested Operations endpoints', + document: { + paths: { + // Collection-scoped Operations endpoints, nested directly under the parent collection + '/api/atlas/v2/resourceName/operations': {}, + '/api/atlas/v2/resourceName/operations/{operationId}': {}, + '/api/atlas/v2/resourceName1/{pathParam}/resourceName2/operations': {}, + '/api/atlas/v2/resourceName1/{pathParam}/resourceName2/operations/{operationId}': {}, + '/api/atlas/v2/unauth/resourceName/operations': {}, + '/api/atlas/v2/unauth/resourceName/operations/{operationId}': {}, + // Instance-scoped Operations endpoints, nested under the parent resource instance + '/api/atlas/v2/resourceName/{pathParam}/operations': {}, + '/api/atlas/v2/resourceName/{pathParam}/operations/{operationId}': {}, + }, + }, + errors: [], + }, + { + name: 'paths that are not Operations endpoints are ignored', + document: { + paths: { + '/api/atlas/v2/resourceName': {}, + '/api/atlas/v2/resourceName/{pathParam}': {}, + '/api/atlas/v2/resourceName/{pathParam}:customMethod': {}, + // Not a well-formed Operations endpoint, covered by xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource + '/api/atlas/v2/resourceName/operations/subresource': {}, + }, + }, + errors: [], + }, + { + name: 'invalid root-level Operations endpoints', + document: { + paths: { + '/api/atlas/v2/operations': {}, + '/api/atlas/v2/operations/{operationId}': {}, + '/api/atlas/v2/unauth/operations': {}, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operations-endpoint-must-not-be-global', + message: ERROR_MESSAGE, + path: ['paths', '/api/atlas/v2/operations'], + severity: DiagnosticSeverity.Error, + }, + { + code: 'xgen-IPA-132-operations-endpoint-must-not-be-global', + message: ERROR_MESSAGE, + path: ['paths', '/api/atlas/v2/operations/{operationId}'], + severity: DiagnosticSeverity.Error, + }, + { + code: 'xgen-IPA-132-operations-endpoint-must-not-be-global', + message: ERROR_MESSAGE, + path: ['paths', '/api/atlas/v2/unauth/operations'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + { + name: 'invalid root-level Operations endpoints with exceptions', + document: { + paths: { + '/api/atlas/v2/operations': { + 'x-xgen-IPA-exception': { + 'xgen-IPA-132-operations-endpoint-must-not-be-global': 'reason', + }, + }, + }, + }, + errors: [], + }, + { + name: 'nested Operations endpoints do not need an exception', + document: { + paths: { + '/api/atlas/v2/resourceName/operations': { + 'x-xgen-IPA-exception': { + 'xgen-IPA-132-operations-endpoint-must-not-be-global': 'reason', + }, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operations-endpoint-must-not-be-global', + message: 'This component adopts the rule and does not need an exception. Please remove the exception.', + path: [ + 'paths', + '/api/atlas/v2/resourceName/operations', + 'x-xgen-IPA-exception', + 'xgen-IPA-132-operations-endpoint-must-not-be-global', + ], + severity: DiagnosticSeverity.Error, + }, + ], + }, +]); diff --git a/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js b/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js new file mode 100644 index 0000000000..72b0e1c7dd --- /dev/null +++ b/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js @@ -0,0 +1,87 @@ +import { describe, expect, it } from '@jest/globals'; +import { + containsOperationsSegment, + isOperationsCollectionPath, + isOperationsPath, + isRootLevelOperationsPath, + isSingleOperationPath, +} from '../../rulesets/functions/utils/longRunningOperations'; + +describe('tools/spectral/ipa/utils/longRunningOperations.js', () => { + describe('isOperationsCollectionPath', () => { + it('recognizes Operations resource collections', () => { + expect(isOperationsCollectionPath('/api/atlas/v2/groups/{groupId}/clusters/operations')).toBe(true); + expect(isOperationsCollectionPath('/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/operations')).toBe(true); + expect(isOperationsCollectionPath('/api/atlas/v2/unauth/resourceName/operations')).toBe(true); + expect(isOperationsCollectionPath('/api/atlas/v2/operations')).toBe(true); + }); + + it('ignores custom method suffixes', () => { + expect(isOperationsCollectionPath('/api/atlas/v2/resourceName/operations:customMethod')).toBe(true); + }); + + it('rejects other paths', () => { + expect(isOperationsCollectionPath('/api/atlas/v2/resourceName/operations/{operationId}')).toBe(false); + expect(isOperationsCollectionPath('/api/atlas/v2/resourceName')).toBe(false); + expect(isOperationsCollectionPath('/api/atlas/v2/resourceName/operations/subresource')).toBe(false); + }); + }); + + describe('isSingleOperationPath', () => { + it('recognizes single Operations resources', () => { + expect(isSingleOperationPath('/api/atlas/v2/groups/{groupId}/clusters/operations/{operationId}')).toBe(true); + expect( + isSingleOperationPath('/api/atlas/v2/groups/{groupId}/clusters/{clusterName}/operations/{operationId}') + ).toBe(true); + expect(isSingleOperationPath('/api/atlas/v2/operations/{operationId}')).toBe(true); + }); + + it('ignores custom method suffixes', () => { + expect(isSingleOperationPath('/api/atlas/v2/resourceName/operations/{operationId}:cancel')).toBe(true); + }); + + it('rejects other paths', () => { + expect(isSingleOperationPath('/api/atlas/v2/resourceName/operations')).toBe(false); + expect(isSingleOperationPath('/api/atlas/v2/resourceName/{pathParam}')).toBe(false); + expect(isSingleOperationPath('/api/atlas/v2/resourceName/operations/{operationId}/subresource')).toBe(false); + }); + }); + + describe('isOperationsPath', () => { + it('recognizes both Operations resource forms', () => { + expect(isOperationsPath('/api/atlas/v2/resourceName/operations')).toBe(true); + expect(isOperationsPath('/api/atlas/v2/resourceName/operations/{operationId}')).toBe(true); + }); + + it('rejects paths nested below an Operations resource', () => { + expect(isOperationsPath('/api/atlas/v2/resourceName/operations/subresource')).toBe(false); + expect(isOperationsPath('/api/atlas/v2/resourceName/operations/{operationId}/subresource')).toBe(false); + }); + }); + + describe('containsOperationsSegment', () => { + it('finds operations segments at any position', () => { + expect(containsOperationsSegment('/api/atlas/v2/resourceName/operations')).toBe(true); + expect(containsOperationsSegment('/api/atlas/v2/resourceName/operations/{operationId}/subresource')).toBe(true); + expect(containsOperationsSegment('/api/atlas/v2/operations/subresource')).toBe(true); + }); + + it('rejects paths without an operations segment', () => { + expect(containsOperationsSegment('/api/atlas/v2/resourceName')).toBe(false); + expect(containsOperationsSegment('/api/atlas/v2/resourceName/{operationId}')).toBe(false); + }); + }); + + describe('isRootLevelOperationsPath', () => { + it('recognizes Operations resources mounted at the API root', () => { + expect(isRootLevelOperationsPath('/api/atlas/v2/operations')).toBe(true); + expect(isRootLevelOperationsPath('/api/atlas/v2/operations/{operationId}')).toBe(true); + expect(isRootLevelOperationsPath('/api/atlas/v2/unauth/operations')).toBe(true); + }); + + it('rejects nested Operations resources', () => { + expect(isRootLevelOperationsPath('/api/atlas/v2/resourceName/operations')).toBe(false); + expect(isRootLevelOperationsPath('/api/atlas/v2/resourceName/{pathParam}/operations/{operationId}')).toBe(false); + }); + }); +}); diff --git a/tools/spectral/ipa/ipa-spectral.yaml b/tools/spectral/ipa/ipa-spectral.yaml index 68b72b3d1c..e15fff8b6f 100644 --- a/tools/spectral/ipa/ipa-spectral.yaml +++ b/tools/spectral/ipa/ipa-spectral.yaml @@ -19,6 +19,7 @@ extends: - ./rulesets/IPA-124.yaml - ./rulesets/IPA-125.yaml - ./rulesets/IPA-126.yaml + - ./rulesets/IPA-132.yaml overrides: - files: diff --git a/tools/spectral/ipa/rulesets/IPA-132.yaml b/tools/spectral/ipa/rulesets/IPA-132.yaml new file mode 100644 index 0000000000..1e1089f351 --- /dev/null +++ b/tools/spectral/ipa/rulesets/IPA-132.yaml @@ -0,0 +1,75 @@ +# IPA-132: Long-Running Operations +# https://mongodb.github.io/ipa/132 + +functions: + - IPA132OperationsEndpointMustNotBeGlobal + - IPA132OperationMustBeAReadOnlyResource + - IPA132OperationsEndpointMustBeALeafResource + +rules: + xgen-IPA-132-operations-endpoint-must-not-be-global: + description: | + Operations endpoints must not be defined as standalone, global endpoints with no parent + resource in their path. + + ##### Implementation details + Rule checks for the following conditions: + + - Applies to Operations endpoints, i.e. paths ending with an `operations` segment or an + `operations/{operationId}` suffix + - The `operations` segment must be preceded by at least one parent resource segment + - A root-level Operations endpoint, such as `/api/atlas/v2/operations` or + `/api/atlas/v2/unauth/operations`, is a violation + - Custom method suffixes (`:customMethod`) are ignored when identifying Operations endpoints + - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation + + message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-132-operations-endpoint-must-not-be-global' + severity: error + given: $.paths + then: + field: '@key' + function: IPA132OperationsEndpointMustNotBeGlobal + + xgen-IPA-132-operation-must-be-a-read-only-resource: + description: | + Operations endpoints are read-only and may only define the get method. + + ##### Implementation details + Rule checks for the following conditions: + + - Applies to Operations endpoints, i.e. paths ending with an `operations` segment or an + `operations/{operationId}` suffix + - The path item must not define `post`, `put`, `patch` or `delete` methods + - Custom method paths attached to an Operations endpoint, such as + `/resource/operations/{operationId}:cancel`, share the Operations resource identifier and + must not define mutating methods either + - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation + + message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-132-operation-must-be-a-read-only-resource' + severity: error + given: $.paths + then: + field: '@key' + function: IPA132OperationMustBeAReadOnlyResource + + xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource: + description: | + Operations endpoints must be leaf resources, with no resources nested below them. + + ##### Implementation details + Rule checks for the following conditions: + + - Applies to paths containing an `operations` segment + - An `operations` segment may only be followed by a single operation identifier path + parameter, e.g. `.../operations` and `.../operations/{operationId}` are valid + - Any further nesting, such as `.../operations/subresource` or + `.../operations/{operationId}/subresource`, is a violation + - Custom method suffixes (`:customMethod`) are ignored when identifying Operations endpoints + - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation + + message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource' + severity: error + given: $.paths + then: + field: '@key' + function: IPA132OperationsEndpointMustBeALeafResource diff --git a/tools/spectral/ipa/rulesets/README.md b/tools/spectral/ipa/rulesets/README.md index a4e0fcf4f0..1602fbf929 100644 --- a/tools/spectral/ipa/rulesets/README.md +++ b/tools/spectral/ipa/rulesets/README.md @@ -1406,4 +1406,59 @@ This rule includes two configuration options: +### IPA-132 + +Rules are based on [https://mongodb.github.io/ipa/132](https://mongodb.github.io/ipa/132). + +#### xgen-IPA-132-operations-endpoint-must-not-be-global + + ![error](https://img.shields.io/badge/error-red) +Operations endpoints must not be defined as standalone, global endpoints with no parent +resource in their path. + +##### Implementation details +Rule checks for the following conditions: + + - Applies to Operations endpoints, i.e. paths ending with an `operations` segment or an + `operations/{operationId}` suffix + - The `operations` segment must be preceded by at least one parent resource segment + - A root-level Operations endpoint, such as `/api/atlas/v2/operations` or + `/api/atlas/v2/unauth/operations`, is a violation + - Custom method suffixes (`:customMethod`) are ignored when identifying Operations endpoints + - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation + +#### xgen-IPA-132-operation-must-be-a-read-only-resource + + ![error](https://img.shields.io/badge/error-red) +Operations endpoints are read-only and may only define the get method. + +##### Implementation details +Rule checks for the following conditions: + + - Applies to Operations endpoints, i.e. paths ending with an `operations` segment or an + `operations/{operationId}` suffix + - The path item must not define `post`, `put`, `patch` or `delete` methods + - Custom method paths attached to an Operations endpoint, such as + `/resource/operations/{operationId}:cancel`, share the Operations resource identifier and + must not define mutating methods either + - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation + +#### xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource + + ![error](https://img.shields.io/badge/error-red) +Operations endpoints must be leaf resources, with no resources nested below them. + +##### Implementation details +Rule checks for the following conditions: + + - Applies to paths containing an `operations` segment + - An `operations` segment may only be followed by a single operation identifier path + parameter, e.g. `.../operations` and `.../operations/{operationId}` are valid + - Any further nesting, such as `.../operations/subresource` or + `.../operations/{operationId}/subresource`, is a violation + - Custom method suffixes (`:customMethod`) are ignored when identifying Operations endpoints + - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation + + + diff --git a/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js b/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js new file mode 100644 index 0000000000..846d9069ee --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js @@ -0,0 +1,43 @@ +import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; +import { isOperationsPath } from './utils/longRunningOperations.js'; + +const FORBIDDEN_METHODS = ['post', 'put', 'patch', 'delete']; + +/** + * Checks that an Operations endpoint defined by IPA-132 is a read-only resource, i.e. its path + * items define no mutating HTTP methods. This includes custom methods attached to an Operations + * endpoint, since they share the Operations resource identifier. + * + * @param {string} input - The path key from the OpenAPI spec + * @param {object} _ - Unused + * @param {object} context - The context object containing the path, documentInventory and rule + */ +export default (input, _, { path, documentInventory, rule }) => { + const ruleName = rule.name; + const oas = documentInventory.resolved; + + if (!isOperationsPath(input)) { + return; + } + + const pathItem = oas.paths[input]; + const errors = checkViolationsAndReturnErrors(pathItem, path, ruleName); + return evaluateAndCollectAdoptionStatus(errors, ruleName, pathItem, path); +}; + +function checkViolationsAndReturnErrors(pathItem, path, ruleName) { + try { + const errors = []; + for (const method of FORBIDDEN_METHODS) { + if (pathItem[method]) { + errors.push({ + path: [...path, method], + message: `Operations endpoints are read-only and do not allow the ${method} method.`, + }); + } + } + return errors; + } catch (e) { + return handleInternalError(ruleName, path, e); + } +} diff --git a/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustBeALeafResource.js b/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustBeALeafResource.js new file mode 100644 index 0000000000..6ade921b27 --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustBeALeafResource.js @@ -0,0 +1,36 @@ +import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; +import { containsOperationsSegment, isOperationsPath } from './utils/longRunningOperations.js'; + +const ERROR_MESSAGE = + 'Operations endpoints must be leaf resources. An `operations` segment may only be followed by a single operation identifier path parameter.'; + +/** + * Checks that an Operations resource defined by IPA-132 is a leaf resource, i.e. nothing is nested + * below `operations` other than a single `{operationId}` path parameter. + * + * @param {string} input - The path key from the OpenAPI spec + * @param {object} _ - Unused + * @param {object} context - The context object containing the path, documentInventory and rule + */ +export default (input, _, { path, documentInventory, rule }) => { + const ruleName = rule.name; + const oas = documentInventory.resolved; + + if (!containsOperationsSegment(input)) { + return; + } + + const errors = checkViolationsAndReturnErrors(input, path, ruleName); + return evaluateAndCollectAdoptionStatus(errors, ruleName, oas.paths[input], path); +}; + +function checkViolationsAndReturnErrors(input, path, ruleName) { + try { + if (!isOperationsPath(input)) { + return [{ path, message: ERROR_MESSAGE }]; + } + return []; + } catch (e) { + return handleInternalError(ruleName, path, e); + } +} diff --git a/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustNotBeGlobal.js b/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustNotBeGlobal.js new file mode 100644 index 0000000000..41aec96ebe --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustNotBeGlobal.js @@ -0,0 +1,36 @@ +import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; +import { isOperationsPath, isRootLevelOperationsPath } from './utils/longRunningOperations.js'; + +const ERROR_MESSAGE = + 'Operations endpoints must not be standalone, global endpoints with no parent resource in their path.'; + +/** + * Checks that an Operations endpoint defined by IPA-132 is not a standalone, global endpoint, + * rejecting Operations endpoints mounted at the API root such as `/api/atlas/v2/operations`. + * + * @param {string} input - The path key from the OpenAPI spec + * @param {object} _ - Unused + * @param {object} context - The context object containing the path, documentInventory and rule + */ +export default (input, _, { path, documentInventory, rule }) => { + const ruleName = rule.name; + const oas = documentInventory.resolved; + + if (!isOperationsPath(input)) { + return; + } + + const errors = checkViolationsAndReturnErrors(input, path, ruleName); + return evaluateAndCollectAdoptionStatus(errors, ruleName, oas.paths[input], path); +}; + +function checkViolationsAndReturnErrors(input, path, ruleName) { + try { + if (isRootLevelOperationsPath(input)) { + return [{ path, message: ERROR_MESSAGE }]; + } + return []; + } catch (e) { + return handleInternalError(ruleName, path, e); + } +} diff --git a/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js b/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js new file mode 100644 index 0000000000..24e45ee6c6 --- /dev/null +++ b/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js @@ -0,0 +1,89 @@ +import { isPathParam } from './componentUtils.js'; +import { isCustomMethodIdentifier, removePrefix, stripCustomMethodName } from './resourceEvaluation.js'; + +export const OPERATIONS_SEGMENT = 'operations'; + +/** + * Splits a path into its resource identifier segments, ignoring the standard path prefix and any + * custom method suffix (`:customMethod`), so that the segments describe the resource hierarchy only. + * + * @param {string} path the path to split + * @returns {string[]} the resource identifier segments + */ +function toResourceSegments(path) { + const pathWithoutCustomMethod = isCustomMethodIdentifier(path) ? stripCustomMethodName(path) : path; + return removePrefix(pathWithoutCustomMethod) + .split('/') + .filter((segment) => segment.length > 0); +} + +/** + * Checks if a path identifies an Operations resource collection defined by IPA-132, i.e. the path + * ends with an `operations` segment. Any custom method suffix is ignored. For example: + * '/api/atlas/v2/resourceName/operations' returns true + * '/api/atlas/v2/resourceName/{pathParam}/operations' returns true + * '/api/atlas/v2/operations' returns true + * '/api/atlas/v2/resourceName/operations/{operationId}' returns false + * + * @param {string} path the path to evaluate + * @returns {boolean} true if the path identifies an Operations resource collection + */ +export function isOperationsCollectionPath(path) { + const segments = toResourceSegments(path); + return segments.length > 0 && segments[segments.length - 1] === OPERATIONS_SEGMENT; +} + +/** + * Checks if a path identifies a single Operations resource defined by IPA-132, i.e. the path ends + * with an `operations` segment followed by a single path parameter. Any custom method suffix is + * ignored. For example: + * '/api/atlas/v2/resourceName/operations/{operationId}' returns true + * '/api/atlas/v2/resourceName/{pathParam}/operations/{operationId}' returns true + * '/api/atlas/v2/resourceName/operations' returns false + * + * @param {string} path the path to evaluate + * @returns {boolean} true if the path identifies a single Operations resource + */ +export function isSingleOperationPath(path) { + const segments = toResourceSegments(path); + return ( + segments.length > 1 && + segments[segments.length - 2] === OPERATIONS_SEGMENT && + isPathParam(segments[segments.length - 1]) + ); +} + +/** + * Checks if a path identifies an Operations resource defined by IPA-132, either the Operations + * resource collection or a single Operations resource. + * + * @param {string} path the path to evaluate + * @returns {boolean} true if the path identifies an Operations resource + */ +export function isOperationsPath(path) { + return isOperationsCollectionPath(path) || isSingleOperationPath(path); +} + +/** + * Checks if any resource identifier segment of a path is an `operations` segment, regardless of + * its position in the path. + * + * @param {string} path the path to evaluate + * @returns {boolean} true if the path contains an `operations` segment + */ +export function containsOperationsSegment(path) { + return toResourceSegments(path).includes(OPERATIONS_SEGMENT); +} + +/** + * Checks if a path identifies an Operations resource mounted at the API root, i.e. an `operations` + * segment with no parent resource, such as '/api/atlas/v2/operations' or + * '/api/atlas/v2/operations/{operationId}'. IPA-132 requires Operations resources to be nested + * under the parent resource that spawned the operation. + * + * @param {string} path the path to evaluate + * @returns {boolean} true if the path identifies a root-level Operations resource + */ +export function isRootLevelOperationsPath(path) { + return isOperationsPath(path) && toResourceSegments(path)[0] === OPERATIONS_SEGMENT; +} From 9eb5a08cacd6a54c7f0bdf3e3ea07ad44ec8b572 Mon Sep 17 00:00:00 2001 From: Julius Jogela Date: Thu, 13 Aug 2026 15:22:14 +0100 Subject: [PATCH 2/7] fix(ipa): address IPA-132 review feedback - Set all three rules to warn severity while IPA-132 is experimental - Remove custom method handling from the Operations path predicates, rule descriptions and tests; a dedicated guideline and rule forbidding custom methods on Operations endpoints will follow separately - Check path item methods by key presence rather than truthiness so a declared but empty method (e.g. "post:") is still flagged by the read-only rule --- ...32OperationMustBeAReadOnlyResource.test.js | 21 ++++++++++--------- ...rationsEndpointMustBeALeafResource.test.js | 11 ++++------ ...2OperationsEndpointMustNotBeGlobal.test.js | 9 ++++---- .../utils/longRunningOperations.test.js | 12 ++++------- tools/spectral/ipa/rulesets/IPA-132.yaml | 11 +++------- tools/spectral/ipa/rulesets/README.md | 11 +++------- .../IPA132OperationMustBeAReadOnlyResource.js | 6 +++--- ...IPA132OperationsEndpointMustNotBeGlobal.js | 4 ++++ .../functions/utils/longRunningOperations.js | 14 ++++++------- 9 files changed, 42 insertions(+), 57 deletions(-) diff --git a/tools/spectral/ipa/__tests__/IPA132OperationMustBeAReadOnlyResource.test.js b/tools/spectral/ipa/__tests__/IPA132OperationMustBeAReadOnlyResource.test.js index fd419bbad6..5b49a8bfc9 100644 --- a/tools/spectral/ipa/__tests__/IPA132OperationMustBeAReadOnlyResource.test.js +++ b/tools/spectral/ipa/__tests__/IPA132OperationMustBeAReadOnlyResource.test.js @@ -59,34 +59,35 @@ testRule('xgen-IPA-132-operation-must-be-a-read-only-resource', [ code: 'xgen-IPA-132-operation-must-be-a-read-only-resource', message: 'Operations endpoints are read-only and do not allow the post method.', path: ['paths', '/api/atlas/v2/resourceName/operations', 'post'], - severity: DiagnosticSeverity.Error, + severity: DiagnosticSeverity.Warning, }, { code: 'xgen-IPA-132-operation-must-be-a-read-only-resource', message: 'Operations endpoints are read-only and do not allow the put method.', path: ['paths', '/api/atlas/v2/resourceName/operations/{operationId}', 'put'], - severity: DiagnosticSeverity.Error, + severity: DiagnosticSeverity.Warning, }, { code: 'xgen-IPA-132-operation-must-be-a-read-only-resource', message: 'Operations endpoints are read-only and do not allow the patch method.', path: ['paths', '/api/atlas/v2/resourceName/operations/{operationId}', 'patch'], - severity: DiagnosticSeverity.Error, + severity: DiagnosticSeverity.Warning, }, { code: 'xgen-IPA-132-operation-must-be-a-read-only-resource', message: 'Operations endpoints are read-only and do not allow the delete method.', path: ['paths', '/api/atlas/v2/resourceName/operations/{operationId}', 'delete'], - severity: DiagnosticSeverity.Error, + severity: DiagnosticSeverity.Warning, }, ], }, { - name: 'invalid custom method attached to an Operations endpoint', + name: 'invalid Operations endpoint with a declared but empty method', document: { paths: { - '/api/atlas/v2/resourceName/operations/{operationId}:cancel': { - post: {}, + '/api/atlas/v2/resourceName/operations': { + get: {}, + post: null, }, }, }, @@ -94,8 +95,8 @@ testRule('xgen-IPA-132-operation-must-be-a-read-only-resource', [ { code: 'xgen-IPA-132-operation-must-be-a-read-only-resource', message: 'Operations endpoints are read-only and do not allow the post method.', - path: ['paths', '/api/atlas/v2/resourceName/operations/{operationId}:cancel', 'post'], - severity: DiagnosticSeverity.Error, + path: ['paths', '/api/atlas/v2/resourceName/operations', 'post'], + severity: DiagnosticSeverity.Warning, }, ], }, @@ -136,7 +137,7 @@ testRule('xgen-IPA-132-operation-must-be-a-read-only-resource', [ 'x-xgen-IPA-exception', 'xgen-IPA-132-operation-must-be-a-read-only-resource', ], - severity: DiagnosticSeverity.Error, + severity: DiagnosticSeverity.Warning, }, ], }, diff --git a/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustBeALeafResource.test.js b/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustBeALeafResource.test.js index 1b0e8fc1e7..3777d6188d 100644 --- a/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustBeALeafResource.test.js +++ b/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustBeALeafResource.test.js @@ -16,9 +16,6 @@ testRule('xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource', [ // Root-level Operations endpoints are leaves, their nesting is covered by // xgen-IPA-132-operations-endpoint-must-not-be-global '/api/atlas/v2/operations': {}, - // Custom method suffixes are ignored, their methods are covered by - // xgen-IPA-132-operation-must-be-a-read-only-resource - '/api/atlas/v2/resourceName/operations/{operationId}:cancel': {}, }, }, errors: [], @@ -48,19 +45,19 @@ testRule('xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource', [ code: 'xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource', message: ERROR_MESSAGE, path: ['paths', '/api/atlas/v2/resourceName/operations/subresource'], - severity: DiagnosticSeverity.Error, + severity: DiagnosticSeverity.Warning, }, { code: 'xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource', message: ERROR_MESSAGE, path: ['paths', '/api/atlas/v2/resourceName/operations/{operationId}/subresource'], - severity: DiagnosticSeverity.Error, + severity: DiagnosticSeverity.Warning, }, { code: 'xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource', message: ERROR_MESSAGE, path: ['paths', '/api/atlas/v2/resourceName/operations/{operationId}/{anotherId}'], - severity: DiagnosticSeverity.Error, + severity: DiagnosticSeverity.Warning, }, ], }, @@ -98,7 +95,7 @@ testRule('xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource', [ 'x-xgen-IPA-exception', 'xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource', ], - severity: DiagnosticSeverity.Error, + severity: DiagnosticSeverity.Warning, }, ], }, diff --git a/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustNotBeGlobal.test.js b/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustNotBeGlobal.test.js index afd5e0e69f..289428294e 100644 --- a/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustNotBeGlobal.test.js +++ b/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustNotBeGlobal.test.js @@ -29,7 +29,6 @@ testRule('xgen-IPA-132-operations-endpoint-must-not-be-global', [ paths: { '/api/atlas/v2/resourceName': {}, '/api/atlas/v2/resourceName/{pathParam}': {}, - '/api/atlas/v2/resourceName/{pathParam}:customMethod': {}, // Not a well-formed Operations endpoint, covered by xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource '/api/atlas/v2/resourceName/operations/subresource': {}, }, @@ -50,19 +49,19 @@ testRule('xgen-IPA-132-operations-endpoint-must-not-be-global', [ code: 'xgen-IPA-132-operations-endpoint-must-not-be-global', message: ERROR_MESSAGE, path: ['paths', '/api/atlas/v2/operations'], - severity: DiagnosticSeverity.Error, + severity: DiagnosticSeverity.Warning, }, { code: 'xgen-IPA-132-operations-endpoint-must-not-be-global', message: ERROR_MESSAGE, path: ['paths', '/api/atlas/v2/operations/{operationId}'], - severity: DiagnosticSeverity.Error, + severity: DiagnosticSeverity.Warning, }, { code: 'xgen-IPA-132-operations-endpoint-must-not-be-global', message: ERROR_MESSAGE, path: ['paths', '/api/atlas/v2/unauth/operations'], - severity: DiagnosticSeverity.Error, + severity: DiagnosticSeverity.Warning, }, ], }, @@ -100,7 +99,7 @@ testRule('xgen-IPA-132-operations-endpoint-must-not-be-global', [ 'x-xgen-IPA-exception', 'xgen-IPA-132-operations-endpoint-must-not-be-global', ], - severity: DiagnosticSeverity.Error, + severity: DiagnosticSeverity.Warning, }, ], }, diff --git a/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js b/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js index 72b0e1c7dd..261d1451b7 100644 --- a/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js +++ b/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js @@ -16,10 +16,6 @@ describe('tools/spectral/ipa/utils/longRunningOperations.js', () => { expect(isOperationsCollectionPath('/api/atlas/v2/operations')).toBe(true); }); - it('ignores custom method suffixes', () => { - expect(isOperationsCollectionPath('/api/atlas/v2/resourceName/operations:customMethod')).toBe(true); - }); - it('rejects other paths', () => { expect(isOperationsCollectionPath('/api/atlas/v2/resourceName/operations/{operationId}')).toBe(false); expect(isOperationsCollectionPath('/api/atlas/v2/resourceName')).toBe(false); @@ -36,10 +32,6 @@ describe('tools/spectral/ipa/utils/longRunningOperations.js', () => { expect(isSingleOperationPath('/api/atlas/v2/operations/{operationId}')).toBe(true); }); - it('ignores custom method suffixes', () => { - expect(isSingleOperationPath('/api/atlas/v2/resourceName/operations/{operationId}:cancel')).toBe(true); - }); - it('rejects other paths', () => { expect(isSingleOperationPath('/api/atlas/v2/resourceName/operations')).toBe(false); expect(isSingleOperationPath('/api/atlas/v2/resourceName/{pathParam}')).toBe(false); @@ -83,5 +75,9 @@ describe('tools/spectral/ipa/utils/longRunningOperations.js', () => { expect(isRootLevelOperationsPath('/api/atlas/v2/resourceName/operations')).toBe(false); expect(isRootLevelOperationsPath('/api/atlas/v2/resourceName/{pathParam}/operations/{operationId}')).toBe(false); }); + + it('rejects paths nested below a root-level operations segment', () => { + expect(isRootLevelOperationsPath('/api/atlas/v2/operations/subresource')).toBe(false); + }); }); }); diff --git a/tools/spectral/ipa/rulesets/IPA-132.yaml b/tools/spectral/ipa/rulesets/IPA-132.yaml index 1e1089f351..83a5416bc3 100644 --- a/tools/spectral/ipa/rulesets/IPA-132.yaml +++ b/tools/spectral/ipa/rulesets/IPA-132.yaml @@ -20,11 +20,10 @@ rules: - The `operations` segment must be preceded by at least one parent resource segment - A root-level Operations endpoint, such as `/api/atlas/v2/operations` or `/api/atlas/v2/unauth/operations`, is a violation - - Custom method suffixes (`:customMethod`) are ignored when identifying Operations endpoints - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-132-operations-endpoint-must-not-be-global' - severity: error + severity: warn given: $.paths then: field: '@key' @@ -40,13 +39,10 @@ rules: - Applies to Operations endpoints, i.e. paths ending with an `operations` segment or an `operations/{operationId}` suffix - The path item must not define `post`, `put`, `patch` or `delete` methods - - Custom method paths attached to an Operations endpoint, such as - `/resource/operations/{operationId}:cancel`, share the Operations resource identifier and - must not define mutating methods either - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-132-operation-must-be-a-read-only-resource' - severity: error + severity: warn given: $.paths then: field: '@key' @@ -64,11 +60,10 @@ rules: parameter, e.g. `.../operations` and `.../operations/{operationId}` are valid - Any further nesting, such as `.../operations/subresource` or `.../operations/{operationId}/subresource`, is a violation - - Custom method suffixes (`:customMethod`) are ignored when identifying Operations endpoints - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource' - severity: error + severity: warn given: $.paths then: field: '@key' diff --git a/tools/spectral/ipa/rulesets/README.md b/tools/spectral/ipa/rulesets/README.md index 1602fbf929..ca92221ef2 100644 --- a/tools/spectral/ipa/rulesets/README.md +++ b/tools/spectral/ipa/rulesets/README.md @@ -1412,7 +1412,7 @@ Rules are based on [https://mongodb.github.io/ipa/132](https://mongodb.github.io #### xgen-IPA-132-operations-endpoint-must-not-be-global - ![error](https://img.shields.io/badge/error-red) + ![warn](https://img.shields.io/badge/warning-yellow) Operations endpoints must not be defined as standalone, global endpoints with no parent resource in their path. @@ -1424,12 +1424,11 @@ Rule checks for the following conditions: - The `operations` segment must be preceded by at least one parent resource segment - A root-level Operations endpoint, such as `/api/atlas/v2/operations` or `/api/atlas/v2/unauth/operations`, is a violation - - Custom method suffixes (`:customMethod`) are ignored when identifying Operations endpoints - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation #### xgen-IPA-132-operation-must-be-a-read-only-resource - ![error](https://img.shields.io/badge/error-red) + ![warn](https://img.shields.io/badge/warning-yellow) Operations endpoints are read-only and may only define the get method. ##### Implementation details @@ -1438,14 +1437,11 @@ Rule checks for the following conditions: - Applies to Operations endpoints, i.e. paths ending with an `operations` segment or an `operations/{operationId}` suffix - The path item must not define `post`, `put`, `patch` or `delete` methods - - Custom method paths attached to an Operations endpoint, such as - `/resource/operations/{operationId}:cancel`, share the Operations resource identifier and - must not define mutating methods either - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation #### xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource - ![error](https://img.shields.io/badge/error-red) + ![warn](https://img.shields.io/badge/warning-yellow) Operations endpoints must be leaf resources, with no resources nested below them. ##### Implementation details @@ -1456,7 +1452,6 @@ Rule checks for the following conditions: parameter, e.g. `.../operations` and `.../operations/{operationId}` are valid - Any further nesting, such as `.../operations/subresource` or `.../operations/{operationId}/subresource`, is a violation - - Custom method suffixes (`:customMethod`) are ignored when identifying Operations endpoints - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation diff --git a/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js b/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js index 846d9069ee..d0f92cfbb0 100644 --- a/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js +++ b/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js @@ -5,8 +5,7 @@ const FORBIDDEN_METHODS = ['post', 'put', 'patch', 'delete']; /** * Checks that an Operations endpoint defined by IPA-132 is a read-only resource, i.e. its path - * items define no mutating HTTP methods. This includes custom methods attached to an Operations - * endpoint, since they share the Operations resource identifier. + * items define no mutating HTTP methods. * * @param {string} input - The path key from the OpenAPI spec * @param {object} _ - Unused @@ -29,7 +28,8 @@ function checkViolationsAndReturnErrors(pathItem, path, ruleName) { try { const errors = []; for (const method of FORBIDDEN_METHODS) { - if (pathItem[method]) { + // Key presence, not truthiness - a declared method with a null/empty value still advertises the route + if (method in pathItem) { errors.push({ path: [...path, method], message: `Operations endpoints are read-only and do not allow the ${method} method.`, diff --git a/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustNotBeGlobal.js b/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustNotBeGlobal.js index 41aec96ebe..92153ebaed 100644 --- a/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustNotBeGlobal.js +++ b/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustNotBeGlobal.js @@ -8,6 +8,10 @@ const ERROR_MESSAGE = * Checks that an Operations endpoint defined by IPA-132 is not a standalone, global endpoint, * rejecting Operations endpoints mounted at the API root such as `/api/atlas/v2/operations`. * + * The existing `resourceBelongsToSingleParent` util is not reusable here: it requires the + * grandparent segment to be a path parameter, which rejects the collection-scoped Operations + * form `/parent/operations` that IPA-132 explicitly allows. + * * @param {string} input - The path key from the OpenAPI spec * @param {object} _ - Unused * @param {object} context - The context object containing the path, documentInventory and rule diff --git a/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js b/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js index 24e45ee6c6..a834387923 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js +++ b/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js @@ -1,25 +1,24 @@ import { isPathParam } from './componentUtils.js'; -import { isCustomMethodIdentifier, removePrefix, stripCustomMethodName } from './resourceEvaluation.js'; +import { removePrefix } from './resourceEvaluation.js'; export const OPERATIONS_SEGMENT = 'operations'; /** - * Splits a path into its resource identifier segments, ignoring the standard path prefix and any - * custom method suffix (`:customMethod`), so that the segments describe the resource hierarchy only. + * Splits a path into its resource identifier segments, ignoring the standard path prefix, so that + * the segments describe the resource hierarchy only. * * @param {string} path the path to split * @returns {string[]} the resource identifier segments */ function toResourceSegments(path) { - const pathWithoutCustomMethod = isCustomMethodIdentifier(path) ? stripCustomMethodName(path) : path; - return removePrefix(pathWithoutCustomMethod) + return removePrefix(path) .split('/') .filter((segment) => segment.length > 0); } /** * Checks if a path identifies an Operations resource collection defined by IPA-132, i.e. the path - * ends with an `operations` segment. Any custom method suffix is ignored. For example: + * ends with an `operations` segment. For example: * '/api/atlas/v2/resourceName/operations' returns true * '/api/atlas/v2/resourceName/{pathParam}/operations' returns true * '/api/atlas/v2/operations' returns true @@ -35,8 +34,7 @@ export function isOperationsCollectionPath(path) { /** * Checks if a path identifies a single Operations resource defined by IPA-132, i.e. the path ends - * with an `operations` segment followed by a single path parameter. Any custom method suffix is - * ignored. For example: + * with an `operations` segment followed by a single path parameter. For example: * '/api/atlas/v2/resourceName/operations/{operationId}' returns true * '/api/atlas/v2/resourceName/{pathParam}/operations/{operationId}' returns true * '/api/atlas/v2/resourceName/operations' returns false From 27fa639fb97667090bf7f18e80f435125cc4bf5e Mon Sep 17 00:00:00 2001 From: Julius Jogela Date: Thu, 13 Aug 2026 15:29:12 +0100 Subject: [PATCH 3/7] fix(ipa): keep custom method paths out of IPA-132 scope Custom method paths yield no resource segments in the IPA-132 predicates, so none of the Operations rules evaluate them: flagging a post on :cancel would contradict the IPA-109 requirement that custom methods use POST, and a dedicated rule forbidding custom methods on Operations endpoints will own that check. Also align the read-only rule statement with the methods it actually flags. --- .../ipa/__tests__/utils/longRunningOperations.test.js | 6 ++++++ tools/spectral/ipa/rulesets/IPA-132.yaml | 3 ++- tools/spectral/ipa/rulesets/README.md | 3 ++- .../rulesets/functions/utils/longRunningOperations.js | 10 +++++++++- 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js b/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js index 261d1451b7..a33b329826 100644 --- a/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js +++ b/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js @@ -45,6 +45,12 @@ describe('tools/spectral/ipa/utils/longRunningOperations.js', () => { expect(isOperationsPath('/api/atlas/v2/resourceName/operations/{operationId}')).toBe(true); }); + it('ignores custom method paths, which are covered by a dedicated rule', () => { + expect(isOperationsPath('/api/atlas/v2/resourceName/operations:customMethod')).toBe(false); + expect(isOperationsPath('/api/atlas/v2/resourceName/operations/{operationId}:cancel')).toBe(false); + expect(containsOperationsSegment('/api/atlas/v2/resourceName/operations/{operationId}:cancel')).toBe(false); + }); + it('rejects paths nested below an Operations resource', () => { expect(isOperationsPath('/api/atlas/v2/resourceName/operations/subresource')).toBe(false); expect(isOperationsPath('/api/atlas/v2/resourceName/operations/{operationId}/subresource')).toBe(false); diff --git a/tools/spectral/ipa/rulesets/IPA-132.yaml b/tools/spectral/ipa/rulesets/IPA-132.yaml index 83a5416bc3..60efc9de49 100644 --- a/tools/spectral/ipa/rulesets/IPA-132.yaml +++ b/tools/spectral/ipa/rulesets/IPA-132.yaml @@ -31,7 +31,8 @@ rules: xgen-IPA-132-operation-must-be-a-read-only-resource: description: | - Operations endpoints are read-only and may only define the get method. + Operations endpoints are read-only and must not define the mutating methods `post`, `put`, + `patch` or `delete`. ##### Implementation details Rule checks for the following conditions: diff --git a/tools/spectral/ipa/rulesets/README.md b/tools/spectral/ipa/rulesets/README.md index ca92221ef2..5bf5b867b9 100644 --- a/tools/spectral/ipa/rulesets/README.md +++ b/tools/spectral/ipa/rulesets/README.md @@ -1429,7 +1429,8 @@ Rule checks for the following conditions: #### xgen-IPA-132-operation-must-be-a-read-only-resource ![warn](https://img.shields.io/badge/warning-yellow) -Operations endpoints are read-only and may only define the get method. +Operations endpoints are read-only and must not define the mutating methods `post`, `put`, +`patch` or `delete`. ##### Implementation details Rule checks for the following conditions: diff --git a/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js b/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js index a834387923..64762f0597 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js +++ b/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js @@ -1,5 +1,5 @@ import { isPathParam } from './componentUtils.js'; -import { removePrefix } from './resourceEvaluation.js'; +import { isCustomMethodIdentifier, removePrefix } from './resourceEvaluation.js'; export const OPERATIONS_SEGMENT = 'operations'; @@ -7,10 +7,18 @@ export const OPERATIONS_SEGMENT = 'operations'; * Splits a path into its resource identifier segments, ignoring the standard path prefix, so that * the segments describe the resource hierarchy only. * + * Custom method paths (`:customMethod`) yield no segments, keeping them out of scope for the + * IPA-132 Operations rules: custom methods on Operations endpoints will be rejected by a dedicated + * rule, and flagging them here would contradict the IPA-109 requirement that custom methods use + * POST or GET. + * * @param {string} path the path to split * @returns {string[]} the resource identifier segments */ function toResourceSegments(path) { + if (isCustomMethodIdentifier(path)) { + return []; + } return removePrefix(path) .split('/') .filter((segment) => segment.length > 0); From 07d5ad070c3e95d6abe352c2c7649ae213af5427 Mon Sep 17 00:00:00 2001 From: Julius Jogela Date: Fri, 14 Aug 2026 15:26:04 +0100 Subject: [PATCH 4/7] fix(ipa): apply IPA-132 review suggestions for util reuse - Export a generalized isRootLevelResource from resourceEvaluation.js (prefix and trailing path param stripped, a single segment remains) and use it in the must-not-be-global rule instead of the bespoke isRootLevelOperationsPath predicate - The read-only rule now takes its forbidden methods from functionOptions and additionally validates the Operation resource with isReadOnlyResource: all properties of the GET response schema must be readOnly, evaluated once per Operations resource on its collection path item --- ...32OperationMustBeAReadOnlyResource.test.js | 74 ++++++++++++++++--- .../utils/longRunningOperations.test.js | 18 ----- .../utils/resourceEvaluation.test.js | 19 +++++ tools/spectral/ipa/rulesets/IPA-132.yaml | 19 ++++- tools/spectral/ipa/rulesets/README.md | 13 +++- .../IPA132OperationMustBeAReadOnlyResource.js | 26 ++++--- ...IPA132OperationsEndpointMustNotBeGlobal.js | 9 +-- .../functions/utils/longRunningOperations.js | 13 ---- .../functions/utils/resourceEvaluation.js | 21 ++++++ 9 files changed, 151 insertions(+), 61 deletions(-) diff --git a/tools/spectral/ipa/__tests__/IPA132OperationMustBeAReadOnlyResource.test.js b/tools/spectral/ipa/__tests__/IPA132OperationMustBeAReadOnlyResource.test.js index 5b49a8bfc9..271e726edd 100644 --- a/tools/spectral/ipa/__tests__/IPA132OperationMustBeAReadOnlyResource.test.js +++ b/tools/spectral/ipa/__tests__/IPA132OperationMustBeAReadOnlyResource.test.js @@ -1,22 +1,56 @@ import testRule from './__helpers__/testRule'; import { DiagnosticSeverity } from '@stoplight/types'; +const readOnlyGet = { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + properties: { + id: { readOnly: true }, + status: { readOnly: true }, + }, + }, + }, + }, + }, + }, +}; + +const nonReadOnlyGet = { + responses: { + 200: { + content: { + 'application/vnd.atlas.2024-08-05+json': { + schema: { + properties: { + id: { readOnly: true }, + status: { type: 'string' }, + }, + }, + }, + }, + }, + }, +}; + testRule('xgen-IPA-132-operation-must-be-a-read-only-resource', [ { name: 'valid read-only Operations endpoints', document: { paths: { '/api/atlas/v2/resourceName/operations': { - get: {}, + get: readOnlyGet, }, '/api/atlas/v2/resourceName/operations/{operationId}': { - get: {}, + get: readOnlyGet, }, '/api/atlas/v2/resourceName/{pathParam}/operations': { - get: {}, + get: readOnlyGet, }, '/api/atlas/v2/resourceName/{pathParam}/operations/{operationId}': { - get: {}, + get: readOnlyGet, }, }, }, @@ -43,11 +77,11 @@ testRule('xgen-IPA-132-operation-must-be-a-read-only-resource', [ document: { paths: { '/api/atlas/v2/resourceName/operations': { - get: {}, + get: readOnlyGet, post: {}, }, '/api/atlas/v2/resourceName/operations/{operationId}': { - get: {}, + get: readOnlyGet, put: {}, patch: {}, delete: {}, @@ -86,7 +120,7 @@ testRule('xgen-IPA-132-operation-must-be-a-read-only-resource', [ document: { paths: { '/api/atlas/v2/resourceName/operations': { - get: {}, + get: readOnlyGet, post: null, }, }, @@ -100,12 +134,34 @@ testRule('xgen-IPA-132-operation-must-be-a-read-only-resource', [ }, ], }, + { + name: 'invalid Operation resource with properties that are not readOnly', + document: { + paths: { + '/api/atlas/v2/resourceName/operations': { + get: nonReadOnlyGet, + }, + '/api/atlas/v2/resourceName/operations/{operationId}': { + get: nonReadOnlyGet, + }, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operation-must-be-a-read-only-resource', + message: + 'The Operation resource must be read-only. All properties of the GET response schema must be marked as readOnly: true.', + path: ['paths', '/api/atlas/v2/resourceName/operations'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, { name: 'invalid Operations endpoints with exceptions', document: { paths: { '/api/atlas/v2/resourceName/operations': { - get: {}, + get: readOnlyGet, post: {}, 'x-xgen-IPA-exception': { 'xgen-IPA-132-operation-must-be-a-read-only-resource': 'reason', @@ -120,7 +176,7 @@ testRule('xgen-IPA-132-operation-must-be-a-read-only-resource', [ document: { paths: { '/api/atlas/v2/resourceName/operations': { - get: {}, + get: readOnlyGet, 'x-xgen-IPA-exception': { 'xgen-IPA-132-operation-must-be-a-read-only-resource': 'reason', }, diff --git a/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js b/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js index a33b329826..7bd9a5b5ee 100644 --- a/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js +++ b/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js @@ -3,7 +3,6 @@ import { containsOperationsSegment, isOperationsCollectionPath, isOperationsPath, - isRootLevelOperationsPath, isSingleOperationPath, } from '../../rulesets/functions/utils/longRunningOperations'; @@ -69,21 +68,4 @@ describe('tools/spectral/ipa/utils/longRunningOperations.js', () => { expect(containsOperationsSegment('/api/atlas/v2/resourceName/{operationId}')).toBe(false); }); }); - - describe('isRootLevelOperationsPath', () => { - it('recognizes Operations resources mounted at the API root', () => { - expect(isRootLevelOperationsPath('/api/atlas/v2/operations')).toBe(true); - expect(isRootLevelOperationsPath('/api/atlas/v2/operations/{operationId}')).toBe(true); - expect(isRootLevelOperationsPath('/api/atlas/v2/unauth/operations')).toBe(true); - }); - - it('rejects nested Operations resources', () => { - expect(isRootLevelOperationsPath('/api/atlas/v2/resourceName/operations')).toBe(false); - expect(isRootLevelOperationsPath('/api/atlas/v2/resourceName/{pathParam}/operations/{operationId}')).toBe(false); - }); - - it('rejects paths nested below a root-level operations segment', () => { - expect(isRootLevelOperationsPath('/api/atlas/v2/operations/subresource')).toBe(false); - }); - }); }); diff --git a/tools/spectral/ipa/__tests__/utils/resourceEvaluation.test.js b/tools/spectral/ipa/__tests__/utils/resourceEvaluation.test.js index b41b30f781..6dd83b664b 100644 --- a/tools/spectral/ipa/__tests__/utils/resourceEvaluation.test.js +++ b/tools/spectral/ipa/__tests__/utils/resourceEvaluation.test.js @@ -4,6 +4,7 @@ import { getResourcePathItems, isReadOnlyResource, isResourceCollectionIdentifier, + isRootLevelResource, isSingleResourceIdentifier, isSingletonResource, } from '../../rulesets/functions/utils/resourceEvaluation'; @@ -589,4 +590,22 @@ describe('tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js', () }); }); }); + + describe('isRootLevelResource', () => { + it('recognizes root-level resources', () => { + expect(isRootLevelResource('/api/atlas/v2/resourceName')).toBe(true); + expect(isRootLevelResource('/api/atlas/v2/resourceName/{id}')).toBe(true); + expect(isRootLevelResource('/api/atlas/v2/operations')).toBe(true); + expect(isRootLevelResource('/api/atlas/v2/operations/{operationId}')).toBe(true); + expect(isRootLevelResource('/api/atlas/v2/unauth/resourceName')).toBe(true); + }); + + it('rejects resources with a parent', () => { + expect(isRootLevelResource('/api/atlas/v2/resourceName/childResource')).toBe(false); + expect(isRootLevelResource('/api/atlas/v2/resourceName/{id}/childResource')).toBe(false); + expect(isRootLevelResource('/api/atlas/v2/resourceName/childResource/{id}')).toBe(false); + expect(isRootLevelResource('/api/atlas/v2/resourceName/operations')).toBe(false); + expect(isRootLevelResource('/api/atlas/v2/resourceName/{pathParam}/operations/{operationId}')).toBe(false); + }); + }); }); diff --git a/tools/spectral/ipa/rulesets/IPA-132.yaml b/tools/spectral/ipa/rulesets/IPA-132.yaml index 60efc9de49..fcaeaf45ab 100644 --- a/tools/spectral/ipa/rulesets/IPA-132.yaml +++ b/tools/spectral/ipa/rulesets/IPA-132.yaml @@ -31,23 +31,36 @@ rules: xgen-IPA-132-operation-must-be-a-read-only-resource: description: | - Operations endpoints are read-only and must not define the mutating methods `post`, `put`, - `patch` or `delete`. + Operations endpoints are read-only. They must not define mutating methods, and all properties + of the Operation resource must be readOnly. ##### Implementation details Rule checks for the following conditions: - Applies to Operations endpoints, i.e. paths ending with an `operations` segment or an `operations/{operationId}` suffix - - The path item must not define `post`, `put`, `patch` or `delete` methods + - The path item must not define any of the methods listed in the `forbiddenMethods` option + - All properties of the Operation resource GET response schema must be marked as + `readOnly: true`; this condition is evaluated once per Operations resource, on its + collection path item - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation + ##### Function options + - forbiddenMethods: Required array parameter listing the HTTP methods an Operations + endpoint must not define + message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-132-operation-must-be-a-read-only-resource' severity: warn given: $.paths then: field: '@key' function: IPA132OperationMustBeAReadOnlyResource + functionOptions: + forbiddenMethods: + - post + - put + - patch + - delete xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource: description: | diff --git a/tools/spectral/ipa/rulesets/README.md b/tools/spectral/ipa/rulesets/README.md index 5bf5b867b9..b0ab6c5fa4 100644 --- a/tools/spectral/ipa/rulesets/README.md +++ b/tools/spectral/ipa/rulesets/README.md @@ -1429,17 +1429,24 @@ Rule checks for the following conditions: #### xgen-IPA-132-operation-must-be-a-read-only-resource ![warn](https://img.shields.io/badge/warning-yellow) -Operations endpoints are read-only and must not define the mutating methods `post`, `put`, -`patch` or `delete`. +Operations endpoints are read-only. They must not define mutating methods, and all properties +of the Operation resource must be readOnly. ##### Implementation details Rule checks for the following conditions: - Applies to Operations endpoints, i.e. paths ending with an `operations` segment or an `operations/{operationId}` suffix - - The path item must not define `post`, `put`, `patch` or `delete` methods + - The path item must not define any of the methods listed in the `forbiddenMethods` option + - All properties of the Operation resource GET response schema must be marked as + `readOnly: true`; this condition is evaluated once per Operations resource, on its + collection path item - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation +##### Function options + - forbiddenMethods: Required array parameter listing the HTTP methods an Operations + endpoint must not define + #### xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource ![warn](https://img.shields.io/badge/warning-yellow) diff --git a/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js b/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js index d0f92cfbb0..85899464ea 100644 --- a/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js +++ b/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js @@ -1,17 +1,19 @@ import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; -import { isOperationsPath } from './utils/longRunningOperations.js'; +import { getResourcePathItems, isReadOnlyResource } from './utils/resourceEvaluation.js'; +import { isOperationsCollectionPath, isOperationsPath } from './utils/longRunningOperations.js'; -const FORBIDDEN_METHODS = ['post', 'put', 'patch', 'delete']; +const READ_ONLY_SCHEMA_ERROR_MESSAGE = + 'The Operation resource must be read-only. All properties of the GET response schema must be marked as readOnly: true.'; /** - * Checks that an Operations endpoint defined by IPA-132 is a read-only resource, i.e. its path - * items define no mutating HTTP methods. + * Checks that an Operations endpoint defined by IPA-132 is a read-only resource: its path items + * define no mutating HTTP methods, and all properties of the Operation resource are readOnly. * * @param {string} input - The path key from the OpenAPI spec - * @param {object} _ - Unused + * @param {{forbiddenMethods: string[]}} options - The methods an Operations endpoint must not define * @param {object} context - The context object containing the path, documentInventory and rule */ -export default (input, _, { path, documentInventory, rule }) => { +export default (input, { forbiddenMethods }, { path, documentInventory, rule }) => { const ruleName = rule.name; const oas = documentInventory.resolved; @@ -20,14 +22,14 @@ export default (input, _, { path, documentInventory, rule }) => { } const pathItem = oas.paths[input]; - const errors = checkViolationsAndReturnErrors(pathItem, path, ruleName); + const errors = checkViolationsAndReturnErrors(input, pathItem, oas, forbiddenMethods, path, ruleName); return evaluateAndCollectAdoptionStatus(errors, ruleName, pathItem, path); }; -function checkViolationsAndReturnErrors(pathItem, path, ruleName) { +function checkViolationsAndReturnErrors(input, pathItem, oas, forbiddenMethods, path, ruleName) { try { const errors = []; - for (const method of FORBIDDEN_METHODS) { + for (const method of forbiddenMethods) { // Key presence, not truthiness - a declared method with a null/empty value still advertises the route if (method in pathItem) { errors.push({ @@ -36,6 +38,12 @@ function checkViolationsAndReturnErrors(pathItem, path, ruleName) { }); } } + + // The readOnly schema condition applies to the resource as a whole, so it is evaluated once + // per Operations resource, on its collection path item + if (isOperationsCollectionPath(input) && !isReadOnlyResource(getResourcePathItems(input, oas.paths))) { + errors.push({ path, message: READ_ONLY_SCHEMA_ERROR_MESSAGE }); + } return errors; } catch (e) { return handleInternalError(ruleName, path, e); diff --git a/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustNotBeGlobal.js b/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustNotBeGlobal.js index 92153ebaed..205600cb1b 100644 --- a/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustNotBeGlobal.js +++ b/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustNotBeGlobal.js @@ -1,5 +1,6 @@ import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; -import { isOperationsPath, isRootLevelOperationsPath } from './utils/longRunningOperations.js'; +import { isRootLevelResource } from './utils/resourceEvaluation.js'; +import { isOperationsPath } from './utils/longRunningOperations.js'; const ERROR_MESSAGE = 'Operations endpoints must not be standalone, global endpoints with no parent resource in their path.'; @@ -8,10 +9,6 @@ const ERROR_MESSAGE = * Checks that an Operations endpoint defined by IPA-132 is not a standalone, global endpoint, * rejecting Operations endpoints mounted at the API root such as `/api/atlas/v2/operations`. * - * The existing `resourceBelongsToSingleParent` util is not reusable here: it requires the - * grandparent segment to be a path parameter, which rejects the collection-scoped Operations - * form `/parent/operations` that IPA-132 explicitly allows. - * * @param {string} input - The path key from the OpenAPI spec * @param {object} _ - Unused * @param {object} context - The context object containing the path, documentInventory and rule @@ -30,7 +27,7 @@ export default (input, _, { path, documentInventory, rule }) => { function checkViolationsAndReturnErrors(input, path, ruleName) { try { - if (isRootLevelOperationsPath(input)) { + if (isRootLevelResource(input)) { return [{ path, message: ERROR_MESSAGE }]; } return []; diff --git a/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js b/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js index 64762f0597..79a3627b17 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js +++ b/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js @@ -80,16 +80,3 @@ export function isOperationsPath(path) { export function containsOperationsSegment(path) { return toResourceSegments(path).includes(OPERATIONS_SEGMENT); } - -/** - * Checks if a path identifies an Operations resource mounted at the API root, i.e. an `operations` - * segment with no parent resource, such as '/api/atlas/v2/operations' or - * '/api/atlas/v2/operations/{operationId}'. IPA-132 requires Operations resources to be nested - * under the parent resource that spawned the operation. - * - * @param {string} path the path to evaluate - * @returns {boolean} true if the path identifies a root-level Operations resource - */ -export function isRootLevelOperationsPath(path) { - return isOperationsPath(path) && toResourceSegments(path)[0] === OPERATIONS_SEGMENT; -} diff --git a/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js b/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js index a3749fbef7..2b811bfa46 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js +++ b/tools/spectral/ipa/rulesets/functions/utils/resourceEvaluation.js @@ -51,6 +51,27 @@ export function isSingleResourceIdentifier(path) { return isResourceCollectionIdentifier(collectionPath); } +/** + * Checks whether a resource is root-level, i.e. mounted directly under the API prefix with no + * parent resource. Both the resource collection path and the single resource path are recognized. + * For example: + * '/resource' returns true + * '/resource/{id}' returns true + * '/parent/{id}/resource' returns false + * '/parent/resource/{id}' returns false + * + * @param {string} resourcePath a path for a resource + * @returns {boolean} + */ +export function isRootLevelResource(resourcePath) { + const path = removePrefix(resourcePath); + const sections = path.split('/').filter((section) => section.length > 0); + if (sections.length > 0 && isPathParam(sections[sections.length - 1])) { + sections.pop(); + } + return sections.length === 1; +} + export function isCustomMethodIdentifier(path) { return path.includes(':'); } From 1a4a51d653015ba889cd1989e42d428505029dcb Mon Sep 17 00:00:00 2001 From: Julius Jogela Date: Fri, 14 Aug 2026 16:29:58 +0100 Subject: [PATCH 5/7] fix(ipa): allow only get on Operations endpoints and harden the IPA-132 rules Per review, the read-only rule no longer takes a forbiddenMethods option: any HTTP method other than get on an Operations endpoint is a violation. Also fix defects found by deeper review of the rules: - The readOnly schema condition is checked per path item on every 2xx response schema of its get method, instead of once per resource via isReadOnlyResource. The previous approach depended on spec key order for collection-scoped resources, false-positived on resources carrying an unrelated IPA-104 exception, never checked resources defining only the single-operation path, and could not be suppressed by an exception on the flagged path item. - The leaf rule validates the first operations segment instead of the path tail, so paths like .../operations/{id}/operations no longer pass. - The Operations path predicates use the lenient isPathParam, so the three rules agree on paths with non-camelCase parameters; parameter casing is IPA-102's job. - Null path items no longer crash the rules. --- ...32OperationMustBeAReadOnlyResource.test.js | 25 ++++++++--- ...rationsEndpointMustBeALeafResource.test.js | 9 ++++ ...2OperationsEndpointMustNotBeGlobal.test.js | 16 +++++++ .../utils/longRunningOperations.test.js | 20 +++++++++ tools/spectral/ipa/rulesets/IPA-132.yaml | 19 ++------ tools/spectral/ipa/rulesets/README.md | 13 ++---- .../IPA132OperationMustBeAReadOnlyResource.js | 44 +++++++++++++------ ...32OperationsEndpointMustBeALeafResource.js | 8 ++-- ...IPA132OperationsEndpointMustNotBeGlobal.js | 2 +- .../functions/utils/longRunningOperations.js | 25 ++++++++++- 10 files changed, 131 insertions(+), 50 deletions(-) diff --git a/tools/spectral/ipa/__tests__/IPA132OperationMustBeAReadOnlyResource.test.js b/tools/spectral/ipa/__tests__/IPA132OperationMustBeAReadOnlyResource.test.js index 271e726edd..5782173073 100644 --- a/tools/spectral/ipa/__tests__/IPA132OperationMustBeAReadOnlyResource.test.js +++ b/tools/spectral/ipa/__tests__/IPA132OperationMustBeAReadOnlyResource.test.js @@ -1,6 +1,9 @@ import testRule from './__helpers__/testRule'; import { DiagnosticSeverity } from '@stoplight/types'; +const READ_ONLY_SCHEMA_ERROR_MESSAGE = + 'The Operation resource must be read-only. All properties of the GET response schema must be marked as readOnly: true.'; + const readOnlyGet = { responses: { 200: { @@ -73,7 +76,7 @@ testRule('xgen-IPA-132-operation-must-be-a-read-only-resource', [ errors: [], }, { - name: 'invalid Operations endpoints with mutating methods', + name: 'invalid Operations endpoints with methods other than get', document: { paths: { '/api/atlas/v2/resourceName/operations': { @@ -85,6 +88,7 @@ testRule('xgen-IPA-132-operation-must-be-a-read-only-resource', [ put: {}, patch: {}, delete: {}, + head: {}, }, }, }, @@ -113,6 +117,12 @@ testRule('xgen-IPA-132-operation-must-be-a-read-only-resource', [ path: ['paths', '/api/atlas/v2/resourceName/operations/{operationId}', 'delete'], severity: DiagnosticSeverity.Warning, }, + { + code: 'xgen-IPA-132-operation-must-be-a-read-only-resource', + message: 'Operations endpoints are read-only and do not allow the head method.', + path: ['paths', '/api/atlas/v2/resourceName/operations/{operationId}', 'head'], + severity: DiagnosticSeverity.Warning, + }, ], }, { @@ -135,7 +145,7 @@ testRule('xgen-IPA-132-operation-must-be-a-read-only-resource', [ ], }, { - name: 'invalid Operation resource with properties that are not readOnly', + name: 'invalid Operation resources with properties that are not readOnly, checked per path item', document: { paths: { '/api/atlas/v2/resourceName/operations': { @@ -149,9 +159,14 @@ testRule('xgen-IPA-132-operation-must-be-a-read-only-resource', [ errors: [ { code: 'xgen-IPA-132-operation-must-be-a-read-only-resource', - message: - 'The Operation resource must be read-only. All properties of the GET response schema must be marked as readOnly: true.', - path: ['paths', '/api/atlas/v2/resourceName/operations'], + message: READ_ONLY_SCHEMA_ERROR_MESSAGE, + path: ['paths', '/api/atlas/v2/resourceName/operations', 'get'], + severity: DiagnosticSeverity.Warning, + }, + { + code: 'xgen-IPA-132-operation-must-be-a-read-only-resource', + message: READ_ONLY_SCHEMA_ERROR_MESSAGE, + path: ['paths', '/api/atlas/v2/resourceName/operations/{operationId}', 'get'], severity: DiagnosticSeverity.Warning, }, ], diff --git a/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustBeALeafResource.test.js b/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustBeALeafResource.test.js index 3777d6188d..fc9299555c 100644 --- a/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustBeALeafResource.test.js +++ b/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustBeALeafResource.test.js @@ -38,6 +38,9 @@ testRule('xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource', [ '/api/atlas/v2/resourceName/operations/subresource': {}, '/api/atlas/v2/resourceName/operations/{operationId}/subresource': {}, '/api/atlas/v2/resourceName/operations/{operationId}/{anotherId}': {}, + // Nesting below the first operations segment is a violation even when the path ends in + // another well-formed operations suffix + '/api/atlas/v2/resourceName/operations/{operationId}/operations': {}, }, }, errors: [ @@ -59,6 +62,12 @@ testRule('xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource', [ path: ['paths', '/api/atlas/v2/resourceName/operations/{operationId}/{anotherId}'], severity: DiagnosticSeverity.Warning, }, + { + code: 'xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource', + message: ERROR_MESSAGE, + path: ['paths', '/api/atlas/v2/resourceName/operations/{operationId}/operations'], + severity: DiagnosticSeverity.Warning, + }, ], }, { diff --git a/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustNotBeGlobal.test.js b/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustNotBeGlobal.test.js index 289428294e..42fc6b0f92 100644 --- a/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustNotBeGlobal.test.js +++ b/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustNotBeGlobal.test.js @@ -65,6 +65,22 @@ testRule('xgen-IPA-132-operations-endpoint-must-not-be-global', [ }, ], }, + { + name: 'null path items are still validated', + document: { + paths: { + '/api/atlas/v2/operations': null, + }, + }, + errors: [ + { + code: 'xgen-IPA-132-operations-endpoint-must-not-be-global', + message: ERROR_MESSAGE, + path: ['paths', '/api/atlas/v2/operations'], + severity: DiagnosticSeverity.Warning, + }, + ], + }, { name: 'invalid root-level Operations endpoints with exceptions', document: { diff --git a/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js b/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js index 7bd9a5b5ee..45acab31e1 100644 --- a/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js +++ b/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js @@ -4,6 +4,7 @@ import { isOperationsCollectionPath, isOperationsPath, isSingleOperationPath, + operationsSegmentIsLeaf, } from '../../rulesets/functions/utils/longRunningOperations'; describe('tools/spectral/ipa/utils/longRunningOperations.js', () => { @@ -36,6 +37,10 @@ describe('tools/spectral/ipa/utils/longRunningOperations.js', () => { expect(isSingleOperationPath('/api/atlas/v2/resourceName/{pathParam}')).toBe(false); expect(isSingleOperationPath('/api/atlas/v2/resourceName/operations/{operationId}/subresource')).toBe(false); }); + + it('is lenient about the path parameter syntax, casing is covered by IPA-102', () => { + expect(isSingleOperationPath('/api/atlas/v2/resourceName/operations/{OperationId}')).toBe(true); + }); }); describe('isOperationsPath', () => { @@ -68,4 +73,19 @@ describe('tools/spectral/ipa/utils/longRunningOperations.js', () => { expect(containsOperationsSegment('/api/atlas/v2/resourceName/{operationId}')).toBe(false); }); }); + + describe('operationsSegmentIsLeaf', () => { + it('accepts leaf Operations endpoints and paths without an operations segment', () => { + expect(operationsSegmentIsLeaf('/api/atlas/v2/resourceName/operations')).toBe(true); + expect(operationsSegmentIsLeaf('/api/atlas/v2/resourceName/operations/{operationId}')).toBe(true); + expect(operationsSegmentIsLeaf('/api/atlas/v2/resourceName')).toBe(true); + }); + + it('rejects nesting below the first operations segment', () => { + expect(operationsSegmentIsLeaf('/api/atlas/v2/resourceName/operations/subresource')).toBe(false); + expect(operationsSegmentIsLeaf('/api/atlas/v2/resourceName/operations/{operationId}/subresource')).toBe(false); + expect(operationsSegmentIsLeaf('/api/atlas/v2/resourceName/operations/{operationId}/operations')).toBe(false); + expect(operationsSegmentIsLeaf('/api/atlas/v2/operations/subresource')).toBe(false); + }); + }); }); diff --git a/tools/spectral/ipa/rulesets/IPA-132.yaml b/tools/spectral/ipa/rulesets/IPA-132.yaml index fcaeaf45ab..c535d1768f 100644 --- a/tools/spectral/ipa/rulesets/IPA-132.yaml +++ b/tools/spectral/ipa/rulesets/IPA-132.yaml @@ -31,7 +31,7 @@ rules: xgen-IPA-132-operation-must-be-a-read-only-resource: description: | - Operations endpoints are read-only. They must not define mutating methods, and all properties + Operations endpoints are read-only. They may only define the get method, and all properties of the Operation resource must be readOnly. ##### Implementation details @@ -39,28 +39,17 @@ rules: - Applies to Operations endpoints, i.e. paths ending with an `operations` segment or an `operations/{operationId}` suffix - - The path item must not define any of the methods listed in the `forbiddenMethods` option - - All properties of the Operation resource GET response schema must be marked as - `readOnly: true`; this condition is evaluated once per Operations resource, on its - collection path item + - The path item must not define any HTTP method other than `get` + - All properties of every 2xx response schema of the `get` method must be marked as + `readOnly: true`; the condition is checked per path item - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation - ##### Function options - - forbiddenMethods: Required array parameter listing the HTTP methods an Operations - endpoint must not define - message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-132-operation-must-be-a-read-only-resource' severity: warn given: $.paths then: field: '@key' function: IPA132OperationMustBeAReadOnlyResource - functionOptions: - forbiddenMethods: - - post - - put - - patch - - delete xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource: description: | diff --git a/tools/spectral/ipa/rulesets/README.md b/tools/spectral/ipa/rulesets/README.md index b0ab6c5fa4..d2ef0ac372 100644 --- a/tools/spectral/ipa/rulesets/README.md +++ b/tools/spectral/ipa/rulesets/README.md @@ -1429,7 +1429,7 @@ Rule checks for the following conditions: #### xgen-IPA-132-operation-must-be-a-read-only-resource ![warn](https://img.shields.io/badge/warning-yellow) -Operations endpoints are read-only. They must not define mutating methods, and all properties +Operations endpoints are read-only. They may only define the get method, and all properties of the Operation resource must be readOnly. ##### Implementation details @@ -1437,16 +1437,11 @@ Rule checks for the following conditions: - Applies to Operations endpoints, i.e. paths ending with an `operations` segment or an `operations/{operationId}` suffix - - The path item must not define any of the methods listed in the `forbiddenMethods` option - - All properties of the Operation resource GET response schema must be marked as - `readOnly: true`; this condition is evaluated once per Operations resource, on its - collection path item + - The path item must not define any HTTP method other than `get` + - All properties of every 2xx response schema of the `get` method must be marked as + `readOnly: true`; the condition is checked per path item - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation -##### Function options - - forbiddenMethods: Required array parameter listing the HTTP methods an Operations - endpoint must not define - #### xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource ![warn](https://img.shields.io/badge/warning-yellow) diff --git a/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js b/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js index 85899464ea..6a9fb0fb40 100644 --- a/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js +++ b/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js @@ -1,19 +1,20 @@ +import { allPropertiesAreReadOnly } from './utils/resourceEvaluation.js'; import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; -import { getResourcePathItems, isReadOnlyResource } from './utils/resourceEvaluation.js'; -import { isOperationsCollectionPath, isOperationsPath } from './utils/longRunningOperations.js'; +import { isOperationsPath } from './utils/longRunningOperations.js'; +const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; const READ_ONLY_SCHEMA_ERROR_MESSAGE = 'The Operation resource must be read-only. All properties of the GET response schema must be marked as readOnly: true.'; /** * Checks that an Operations endpoint defined by IPA-132 is a read-only resource: its path items - * define no mutating HTTP methods, and all properties of the Operation resource are readOnly. + * may only define the get method, and all properties of the Operation resource are readOnly. * * @param {string} input - The path key from the OpenAPI spec - * @param {{forbiddenMethods: string[]}} options - The methods an Operations endpoint must not define + * @param {object} _ - Unused * @param {object} context - The context object containing the path, documentInventory and rule */ -export default (input, { forbiddenMethods }, { path, documentInventory, rule }) => { +export default (input, _, { path, documentInventory, rule }) => { const ruleName = rule.name; const oas = documentInventory.resolved; @@ -21,17 +22,17 @@ export default (input, { forbiddenMethods }, { path, documentInventory, rule }) return; } - const pathItem = oas.paths[input]; - const errors = checkViolationsAndReturnErrors(input, pathItem, oas, forbiddenMethods, path, ruleName); + const pathItem = oas.paths[input] ?? {}; + const errors = checkViolationsAndReturnErrors(pathItem, path, ruleName); return evaluateAndCollectAdoptionStatus(errors, ruleName, pathItem, path); }; -function checkViolationsAndReturnErrors(input, pathItem, oas, forbiddenMethods, path, ruleName) { +function checkViolationsAndReturnErrors(pathItem, path, ruleName) { try { const errors = []; - for (const method of forbiddenMethods) { + for (const method of HTTP_METHODS) { // Key presence, not truthiness - a declared method with a null/empty value still advertises the route - if (method in pathItem) { + if (method !== 'get' && method in pathItem) { errors.push({ path: [...path, method], message: `Operations endpoints are read-only and do not allow the ${method} method.`, @@ -39,13 +40,28 @@ function checkViolationsAndReturnErrors(input, pathItem, oas, forbiddenMethods, } } - // The readOnly schema condition applies to the resource as a whole, so it is evaluated once - // per Operations resource, on its collection path item - if (isOperationsCollectionPath(input) && !isReadOnlyResource(getResourcePathItems(input, oas.paths))) { - errors.push({ path, message: READ_ONLY_SCHEMA_ERROR_MESSAGE }); + // Every 2xx response schema of the get method must consist of readOnly properties only. The + // check is per path item, so the exception for a violation is placed on the flagged path item. + if (hasWritableGetResponseSchema(pathItem)) { + errors.push({ path: [...path, 'get'], message: READ_ONLY_SCHEMA_ERROR_MESSAGE }); } return errors; } catch (e) { return handleInternalError(ruleName, path, e); } } + +function hasWritableGetResponseSchema(pathItem) { + const responses = pathItem.get?.responses ?? {}; + for (const [responseCode, response] of Object.entries(responses)) { + if (!responseCode.startsWith('2')) { + continue; + } + for (const mediaTypeObject of Object.values(response?.content ?? {})) { + if (mediaTypeObject?.schema && !allPropertiesAreReadOnly(mediaTypeObject.schema)) { + return true; + } + } + } + return false; +} diff --git a/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustBeALeafResource.js b/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustBeALeafResource.js index 6ade921b27..e708b74d1a 100644 --- a/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustBeALeafResource.js +++ b/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustBeALeafResource.js @@ -1,12 +1,12 @@ import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; -import { containsOperationsSegment, isOperationsPath } from './utils/longRunningOperations.js'; +import { containsOperationsSegment, operationsSegmentIsLeaf } from './utils/longRunningOperations.js'; const ERROR_MESSAGE = 'Operations endpoints must be leaf resources. An `operations` segment may only be followed by a single operation identifier path parameter.'; /** * Checks that an Operations resource defined by IPA-132 is a leaf resource, i.e. nothing is nested - * below `operations` other than a single `{operationId}` path parameter. + * below the first `operations` segment other than a single `{operationId}` path parameter. * * @param {string} input - The path key from the OpenAPI spec * @param {object} _ - Unused @@ -21,12 +21,12 @@ export default (input, _, { path, documentInventory, rule }) => { } const errors = checkViolationsAndReturnErrors(input, path, ruleName); - return evaluateAndCollectAdoptionStatus(errors, ruleName, oas.paths[input], path); + return evaluateAndCollectAdoptionStatus(errors, ruleName, oas.paths[input] ?? {}, path); }; function checkViolationsAndReturnErrors(input, path, ruleName) { try { - if (!isOperationsPath(input)) { + if (!operationsSegmentIsLeaf(input)) { return [{ path, message: ERROR_MESSAGE }]; } return []; diff --git a/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustNotBeGlobal.js b/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustNotBeGlobal.js index 205600cb1b..b1eecd4f9d 100644 --- a/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustNotBeGlobal.js +++ b/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustNotBeGlobal.js @@ -22,7 +22,7 @@ export default (input, _, { path, documentInventory, rule }) => { } const errors = checkViolationsAndReturnErrors(input, path, ruleName); - return evaluateAndCollectAdoptionStatus(errors, ruleName, oas.paths[input], path); + return evaluateAndCollectAdoptionStatus(errors, ruleName, oas.paths[input] ?? {}, path); }; function checkViolationsAndReturnErrors(input, path, ruleName) { diff --git a/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js b/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js index 79a3627b17..2034945338 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js +++ b/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js @@ -1,5 +1,4 @@ -import { isPathParam } from './componentUtils.js'; -import { isCustomMethodIdentifier, removePrefix } from './resourceEvaluation.js'; +import { isCustomMethodIdentifier, isPathParam, removePrefix } from './resourceEvaluation.js'; export const OPERATIONS_SEGMENT = 'operations'; @@ -80,3 +79,25 @@ export function isOperationsPath(path) { export function containsOperationsSegment(path) { return toResourceSegments(path).includes(OPERATIONS_SEGMENT); } + +/** + * Checks that nothing is nested below the first `operations` segment of a path: the segment may + * only be followed by a single operation identifier path parameter. Paths without an `operations` + * segment are considered leaves. For example: + * '/api/atlas/v2/resourceName/operations' returns true + * '/api/atlas/v2/resourceName/operations/{operationId}' returns true + * '/api/atlas/v2/resourceName/operations/subresource' returns false + * '/api/atlas/v2/resourceName/operations/{operationId}/operations' returns false + * + * @param {string} path the path to evaluate + * @returns {boolean} true if nothing is nested below the first `operations` segment + */ +export function operationsSegmentIsLeaf(path) { + const segments = toResourceSegments(path); + const firstIndex = segments.indexOf(OPERATIONS_SEGMENT); + if (firstIndex === -1) { + return true; + } + const trailingSegments = segments.slice(firstIndex + 1); + return trailingSegments.length === 0 || (trailingSegments.length === 1 && isPathParam(trailingSegments[0])); +} From 67b8faf6d030d4073eaf8749bb98b38dc08da833 Mon Sep 17 00:00:00 2001 From: Julius Jogela Date: Fri, 14 Aug 2026 17:19:41 +0100 Subject: [PATCH 6/7] fix(ipa): check the readOnly Operation schema on the single Operation endpoint Per review, the readOnly schema condition runs on the single Operation endpoint only, where the Get method is defined: the List method on the collection reuses the Operation resource schema and its response shape is owned by the must-return-operation-response rule. The method check still runs on every Operations path item. Also per review, drop the null path item and unnecessary-exception test cases from the must-not-be-global tests, and reuse shared path item examples across the read-only test cases. --- ...32OperationMustBeAReadOnlyResource.test.js | 47 +++++++------------ ...2OperationsEndpointMustNotBeGlobal.test.js | 41 ---------------- tools/spectral/ipa/rulesets/IPA-132.yaml | 5 +- tools/spectral/ipa/rulesets/README.md | 5 +- .../IPA132OperationMustBeAReadOnlyResource.js | 13 ++--- 5 files changed, 30 insertions(+), 81 deletions(-) diff --git a/tools/spectral/ipa/__tests__/IPA132OperationMustBeAReadOnlyResource.test.js b/tools/spectral/ipa/__tests__/IPA132OperationMustBeAReadOnlyResource.test.js index 5782173073..ce8bfbeaf1 100644 --- a/tools/spectral/ipa/__tests__/IPA132OperationMustBeAReadOnlyResource.test.js +++ b/tools/spectral/ipa/__tests__/IPA132OperationMustBeAReadOnlyResource.test.js @@ -38,23 +38,18 @@ const nonReadOnlyGet = { }, }; +const readOnlyOperationsEndpoint = { get: readOnlyGet }; +const nonReadOnlyOperationsEndpoint = { get: nonReadOnlyGet }; + testRule('xgen-IPA-132-operation-must-be-a-read-only-resource', [ { name: 'valid read-only Operations endpoints', document: { paths: { - '/api/atlas/v2/resourceName/operations': { - get: readOnlyGet, - }, - '/api/atlas/v2/resourceName/operations/{operationId}': { - get: readOnlyGet, - }, - '/api/atlas/v2/resourceName/{pathParam}/operations': { - get: readOnlyGet, - }, - '/api/atlas/v2/resourceName/{pathParam}/operations/{operationId}': { - get: readOnlyGet, - }, + '/api/atlas/v2/resourceName/operations': readOnlyOperationsEndpoint, + '/api/atlas/v2/resourceName/operations/{operationId}': readOnlyOperationsEndpoint, + '/api/atlas/v2/resourceName/{pathParam}/operations': readOnlyOperationsEndpoint, + '/api/atlas/v2/resourceName/{pathParam}/operations/{operationId}': readOnlyOperationsEndpoint, }, }, errors: [], @@ -80,11 +75,11 @@ testRule('xgen-IPA-132-operation-must-be-a-read-only-resource', [ document: { paths: { '/api/atlas/v2/resourceName/operations': { - get: readOnlyGet, + ...readOnlyOperationsEndpoint, post: {}, }, '/api/atlas/v2/resourceName/operations/{operationId}': { - get: readOnlyGet, + ...readOnlyOperationsEndpoint, put: {}, patch: {}, delete: {}, @@ -130,7 +125,7 @@ testRule('xgen-IPA-132-operation-must-be-a-read-only-resource', [ document: { paths: { '/api/atlas/v2/resourceName/operations': { - get: readOnlyGet, + ...readOnlyOperationsEndpoint, post: null, }, }, @@ -145,24 +140,16 @@ testRule('xgen-IPA-132-operation-must-be-a-read-only-resource', [ ], }, { - name: 'invalid Operation resources with properties that are not readOnly, checked per path item', + name: 'invalid single Operation endpoint with properties that are not readOnly', document: { paths: { - '/api/atlas/v2/resourceName/operations': { - get: nonReadOnlyGet, - }, - '/api/atlas/v2/resourceName/operations/{operationId}': { - get: nonReadOnlyGet, - }, + // The List method on the collection reuses the Operation resource schema, so only the + // single Operation endpoint, where the Get method is defined, is checked + '/api/atlas/v2/resourceName/operations': nonReadOnlyOperationsEndpoint, + '/api/atlas/v2/resourceName/operations/{operationId}': nonReadOnlyOperationsEndpoint, }, }, errors: [ - { - code: 'xgen-IPA-132-operation-must-be-a-read-only-resource', - message: READ_ONLY_SCHEMA_ERROR_MESSAGE, - path: ['paths', '/api/atlas/v2/resourceName/operations', 'get'], - severity: DiagnosticSeverity.Warning, - }, { code: 'xgen-IPA-132-operation-must-be-a-read-only-resource', message: READ_ONLY_SCHEMA_ERROR_MESSAGE, @@ -176,7 +163,7 @@ testRule('xgen-IPA-132-operation-must-be-a-read-only-resource', [ document: { paths: { '/api/atlas/v2/resourceName/operations': { - get: readOnlyGet, + ...readOnlyOperationsEndpoint, post: {}, 'x-xgen-IPA-exception': { 'xgen-IPA-132-operation-must-be-a-read-only-resource': 'reason', @@ -191,7 +178,7 @@ testRule('xgen-IPA-132-operation-must-be-a-read-only-resource', [ document: { paths: { '/api/atlas/v2/resourceName/operations': { - get: readOnlyGet, + ...readOnlyOperationsEndpoint, 'x-xgen-IPA-exception': { 'xgen-IPA-132-operation-must-be-a-read-only-resource': 'reason', }, diff --git a/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustNotBeGlobal.test.js b/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustNotBeGlobal.test.js index 42fc6b0f92..d2797ced1b 100644 --- a/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustNotBeGlobal.test.js +++ b/tools/spectral/ipa/__tests__/IPA132OperationsEndpointMustNotBeGlobal.test.js @@ -65,22 +65,6 @@ testRule('xgen-IPA-132-operations-endpoint-must-not-be-global', [ }, ], }, - { - name: 'null path items are still validated', - document: { - paths: { - '/api/atlas/v2/operations': null, - }, - }, - errors: [ - { - code: 'xgen-IPA-132-operations-endpoint-must-not-be-global', - message: ERROR_MESSAGE, - path: ['paths', '/api/atlas/v2/operations'], - severity: DiagnosticSeverity.Warning, - }, - ], - }, { name: 'invalid root-level Operations endpoints with exceptions', document: { @@ -94,29 +78,4 @@ testRule('xgen-IPA-132-operations-endpoint-must-not-be-global', [ }, errors: [], }, - { - name: 'nested Operations endpoints do not need an exception', - document: { - paths: { - '/api/atlas/v2/resourceName/operations': { - 'x-xgen-IPA-exception': { - 'xgen-IPA-132-operations-endpoint-must-not-be-global': 'reason', - }, - }, - }, - }, - errors: [ - { - code: 'xgen-IPA-132-operations-endpoint-must-not-be-global', - message: 'This component adopts the rule and does not need an exception. Please remove the exception.', - path: [ - 'paths', - '/api/atlas/v2/resourceName/operations', - 'x-xgen-IPA-exception', - 'xgen-IPA-132-operations-endpoint-must-not-be-global', - ], - severity: DiagnosticSeverity.Warning, - }, - ], - }, ]); diff --git a/tools/spectral/ipa/rulesets/IPA-132.yaml b/tools/spectral/ipa/rulesets/IPA-132.yaml index c535d1768f..c8a3a422bf 100644 --- a/tools/spectral/ipa/rulesets/IPA-132.yaml +++ b/tools/spectral/ipa/rulesets/IPA-132.yaml @@ -40,8 +40,9 @@ rules: - Applies to Operations endpoints, i.e. paths ending with an `operations` segment or an `operations/{operationId}` suffix - The path item must not define any HTTP method other than `get` - - All properties of every 2xx response schema of the `get` method must be marked as - `readOnly: true`; the condition is checked per path item + - On the single Operation endpoint (`.../operations/{operationId}`), where the Get method + is defined, all properties of every 2xx response schema of the `get` method must be + marked as `readOnly: true` - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation message: '{{error}} https://mdb.link/mongodb-atlas-openapi-validation#xgen-IPA-132-operation-must-be-a-read-only-resource' diff --git a/tools/spectral/ipa/rulesets/README.md b/tools/spectral/ipa/rulesets/README.md index d2ef0ac372..0b4dacbcb0 100644 --- a/tools/spectral/ipa/rulesets/README.md +++ b/tools/spectral/ipa/rulesets/README.md @@ -1438,8 +1438,9 @@ Rule checks for the following conditions: - Applies to Operations endpoints, i.e. paths ending with an `operations` segment or an `operations/{operationId}` suffix - The path item must not define any HTTP method other than `get` - - All properties of every 2xx response schema of the `get` method must be marked as - `readOnly: true`; the condition is checked per path item + - On the single Operation endpoint (`.../operations/{operationId}`), where the Get method + is defined, all properties of every 2xx response schema of the `get` method must be + marked as `readOnly: true` - Paths with `x-xgen-IPA-exception` for this rule are excluded from validation #### xgen-IPA-132-operations-endpoint-must-be-a-leaf-resource diff --git a/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js b/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js index 6a9fb0fb40..833470190a 100644 --- a/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js +++ b/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js @@ -1,6 +1,6 @@ import { allPropertiesAreReadOnly } from './utils/resourceEvaluation.js'; import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; -import { isOperationsPath } from './utils/longRunningOperations.js'; +import { isOperationsPath, isSingleOperationPath } from './utils/longRunningOperations.js'; const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; const READ_ONLY_SCHEMA_ERROR_MESSAGE = @@ -23,11 +23,11 @@ export default (input, _, { path, documentInventory, rule }) => { } const pathItem = oas.paths[input] ?? {}; - const errors = checkViolationsAndReturnErrors(pathItem, path, ruleName); + const errors = checkViolationsAndReturnErrors(input, pathItem, path, ruleName); return evaluateAndCollectAdoptionStatus(errors, ruleName, pathItem, path); }; -function checkViolationsAndReturnErrors(pathItem, path, ruleName) { +function checkViolationsAndReturnErrors(input, pathItem, path, ruleName) { try { const errors = []; for (const method of HTTP_METHODS) { @@ -40,9 +40,10 @@ function checkViolationsAndReturnErrors(pathItem, path, ruleName) { } } - // Every 2xx response schema of the get method must consist of readOnly properties only. The - // check is per path item, so the exception for a violation is placed on the flagged path item. - if (hasWritableGetResponseSchema(pathItem)) { + // The readOnly condition is checked on the single Operation endpoint, where the Get method is + // defined. The List method on the collection reuses the Operation resource schema and its + // response shape is validated by xgen-IPA-132-operation-endpoints-must-return-operation-response. + if (isSingleOperationPath(input) && hasWritableGetResponseSchema(pathItem)) { errors.push({ path: [...path, 'get'], message: READ_ONLY_SCHEMA_ERROR_MESSAGE }); } return errors; From 58803309b3c8080a35bd7f15312a9f7caca30a46 Mon Sep 17 00:00:00 2001 From: Julius Jogela Date: Fri, 14 Aug 2026 18:18:32 +0100 Subject: [PATCH 7/7] fix(ipa): validate Operations endpoint methods from the defined path item keys Per review, the read-only method check follows the IPA113ResetMethodMustUsePost approach: the HTTP methods defined on the path item are extracted from its keys and any method other than get is a violation. Also rename the schema helper to hasReadOnlyGetResponseSchema, drop the null path item fallbacks, and remove comments referencing rules that are not part of this PR. --- .../utils/longRunningOperations.test.js | 2 +- .../IPA132OperationMustBeAReadOnlyResource.js | 24 ++++++++++--------- ...32OperationsEndpointMustBeALeafResource.js | 2 +- ...IPA132OperationsEndpointMustNotBeGlobal.js | 2 +- .../functions/utils/longRunningOperations.js | 5 ++-- 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js b/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js index 45acab31e1..461e303c0b 100644 --- a/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js +++ b/tools/spectral/ipa/__tests__/utils/longRunningOperations.test.js @@ -49,7 +49,7 @@ describe('tools/spectral/ipa/utils/longRunningOperations.js', () => { expect(isOperationsPath('/api/atlas/v2/resourceName/operations/{operationId}')).toBe(true); }); - it('ignores custom method paths, which are covered by a dedicated rule', () => { + it('ignores custom method paths', () => { expect(isOperationsPath('/api/atlas/v2/resourceName/operations:customMethod')).toBe(false); expect(isOperationsPath('/api/atlas/v2/resourceName/operations/{operationId}:cancel')).toBe(false); expect(containsOperationsSegment('/api/atlas/v2/resourceName/operations/{operationId}:cancel')).toBe(false); diff --git a/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js b/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js index 833470190a..5a7d789a30 100644 --- a/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js +++ b/tools/spectral/ipa/rulesets/functions/IPA132OperationMustBeAReadOnlyResource.js @@ -2,6 +2,7 @@ import { allPropertiesAreReadOnly } from './utils/resourceEvaluation.js'; import { evaluateAndCollectAdoptionStatus, handleInternalError } from './utils/collectionUtils.js'; import { isOperationsPath, isSingleOperationPath } from './utils/longRunningOperations.js'; +const VALID_METHOD = 'get'; const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; const READ_ONLY_SCHEMA_ERROR_MESSAGE = 'The Operation resource must be read-only. All properties of the GET response schema must be marked as readOnly: true.'; @@ -22,7 +23,7 @@ export default (input, _, { path, documentInventory, rule }) => { return; } - const pathItem = oas.paths[input] ?? {}; + const pathItem = oas.paths[input]; const errors = checkViolationsAndReturnErrors(input, pathItem, path, ruleName); return evaluateAndCollectAdoptionStatus(errors, ruleName, pathItem, path); }; @@ -30,9 +31,11 @@ export default (input, _, { path, documentInventory, rule }) => { function checkViolationsAndReturnErrors(input, pathItem, path, ruleName) { try { const errors = []; - for (const method of HTTP_METHODS) { - // Key presence, not truthiness - a declared method with a null/empty value still advertises the route - if (method !== 'get' && method in pathItem) { + + // Extract the keys which are equivalent of the http methods + const httpMethods = Object.keys(pathItem).filter((key) => HTTP_METHODS.includes(key)); + for (const method of httpMethods) { + if (method !== VALID_METHOD) { errors.push({ path: [...path, method], message: `Operations endpoints are read-only and do not allow the ${method} method.`, @@ -40,10 +43,9 @@ function checkViolationsAndReturnErrors(input, pathItem, path, ruleName) { } } - // The readOnly condition is checked on the single Operation endpoint, where the Get method is - // defined. The List method on the collection reuses the Operation resource schema and its - // response shape is validated by xgen-IPA-132-operation-endpoints-must-return-operation-response. - if (isSingleOperationPath(input) && hasWritableGetResponseSchema(pathItem)) { + // The List method on the collection reuses the Operation resource schema, so the readOnly + // condition is validated on the single Operation endpoint, where the Get method is defined + if (isSingleOperationPath(input) && !hasReadOnlyGetResponseSchema(pathItem)) { errors.push({ path: [...path, 'get'], message: READ_ONLY_SCHEMA_ERROR_MESSAGE }); } return errors; @@ -52,7 +54,7 @@ function checkViolationsAndReturnErrors(input, pathItem, path, ruleName) { } } -function hasWritableGetResponseSchema(pathItem) { +function hasReadOnlyGetResponseSchema(pathItem) { const responses = pathItem.get?.responses ?? {}; for (const [responseCode, response] of Object.entries(responses)) { if (!responseCode.startsWith('2')) { @@ -60,9 +62,9 @@ function hasWritableGetResponseSchema(pathItem) { } for (const mediaTypeObject of Object.values(response?.content ?? {})) { if (mediaTypeObject?.schema && !allPropertiesAreReadOnly(mediaTypeObject.schema)) { - return true; + return false; } } } - return false; + return true; } diff --git a/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustBeALeafResource.js b/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustBeALeafResource.js index e708b74d1a..2ea892f185 100644 --- a/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustBeALeafResource.js +++ b/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustBeALeafResource.js @@ -21,7 +21,7 @@ export default (input, _, { path, documentInventory, rule }) => { } const errors = checkViolationsAndReturnErrors(input, path, ruleName); - return evaluateAndCollectAdoptionStatus(errors, ruleName, oas.paths[input] ?? {}, path); + return evaluateAndCollectAdoptionStatus(errors, ruleName, oas.paths[input], path); }; function checkViolationsAndReturnErrors(input, path, ruleName) { diff --git a/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustNotBeGlobal.js b/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustNotBeGlobal.js index b1eecd4f9d..205600cb1b 100644 --- a/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustNotBeGlobal.js +++ b/tools/spectral/ipa/rulesets/functions/IPA132OperationsEndpointMustNotBeGlobal.js @@ -22,7 +22,7 @@ export default (input, _, { path, documentInventory, rule }) => { } const errors = checkViolationsAndReturnErrors(input, path, ruleName); - return evaluateAndCollectAdoptionStatus(errors, ruleName, oas.paths[input] ?? {}, path); + return evaluateAndCollectAdoptionStatus(errors, ruleName, oas.paths[input], path); }; function checkViolationsAndReturnErrors(input, path, ruleName) { diff --git a/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js b/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js index 2034945338..7e82cc02b6 100644 --- a/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js +++ b/tools/spectral/ipa/rulesets/functions/utils/longRunningOperations.js @@ -7,9 +7,8 @@ export const OPERATIONS_SEGMENT = 'operations'; * the segments describe the resource hierarchy only. * * Custom method paths (`:customMethod`) yield no segments, keeping them out of scope for the - * IPA-132 Operations rules: custom methods on Operations endpoints will be rejected by a dedicated - * rule, and flagging them here would contradict the IPA-109 requirement that custom methods use - * POST or GET. + * IPA-132 Operations rules: flagging their methods here would contradict the IPA-109 requirement + * that custom methods use POST or GET. * * @param {string} path the path to split * @returns {string[]} the resource identifier segments