From b2fa1a40a42b6c19716931809ccdbb5afee76177 Mon Sep 17 00:00:00 2001 From: Matatjahu Date: Tue, 12 Apr 2022 19:48:04 +0200 Subject: [PATCH] feat(rulesets): add rules to validate channels.servers and server.security --- docs/reference/asyncapi-rules.md | 48 ++++ .../asyncapi-channel-servers.test.ts | 118 +++++++++ .../asyncapi-server-security.test.ts | 225 ++++++++++++++++++ .../functions/asyncApi2ChannelServers.ts | 54 +++++ .../asyncapi/functions/asyncApi2Security.ts | 75 ++++++ packages/rulesets/src/asyncapi/index.ts | 24 ++ 6 files changed, 544 insertions(+) create mode 100644 packages/rulesets/src/asyncapi/__tests__/asyncapi-channel-servers.test.ts create mode 100644 packages/rulesets/src/asyncapi/__tests__/asyncapi-server-security.test.ts create mode 100644 packages/rulesets/src/asyncapi/functions/asyncApi2ChannelServers.ts create mode 100644 packages/rulesets/src/asyncapi/functions/asyncApi2Security.ts diff --git a/docs/reference/asyncapi-rules.md b/docs/reference/asyncapi-rules.md index 4e488bbe2..c98024230 100644 --- a/docs/reference/asyncapi-rules.md +++ b/docs/reference/asyncapi-rules.md @@ -24,6 +24,48 @@ Keep trailing slashes off of channel names, as it can cause some confusion. Most **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”. @@ -288,6 +330,12 @@ 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 + ### asyncapi-servers A non empty `servers` object is expected to be located at the root of the document. 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..48c7c7451 --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-channel-servers.test.ts @@ -0,0 +1,118 @@ +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 - 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-server-security.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-server-security.test.ts new file mode 100644 index 000000000..4ed7afbed --- /dev/null +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-server-security.test.ts @@ -0,0 +1,225 @@ +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 (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, + }, + ], + }, +]); diff --git a/packages/rulesets/src/asyncapi/functions/asyncApi2ChannelServers.ts b/packages/rulesets/src/asyncapi/functions/asyncApi2ChannelServers.ts new file mode 100644 index 000000000..af89075a2 --- /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..dffb0281e --- /dev/null +++ b/packages/rulesets/src/asyncapi/functions/asyncApi2Security.ts @@ -0,0 +1,75 @@ +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, null>( + { + input: { + type: 'object', + additionalProperties: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + options: null, + }, + function asyncApi2Security(targetVal = {}, _, 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: `Server 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 434240a9a..def4304c5 100644 --- a/packages/rulesets/src/asyncapi/index.ts +++ b/packages/rulesets/src/asyncapi/index.ts @@ -8,9 +8,11 @@ import { alphabetical, } from '@stoplight/spectral-functions'; +import asyncApi2ChannelServers from './functions/asyncApi2ChannelServers'; import asyncApi2DocumentSchema from './functions/asyncApi2DocumentSchema'; import asyncApi2SchemaValidation from './functions/asyncApi2SchemaValidation'; import asyncApi2PayloadValidation from './functions/asyncApi2PayloadValidation'; +import asyncApi2Security from './functions/asyncApi2Security'; export default { documentationUrl: 'https://meta.stoplight.io/docs/spectral/docs/reference/asyncapi-rules.md', @@ -55,6 +57,17 @@ export default { }, }, }, + '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}}).', @@ -318,6 +331,17 @@ 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, + }, + }, 'asyncapi-servers': { description: 'AsyncAPI object must have non-empty "servers" object.', recommended: true,