Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .vitepress/config/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
83 changes: 83 additions & 0 deletions en/contributing/adr/030-openapi-authz-generation.md
Original file line number Diff line number Diff line change
@@ -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).
1 change: 1 addition & 0 deletions en/contributing/adr/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions en/guide/auth/proto-authz.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
111 changes: 111 additions & 0 deletions en/guide/openapi.md
Original file line number Diff line number Diff line change
@@ -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
Loading