From 3a6d20e22c88bfa3cd4612f0c7058ff549a84c40 Mon Sep 17 00:00:00 2001 From: intech Date: Tue, 23 Jun 2026 15:29:29 +0400 Subject: [PATCH] feat(car-sharing): generate authz-aware OpenAPI from the proto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add OpenAPI v3.1 generation that reflects Connectum's proto authz, so the published contract cannot drift from what the gateway enforces at runtime. - buf.gen.openapi.yaml: separate template (inputs: proto) running the sudorandom/protoc-gen-connect-openapi buf remote plugin, kept apart from the offline buf.gen.yaml so the network plugin never runs during buf:generate. - scripts/openapi-authz.ts: overlay that reads connectum.auth.v1 options via resolveMethodAuth — the same reader createProtoAuthzInterceptor uses — and injects per-operation security (bearerAuth), security: [] + x-connectum-public for public methods, and x-connectum-required-roles/-scopes. - openapi/{trips,fleet,billing}/v1/*.openapi.yaml: the committed showcase output. - package.json: `pnpm openapi` script (base gen → authz overlay) + yaml devDep. - README: "OpenAPI — the published contract reflects the authz" section. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MdeH7fExPmiRHRirGuvGk3 --- car-sharing/README.md | 34 ++ car-sharing/buf.gen.openapi.yaml | 17 + .../openapi/billing/v1/billing.openapi.yaml | 434 ++++++++++++++++++ .../openapi/fleet/v1/fleet.openapi.yaml | 339 ++++++++++++++ .../openapi/trips/v1/trips.openapi.yaml | 395 ++++++++++++++++ car-sharing/package.json | 4 +- car-sharing/scripts/openapi-authz.ts | 75 +++ 7 files changed, 1297 insertions(+), 1 deletion(-) create mode 100644 car-sharing/buf.gen.openapi.yaml create mode 100644 car-sharing/openapi/billing/v1/billing.openapi.yaml create mode 100644 car-sharing/openapi/fleet/v1/fleet.openapi.yaml create mode 100644 car-sharing/openapi/trips/v1/trips.openapi.yaml create mode 100644 car-sharing/scripts/openapi-authz.ts diff --git a/car-sharing/README.md b/car-sharing/README.md index 9c3c665..59e89cd 100644 --- a/car-sharing/README.md +++ b/car-sharing/README.md @@ -405,6 +405,40 @@ needs **no signing secret** (the old `k8s/secret-jwt.yaml` was removed); only `OATHKEEPER_JWKS_URI` / `JWT_ISSUER` / `JWT_AUDIENCE` in `k8s/configmap.yaml`. See `ory/oathkeeper/README.md` for the full edge + ext_authz details. +## OpenAPI — the published contract reflects the authz + +The proto is the single source of truth: the same `connectum.auth.v1` options that +the gateway **enforces** at runtime also drive the **published** OpenAPI contract, +so the two cannot drift. Generate it with: + +```bash +pnpm openapi # buf generate (base) → scripts/openapi-authz.ts (authz overlay) +``` + +Two steps, decoupled from the offline `pnpm buf:generate`: + +1. **Base spec** — `buf.gen.openapi.yaml` runs + [`protoc-gen-connect-openapi`](https://github.com/sudorandom/protoc-gen-connect-openapi) + (buf remote plugin) → OpenAPI v3.1 for the Connect API under `openapi/`. +2. **Authz overlay** — `scripts/openapi-authz.ts` reads the `connectum.auth.v1` + options via **`resolveMethodAuth`** (the *same* reader the runtime + `createProtoAuthzInterceptor` uses) and injects, per operation: + - a `bearerAuth` (JWT) `securityScheme`; + - `security: [{ bearerAuth: [] }]` on methods that require auth (e.g. + `StartTrip`, `GetTrip`); + - `security: []` + `x-connectum-public: true` on `public` methods (e.g. the + tokenless worker RPCs `EndTrip` / `RecordTrip`, and all of fleet/billing); + - `x-connectum-required-roles` / `-scopes` where the proto declares them. + +The committed `openapi/*.yaml` is the showcase output — regenerate with +`pnpm openapi` after changing the proto or its auth options. + +> **Notes.** Streaming RPCs (`ListVehicles`) are omitted from the base spec +> unless the plugin's `with-streaming` opt is set. The overlay works on the +> published `@connectum/auth` 1.0.0; once 1.1.0 is out, methods marked +> `internal` in the proto also get `x-internal: true` (the resolver then +> exposes that marker). + ## Build the image ```bash diff --git a/car-sharing/buf.gen.openapi.yaml b/car-sharing/buf.gen.openapi.yaml new file mode 100644 index 0000000..e7e406d --- /dev/null +++ b/car-sharing/buf.gen.openapi.yaml @@ -0,0 +1,17 @@ +version: v2 +clean: true +# Separate template for the OpenAPI BASE spec, kept apart from buf.gen.yaml so the +# network-dependent remote plugin never runs during the offline `buf:generate` +# (TS codegen). `inputs: proto` limits generation to this example's own services +# (the connectum/auth options module is still resolved as a workspace import, but +# not itself emitted). Run via `pnpm openapi`, which then applies the Connectum +# authz overlay (scripts/openapi-authz.ts) on top of this base. +inputs: + - directory: proto +plugins: + # OpenAPI v3.1 from the Connect proto (sudorandom/protoc-gen-connect-openapi). + - remote: buf.build/community/sudorandom-connect-openapi:v0.25.7 + out: openapi + opt: + - format=yaml + - features=connectrpc diff --git a/car-sharing/openapi/billing/v1/billing.openapi.yaml b/car-sharing/openapi/billing/v1/billing.openapi.yaml new file mode 100644 index 0000000..48b8bb2 --- /dev/null +++ b/car-sharing/openapi/billing/v1/billing.openapi.yaml @@ -0,0 +1,434 @@ +openapi: 3.1.0 +info: + title: billing.v1 +paths: + /billing.v1.BillingService/AddCharge: + post: + tags: + - billing.v1.BillingService + summary: AddCharge + description: >- + AddCharge adds a charge line (in minor units, e.g. cents) to a trip's + tab + and returns the minted charge id. The saga derives the amount from the trip + duration. + operationId: billing.v1.BillingService.AddCharge + parameters: + - name: Connect-Protocol-Version + in: header + required: true + schema: + $ref: "#/components/schemas/connect-protocol-version" + - name: Connect-Timeout-Ms + in: header + schema: + $ref: "#/components/schemas/connect-timeout-header" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/billing.v1.AddChargeRequest" + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/billing.v1.AddChargeResponse" + default: + description: Error + content: + application/json: + schema: + $ref: "#/components/schemas/connect.error" + security: [] + x-connectum-public: true + /billing.v1.BillingService/OpenTab: + post: + tags: + - billing.v1.BillingService + summary: OpenTab + description: >- + OpenTab opens a billing tab for the given trip and returns it. + Idempotent + by trip_id: re-opening an existing tab returns the same tab. + operationId: billing.v1.BillingService.OpenTab + parameters: + - name: Connect-Protocol-Version + in: header + required: true + schema: + $ref: "#/components/schemas/connect-protocol-version" + - name: Connect-Timeout-Ms + in: header + schema: + $ref: "#/components/schemas/connect-timeout-header" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/billing.v1.OpenTabRequest" + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/billing.v1.OpenTabResponse" + default: + description: Error + content: + application/json: + schema: + $ref: "#/components/schemas/connect.error" + security: [] + x-connectum-public: true + /billing.v1.BillingService/RefundCharge: + post: + tags: + - billing.v1.BillingService + summary: RefundCharge + description: |- + RefundCharge refunds a charge by id (saga compensation for AddCharge). + Idempotent: a missing or already-refunded charge is a no-op success. + operationId: billing.v1.BillingService.RefundCharge + parameters: + - name: Connect-Protocol-Version + in: header + required: true + schema: + $ref: "#/components/schemas/connect-protocol-version" + - name: Connect-Timeout-Ms + in: header + schema: + $ref: "#/components/schemas/connect-timeout-header" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/billing.v1.RefundChargeRequest" + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/billing.v1.RefundChargeResponse" + default: + description: Error + content: + application/json: + schema: + $ref: "#/components/schemas/connect.error" + security: [] + x-connectum-public: true + /billing.v1.BillingService/Settle: + post: + tags: + - billing.v1.BillingService + summary: Settle + description: |- + Settle finalizes (closes) a trip's tab — the terminal happy-path billing + step. FAILED_PRECONDITION if there is no tab for the trip. + operationId: billing.v1.BillingService.Settle + parameters: + - name: Connect-Protocol-Version + in: header + required: true + schema: + $ref: "#/components/schemas/connect-protocol-version" + - name: Connect-Timeout-Ms + in: header + schema: + $ref: "#/components/schemas/connect-timeout-header" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/billing.v1.SettleRequest" + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/billing.v1.SettleResponse" + default: + description: Error + content: + application/json: + schema: + $ref: "#/components/schemas/connect.error" + security: [] + x-connectum-public: true + /billing.v1.BillingService/VoidTab: + post: + tags: + - billing.v1.BillingService + summary: VoidTab + description: >- + VoidTab voids a trip's tab (saga compensation for OpenTab). Idempotent: + a + missing or already-void tab is a no-op success. + operationId: billing.v1.BillingService.VoidTab + parameters: + - name: Connect-Protocol-Version + in: header + required: true + schema: + $ref: "#/components/schemas/connect-protocol-version" + - name: Connect-Timeout-Ms + in: header + schema: + $ref: "#/components/schemas/connect-timeout-header" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/billing.v1.VoidTabRequest" + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/billing.v1.VoidTabResponse" + default: + description: Error + content: + application/json: + schema: + $ref: "#/components/schemas/connect.error" + security: [] + x-connectum-public: true +components: + schemas: + billing.v1.AddChargeRequest: + type: object + properties: + tripId: + type: string + title: trip_id + amountCents: + type: + - integer + - string + title: amount_cents + format: int64 + description: >- + Charge amount in minor units (e.g. cents). The saga derives it from + the + trip duration. + title: AddChargeRequest + additionalProperties: false + billing.v1.AddChargeResponse: + type: object + properties: + chargeId: + type: string + title: charge_id + title: AddChargeResponse + additionalProperties: false + billing.v1.OpenTabRequest: + type: object + properties: + tripId: + type: string + title: trip_id + title: OpenTabRequest + additionalProperties: false + billing.v1.OpenTabResponse: + type: object + properties: + tab: + title: tab + $ref: "#/components/schemas/billing.v1.Tab" + title: OpenTabResponse + additionalProperties: false + billing.v1.RefundChargeRequest: + type: object + properties: + tripId: + type: string + title: trip_id + chargeId: + type: string + title: charge_id + title: RefundChargeRequest + additionalProperties: false + billing.v1.RefundChargeResponse: + type: object + properties: + refunded: + type: boolean + title: refunded + description: >- + True if the charge existed and is now refunded (or was already + refunded); + false if the charge id is unknown. Either way the call succeeds. + title: RefundChargeResponse + additionalProperties: false + billing.v1.SettleRequest: + type: object + properties: + tripId: + type: string + title: trip_id + title: SettleRequest + additionalProperties: false + billing.v1.SettleResponse: + type: object + properties: + tab: + title: tab + $ref: "#/components/schemas/billing.v1.Tab" + title: SettleResponse + additionalProperties: false + billing.v1.Tab: + type: object + properties: + id: + type: string + title: id + open: + type: boolean + title: open + description: True while the tab is open; false once it is settled or voided. + title: Tab + additionalProperties: false + billing.v1.VoidTabRequest: + type: object + properties: + tripId: + type: string + title: trip_id + title: VoidTabRequest + additionalProperties: false + billing.v1.VoidTabResponse: + type: object + properties: + voided: + type: boolean + title: voided + description: >- + True if a tab existed and is now void (or was already void); false + if there + was never a tab for the trip. Either way the call succeeds (idempotent). + title: VoidTabResponse + additionalProperties: false + connect-protocol-version: + type: number + title: Connect-Protocol-Version + enum: + - 1 + description: Define the version of the Connect protocol + const: 1 + connect-timeout-header: + type: number + title: Connect-Timeout-Ms + description: Define the timeout, in ms + connect.error: + type: object + properties: + code: + type: string + examples: + - not_found + enum: + - canceled + - unknown + - invalid_argument + - deadline_exceeded + - not_found + - already_exists + - permission_denied + - resource_exhausted + - failed_precondition + - aborted + - out_of_range + - unimplemented + - internal + - unavailable + - data_loss + - unauthenticated + description: The status code, which should be an enum value of + [google.rpc.Code][google.rpc.Code]. + message: + type: string + description: A developer-facing error message, which should be in English. Any + user-facing error message should be localized and sent in the + [google.rpc.Status.details][google.rpc.Status.details] field, or + localized by the client. + details: + type: array + items: + $ref: "#/components/schemas/connect.error_details.Any" + description: A list of messages that carry the error details. There is no limit + on the number of messages. + title: Connect Error + additionalProperties: true + description: "Error type returned by Connect: + https://connectrpc.com/docs/go/errors/#http-representation" + connect.error_details.Any: + type: object + properties: + type: + type: string + description: "A URL that acts as a globally unique identifier for the type of + the serialized message. For example: + `type.googleapis.com/google.rpc.ErrorInfo`. This is used to + determine the schema of the data in the `value` field and is the + discriminator for the `debug` field." + value: + type: string + format: binary + description: The Protobuf message, serialized as bytes and base64-encoded. The + specific message type is identified by the `type` field. + debug: + oneOf: + - type: object + title: Any + additionalProperties: true + description: Detailed error information. + discriminator: + propertyName: type + title: Debug + description: Deserialized error detail payload. The 'type' field indicates the + schema. This field is for easier debugging and should not be relied + upon for application logic. + additionalProperties: true + description: Contains an arbitrary serialized message along with a @type that + describes the type of the serialized message, with an additional debug + field for ConnectRPC error details. + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: Connectum JWT auth (createJwtAuthInterceptor / proto authz). + Required for every non-public method. +security: [] +tags: + - name: billing.v1.BillingService + description: >- + BillingService keeps a trip's billing tab and charges (a leaf service). + + Like FleetService, it is INTERNAL: it is reached only by the trip handler's + ctx.call (OpenTab pre-Phase-2) and, in Phase 2, by the Temporal worker's + activities (OpenTab / AddCharge / Settle and their compensations VoidTab / + RefundCharge). It is marked `service_auth { public: true }`, so the + gateway-facing JWT auth and proto authz interceptors skip every RPC: the + worker's ConnectRPC clients carry no Authorization header, exactly like the + in-process ctx.call did. The trust boundary is the mesh (Istio mTLS + + AuthorizationPolicy; see istio/). + + Persistence stays in-memory in Phase 2 — the DURABLE billing state lives in + the Temporal workflow history. The in-memory ledger only needs to record + enough (tab state, charges keyed by id) for the compensations to be + idempotent: VoidTab on an already-void tab and RefundCharge on an + already-refunded charge are no-op successes. diff --git a/car-sharing/openapi/fleet/v1/fleet.openapi.yaml b/car-sharing/openapi/fleet/v1/fleet.openapi.yaml new file mode 100644 index 0000000..f77f98f --- /dev/null +++ b/car-sharing/openapi/fleet/v1/fleet.openapi.yaml @@ -0,0 +1,339 @@ +openapi: 3.1.0 +info: + title: fleet.v1 +paths: + /fleet.v1.FleetService/GetVehicle: + post: + tags: + - fleet.v1.FleetService + summary: GetVehicle + description: GetVehicle returns a vehicle by id, or NOT_FOUND if unknown. + operationId: fleet.v1.FleetService.GetVehicle + parameters: + - name: Connect-Protocol-Version + in: header + required: true + schema: + $ref: "#/components/schemas/connect-protocol-version" + - name: Connect-Timeout-Ms + in: header + schema: + $ref: "#/components/schemas/connect-timeout-header" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/fleet.v1.GetVehicleRequest" + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/fleet.v1.GetVehicleResponse" + default: + description: Error + content: + application/json: + schema: + $ref: "#/components/schemas/connect.error" + security: [] + x-connectum-public: true + /fleet.v1.FleetService/ListVehicles: {} + /fleet.v1.FleetService/ReleaseVehicle: + post: + tags: + - fleet.v1.FleetService + summary: ReleaseVehicle + description: >- + ReleaseVehicle returns a vehicle to the available pool (available=true, + status="available") and returns it. NOT_FOUND if the id is unknown. + FAILED_PRECONDITION if the vehicle is in maintenance (cannot be released + back into service from the fleet API). + operationId: fleet.v1.FleetService.ReleaseVehicle + parameters: + - name: Connect-Protocol-Version + in: header + required: true + schema: + $ref: "#/components/schemas/connect-protocol-version" + - name: Connect-Timeout-Ms + in: header + schema: + $ref: "#/components/schemas/connect-timeout-header" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/fleet.v1.ReleaseVehicleRequest" + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/fleet.v1.Vehicle" + default: + description: Error + content: + application/json: + schema: + $ref: "#/components/schemas/connect.error" + security: [] + x-connectum-public: true + /fleet.v1.FleetService/ReserveVehicle: + post: + tags: + - fleet.v1.FleetService + summary: ReserveVehicle + description: >- + ReserveVehicle marks a vehicle reserved (available=false, + status="reserved") and returns it. NOT_FOUND if the id is unknown; + FAILED_PRECONDITION if the vehicle is already reserved or in maintenance + (not currently available). + operationId: fleet.v1.FleetService.ReserveVehicle + parameters: + - name: Connect-Protocol-Version + in: header + required: true + schema: + $ref: "#/components/schemas/connect-protocol-version" + - name: Connect-Timeout-Ms + in: header + schema: + $ref: "#/components/schemas/connect-timeout-header" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/fleet.v1.ReserveVehicleRequest" + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/fleet.v1.Vehicle" + default: + description: Error + content: + application/json: + schema: + $ref: "#/components/schemas/connect.error" + security: [] + x-connectum-public: true +components: + schemas: + connect-protocol-version: + type: number + title: Connect-Protocol-Version + enum: + - 1 + description: Define the version of the Connect protocol + const: 1 + connect-timeout-header: + type: number + title: Connect-Timeout-Ms + description: Define the timeout, in ms + connect.error: + type: object + properties: + code: + type: string + examples: + - not_found + enum: + - canceled + - unknown + - invalid_argument + - deadline_exceeded + - not_found + - already_exists + - permission_denied + - resource_exhausted + - failed_precondition + - aborted + - out_of_range + - unimplemented + - internal + - unavailable + - data_loss + - unauthenticated + description: The status code, which should be an enum value of + [google.rpc.Code][google.rpc.Code]. + message: + type: string + description: A developer-facing error message, which should be in English. Any + user-facing error message should be localized and sent in the + [google.rpc.Status.details][google.rpc.Status.details] field, or + localized by the client. + details: + type: array + items: + $ref: "#/components/schemas/connect.error_details.Any" + description: A list of messages that carry the error details. There is no limit + on the number of messages. + title: Connect Error + additionalProperties: true + description: "Error type returned by Connect: + https://connectrpc.com/docs/go/errors/#http-representation" + connect.error_details.Any: + type: object + properties: + type: + type: string + description: "A URL that acts as a globally unique identifier for the type of + the serialized message. For example: + `type.googleapis.com/google.rpc.ErrorInfo`. This is used to + determine the schema of the data in the `value` field and is the + discriminator for the `debug` field." + value: + type: string + format: binary + description: The Protobuf message, serialized as bytes and base64-encoded. The + specific message type is identified by the `type` field. + debug: + oneOf: + - type: object + title: Any + additionalProperties: true + description: Detailed error information. + discriminator: + propertyName: type + title: Debug + description: Deserialized error detail payload. The 'type' field indicates the + schema. This field is for easier debugging and should not be relied + upon for application logic. + additionalProperties: true + description: Contains an arbitrary serialized message along with a @type that + describes the type of the serialized message, with an additional debug + field for ConnectRPC error details. + fleet.v1.GetVehicleRequest: + type: object + properties: + id: + type: string + title: id + title: GetVehicleRequest + additionalProperties: false + fleet.v1.GetVehicleResponse: + type: object + properties: + vehicle: + title: vehicle + $ref: "#/components/schemas/fleet.v1.Vehicle" + title: GetVehicleResponse + additionalProperties: false + fleet.v1.ListVehiclesRequest: + type: object + properties: + availableOnly: + type: boolean + title: available_only + description: When true, only vehicles that are currently available are streamed. + pageSize: + type: integer + title: page_size + format: int32 + description: Max vehicles per page. Server clamps to a sane default/max when 0 + or large. + pageToken: + type: string + title: page_token + description: >- + Cursor: the id of the last vehicle from the previous page. Empty = + first + page. The stream itself carries no token, so the client derives the cursor + from the last streamed Vehicle.id. + title: ListVehiclesRequest + additionalProperties: false + fleet.v1.Location: + type: object + properties: + lat: + type: number + title: lat + format: double + lng: + type: number + title: lng + format: double + title: Location + additionalProperties: false + description: Location is a vehicle's last-known position. + fleet.v1.ReleaseVehicleRequest: + type: object + properties: + id: + type: string + title: id + title: ReleaseVehicleRequest + additionalProperties: false + fleet.v1.ReserveVehicleRequest: + type: object + properties: + id: + type: string + title: id + title: ReserveVehicleRequest + additionalProperties: false + fleet.v1.Vehicle: + type: object + properties: + id: + type: string + title: id + model: + type: string + title: model + available: + type: boolean + title: available + description: >- + available <=> status == "available". Kept for the trip handler, + which only + checks availability. + status: + type: string + title: status + description: >- + Lifecycle state: "available" | "reserved" | "maintenance". Modeled + as a + string (not a proto enum) so the generated TypeScript stays erasable — + this example's tsconfig sets `erasableSyntaxOnly`, which rejects the native + `enum` that protoc-gen-es emits. The string domain is pinned in code via a + `const` object (see src/db/schema.ts VehicleStatus). + location: + title: location + $ref: "#/components/schemas/fleet.v1.Location" + title: Vehicle + additionalProperties: false + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: Connectum JWT auth (createJwtAuthInterceptor / proto authz). + Required for every non-public method. +security: [] +tags: + - name: fleet.v1.FleetService + description: >- + FleetService is the vehicle system of record (a leaf service). + + It is an INTERNAL service: it is reached only by the trip handler via a + cross-service ctx.call, never by external clients. The service-level + `public: true` option marks every RPC as public, so the gateway-facing JWT + auth and proto authz interceptors skip it. An internal ctx.call carries no + Authorization header (Connectum does not auto-propagate inbound headers), so + without this annotation an in-process call from the trip handler would be + rejected as unauthenticated by the same interceptor chain that protects the + edge. In production the network boundary is enforced by Istio mTLS + an + AuthorizationPolicy that only admits the trips ServiceAccount (see istio/). + + Persistence (Phase 1): the fleet is the only service backed by a real + database — Drizzle ORM over Postgres (postgres.js). Tests inject a PGlite + in-process Postgres via the same DI the server uses, so no Docker is needed. diff --git a/car-sharing/openapi/trips/v1/trips.openapi.yaml b/car-sharing/openapi/trips/v1/trips.openapi.yaml new file mode 100644 index 0000000..bd801ec --- /dev/null +++ b/car-sharing/openapi/trips/v1/trips.openapi.yaml @@ -0,0 +1,395 @@ +openapi: 3.1.0 +info: + title: trips.v1 +paths: + /trips.v1.TripService/EndTrip: + post: + tags: + - trips.v1.TripService + summary: EndTrip + description: >- + EndTrip transitions a trip to a terminal status (ENDED on the happy + path, + CANCELLED as the record-trip compensation). INTERNAL: called by the worker's + activities. Public so the tokenless worker passes the gateway auth chain. + operationId: trips.v1.TripService.EndTrip + parameters: + - name: Connect-Protocol-Version + in: header + required: true + schema: + $ref: "#/components/schemas/connect-protocol-version" + - name: Connect-Timeout-Ms + in: header + schema: + $ref: "#/components/schemas/connect-timeout-header" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/trips.v1.EndTripRequest" + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/trips.v1.EndTripResponse" + default: + description: Error + content: + application/json: + schema: + $ref: "#/components/schemas/connect.error" + security: [] + x-connectum-public: true + /trips.v1.TripService/GetTrip: + post: + tags: + - trips.v1.TripService + summary: GetTrip + description: >- + GetTrip returns a trip's current status. Requires a valid JWT. It reads + live + status from the running workflow via a Temporal Workflow Query, falling back + to a terminal status when the workflow has closed. + operationId: trips.v1.TripService.GetTrip + parameters: + - name: Connect-Protocol-Version + in: header + required: true + schema: + $ref: "#/components/schemas/connect-protocol-version" + - name: Connect-Timeout-Ms + in: header + schema: + $ref: "#/components/schemas/connect-timeout-header" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/trips.v1.GetTripRequest" + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/trips.v1.GetTripResponse" + default: + description: Error + content: + application/json: + schema: + $ref: "#/components/schemas/connect.error" + security: + - bearerAuth: [] + /trips.v1.TripService/RecordTrip: + post: + tags: + - trips.v1.TripService + summary: RecordTrip + description: >- + RecordTrip creates the trip ledger row (status STARTED). INTERNAL: + called by + the worker's reserve→record activity. Public so the tokenless worker passes + the gateway auth chain. + operationId: trips.v1.TripService.RecordTrip + parameters: + - name: Connect-Protocol-Version + in: header + required: true + schema: + $ref: "#/components/schemas/connect-protocol-version" + - name: Connect-Timeout-Ms + in: header + schema: + $ref: "#/components/schemas/connect-timeout-header" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/trips.v1.RecordTripRequest" + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/trips.v1.RecordTripResponse" + default: + description: Error + content: + application/json: + schema: + $ref: "#/components/schemas/connect.error" + security: [] + x-connectum-public: true + /trips.v1.TripService/StartTrip: + post: + tags: + - trips.v1.TripService + summary: StartTrip + description: >- + StartTrip starts a trip for (user_id, vehicle_id). Requires a valid JWT. + It pre-checks availability SYNCHRONOUSLY (FAILED_PRECONDITION if the vehicle + is unavailable, NOT_FOUND if unknown — propagated from FleetService) BEFORE + starting the durable workflow, then returns the trip id, initial status, and + the workflow id. + operationId: trips.v1.TripService.StartTrip + parameters: + - name: Connect-Protocol-Version + in: header + required: true + schema: + $ref: "#/components/schemas/connect-protocol-version" + - name: Connect-Timeout-Ms + in: header + schema: + $ref: "#/components/schemas/connect-timeout-header" + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/trips.v1.StartTripRequest" + required: true + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/trips.v1.StartTripResponse" + default: + description: Error + content: + application/json: + schema: + $ref: "#/components/schemas/connect.error" + security: + - bearerAuth: [] +components: + schemas: + connect-protocol-version: + type: number + title: Connect-Protocol-Version + enum: + - 1 + description: Define the version of the Connect protocol + const: 1 + connect-timeout-header: + type: number + title: Connect-Timeout-Ms + description: Define the timeout, in ms + connect.error: + type: object + properties: + code: + type: string + examples: + - not_found + enum: + - canceled + - unknown + - invalid_argument + - deadline_exceeded + - not_found + - already_exists + - permission_denied + - resource_exhausted + - failed_precondition + - aborted + - out_of_range + - unimplemented + - internal + - unavailable + - data_loss + - unauthenticated + description: The status code, which should be an enum value of + [google.rpc.Code][google.rpc.Code]. + message: + type: string + description: A developer-facing error message, which should be in English. Any + user-facing error message should be localized and sent in the + [google.rpc.Status.details][google.rpc.Status.details] field, or + localized by the client. + details: + type: array + items: + $ref: "#/components/schemas/connect.error_details.Any" + description: A list of messages that carry the error details. There is no limit + on the number of messages. + title: Connect Error + additionalProperties: true + description: "Error type returned by Connect: + https://connectrpc.com/docs/go/errors/#http-representation" + connect.error_details.Any: + type: object + properties: + type: + type: string + description: "A URL that acts as a globally unique identifier for the type of + the serialized message. For example: + `type.googleapis.com/google.rpc.ErrorInfo`. This is used to + determine the schema of the data in the `value` field and is the + discriminator for the `debug` field." + value: + type: string + format: binary + description: The Protobuf message, serialized as bytes and base64-encoded. The + specific message type is identified by the `type` field. + debug: + oneOf: + - type: object + title: Any + additionalProperties: true + description: Detailed error information. + discriminator: + propertyName: type + title: Debug + description: Deserialized error detail payload. The 'type' field indicates the + schema. This field is for easier debugging and should not be relied + upon for application logic. + additionalProperties: true + description: Contains an arbitrary serialized message along with a @type that + describes the type of the serialized message, with an additional debug + field for ConnectRPC error details. + trips.v1.EndTripRequest: + type: object + properties: + tripId: + type: string + title: trip_id + status: + type: string + title: status + description: >- + Target terminal status: "ENDED" (normal finish) or "CANCELLED" + (compensation). See the TripStatus domain in src/temporal/tripStatus.ts. + title: EndTripRequest + additionalProperties: false + trips.v1.EndTripResponse: + type: object + properties: + trip: + title: trip + $ref: "#/components/schemas/trips.v1.Trip" + title: EndTripResponse + additionalProperties: false + trips.v1.GetTripRequest: + type: object + properties: + tripId: + type: string + title: trip_id + title: GetTripRequest + additionalProperties: false + trips.v1.GetTripResponse: + type: object + properties: + trip: + title: trip + $ref: "#/components/schemas/trips.v1.Trip" + title: GetTripResponse + additionalProperties: false + trips.v1.RecordTripRequest: + type: object + properties: + userId: + type: string + title: user_id + vehicleId: + type: string + title: vehicle_id + tripId: + type: string + title: trip_id + title: RecordTripRequest + additionalProperties: false + trips.v1.RecordTripResponse: + type: object + properties: + trip: + title: trip + $ref: "#/components/schemas/trips.v1.Trip" + title: RecordTripResponse + additionalProperties: false + trips.v1.StartTripRequest: + type: object + properties: + userId: + type: string + title: user_id + vehicleId: + type: string + title: vehicle_id + title: StartTripRequest + additionalProperties: false + trips.v1.StartTripResponse: + type: object + properties: + trip: + title: trip + $ref: "#/components/schemas/trips.v1.Trip" + workflowId: + type: string + title: workflow_id + description: >- + The durable Temporal workflow id (== the trip id) that orchestrates + the + saga. Clients poll status via GetTrip(trip.id). (Additive in Phase 2.) + title: StartTripResponse + additionalProperties: false + trips.v1.Trip: + type: object + properties: + id: + type: string + title: id + status: + type: string + title: status + description: >- + Trip lifecycle status: "STARTED" | "ENDED" | "SETTLED" | + "CANCELLED". + Modeled as a string (not a proto enum) so the generated TypeScript stays + erasable (this example's tsconfig sets `erasableSyntaxOnly`). The domain is + pinned in code by the TripStatus `const` object (src/temporal/tripStatus.ts). + title: Trip + additionalProperties: false + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: Connectum JWT auth (createJwtAuthInterceptor / proto authz). + Required for every non-public method. +security: [] +tags: + - name: trips.v1.TripService + description: >- + TripService is the EDGE service — the only one external clients call. + + Phase 2: StartTrip no longer orchestrates with ctx.call. It now does a + SYNCHRONOUS availability pre-check (a ctx.call to FleetService/GetVehicle, + preserving today's FAILED_PRECONDITION/NOT_FOUND error contract) and then + STARTS a durable Temporal workflow (TripWorkflow) that owns the saga: + reserve → record → end → openTab → addCharge → settle, with automatic + compensation on failure. StartTrip returns immediately with the trip id, an + initial status, and the workflow id. Live status is read via GetTrip, which + issues a Temporal Workflow Query. + + RecordTrip and EndTrip are INTERNAL RPCs the workflow's activities call from + the (JWT-less) Temporal worker. They own the in-memory trip ledger. + + Authorization is declared in proto and enforced by the gateway interceptors: + - service_auth.default_policy = "allow": any AUTHENTICATED caller may invoke + methods that carry no method-level rule (StartTrip, GetTrip) — a valid JWT + is required (no token -> UNAUTHENTICATED) but no particular role. + - method_auth { public: true } on RecordTrip/EndTrip: those two skip both + authn and authz, so the worker's tokenless ConnectRPC client may call + them (mirroring how fleet/billing are service-level public). The real + network trust boundary is the mesh (Istio mTLS + AuthorizationPolicy). diff --git a/car-sharing/package.json b/car-sharing/package.json index 454d892..37bfb1b 100644 --- a/car-sharing/package.json +++ b/car-sharing/package.json @@ -16,6 +16,7 @@ "buf:generate": "buf generate", "buf:lint": "buf lint", "build:proto": "pnpm run buf:generate", + "openapi": "buf generate --template buf.gen.openapi.yaml && node scripts/openapi-authz.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:push": "drizzle-kit push", @@ -69,6 +70,7 @@ "@types/node": "^25.2.0", "drizzle-kit": "^0.31.0", "jose": "^6.2.3", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "yaml": "^2.6.0" } } diff --git a/car-sharing/scripts/openapi-authz.ts b/car-sharing/scripts/openapi-authz.ts new file mode 100644 index 0000000..0066df7 --- /dev/null +++ b/car-sharing/scripts/openapi-authz.ts @@ -0,0 +1,75 @@ +// Overlay Connectum authz onto the base OpenAPI. +// +// The base spec (sudorandom/protoc-gen-connect-openapi) knows the Connect API +// but NOT Connectum's authz. This step reads the connectum.auth.v1 proto options +// via `resolveMethodAuth` — the SAME reader the runtime `createProtoAuthzInterceptor` +// uses — and injects, per operation, an OpenAPI `security` requirement plus +// `x-connectum-*` extensions. One resolver drives BOTH runtime enforcement and +// the published contract, so the spec can't drift from what's actually enforced. +// +// Run via `pnpm openapi` (generates the base from buf.gen.openapi.yaml, then this +// overlay). NOTE: streaming RPCs (e.g. ListVehicles) are omitted from the base +// unless the plugin's `with-streaming` opt is set, so they get no operation here. +// A method marked `internal` (1.1.0) would also add `x-internal: true` once the +// resolver exposes that field. + +import { readFileSync, rmSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { parse, stringify } from "yaml"; +import { resolveMethodAuth } from "@connectum/auth/proto"; +import { BillingService } from "#gen/billing/v1/billing_pb.ts"; +import { FleetService } from "#gen/fleet/v1/fleet_pb.ts"; +import { TripService } from "#gen/trips/v1/trips_pb.ts"; + +/** Each generated service ↔ its base OpenAPI file (relative to the package root). */ +const SPECS = [ + { svc: TripService, file: "openapi/trips/v1/trips.openapi.yaml" }, + { svc: FleetService, file: "openapi/fleet/v1/fleet.openapi.yaml" }, + { svc: BillingService, file: "openapi/billing/v1/billing.openapi.yaml" }, +] as const; + +/** The JWT bearer scheme the gateway enforces (createJwtAuthInterceptor). */ +const BEARER_AUTH = { + type: "http", + scheme: "bearer", + bearerFormat: "JWT", + description: "Connectum JWT auth (createJwtAuthInterceptor / proto authz). Required for every non-public method.", +}; + +for (const { svc, file } of SPECS) { + const path = fileURLToPath(new URL(`../${file}`, import.meta.url)); + // biome-ignore lint/suspicious/noExplicitAny: structurally patching a parsed OpenAPI doc. + const doc: any = parse(readFileSync(path, "utf8")); + + doc.components ??= {}; + doc.components.securitySchemes ??= {}; + doc.components.securitySchemes.bearerAuth = BEARER_AUTH; + + let publicCount = 0; + let securedCount = 0; + for (const method of svc.methods) { + const op = doc.paths?.[`/${svc.typeName}/${method.name}`]?.post; + if (op === undefined) continue; + const auth = resolveMethodAuth(method); + if (auth.public) { + op.security = []; // explicitly open — overrides any global requirement + op["x-connectum-public"] = true; + publicCount += 1; + continue; + } + op.security = [{ bearerAuth: [] }]; + if (auth.requires && auth.requires.roles.length > 0) op["x-connectum-required-roles"] = [...auth.requires.roles]; + if (auth.requires && auth.requires.scopes.length > 0) op["x-connectum-required-scopes"] = [...auth.requires.scopes]; + // A method marked `internal` (1.1.0) would add `op["x-internal"] = true` here. + securedCount += 1; + } + + writeFileSync(path, stringify(doc)); + console.log(`overlay ${file}: ${securedCount} secured, ${publicCount} public (of ${svc.methods.length} methods)`); +} + +// The base plugin also emits a schemas-only spec for the imported +// connectum/auth/v1/options.proto (no service of our own) — drop that noise so +// only this example's API specs remain. +rmSync(fileURLToPath(new URL("../openapi/connectum", import.meta.url)), { recursive: true, force: true }); +