diff --git a/.changeset/stupid-dryers-add.md b/.changeset/stupid-dryers-add.md new file mode 100644 index 0000000000..92f5419e66 --- /dev/null +++ b/.changeset/stupid-dryers-add.md @@ -0,0 +1,7 @@ +--- +'@redocly/openapi-core': minor +--- + +Added `security-defined` rule for AsyncAPI 2.x and 3.x. + +**Warning**: this rule is enabled at `error` severity in the `recommended` ruleset, so AsyncAPI documents that previously linted clean may now fail. The rule flags security `$ref`s that target an undefined scheme or a path outside `components.securitySchemes`, and operations that declare no `security` of their own when the applicable servers don't supply one either. diff --git a/docs/@v2/guides/lint-asyncapi.md b/docs/@v2/guides/lint-asyncapi.md index 5b09aec6e7..2fc22980c3 100644 --- a/docs/@v2/guides/lint-asyncapi.md +++ b/docs/@v2/guides/lint-asyncapi.md @@ -51,6 +51,7 @@ The currently supported rules are: - `info-contact`: the `Info` section must contain a valid `Contact` field. - `operation-operationId`: every operation must have a valid `operationId`. +- `security-defined`: every scheme referenced from an operation or server `security` array must be defined in `components.securitySchemes` (AsyncAPI 2.x and 3.0). - `channels-kebab-case`: channel address should be `kebab-case` (lowercase with hyphens). - `no-channel-trailing-slash`: channel names must not have trailing slashes in their address. - `tag-description`: all tags require a description. diff --git a/docs/@v2/rules/async/security-defined.md b/docs/@v2/rules/async/security-defined.md new file mode 100644 index 0000000000..d8a7eb9e5c --- /dev/null +++ b/docs/@v2/rules/async/security-defined.md @@ -0,0 +1,122 @@ +# security-defined + +Verifies that every security scheme referenced from an operation or server `security` array is defined in `components.securitySchemes`. + +| AsyncAPI | Compatibility | +| -------- | ------------- | +| 2.6 | ✅ | +| 3.0 | ✅ | + +## API design principles + +In AsyncAPI 2.x, `security` entries on operations and servers are bare security scheme names that must match a key under `components.securitySchemes`. +A typo or rename breaks the reference but the document remains structurally valid. +The key mismatch is only visible to clients at runtime. + +In AsyncAPI 3.0, `security` entries are `SecurityScheme` objects, typically expressed as `$ref`s into `components.securitySchemes`. + +The `security-defined` rule reports when a security `$ref` does not point into `components.securitySchemes` or when it points at a name that is not defined there. +This rule catches these name mismatches at lint time. + +## Configuration + +| Option | Type | Description | +| -------- | ------ | ------------------------------------------------------------------------------------------ | +| severity | string | Possible values: `off`, `warn`, `error`. Default `error` (in `recommended` configuration). | + +An example configuration: + +```yaml +rules: + security-defined: error +``` + +## Examples + +Given this configuration: + +```yaml +rules: + security-defined: error +``` + +Example of an **incorrect** security definition due to a mismatch between the referenced name and `components.securitySchemes`: + +```yaml +asyncapi: '2.6.0' +channels: + user/signedup: + subscribe: + security: + - OAuth: [] # no matching scheme in components.securitySchemes + message: + messageId: UserSignedUp +components: + securitySchemes: + JWT: + type: http + scheme: bearer +``` + +Example of a **correct** AsyncAPI 2.x security definition: + +```yaml +asyncapi: '2.6.0' +channels: + user/signedup: + subscribe: + security: + - JWT: [] + message: + messageId: UserSignedUp +components: + securitySchemes: + JWT: + type: http + scheme: bearer +``` + +Example of an **incorrect** AsyncAPI 3.0 security definition where the `$ref` points at an undefined scheme: + +```yaml +asyncapi: '3.0.0' +operations: + sendMessage: + action: send + channel: + $ref: '#/channels/userSignedUp' + security: + - $ref: '#/components/securitySchemes/OAuth' +components: + securitySchemes: + JWT: + type: http + scheme: bearer +``` + +Example of a **correct** AsyncAPI 3.0 security definition: + +```yaml +asyncapi: '3.0.0' +operations: + sendMessage: + action: send + channel: + $ref: '#/channels/userSignedUp' + security: + - $ref: '#/components/securitySchemes/JWT' +components: + securitySchemes: + JWT: + type: http + scheme: bearer +``` + +## Related rules + +- [security-defined](../oas/security-defined.md) — equivalent rule for OpenAPI. + +## Resources + +- [AsyncAPI 2.x rule source](https://github.com/Redocly/redocly-cli/blob/main/packages/core/src/rules/async2/security-defined.ts) +- [AsyncAPI 3.0 rule source](https://github.com/Redocly/redocly-cli/blob/main/packages/core/src/rules/async3/security-defined.ts) diff --git a/docs/@v2/rules/built-in-rules.md b/docs/@v2/rules/built-in-rules.md index 5e52a7a058..064bc81718 100644 --- a/docs/@v2/rules/built-in-rules.md +++ b/docs/@v2/rules/built-in-rules.md @@ -123,6 +123,7 @@ Other rules, such as the `struct` and `info.*`, also apply to AsyncAPI. - [channels-kebab-case](./async/channels-kebab-case.md): Channels must be in `kebab-case` format - [no-channel-trailing-slash](./async/no-channel-trailing-slash.md): No trailing slashes on channels +- [security-defined](./async/security-defined.md): Security scheme names referenced from operations or servers must be defined in `components.securitySchemes` ## Arazzo rules diff --git a/docs/@v2/rules/ruleset-templates.md b/docs/@v2/rules/ruleset-templates.md index c46e24c234..fa8b32695b 100644 --- a/docs/@v2/rules/ruleset-templates.md +++ b/docs/@v2/rules/ruleset-templates.md @@ -151,6 +151,7 @@ rules: no-required-schema-properties-undefined: warn no-schema-type-mismatch: warn operation-operationId: warn + security-defined: warn struct: error tag-description: warn ``` @@ -163,6 +164,7 @@ rules: no-required-schema-properties-undefined: warn no-schema-type-mismatch: warn operation-operationId: warn + security-defined: warn struct: error tag-description: warn ``` @@ -362,6 +364,7 @@ rules: no-required-schema-properties-undefined: warn no-schema-type-mismatch: error operation-operationId: warn + security-defined: error security-scopes-defined: warn struct: error tag-description: warn @@ -378,6 +381,7 @@ rules: no-required-schema-properties-undefined: warn no-schema-type-mismatch: error operation-operationId: warn + security-defined: error security-scopes-defined: warn struct: error tag-description: warn diff --git a/docs/@v2/v2.sidebars.yaml b/docs/@v2/v2.sidebars.yaml index 954aad8145..a34a4399bf 100644 --- a/docs/@v2/v2.sidebars.yaml +++ b/docs/@v2/v2.sidebars.yaml @@ -161,6 +161,7 @@ - separator: AsyncAPI - page: rules/async/channels-kebab-case.md - page: rules/async/no-channel-trailing-slash.md + - page: rules/async/security-defined.md - separator: Open-RPC - page: rules/openrpc/spec-no-duplicated-method-params.md - page: rules/openrpc/spec-no-required-params-after-optional.md diff --git a/packages/cli/src/commands/split/asyncapi/gather-asyncapi-component-files.ts b/packages/cli/src/commands/split/asyncapi/gather-asyncapi-component-files.ts index dc3512af50..104c18b1b2 100644 --- a/packages/cli/src/commands/split/asyncapi/gather-asyncapi-component-files.ts +++ b/packages/cli/src/commands/split/asyncapi/gather-asyncapi-component-files.ts @@ -2,7 +2,11 @@ import { isTruthy } from '@redocly/openapi-core'; import * as path from 'node:path'; import { COMPONENTS } from '../constants.js'; -import { type ComponentsFiles, type AnyAsyncApiDefinition } from '../types.js'; +import { + type AnyAsyncApiComponents, + type AnyAsyncApiDefinition, + type ComponentsFiles, +} from '../types.js'; import { getFileNamePath } from '../utils/get-file-name-path.js'; import { findAsyncApiComponentTypes } from './find-asyncapi-component-types.js'; @@ -19,7 +23,7 @@ export function gatherAsyncApiComponentFiles({ ext: string; specVersion: 'async2' | 'async3'; }) { - const { components } = asyncapi; + const components: AnyAsyncApiComponents | undefined = asyncapi.components; if (!components) return; const componentsDir = path.join(asyncapiDir, COMPONENTS); const componentTypes = findAsyncApiComponentTypes(components, specVersion); diff --git a/packages/cli/src/commands/split/asyncapi/iterate-asyncapi-components.ts b/packages/cli/src/commands/split/asyncapi/iterate-asyncapi-components.ts index 368d4c238f..08ccad8c43 100644 --- a/packages/cli/src/commands/split/asyncapi/iterate-asyncapi-components.ts +++ b/packages/cli/src/commands/split/asyncapi/iterate-asyncapi-components.ts @@ -6,11 +6,12 @@ import * as path from 'node:path'; import { writeToFileByExtension } from '../../../utils/miscellaneous.js'; import { COMPONENTS } from '../constants.js'; import { - type ChannelsFiles, - type ComponentsFiles, + type AnyAsyncApiComponents, type AnyAsyncApiDefinition, type AsyncApi2SplittableComponent, type AsyncApi3SplittableComponent, + type ChannelsFiles, + type ComponentsFiles, } from '../types.js'; import { assertWithinDir } from '../utils/assert-within-dir.js'; import { createComponentDir } from '../utils/create-component-dir.js'; @@ -36,7 +37,7 @@ export function iterateAsyncApiComponents({ ext: string; specVersion: 'async2' | 'async3'; }) { - const { components } = asyncapi; + const components: AnyAsyncApiComponents | undefined = asyncapi.components; if (components) { const componentsDir = path.join(asyncapiDir, COMPONENTS); fs.mkdirSync(componentsDir, { recursive: true }); @@ -65,7 +66,7 @@ export function iterateAsyncApiComponents({ writeToFileByExtension(componentData, filename); } - delete asyncapi.components?.[componentType]?.[componentName]; + delete components?.[componentType]?.[componentName]; } removeAsyncApiEmptyComponents(asyncapi, componentType); } diff --git a/packages/cli/src/commands/split/asyncapi/remove-asyncapi-empty-components.ts b/packages/cli/src/commands/split/asyncapi/remove-asyncapi-empty-components.ts index 4cbe298ffa..785f206b2c 100644 --- a/packages/cli/src/commands/split/asyncapi/remove-asyncapi-empty-components.ts +++ b/packages/cli/src/commands/split/asyncapi/remove-asyncapi-empty-components.ts @@ -1,6 +1,7 @@ import { isEmptyObject } from '@redocly/openapi-core'; import type { + AnyAsyncApiComponents, AnyAsyncApiDefinition, AsyncApi2SplittableComponent, AsyncApi3SplittableComponent, @@ -10,7 +11,7 @@ export function removeAsyncApiEmptyComponents( asyncapi: AnyAsyncApiDefinition, componentType: AsyncApi2SplittableComponent | AsyncApi3SplittableComponent ) { - const components = asyncapi.components; + const components: AnyAsyncApiComponents | undefined = asyncapi.components; if (!components) return; if (isEmptyObject(components[componentType])) { diff --git a/packages/cli/src/commands/split/types.ts b/packages/cli/src/commands/split/types.ts index 5cf2ef1aef..a508755953 100644 --- a/packages/cli/src/commands/split/types.ts +++ b/packages/cli/src/commands/split/types.ts @@ -45,6 +45,9 @@ export type AsyncApi3SplittableComponent = (typeof ASYNCAPI3_SPLITTABLE_COMPONEN export type AnyOas3Definition = Oas3Definition | Oas3_1Definition | Oas3_2Definition; export type AnyAsyncApiDefinition = Async2Definition | Async3Definition; +export type AnyAsyncApiComponents = Partial< + Record> +>; export type AnyDefinition = AnyOas3Definition | AnyAsyncApiDefinition; export type SplitArgv = { diff --git a/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap b/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap index 7606619204..1fd01bf84f 100644 --- a/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap +++ b/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap @@ -67,6 +67,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "error", "operation-operationId": "warn", + "security-defined": "error", "security-scopes-defined": "warn", "tag-description": "warn", "tags-alphabetical": "off", @@ -84,6 +85,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "error", "operation-operationId": "warn", + "security-defined": "error", "security-scopes-defined": "warn", "tag-description": "warn", "tags-alphabetical": "off", @@ -490,6 +492,7 @@ exports[`resolveConfig > should resolve extends with local file config which con "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "error", "operation-operationId": "warn", + "security-defined": "error", "security-scopes-defined": "warn", "tag-description": "warn", "tags-alphabetical": "off", @@ -507,6 +510,7 @@ exports[`resolveConfig > should resolve extends with local file config which con "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "error", "operation-operationId": "warn", + "security-defined": "error", "security-scopes-defined": "warn", "tag-description": "warn", "tags-alphabetical": "off", diff --git a/packages/core/src/config/__tests__/load.test.ts b/packages/core/src/config/__tests__/load.test.ts index 20a957169c..8298312ca8 100644 --- a/packages/core/src/config/__tests__/load.test.ts +++ b/packages/core/src/config/__tests__/load.test.ts @@ -190,6 +190,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "warn", "operation-operationId": "warn", + "security-defined": "warn", "security-scopes-defined": "off", "tag-description": "warn", "tags-alphabetical": "off", @@ -207,6 +208,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "warn", "operation-operationId": "warn", + "security-defined": "warn", "security-scopes-defined": "off", "tag-description": "warn", "tags-alphabetical": "off", @@ -550,6 +552,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "error", "operation-operationId": "warn", + "security-defined": "error", "security-scopes-defined": "warn", "tag-description": "warn", "tags-alphabetical": "off", @@ -567,6 +570,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "error", "operation-operationId": "warn", + "security-defined": "error", "security-scopes-defined": "warn", "tag-description": "warn", "tags-alphabetical": "off", @@ -915,6 +919,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "warn", "operation-operationId": "warn", + "security-defined": "warn", "security-scopes-defined": "off", "tag-description": "warn", "tags-alphabetical": "off", @@ -932,6 +937,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "warn", "operation-operationId": "warn", + "security-defined": "warn", "security-scopes-defined": "off", "tag-description": "warn", "tags-alphabetical": "off", @@ -1364,6 +1370,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "warn", "operation-operationId": "warn", + "security-defined": "warn", "security-scopes-defined": "off", "tag-description": "warn", "tags-alphabetical": "off", @@ -1381,6 +1388,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "warn", "operation-operationId": "warn", + "security-defined": "warn", "security-scopes-defined": "off", "tag-description": "warn", "tags-alphabetical": "off", @@ -1724,6 +1732,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "error", "operation-operationId": "warn", + "security-defined": "error", "security-scopes-defined": "warn", "tag-description": "warn", "tags-alphabetical": "off", @@ -1741,6 +1750,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "error", "operation-operationId": "warn", + "security-defined": "error", "security-scopes-defined": "warn", "tag-description": "warn", "tags-alphabetical": "off", @@ -2089,6 +2099,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "warn", "operation-operationId": "warn", + "security-defined": "warn", "security-scopes-defined": "off", "tag-description": "warn", "tags-alphabetical": "off", @@ -2106,6 +2117,7 @@ describe('loadConfig', () => { "no-required-schema-properties-undefined": "warn", "no-schema-type-mismatch": "warn", "operation-operationId": "warn", + "security-defined": "warn", "security-scopes-defined": "off", "tag-description": "warn", "tags-alphabetical": "off", diff --git a/packages/core/src/config/all.ts b/packages/core/src/config/all.ts index 49e54dd65d..b47bb5e6b8 100644 --- a/packages/core/src/config/all.ts +++ b/packages/core/src/config/all.ts @@ -260,6 +260,7 @@ const all: RawGovernanceConfig<'built-in'> = { 'info-license-strict': 'error', 'no-channel-trailing-slash': 'error', 'operation-operationId': 'error', + 'security-defined': 'error', 'security-scopes-defined': 'error', 'tag-description': 'error', 'tags-alphabetical': 'error', @@ -275,6 +276,7 @@ const all: RawGovernanceConfig<'built-in'> = { 'info-license-strict': 'error', 'no-channel-trailing-slash': 'error', 'operation-operationId': 'error', + 'security-defined': 'error', 'security-scopes-defined': 'error', 'tag-description': 'error', 'tags-alphabetical': 'error', diff --git a/packages/core/src/config/minimal.ts b/packages/core/src/config/minimal.ts index 7c2d28e0f8..9e31412171 100644 --- a/packages/core/src/config/minimal.ts +++ b/packages/core/src/config/minimal.ts @@ -244,6 +244,7 @@ const minimal: RawGovernanceConfig<'built-in'> = { 'no-required-schema-properties-undefined': 'warn', 'no-schema-type-mismatch': 'warn', 'operation-operationId': 'warn', + 'security-defined': 'warn', 'security-scopes-defined': 'off', 'tag-description': 'warn', 'tags-alphabetical': 'off', @@ -259,6 +260,7 @@ const minimal: RawGovernanceConfig<'built-in'> = { 'no-required-schema-properties-undefined': 'warn', 'no-schema-type-mismatch': 'warn', 'operation-operationId': 'warn', + 'security-defined': 'warn', 'security-scopes-defined': 'off', 'tag-description': 'warn', 'tags-alphabetical': 'off', diff --git a/packages/core/src/config/recommended-strict.ts b/packages/core/src/config/recommended-strict.ts index 1e484aeba6..65da8bffd9 100644 --- a/packages/core/src/config/recommended-strict.ts +++ b/packages/core/src/config/recommended-strict.ts @@ -244,6 +244,7 @@ const recommendedStrict: RawGovernanceConfig<'built-in'> = { 'no-required-schema-properties-undefined': 'error', 'no-schema-type-mismatch': 'error', 'operation-operationId': 'error', + 'security-defined': 'error', 'security-scopes-defined': 'error', 'tag-description': 'error', 'tags-alphabetical': 'off', @@ -259,6 +260,7 @@ const recommendedStrict: RawGovernanceConfig<'built-in'> = { 'no-required-schema-properties-undefined': 'error', 'no-schema-type-mismatch': 'error', 'operation-operationId': 'error', + 'security-defined': 'error', 'security-scopes-defined': 'error', 'tag-description': 'error', 'tags-alphabetical': 'off', diff --git a/packages/core/src/config/recommended.ts b/packages/core/src/config/recommended.ts index 9877b5b6a7..f583fce1d3 100644 --- a/packages/core/src/config/recommended.ts +++ b/packages/core/src/config/recommended.ts @@ -244,6 +244,7 @@ const recommended: RawGovernanceConfig<'built-in'> = { 'no-required-schema-properties-undefined': 'warn', 'no-schema-type-mismatch': 'error', 'operation-operationId': 'warn', + 'security-defined': 'error', 'security-scopes-defined': 'warn', 'tag-description': 'warn', 'tags-alphabetical': 'off', @@ -259,6 +260,7 @@ const recommended: RawGovernanceConfig<'built-in'> = { 'no-required-schema-properties-undefined': 'warn', 'no-schema-type-mismatch': 'error', 'operation-operationId': 'warn', + 'security-defined': 'error', 'security-scopes-defined': 'warn', 'tag-description': 'warn', 'tags-alphabetical': 'off', diff --git a/packages/core/src/config/spec.ts b/packages/core/src/config/spec.ts index 3b3e69c404..67abac8864 100644 --- a/packages/core/src/config/spec.ts +++ b/packages/core/src/config/spec.ts @@ -244,6 +244,7 @@ const spec: RawGovernanceConfig<'built-in'> = { 'no-required-schema-properties-undefined': 'off', 'no-schema-type-mismatch': 'off', 'operation-operationId': 'off', + 'security-defined': 'off', 'security-scopes-defined': 'off', 'tag-description': 'off', 'tags-alphabetical': 'off', @@ -259,6 +260,7 @@ const spec: RawGovernanceConfig<'built-in'> = { 'no-required-schema-properties-undefined': 'off', 'no-schema-type-mismatch': 'off', 'operation-operationId': 'off', + 'security-defined': 'off', 'security-scopes-defined': 'off', 'tag-description': 'off', 'tags-alphabetical': 'off', diff --git a/packages/core/src/rules/async2/__tests__/security-defined.test.ts b/packages/core/src/rules/async2/__tests__/security-defined.test.ts new file mode 100644 index 0000000000..9d8eb8194c --- /dev/null +++ b/packages/core/src/rules/async2/__tests__/security-defined.test.ts @@ -0,0 +1,711 @@ +import { outdent } from 'outdent'; + +import { parseYamlToDocument, replaceSourceWithRef } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { lintDocument } from '../../../lint.js'; +import { BaseResolver } from '../../../resolve.js'; + +describe('Async2 security-defined', () => { + it('should report when an operation references an undefined security scheme', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '2.6.0' + info: + title: Cool API + version: 1.0.0 + channels: + some/channel: + subscribe: + security: + - undefinedScheme: [] + message: + messageId: Message1 + components: + securitySchemes: + knownScheme: + type: apiKey + in: user + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/channels/some~1channel/subscribe/security/0/undefinedScheme", + "reportOnKey": true, + "source": "asyncapi.yaml", + }, + ], + "message": "There is no \`undefinedScheme\` security scheme defined.", + "ruleId": "security-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should report when a server references an undefined security scheme', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '2.6.0' + info: + title: Cool API + version: 1.0.0 + servers: + production: + url: kafka.example.com + protocol: kafka + security: + - missingScheme: [] + channels: + some/channel: {} + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/servers/production/security/0/missingScheme", + "reportOnKey": true, + "source": "asyncapi.yaml", + }, + ], + "message": "There is no \`missingScheme\` security scheme defined.", + "ruleId": "security-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should not report when all referenced schemes are defined', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '2.6.0' + info: + title: Cool API + version: 1.0.0 + servers: + production: + url: kafka.example.com + protocol: kafka + security: + - apiKeyAuth: [] + channels: + some/channel: + subscribe: + security: + - apiKeyAuth: [] + message: + messageId: Message1 + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should report when the referenced security scheme has no value (half-finished YAML)', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '2.6.0' + info: + title: Cool API + version: 1.0.0 + servers: + production: + url: kafka.example.com + protocol: kafka + security: + - apiKeyAuth: [] + channels: + some/channel: + subscribe: + message: + messageId: Message1 + components: + securitySchemes: + apiKeyAuth: + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/servers/production/security/0/apiKeyAuth", + "reportOnKey": true, + "source": "asyncapi.yaml", + }, + ], + "message": "There is no \`apiKeyAuth\` security scheme defined.", + "ruleId": "security-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should report when the operation security array is empty and no server secures it', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '2.6.0' + info: + title: Cool API + version: 1.0.0 + channels: + some/channel: + subscribe: + security: [] + message: + messageId: Message1 + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/channels/some~1channel/subscribe", + "reportOnKey": true, + "source": "asyncapi.yaml", + }, + ], + "message": "Every operation should have security defined on it.", + "ruleId": "security-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should not report when the operation security array is empty but an applicable server is secured', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '2.6.0' + info: + title: Cool API + version: 1.0.0 + servers: + production: + url: kafka.example.com + protocol: kafka + security: + - apiKeyAuth: [] + channels: + some/channel: + subscribe: + security: [] + message: + messageId: Message1 + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should not report when an applicable server already has security defined', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '2.6.0' + info: + title: Cool API + version: 1.0.0 + servers: + production: + url: kafka.example.com + protocol: kafka + security: + - apiKeyAuth: [] + channels: + some/channel: + subscribe: + message: + messageId: Message1 + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should not report when a channel has an empty servers list and all servers are secured', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '2.6.0' + info: + title: Cool API + version: 1.0.0 + servers: + production: + url: kafka.example.com + protocol: kafka + security: + - apiKeyAuth: [] + channels: + some/channel: + servers: [] + subscribe: + message: + messageId: Message1 + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should report when an applicable server has an empty security array', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '2.6.0' + info: + title: Cool API + version: 1.0.0 + servers: + production: + url: kafka.example.com + protocol: kafka + security: [] + channels: + some/channel: + subscribe: + message: + messageId: Message1 + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/channels/some~1channel/subscribe", + "reportOnKey": true, + "source": "asyncapi.yaml", + }, + ], + "message": "Every operation should have security defined on it.", + "ruleId": "security-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should report when the channel is bound only to servers without security', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '2.6.0' + info: + title: Cool API + version: 1.0.0 + servers: + secured: + url: kafka.example.com + protocol: kafka + security: + - apiKeyAuth: [] + insecure: + url: kafka.internal + protocol: kafka + channels: + some/channel: + servers: + - insecure + subscribe: + message: + messageId: Message1 + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/channels/some~1channel/subscribe", + "reportOnKey": true, + "source": "asyncapi.yaml", + }, + ], + "message": "Every operation should have security defined on it.", + "ruleId": "security-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should report when channel is bound to both secured and unsecured servers', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '2.6.0' + info: + title: Cool API + version: 1.0.0 + servers: + production: + url: kafka.example.com + protocol: kafka + security: + - apiKeyAuth: [] + public: + url: kafka.public.example.com + protocol: kafka + channels: + some/channel: + servers: + - production + - public + subscribe: + message: + messageId: Message1 + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/channels/some~1channel/subscribe", + "reportOnKey": true, + "source": "asyncapi.yaml", + }, + ], + "message": "Every operation should have security defined on it.", + "ruleId": "security-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should ignore reusable component servers without security when checking applicability', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '2.6.0' + info: + title: Cool API + version: 1.0.0 + servers: + production: + url: kafka.example.com + protocol: kafka + security: + - apiKeyAuth: [] + channels: + some/channel: + subscribe: + message: + messageId: Message1 + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + servers: + staging: + url: kafka.staging.example.com + protocol: kafka + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should ignore reusable component channels without security when checking applicability', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '2.6.0' + info: + title: Cool API + version: 1.0.0 + servers: + production: + url: kafka.example.com + protocol: kafka + security: + - apiKeyAuth: [] + staging: + url: kafka.staging.example.com + protocol: kafka + channels: + some/channel: + servers: + - production + subscribe: + message: + messageId: Message1 + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + channels: + reusableChannel: + servers: + - staging + subscribe: + message: + messageId: Message2 + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should not report when security is declared via an operation trait', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '2.6.0' + info: + title: Cool API + version: 1.0.0 + channels: + some/channel: + subscribe: + traits: + - $ref: '#/components/operationTraits/secured' + message: + messageId: Message1 + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + operationTraits: + secured: + security: + - apiKeyAuth: [] + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should report when an operation has no security defined', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '2.6.0' + info: + title: Cool API + version: 1.0.0 + channels: + some/channel: + subscribe: + message: + messageId: Message1 + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/channels/some~1channel/subscribe", + "reportOnKey": true, + "source": "asyncapi.yaml", + }, + ], + "message": "Every operation should have security defined on it.", + "ruleId": "security-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); +}); diff --git a/packages/core/src/rules/async2/index.ts b/packages/core/src/rules/async2/index.ts index 70364765b9..9d827a1308 100644 --- a/packages/core/src/rules/async2/index.ts +++ b/packages/core/src/rules/async2/index.ts @@ -16,6 +16,7 @@ import { TagDescription } from '../common/tag-description.js'; import { TagsAlphabetical } from '../common/tags-alphabetical.js'; import { ChannelsKebabCase } from './channels-kebab-case.js'; import { NoChannelTrailingSlash } from './no-channel-trailing-slash.js'; +import { SecurityDefined } from './security-defined.js'; export const rules: Async2RuleSet<'built-in'> = { struct: Struct as Async2Rule, @@ -24,6 +25,7 @@ export const rules: Async2RuleSet<'built-in'> = { 'info-contact': InfoContact as Async2Rule, 'info-license-strict': InfoLicenseStrict as Async2Rule, 'operation-operationId': OperationOperationId as Async2Rule, + 'security-defined': SecurityDefined, 'channels-kebab-case': ChannelsKebabCase, 'no-channel-trailing-slash': NoChannelTrailingSlash, 'tag-description': TagDescription as Async2Rule, diff --git a/packages/core/src/rules/async2/security-defined.ts b/packages/core/src/rules/async2/security-defined.ts new file mode 100644 index 0000000000..651a65c4e4 --- /dev/null +++ b/packages/core/src/rules/async2/security-defined.ts @@ -0,0 +1,97 @@ +import type { Location } from '../../ref-utils.js'; +import type { + Async2Channel, + Async2Operation, + Async2SecurityRequirement, + Async2SecurityScheme, + Async2Server, +} from '../../typings/asyncapi.js'; +import type { Async2Rule } from '../../visitors.js'; +import type { UserContext } from '../../walk.js'; +import { hasSecurityRequirements, isAsyncOperationSecured } from '../utils.js'; + +export const SecurityDefined: Async2Rule = () => { + const referencedSchemes = new Map< + string, + { + defined?: boolean; + from: Location[]; + } + >(); + const serverHasSecurity = new Map(); + const operationsWithoutSecurity: { location: Location; channelServers?: string[] }[] = []; + let currentChannelServers: string[] | undefined; + let inComponents = false; + + return { + Root: { + leave(_root, { report }: UserContext) { + for (const [name, scheme] of referencedSchemes.entries()) { + if (scheme.defined) continue; + for (const reportedFromLocation of scheme.from) { + report({ + message: `There is no \`${name}\` security scheme defined.`, + location: reportedFromLocation.key(), + }); + } + } + + const allServerNames = [...serverHasSecurity.keys()]; + for (const { location, channelServers } of operationsWithoutSecurity) { + const applicableServers = channelServers ?? allServerNames; + const securedByServer = + applicableServers.length > 0 && + applicableServers.every((name) => serverHasSecurity.get(name)); + if (!securedByServer) { + report({ + message: `Every operation should have security defined on it.`, + location: location.key(), + }); + } + } + }, + }, + SecurityScheme(scheme: Async2SecurityScheme, { key }: UserContext) { + if (scheme == null) return; + referencedSchemes.set(key.toString(), { defined: true, from: [] }); + }, + SecurityRequirement(requirements: Async2SecurityRequirement, { location }: UserContext) { + for (const requirement of Object.keys(requirements)) { + const authScheme = referencedSchemes.get(requirement); + const requirementLocation = location.child([requirement]); + if (!authScheme) { + referencedSchemes.set(requirement, { from: [requirementLocation] }); + } else { + authScheme.from.push(requirementLocation); + } + } + }, + Components: { + enter() { + inComponents = true; + }, + leave() { + inComponents = false; + }, + }, + Server(server: Async2Server, { key }: UserContext) { + if (inComponents) return; + serverHasSecurity.set(key.toString(), hasSecurityRequirements(server)); + }, + Channel: { + skip() { + return inComponents; + }, + enter(channel: Async2Channel) { + currentChannelServers = + Array.isArray(channel?.servers) && channel.servers.length > 0 + ? channel.servers + : undefined; + }, + Operation(operation: Async2Operation, { location, resolve }: UserContext) { + if (isAsyncOperationSecured(operation, resolve)) return; + operationsWithoutSecurity.push({ location, channelServers: currentChannelServers }); + }, + }, + }; +}; diff --git a/packages/core/src/rules/async3/__tests__/security-defined.test.ts b/packages/core/src/rules/async3/__tests__/security-defined.test.ts new file mode 100644 index 0000000000..f0b6813c7f --- /dev/null +++ b/packages/core/src/rules/async3/__tests__/security-defined.test.ts @@ -0,0 +1,1126 @@ +import { outdent } from 'outdent'; + +import { parseYamlToDocument, replaceSourceWithRef } from '../../../../__tests__/utils.js'; +import { createConfig } from '../../../config/index.js'; +import { lintDocument } from '../../../lint.js'; +import { BaseResolver } from '../../../resolve.js'; + +async function lintMultiFileDocument( + body: string, + additionalDocuments: { absoluteRef: string; body: string }[] +) { + const externalRefResolver = new BaseResolver(); + additionalDocuments.forEach((item) => + externalRefResolver.cache.set( + item.absoluteRef, + Promise.resolve(parseYamlToDocument(item.body, item.absoluteRef)) + ) + ); + return await lintDocument({ + externalRefResolver, + document: parseYamlToDocument(body, '/asyncapi.yaml'), + config: await createConfig({ rules: { 'security-defined': 'error' } }), + }); +} + +describe('Async3 security-defined', () => { + it('should report when an operation references an undefined security scheme via $ref', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Cool API + version: 1.0.0 + channels: + some/channel: + address: some/channel + operations: + sendMessage: + action: send + channel: + $ref: '#/channels/some~1channel' + security: + - $ref: '#/components/securitySchemes/undefinedScheme' + components: + securitySchemes: + knownScheme: + type: apiKey + in: user + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/operations/sendMessage/security/0", + "reportOnKey": true, + "source": "asyncapi.yaml", + }, + ], + "message": "There is no \`undefinedScheme\` security scheme defined.", + "ruleId": "security-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should report when the referenced security scheme has no value (half-finished YAML)', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Cool API + version: 1.0.0 + channels: + some/channel: + address: some/channel + operations: + sendMessage: + action: send + channel: + $ref: '#/channels/some~1channel' + security: + - $ref: '#/components/securitySchemes/apiKeyAuth' + components: + securitySchemes: + apiKeyAuth: + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/operations/sendMessage/security/0", + "reportOnKey": true, + "source": "asyncapi.yaml", + }, + ], + "message": "There is no \`apiKeyAuth\` security scheme defined.", + "ruleId": "security-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should report when a server references an undefined security scheme via $ref', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Cool API + version: 1.0.0 + servers: + production: + host: kafka.example.com + protocol: kafka + security: + - $ref: '#/components/securitySchemes/missingScheme' + channels: + some/channel: + address: some/channel + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/servers/production/security/0", + "reportOnKey": true, + "source": "asyncapi.yaml", + }, + ], + "message": "There is no \`missingScheme\` security scheme defined.", + "ruleId": "security-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should not report when all $refs resolve to defined schemes', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Cool API + version: 1.0.0 + servers: + production: + host: kafka.example.com + protocol: kafka + security: + - $ref: '#/components/securitySchemes/apiKeyAuth' + channels: + some/channel: + address: some/channel + operations: + sendMessage: + action: send + channel: + $ref: '#/channels/some~1channel' + security: + - $ref: '#/components/securitySchemes/apiKeyAuth' + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should report when the operation security array is empty and no server secures it', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Cool API + version: 1.0.0 + channels: + some/channel: + address: some/channel + operations: + sendMessage: + action: send + channel: + $ref: '#/channels/some~1channel' + security: [] + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/operations/sendMessage", + "reportOnKey": true, + "source": "asyncapi.yaml", + }, + ], + "message": "Every operation should have security defined on it.", + "ruleId": "security-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should not report when the operation security array is empty but an applicable server is secured', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Cool API + version: 1.0.0 + servers: + production: + host: kafka.example.com + protocol: kafka + security: + - $ref: '#/components/securitySchemes/apiKeyAuth' + channels: + some/channel: + address: some/channel + operations: + sendMessage: + action: send + channel: + $ref: '#/channels/some~1channel' + security: [] + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should report when a $ref points outside components.securitySchemes', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Cool API + version: 1.0.0 + channels: + some/channel: + address: some/channel + operations: + sendMessage: + action: send + channel: + $ref: '#/channels/some~1channel' + security: + - $ref: '#/components/schemas/SomethingElse' + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + schemas: + SomethingElse: + type: object + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/operations/sendMessage/security/0", + "reportOnKey": true, + "source": "asyncapi.yaml", + }, + ], + "message": "Security scheme \`$ref\` must point to \`#/components/securitySchemes\`.", + "ruleId": "security-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should still check security for a root operation that $refs into components.operations', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Cool API + version: 1.0.0 + channels: + some/channel: + address: some/channel + operations: + sendMessage: + $ref: '#/components/operations/SendMessage' + components: + operations: + SendMessage: + action: send + channel: + $ref: '#/channels/some~1channel' + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/operations/sendMessage", + "reportOnKey": true, + "source": "asyncapi.yaml", + }, + ], + "message": "Every operation should have security defined on it.", + "ruleId": "security-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should report when channel is bound to both secured and unsecured servers', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Cool API + version: 1.0.0 + servers: + production: + host: kafka.example.com + protocol: kafka + security: + - $ref: '#/components/securitySchemes/apiKeyAuth' + public: + host: kafka.public.example.com + protocol: kafka + channels: + some/channel: + address: some/channel + servers: + - $ref: '#/servers/production' + - $ref: '#/servers/public' + operations: + sendMessage: + action: send + channel: + $ref: '#/channels/some~1channel' + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/operations/sendMessage", + "reportOnKey": true, + "source": "asyncapi.yaml", + }, + ], + "message": "Every operation should have security defined on it.", + "ruleId": "security-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should not report when security is declared via an operation trait', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Cool API + version: 1.0.0 + channels: + some/channel: + address: some/channel + operations: + sendMessage: + action: send + channel: + $ref: '#/channels/some~1channel' + traits: + - $ref: '#/components/operationTraits/secured' + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + operationTraits: + secured: + security: + - $ref: '#/components/securitySchemes/apiKeyAuth' + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should not report when an applicable server already has security defined', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Cool API + version: 1.0.0 + servers: + production: + host: kafka.example.com + protocol: kafka + security: + - $ref: '#/components/securitySchemes/apiKeyAuth' + channels: + some/channel: + address: some/channel + operations: + sendMessage: + action: send + channel: + $ref: '#/channels/some~1channel' + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should not report when a channel has an empty servers list and all servers are secured', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Cool API + version: 1.0.0 + servers: + production: + host: kafka.example.com + protocol: kafka + security: + - $ref: '#/components/securitySchemes/apiKeyAuth' + channels: + some/channel: + address: some/channel + servers: [] + operations: + sendMessage: + action: send + channel: + $ref: '#/channels/some~1channel' + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should report when an applicable server has an empty security array', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Cool API + version: 1.0.0 + servers: + production: + host: kafka.example.com + protocol: kafka + security: [] + channels: + some/channel: + address: some/channel + operations: + sendMessage: + action: send + channel: + $ref: '#/channels/some~1channel' + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/operations/sendMessage", + "reportOnKey": true, + "source": "asyncapi.yaml", + }, + ], + "message": "Every operation should have security defined on it.", + "ruleId": "security-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should report when the operation channel is bound only to servers without security', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Cool API + version: 1.0.0 + servers: + secured: + host: kafka.example.com + protocol: kafka + security: + - $ref: '#/components/securitySchemes/apiKeyAuth' + insecure: + host: kafka.internal + protocol: kafka + channels: + some/channel: + address: some/channel + servers: + - $ref: '#/servers/insecure' + operations: + sendMessage: + action: send + channel: + $ref: '#/channels/some~1channel' + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/operations/sendMessage", + "reportOnKey": true, + "source": "asyncapi.yaml", + }, + ], + "message": "Every operation should have security defined on it.", + "ruleId": "security-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should not report on reusable operations under components.operations', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Cool API + version: 1.0.0 + servers: + production: + host: kafka.example.com + protocol: kafka + security: + - $ref: '#/components/securitySchemes/apiKeyAuth' + channels: + some/channel: + address: some/channel + operations: + sendMessage: + $ref: '#/components/operations/sendMessage' + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + operations: + sendMessage: + action: send + channel: + $ref: '#/channels/some~1channel' + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should report when an operation has no security defined', async () => { + const document = parseYamlToDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Cool API + version: 1.0.0 + channels: + some/channel: + address: some/channel + operations: + sendMessage: + action: send + channel: + $ref: '#/channels/some~1channel' + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + `, + 'asyncapi.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ + rules: { 'security-defined': 'error' }, + }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/operations/sendMessage", + "reportOnKey": true, + "source": "asyncapi.yaml", + }, + ], + "message": "Every operation should have security defined on it.", + "ruleId": "security-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should not report when a referenced operation is secured via a trait in its own file', async () => { + const results = await lintMultiFileDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Cool API + version: 1.0.0 + channels: + some/channel: + address: some/channel + operations: + sendMessage: + $ref: '/ops.yaml#/operations/sendMessage' + `, + [ + { + absoluteRef: '/ops.yaml', + body: outdent` + asyncapi: '3.0.0' + info: + title: Ops + version: 1.0.0 + operations: + sendMessage: + action: send + channel: + $ref: '/asyncapi.yaml#/channels/some~1channel' + traits: + - $ref: '#/components/operationTraits/secured' + components: + operationTraits: + secured: + security: + - $ref: '#/components/securitySchemes/apiKeyAuth' + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + `, + }, + ] + ); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should not report when a referenced operation is secured by channel servers in its own file', async () => { + const results = await lintMultiFileDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Cool API + version: 1.0.0 + operations: + sendMessage: + $ref: '/ops.yaml#/operations/sendMessage' + `, + [ + { + absoluteRef: '/ops.yaml', + body: outdent` + asyncapi: '3.0.0' + info: + title: Ops + version: 1.0.0 + servers: + production: + host: kafka.example.com + protocol: kafka + security: + - $ref: '#/components/securitySchemes/apiKeyAuth' + channels: + some/channel: + address: some/channel + servers: + - $ref: '#/servers/production' + operations: + sendMessage: + action: send + channel: + $ref: '#/channels/some~1channel' + components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: user + `, + }, + ] + ); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should report when a referenced operation in another file has no security', async () => { + const results = await lintMultiFileDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Cool API + version: 1.0.0 + operations: + sendMessage: + $ref: '/ops.yaml#/operations/sendMessage' + `, + [ + { + absoluteRef: '/ops.yaml', + body: outdent` + asyncapi: '3.0.0' + info: + title: Ops + version: 1.0.0 + channels: + some/channel: + address: some/channel + operations: + sendMessage: + action: send + channel: + $ref: '#/channels/some~1channel' + `, + }, + ] + ); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/operations/sendMessage", + "reportOnKey": true, + "source": "/asyncapi.yaml", + }, + ], + "message": "Every operation should have security defined on it.", + "ruleId": "security-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should not report when root servers is a map-level $ref to a secured server in another file', async () => { + const results = await lintMultiFileDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Cool API + version: 1.0.0 + servers: + $ref: '/servers.yaml' + channels: + myChannel: + address: user/signedup + operations: + sendUserSignedUp: + action: send + channel: + $ref: '#/channels/myChannel' + `, + [ + { + absoluteRef: '/servers.yaml', + body: outdent` + prod: + host: example.com + protocol: kafka + security: + - type: apiKey + in: user + `, + }, + ] + ); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should not report when operations is a map-level $ref and the operation is secured', async () => { + const results = await lintMultiFileDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Cool API + version: 1.0.0 + servers: + prod: + host: example.com + protocol: kafka + security: + - type: apiKey + in: user + channels: + myChannel: + address: user/signedup + operations: + $ref: '/operations.yaml' + `, + [ + { + absoluteRef: '/operations.yaml', + body: outdent` + sendUserSignedUp: + action: send + channel: + $ref: '/asyncapi.yaml#/channels/myChannel' + `, + }, + ] + ); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); + + it('should report when operations is a map-level $ref and an operation has no security', async () => { + const results = await lintMultiFileDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Cool API + version: 1.0.0 + channels: + myChannel: + address: user/signedup + operations: + $ref: '/operations.yaml' + `, + [ + { + absoluteRef: '/operations.yaml', + body: outdent` + sendUserSignedUp: + action: send + channel: + $ref: '/asyncapi.yaml#/channels/myChannel' + `, + }, + ] + ); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/sendUserSignedUp", + "reportOnKey": true, + "source": "/operations.yaml", + }, + ], + "message": "Every operation should have security defined on it.", + "ruleId": "security-defined", + "severity": "error", + "suggest": [], + }, + ] + `); + }); + + it('should not report when security $refs point to a security scheme in an external file', async () => { + const results = await lintMultiFileDocument( + outdent` + asyncapi: '3.0.0' + info: + title: Async3 External Security Ref + version: 1.0.0 + servers: + production: + host: broker.example.com + protocol: mqtt + security: + - $ref: '/apiKey.yaml' + channels: + userSignups: + address: user/signedup + operations: + sendUserSignups: + action: send + channel: + $ref: '#/channels/userSignups' + security: + - $ref: '/apiKey.yaml' + `, + [ + { + absoluteRef: '/apiKey.yaml', + body: outdent` + type: httpApiKey + name: api_key + in: header + description: API key security scheme defined in an external file + `, + }, + ] + ); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); + }); +}); diff --git a/packages/core/src/rules/async3/index.ts b/packages/core/src/rules/async3/index.ts index 0e477ee549..309a2a6f73 100644 --- a/packages/core/src/rules/async3/index.ts +++ b/packages/core/src/rules/async3/index.ts @@ -15,6 +15,7 @@ import { TagDescription } from '../common/tag-description.js'; import { TagsAlphabetical } from '../common/tags-alphabetical.js'; import { ChannelsKebabCase } from './channels-kebab-case.js'; import { NoChannelTrailingSlash } from './no-channel-trailing-slash.js'; +import { SecurityDefined } from './security-defined.js'; import { SecurityScopesDefined } from './security-scopes-defined.js'; export const rules: Async3RuleSet<'built-in'> = { @@ -24,6 +25,7 @@ export const rules: Async3RuleSet<'built-in'> = { 'info-contact': InfoContact as Async3Rule, 'info-license-strict': InfoLicenseStrict as Async3Rule, 'operation-operationId': OperationOperationId as Async3Rule, + 'security-defined': SecurityDefined, 'channels-kebab-case': ChannelsKebabCase, 'no-channel-trailing-slash': NoChannelTrailingSlash, 'tag-description': TagDescription as Async3Rule, diff --git a/packages/core/src/rules/async3/security-defined.ts b/packages/core/src/rules/async3/security-defined.ts new file mode 100644 index 0000000000..1425b36c93 --- /dev/null +++ b/packages/core/src/rules/async3/security-defined.ts @@ -0,0 +1,162 @@ +import { isRef, type Location } from '../../ref-utils.js'; +import type { + Async3Channel, + Async3Operation, + Async3SecurityScheme, + Async3Server, +} from '../../typings/asyncapi3.js'; +import type { Referenced } from '../../typings/openapi.js'; +import type { Async3Rule } from '../../visitors.js'; +import type { UserContext } from '../../walk.js'; +import { hasSecurityRequirements, isAsyncOperationSecured } from '../utils.js'; + +const SECURITY_SCHEMES_POINTER = '#/components/securitySchemes/'; + +type SecurityReference = { + location: Location; + name: string; + refPointer: string; + resolved: boolean; + local: boolean; +}; + +function getRefPointer(ref: string): string { + const hashIndex = ref.indexOf('#'); + return hashIndex === -1 ? ref : ref.slice(hashIndex); +} + +function pointsToSecurityScheme(pointer: string): boolean { + return ( + pointer.startsWith(SECURITY_SCHEMES_POINTER) && + !pointer.slice(SECURITY_SCHEMES_POINTER.length).includes('/') + ); +} + +export const SecurityDefined: Async3Rule = () => { + const references: SecurityReference[] = []; + let rootServers: Record> | undefined; + let rootServersFrom: string | undefined; + let rootOperations: Record> | undefined; + let rootOperationsFrom: string | undefined; + let rootOperationsLocation: Location | undefined; + + const isOperationSecuredByServers = ( + operation: Async3Operation, + resolve: UserContext['resolve'], + resolveFrom?: string + ): boolean => { + const channelRef = operation.channel; + const resolvedChannel = isRef(channelRef) + ? resolve(channelRef, resolveFrom) + : undefined; + const channel: Async3Channel | undefined = isRef(channelRef) + ? resolvedChannel?.node + : channelRef; + const channelServers = channel?.servers; + + let applicableServers: Array>; + let serversFrom: string | undefined; + if (channelServers && channelServers.length > 0) { + applicableServers = channelServers; + serversFrom = resolvedChannel?.location?.source.absoluteRef ?? resolveFrom; + } else { + applicableServers = rootServers ? Object.values(rootServers) : []; + serversFrom = rootServersFrom; + } + + if (applicableServers.length === 0) return false; + return applicableServers.every((server) => { + const serverNode = isRef(server) ? resolve(server, serversFrom).node : server; + return hasSecurityRequirements(serverNode); + }); + }; + + return { + Root: { + enter(root, { location, resolve }: UserContext) { + const serversNode = root?.servers; + if (isRef(serversNode)) { + const resolvedServers = resolve>>(serversNode); + rootServers = resolvedServers.node ?? undefined; + rootServersFrom = resolvedServers.location?.source.absoluteRef; + } else { + rootServers = serversNode; + rootServersFrom = undefined; + } + const operationsNode = root?.operations; + if (isRef(operationsNode)) { + const resolvedOperations = + resolve>>(operationsNode); + rootOperations = resolvedOperations.node ?? undefined; + rootOperationsFrom = resolvedOperations.location?.source.absoluteRef; + rootOperationsLocation = resolvedOperations.location; + } else { + rootOperations = operationsNode; + rootOperationsFrom = undefined; + rootOperationsLocation = location.child(['operations']); + } + }, + leave(_root, { report, resolve }: UserContext) { + for (const reference of references) { + if (reference.local && !pointsToSecurityScheme(reference.refPointer)) { + report({ + message: `Security scheme \`$ref\` must point to \`#/components/securitySchemes\`.`, + location: reference.location.key(), + }); + continue; + } + + if (!reference.resolved) { + report({ + message: `There is no \`${reference.name}\` security scheme defined.`, + location: reference.location.key(), + }); + } + } + + if (rootOperations && rootOperationsLocation) { + const operationsLocation = rootOperationsLocation; + for (const [opName, opRef] of Object.entries(rootOperations)) { + let operation: Async3Operation | undefined; + let resolveFrom: string | undefined; + if (isRef(opRef)) { + const resolvedOperation = resolve(opRef, rootOperationsFrom); + operation = resolvedOperation.node; + resolveFrom = resolvedOperation.location?.source.absoluteRef; + } else { + operation = opRef; + resolveFrom = rootOperationsFrom; + } + if (!operation) continue; + if (isAsyncOperationSecured(operation, resolve, resolveFrom)) continue; + if (isOperationSecuredByServers(operation, resolve, resolveFrom)) continue; + report({ + message: `Every operation should have security defined on it.`, + location: operationsLocation.child([opName]).key(), + }); + } + } + }, + }, + SecuritySchemeList: { + enter(list: Array>, { location, resolve }: UserContext) { + if (!list) return; + for (let i = 0; i < list.length; i++) { + const item = list[i]; + if (!isRef(item)) continue; + const itemLocation = location.child([i]); + const resolved = resolve(item); + const refPointer = getRefPointer(item.$ref); + const name = refPointer.split('/').pop() ?? item.$ref; + references.push({ + location: itemLocation, + name, + refPointer, + resolved: resolved.node != null, + local: item.$ref.startsWith('#'), + }); + } + }, + }, + }; +}; diff --git a/packages/core/src/rules/utils.ts b/packages/core/src/rules/utils.ts index c389c402e0..8be2da2994 100644 --- a/packages/core/src/rules/utils.ts +++ b/packages/core/src/rules/utils.ts @@ -2,6 +2,8 @@ import type { Context as AjvContext } from '@redocly/ajv/dist/2020.js'; import { default as levenshtein } from 'js-levenshtein'; import { isRef, Location } from '../ref-utils.js'; +import type { Async2Operation, Async2OperationTrait } from '../typings/asyncapi.js'; +import type { Async3Operation, Async3OperationTrait } from '../typings/asyncapi3.js'; import type { Oas3Example, Oas3Schema, @@ -46,6 +48,28 @@ export function oasTypeOf(value: unknown) { } } +type SecuredOperation = Async2Operation | Async3Operation; +type SecuredTrait = Async2OperationTrait | Async3OperationTrait; + +export function hasSecurityRequirements(node: { security?: unknown } | undefined): boolean { + const security = node?.security; + return Array.isArray(security) && security.length > 0; +} + +export function isAsyncOperationSecured( + operation: SecuredOperation | undefined, + resolve: UserContext['resolve'], + resolveFrom?: string +): boolean { + if (hasSecurityRequirements(operation)) return true; + if (!Array.isArray(operation?.traits)) return false; + for (const trait of operation.traits) { + const traitNode = isRef(trait) ? resolve(trait, resolveFrom).node : trait; + if (hasSecurityRequirements(traitNode)) return true; + } + return false; +} + /** * Checks if value matches specified JSON schema type * diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts index 44fc928835..b0ed177a58 100644 --- a/packages/core/src/types/redocly-yaml.ts +++ b/packages/core/src/types/redocly-yaml.ts @@ -129,6 +129,7 @@ const builtInAsync2Rules = [ 'info-contact', 'info-license-strict', 'operation-operationId', + 'security-defined', 'tag-description', 'tags-alphabetical', 'channels-kebab-case', @@ -146,6 +147,7 @@ const builtInAsync3Rules = [ 'info-contact', 'info-license-strict', 'operation-operationId', + 'security-defined', 'tag-description', 'tags-alphabetical', 'channels-kebab-case', diff --git a/packages/core/src/typings/asyncapi.ts b/packages/core/src/typings/asyncapi.ts index 3023b22fb9..8e9eb3037f 100644 --- a/packages/core/src/typings/asyncapi.ts +++ b/packages/core/src/typings/asyncapi.ts @@ -1,10 +1,12 @@ +import type { Referenced } from './openapi.js'; + export interface Async2Definition { asyncapi: string; info?: Async2Info; id?: string; - servers?: Record; - channels?: Record; - components?: Record; + servers?: Record; + channels?: Record; + components?: Async2Components; tags?: unknown[]; externalDocs?: unknown; defaultContentType?: string; @@ -30,3 +32,71 @@ export interface Async2License { name: string; url?: string; } + +export interface Async2Components { + schemas?: Record; + messages?: Record; + securitySchemes?: Record; + parameters?: Record; + correlationIds?: Record; + operationTraits?: Record; + messageTraits?: Record; + serverBindings?: Record; + channelBindings?: Record; + operationBindings?: Record; + messageBindings?: Record; +} + +export type Async2SecurityRequirement = Record; + +export interface Async2SecurityScheme { + type: string; + description?: string; + name?: string; + in?: string; + scheme?: string; + bearerFormat?: string; + openIdConnectUrl?: string; + flows?: Record; +} + +export interface Async2Server { + url: string; + protocol: string; + protocolVersion?: string; + description?: string; + variables?: Record; + security?: Async2SecurityRequirement[]; + bindings?: unknown; +} + +export interface Async2Channel { + description?: string; + subscribe?: Async2Operation; + publish?: Async2Operation; + parameters?: Record; + bindings?: unknown; + servers?: string[]; +} + +export interface Async2OperationTrait { + operationId?: string; + summary?: string; + description?: string; + tags?: unknown[]; + externalDocs?: unknown; + bindings?: unknown; + security?: Async2SecurityRequirement[]; +} + +export interface Async2Operation { + operationId?: string; + summary?: string; + description?: string; + tags?: unknown[]; + externalDocs?: unknown; + bindings?: unknown; + traits?: Array>; + message?: unknown; + security?: Async2SecurityRequirement[]; +} diff --git a/packages/core/src/typings/asyncapi3.ts b/packages/core/src/typings/asyncapi3.ts index d7f8afe4bd..4a5bf67a62 100644 --- a/packages/core/src/typings/asyncapi3.ts +++ b/packages/core/src/typings/asyncapi3.ts @@ -1,6 +1,8 @@ +import type { Referenced } from './openapi.js'; + export type Async3Definition = { asyncapi: string; - servers?: Record; + servers?: Record>; info: Async3Info; channels?: Record; components?: Record; @@ -8,23 +10,6 @@ export type Async3Definition = { defaultContentType?: string; }; -export type Async3Operation = { - action: 'send' | 'receive'; - channel: Channel; - title?: string; - summary?: string; - description?: string; - security?: Record[]; - tags?: Tag[]; - externalDocs?: ExternalDocumentation; - bindings?: Record; - traits?: Record[]; - messages?: Record[]; - reply?: Record; - - 'x-send-operations'?: string[]; // internal type -}; - export interface Async3Info { title: string; version: string; @@ -61,18 +46,77 @@ export interface ExternalDoc { description?: string; } -export type Channel = { +export interface Async3Components { + schemas?: Record; + messages?: Record; + securitySchemes?: Record; + parameters?: Record; + correlationIds?: Record; + operationTraits?: Record; + messageTraits?: Record; + serverBindings?: Record; + channelBindings?: Record; + operationBindings?: Record; + messageBindings?: Record; + channels?: Record; + servers?: Record>; +} + +export interface Async3Server { + host: string; + protocol: string; + protocolVersion?: string; + pathname?: string; + description?: string; + variables?: Record; + security?: Array>; + bindings?: unknown; +} + +export interface Async3Channel { address?: string | null; - messages?: Record; + messages?: Record; title?: string; summary?: string; description?: string; - servers?: Record[]; - parameters?: Record; - tags?: Record; + servers?: Array>; + parameters?: Record; + tags?: Tag[]; externalDocs?: ExternalDocumentation; bindings?: ChannelBindings; -}; +} + +/** + * @deprecated Use `Async3Channel` instead. + */ +export type Channel = Async3Channel; + +export interface Async3OperationTrait { + title?: string; + summary?: string; + description?: string; + tags?: Tag[]; + externalDocs?: ExternalDoc; + bindings?: unknown; + security?: Array>; +} + +export interface Async3Operation { + action?: 'send' | 'receive'; + channel?: Referenced; + title?: string; + summary?: string; + description?: string; + tags?: Tag[]; + externalDocs?: ExternalDoc; + operationId?: string; + security?: Array>; + bindings?: unknown; + traits?: Array>; + reply?: unknown; + + 'x-send-operations'?: string[]; // internal type +} export interface ExternalDocumentation { url: string; diff --git a/packages/core/src/visitors.ts b/packages/core/src/visitors.ts index 5bb36b70a0..1532ea3afa 100644 --- a/packages/core/src/visitors.ts +++ b/packages/core/src/visitors.ts @@ -18,9 +18,23 @@ import type { Step, Workflow, } from './typings/arazzo.js'; -import type { Async2Definition } from './typings/asyncapi.js'; -import type { Async3Definition } from './typings/asyncapi3.js'; import type { + Async2Channel, + Async2Definition, + Async2Operation, + Async2SecurityRequirement, + Async2SecurityScheme, + Async2Server, +} from './typings/asyncapi.js'; +import type { + Async3Channel, + Async3Definition, + Async3Operation, + Async3SecurityScheme, + Async3Server, +} from './typings/asyncapi3.js'; +import type { + Referenced, Oas3Definition, Oas3_1Definition, Oas3_2Definition, @@ -262,11 +276,23 @@ type Oas2FlatVisitor = { type Async2FlatVisitor = { Root?: VisitFunctionOrObject; Schema?: VisitFunctionOrObject; + Channel?: VisitFunctionOrObject; + Operation?: VisitFunctionOrObject; + Server?: VisitFunctionOrObject; + SecurityRequirement?: VisitFunctionOrObject; + SecurityScheme?: VisitFunctionOrObject; + NamedSecuritySchemes?: VisitFunctionOrObject>; }; type Async3FlatVisitor = { Root?: VisitFunctionOrObject; Schema?: VisitFunctionOrObject; + Channel?: VisitFunctionOrObject; + Operation?: VisitFunctionOrObject; + Server?: VisitFunctionOrObject; + SecurityScheme?: VisitFunctionOrObject; + SecuritySchemeList?: VisitFunctionOrObject>>; + NamedSecuritySchemes?: VisitFunctionOrObject>; }; type ArazzoFlatVisitor = {