diff --git a/.vitepress/config/en.ts b/.vitepress/config/en.ts index b58fa375..87dd4344 100644 --- a/.vitepress/config/en.ts +++ b/.vitepress/config/en.ts @@ -137,6 +137,7 @@ const guideSidebar: DefaultTheme.SidebarItem[] = [ { text: 'Custom Protocols', link: '/en/guide/protocols/custom' }, ], }, + { text: 'OpenAPI', link: '/en/guide/openapi' }, { text: 'Testing', link: '/en/guide/testing', diff --git a/en/contributing/adr/030-openapi-authz-generation.md b/en/contributing/adr/030-openapi-authz-generation.md new file mode 100644 index 00000000..033c35ea --- /dev/null +++ b/en/contributing/adr/030-openapi-authz-generation.md @@ -0,0 +1,83 @@ +# ADR-030: OpenAPI generation with proto-authz overlay + +## Status + +Accepted -- 2026-06-23 (reference pattern shipped in the `car-sharing` example; a framework-level CLI command is a follow-up) + +Extends [ADR-024](./024-auth-authz-strategy.md) (auth/authz strategy) and [ADR-029](./029-internal-service-to-service-auth.md) (the `internal` marker). + +## Context + +Connectum services speak gRPC/Connect, but their contract often has to be consumed by audiences that do not: REST/HTTP clients, API gateways, Swagger UI, client/SDK generators, and external API catalogs. The lingua franca for those is an **OpenAPI** document. + +A generator already exists -- [`protoc-gen-connect-openapi`](https://github.com/sudorandom/protoc-gen-connect-openapi) (available as the buf remote plugin `buf.build/community/sudorandom-connect-openapi`) -- and it produces a faithful OpenAPI v3.1 description of the Connect API surface (paths, request/response schemas, `connectrpc` framing). + +But it is **blind to Connectum's authorization model**. Connectum expresses authz as proto options consumed at runtime by `createProtoAuthzInterceptor` ([ADR-024](./024-auth-authz-strategy.md)): + +- `service_auth` / `method_auth` with `public`, `requires { roles, scopes }`, `policy`, `default_policy`; +- and, from [ADR-029](./029-internal-service-to-service-auth.md), the `internal` marker. + +So a bare OpenAPI document says nothing about which operations need a token, which roles/scopes they demand, or which are intentionally world-open. A consumer reading the published contract would not know how to call the secured methods, and an auditor could not see the security posture from the document. Worse, if the security section were filled in by hand, it would be a **second source of truth** that drifts from what the interceptor actually enforces. + +The question this ADR settles: *how should an OpenAPI contract be produced so that it reflects Connectum authz without introducing drift, and at what layer of the project should that live right now?* + +## Decision + +Generate OpenAPI in **two decoupled steps**, and ship it as a **reference pattern in the `car-sharing` example** rather than as a framework feature -- for now. + +### 1. Base spec — buf remote plugin + +A dedicated buf template (`buf.gen.openapi.yaml`, separate from the offline `buf.gen.yaml`) runs `protoc-gen-connect-openapi` to emit OpenAPI v3.1 under `openapi/`. Keeping it in its own template means the **network-dependent remote plugin never runs during the offline `buf:generate`** (TS codegen and the test suite stay offline and deterministic). + +### 2. Authz overlay — one resolver, no drift + +A post-processor (`scripts/openapi-authz.ts`) reads the proto authz options through **`resolveMethodAuth`** from `@connectum/auth/proto` -- *the same reader `createProtoAuthzInterceptor` uses at runtime* -- and patches each operation in the generated document. One resolver drives **both** runtime enforcement and the published contract, so the two cannot disagree. + +The mapping from Connectum authz to OpenAPI: + +| Connectum authz (proto) | `resolveMethodAuth` result | OpenAPI patch on the operation | +|---|---|---| +| `public: true` | `auth.public === true` | `security: []` (explicitly open) + `x-connectum-public: true` | +| gated (default / `requires` / `policy`) | `auth.public === false` | `security: [{ bearerAuth: [] }]` | +| `requires { roles: [...] }` | `auth.requires.roles` | `x-connectum-required-roles: [...]` | +| `requires { scopes: [...] }` | `auth.requires.scopes` | `x-connectum-required-scopes: [...]` | +| `internal: true` (1.1.0, [ADR-029](./029-internal-service-to-service-auth.md)) | `auth.internal === true` | `x-internal: true` | + +A single `bearerAuth` (`http`/`bearer`/JWT) security scheme is added to `components.securitySchemes`, matching the gateway's `createJwtAuthInterceptor` contract. + +`x-connectum-*` are vendor extensions: they are advisory metadata for humans, gateways, and catalogs (the wire enforcement remains the interceptor's job), while `security` is standard OpenAPI that off-the-shelf tooling already understands. + +### 3. Layer — example-level reference pattern, not a shipped CLI + +The whole pattern is **example code plus codegen config**. It reads proto and works against the **published `@connectum/auth` 1.0.0** -- it does **not** modify any published package. A first-class `connectum openapi` CLI command that generalises the overlay across arbitrary services is recorded as a **follow-up**, deferred because the generic mechanism is not yet validated and would touch published packages. + +## Consequences + +### Positive + +- **Single source of truth.** Authz is declared once in proto; the runtime interceptor and the published OpenAPI both derive from it via the same resolver -- no drift by construction. +- **Audit-friendly contract.** Reviewers and external consumers see, per operation, whether a token is required and which roles/scopes it demands, in standard OpenAPI plus explicit extensions. +- **Off-the-shelf tooling.** The `security` requirements and `bearerAuth` scheme are consumed as-is by Swagger UI, client generators, and gateways. +- **Offline build stays offline.** The remote plugin is isolated to its own template, so `buf:generate`/tests do not gain a network dependency. +- **No published-package risk.** Shipping it as an example proves the pattern end-to-end before committing the framework to an API. + +### Negative + +- **Network dependency for generation.** `pnpm openapi` invokes a buf remote plugin; it is not usable fully offline (mitigated by committing the generated `openapi/*.yaml` as the showcase output). +- **Streaming RPCs get no operation** in the base spec unless the plugin's `with-streaming` opt is set (OpenAPI's request/response model does not fit server-/client-/bidi-streaming; an inherent limitation, called out in the guide). +- **Not yet a framework feature.** Each service that wants this today copies the example's overlay; the reusable CLI is still a follow-up. +- **Vendor extensions are advisory.** `x-connectum-*` document intent but do not themselves enforce anything; enforcement remains the interceptor's responsibility. + +## Alternatives Considered + +- **Hand-write the OpenAPI security sections.** Rejected: a second source of truth that drifts from the interceptor -- exactly the failure this ADR avoids. +- **Ship a `connectum openapi` CLI command now.** Deferred: the generic, multi-service mechanism is unvalidated, and baking it into a published package before the pattern is proven would be premature. Kept as a follow-up. +- **Rely solely on the plugin's `google.api.http` / security handling.** Insufficient: the plugin understands standard annotations, not Connectum's `connectum.auth.v1` options, so it cannot derive `security` from our authz model. +- **[`protodocs`](https://github.com/sudorandom/protodocs) for a docs site.** Deferred: the author states it is not yet ready for use; revisit when it stabilises. + +## References + +- Reference implementation: the `car-sharing` example (`buf.gen.openapi.yaml`, `scripts/openapi-authz.ts`, committed `openapi/*.yaml`). +- Guide: [OpenAPI](/en/guide/openapi). +- [ADR-024: Auth/Authz Strategy](./024-auth-authz-strategy.md), [ADR-029: Internal Service-to-Service Auth](./029-internal-service-to-service-auth.md). +- [`protoc-gen-connect-openapi`](https://github.com/sudorandom/protoc-gen-connect-openapi). diff --git a/en/contributing/adr/index.md b/en/contributing/adr/index.md index cc6e8d78..c665f8ee 100644 --- a/en/contributing/adr/index.md +++ b/en/contributing/adr/index.md @@ -28,6 +28,7 @@ Architecture Decision Records (ADRs) capture important design decisions with the | 027 | [External Contracts vs EventBus](/en/contributing/adr/027-external-contracts-vs-eventbus) | 2026-06-12 | External contracts at adapter layer; EventBus stays protobuf-only; remove `sync` | | 028 | [Service Catalog](/en/contributing/adr/028-service-catalog) | 2026-06-15 | Declarative `ctx.call`/`ctx.stream`, `defineService`, sync `RemoteResolver`, split error model, buf codegen | | 029 | [Internal Service-to-Service Auth](/en/contributing/adr/029-internal-service-to-service-auth) | 2026-06-21 | First-class `internal` marker distinct from `public`; per-service trust-source interceptor (mesh identity / issuer-bound JWKS) for worker/out-of-process callers | +| 030 | [OpenAPI generation with proto-authz overlay](/en/contributing/adr/030-openapi-authz-generation) | 2026-06-23 | Generate OpenAPI v3.1 (buf remote plugin) + overlay reading the same `resolveMethodAuth` the runtime uses → contract reflects authz, no drift; reference pattern in `car-sharing`, framework CLI deferred | ## Creating a New ADR diff --git a/en/guide/auth/proto-authz.md b/en/guide/auth/proto-authz.md index 6e30d27a..4ef5b702 100644 --- a/en/guide/auth/proto-authz.md +++ b/en/guide/auth/proto-authz.md @@ -208,4 +208,5 @@ await server.start(); - [Auth Context](/en/guide/auth/context) -- accessing identity in handlers - [@connectum/auth](/en/packages/auth) -- Package Guide - [@connectum/auth API](/en/api/@connectum/auth/) -- Full API Reference +- [OpenAPI](/en/guide/openapi) -- publish a contract whose `security` reflects these options - [ADR-024: Auth/Authz Strategy](/en/contributing/adr/024-auth-authz-strategy) -- design rationale diff --git a/en/guide/openapi.md b/en/guide/openapi.md new file mode 100644 index 00000000..78e6662e --- /dev/null +++ b/en/guide/openapi.md @@ -0,0 +1,111 @@ +--- +outline: deep +--- + +# OpenAPI + +Connectum services speak gRPC/Connect, but their contract often has to reach audiences that do not: REST/HTTP clients, API gateways, Swagger UI, SDK generators, and API catalogs. The common denominator for those is an **OpenAPI** document. + +Connectum's authorization lives in `.proto` options ([Proto-Based Authz](/en/guide/auth/proto-authz)). The pattern on this page generates an OpenAPI v3.1 contract that **reflects that authz** -- the same options the `createProtoAuthzInterceptor` enforces at runtime also drive the published spec, so the two cannot drift. + +::: tip Reference implementation +The [`car-sharing`](https://github.com/Connectum-Framework/examples/tree/main/car-sharing) example ships this end-to-end (`buf.gen.openapi.yaml`, `scripts/openapi-authz.ts`, committed `openapi/*.yaml`). The rationale is recorded in [ADR-030](/en/contributing/adr/030-openapi-authz-generation). +::: + +## How it works + +Generation is **two decoupled steps**, run together via one script: + +1. **Base spec** -- the [`protoc-gen-connect-openapi`](https://github.com/sudorandom/protoc-gen-connect-openapi) buf remote plugin emits a faithful OpenAPI v3.1 description of the Connect API (paths, schemas, framing). It is accurate about the *shape* but blind to Connectum authz. +2. **Authz overlay** -- a small post-processor reads the `connectum.auth.v1` options via **`resolveMethodAuth`** from `@connectum/auth/proto` (the *same* reader the runtime interceptor uses) and patches each operation with `security` and `x-connectum-*` extensions. + +Keep the OpenAPI generation in its **own** buf template, separate from the one that emits your TypeScript. The remote plugin needs network access; isolating it means your normal `buf:generate` and tests stay offline and deterministic. + +### 1. Base spec template + +```yaml +# buf.gen.openapi.yaml — separate from buf.gen.yaml (offline TS codegen) +version: v2 +clean: true +inputs: + - directory: proto +plugins: + - remote: buf.build/community/sudorandom-connect-openapi:v0.25.7 + out: openapi + opt: + - format=yaml + - features=connectrpc +``` + +### 2. Authz overlay + +The overlay walks each service's methods, resolves the proto authz, and patches the corresponding operation. `resolveMethodAuth(method)` returns `{ public, internal?, policy?, requires? }`. + +```typescript +import { readFileSync, writeFileSync } from 'node:fs'; +import { parse, stringify } from 'yaml'; +import { resolveMethodAuth } from '@connectum/auth/proto'; +import { OrderService } from '#gen/order/v1/order_pb.ts'; + +// One JWT bearer scheme, matching createJwtAuthInterceptor at the edge. +const bearerAuth = { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }; + +const path = 'openapi/order/v1/order.openapi.yaml'; +const doc: any = parse(readFileSync(path, 'utf8')); +doc.components ??= {}; +doc.components.securitySchemes ??= {}; +doc.components.securitySchemes.bearerAuth = bearerAuth; + +for (const method of OrderService.methods) { + const op = doc.paths?.[`/${OrderService.typeName}/${method.name}`]?.post; + if (op === undefined) continue; // e.g. streaming RPCs are not emitted by default + const auth = resolveMethodAuth(method); + + if (auth.public) { + op.security = []; // explicitly open — overrides any global requirement + op['x-connectum-public'] = true; + continue; + } + op.security = [{ bearerAuth: [] }]; + if (auth.requires?.roles.length) op['x-connectum-required-roles'] = [...auth.requires.roles]; + if (auth.requires?.scopes.length) op['x-connectum-required-scopes'] = [...auth.requires.scopes]; +} + +writeFileSync(path, stringify(doc)); +``` + +Wire both steps into one command: + +```json +{ + "scripts": { + "openapi": "buf generate --template buf.gen.openapi.yaml && node scripts/openapi-authz.ts" + } +} +``` + +## Authz → OpenAPI mapping + +| Connectum authz (proto) | `resolveMethodAuth` | OpenAPI patch on the operation | +|---|---|---| +| `public: true` | `auth.public === true` | `security: []` + `x-connectum-public: true` | +| gated (default / `requires` / `policy`) | `auth.public === false` | `security: [{ bearerAuth: [] }]` | +| `requires { roles: [...] }` | `auth.requires.roles` | `x-connectum-required-roles: [...]` | +| `requires { scopes: [...] }` | `auth.requires.scopes` | `x-connectum-required-scopes: [...]` | +| `internal: true` | `auth.internal === true` | `x-internal: true` | + +`security` and the `bearerAuth` scheme are standard OpenAPI that off-the-shelf tooling already understands. The `x-connectum-*` entries are **vendor extensions** -- advisory metadata for humans, gateways, and catalogs. They document intent; the wire enforcement remains the interceptor's job ([Proto-Based Authz](/en/guide/auth/proto-authz)). + +## Notes & limitations + +- **Streaming RPCs** (server-, client-, or bidi-streaming) get no operation in the base spec unless the plugin's `with-streaming` opt is set -- OpenAPI's request/response model does not fit streaming. The plugin leaves an empty path entry, and the overlay skips any method that has no generated operation. (In `car-sharing`, `FleetService.ListVehicles` is server-streaming and is therefore skipped.) +- **Network dependency.** `pnpm openapi` invokes a buf *remote* plugin, so generation is not fully offline. Commit the generated `openapi/*.yaml` so consumers and CI have the spec without regenerating. +- **The `internal` marker** (`x-internal: true`) requires `@connectum/auth` >= 1.1.0 (see [ADR-029](/en/contributing/adr/029-internal-service-to-service-auth)). On 1.0.0, `internal` methods resolve as gated. +- **Reference pattern, not a CLI (yet).** This is example code plus codegen config; it does not modify any published package. A first-class `connectum openapi` command is a [planned follow-up](/en/contributing/adr/030-openapi-authz-generation). + +## Related + +- [Proto-Based Authz](/en/guide/auth/proto-authz) -- the authz options this overlay reads +- [@connectum/auth](/en/packages/auth) -- Package Guide (`resolveMethodAuth`, `getPublicMethods`) +- [ADR-030: OpenAPI generation with proto-authz overlay](/en/contributing/adr/030-openapi-authz-generation) -- design rationale +- [`protoc-gen-connect-openapi`](https://github.com/sudorandom/protoc-gen-connect-openapi) -- the base generator