diff --git a/docs/reference/asyncapi-rules.md b/docs/reference/asyncapi-rules.md index 92f460ae2..d22874787 100644 --- a/docs/reference/asyncapi-rules.md +++ b/docs/reference/asyncapi-rules.md @@ -30,6 +30,48 @@ All channel parameters should be defined in the `parameters` object of the chann **Recommended:** Yes +### asyncapi-channel-servers + +Channel servers must be defined in the `servers` object. + +**Bad Example** + +```yaml +asyncapi: "2.0.0" +info: + title: Awesome API + description: A very well defined API + version: "1.0" +servers: + production: + url: "stoplight.io" + protocol: "https" +channels: + hello: + servers: + - development +``` + +**Good Example** + +```yaml +asyncapi: "2.0.0" +info: + title: Awesome API + description: A very well defined API + version: "1.0" +servers: + production: + url: "stoplight.io" + protocol: "https" +channels: + hello: + servers: + - production +``` + +**Recommended:** Yes + ### asyncapi-headers-schema-type-object The schema definition of the application headers must be of type “object”. @@ -219,6 +261,38 @@ This operation ID is essentially a reference for the operation. Tools may use it **Recommended:** Yes +### asyncapi-operation-security + +Operation `security` values must match a scheme defined in the `components.securitySchemes` object. It also checks if there are `oauth2` scopes that have been defined for the given security. + +**Recommended:** Yes + +**Good Example** + +```yaml +channels: + "user/signup": + publish: + security: + - petstore_auth: [] +components: + securitySchemes: + petstore_auth: ... +``` + +**Bad Example** + +```yaml +channels: + "user/signup": + publish: + security: + - not_defined: [] +components: + securitySchemes: + petstore_auth: ... +``` + ### asyncapi-parameter-description Parameter objects should have a `description`. @@ -369,6 +443,38 @@ Server URL should not point at example.com. **Recommended:** No +### asyncapi-server-security + +Server `security` values must match a scheme defined in the `components.securitySchemes` object. It also checks if there are `oauth2` scopes that have been defined for the given security. + +**Recommended:** Yes + +**Good Example** + +```yaml +servers: + production: + url: test.mosquitto.org + security: + - petstore_auth: [] +components: + securitySchemes: + petstore_auth: ... +``` + +**Bad Example** + +```yaml +servers: + production: + url: test.mosquitto.org + security: + - not_defined: [] +components: + securitySchemes: + petstore_auth: ... +``` + ### asyncapi-server-variables All server URL variables should be defined in the `variables` object of the server. They should also not contain redundant variables that do not exist in the server address. diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-channel-servers.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-channel-servers.test.ts new file mode 100644 index 000000000..64ca5f57a --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-channel-servers.test.ts @@ -0,0 +1,141 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule from './__helpers__/tester'; + +testRule('asyncapi-channel-servers', [ + { + name: 'valid case', + document: { + asyncapi: '2.2.0', + servers: { + development: {}, + production: {}, + }, + channels: { + channel: { + servers: ['development'], + }, + }, + }, + errors: [], + }, + + { + name: 'valid case - without defined servers', + document: { + asyncapi: '2.2.0', + servers: { + development: {}, + production: {}, + }, + channels: { + channel: {}, + }, + }, + errors: [], + }, + + { + name: 'valid case - without defined servers in the root', + document: { + asyncapi: '2.2.0', + channels: { + channel: {}, + }, + }, + errors: [], + }, + + { + name: 'valid case - without defined channels in the root', + document: { + asyncapi: '2.2.0', + servers: { + development: {}, + production: {}, + }, + }, + errors: [], + }, + + { + name: 'valid case - with empty array', + document: { + asyncapi: '2.2.0', + servers: { + development: {}, + production: {}, + }, + channels: { + channel: { + servers: [], + }, + }, + }, + errors: [], + }, + + { + name: 'invalid case', + document: { + asyncapi: '2.2.0', + servers: { + development: {}, + production: {}, + }, + channels: { + channel: { + servers: ['another-server'], + }, + }, + }, + errors: [ + { + message: 'Channel contains server that are not defined on the "servers" object.', + path: ['channels', 'channel', 'servers', '0'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'invalid case - one server is defined, another one not', + document: { + asyncapi: '2.2.0', + servers: { + development: {}, + production: {}, + }, + channels: { + channel: { + servers: ['production', 'another-server'], + }, + }, + }, + errors: [ + { + message: 'Channel contains server that are not defined on the "servers" object.', + path: ['channels', 'channel', 'servers', '1'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'invalid case - without defined servers', + document: { + asyncapi: '2.2.0', + channels: { + channel: { + servers: ['production'], + }, + }, + }, + errors: [ + { + message: 'Channel contains server that are not defined on the "servers" object.', + path: ['channels', 'channel', 'servers', '0'], + severity: DiagnosticSeverity.Error, + }, + ], + }, +]); diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-operation-security.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-operation-security.test.ts new file mode 100644 index 000000000..64cb9ff65 --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-operation-security.test.ts @@ -0,0 +1,340 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule from './__helpers__/tester'; + +testRule('asyncapi-operation-security', [ + { + name: 'valid case', + document: { + asyncapi: '2.4.0', + channels: { + channel: { + publish: { + security: [ + { + petstore_auth: [], + }, + ], + }, + }, + }, + components: { + securitySchemes: { + petstore_auth: {}, + }, + }, + }, + errors: [], + }, + + { + name: 'valid case (without security field)', + document: { + asyncapi: '2.4.0', + channels: { + channel: { + publish: {}, + subscribe: {}, + }, + }, + components: { + securitySchemes: { + petstore_auth: {}, + }, + }, + }, + errors: [], + }, + + { + name: 'valid case (oauth2)', + document: { + asyncapi: '2.4.0', + channels: { + channel: { + publish: { + security: [ + { + petstore_auth: ['write:pets'], + }, + ], + }, + }, + }, + components: { + securitySchemes: { + petstore_auth: { + type: 'oauth2', + flows: { + implicit: { + scopes: { + 'write:pets': '...', + 'read:pets': '...', + }, + }, + }, + }, + }, + }, + }, + errors: [], + }, + + { + name: 'invalid case (publish operation)', + document: { + asyncapi: '2.4.0', + channels: { + channel: { + publish: { + security: [ + { + not_defined: [], + }, + ], + }, + }, + }, + components: { + securitySchemes: { + petstore_auth: {}, + }, + }, + }, + errors: [ + { + message: 'Operation must not reference an undefined security scheme.', + path: ['channels', 'channel', 'publish', 'security', '0', 'not_defined'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'invalid case (subscribe operation)', + document: { + asyncapi: '2.4.0', + channels: { + channel: { + subscribe: { + security: [ + { + not_defined: [], + }, + ], + }, + }, + }, + components: { + securitySchemes: { + petstore_auth: {}, + }, + }, + }, + errors: [ + { + message: 'Operation must not reference an undefined security scheme.', + path: ['channels', 'channel', 'subscribe', 'security', '0', 'not_defined'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'invalid case (oauth2)', + document: { + asyncapi: '2.4.0', + channels: { + channel: { + publish: { + security: [ + { + petstore_auth: ['write:pets', 'not:defined'], + }, + ], + }, + }, + }, + components: { + securitySchemes: { + petstore_auth: { + type: 'oauth2', + flows: { + implicit: { + scopes: { + 'write:pets': '...', + 'read:pets': '...', + }, + }, + }, + }, + }, + }, + }, + errors: [ + { + message: 'Non-existing security scope for the specified security scheme. Available: [write:pets, read:pets]', + path: ['channels', 'channel', 'publish', 'security', '0', 'petstore_auth', '1'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'invalid case (oauth2) - multiple flows and not defined scopes', + document: { + asyncapi: '2.4.0', + channels: { + channel: { + publish: { + security: [ + { + petstore_auth: ['write:pets', 'not:defined1', 'not:defined2'], + }, + ], + }, + }, + }, + components: { + securitySchemes: { + petstore_auth: { + type: 'oauth2', + flows: { + implicit: { + scopes: { + 'write:pets': '...', + 'read:pets': '...', + }, + }, + password: { + scopes: { + 'write:dogs': '...', + 'read:dogs': '...', + }, + }, + clientCredentials: { + scopes: { + 'write:cats': '...', + 'read:cats': '...', + }, + }, + }, + }, + }, + }, + }, + errors: [ + { + message: + 'Non-existing security scope for the specified security scheme. Available: [write:pets, read:pets, write:dogs, read:dogs, write:cats, read:cats]', + path: ['channels', 'channel', 'publish', 'security', '0', 'petstore_auth', '1'], + severity: DiagnosticSeverity.Error, + }, + { + message: + 'Non-existing security scope for the specified security scheme. Available: [write:pets, read:pets, write:dogs, read:dogs, write:cats, read:cats]', + path: ['channels', 'channel', 'publish', 'security', '0', 'petstore_auth', '2'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'invalid case (oauth2) - not valid flow', + document: { + asyncapi: '2.4.0', + channels: { + channel: { + publish: { + security: [ + { + petstore_auth: ['write:pets', 'not:defined'], + }, + ], + }, + }, + }, + components: { + securitySchemes: { + petstore_auth: { + type: 'oauth2', + flows: { + implicit: { + scopes: { + 'write:pets': '...', + 'read:pets': '...', + }, + }, + notDefinedFlow: { + scopes: { + 'write:dogs': '...', + 'read:dogs': '...', + }, + }, + }, + }, + }, + }, + }, + errors: [ + { + message: 'Non-existing security scope for the specified security scheme. Available: [write:pets, read:pets]', + path: ['channels', 'channel', 'publish', 'security', '0', 'petstore_auth', '1'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'invalid case (multiple securities)', + document: { + asyncapi: '2.4.0', + channels: { + channel: { + publish: { + security: [ + { + not_defined: [], + }, + { + petstore_auth: ['write:pets', 'not:defined'], + }, + ], + }, + }, + }, + components: { + securitySchemes: { + petstore_auth: { + type: 'oauth2', + flows: { + implicit: { + scopes: { + 'write:pets': '...', + 'read:pets': '...', + }, + }, + notDefinedFlow: { + scopes: { + 'write:dogs': '...', + 'read:dogs': '...', + }, + }, + }, + }, + }, + }, + }, + errors: [ + { + message: 'Operation must not reference an undefined security scheme.', + path: ['channels', 'channel', 'publish', 'security', '0', 'not_defined'], + severity: DiagnosticSeverity.Error, + }, + { + message: 'Non-existing security scope for the specified security scheme. Available: [write:pets, read:pets]', + path: ['channels', 'channel', 'publish', 'security', '1', 'petstore_auth', '1'], + severity: DiagnosticSeverity.Error, + }, + ], + }, +]); diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-server-security.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-server-security.test.ts new file mode 100644 index 000000000..74e160a9c --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-server-security.test.ts @@ -0,0 +1,293 @@ +import { DiagnosticSeverity } from '@stoplight/types'; +import testRule from './__helpers__/tester'; + +testRule('asyncapi-server-security', [ + { + name: 'valid case', + document: { + asyncapi: '2.0.0', + servers: { + production: { + security: [ + { + petstore_auth: [], + }, + ], + }, + }, + components: { + securitySchemes: { + petstore_auth: {}, + }, + }, + }, + errors: [], + }, + + { + name: 'valid case (without security field)', + document: { + asyncapi: '2.0.0', + servers: { + production: {}, + }, + components: { + securitySchemes: { + petstore_auth: {}, + }, + }, + }, + errors: [], + }, + + { + name: 'valid case (oauth2)', + document: { + asyncapi: '2.0.0', + servers: { + production: { + security: [ + { + petstore_auth: ['write:pets'], + }, + ], + }, + }, + components: { + securitySchemes: { + petstore_auth: { + type: 'oauth2', + flows: { + implicit: { + scopes: { + 'write:pets': '...', + 'read:pets': '...', + }, + }, + }, + }, + }, + }, + }, + errors: [], + }, + + { + name: 'invalid case', + document: { + asyncapi: '2.0.0', + servers: { + production: { + security: [ + { + not_defined: [], + }, + ], + }, + }, + components: { + securitySchemes: { + petstore_auth: {}, + }, + }, + }, + errors: [ + { + message: 'Server must not reference an undefined security scheme.', + path: ['servers', 'production', 'security', '0', 'not_defined'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'invalid case (oauth2)', + document: { + asyncapi: '2.0.0', + servers: { + production: { + security: [ + { + petstore_auth: ['write:pets', 'not:defined'], + }, + ], + }, + }, + components: { + securitySchemes: { + petstore_auth: { + type: 'oauth2', + flows: { + implicit: { + scopes: { + 'write:pets': '...', + 'read:pets': '...', + }, + }, + }, + }, + }, + }, + }, + errors: [ + { + message: 'Non-existing security scope for the specified security scheme. Available: [write:pets, read:pets]', + path: ['servers', 'production', 'security', '0', 'petstore_auth', '1'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'invalid case (oauth2) - multiple flows and not defined scopes', + document: { + asyncapi: '2.0.0', + servers: { + production: { + security: [ + { + petstore_auth: ['write:pets', 'not:defined1', 'not:defined2'], + }, + ], + }, + }, + components: { + securitySchemes: { + petstore_auth: { + type: 'oauth2', + flows: { + implicit: { + scopes: { + 'write:pets': '...', + 'read:pets': '...', + }, + }, + password: { + scopes: { + 'write:dogs': '...', + 'read:dogs': '...', + }, + }, + clientCredentials: { + scopes: { + 'write:cats': '...', + 'read:cats': '...', + }, + }, + }, + }, + }, + }, + }, + errors: [ + { + message: + 'Non-existing security scope for the specified security scheme. Available: [write:pets, read:pets, write:dogs, read:dogs, write:cats, read:cats]', + path: ['servers', 'production', 'security', '0', 'petstore_auth', '1'], + severity: DiagnosticSeverity.Error, + }, + { + message: + 'Non-existing security scope for the specified security scheme. Available: [write:pets, read:pets, write:dogs, read:dogs, write:cats, read:cats]', + path: ['servers', 'production', 'security', '0', 'petstore_auth', '2'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'invalid case (oauth2) - not valid flow', + document: { + asyncapi: '2.0.0', + servers: { + production: { + security: [ + { + petstore_auth: ['write:pets', 'not:defined'], + }, + ], + }, + }, + components: { + securitySchemes: { + petstore_auth: { + type: 'oauth2', + flows: { + implicit: { + scopes: { + 'write:pets': '...', + 'read:pets': '...', + }, + }, + notDefinedFlow: { + scopes: { + 'write:dogs': '...', + 'read:dogs': '...', + }, + }, + }, + }, + }, + }, + }, + errors: [ + { + message: 'Non-existing security scope for the specified security scheme. Available: [write:pets, read:pets]', + path: ['servers', 'production', 'security', '0', 'petstore_auth', '1'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: 'invalid case (multiple securities)', + document: { + asyncapi: '2.0.0', + servers: { + production: { + security: [ + { + not_defined: [], + }, + { + petstore_auth: ['write:pets', 'not:defined'], + }, + ], + }, + }, + components: { + securitySchemes: { + petstore_auth: { + type: 'oauth2', + flows: { + implicit: { + scopes: { + 'write:pets': '...', + 'read:pets': '...', + }, + }, + notDefinedFlow: { + scopes: { + 'write:dogs': '...', + 'read:dogs': '...', + }, + }, + }, + }, + }, + }, + }, + errors: [ + { + message: 'Server must not reference an undefined security scheme.', + path: ['servers', 'production', 'security', '0', 'not_defined'], + severity: DiagnosticSeverity.Error, + }, + { + message: 'Non-existing security scope for the specified security scheme. Available: [write:pets, read:pets]', + path: ['servers', 'production', 'security', '1', 'petstore_auth', '1'], + severity: DiagnosticSeverity.Error, + }, + ], + }, +]); diff --git a/packages/rulesets/src/asyncapi/functions/asyncApi2ChannelServers.ts b/packages/rulesets/src/asyncapi/functions/asyncApi2ChannelServers.ts new file mode 100644 index 000000000..573c13b35 --- /dev/null +++ b/packages/rulesets/src/asyncapi/functions/asyncApi2ChannelServers.ts @@ -0,0 +1,54 @@ +import { createRulesetFunction } from '@stoplight/spectral-core'; + +import type { IFunctionResult } from '@stoplight/spectral-core'; + +export default createRulesetFunction< + { servers?: Record; channels?: Record }> }, + null +>( + { + input: { + type: 'object', + properties: { + servers: { + type: 'object', + }, + channels: { + type: 'object', + additionalProperties: { + type: 'object', + properties: { + servers: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + options: null, + }, + function asyncApi2ChannelServers(targetVal, _) { + const results: IFunctionResult[] = []; + if (!targetVal.channels) return results; + const serverNames = Object.keys(targetVal.servers ?? {}); + + Object.entries(targetVal.channels ?? {}).forEach(([channelAddress, channel]) => { + if (!channel.servers) return; + + channel.servers.forEach((serverName, index) => { + if (!serverNames.includes(serverName)) { + results.push({ + message: `Channel contains server that are not defined on the "servers" object.`, + path: ['channels', channelAddress, 'servers', index], + }); + } + }); + }); + + return results; + }, +); diff --git a/packages/rulesets/src/asyncapi/functions/asyncApi2Security.ts b/packages/rulesets/src/asyncapi/functions/asyncApi2Security.ts new file mode 100644 index 000000000..e24c938ab --- /dev/null +++ b/packages/rulesets/src/asyncapi/functions/asyncApi2Security.ts @@ -0,0 +1,83 @@ +import { createRulesetFunction } from '@stoplight/spectral-core'; + +import type { IFunctionResult } from '@stoplight/spectral-core'; +import { isPlainObject } from '@stoplight/json'; + +type Scopes = { + scopes: Record; +}; +type OAuth2Security = { + implicit?: Scopes; + password?: Scopes; + clientCredentials?: Scopes; + authorizationCode?: Scopes; +}; + +const OAuth2Keys = ['implicit', 'password', 'clientCredentials', 'authorizationCode']; +function getAllScopes(oauth2: OAuth2Security): string[] { + const scopes: string[] = []; + OAuth2Keys.forEach(key => { + const flow = oauth2[key] as Scopes; + if (isPlainObject(flow) && isPlainObject(flow)) { + scopes.push(...Object.keys(flow.scopes)); + } + }); + return Array.from(new Set(scopes)); +} + +export default createRulesetFunction, { objectType: 'Server' | 'Operation' }>( + { + input: { + type: 'object', + additionalProperties: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + options: { + type: 'object', + properties: { + objectType: { + type: 'string', + enum: ['Server', 'Operation'], + }, + }, + }, + }, + function asyncApi2Security(targetVal = {}, { objectType }, ctx) { + const results: IFunctionResult[] = []; + const spec = ctx.document.data as { + components: { securitySchemes: Record }; + }; + const securitySchemes = spec?.components?.securitySchemes ?? {}; + const securitySchemesKeys = Object.keys(securitySchemes); + + Object.keys(targetVal).forEach(securityKey => { + if (!securitySchemesKeys.includes(securityKey)) { + results.push({ + message: `${objectType} must not reference an undefined security scheme.`, + path: [...ctx.path, securityKey], + }); + } + + const securityScheme = securitySchemes[securityKey]; + if (securityScheme?.type === 'oauth2') { + const availableScopes = getAllScopes(securityScheme.flows ?? {}); + targetVal[securityKey].forEach((securityScope, index) => { + if (!availableScopes.includes(securityScope)) { + results.push({ + message: `Non-existing security scope for the specified security scheme. Available: [${availableScopes.join( + ', ', + )}]`, + path: [...ctx.path, securityKey, index], + }); + } + }); + } + }); + + return results; + }, +); diff --git a/packages/rulesets/src/asyncapi/index.ts b/packages/rulesets/src/asyncapi/index.ts index f0bbe118c..1596cf823 100644 --- a/packages/rulesets/src/asyncapi/index.ts +++ b/packages/rulesets/src/asyncapi/index.ts @@ -9,6 +9,7 @@ import { } from '@stoplight/spectral-functions'; import asyncApi2ChannelParameters from './functions/asyncApi2ChannelParameters'; +import asyncApi2ChannelServers from './functions/asyncApi2ChannelServers'; import asyncApi2DocumentSchema from './functions/asyncApi2DocumentSchema'; import asyncApi2MessageExamplesValidation from './functions/asyncApi2MessageExamplesValidation'; import asyncApi2OperationIdUniqueness from './functions/asyncApi2OperationIdUniqueness'; @@ -16,6 +17,7 @@ import asyncApi2SchemaValidation from './functions/asyncApi2SchemaValidation'; import asyncApi2PayloadValidation from './functions/asyncApi2PayloadValidation'; import asyncApi2ServerVariables from './functions/asyncApi2ServerVariables'; import { uniquenessTags } from '../shared/functions'; +import asyncApi2Security from './functions/asyncApi2Security'; export default { documentationUrl: 'https://meta.stoplight.io/docs/spectral/docs/reference/asyncapi-rules.md', @@ -71,6 +73,17 @@ export default { function: asyncApi2ChannelParameters, }, }, + 'asyncapi-channel-servers': { + description: 'Channel servers must be defined in the "servers" object.', + message: '{{error}}', + severity: 'error', + type: 'validation', + recommended: true, + given: '$', + then: { + function: asyncApi2ChannelServers, + }, + }, 'asyncapi-headers-schema-type-object': { description: 'Headers schema type must be "object".', message: 'Headers schema type must be "object" ({{error}}).', @@ -214,6 +227,20 @@ export default { function: truthy, }, }, + 'asyncapi-operation-security': { + description: 'Operation have to reference a defined security schemes.', + message: '{{error}}', + severity: 'error', + type: 'validation', + recommended: true, + given: '$.channels[*][publish,subscribe].security.*', + then: { + function: asyncApi2Security, + functionOptions: { + objectType: 'Operation', + }, + }, + }, 'asyncapi-parameter-description': { description: 'Parameter objects must have "description".', recommended: false, @@ -380,6 +407,20 @@ export default { }, }, }, + 'asyncapi-server-security': { + description: 'Server have to reference a defined security schemes.', + message: '{{error}}', + severity: 'error', + type: 'validation', + recommended: true, + given: '$.servers.*.security.*', + then: { + function: asyncApi2Security, + functionOptions: { + objectType: 'Server', + }, + }, + }, 'asyncapi-servers': { description: 'AsyncAPI object must have non-empty "servers" object.', recommended: true,