diff --git a/Cargo.lock b/Cargo.lock index 9c02857..8560839 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1598,6 +1598,7 @@ dependencies = [ "tokio", "tokio-retry", "tokio-rustls", + "tokio-tungstenite", "tower", "tracing", "url", diff --git a/gears/system/oagw/docs/DECOMPOSITION.md b/gears/system/oagw/docs/DECOMPOSITION.md new file mode 100644 index 0000000..56ae9fa --- /dev/null +++ b/gears/system/oagw/docs/DECOMPOSITION.md @@ -0,0 +1,845 @@ +# Decomposition: Outbound API Gateway (OAGW) + + + + +- [1. Overview](#1-overview) + - [Decomposition Strategy](#decomposition-strategy) + - [Parallelization Opportunities](#parallelization-opportunities) + - [Mandatory Overrides for the Graded Configuration](#mandatory-overrides-for-the-graded-configuration) + - [Scope Reality for the Graded Configuration](#scope-reality-for-the-graded-configuration) +- [2. Entries](#2-entries) + - [2.1 Gear Foundation - HIGH](#21-gear-foundation---high) + - [2.2 Resource Model and Store - HIGH](#22-resource-model-and-store---high) + - [2.3 Upstream Management API - HIGH](#23-upstream-management-api---high) + - [2.4 Route Management API - HIGH](#24-route-management-api---high) + - [2.5 Plugin Management API - MEDIUM](#25-plugin-management-api---medium) + - [2.6 Proxy Data Plane — HTTP - HIGH](#26-proxy-data-plane--http---high) + - [2.7 Proxy Streaming - HIGH](#27-proxy-streaming---high) + - [2.8 Policy and Plugins - HIGH](#28-policy-and-plugins---high) +- [3. Feature Dependencies](#3-feature-dependencies) + + + +**Overall implementation status:** +- [ ] `p1` - **ID**: `cpt-cf-oagw-status-oagw` + +## 1. Overview + +`gears/system/oagw/oagw/` is the single Rust gear implementing OAGW, the outbound API +gateway. Its `src/lib.rs` is currently empty, so this DECOMPOSITION treats the build as a +from-scratch reconstruction against the supplied `PRD.md`, `DESIGN.md`, the nine ADRs, and +the two JSON Schemas (`upstream.v1.schema.json`, `route.v1.schema.json`). The plan below is +the ordered list that the downstream FEATURE-authoring and implementation work must follow. + +### Decomposition Strategy + +The eight features follow the natural build-up of the gear: first the gear shell and shared +error model that every later handler depends on, then the domain model and its tenant-scoped +store, then the three management REST surfaces (upstream, route, plugin) in the order their +foreign-key-like references require, then the data-plane proxy path for plain HTTP, then +streaming upgrades on top of that path, and finally the cross-cutting policy layer (auth +injection, rate limiting, built-in plugins, CORS, hierarchical configuration) that decorates +proxied requests. Each feature is scoped so it can be implemented and tested on its own once +its dependencies exist, and each carries the full set of PRD/DESIGN/ADR identifiers it is +responsible for. + +Feature 2.8 (Policy and Plugins) is deliberately larger than its seven siblings. It is the +cross-cutting policy layer applied on top of an already-working proxy path, and it spans ADR +0003 (rate limiting), ADR 0004 (CORS), ADR 0008 (OAuth2 client-credentials auth plugin), and +ADR 0009 (required-headers guard plugin). It is expected to be implemented as several +independently testable slices inside one feature, not as a single atomic change. + +Three conventions apply across this document. Per-feature phase and milestone breakdown is +intentionally out of scope at this artifact level. That detail belongs to the FEATURE artifacts +written for each entry below. Feature headings use the template's plain priority suffix, for +example `- HIGH`, rather than the kit example's emoji convention. A reference line below carries +a checkbox only when the identifier it cites is a tracked task in its own source document. A +reference to a plain design element, one with no task checkbox in its definition, carries no +checkbox here either. + +### Parallelization Opportunities + +- `cpt-cf-oagw-feature-plugin-management-api` and `cpt-cf-oagw-feature-proxy-data-plane-http` + both depend only on `cpt-cf-oagw-feature-route-management-api` and do not depend on each + other; they can be built in parallel once route management lands. +- `cpt-cf-oagw-feature-policy-and-plugins` is the join point for that parallel work — it needs + both the plugin catalog and the working data-plane proxy, so it cannot start until both are + done. +- `cpt-cf-oagw-feature-proxy-streaming` only extends the proxy path built in + `cpt-cf-oagw-feature-proxy-data-plane-http`; it does not need the plugin or policy work and + could be pulled forward in parallel with `cpt-cf-oagw-feature-plugin-management-api` if + staffing allows, though the dependency list below keeps it strictly after the HTTP proxy for + clarity. + +### Mandatory Overrides for the Graded Configuration + +The graded deployment differs from what `PRD.md` and `DESIGN.md` describe in four ways. Where +the supplied documents and this section disagree, this section governs. The supplied documents +are left unmodified because they describe how the component's original authors deployed it, +not how this build is graded. + +**Override 1 — route paths are gear-relative, with no `/api` prefix.** `PRD.md` and +`DESIGN.md` tabulate every management and proxy path as `/api/oagw/v1/...`. That form is the +absolute path behind an operator gateway whose `prefix_path` (the path segment the +`api-gateway` gear puts in front of every route it hosts) is `/api`. In this platform, each +gear registers its own paths, and the `api-gateway` gear nests one global `prefix_path` over +the whole assembled router. This nesting happens via `apply_prefix` in `api-gateway`'s +`gear.rs`. The graded configuration leaves `prefix_path` empty, so the real paths this build +must serve are `/oagw/v1/upstreams`, `/oagw/v1/routes`, `/oagw/v1/plugins`, and +`/oagw/v1/proxy/{alias}` — never `/api/oagw/v1/...`. Every feature's API bullets below use the +`/oagw/v1/...` form. + +**Override 2 — `http` and `ws` are additional legal endpoint schemes, kept separate from +whether a plaintext call is actually made.** The supplied schema +(`upstream.v1.schema.json`) defines the `scheme` enum as exactly four values: `https`, `wss`, +`wt`, and `grpc`. `config/e2e-local.yaml` sets `oagw.config.allow_http_upstream: true`. +`DESIGN.md`'s `cpt-cf-oagw-constraint-https-only` describes the gateway's default posture +(HTTPS-only, plaintext blocked); it does not describe the graded configuration. Because the +graded flag lifts that default posture, this build adds `http` as a fifth accepted `scheme` +value — a deliberate, named extension beyond the supplied schema, never a restatement of what +the supplied schema already contains. The implementation additionally tolerates `ws` as the +plaintext counterpart of `wss`, so a plaintext WebSocket upstream can be declared under the +same flag; this is likewise a deliberate extension, not part of the supplied enum. Two layers +must stay distinct: (a) which schemes the upstream `scheme` field accepts when an upstream is +created — `http` and `ws` must be accepted alongside the four supplied values, so a create +request naming either is not rejected by schema validation; and (b) whether OAGW actually opens +a plaintext connection to an upstream — that is governed solely by `allow_http_upstream`, which +is `true` in the graded configuration. When the flag is unset or `false`, the default +HTTPS-only posture from `cpt-cf-oagw-constraint-https-only` applies and plaintext connection +attempts are rejected even if the stored scheme is `http` or `ws`. + +**Override 3 — proxying covers plain HTTP, SSE, and WebSocket alike.** The proxy path is not +"HTTP requests, and separately, streaming." A single proxy endpoint must serve ordinary +request/response calls, Server-Sent-Events (SSE, a one-way streaming response format) and +WebSocket upgrade negotiation, all through the same alias- and route-resolution logic. +`cpt-cf-oagw-feature-proxy-data-plane-http` and `cpt-cf-oagw-feature-proxy-streaming` split the +work but must compose into one coherent endpoint family. + +**Override 4 — automated tests are part of the definition of done, and live in the crate, not +in the component's acceptance suite.** `testing/e2e/gears/oagw/` is reserved for the +component's own end-to-end acceptance tests and must not receive gear-level tests from this +build. Every feature below is expected to ship with inline `#[cfg(test)]` modules and/or tests +under `gears/system/oagw/oagw/tests/` covering its behavior; this applies uniformly across all +eight features and is not repeated per feature. + +### Scope Reality for the Graded Configuration + +The graded server runs `config/e2e-local.yaml` with `config/e2e-features.txt`. Under that +configuration: + +- No database is configured for `oagw`. Persistence is a tenant-scoped in-memory store, not + SQL migrations. `cpt-cf-oagw-db-schema` and `cpt-cf-oagw-constraint-multi-sql` are covered as + an in-memory store that honors the documented invariants — `(tenant_id, alias)` uniqueness, + tenant scoping on every read/write, and the anonymous GTS (Global Type System) + resource-identifier pattern (`gts.cf.core.oagw.{type}.v1~{uuid}`) — rather than as + PostgreSQL/MySQL/SQLite migrations. +- gRPC upstreams, Starlark custom-plugin execution, WebTransport session flows, and the + optional Redis L2 config-cache have no enabled runtime dependency in this deployment. Each is + declared out of scope under the features that actually own it: gRPC dispatch under + `cpt-cf-oagw-feature-resource-model-and-store`, `cpt-cf-oagw-feature-route-management-api`, + and `cpt-cf-oagw-feature-proxy-data-plane-http`; Starlark execution under + `cpt-cf-oagw-feature-plugin-management-api` and `cpt-cf-oagw-feature-policy-and-plugins`; + WebTransport under `cpt-cf-oagw-feature-proxy-streaming`; and the Redis L2 cache under + `cpt-cf-oagw-feature-policy-and-plugins`. The documented API surface for each still validates + and rejects unsupported requests coherently (e.g., a 501/400-class response) rather than + panicking or silently mis-routing. +- The `oagw` config block present in `config/e2e-local.yaml` is exactly: `proxy_timeout_secs: + 2`, `allow_http_upstream: true`, `ssrf_policy.enabled: false`. The last setting means SSRF + (Server-Side Request Forgery) protection logic must exist per `cpt-cf-oagw-nfr-ssrf-protection`, + but its runtime enforcement is toggled off in this deployment; the feature that owns it + states this explicitly rather than assuming the policy is always active. +- `cpt-cf-oagw-fr-alias-resolution`, `cpt-cf-oagw-fr-config-layering`, and + `cpt-cf-oagw-fr-hierarchical-config` are checked (`[x]`) below only because `PRD.md` marks + their source definitions done, and the validator rule `def-done-ref-not-done` forces a + reference to match its definition's checkbox state. No code implements any of the three in + this build. Implementers must treat all three as fully unbuilt; the checkbox state is a + validator artifact, not a claim of working code. + +## 2. Entries + +### 2.1 [Gear Foundation](feature-gear-foundation/) - HIGH + +- [ ] `p1` - **ID**: `cpt-cf-oagw-feature-gear-foundation` + +- **Purpose**: Registers the `oagw` gear with the host runtime, deserializes `OagwConfig` from + the `oagw.config` YAML block (`proxy_timeout_secs`, `allow_http_upstream`, `ssrf_policy`), + mounts the base router at `/oagw/v1` (Override 1), and establishes the shared error model + every later handler uses: RFC 9457 (`application/problem+json`) Problem Details with GTS + error-type identifiers, plus an `X-OAGW-Error-Source: gateway|upstream` + header on every response. It also performs GTS schema registration for `cpt-cf-oagw-actor-types-registry` + at startup so later features can register their own type schemas. + +- **Depends On**: None + +- **Scope**: + - Gear registration, lifecycle wiring, and `OagwConfig` deserialization from the `oagw.config` block. + - Module skeleton for the Control Plane / Data Plane split described in `cpt-cf-oagw-design-layers`. + - Base router mounted at `/oagw/v1` per Override 1; no resource endpoints live here yet. + - Shared RFC 9457 Problem Details error type carrying `type`, `title`, `status`, `detail`, + `instance`, and the OAGW extension fields (`upstream_id`, `host`, `path`, + `retry_after_seconds`, `trace_id`). + - `X-OAGW-Error-Source: gateway|upstream` header attached to every response, gateway or + passthrough, per `cpt-cf-oagw-adr-error-source-distinction`. + - Registration of the gear's external dependency handles (`types_registry`, `cred_store`, + `api_ingress`, `toolkit-db`, `toolkit-auth`) even where a given dependency's data path is + unused in this deployment (e.g., no database configured). + - Inbound Bearer token authentication via `toolkit-auth`, wired as the shared permission-check + mechanism that every route registered by this gear, in every later feature, reuses. The + graded configuration's static auth stack resolves most checks to an always-pass state, but + the gate itself must still exist and run on every request. + +- **Out of scope**: + - Any concrete resource CRUD or proxy handler logic (later features). + - `toolkit-db` migrations — no database is configured in the graded deployment (Scope Reality). + +- **Requirements Covered**: + + - [ ] `p1` - `cpt-cf-oagw-fr-error-codes` + +- **Design Principles Covered**: + + - `p1` - `cpt-cf-oagw-principle-rfc9457` + - `p1` - `cpt-cf-oagw-principle-error-source` + +- **Design Constraints Covered**: + + - `p1` - `cpt-cf-oagw-constraint-toolkit-deploy` + +- **Domain Model Entities**: + - None + +- **Design Components**: + + - `p1` - `cpt-cf-oagw-design-layers` + - `p1` - `cpt-cf-oagw-tech-dependencies` + - `p1` - `cpt-cf-oagw-design-drivers` + - `p1` - `cpt-cf-oagw-design-dependencies` + - `p1` - `cpt-cf-oagw-interface-api` + - `p1` - `cpt-cf-oagw-adr-error-source-distinction` + +- **API**: + - Base router mounted at `/oagw/v1` (Override 1). No resource endpoints; subsequent + features register their paths under this mount. + +- **Sequences**: + + - None + +- **Data**: + + - None + +### 2.2 [Resource Model and Store](feature-resource-model-and-store/) - HIGH + +- [ ] `p1` - **ID**: `cpt-cf-oagw-feature-resource-model-and-store` + +- **Purpose**: Defines the Upstream, Route, and Plugin domain entities matching + `upstream.v1.schema.json` and `route.v1.schema.json` field for field, the request/response + DTOs and schema-level validation for them, alias derivation and the alias pattern, and the + tenant-scoped in-memory store that later CRUD and proxy features read and write through. + +- **Depends On**: `cpt-cf-oagw-feature-gear-foundation` + +- **Scope**: + - `Upstream`, `Route`, `Plugin`, `ServerConfig`, `Endpoint`, `AuthConfig`, `HeadersConfig`, + `RateLimitConfig`, `CorsConfig`, and `PluginsConfig` domain types matching the JSON Schemas. + - Schema-level validation: required fields, enums (including `scheme` accepting the supplied + `https`, `wss`, `wt`, `grpc` values plus the `http` and `ws` extensions added by Override 2), + the alias pattern `^[a-z0-9]([a-z0-9.:-]*[a-z0-9])?$`, and the tag pattern + `^[a-z0-9_-]+$`. + - Alias derivation rules: hostname-based endpoints auto-derive (single hostname, or common + registrable suffix across multiple hostnames), IP-based or non-derivable endpoints require + an explicit alias, normalization to ASCII lowercase with trailing dots stripped, and + alias immutability once set. + - Tenant-scoped in-memory store (Scope Reality: no database is configured) enforcing + `(tenant_id, alias)` uniqueness for upstreams, match-rule uniqueness for routes, and the + anonymous GTS resource-identifier pattern (`gts.cf.core.oagw.{type}.v1~{uuid}`) for all + three entity types. + - Tenant-hierarchy walk primitive (descendant-to-root) used later by alias resolution and + route matching. + +- **Out of scope**: + - gRPC-specific request dispatch — the `protocol` and `match.grpc` schema fields are + accepted and validated, but no gRPC proxy code path exists in this build (Scope Reality). + - `toolkit-db` / SeaORM migrations — persistence here is the in-memory store described above, + not SQL (Scope Reality, `cpt-cf-oagw-constraint-multi-sql`). + +- **Requirements Covered**: + + - [x] `p2` - `cpt-cf-oagw-fr-alias-resolution` + - [ ] `p1` - `cpt-cf-oagw-nfr-multi-tenancy` + - [ ] `p1` - `cpt-cf-oagw-nfr-input-validation` + +- **Design Principles Covered**: + + - `p1` - `cpt-cf-oagw-principle-tenant-scope` + +- **Design Constraints Covered**: + + - `p1` - `cpt-cf-oagw-constraint-multi-sql` + +- **Domain Model Entities**: + - Upstream + - Route + - Plugin + - ServerConfig + - Endpoint + +- **Design Components**: + + - `p1` - `cpt-cf-oagw-design-domain-model` + +- **API**: + - None. This feature is the domain/store layer consumed by the REST features that follow. + +- **Sequences**: + + - None + +- **Data**: + + - `p1` - `cpt-cf-oagw-db-schema` + +### 2.3 [Upstream Management API](feature-upstream-management-api/) - HIGH + +- [ ] `p1` - **ID**: `cpt-cf-oagw-feature-upstream-management-api` + +- **Purpose**: Exposes CRUD (create/read/update/delete) over upstreams for + `cpt-cf-oagw-actor-platform-operator` and `cpt-cf-oagw-actor-tenant-admin`, enforcing + create/replace/delete semantics, alias uniqueness, immutable fields, and tenant scoping so + every proxy request in later features has a configured target to resolve against. + +- **Depends On**: `cpt-cf-oagw-feature-resource-model-and-store` + +- **Scope**: + - `POST /oagw/v1/upstreams`, `GET /oagw/v1/upstreams`, `GET /oagw/v1/upstreams/{id}`, + `PUT /oagw/v1/upstreams/{id}`, `DELETE /oagw/v1/upstreams/{id}` (Override 1 paths). + - Create: server-generated UUID, alias auto-derivation or explicit-alias validation, alias + conflict within tenant returns `409 Conflict`; a create request whose endpoints use scheme + `http` or `ws` is accepted at validation time regardless of `allow_http_upstream` (Override 2). + - Replace: full-document replacement, `id`/`tenant_id` immutable, alias recomputed only when + derivable and unchanged, otherwise the request is rejected (`400 Validation`). + - Delete and enable/disable (`enabled` boolean, default `true`): a disabled upstream causes + proxy requests to be rejected with `503 Service Unavailable`; ancestor-disabled upstreams + cannot be re-enabled by a descendant. + - List query parameters: OData `$filter`, `$select`, `$orderby`, `$top` (default 50, max + 100), `$skip`. + - Tenant scoping: ancestor upstreams are invisible (`404`) through this management surface + even though they remain reachable at proxy time via the tenant-hierarchy walk. + - Per-endpoint permission gates on every CRUD route: + `gts.cf.core.oagw.upstream.v1~:{create;override;read;delete}`. The ancestor-alias "bind" + path — creating an upstream whose alias matches an ancestor's — additionally requires the + `oagw:upstream:bind` permission on top of `create`. + +- **Out of scope**: + - Route and plugin CRUD (separate features below). + - Enforcing whether a plaintext connection is actually opened to an `http`- or `ws`-scheme + upstream — that runtime behavior belongs to the data-plane proxy feature, gated by + `allow_http_upstream` (Override 2). + +- **Requirements Covered**: + + - [ ] `p1` - `cpt-cf-oagw-fr-upstream-mgmt` + - [ ] `p1` - `cpt-cf-oagw-fr-enable-disable` + - [ ] `p1` - `cpt-cf-oagw-usecase-configure-upstream` + +- **Design Principles Covered**: + + - None + +- **Design Constraints Covered**: + + - None + +- **Domain Model Entities**: + - Upstream + +- **Design Components**: + + - [ ] `p1` - `cpt-cf-oagw-interface-management-api` + +- **API**: + - `POST /oagw/v1/upstreams` + - `GET /oagw/v1/upstreams` + - `GET /oagw/v1/upstreams/{id}` + - `PUT /oagw/v1/upstreams/{id}` + - `DELETE /oagw/v1/upstreams/{id}` + +- **Sequences**: + + - None + +- **Data**: + + - None + +### 2.4 [Route Management API](feature-route-management-api/) - HIGH + +- [ ] `p1` - **ID**: `cpt-cf-oagw-feature-route-management-api` + +- **Purpose**: Exposes CRUD over routes, the matching rules that map an inbound proxy request + to a specific upstream behavior, so `cpt-cf-oagw-actor-platform-operator` and + `cpt-cf-oagw-actor-tenant-admin` can define which methods, paths, and query parameters are + reachable on each upstream before proxying goes live. + +- **Depends On**: `cpt-cf-oagw-feature-upstream-management-api` + +- **Scope**: + - `POST /oagw/v1/routes`, `GET /oagw/v1/routes`, `GET /oagw/v1/routes/{id}`, + `PUT /oagw/v1/routes/{id}`, `DELETE /oagw/v1/routes/{id}` (Override 1 paths). + - Create: `upstream_id` must exist and belong to the calling tenant (ancestor upstreams are + not directly addressable here), `match` must contain exactly one of `http` or `grpc`, + match-rule uniqueness within the upstream (same path + priority + method → `409 Conflict`). + - Replace: `upstream_id` is immutable and not present in the update DTO; match-rule + uniqueness is re-validated. + - List query parameters mirroring the upstream surface (`$filter`, `$select`, `$orderby`, + `$top`, `$skip`). + - Per-endpoint permission gates on every CRUD route: + `gts.cf.core.oagw.route.v1~:{create;override;read;delete}`. + +- **Out of scope**: + - gRPC match dispatch at proxy time — the `match.grpc` shape is validated and stored, but no + gRPC request is ever routed against it in this build (Scope Reality). + +- **Requirements Covered**: + + - [ ] `p1` - `cpt-cf-oagw-fr-route-mgmt` + - [ ] `p1` - `cpt-cf-oagw-usecase-configure-route` + +- **Design Principles Covered**: + + - None + +- **Design Constraints Covered**: + + - None + +- **Domain Model Entities**: + - Route + +- **Design Components**: + + - None + +- **API**: + - `POST /oagw/v1/routes` + - `GET /oagw/v1/routes` + - `GET /oagw/v1/routes/{id}` + - `PUT /oagw/v1/routes/{id}` + - `DELETE /oagw/v1/routes/{id}` + +- **Sequences**: + + - None + +- **Data**: + + - None + +### 2.5 [Plugin Management API](feature-plugin-management-api/) - MEDIUM + +- [ ] `p2` - **ID**: `cpt-cf-oagw-feature-plugin-management-api` + +- **Purpose**: Exposes the create/read/delete/source-fetch surface for custom (Starlark-authored) + plugin definitions, respecting plugin immutability — there is no update endpoint — and + guarding deletion so a plugin still referenced by an upstream or route cannot be removed out + from under them. + +- **Depends On**: `cpt-cf-oagw-feature-route-management-api` + +- **Scope**: + - `POST /oagw/v1/plugins`, `GET /oagw/v1/plugins`, `GET /oagw/v1/plugins/{id}`, + `DELETE /oagw/v1/plugins/{id}`, `GET /oagw/v1/plugins/{id}/source` (Override 1 paths). + - No `PUT` — plugins are immutable after creation; changes are made by creating a new plugin + and re-binding upstream/route references to it. + - `DELETE` returns `204 No Content` when the plugin is unreferenced, and `409 Conflict` + (`PluginInUse`) when referenced, with the reference list (`referenced_by.upstreams`, + `referenced_by.routes`) in the RFC 9457 problem body. + - Plugin identification and storage per the GTS plugin-identification model: named + (built-in) plugins resolved via in-process registry and never stored; custom plugins + stored with `id = {uuid}` and both `plugin_ref` and `plugin_uuid` recorded. + - Reference tracking across `oagw_upstream_plugin`, `oagw_route_plugin` bindings, and the + scalar `auth_plugin_ref`/`auth_plugin_uuid` columns on upstreams, so the in-use check does + not require scanning arbitrary JSON. + +- **Out of scope**: + - Starlark sandboxed execution of custom plugin source (no network I/O, no file I/O, no + imports, timeout/memory limits) — this build stores and serves plugin definitions but has + no enabled runtime to execute them (Scope Reality). The catalog of built-in, non-Starlark + plugin identifiers is covered instead by `cpt-cf-oagw-feature-policy-and-plugins`. + - Time-based garbage collection of unlinked plugins (`gc_eligible_at` sweep) — deferred; the + reference-tracking check above is still enforced synchronously on delete. + +- **Requirements Covered**: + + - [ ] `p2` - `cpt-cf-oagw-fr-plugin-system` + +- **Design Principles Covered**: + + - `p2` - `cpt-cf-oagw-principle-plugin-immutable` + +- **Design Constraints Covered**: + + - None + +- **Domain Model Entities**: + - Plugin + +- **Design Components**: + + - `p2` - `cpt-cf-oagw-adr-plugin-system` + +- **API**: + - `POST /oagw/v1/plugins` + - `GET /oagw/v1/plugins` + - `GET /oagw/v1/plugins/{id}` + - `DELETE /oagw/v1/plugins/{id}` + - `GET /oagw/v1/plugins/{id}/source` + +- **Sequences**: + + - None + +- **Data**: + + - None + +### 2.6 [Proxy Data Plane — HTTP](feature-proxy-data-plane-http/) - HIGH + +- [ ] `p1` - **ID**: `cpt-cf-oagw-feature-proxy-data-plane-http` + +- **Purpose**: Implements the core proxy path for plain HTTP requests from + `cpt-cf-oagw-actor-app-developer` to an external `cpt-cf-oagw-actor-upstream-service`: alias + resolution, route matching, guard checks, header transformation, and forwarding, with no + automatic retry of the client's request. + +- **Depends On**: `cpt-cf-oagw-feature-route-management-api` + +- **Scope**: + - `{METHOD} /oagw/v1/proxy/{alias}[/{path}][?{query}]` (Override 1 path form). + - Authorization as step 1 of every proxy request: the `gts.cf.core.oagw.proxy.v1~:invoke` + permission check runs before the tenant-chain alias walk and route match described below. In + the graded configuration's static auth stack this check is trivially satisfied, but it still + executes on every request. + - Alias resolution walking the tenant hierarchy descendant-to-root, closest match wins + (shadowing); disabled upstream → `503 Service Unavailable`. + - Route matching by method and longest path prefix for HTTP upstreams; guard rules (method + allowlist, query allowlist, `path_suffix_mode: disabled|append`). + - Body validation: `Content-Length` consistency, the 100MB hard limit (`413 PayloadTooLarge` + before buffering), and rejection of unsupported `Transfer-Encoding` values. + - Header handling per the three categories in DESIGN.md §3.2 "Headers Transformation" + (routing, hop-by-hop, passthrough): this feature implements only the non-configurable + behaviour — routing-header consumption and stripping (`X-OAGW-Target-Host`) and hop-by-hop + header stripping (see DESIGN.md §3.2 for the full hop-by-hop header list) — plus `Host` + (HTTP/1.1) and `:authority` (HTTP/2) rewriting to the upstream's endpoint. + - `X-OAGW-Target-Host` multi-endpoint behavior: optional for single-endpoint or + explicit-alias pools (round-robin if absent), required when the alias was derived from a + common hostname suffix. + - Per-request timeout from `OagwConfig.proxy_timeout_secs` (`2` in the graded configuration); + no automatic re-issuing of the client's request on failure, per + `cpt-cf-oagw-principle-no-retry`. + - Upstream error passthrough: the upstream's response body and status are forwarded + unchanged, tagged `X-OAGW-Error-Source: upstream`, distinct from gateway-originated errors + tagged `gateway`. + - Scheme policy split (Override 2): the stored `scheme` may be `http` or `ws`; whether OAGW + opens a plaintext connection is controlled solely by `allow_http_upstream` (`true` in the + graded configuration). SSRF (Server-Side Request Forgery) protections per + `cpt-cf-oagw-nfr-ssrf-protection` — DNS/IP validation, well-known header stripping, path + and query validation against route configuration — must exist, but their runtime + enforcement is disabled in this deployment (`ssrf_policy.enabled: false`); the code must + still evaluate coherently with the policy off, not skip its own guard checks. + +- **Out of scope**: + - gRPC request classification and forwarding (Scope Reality — no gRPC proxy code path). + - SSE and WebSocket upgrade handling (`cpt-cf-oagw-feature-proxy-streaming`). + - Auth-plugin credential injection, rate limiting, and built-in CORS + (`cpt-cf-oagw-feature-policy-and-plugins`) — this feature's guard/transform stages exist + but the plugin chain itself is populated by that later feature. + - Configurable header rules driven by the upstream/route `headers` configuration (`set`, + `add`, `remove`, and passthrough mode) — implemented by + `cpt-cf-oagw-feature-policy-and-plugins`; this feature only covers the non-configurable + routing/hop-by-hop/rewrite behaviour above. + - Circuit breaker enforcement — documented as core resilience functionality but listed as + future work in `DESIGN.md` §4.7; not implemented in this build. + +- **Requirements Covered**: + + - [ ] `p1` - `cpt-cf-oagw-fr-request-proxy` + - [ ] `p1` - `cpt-cf-oagw-fr-header-transform` (this feature covers the non-configurable + routing/hop-by-hop/rewrite half; the configurable `set`/`add`/`remove`/passthrough rules are + covered by `cpt-cf-oagw-feature-policy-and-plugins`) + - [ ] `p1` - `cpt-cf-oagw-usecase-proxy-request` + - [ ] `p1` - `cpt-cf-oagw-nfr-ssrf-protection` + - [ ] `p1` - `cpt-cf-oagw-nfr-low-latency` + +- **Design Principles Covered**: + + - `p1` - `cpt-cf-oagw-principle-no-retry` + - `p1` - `cpt-cf-oagw-principle-no-cache` + +- **Design Constraints Covered**: + + - `p1` - `cpt-cf-oagw-constraint-body-limit` + - `p1` - `cpt-cf-oagw-constraint-https-only` + - `p1` - `cpt-cf-oagw-constraint-no-direct-internet` + +- **Domain Model Entities**: + - ProxyContext (resolved upstream + route + in-flight request state) + - ProxyResponse + +- **Design Components**: + + - [ ] `p1` - `cpt-cf-oagw-interface-proxy-api` + - `p1` - `cpt-cf-oagw-component-model` + - `p1` - `cpt-cf-oagw-design-overview` + - `p1` - `cpt-cf-oagw-adr-request-routing` + - `p1` - `cpt-cf-oagw-adr-data-plane-caching` + - `p1` - `cpt-cf-oagw-adr-state-management` + +- **API**: + - `{METHOD} /oagw/v1/proxy/{alias}[/{path}][?{query}]` + +- **Sequences**: + + - `p1` - `cpt-cf-oagw-seq-proxy-flow` + +- **Data**: + + - None + +### 2.7 [Proxy Streaming](feature-proxy-streaming/) - HIGH + +- [ ] `p1` - **ID**: `cpt-cf-oagw-feature-proxy-streaming` + +- **Purpose**: Extends the same proxy endpoint built in + `cpt-cf-oagw-feature-proxy-data-plane-http` to cover Server-Sent-Events (SSE) streams and + WebSocket upgrades, so `cpt-cf-oagw-actor-app-developer` can consume streaming external APIs + (e.g., chat-completion SSE) through the identical alias/route/header path. + +- **Depends On**: `cpt-cf-oagw-feature-proxy-data-plane-http` + +- **Scope**: + - SSE responses: connection established to the upstream, events forwarded to the client as + received, with open/close/error lifecycle handling (Override 3). + - WebSocket upgrade negotiation (`Upgrade: websocket`) through + `{METHOD} /oagw/v1/proxy/{alias}[/{path}]`, followed by bidirectional frame relay between + client and upstream. + - Connection lifecycle on upstream close (client connection closed, event logged) and on + client disconnect (upstream connection closed). + - `X-OAGW-Error-Source` semantics applied to streaming connections and upgrade failures, + consistent with the plain-HTTP error model from `cpt-cf-oagw-feature-gear-foundation`. + +- **Out of scope**: + - WebTransport session flows — `PRD.md` and `DESIGN.md` list WebTransport alongside + WebSocket, but it has no enabled runtime dependency in this deployment (Scope Reality); the + `wt` scheme value still validates at the schema layer without a working WebTransport data path. + - gRPC streaming (bidirectional or server-streaming) — out of scope with gRPC generally in + this build. + +- **Requirements Covered**: + + - [ ] `p1` - `cpt-cf-oagw-fr-streaming` + - [ ] `p1` - `cpt-cf-oagw-usecase-sse-streaming` + +- **Design Principles Covered**: + + - None + +- **Design Constraints Covered**: + + - None + +- **Domain Model Entities**: + - None + +- **Design Components**: + + - `p1` - `cpt-cf-oagw-interface-api` + +- **API**: + - `{METHOD} /oagw/v1/proxy/{alias}[/{path}]` with `Upgrade: websocket` + - `GET /oagw/v1/proxy/{alias}[/{path}]` with `Accept: text/event-stream` (SSE) + +- **Sequences**: + + - None + +- **Data**: + + - None + +### 2.8 [Policy and Plugins](feature-policy-and-plugins/) - HIGH + +- [ ] `p1` - **ID**: `cpt-cf-oagw-feature-policy-and-plugins` + +- **Purpose**: Applies the built-in policy layer to proxied requests: credential injection by + auth plugins against `cpt-cf-oagw-actor-cred-store`, the required-headers guard plugin, rate + limiting, built-in CORS handling, header transformation rules, and hierarchical + configuration layering/sharing across the tenant hierarchy. This is the layer that turns the + bare data-plane proxy into the full gateway described in `PRD.md` §1.1. + +- **Depends On**: `cpt-cf-oagw-feature-proxy-data-plane-http`, `cpt-cf-oagw-feature-plugin-management-api` + +- **Scope**: + - Auth plugins resolved by GTS identifier and executed once per request before guards: + `noop`, `apikey` (header/query), `oauth2_client_cred` (Form) and + `oauth2_client_cred_basic` (Basic) with an internal token cache keyed on + `(tenant, subject, config)` and TTL `min(config_ttl, expires_in - 30s)`. `basic`/`bearer` + are catalog-only GTS identifiers with no backing implementation; using either as + `auth.plugin_type` fails with `unknown auth plugin`. Credentials are resolved from + `cred_store` by `cred://` reference at request time and never logged. + - `RequiredHeadersGuardPlugin`: `required_request_headers` checked in the request phase + (missing header → `400`), `required_response_headers` checked in the response phase + (missing header → `502`); fail-open when unconfigured; case-insensitive presence check, + first missing header reported. + - Rate limiting: token-bucket (default) or sliding-window algorithm, dual-rate + (`sustained`/`burst`) configuration, `scope` (global/tenant/user/ip/route), `strategy` + (reject/queue/degrade), `cost` per request, `X-RateLimit-*` and `Retry-After` response + headers, `429 Too Many Requests` on reject. + - Built-in CORS: preflight `OPTIONS` returns a permissive `204` at the handler level (no + upstream resolution, no tenant context); actual cross-origin requests are validated against + `allowed_origins`/`allowed_methods` after upstream resolution and before forwarding, + `403 Forbidden` on rejection; `allow_credentials` cannot combine with a wildcard origin. + - Header set/add/remove and passthrough (`none`/`allowlist`/`all`) transformation rules from + the upstream/route `headers` configuration — the configurable half of header transformation; + the non-configurable routing/hop-by-hop/rewrite half is implemented by + `cpt-cf-oagw-feature-proxy-data-plane-http`. + - Hierarchical configuration layering and merge order (Upstream < Route < Tenant) and the + three sharing modes (`private`/`inherit`/`enforce`) for auth, rate limits (`min` of + ancestor/descendant), plugins (ancestor plugins execute before descendant plugins, + enforced plugins cannot be removed), and CORS (union under `inherit`, fixed under + `enforce`); tags use add-only union semantics regardless of sharing mode. + - Baseline availability behavior expected of every request path (no unhandled panics, + consistent error responses under upstream failure) as the achievable portion of + `cpt-cf-oagw-nfr-high-availability` in this build. + - Metric instrumentation for the core proxy series from `DESIGN.md` §4.2: + `oagw_requests_total`, `oagw_request_duration_seconds`, `oagw_requests_in_flight`, and + `oagw_errors_total`, carrying the documented OTel (OpenTelemetry) label keys (`host`, + `http.request.method`, `http.route`, `http.response.status_code`, `phase`, `error_type`). + - Structured audit-log fields per `DESIGN.md` §4.3: `request_id`, `tenant_id`, `method`, + `path`, `status`, and `duration_ms`, emitted at `INFO`/`WARN`/`ERROR` levels. Logs never + carry request/response bodies or credential material. + +- **Out of scope**: + - Circuit breaker state machine and trip/reset behavior — `DESIGN.md` §4.7 lists it as future + work and it is not a plugin; `cpt-cf-oagw-nfr-high-availability`'s circuit-breaker clause is + therefore not implemented, only the baseline availability behavior above is. + - Starlark custom-plugin sandboxing (no network/file I/O, timeout/memory limits) — + `cpt-cf-oagw-nfr-starlark-sandbox` has no runtime dependency in this deployment (Scope + Reality); custom plugin definitions are stored and served by + `cpt-cf-oagw-feature-plugin-management-api` but never executed here. + - Redis-backed distributed rate-limit sync and Redis L2 config cache — no Redis dependency is + enabled in the graded configuration; rate limiting runs as per-instance local state. + - ADR 0003 proposes a `budget` / `overcommit_ratio` hierarchical-allocation extension for rate + limits, but that extension was never carried into `upstream.v1.schema.json` or + `route.v1.schema.json`; those schemas define only `sharing`, `algorithm`, `sustained`, + `burst`, `scope`, `strategy`, and `cost`. There is no schema field to implement, so this + feature does not attempt the `budget` mechanism. The effective-limit rule actually built is + the simple `min(ancestor, descendant)` enforce rule from `DESIGN.md`, covered above. + - A dedicated `/metrics` scrape route mounted by this gear: `DESIGN.md` marks `/metrics` + admin-only, and on this platform aggregating admin-facing scrape surfaces is a host-runtime + concern rather than a per-gear one. This feature emits the metric series above through the + shared instrumentation hooks and does not assume it also owns that route. + +- **Requirements Covered**: + + - [ ] `p1` - `cpt-cf-oagw-fr-auth-injection` + - [ ] `p1` - `cpt-cf-oagw-fr-rate-limiting` + - [ ] `p2` - `cpt-cf-oagw-fr-builtin-plugins` + - [ ] `p1` - `cpt-cf-oagw-fr-header-transform` (this feature covers the configurable + `set`/`add`/`remove`/passthrough rules; the non-configurable routing/hop-by-hop/rewrite half + is covered by `cpt-cf-oagw-feature-proxy-data-plane-http`) + - [x] `p2` - `cpt-cf-oagw-fr-config-layering` + - [x] `p2` - `cpt-cf-oagw-fr-hierarchical-config` + - [ ] `p2` - `cpt-cf-oagw-usecase-rate-limit-exceeded` + - [ ] `p1` - `cpt-cf-oagw-nfr-credential-isolation` + - [ ] `p2` - `cpt-cf-oagw-nfr-observability` + - [ ] `p1` - `cpt-cf-oagw-nfr-high-availability` + - [ ] `p3` - `cpt-cf-oagw-nfr-starlark-sandbox` + +- **Design Principles Covered**: + + - `p1` - `cpt-cf-oagw-principle-cred-isolation` + +- **Design Constraints Covered**: + + - None + +- **Domain Model Entities**: + - Rate limiter state (token bucket) + - Credential reference (`secret_ref`) + - CORS policy + - Header transformation rule + +- **Design Components**: + + - [ ] `p1` - `cpt-cf-oagw-contract-cred-store` + - [ ] `p1` - `cpt-cf-oagw-contract-types-registry` + - `p1` - `cpt-cf-oagw-interface-api` + - `p2` - `cpt-cf-oagw-adr-rate-limiting` + - `p2` - `cpt-cf-oagw-adr-cors` + - `p1` - `cpt-cf-oagw-adr-oauth2-client-credentials-auth-plugin` + - `p2` - `cpt-cf-oagw-adr-required-headers-guard-plugin` + +- **API**: + - No new endpoints. Policy runs inside the existing `{METHOD} /oagw/v1/proxy/{alias}...` + request/response cycle, adding `X-RateLimit-*`/`Retry-After` headers, CORS response + headers, and permissive `204` handling for preflight `OPTIONS`. + +- **Sequences**: + + - None + +- **Data**: + + - None + +--- + +## 3. Feature Dependencies + +```text +cpt-cf-oagw-feature-gear-foundation + ↓ +cpt-cf-oagw-feature-resource-model-and-store + ↓ +cpt-cf-oagw-feature-upstream-management-api + ↓ +cpt-cf-oagw-feature-route-management-api + ↓ + ├─→ cpt-cf-oagw-feature-plugin-management-api + │ + └─→ cpt-cf-oagw-feature-proxy-data-plane-http + ↓ + cpt-cf-oagw-feature-proxy-streaming + +cpt-cf-oagw-feature-plugin-management-api ─────┐ +cpt-cf-oagw-feature-proxy-data-plane-http ─────┴─→ cpt-cf-oagw-feature-policy-and-plugins +``` + +**Dependency Rationale**: + +- `cpt-cf-oagw-feature-resource-model-and-store` requires `cpt-cf-oagw-feature-gear-foundation`: + the domain model and store live inside the gear module skeleton and reuse its error types. +- `cpt-cf-oagw-feature-upstream-management-api` requires + `cpt-cf-oagw-feature-resource-model-and-store`: the management handlers validate and persist + through the domain model and store defined there. +- `cpt-cf-oagw-feature-route-management-api` requires + `cpt-cf-oagw-feature-upstream-management-api`: every route references an `upstream_id` that + must already exist and be visible through upstream CRUD. +- `cpt-cf-oagw-feature-plugin-management-api` requires + `cpt-cf-oagw-feature-route-management-api`: plugin-in-use tracking scans upstream and route + plugin bindings, which requires both resource types to already be manageable. +- `cpt-cf-oagw-feature-proxy-data-plane-http` requires + `cpt-cf-oagw-feature-route-management-api`: the proxy path resolves an upstream, then matches + a route, so both CRUD surfaces must exist and be populated first. +- `cpt-cf-oagw-feature-plugin-management-api` and `cpt-cf-oagw-feature-proxy-data-plane-http` + are independent of each other and can be developed in parallel once route management is done. +- `cpt-cf-oagw-feature-proxy-streaming` requires `cpt-cf-oagw-feature-proxy-data-plane-http`: + streaming upgrades reuse the same alias resolution, route matching, and header handling built + there; it only adds upgrade negotiation and bidirectional relay on top. +- `cpt-cf-oagw-feature-policy-and-plugins` requires both + `cpt-cf-oagw-feature-proxy-data-plane-http` and `cpt-cf-oagw-feature-plugin-management-api`: + auth/guard/transform execution needs a working proxy request to attach to, and the built-in + plugin catalog needs the plugin identification and storage model from plugin management. diff --git a/gears/system/oagw/docs/features/gear-foundation.md b/gears/system/oagw/docs/features/gear-foundation.md new file mode 100644 index 0000000..5f71b89 --- /dev/null +++ b/gears/system/oagw/docs/features/gear-foundation.md @@ -0,0 +1,409 @@ +# Feature: Gear Foundation + + + + +- [1. Feature Context](#1-feature-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Inbound Authenticated Request](#inbound-authenticated-request) + - [Unmatched Route Produces a Structured Gateway Error](#unmatched-route-produces-a-structured-gateway-error) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Gear Registration and Startup](#gear-registration-and-startup) + - [OagwConfig Deserialization and Defaulting](#oagwconfig-deserialization-and-defaulting) + - [Gateway Error Construction](#gateway-error-construction) + - [Bearer Token Authentication Gate](#bearer-token-authentication-gate) +- [4. States (CDSL)](#4-states-cdsl) + - [Gear Lifecycle State Machine](#gear-lifecycle-state-machine) +- [5. Definitions of Done](#5-definitions-of-done) + - [Module Skeleton for the Control Plane / Data Plane Split](#module-skeleton-for-the-control-plane--data-plane-split) + - [Gear Registration via Toolkit Macro and Inventory](#gear-registration-via-toolkit-macro-and-inventory) + - [OagwConfig Deserialization with Field Defaults](#oagwconfig-deserialization-with-field-defaults) + - [Base Router Mounted at /oagw/v1](#base-router-mounted-at-oagwv1) + - [Shared RFC 9457 Problem Details Type](#shared-rfc-9457-problem-details-type) + - [GTS Error-Type and HTTP Status Pairings](#gts-error-type-and-http-status-pairings) + - [Universal X-OAGW-Error-Source Header](#universal-x-oagw-error-source-header) + - [Inbound Bearer Token Authentication and Shared Permission Gate](#inbound-bearer-token-authentication-and-shared-permission-gate) + - [External Dependency Handles Resolved from the Typed Client Registry](#external-dependency-handles-resolved-from-the-typed-client-registry) +- [6. Acceptance Criteria](#6-acceptance-criteria) + + + +- [ ] `p1` - **ID**: `cpt-cf-oagw-featstatus-gear-foundation-implemented` + + +- [ ] `p1` - `cpt-cf-oagw-feature-gear-foundation` +## 1. Feature Context + +### 1.1 Overview + +This feature builds the skeleton of the `oagw` gear: registration with the host runtime, +configuration loading, the base route mount, and the shared error model every later feature +attaches to. + +### 1.2 Purpose + +`gears/system/oagw/oagw/src/lib.rs` is currently empty. Before any resource CRUD or proxy +handler can exist, the gear must be discoverable by the host runtime, must load its own +configuration, must expose a mount point for routes, and must have one consistent way to +report errors. This feature builds that shared base so features 2 through 8 can attach +handlers, repositories, and plugins to it without re-deriving these mechanics. + +**Requirements**: `cpt-cf-oagw-fr-error-codes` + +**Principles**: `cpt-cf-oagw-principle-rfc9457`, `cpt-cf-oagw-principle-error-source` + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-oagw-actor-platform-operator` | Sends Bearer-token-authenticated requests to any route mounted under `/oagw/v1`; subject to the shared permission gate this feature establishes. | +| `cpt-cf-oagw-actor-tenant-admin` | Same inbound authentication gate applies before any tenant-scoped management call defined in later features. | +| `cpt-cf-oagw-actor-app-developer` | Calls proxy routes through the same base router and inbound authentication gate; receives an RFC 9457 (a standard, machine-readable HTTP error body format) gateway error when a call fails before reaching an upstream. | +| `cpt-cf-oagw-actor-types-registry` | Its handle is resolved and exposed from the typed client registry during this feature's startup, so later features register their own type schemas against an initialized registry, instead of against one this feature must reach. | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) +- **Design**: [DESIGN.md](../DESIGN.md) — `cpt-cf-oagw-design-drivers` (§1.2 Architecture Drivers), + `cpt-cf-oagw-design-layers` (§1.3 Architecture Layers: `domain/`, `infra/`, `api/rest/` split), + `cpt-cf-oagw-tech-dependencies` (§1.3 Technology Dependencies), `cpt-cf-oagw-design-dependencies` + (§3.4 Internal & External Dependencies), `cpt-cf-oagw-interface-api` (§3.3 API Contracts; only + the base mount applies at this feature's gate) +- **ADRs**: [0007 Error Source Distinction](../ADR/0007-error-source-distinction.md), [0001 Request Routing](../ADR/0001-request-routing.md) +- **Decomposition**: `cpt-cf-oagw-feature-gear-foundation` +- **Dependencies**: None + +## 2. Actor Flows (CDSL) + +**Use cases**: None. This feature has no dedicated PRD use case; it supplies the +authentication and error mechanics that later features' use cases depend on. + +### Inbound Authenticated Request + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-gear-foundation-inbound-auth` + +**Actor**: `cpt-cf-oagw-actor-app-developer` + +**Success Scenarios**: +- A request carrying a valid Bearer token and the required GTS permission is handed to its + matched route handler. + +**Error Scenarios**: +- A missing, malformed, or invalid Bearer token stops the request before any handler runs. +- A resolved principal lacking the permission required by the matched route is stopped the + same way, using the one 401 error type DESIGN.md defines for authentication failure. + +**Steps**: +1. [ ] - `p1` - Actor sends a request to a route under `/oagw/v1` carrying an `Authorization: Bearer ` header - `inst-auth-1` +2. [ ] - `p1` - API: `{METHOD} /oagw/v1/{path}` (request enters the gear's base router) - `inst-auth-2` +3. [ ] - `p1` - `toolkit-auth` (the platform's shared authentication library) validates the token and resolves a security principal - `inst-auth-3` +4. [ ] - `p1` - **IF** the token is missing, malformed, or invalid - `inst-auth-4` + 1. [ ] - `p1` - **RETURN** 401 `AuthenticationFailed` problem body (`application/problem+json`) with `X-OAGW-Error-Source: gateway` - `inst-auth-4a` +5. [ ] - `p1` - **ELSE** - `inst-auth-5` + 1. [ ] - `p1` - The shared permission-check mechanism evaluates the GTS permission required by the matched route against the resolved principal - `inst-auth-5a` +6. [ ] - `p1` - **IF** the resolved principal lacks the required permission - `inst-auth-6` + 1. [ ] - `p1` - **RETURN** 401 `AuthenticationFailed` problem body with `X-OAGW-Error-Source: gateway` - `inst-auth-6a` +7. [ ] - `p1` - **ELSE** - `inst-auth-7` + 1. [ ] - `p1` - **RETURN** control passed to the matched route handler, principal attached to the request context - `inst-auth-7a` + +### Unmatched Route Produces a Structured Gateway Error + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-gear-foundation-unmatched-route` + +**Actor**: `cpt-cf-oagw-actor-app-developer` + +**Success Scenarios**: +- A request to a path under `/oagw/v1` that no feature has registered a handler for receives + a structured `RouteNotFound` problem body instead of a bare framework 404. +- The response still carries `X-OAGW-Error-Source: gateway` alongside the problem body. + +**Error Scenarios**: +- None beyond the case above; this flow is itself the gear's fallback for unmatched paths. + +**Steps**: +1. [ ] - `p1` - Actor sends a request to `/oagw/v1/{unregistered-path}` after passing Bearer token authentication - `inst-unmatched-1` +2. [ ] - `p1` - API: `{METHOD} /oagw/v1/{unregistered-path}` (no handler is registered for this path in this build) - `inst-unmatched-2` +3. [ ] - `p1` - The base router's fallback handler builds a `RouteNotFound` problem body carrying `type`, `title`, `status`, `detail`, `instance`, and `trace_id` - `inst-unmatched-3` +4. [ ] - `p1` - **RETURN** 404 response, `Content-Type: application/problem+json`, `X-OAGW-Error-Source: gateway` - `inst-unmatched-4` + +## 3. Processes / Business Logic (CDSL) + +### Gear Registration and Startup + +- [ ] `p1` - **ID**: `cpt-cf-oagw-algo-gear-foundation-registration` + +**Input**: Host runtime startup event (the runtime's gear discovery pass) + +**Output**: `oagw` gear instance registered, base router mounted, and the `types_registry`, +`credstore`, `authz-resolver`, and `tenant-resolver` handles resolved and exposed + +**Steps**: +1. [ ] - `p1` - The host runtime iterates gears collected by `inventory` (a Rust crate that gathers `#[toolkit::gear(...)]`-tagged items at compile time for later discovery) - `inst-reg-1` +2. [ ] - `p1` - The `oagw` gear's `#[toolkit::gear(...)]` entry point is invoked with handles to `types_registry`, `cred_store`, `api_ingress`, `toolkit-db`, and `toolkit-auth` - `inst-reg-2` +3. [ ] - `p1` - **TRY** - `inst-reg-3` + 1. [ ] - `p1` - Deserialize `gears.oagw.config` into `OagwConfig` (delegates to `cpt-cf-oagw-algo-gear-foundation-config-load`) - `inst-reg-3a` +4. [ ] - `p1` - **CATCH** a config deserialization failure - `inst-reg-4` + 1. [ ] - `p1` - Abort gear startup with a descriptive error, so the host runtime fails fast rather than serving with an unknown config - `inst-reg-4a` +5. [ ] - `p1` - Mount the base router at `/oagw/v1` (Override 1: gear-relative, no `/api` prefix), giving later features a place to register resource routes - `inst-reg-5` +6. [ ] - `p1` - Register the Bearer-token authentication gate (`cpt-cf-oagw-algo-gear-foundation-bearer-auth`) on the mounted router - `inst-reg-6` +7. [ ] - `p1` - Resolve and expose the `types_registry`, `credstore`, `authz-resolver`, and `tenant-resolver` handles from the typed client registry, so later features register against an initialized dependency rather than an absent one - `inst-reg-7` +8. [ ] - `p1` - **RETURN** gear ready for request handling - `inst-reg-8` + +### OagwConfig Deserialization and Defaulting + +- [ ] `p1` - **ID**: `cpt-cf-oagw-algo-gear-foundation-config-load` + +**Input**: Raw `gears.oagw.config` YAML node, present or absent + +**Output**: A fully populated `OagwConfig` value + +**Steps**: +1. [ ] - `p1` - **IF** the `gears.oagw.config` block is absent from the supplied configuration file - `inst-cfgload-1` + 1. [ ] - `p1` - Use the documented default for every field: `proxy_timeout_secs`, `allow_http_upstream`, and `ssrf_policy.enabled` - `inst-cfgload-1a` +2. [ ] - `p1` - **ELSE** - `inst-cfgload-2` + 1. [ ] - `p1` - Deserialize the present block, applying the same per-field default to any field the block omits - `inst-cfgload-2a` +3. [ ] - `p1` - **FOR EACH** field in the resulting `OagwConfig` - `inst-cfgload-3` + 1. [ ] - `p1` - Validate the field deserializes as the type `OagwConfig` declares for it; for + example, `proxy_timeout_secs` must deserialize as the integer type `OagwConfig` declares - `inst-cfgload-3a` +4. [ ] - `p1` - **IF** any field's value fails that type check - `inst-cfgload-4` + 1. [ ] - `p1` - **RETURN** a config deserialization error to the caller; this surfaces through + the same channel the registration algorithm's **CATCH** (`inst-reg-4`) already handles - `inst-cfgload-4a` +5. [ ] - `p1` - **ELSE** - `inst-cfgload-5` + 1. [ ] - `p1` - **RETURN** the populated `OagwConfig` - `inst-cfgload-5a` + +### Gateway Error Construction + +- [ ] `p1` - **ID**: `cpt-cf-oagw-algo-gear-foundation-error-construct` + +**Input**: An internal error condition raised by a handler (this feature's own fallback, or a +later feature's handler) + +**Output**: An RFC 9457 problem body (`application/problem+json`) carrying +`X-OAGW-Error-Source: gateway` + +**Steps**: +1. [ ] - `p1` - Map the internal error condition to one of the GTS error-type identifiers and its HTTP status from DESIGN.md's error table (§3.3): `RouteError`/`ValidationError` (400), `MissingTargetHost` (400), `InvalidTargetHost` (400), `UnknownTargetHost` (400), `AuthenticationFailed` (401), `RouteNotFound` (404), `PluginInUse` (409), `PayloadTooLarge` (413), `RateLimitExceeded` (429), `SecretNotFound` (500), `ProtocolError` (502), `DownstreamError` (502), `StreamAborted` (502), `LinkUnavailable` (503), `CircuitBreakerOpen` (503), `PluginNotFound` (503), `ConnectionTimeout` (504), `RequestTimeout` (504), `IdleTimeout` (504) - `inst-errconstruct-1` +2. [ ] - `p1` - Build the Problem Details body: `type`, `title`, `status`, `detail`, `instance`, plus the OAGW extension fields `upstream_id`, `host`, `path`, `retry_after_seconds`, and `trace_id` where the error type carries them - `inst-errconstruct-2` +3. [ ] - `p1` - **IF** the error type carries retry guidance (for example, `RateLimitExceeded`) - `inst-errconstruct-3` + 1. [ ] - `p1` - Set the `Retry-After` header from `retry_after_seconds` - `inst-errconstruct-3a` +4. [ ] - `p1` - Set `Content-Type: application/problem+json` - `inst-errconstruct-4` +5. [ ] - `p1` - Set `X-OAGW-Error-Source: gateway` - `inst-errconstruct-5` +6. [ ] - `p1` - **RETURN** the HTTP response with the mapped status code - `inst-errconstruct-6` + +### Bearer Token Authentication Gate + +- [ ] `p1` - **ID**: `cpt-cf-oagw-algo-gear-foundation-bearer-auth` + +**Input**: An inbound HTTP request addressed to a route under `/oagw/v1` + +**Output**: An authenticated principal attached to the request, or an early 401 gateway error + +**Steps**: +1. [ ] - `p1` - Extract the `Authorization` header from the request - `inst-bearer-1` +2. [ ] - `p1` - **IF** the header is missing or not a `Bearer`-scheme value - `inst-bearer-2` + 1. [ ] - `p1` - **RETURN** 401 `AuthenticationFailed` built by `cpt-cf-oagw-algo-gear-foundation-error-construct` - `inst-bearer-2a` +3. [ ] - `p1` - **TRY** - `inst-bearer-3` + 1. [ ] - `p1` - Validate the token and resolve a security principal via `toolkit-auth` - `inst-bearer-3a` +4. [ ] - `p1` - **CATCH** a validation failure - `inst-bearer-4` + 1. [ ] - `p1` - **RETURN** 401 `AuthenticationFailed` built by `cpt-cf-oagw-algo-gear-foundation-error-construct` - `inst-bearer-4a` +5. [ ] - `p1` - Evaluate the GTS permission required by the matched route against the resolved principal - `inst-bearer-5` +6. [ ] - `p1` - **IF** the permission check fails - `inst-bearer-6` + 1. [ ] - `p1` - **RETURN** 401 `AuthenticationFailed` built by `cpt-cf-oagw-algo-gear-foundation-error-construct` - `inst-bearer-6a` +7. [ ] - `p1` - **RETURN** the resolved principal attached to the request context; the request proceeds to its matched handler - `inst-bearer-7` + +## 4. States (CDSL) + +### Gear Lifecycle State Machine + +- [ ] `p2` - **ID**: `cpt-cf-oagw-state-gear-foundation-lifecycle` + +**States**: Discovered, ConfigLoaded, RouterMounted, Ready + +**Initial State**: Discovered + +**Transitions**: +1. [ ] - `p1` - **FROM** Discovered **TO** ConfigLoaded **WHEN** `inventory` invokes the gear's `#[toolkit::gear(...)]` entry point and `OagwConfig` deserialization succeeds - `inst-lifecycle-1` +2. [ ] - `p1` - **FROM** ConfigLoaded **TO** RouterMounted **WHEN** the base router is mounted at `/oagw/v1` with the Bearer-token authentication gate attached - `inst-lifecycle-2` +3. [ ] - `p1` - **FROM** RouterMounted **TO** Ready **WHEN** the `types_registry`, `credstore`, + `authz-resolver`, and `tenant-resolver` handles are resolved from the typed client registry - `inst-lifecycle-3` + +## 5. Definitions of Done + +The `Entities` bullets below (`OagwConfig`, `ProblemDetails`) name gear-level Rust types this +feature defines, not tracked domain-model entities. DECOMPOSITION §2.1 lists Domain Model +Entities as None for this feature, and that stays unchanged. + +### Module Skeleton for the Control Plane / Data Plane Split + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-gear-foundation-module-skeleton` + +The system **MUST** scaffold the three-layer module skeleton `cpt-cf-oagw-design-layers` +defines: `domain/`, `infra/`, and `api/rest/`. The `domain/` module **MUST** declare +`ControlPlaneService` and `DataPlaneService` as minimal Rust traits. This gives ADR-0001's +control-plane / data-plane dispatch rule a concrete place to attach later, with no method +bodies required at this feature's gate. The `infra/` module **MUST** exist as a placeholder +for the SeaORM repositories, Pingora bridge, and plugin registry that later features fill in. +The `api/rest/` module **MUST** hold this feature's own base router and fallback handler, so +later features register their Axum handlers in the same layer rather than inventing a new one. + +**Implements**: +- `cpt-cf-oagw-algo-gear-foundation-registration` + +**Touches**: +- Entities: None (module and trait scaffolding, not a domain-model entity) + +### Gear Registration via Toolkit Macro and Inventory + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-gear-foundation-registration` + +The system **MUST** register the `oagw` gear with the host runtime using the +`#[toolkit::gear(...)]` attribute macro together with `inventory`'s compile-time +self-registration, so the runtime discovers and initializes `oagw` automatically at startup +with no manual wiring elsewhere in the platform. + +**Implements**: +- `cpt-cf-oagw-algo-gear-foundation-registration` +- `cpt-cf-oagw-state-gear-foundation-lifecycle` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: +- Entities: `OagwConfig` + +### OagwConfig Deserialization with Field Defaults + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-gear-foundation-config-defaults` + +The system **MUST** deserialize `OagwConfig` from the `gears.oagw.config` YAML block +(`proxy_timeout_secs`, `allow_http_upstream`, `ssrf_policy.enabled`) and **MUST** supply a +working default for every one of those fields, so a deployment with no `oagw.config` block +still starts and serves requests correctly. + +**Implements**: +- `cpt-cf-oagw-algo-gear-foundation-config-load` + +**Touches**: +- Entities: `OagwConfig` + +### Base Router Mounted at /oagw/v1 + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-gear-foundation-router-mount` + +The system **MUST** mount the gear's base router at the gear-relative path `/oagw/v1`, with no +`/api` prefix (Override 1), so every later feature registers its resource routes underneath +this one mount point rather than choosing its own base path. + +**Implements**: +- `cpt-cf-oagw-algo-gear-foundation-registration` + +**Touches**: +- API: base path `/oagw/v1` + +### Shared RFC 9457 Problem Details Type + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-gear-foundation-error-model` + +The system **MUST** define a shared Problem Details type carrying `type`, `title`, `status`, +`detail`, `instance`, and the OAGW extension fields `upstream_id`, `host`, `path`, +`retry_after_seconds`, and `trace_id`. Every later feature builds its error responses from this +one shared type, instead of redefining the body shape per handler. + +**Implements**: +- `cpt-cf-oagw-flow-gear-foundation-unmatched-route` +- `cpt-cf-oagw-algo-gear-foundation-error-construct` + +**Touches**: +- API: base path `/oagw/v1` (fallback handler) +- Entities: `ProblemDetails` + +### GTS Error-Type and HTTP Status Pairings + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-gear-foundation-error-type-catalog` + +The system **MUST** define all nineteen GTS error-type identifiers and their HTTP status +pairings from DESIGN.md §3.3. Later features are the ones that actually trigger most of them. +Each later feature then maps its own failures onto an already-declared identifier, instead of +inventing new error-type strings. + +**Implements**: +- `cpt-cf-oagw-algo-gear-foundation-error-construct` + +**Touches**: +- Entities: `ProblemDetails` + +### Universal X-OAGW-Error-Source Header + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-gear-foundation-error-source-header` + +The system **MUST** attach `X-OAGW-Error-Source: gateway|upstream` to every response this gear +returns, success or error alike. A client can then always tell whether a given response came +from the gateway itself, or was relayed from an upstream call. + +**Implements**: +- `cpt-cf-oagw-flow-gear-foundation-unmatched-route` +- `cpt-cf-oagw-algo-gear-foundation-error-construct` + +**Touches**: +- API: base path `/oagw/v1` (fallback handler) + +### Inbound Bearer Token Authentication and Shared Permission Gate + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-gear-foundation-bearer-auth` + +The system **MUST** wire `toolkit-auth` Bearer token authentication as the shared +permission-check gate on every route mounted under `/oagw/v1`, in this feature and every later +one, denying an unauthenticated or unauthorized request with a 401 gateway error before any +handler logic runs. + +**Implements**: +- `cpt-cf-oagw-flow-gear-foundation-inbound-auth` +- `cpt-cf-oagw-algo-gear-foundation-bearer-auth` + +**Touches**: +- API: base path `/oagw/v1` (authentication gate) + +### External Dependency Handles Resolved from the Typed Client Registry + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-gear-foundation-dependency-registration` + +The system **MUST** register handles for `types_registry`, `cred_store`, `api_ingress`, +`toolkit-db`, and `toolkit-auth` at startup. This holds even where a given dependency's data +path is unused in the graded deployment, for example when no database is configured. It +**MUST** also resolve and expose the `types_registry`, `credstore`, `authz-resolver`, and +`tenant-resolver` handles from the typed client registry during init. Later features then +register their own GTS (Global Type System) type schemas against an already-initialized +dependency, instead of one this feature must reach on their behalf. Registering the entity +schemas those types describe is not this feature's job; it belongs to the features that own +those entities. + +**Implements**: +- `cpt-cf-oagw-algo-gear-foundation-registration` + +## 6. Acceptance Criteria + +- [ ] The server starts with the `oagw` gear registered, discoverable through the `inventory` + mechanism, with no startup panic. +- [ ] A route registered inside this feature's own test suite, under `/oagw/v1`, is served by + its handler rather than answered with a bare framework 404; production resource routes are + not registered until later features land. +- [ ] A request to an unregistered path under `/oagw/v1` returns a `RouteNotFound` problem body + (`application/problem+json`) with `X-OAGW-Error-Source: gateway` and HTTP status 404. +- [ ] A deliberately triggered gateway error returns `application/problem+json` carrying the + documented GTS `type` identifier for that error and the matching HTTP status from + DESIGN.md §3.3. +- [ ] A request to a route under `/oagw/v1` with no `Authorization` header, or an invalid + Bearer token, is rejected with HTTP 401, `AuthenticationFailed`, and + `X-OAGW-Error-Source: gateway`. +- [ ] `OagwConfig` loads with the documented defaults when the `gears.oagw.config` block is + absent from the supplied configuration. +- [ ] `OagwConfig` loads `proxy_timeout_secs: 2`, `allow_http_upstream: true`, and + `ssrf_policy.enabled: false` when started with `config/e2e-local.yaml`. +- [ ] Every response this feature itself produces, success or error, carries an + `X-OAGW-Error-Source` header set to `gateway`. +- [ ] The `upstream` value of `X-OAGW-Error-Source` is not exercised at this feature's gate; it + is first exercised starting with `cpt-cf-oagw-feature-proxy-data-plane-http`, once an actual + upstream call exists to relay an error from. diff --git a/gears/system/oagw/docs/features/plugin-management-api.md b/gears/system/oagw/docs/features/plugin-management-api.md new file mode 100644 index 0000000..854765f --- /dev/null +++ b/gears/system/oagw/docs/features/plugin-management-api.md @@ -0,0 +1,471 @@ +# Feature: Plugin Management API + + + +- [1. Feature Context](#1-feature-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Create Custom Plugin Flow](#create-custom-plugin-flow) + - [List Plugins Flow](#list-plugins-flow) + - [Get Plugin Flow](#get-plugin-flow) + - [Get Plugin Source Flow](#get-plugin-source-flow) + - [Delete Plugin Flow](#delete-plugin-flow) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Plugin Reference Resolution Algorithm](#plugin-reference-resolution-algorithm) + - [Plugin In-Use Scan Algorithm](#plugin-in-use-scan-algorithm) + - [Plugin Create Validation Algorithm](#plugin-create-validation-algorithm) +- [4. States (CDSL)](#4-states-cdsl) + - [Plugin Lifecycle State Machine](#plugin-lifecycle-state-machine) +- [5. Definitions of Done](#5-definitions-of-done) + - [Implement Plugin Creation](#implement-plugin-creation) + - [Implement Plugin Listing with OData Query Support](#implement-plugin-listing-with-odata-query-support) + - [Implement Plugin Read and Source-Fetch Endpoints](#implement-plugin-read-and-source-fetch-endpoints) + - [Implement Plugin Deletion with Reference Guard](#implement-plugin-deletion-with-reference-guard) + - [Enforce Plugin Immutability](#enforce-plugin-immutability) + - [Conform Plugin Error Responses to RFC 9457](#conform-plugin-error-responses-to-rfc-9457) +- [6. Acceptance Criteria](#6-acceptance-criteria) + + + +- [ ] `p2` - **ID**: `cpt-cf-oagw-featstatus-plugin-management-api-implemented` + + +- [ ] `p2` - `cpt-cf-oagw-feature-plugin-management-api` + +## 1. Feature Context + +### 1.1 Overview + +This feature exposes the create, list, read, source-fetch, and delete surface for custom +plugin definitions. A custom plugin is a tenant-authored script written in Starlark (a +Python-like scripting language OAGW stores and returns but does not execute in this build). +There is no update route — plugins are immutable once created. + +### 1.2 Purpose + +The Plugin Management API lets a platform operator or tenant administrator register, inspect, +and retire the auth, guard, and transform plugins that upstream and route configurations bind +to. It covers only the storage and catalogue half of the plugin system; the traits that run +plugins during a proxied request belong to other features. + +**Requirements**: `cpt-cf-oagw-fr-plugin-system`, `cpt-cf-oagw-nfr-multi-tenancy`, `cpt-cf-oagw-nfr-input-validation` + +**Principles**: `cpt-cf-oagw-principle-plugin-immutable`, `cpt-cf-oagw-principle-rfc9457`, `cpt-cf-oagw-principle-error-source` + +**Plugin entity**: a `Plugin` record has `id` (the stored UUID), `tenant_id`, `plugin_type` +(`auth`, `guard`, or `transform`), `name`, `description`, `config_schema` (a JSON Schema object +describing the plugin's configuration shape), `phases` (transform plugins only — any of +`on_request`, `on_response`, `on_error`, naming which stage of a proxied call the plugin +touches), `source_code` (the stored Starlark text), `last_used_at`, and `gc_eligible_at` (the +latter two support the future garbage-collection sweep described under Out of Scope below). + +**Plugin identification model**: every plugin is addressed in the API by a GTS (Global Type +System — the platform's schema-and-instance identifier scheme) identifier. Named plugins — +built into the gateway or shipped by a deployed gear — use +`gts.cf.core.oagw.{type}_plugin.v1~cf.core.oagw.{name}.v1` and resolve through an in-process +registry; they are never stored in the plugin store and are never subject to garbage +collection. Custom plugins use `gts.cf.core.oagw.{type}_plugin.v1~{uuid}` and are stored, +keyed by that UUID. `cpt-cf-oagw-algo-plugin-api-ref-resolution` (Section 3) defines the exact +four-step lookup this feature performs whenever it is handed such an identifier. + +**Built-in catalogue**: not every documented identifier resolves. The registry actually +answers for auth `noop`, `apikey`, `oauth2_client_cred`, and `oauth2_client_cred_basic`; for +guard `required_headers`; and for transform `request_id`. The remaining identifiers exist only +for types-registry cataloguing and fail resolution: auth `basic.v1` and `bearer.v1` are +reserved with no backing implementation and fail with `"unknown auth plugin"`; guard +`timeout.v1` and `cors.v1` name core data-plane functionality (request timeout enforcement and +CORS validation) rather than a `GuardPlugin` implementation; transform `logging.v1` and +`metrics.v1` name core instrumentation (structured logging and Prometheus metrics) rather than +a `TransformPlugin` implementation. `required_headers.v1` is the only guard identifier this +feature's callers may bind through `plugins.items[].plugin_ref`. + +**Tenant scoping and permissions**: every plugin read, create, and delete is scoped to the +calling tenant; a plugin owned by a different tenant is invisible and reported as 404, matching +the invisibility rule applied to upstreams and routes. Named plugins are global registry +entries, not tenant data, so every tenant sees the same catalogue. Each plugin type carries its +own permission on the Bearer token presented to the Management API: + +| Permission | Allows | +|---|---| +| `gts.cf.core.oagw.auth_plugin.v1~:{create;read;delete}` | Create, read, delete auth plugins | +| `gts.cf.core.oagw.guard_plugin.v1~:{create;read;delete}` | Create, read, delete guard plugins | +| `gts.cf.core.oagw.transform_plugin.v1~:{create;read;delete}` | Create, read, delete transform plugins | + +**Out of scope**: Starlark source is stored and served verbatim but never executed — no +Starlark runtime is enabled in this build, so `cpt-cf-oagw-nfr-starlark-sandbox`'s sandboxed +execution requirements have nothing to run against here. The periodic garbage-collection sweep +that deletes plugin rows once `gc_eligible_at` has passed is also deferred; this feature still +enforces the synchronous in-use check on every `DELETE` (Section 3), so an unlinked plugin +remains deletable on demand even though the automatic sweep does not run. + +### 1.3 Actors + +- `cpt-cf-oagw-actor-platform-operator` - Registers and retires system-wide custom plugins and consults the built-in catalogue before binding a plugin to an upstream or route. +- `cpt-cf-oagw-actor-tenant-admin` - Creates tenant-scoped custom plugins, reviews their stored source, and deletes plugins no longer referenced. + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) +- **Design**: [DESIGN.md](../DESIGN.md) +- **Design elements**: `cpt-cf-oagw-design-domain-model`, `cpt-cf-oagw-component-model`, `cpt-cf-oagw-interface-api`, `cpt-cf-oagw-db-schema`, `cpt-cf-oagw-adr-plugin-system` +- **Dependencies**: `cpt-cf-oagw-feature-route-management-api` + +## 2. Actor Flows (CDSL) + +**Use cases**: `cpt-cf-oagw-fr-plugin-system` + +### Create Custom Plugin Flow + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-plugin-api-create` + +**Actor**: `cpt-cf-oagw-actor-tenant-admin` + +**Success Scenarios**: +- A well-formed plugin definition is stored and a UUID-backed GTS identifier is returned + +**Error Scenarios**: +- `plugin_type` is not `auth`, `guard`, or `transform` +- `phases` is supplied for a non-transform `plugin_type` +- `config_schema` is not a syntactically valid JSON Schema object +- `name` is empty, or already used by another plugin for this tenant +- `phases` is empty or contains a value outside `on_request`/`on_response`/`on_error` when `plugin_type` is `transform` +- `source_code` is empty +- Bearer token lacks the matching `{type}_plugin.v1~:create` permission + +**Steps**: +1. [ ] - `p1` - Tenant admin submits a plugin definition (`plugin_type`, `name`, `description`, `config_schema`, `phases`, `source_code`) - `inst-create-1` +2. [ ] - `p1` - API: POST /oagw/v1/plugins ({ plugin_type, name, description, config_schema, phases, source_code }) - `inst-create-2` +3. [ ] - `p1` - **IF** caller lacks the `gts.cf.core.oagw.{plugin_type}_plugin.v1~:create` permission matching the submitted `plugin_type` - `inst-create-3` + 1. [ ] - `p1` - **RETURN** 401 AuthenticationFailed (`application/problem+json`, `X-OAGW-Error-Source: gateway`) - `inst-create-3a` +4. [ ] - `p1` - **ELSE** - `inst-create-4` + 1. [ ] - `p1` - Run `cpt-cf-oagw-algo-plugin-api-create-validate` against the submitted body - `inst-create-4a` + 2. [ ] - `p1` - **IF** validation passes - `inst-create-4b` + 1. [ ] - `p1` - Generate a server-side UUID for the plugin instance - `inst-create-4b1` + 2. [ ] - `p1` - Store: INSERT oagw_plugin (id, tenant_id, plugin_type, name, description, config_schema, phases, source_code, last_used_at=null, gc_eligible_at=null) - `inst-create-4b2` + 3. [ ] - `p1` - Assemble `id = gts.cf.core.oagw.{plugin_type}_plugin.v1~{uuid}` and `plugin_uuid = {uuid}` - `inst-create-4b3` + 4. [ ] - `p1` - **RETURN** 201 Created with the stored plugin body - `inst-create-4b4` + 3. [ ] - `p1` - **ELSE** - `inst-create-4c` + 1. [ ] - `p1` - **RETURN** 400 ValidationError (`application/problem+json`, `X-OAGW-Error-Source: gateway`) - `inst-create-4c1` + +### List Plugins Flow + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-plugin-api-list` + +**Actor**: `cpt-cf-oagw-actor-platform-operator` + +**Success Scenarios**: +- The tenant's stored custom plugins are returned, filtered and paginated as requested + +**Error Scenarios**: +- `$top` or `$skip` is not a non-negative integer +- Caller holds none of the three `{type}_plugin.v1~:read` permissions (`auth_plugin`, `guard_plugin`, `transform_plugin`) + +**Steps**: +1. [ ] - `p1` - Operator requests the plugin catalogue with optional OData (Open Data Protocol — the query-parameter convention this API reuses from the upstream and route list endpoints) parameters - `inst-list-1` +2. [ ] - `p1` - API: GET /oagw/v1/plugins?$filter=...&$select=...&$top=...&$skip=... - `inst-list-2` +3. [ ] - `p1` - **IF** caller holds none of `gts.cf.core.oagw.auth_plugin.v1~:read`, `gts.cf.core.oagw.guard_plugin.v1~:read`, `gts.cf.core.oagw.transform_plugin.v1~:read` - `inst-list-3` + 1. [ ] - `p1` - **RETURN** 401 AuthenticationFailed (`application/problem+json`, `X-OAGW-Error-Source: gateway`) - `inst-list-3a` +4. [ ] - `p1` - **ELSE** - `inst-list-4` + 1. [ ] - `p1` - Store: SELECT oagw_plugin WHERE tenant_id = :tenant - `inst-list-4a` + 2. [ ] - `p1` - **IF** `$filter` is present - `inst-list-4b` + 1. [ ] - `p1` - Apply the OData filter expression to the in-memory result set - `inst-list-4b1` + 3. [ ] - `p1` - **IF** `$select` is present - `inst-list-4c` + 1. [ ] - `p1` - Project only the requested fields for each returned plugin - `inst-list-4c1` + 4. [ ] - `p1` - Apply `$skip` offset then `$top` limit (default 50, max 100) - `inst-list-4d` + 5. [ ] - `p1` - **RETURN** 200 with the paginated list of tenant-scoped custom plugins, limited to plugin types for which the caller holds the matching `read` permission; named plugins never appear here because they are not stored - `inst-list-4e` + +### Get Plugin Flow + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-plugin-api-get` + +**Actor**: `cpt-cf-oagw-actor-platform-operator` + +**Success Scenarios**: +- A stored plugin's full metadata, including its `source_code` field, is returned + +**Error Scenarios**: +- `{id}` names a plugin owned by a different tenant, a named/reserved identifier, or an unknown UUID +- Caller lacks the `gts.cf.core.oagw.{type}_plugin.v1~:read` permission matching the `{type}` parsed from `{id}` + +**Steps**: +1. [ ] - `p1` - Caller requests a plugin by its GTS identifier - `inst-get-1` +2. [ ] - `p1` - API: GET /oagw/v1/plugins/{id} - `inst-get-2` +3. [ ] - `p1` - **IF** caller lacks the `gts.cf.core.oagw.{type}_plugin.v1~:read` permission for the `{type}` parsed from `{id}`'s schema part - `inst-get-3` + 1. [ ] - `p1` - **RETURN** 401 AuthenticationFailed (`application/problem+json`, `X-OAGW-Error-Source: gateway`) - `inst-get-3a` +4. [ ] - `p1` - **ELSE** - `inst-get-4` + 1. [ ] - `p1` - Run `cpt-cf-oagw-algo-plugin-api-ref-resolution` on `{id}` - `inst-get-4a` + 2. [ ] - `p1` - **IF** resolution returns a stored plugin owned by the caller's tenant - `inst-get-4b` + 1. [ ] - `p1` - **RETURN** 200 with the full plugin record - `inst-get-4b1` + 3. [ ] - `p1` - **ELSE** - `inst-get-4c` + 1. [ ] - `p1` - **RETURN** 404 RouteNotFound-shaped Problem Details (`application/problem+json`, `X-OAGW-Error-Source: gateway`). `RouteNotFound` is reused here because it is the only 404 identifier the supplied error table defines. DESIGN.md's `PluginNotFound` is deliberately not used, since that identifier names a 503 for a different, data-plane failure mode - `inst-get-4c1` + +### Get Plugin Source Flow + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-plugin-api-get-source` + +**Actor**: `cpt-cf-oagw-actor-tenant-admin` + +**Success Scenarios**: +- The stored Starlark source is returned verbatim, unmodified and unexecuted + +**Error Scenarios**: +- `{id}` names a plugin owned by a different tenant, a named/reserved identifier, or an unknown UUID +- Caller lacks the `gts.cf.core.oagw.{type}_plugin.v1~:read` permission matching the `{type}` parsed from `{id}` + +**Steps**: +1. [ ] - `p1` - Caller requests the raw source of a plugin - `inst-src-1` +2. [ ] - `p1` - API: GET /oagw/v1/plugins/{id}/source - `inst-src-2` +3. [ ] - `p1` - **IF** caller lacks the `gts.cf.core.oagw.{type}_plugin.v1~:read` permission for the `{type}` parsed from `{id}`'s schema part - `inst-src-3` + 1. [ ] - `p1` - **RETURN** 401 AuthenticationFailed (`application/problem+json`, `X-OAGW-Error-Source: gateway`) - `inst-src-3a` +4. [ ] - `p1` - **ELSE** - `inst-src-4` + 1. [ ] - `p1` - Run `cpt-cf-oagw-algo-plugin-api-ref-resolution` on `{id}` - `inst-src-4a` + 2. [ ] - `p1` - **IF** resolution returns a stored plugin owned by the caller's tenant - `inst-src-4b` + 1. [ ] - `p1` - **RETURN** 200 `text/plain` body equal to the stored `source_code`, returned verbatim and never run - `inst-src-4b1` + 3. [ ] - `p1` - **ELSE** - `inst-src-4c` + 1. [ ] - `p1` - **RETURN** 404 RouteNotFound-shaped Problem Details - `inst-src-4c1` + +### Delete Plugin Flow + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-plugin-api-delete` + +**Actor**: `cpt-cf-oagw-actor-tenant-admin` + +**Success Scenarios**: +- An unreferenced plugin is permanently removed from the store + +**Error Scenarios**: +- `{id}` does not resolve to a plugin owned by the caller's tenant +- The plugin is still bound to an upstream or a route +- Caller lacks the `gts.cf.core.oagw.{type}_plugin.v1~:delete` permission matching the `{type}` parsed from `{id}` + +**Steps**: +1. [ ] - `p1` - Tenant admin requests removal of a plugin - `inst-del-1` +2. [ ] - `p1` - API: DELETE /oagw/v1/plugins/{id} - `inst-del-2` +3. [ ] - `p1` - **IF** caller lacks the `gts.cf.core.oagw.{type}_plugin.v1~:delete` permission for the `{type}` parsed from `{id}`'s schema part - `inst-del-3` + 1. [ ] - `p1` - **RETURN** 401 AuthenticationFailed (`application/problem+json`, `X-OAGW-Error-Source: gateway`) - `inst-del-3a` +4. [ ] - `p1` - **ELSE** - `inst-del-4` + 1. [ ] - `p1` - Run `cpt-cf-oagw-algo-plugin-api-ref-resolution` on `{id}` - `inst-del-4a` + 2. [ ] - `p1` - **IF** resolution does not return a stored plugin owned by the caller's tenant - `inst-del-4b` + 1. [ ] - `p1` - **RETURN** 404 RouteNotFound-shaped Problem Details - `inst-del-4b1` + 3. [ ] - `p1` - **ELSE** - `inst-del-4c` + 1. [ ] - `p1` - Run `cpt-cf-oagw-algo-plugin-api-in-use-scan` on the resolved `plugin_uuid` - `inst-del-4c1` + 2. [ ] - `p1` - **IF** the scan's `referenced_by.upstreams` and `referenced_by.routes` are both empty - `inst-del-4c2` + 1. [ ] - `p1` - Store: DELETE oagw_plugin WHERE id = :uuid AND tenant_id = :tenant - `inst-del-4c2a` + 2. [ ] - `p1` - **RETURN** 204 No Content - `inst-del-4c2b` + 3. [ ] - `p1` - **ELSE** - `inst-del-4c3` + 1. [ ] - `p1` - **RETURN** 409 Conflict (PluginInUse) with `type`, `title`, `status`, `detail`, `plugin_id`, and the populated `referenced_by` object - `inst-del-4c3a` + +## 3. Processes / Business Logic (CDSL) + +### Plugin Reference Resolution Algorithm + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-plugin-api-ref-resolution` + +**Input**: a GTS plugin identifier (e.g. the `{id}` path parameter), of the form +`gts.cf.core.oagw.{type}_plugin.v1~{instance}` + +**Output**: `Stored(plugin_uuid, plugin_type, tenant_id)`, `Named(plugin_ref)`, or `NotFound` + +**Steps**: +1. [ ] - `p1` - Parse the identifier into its schema part (`gts.cf.core.oagw.{type}_plugin.v1`) and the instance part after `~` - `inst-resolve-1` +2. [ ] - `p1` - **IF** the instance part parses as a valid UUID - `inst-resolve-2` + 1. [ ] - `p1` - Store: SELECT oagw_plugin WHERE tenant_id = :tenant AND id = :uuid - `inst-resolve-2a` +3. [ ] - `p1` - **IF** a row was found and its stored `plugin_type` matches the requested `{type}_plugin` schema - `inst-resolve-3` + 1. [ ] - `p1` - **RETURN** `Stored(uuid, plugin_type, tenant_id)` - `inst-resolve-3a` +4. [ ] - `p1` - **ELSE IF** the instance part is not a UUID (a dotted name, e.g. `cf.core.oagw.apikey.v1`) - `inst-resolve-4` + 1. [ ] - `p1` - Look up the name against the in-process registry for the requested `plugin_type` - `inst-resolve-4a` +5. [ ] - `p1` - **IF** the name is a registry-resolvable built-in (auth: `noop`, `apikey`, `oauth2_client_cred`, `oauth2_client_cred_basic`; guard: `required_headers`; transform: `request_id`) - `inst-resolve-5` + 1. [ ] - `p1` - **RETURN** `Named(plugin_ref)` - `inst-resolve-5a` +6. [ ] - `p1` - **ELSE** - `inst-resolve-6` + 1. [ ] - `p1` - **RETURN** `NotFound` — covers a missing or schema-mismatched UUID row, catalog-only identifiers (`basic.v1`, `bearer.v1`, `timeout.v1`, `cors.v1`, `logging.v1`, `metrics.v1`), and unrecognized names; an auth-type lookup additionally carries the detail `"unknown auth plugin"` - `inst-resolve-6a` +7. [ ] - `p1` - **RETURN** to the caller a persistence rule: always store `plugin_ref`; store `plugin_uuid` only when the result was `Stored` - `inst-resolve-7` + +### Plugin In-Use Scan Algorithm + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-plugin-api-in-use-scan` + +**Input**: `plugin_uuid` of a stored plugin, `tenant_id` + +**Output**: `referenced_by { upstreams: [gts identifiers], routes: [gts identifiers] }` + +**Steps**: +1. [ ] - `p1` - Store: SELECT parent_id FROM oagw_upstream_plugin WHERE tenant_id = :tenant AND plugin_uuid = :uuid - `inst-scan-1` +2. [ ] - `p1` - Store: SELECT parent_id FROM oagw_route_plugin WHERE tenant_id = :tenant AND plugin_uuid = :uuid - `inst-scan-2` +3. [ ] - `p1` - Store: SELECT id FROM oagw_upstream WHERE tenant_id = :tenant AND auth_plugin_uuid = :uuid - `inst-scan-3` +4. [ ] - `p1` - **FOR EACH** upstream id found in steps 1 and 3 - `inst-scan-4` + 1. [ ] - `p1` - Add its `gts.cf.core.oagw.upstream.v1~{id}` identifier to `referenced_by.upstreams`, de-duplicated - `inst-scan-4a` +5. [ ] - `p1` - **FOR EACH** route id found in step 2 - `inst-scan-5` + 1. [ ] - `p1` - Add its `gts.cf.core.oagw.route.v1~{id}` identifier to `referenced_by.routes` - `inst-scan-5a` +6. [ ] - `p1` - **RETURN** `referenced_by` — both arrays empty means the plugin is unreferenced and deletable - `inst-scan-6` + +### Plugin Create Validation Algorithm + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-plugin-api-create-validate` + +**Input**: `POST /oagw/v1/plugins` request body + +**Output**: `{ valid: bool, errors: [String] }` + +**Steps**: +1. [ ] - `p1` - Parse and normalize `plugin_type`, `name`, `description`, `config_schema`, `phases`, `source_code` - `inst-cval-1` +2. [ ] - `p1` - **IF** `plugin_type` is not one of `auth`, `guard`, `transform` - `inst-cval-2` + 1. [ ] - `p1` - Add error: invalid `plugin_type` - `inst-cval-2a` +3. [ ] - `p1` - **IF** `name` is empty, or already used by another plugin for this tenant - `inst-cval-3` + 1. [ ] - `p1` - Add error: `name` must be unique per tenant - `inst-cval-3a` +4. [ ] - `p1` - **IF** `phases` is present and `plugin_type` is not `transform` - `inst-cval-4` + 1. [ ] - `p1` - Add error: `phases` is only valid for transform plugins - `inst-cval-4a` +5. [ ] - `p1` - **IF** `plugin_type` is `transform` and `phases` is empty or contains a value outside `on_request`/`on_response`/`on_error` - `inst-cval-5` + 1. [ ] - `p1` - Add error: invalid `phases` value - `inst-cval-5a` +6. [ ] - `p1` - **IF** `config_schema` is present and is not a syntactically valid JSON Schema object - `inst-cval-6` + 1. [ ] - `p1` - Add error: `config_schema` must be a valid JSON Schema - `inst-cval-6a` +7. [ ] - `p1` - **IF** `source_code` is empty - `inst-cval-7` + 1. [ ] - `p1` - Add error: `source_code` is required - `inst-cval-7a` +8. [ ] - `p1` - **RETURN** `{ valid: errors.length === 0, errors }` - `inst-cval-8` + +## 4. States (CDSL) + +### Plugin Lifecycle State Machine + +- [ ] `p2` - **ID**: `cpt-cf-oagw-state-plugin-api-lifecycle` + +**States**: created, bound, unlinked, gc-eligible, deleted + +**Initial State**: created + +**Transitions**: +1. [ ] - `p1` - **FROM** created **TO** bound **WHEN** a binding referencing the plugin's `plugin_uuid` is added — an `oagw_upstream_plugin`/`oagw_route_plugin` row, or an upstream's `auth_plugin_uuid` column - `inst-lc-1` +2. [ ] - `p1` - **FROM** bound **TO** unlinked **WHEN** the last referencing binding is removed and `cpt-cf-oagw-algo-plugin-api-in-use-scan` returns zero bindings - `inst-lc-2` +3. [ ] - `p1` - **FROM** unlinked **TO** bound **WHEN** a new binding references the plugin again - `inst-lc-3` +4. [ ] - `p1` - **FROM** unlinked **TO** gc-eligible **WHEN** `gc_eligible_at` is stamped with the TTL expiry timestamp (default 30 days per DESIGN.md) at the moment the plugin becomes unlinked - `inst-lc-4` +5. [ ] - `p1` - **FROM** created **TO** deleted **WHEN** DELETE /oagw/v1/plugins/{id} succeeds for a plugin that was never bound - `inst-lc-5` +6. [ ] - `p1` - **FROM** unlinked **TO** deleted **WHEN** DELETE /oagw/v1/plugins/{id} succeeds while the plugin is unlinked - `inst-lc-6` +7. [ ] - `p1` - **FROM** gc-eligible **TO** deleted **WHEN** DELETE /oagw/v1/plugins/{id} succeeds while the plugin awaits garbage collection, or — out of scope for this build — a periodic garbage-collection sweep runs after `gc_eligible_at` has passed - `inst-lc-7` + +## 5. Definitions of Done + +### Implement Plugin Creation + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-plugin-api-create` + +The system **MUST** accept `POST /oagw/v1/plugins`, validate the payload with +`cpt-cf-oagw-algo-plugin-api-create-validate`, and persist an accepted custom plugin under a +server-generated UUID assembled into a GTS identifier of the form +`gts.cf.core.oagw.{plugin_type}_plugin.v1~{uuid}`. + +**Implements**: +- `cpt-cf-oagw-flow-plugin-api-create` +- `cpt-cf-oagw-algo-plugin-api-create-validate` + +**Touches**: +- API: `POST /oagw/v1/plugins` +- DB Table: `cpt-cf-oagw-db-schema` +- Entities: `Plugin` + +### Implement Plugin Listing with OData Query Support + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-plugin-api-list` + +The system **MUST** list the calling tenant's stored custom plugins via `GET /oagw/v1/plugins`, +applying `$filter`, `$select`, `$top` (default 50, max 100), and `$skip` exactly as the +upstream and route list endpoints do. + +**Implements**: +- `cpt-cf-oagw-flow-plugin-api-list` + +**Touches**: +- API: `GET /oagw/v1/plugins` +- DB Table: `cpt-cf-oagw-db-schema` +- Entities: `Plugin` + +### Implement Plugin Read and Source-Fetch Endpoints + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-plugin-api-read` + +The system **MUST** resolve the `{id}` path parameter through +`cpt-cf-oagw-algo-plugin-api-ref-resolution` for both `GET /oagw/v1/plugins/{id}` and +`GET /oagw/v1/plugins/{id}/source`, returning the stored record or the raw `source_code` for a +tenant-owned, UUID-backed plugin, and a `404 RouteNotFound`-shaped Problem Details response for +every other case. + +**Implements**: +- `cpt-cf-oagw-flow-plugin-api-get` +- `cpt-cf-oagw-flow-plugin-api-get-source` +- `cpt-cf-oagw-algo-plugin-api-ref-resolution` + +**Touches**: +- API: `GET /oagw/v1/plugins/{id}` +- API: `GET /oagw/v1/plugins/{id}/source` +- DB Table: `cpt-cf-oagw-db-schema` +- Entities: `Plugin` + +### Implement Plugin Deletion with Reference Guard + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-plugin-api-delete` + +The system **MUST** run `cpt-cf-oagw-algo-plugin-api-in-use-scan` across +`oagw_upstream_plugin`, `oagw_route_plugin`, and every upstream's `auth_plugin_uuid` column +before deleting a plugin, returning `204 No Content` when unreferenced and `409 Conflict` +(`PluginInUse`) with a populated `referenced_by` object otherwise. + +**Implements**: +- `cpt-cf-oagw-flow-plugin-api-delete` +- `cpt-cf-oagw-algo-plugin-api-in-use-scan` +- `cpt-cf-oagw-state-plugin-api-lifecycle` + +**Touches**: +- API: `DELETE /oagw/v1/plugins/{id}` +- DB Table: `cpt-cf-oagw-db-schema` +- Entities: `Plugin` + +### Enforce Plugin Immutability + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-plugin-api-immutable` + +The system **MUST NOT** register a `PUT` or `PATCH` route under `/oagw/v1/plugins/{id}`. +Changing a plugin's behavior **MUST** be done by creating a new plugin via `POST` and +re-binding the affected upstream and route references to the new identifier. + +**Implements**: +- `cpt-cf-oagw-state-plugin-api-lifecycle` + +**Constraints**: `cpt-cf-oagw-principle-plugin-immutable` + +**Touches**: +- Entities: `Plugin` + +### Conform Plugin Error Responses to RFC 9457 + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-plugin-api-errors` + +The system **MUST** render every plugin-endpoint error (`400`, `401`, `404`, `409`) as an +`application/problem+json` body carrying the matching GTS `type` from DESIGN.md's error table, +plus an `X-OAGW-Error-Source: gateway` header on every response. + +**Implements**: +- `cpt-cf-oagw-flow-plugin-api-create` +- `cpt-cf-oagw-flow-plugin-api-delete` + +**Constraints**: `cpt-cf-oagw-principle-rfc9457`, `cpt-cf-oagw-principle-error-source` + +**Touches**: +- API: `POST /oagw/v1/plugins` +- API: `GET /oagw/v1/plugins` +- API: `GET /oagw/v1/plugins/{id}` +- API: `GET /oagw/v1/plugins/{id}/source` +- API: `DELETE /oagw/v1/plugins/{id}` +- Entities: `Plugin` + +## 6. Acceptance Criteria + +- [ ] `POST /oagw/v1/plugins` with a valid custom plugin body returns `201 Created` and a UUID-backed GTS identifier of the form `gts.cf.core.oagw.{plugin_type}_plugin.v1~{uuid}` +- [ ] `GET /oagw/v1/plugins/{id}/source` returns the stored `source_code` verbatim for a UUID-backed plugin +- [ ] `DELETE /oagw/v1/plugins/{id}` for an unreferenced plugin returns `204 No Content` +- [ ] `DELETE /oagw/v1/plugins/{id}` for a plugin bound to an upstream or a route returns `409 Conflict` with a `referenced_by` object naming that upstream or route +- [ ] No `PUT` (or `PATCH`) route exists under `/oagw/v1/plugins/{id}` +- [ ] `GET /oagw/v1/plugins` honors `$filter`, `$select`, `$top`, and `$skip`, and returns only plugins owned by the caller's tenant +- [ ] `GET /oagw/v1/plugins/{id}` for a reserved, catalog-only identifier (e.g. `gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.basic.v1`) returns a `404 RouteNotFound`-shaped Problem Details response, since only custom UUID-backed plugins are stored +- [ ] `GET /oagw/v1/plugins/{id}` for a tenant-owned, UUID-backed plugin returns `200 OK` with the full stored record, including its `source_code` field +- [ ] A request against any plugin endpoint with no valid Bearer token, or a token lacking the matching `{type}_plugin.v1~:{create|read|delete}` permission, returns `401 AuthenticationFailed` diff --git a/gears/system/oagw/docs/features/policy-and-plugins.md b/gears/system/oagw/docs/features/policy-and-plugins.md new file mode 100644 index 0000000..84b4d16 --- /dev/null +++ b/gears/system/oagw/docs/features/policy-and-plugins.md @@ -0,0 +1,983 @@ +# Feature: Policy and Plugins + + + +- [1. Feature Context](#1-feature-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Credential-Injected Proxy Flow](#credential-injected-proxy-flow) + - [Required Header Rejection Flow](#required-header-rejection-flow) + - [Rate Limit Exceeded Flow](#rate-limit-exceeded-flow) + - [CORS Preflight Flow](#cors-preflight-flow) + - [CORS Actual Request Flow](#cors-actual-request-flow) + - [Configure Policy Layer Flow](#configure-policy-layer-flow) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Hierarchical Configuration Merge Algorithm](#hierarchical-configuration-merge-algorithm) + - [Plugin Chain Composition Algorithm](#plugin-chain-composition-algorithm) + - [Plugin Chain Execution Algorithm](#plugin-chain-execution-algorithm) + - [Auth Credential Injection Algorithm](#auth-credential-injection-algorithm) + - [OAuth2 Token Cache Algorithm](#oauth2-token-cache-algorithm) + - [Required Headers Evaluation Algorithm](#required-headers-evaluation-algorithm) + - [Token Bucket Admission Algorithm](#token-bucket-admission-algorithm) + - [CORS Preflight Handling Algorithm](#cors-preflight-handling-algorithm) + - [CORS Actual Request Validation Algorithm](#cors-actual-request-validation-algorithm) + - [CORS Configuration Validation Algorithm](#cors-configuration-validation-algorithm) + - [Header Rule Application Algorithm](#header-rule-application-algorithm) + - [Observability Emission Algorithm](#observability-emission-algorithm) +- [4. States (CDSL)](#4-states-cdsl) + - [Token Cache Entry State Machine](#token-cache-entry-state-machine) + - [Rate Limit Bucket State Machine](#rate-limit-bucket-state-machine) +- [5. Definitions of Done](#5-definitions-of-done) + - [Plugin Chain Composition and Execution](#plugin-chain-composition-and-execution) + - [Auth Plugin Registry and Built-ins](#auth-plugin-registry-and-built-ins) + - [Request-Time Credential Resolution](#request-time-credential-resolution) + - [OAuth2 Token Cache](#oauth2-token-cache) + - [Required Headers Guard](#required-headers-guard) + - [Rate Limiting](#rate-limiting) + - [CORS Preflight Fast Path](#cors-preflight-fast-path) + - [CORS Actual Request Enforcement](#cors-actual-request-enforcement) + - [CORS Configuration Validation](#cors-configuration-validation) + - [Configurable Header Transformation](#configurable-header-transformation) + - [Hierarchical Configuration Merge](#hierarchical-configuration-merge) + - [Metrics and Audit Logging](#metrics-and-audit-logging) +- [6. Acceptance Criteria](#6-acceptance-criteria) +- [7. Additional Context (optional)](#7-additional-context-optional) + - [7.1 Out of Scope, With Reasons](#71-out-of-scope-with-reasons) + - [7.2 Availability and Resilience Posture](#72-availability-and-resilience-posture) + - [7.3 Explicit Non-Applicability](#73-explicit-non-applicability) + + + +- [ ] `p1` - **ID**: `cpt-cf-oagw-featstatus-policy-and-plugins-implemented` + + +- [ ] `p1` - `cpt-cf-oagw-feature-policy-and-plugins` + +## 1. Feature Context + +### 1.1 Overview + +This feature is the policy layer that OAGW (Outbound API Gateway) applies to a proxied request +once the plain data-plane path already resolves an upstream and forwards a call. It adds +credential injection, guards, rate limiting, CORS (Cross-Origin Resource Sharing), configurable +header rules, hierarchical configuration merging, and observability around that existing path. + +### 1.2 Purpose + +The bare proxy path forwards a request; it does not authenticate it to the upstream, throttle +it, validate its origin, or reshape its headers. This feature supplies those behaviours, so the +gateway matches the product described in PRD.md §1.1. It is deliberately the largest feature in +the decomposition and ships as twelve independently testable slices inside one artifact, one +slice per Definition of Done listed in §5. + +**In scope**: plugin chain composition and execution; the auth plugin registry and its four +resolvable built-ins; the required-headers guard; rate limiting; built-in CORS; configurable +header transformation; hierarchical configuration; metrics and audit logging. + +**Out of scope**, each with its reason, expanded in §7.1: Starlark custom-plugin execution, a +Redis-backed distributed rate-limit sync, the Redis L2 configuration cache, and the circuit +breaker. + +**Requirements**: `cpt-cf-oagw-fr-auth-injection`, `cpt-cf-oagw-fr-rate-limiting`, +`cpt-cf-oagw-fr-builtin-plugins`, `cpt-cf-oagw-fr-header-transform`, +`cpt-cf-oagw-fr-config-layering`, `cpt-cf-oagw-fr-hierarchical-config`, +`cpt-cf-oagw-nfr-credential-isolation`, `cpt-cf-oagw-nfr-observability`, +`cpt-cf-oagw-nfr-high-availability`, `cpt-cf-oagw-nfr-starlark-sandbox` + +**Principles**: `cpt-cf-oagw-principle-cred-isolation` + +**Constraints**: none. The decomposition entry allocates no design constraint to this feature; +body limits, the HTTPS-only posture, and multi-backend storage are owned by other features. + +`cpt-cf-oagw-fr-header-transform` is split across two features. The proxy-data-plane feature +owns routing-header consumption, hop-by-hop stripping, and `Host` or `:authority` rewriting. +This feature owns only the configurable `set`, `add`, `remove`, and passthrough rules. + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-oagw-actor-app-developer` | Sends the proxied request whose credentials are injected, whose rate limit is charged, and whose origin is validated; receives the 429, 403, 400, and 502 policy rejections. | +| `cpt-cf-oagw-actor-tenant-admin` | Configures auth, CORS, and header rules on their own upstreams, and rate limits and plugin lists on their own upstreams and routes, within the sharing modes their ancestors allow. | +| `cpt-cf-oagw-actor-platform-operator` | Configures the ancestor-level policy that descendants inherit, and grants the override permissions the merge algorithm checks. | +| `cpt-cf-oagw-actor-cred-store` | Resolves every `cred://` reference to secret material at request time; denies access to secrets the calling tenant may not read. | +| `cpt-cf-oagw-actor-upstream-service` | Receives the credential-bearing outbound request and returns the response whose headers the response-phase policy inspects. | +| `cpt-cf-oagw-actor-types-registry` | Holds the GTS (Global Type System) catalog entries for identifiers such as `basic` and `bearer` that this feature deliberately refuses to resolve. | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) §5.2 Proxy Execution, §5.3 Plugin System, §5.5 Configuration + Hierarchy, §6.1 Non-Functional Requirements +- **Design**: [DESIGN.md](../DESIGN.md) §3.2 Component Model (plugin system, hierarchical + configuration, headers transformation, secret access control), §3.3 API Contracts + (`cpt-cf-oagw-interface-api`), §4.2 Metrics and Observability, §4.3 Audit Logging +- **ADRs**: [0002-plugin-system.md](../ADR/0002-plugin-system.md), + `cpt-cf-oagw-adr-rate-limiting`, `cpt-cf-oagw-adr-cors`, + `cpt-cf-oagw-adr-oauth2-client-credentials-auth-plugin`, + `cpt-cf-oagw-adr-required-headers-guard-plugin` +- **Contracts**: `cpt-cf-oagw-contract-cred-store`, `cpt-cf-oagw-contract-types-registry` +- **Schemas**: [upstream.v1.schema.json](../schemas/upstream.v1.schema.json), + [route.v1.schema.json](../schemas/route.v1.schema.json) +- **Decomposition**: `cpt-cf-oagw-feature-policy-and-plugins` +- **Dependencies**: `cpt-cf-oagw-feature-proxy-data-plane-http` (the working proxy request this + policy layer decorates) and `cpt-cf-oagw-feature-plugin-management-api` (the plugin + identification and storage model the chain resolves against) + +This feature adds no endpoint. All behaviour below runs inside the existing proxy request cycle +at `{METHOD} /oagw/v1/proxy/{alias}[/{path}][?{query}]`. + +## 2. Actor Flows (CDSL) + +**Use cases**: `cpt-cf-oagw-usecase-rate-limit-exceeded` + +Every flow except the CORS preflight runs after the proxy handler has already authenticated the +caller, resolved the alias, and matched a route. Preflight is the one path that answers before +any of that, because a browser preflight carries no credentials and therefore no tenant context. + +### Credential-Injected Proxy Flow + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-policy-credential-injected-proxy` + +**Actor**: `cpt-cf-oagw-actor-app-developer` + +**Success Scenarios**: +- An `apikey` auth plugin injects the configured header on the outbound request, and the secret + value appears in no log record and no response. +- An `oauth2_client_cred` plugin serves a cached bearer token without contacting the identity + provider a second time within the cached time to live. +- Upstream-level plugins run before route-level ones, so a declared chain of two upstream and + two route plugins executes in that concatenated order. + +**Error Scenarios**: +- The configured auth identifier is `basic` or `bearer`, which are catalog-only, so the request + fails with "unknown auth plugin". +- The referenced secret does not exist, giving 500 SecretNotFound. +- The referenced secret exists but is not accessible to the calling tenant, giving 401. +- A guard rejects during the request phase, so the upstream is never called. + +**Steps**: +1. [ ] - `p1` - Developer sends a proxied call to an upstream that declares auth, guards, and transforms - `inst-cip-1` +2. [ ] - `p1` - API: {METHOD} /oagw/v1/proxy/{alias}[/{path}][?{query}] (client body and headers forwarded to the policy layer) - `inst-cip-2` +3. [ ] - `p1` - Run `cpt-cf-oagw-algo-policy-hierarchical-merge` to produce one effective configuration from upstream, route, and tenant layers - `inst-cip-3` +4. [ ] - `p1` - Run `cpt-cf-oagw-algo-policy-cors-actual-validation` when the request carries an `Origin` header - `inst-cip-4` +5. [ ] - `p1` - Run `cpt-cf-oagw-algo-policy-token-bucket-admission` before any credential is fetched - `inst-cip-5` +6. [ ] - `p1` - Run `cpt-cf-oagw-algo-policy-plugin-chain-compose` to resolve the auth, guard, and transform stages - `inst-cip-6` +7. [ ] - `p1` - **IF** any declared identifier fails to resolve - `inst-cip-7` + 1. [ ] - `p1` - **RETURN** 503 PluginNotFound as `application/problem+json` with `X-OAGW-Error-Source: gateway` - `inst-cip-7a` +8. [ ] - `p1` - **ELSE** - `inst-cip-8` + 1. [ ] - `p1` - Run `cpt-cf-oagw-algo-policy-auth-injection` once, before every guard - `inst-cip-8a` + 2. [ ] - `p1` - **IF** credential resolution fails - `inst-cip-8a1` + 1. [ ] - `p1` - **RETURN** 500 SecretNotFound for a missing secret, or 401 AuthenticationFailed for an inaccessible one - `inst-cip-8a2` + 3. [ ] - `p1` - Run `cpt-cf-oagw-algo-policy-plugin-chain-execute` for the guard and request-transform stages - `inst-cip-8b` + 4. [ ] - `p1` - Run `cpt-cf-oagw-algo-policy-header-rules-apply` in the request phase over the outbound header map - `inst-cip-8c` + 5. [ ] - `p1` - Forward the outbound request to `cpt-cf-oagw-actor-upstream-service` with the injected credential - `inst-cip-8d` + 6. [ ] - `p1` - Run the response half of `cpt-cf-oagw-algo-policy-plugin-chain-execute`, then the response phase of `cpt-cf-oagw-algo-policy-header-rules-apply` - `inst-cip-8e` + 7. [ ] - `p1` - Run `cpt-cf-oagw-algo-policy-observability-emit` to record metrics and one audit line - `inst-cip-8f` + 8. [ ] - `p1` - **RETURN** the upstream response with the configured response headers applied - `inst-cip-8g` + +### Required Header Rejection Flow + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-policy-required-header-rejection` + +**Actor**: `cpt-cf-oagw-actor-app-developer` + +**Success Scenarios**: +- A request carrying every configured header, in any letter case, reaches the upstream normally. +- An upstream whose configuration is absent or blank after trimming sees no behaviour change, + because the guard fails open. + +**Error Scenarios**: +- A configured request header is missing, so the call is rejected with 400 before the upstream + is contacted. +- A configured response header is missing from the upstream reply, so the caller receives 502. + +**Steps**: +1. [ ] - `p1` - Developer sends a proxied call to an upstream that binds the required-headers guard - `inst-rhr-1` +2. [ ] - `p1` - API: {METHOD} /oagw/v1/proxy/{alias}[/{path}] (guard reads its configuration from the bound plugin entry) - `inst-rhr-2` +3. [ ] - `p1` - Run `cpt-cf-oagw-algo-policy-required-headers-eval` in the request phase - `inst-rhr-3` +4. [ ] - `p1` - **IF** the request phase reports a missing header - `inst-rhr-4` + 1. [ ] - `p1` - **RETURN** 400 with error code `REQUIRED_HEADER_MISSING` naming only the first missing header - `inst-rhr-4a` +5. [ ] - `p1` - **ELSE** - `inst-rhr-5` + 1. [ ] - `p1` - Forward the request and await the upstream response - `inst-rhr-5a` + 2. [ ] - `p1` - Run `cpt-cf-oagw-algo-policy-required-headers-eval` in the response phase - `inst-rhr-5b` + 3. [ ] - `p1` - **IF** the response phase reports a missing header - `inst-rhr-5c` + 1. [ ] - `p1` - **RETURN** 502 with error code `REQUIRED_HEADER_MISSING` naming only the first missing header - `inst-rhr-5c1` + 4. [ ] - `p1` - **ELSE** - `inst-rhr-5d` + 1. [ ] - `p1` - **RETURN** the upstream response unchanged by this guard - `inst-rhr-5d1` + +### Rate Limit Exceeded Flow + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-policy-rate-limit-exceeded` + +**Actor**: `cpt-cf-oagw-actor-app-developer` + +**Success Scenarios**: +- Calls within the sustained rate are admitted and carry the three `X-RateLimit-*` headers. +- A burst up to the configured capacity is admitted before the bucket empties. + +**Error Scenarios**: +- The bucket is empty under the default `reject` strategy, so the caller receives 429 with + `Retry-After`. +- The bucket is empty under `queue`, and the wait exceeds the configured proxy timeout, so the + request is rejected with the same 429 shape. + +**Steps**: +1. [ ] - `p1` - Developer sends proxied calls faster than the configured sustained rate - `inst-rle-1` +2. [ ] - `p1` - API: {METHOD} /oagw/v1/proxy/{alias}[/{path}] (each call charges the configured cost) - `inst-rle-2` +3. [ ] - `p1` - Run `cpt-cf-oagw-algo-policy-token-bucket-admission` against the per-instance limiter for the configured scope - `inst-rle-3` +4. [ ] - `p1` - **IF** the bucket holds at least the configured cost - `inst-rle-4` + 1. [ ] - `p1` - Deduct the cost and continue the proxy request with `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` attached to the eventual response - `inst-rle-4a` +5. [ ] - `p1` - **ELSE IF** strategy is `reject` - `inst-rle-5` + 1. [ ] - `p1` - Emit a WARN audit record naming the scope key, never the caller's credentials - `inst-rle-5a` + 2. [ ] - `p1` - **RETURN** 429 RateLimitExceeded with `Retry-After` and the three `X-RateLimit-*` headers - `inst-rle-5b` +6. [ ] - `p1` - **ELSE IF** strategy is `queue` - `inst-rle-6` + 1. [ ] - `p1` - Hold the request until enough tokens accumulate or the proxy timeout elapses - `inst-rle-6a` + 2. [ ] - `p1` - **RETURN** the same 429 shape when the timeout wins the race - `inst-rle-6b` +7. [ ] - `p1` - **ELSE** (strategy is `degrade`) - `inst-rle-7` + 1. [ ] - `p1` - Admit the request, mark the proxy context degraded, report `X-RateLimit-Remaining: 0`, and emit a WARN audit record - `inst-rle-7a` + +### CORS Preflight Flow + +- [ ] `p2` - **ID**: `cpt-cf-oagw-flow-policy-cors-preflight` + +**Actor**: `cpt-cf-oagw-actor-app-developer` + +**Success Scenarios**: +- A browser preflight receives 204 with the echoed origin, echoed method, and + `Access-Control-Max-Age: 86400`, without any upstream being resolved. + +**Error Scenarios**: +- An `OPTIONS` call that lacks `Origin` or `Access-Control-Request-Method` is not a preflight and + falls through to ordinary proxy handling, including its authentication requirement. + +**Steps**: +1. [ ] - `p2` - Browser sends the preflight on behalf of the developer's page - `inst-cpf-1` +2. [ ] - `p2` - API: OPTIONS /oagw/v1/proxy/{alias}[/{path}] with `Origin` and `Access-Control-Request-Method` - `inst-cpf-2` +3. [ ] - `p2` - Run `cpt-cf-oagw-algo-policy-cors-preflight` at handler entry, before alias resolution - `inst-cpf-3` +4. [ ] - `p2` - **IF** the three preflight conditions all hold - `inst-cpf-4` + 1. [ ] - `p2` - **RETURN** 204 No Content with the echoed CORS headers and no body - `inst-cpf-4a` +5. [ ] - `p2` - **ELSE** - `inst-cpf-5` + 1. [ ] - `p2` - **RETURN** control to ordinary proxy handling for a normal `OPTIONS` request - `inst-cpf-5a` + +### CORS Actual Request Flow + +- [ ] `p2` - **ID**: `cpt-cf-oagw-flow-policy-cors-actual-request` + +**Actor**: `cpt-cf-oagw-actor-app-developer` + +**Success Scenarios**: +- A cross-origin call from an allowed origin and method reaches the upstream, and the response + carries `Access-Control-Allow-Origin` and `Vary: Origin`. + +**Error Scenarios**: +- The origin is absent from `allowed_origins`, so the call is rejected with 403 before forwarding. +- The method is absent from `allowed_methods`, so the call is rejected with 403 before forwarding. +- The origin differs only by port or by scheme, which still fails, because matching is exact. + +**Steps**: +1. [ ] - `p2` - Browser sends the actual cross-origin call with an `Origin` header - `inst-car-1` +2. [ ] - `p2` - API: {METHOD} /oagw/v1/proxy/{alias}[/{path}] with `Origin` and the caller's Bearer token - `inst-car-2` +3. [ ] - `p2` - Resolve the upstream and merge configuration, so a tenant-scoped CORS policy exists - `inst-car-3` +4. [ ] - `p2` - Run `cpt-cf-oagw-algo-policy-cors-actual-validation` before forwarding - `inst-car-4` +5. [ ] - `p2` - **IF** the origin is not allowed - `inst-car-5` + 1. [ ] - `p2` - **RETURN** 403 with type `gts.cf.core.errors.err.v1~cf.oagw.cors.origin_not_allowed.v1` - `inst-car-5a` +6. [ ] - `p2` - **ELSE IF** the method is not allowed - `inst-car-6` + 1. [ ] - `p2` - **RETURN** 403 with type `gts.cf.core.errors.err.v1~cf.oagw.cors.method_not_allowed.v1` - `inst-car-6a` +7. [ ] - `p2` - **ELSE** - `inst-car-7` + 1. [ ] - `p2` - Forward the request, then add the configured CORS response headers plus `Vary: Origin` - `inst-car-7a` + 2. [ ] - `p2` - **RETURN** the upstream response to the browser - `inst-car-7b` + +### Configure Policy Layer Flow + +- [ ] `p2` - **ID**: `cpt-cf-oagw-flow-policy-configure-policy-layer` + +**Actor**: `cpt-cf-oagw-actor-tenant-admin` + +**Success Scenarios**: +- A descendant tenant sets a stricter rate limit than its ancestor, and the stricter value wins. +- A descendant appends plugins to an inherited chain and keeps every enforced ancestor plugin. +- A descendant adds an origin under `inherit`, and the effective list is the union of both. + +**Error Scenarios**: +- A configuration sets `allow_credentials` together with a wildcard origin and is rejected at + configuration-validation time. +- A merge under `inherit` would union a wildcard origin into a credential-bearing policy, and the + merged result is rejected by the same rule. +- A descendant without `oagw:upstream:override_auth` tries to replace an inherited credential. + +**Steps**: +1. [ ] - `p2` - Admin submits an upstream document carrying `auth`, `rate_limit`, `plugins`, `cors`, `headers`, and `tags`, or a route document carrying only `rate_limit`, `plugins`, and `tags`, since the Route schema defines no `auth`, `cors`, or `headers` property - `inst-cpl-1` +2. [ ] - `p2` - API: POST /oagw/v1/upstreams or PUT /oagw/v1/upstreams/{id} (or the equivalent route endpoint for `rate_limit`, `plugins`, and `tags`; policy fields validated by this feature) - `inst-cpl-2` +3. [ ] - `p2` - Run `cpt-cf-oagw-algo-policy-cors-config-validation` over the submitted `cors` block - `inst-cpl-3` +4. [ ] - `p2` - **IF** validation reports the wildcard-with-credentials combination - `inst-cpl-4` + 1. [ ] - `p2` - **RETURN** 400 ValidationError naming the offending field pair - `inst-cpl-4a` +5. [ ] - `p2` - **ELSE** - `inst-cpl-5` + 1. [ ] - `p2` - Store the document through the management surface owned by the upstream and route features - `inst-cpl-5a` + 2. [ ] - `p2` - Run `cpt-cf-oagw-algo-policy-hierarchical-merge` on the next proxied request that selects it - `inst-cpl-5b` + 3. [ ] - `p2` - **RETURN** the effective configuration the merge produced, applied to that request - `inst-cpl-5c` + +## 3. Processes / Business Logic (CDSL) + +### Hierarchical Configuration Merge Algorithm + +- [ ] `p1` - **ID**: `cpt-cf-oagw-algo-policy-hierarchical-merge` + +**Input**: The selected upstream document, the matched route document, the calling tenant's own +upstream and route documents for that alias — found by the alias-shadowing walk from descendant +toward root that PRD.md §5.5 defines, where the closest match wins — the ancestor chain walked +from that same descendant toward the root, and the caller's granted permissions + +**Output**: One effective configuration holding auth, rate limit, plugins, CORS, headers, and tags, +with auth, CORS, and headers drawn only from Upstream documents, because Route defines none of +those three properties + +**Steps**: +1. [ ] - `p1` - Layer the documents by priority: the upstream document the alias-shadowing walk resolves is the base, its matched route document overrides it for the fields the Route schema defines, and the calling tenant's own upstream and route documents override both wherever that same walk finds the calling tenant closer than the ancestor who owns the base documents; an ancestor field marked `enforce` still applies regardless of which tenant the walk selects - `inst-hm-1` +2. [ ] - `p1` - State the schema reality before merging any field: `auth`, `headers`, and `cors` are Upstream-level fields only, since `route.v1.schema.json` defines no such property; only `plugins`, `rate_limit`, and `tags` exist at the Route layer and participate in the merge there - `inst-hm-2` +3. [ ] - `p1` - **FOR EACH** field carrying a sharing mode — `auth` and `cors`, read from the Upstream documents only, plus `rate_limit` and `plugins`, read from whichever of the Upstream and Route documents defines each - `inst-hm-3` + 1. [ ] - `p1` - **IF** the ancestor sharing mode is `private` - `inst-hm-3a` + 1. [ ] - `p1` - Discard the ancestor value; only the descendant's own value applies - `inst-hm-3a1` + 2. [ ] - `p1` - **ELSE IF** the mode is `inherit` - `inst-hm-3b` + 1. [ ] - `p1` - Use the descendant value when present, otherwise fall back to the ancestor value - `inst-hm-3b1` + 3. [ ] - `p1` - **ELSE** (the mode is `enforce`) - `inst-hm-3c` + 1. [ ] - `p1` - Keep the ancestor value active and forbid any descendant value that would relax it - `inst-hm-3c1` +4. [ ] - `p1` - **IF** the descendant supplies its own `auth` over an `inherit` ancestor - `inst-hm-4` + 1. [ ] - `p1` - **IF** the caller lacks `oagw:upstream:override_auth` - `inst-hm-4a` + 1. [ ] - `p1` - Keep the ancestor credential reference unchanged - `inst-hm-4a1` + 2. [ ] - `p1` - **ELSE** - `inst-hm-4b` + 1. [ ] - `p1` - Adopt the descendant credential reference - `inst-hm-4b1` +5. [ ] - `p1` - Normalize every candidate sustained rate to tokens per second, so windows of different units compare correctly - `inst-hm-5` +6. [ ] - `p1` - Set the effective sustained rate to the minimum across the descendant value and every enforced ancestor value - `inst-hm-6` +7. [ ] - `p1` - Set the effective burst capacity to the minimum across the same set, independently of the sustained rate - `inst-hm-7` +8. [ ] - `p1` - Concatenate plugin lists ancestor-first, then descendant, drawing entries from each tenant's Upstream and Route documents in turn, and keeping declaration order inside each list - `inst-hm-8` +9. [ ] - `p1` - **IF** a descendant list omits a plugin an ancestor marked `enforce` - `inst-hm-9` + 1. [ ] - `p1` - Re-insert that plugin at its ancestor position, because enforced plugins cannot be removed - `inst-hm-9a` +10. [ ] - `p1` - **IF** the effective CORS sharing mode is `inherit` - `inst-hm-10` + 1. [ ] - `p1` - Union the ancestor and descendant `allowed_origins` and `allowed_methods`, dropping duplicates - `inst-hm-10a` +11. [ ] - `p1` - **ELSE IF** the mode is `enforce` - `inst-hm-11` + 1. [ ] - `p1` - Keep the ancestor CORS values and discard descendant additions - `inst-hm-11a` +12. [ ] - `p1` - Union the ancestor and descendant tags unconditionally, drawing from each tenant's Upstream and Route documents alike, because tags carry no sharing mode and are add-only - `inst-hm-12` +13. [ ] - `p1` - Re-run `cpt-cf-oagw-algo-policy-cors-config-validation` over the merged CORS block - `inst-hm-13` +14. [ ] - `p1` - **RETURN** the effective configuration consumed by every other algorithm in this feature - `inst-hm-14` + +### Plugin Chain Composition Algorithm + +- [ ] `p1` - **ID**: `cpt-cf-oagw-algo-policy-plugin-chain-compose` + +**Input**: The effective configuration's upstream and route plugin lists, the auth, guard, and +transform registries + +**Output**: An ordered chain split into one auth slot, a guard stage, and a transform stage, or a +resolution error + +**Steps**: +1. [ ] - `p1` - Read the upstream plugin items in declaration order - `inst-pcc-1` +2. [ ] - `p1` - Read the route plugin items in declaration order - `inst-pcc-2` +3. [ ] - `p1` - Concatenate the two lists upstream-first, so two upstream and two route entries execute as upstream one, upstream two, route one, route two - `inst-pcc-3` +4. [ ] - `p1` - **FOR EACH** entry in the concatenated list - `inst-pcc-4` + 1. [ ] - `p1` - **IF** the entry is the required-headers guard identifier - `inst-pcc-4a` + 1. [ ] - `p1` - Resolve it from the guard registry and append it to the guard stage - `inst-pcc-4a1` + 2. [ ] - `p1` - **ELSE IF** the entry is the request-id transform identifier - `inst-pcc-4b` + 1. [ ] - `p1` - Resolve it from the transform registry and append it to the transform stage - `inst-pcc-4b1` + 3. [ ] - `p1` - **ELSE IF** the entry is a catalog-only identifier such as `timeout`, `cors`, `logging`, or `metrics` - `inst-pcc-4c` + 1. [ ] - `p1` - **RETURN** 503 PluginNotFound, because those identifiers name core data-plane behaviour and are not registry-resolvable - `inst-pcc-4c1` + 4. [ ] - `p1` - **ELSE IF** the entry is a custom plugin identifier - `inst-pcc-4d` + 1. [ ] - `p1` - **RETURN** 503 PluginNotFound, because no Starlark runtime is enabled in this build - `inst-pcc-4d1` + 5. [ ] - `p1` - **ELSE** - `inst-pcc-4e` + 1. [ ] - `p1` - **RETURN** 503 PluginNotFound naming the unresolved identifier - `inst-pcc-4e1` +5. [ ] - `p1` - Resolve the single auth slot from the effective `auth.type` value, leaving it empty when no auth is configured - `inst-pcc-5` +6. [ ] - `p1` - **RETURN** the composed chain with its stage boundaries fixed - `inst-pcc-6` + +### Plugin Chain Execution Algorithm + +- [ ] `p1` - **ID**: `cpt-cf-oagw-algo-policy-plugin-chain-execute` + +**Input**: The composed chain, the mutable request context, and later the response or error context + +**Output**: A forwarded request and a returned response, or the first rejection the chain produced + +**Steps**: +1. [ ] - `p1` - Run the auth slot exactly once, before any guard, so credentials exist for every later stage - `inst-pce-1` +2. [ ] - `p1` - **FOR EACH** guard in the guard stage, in composed order - `inst-pce-2` + 1. [ ] - `p1` - Call its request-phase check against the current request context - `inst-pce-2a` + 2. [ ] - `p1` - **IF** the guard rejects - `inst-pce-2b` + 1. [ ] - `p1` - **RETURN** the guard's status and error code without calling the upstream at all - `inst-pce-2b1` +3. [ ] - `p1` - **FOR EACH** transform in the transform stage, in composed order - `inst-pce-3` + 1. [ ] - `p1` - Call its request-phase hook, allowing it to mutate headers, path, or query - `inst-pce-3a` +4. [ ] - `p1` - Hand the mutated request to the data-plane forwarder and await the outcome - `inst-pce-4` +5. [ ] - `p1` - **IF** the forwarder produced a response - `inst-pce-5` + 1. [ ] - `p1` - **FOR EACH** guard in composed order, call its response-phase check - `inst-pce-5a` + 2. [ ] - `p1` - **IF** a guard rejects in the response phase - `inst-pce-5b` + 1. [ ] - `p1` - **RETURN** that rejection instead of the upstream response - `inst-pce-5b1` + 3. [ ] - `p1` - **FOR EACH** transform in composed order, call its response-phase hook - `inst-pce-5c` +6. [ ] - `p1` - **ELSE** - `inst-pce-6` + 1. [ ] - `p1` - **FOR EACH** transform in composed order, call its error-phase hook instead of the response hook - `inst-pce-6a` +7. [ ] - `p1` - **RETURN** the response or the error, with the composed order preserved and never reversed - `inst-pce-7` + +### Auth Credential Injection Algorithm + +- [ ] `p1` - **ID**: `cpt-cf-oagw-algo-policy-auth-injection` + +**Input**: The effective `auth` block, the credential store handle, and the outbound request builder + +**Output**: An outbound request carrying the credential, or a credential error + +**Steps**: +1. [ ] - `p1` - **IF** no `auth` block is configured, or its type names the `noop` plugin - `inst-aci-1` + 1. [ ] - `p1` - **RETURN** the outbound request unchanged - `inst-aci-1a` +2. [ ] - `p1` - Look the type identifier up in the auth registry, which holds exactly `noop`, `apikey`, `oauth2_client_cred`, and `oauth2_client_cred_basic` - `inst-aci-2` +3. [ ] - `p1` - **IF** the lookup misses, which is always the case for the catalog-only `basic` and `bearer` identifiers - `inst-aci-3` + 1. [ ] - `p1` - **RETURN** 503 PluginNotFound whose detail reads "unknown auth plugin" followed by the offending identifier - `inst-aci-3a` +4. [ ] - `p1` - **IF** the resolved plugin is `apikey` - `inst-aci-4` + 1. [ ] - `p1` - Read the placement, which is either `header` or `query`, and the parameter name from the plugin configuration - `inst-aci-4a` + 2. [ ] - `p1` - Resolve the `cred://` reference through `cpt-cf-oagw-contract-cred-store` at request time, never from a stored copy - `inst-aci-4b` + 3. [ ] - `p1` - **IF** the store reports the secret does not exist - `inst-aci-4c` + 1. [ ] - `p1` - **RETURN** 500 SecretNotFound - `inst-aci-4c1` + 4. [ ] - `p1` - **ELSE IF** the store reports the secret is not accessible to the calling tenant - `inst-aci-4d` + 1. [ ] - `p1` - **RETURN** 401 AuthenticationFailed - `inst-aci-4d1` + 5. [ ] - `p1` - **ELSE** - `inst-aci-4e` + 1. [ ] - `p1` - Place the secret in the named header, or as the named query parameter, on the outbound request only - `inst-aci-4e1` +5. [ ] - `p1` - **ELSE IF** the resolved plugin is either OAuth2 client-credentials variant - `inst-aci-5` + 1. [ ] - `p1` - Delegate to `cpt-cf-oagw-algo-policy-oauth2-token-cache` and inject the bearer value it returns - `inst-aci-5a` +6. [ ] - `p1` - Hold the resolved material only for the lifetime of the request, and never write it to the resource store - `inst-aci-6` +7. [ ] - `p1` - Exclude the material from every metric label, audit field, error detail, and response body - `inst-aci-7` +8. [ ] - `p1` - **RETURN** the outbound request carrying the credential - `inst-aci-8` + +### OAuth2 Token Cache Algorithm + +- [ ] `p1` - **ID**: `cpt-cf-oagw-algo-policy-oauth2-token-cache` + +**Input**: The plugin configuration, the client-auth-method variant, the security context, and the +in-process token cache + +**Output**: A bearer token value ready for injection, or a credential error + +**Steps**: +1. [ ] - `p1` - Parse the configuration, requiring exactly one of the token endpoint or the issuer URL, plus both credential references - `inst-otc-1` +2. [ ] - `p1` - **IF** both endpoint forms are present, or both are absent - `inst-otc-2` + 1. [ ] - `p1` - **RETURN** a configuration error mapped to 400 ValidationError - `inst-otc-2a` +3. [ ] - `p1` - Build the cache key by joining the subject tenant identifier, the subject identifier, the auth-method tag, and a deterministic hash of the sorted configuration pairs with colons - `inst-otc-3` +4. [ ] - `p1` - Look the key up in the cache - `inst-otc-4` +5. [ ] - `p1` - **IF** an entry is returned and the key it stores equals the lookup key - `inst-otc-5` + 1. [ ] - `p1` - **RETURN** the cached token, so no identity-provider call is made - `inst-otc-5a` +6. [ ] - `p1` - **ELSE IF** an entry is returned whose stored key differs - `inst-otc-6` + 1. [ ] - `p1` - Treat the hit as a miss, because a hash collision must never leak another tenant's token - `inst-otc-6a` +7. [ ] - `p1` - Resolve the client identifier and the client secret through the credential store, applying the same 500 and 401 mapping as the injection algorithm - `inst-otc-7` +8. [ ] - `p1` - **TRY** - `inst-otc-8` + 1. [ ] - `p1` - API: POST to the token endpoint, placing credentials in the form body for the Form variant and in the `Authorization` header for the Basic variant - `inst-otc-8a` +9. [ ] - `p1` - **CATCH** an exchange failure - `inst-otc-9` + 1. [ ] - `p1` - **RETURN** the error without caching anything, so the next request retries the identity provider - `inst-otc-9a` +10. [ ] - `p1` - Compute the time to live as the smaller of the configured ceiling, whose default is 300 seconds, and the reported lifetime minus a 30-second safety margin - `inst-otc-10` +11. [ ] - `p1` - **IF** the computed time to live is zero or negative - `inst-otc-11` + 1. [ ] - `p1` - **RETURN** the token for immediate use without storing it - `inst-otc-11a` +12. [ ] - `p1` - **ELSE** - `inst-otc-12` + 1. [ ] - `p1` - Store an entry that carries its own key alongside the token, inside a cache whose default capacity is 10,000 entries - `inst-otc-12a` +13. [ ] - `p1` - **RETURN** the token; an upstream 401 later in the request never triggers a retry of the original call - `inst-otc-13` + +### Required Headers Evaluation Algorithm + +- [ ] `p1` - **ID**: `cpt-cf-oagw-algo-policy-required-headers-eval` + +**Input**: The phase, the guard's configuration map, and the header set for that phase + +**Output**: An allow decision, or a reject decision carrying a status and the first missing name + +**Steps**: +1. [ ] - `p1` - Select `required_request_headers` in the request phase and `required_response_headers` in the response phase - `inst-rhe-1` +2. [ ] - `p1` - **IF** the selected key is absent from the configuration - `inst-rhe-2` + 1. [ ] - `p1` - **RETURN** allow, because an unconfigured phase fails open - `inst-rhe-2a` +3. [ ] - `p1` - Split the value on commas, trim each entry, lowercase it, and drop entries that became empty - `inst-rhe-3` +4. [ ] - `p1` - **IF** the resulting list is empty, which covers a value that was only commas and spaces - `inst-rhe-4` + 1. [ ] - `p1` - **RETURN** allow, because a blank-after-trim configuration is a no-op - `inst-rhe-4a` +5. [ ] - `p1` - **FOR EACH** required name, in list order - `inst-rhe-5` + 1. [ ] - `p1` - **IF** the header set has no entry matching that name case-insensitively - `inst-rhe-5a` + 1. [ ] - `p1` - **RETURN** reject with 400 in the request phase, or 502 in the response phase, error code `REQUIRED_HEADER_MISSING`, and only this first name in the detail - `inst-rhe-5a1` +6. [ ] - `p1` - **RETURN** allow, having checked presence only and never any header value - `inst-rhe-6` + +### Token Bucket Admission Algorithm + +- [ ] `p1` - **ID**: `cpt-cf-oagw-algo-policy-token-bucket-admission` + +**Input**: The effective rate-limit block, the request's scope identifiers, and the current instant + +**Output**: An admit decision with rate-limit headers, or a rejection + +**Steps**: +1. [ ] - `p1` - **IF** the effective configuration carries no rate limit - `inst-tba-1` + 1. [ ] - `p1` - **RETURN** admit with no rate-limit headers - `inst-tba-1a` +2. [ ] - `p1` - Convert the sustained window enum to seconds, mapping second to 1, minute to 60, hour to 3600, and day to 86400 - `inst-tba-2` +3. [ ] - `p1` - Compute the refill rate as the sustained rate divided by those window seconds - `inst-tba-3` +4. [ ] - `p1` - Set the capacity to the configured burst capacity, defaulting to the sustained rate when it is absent - `inst-tba-4` +5. [ ] - `p1` - Build the counter key from the scope enum, which selects a constant, the tenant, the caller, the client address, or the matched route, defaulting to tenant - `inst-tba-5` +6. [ ] - `p1` - Look up or create the limiter for that key in per-instance local state, with no cross-node synchronization - `inst-tba-6` +7. [ ] - `p1` - **IF** the algorithm is `token_bucket` - `inst-tba-7` + 1. [ ] - `p1` - Refill by adding elapsed seconds times the refill rate, clamped to the capacity, then record the new instant - `inst-tba-7a` + 2. [ ] - `p1` - Set the admission test to whether the available tokens are at least the configured cost, whose default is 1 - `inst-tba-7b` +8. [ ] - `p1` - **ELSE** (the algorithm is `sliding_window`) - `inst-tba-8` + 1. [ ] - `p1` - Count the cost already charged inside the trailing window and set the admission test to whether that count plus this cost stays within the sustained rate - `inst-tba-8a` +9. [ ] - `p1` - Compute the reset instant as the epoch second when the limiter next admits a request of this cost - `inst-tba-9` +10. [ ] - `p1` - **IF** the admission test passes - `inst-tba-10` + 1. [ ] - `p1` - Charge the cost and **RETURN** admit with `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` - `inst-tba-10a` +11. [ ] - `p1` - **ELSE IF** the strategy is `reject`, which is the default - `inst-tba-11` + 1. [ ] - `p1` - **RETURN** 429 RateLimitExceeded with the three headers plus `Retry-After` in whole seconds, never below one - `inst-tba-11a` +12. [ ] - `p1` - **ELSE IF** the strategy is `queue` - `inst-tba-12` + 1. [ ] - `p1` - Wait for the reset instant, bounded by the configured proxy timeout, then re-test once - `inst-tba-12a` + 2. [ ] - `p1` - **RETURN** admit on success, or the same 429 shape when the bound elapses first - `inst-tba-12b` +13. [ ] - `p1` - **ELSE** (the strategy is `degrade`) - `inst-tba-13` + 1. [ ] - `p1` - **RETURN** admit with the context marked degraded and `X-RateLimit-Remaining` reported as zero - `inst-tba-13a` + +### CORS Preflight Handling Algorithm + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-policy-cors-preflight` + +**Input**: The inbound request at proxy handler entry + +**Output**: A 204 preflight response, or a signal to continue ordinary handling + +**Steps**: +1. [ ] - `p2` - Test whether the method is `OPTIONS` - `inst-cpa-1` +2. [ ] - `p2` - Test whether an `Origin` header is present - `inst-cpa-2` +3. [ ] - `p2` - Test whether an `Access-Control-Request-Method` header is present - `inst-cpa-3` +4. [ ] - `p2` - **IF** any of the three tests fails - `inst-cpa-4` + 1. [ ] - `p2` - **RETURN** the continue signal, leaving the request to ordinary proxy handling - `inst-cpa-4a` +5. [ ] - `p2` - Echo the request origin into `Access-Control-Allow-Origin` - `inst-cpa-5` +6. [ ] - `p2` - Echo the requested method into `Access-Control-Allow-Methods` - `inst-cpa-6` +7. [ ] - `p2` - **IF** `Access-Control-Request-Headers` is present - `inst-cpa-7` + 1. [ ] - `p2` - Echo its value into `Access-Control-Allow-Headers` - `inst-cpa-7a` +8. [ ] - `p2` - Add `Access-Control-Max-Age: 86400` and `Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers` - `inst-cpa-8` +9. [ ] - `p2` - **RETURN** 204 No Content with an empty body, having resolved no upstream, required no tenant context, and run no plugin - `inst-cpa-9` + +### CORS Actual Request Validation Algorithm + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-policy-cors-actual-validation` + +**Input**: The resolved request, its `Origin` header, and the effective CORS block + +**Output**: A forward decision with response headers, or a 403 rejection + +**Steps**: +1. [ ] - `p2` - **IF** the request carries no `Origin` header - `inst-cav-1` + 1. [ ] - `p2` - **RETURN** forward, because the call is not cross-origin - `inst-cav-1a` +2. [ ] - `p2` - **IF** the effective CORS block is absent or disabled - `inst-cav-2` + 1. [ ] - `p2` - **RETURN** forward with no CORS response headers, which is the deny-by-default posture a browser then enforces - `inst-cav-2a` +3. [ ] - `p2` - **FOR EACH** configured allowed origin - `inst-cav-3` + 1. [ ] - `p2` - Compare it to the request origin by exact string equality, treating a single asterisk entry as matching any origin - `inst-cav-3a` +4. [ ] - `p2` - **IF** no entry matched, including entries differing only by port or by scheme - `inst-cav-4` + 1. [ ] - `p2` - **RETURN** 403 with type `gts.cf.core.errors.err.v1~cf.oagw.cors.origin_not_allowed.v1`, before any byte reaches the upstream - `inst-cav-4a` +5. [ ] - `p2` - **IF** the request method is absent from `allowed_methods`, whose default is GET and POST - `inst-cav-5` + 1. [ ] - `p2` - **RETURN** 403 with type `gts.cf.core.errors.err.v1~cf.oagw.cors.method_not_allowed.v1` - `inst-cav-5a` +6. [ ] - `p2` - Forward the request, then set `Access-Control-Allow-Origin` to the request origin - `inst-cav-6` +7. [ ] - `p2` - **IF** `expose_headers` is configured - `inst-cav-7` + 1. [ ] - `p2` - Set `Access-Control-Expose-Headers` to that list - `inst-cav-7a` +8. [ ] - `p2` - **IF** `allow_credentials` is enabled - `inst-cav-8` + 1. [ ] - `p2` - Set `Access-Control-Allow-Credentials` to true - `inst-cav-8a` +9. [ ] - `p2` - Always add `Vary: Origin`, so a shared cache cannot serve one origin's response to another - `inst-cav-9` +10. [ ] - `p2` - **RETURN** the response carrying those headers - `inst-cav-10` + +### CORS Configuration Validation Algorithm + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-policy-cors-config-validation` + +**Input**: A CORS block, either as submitted on an upstream or route, or as produced by the merge + +**Output**: A pass result, or a validation error naming the offending fields + +**Steps**: +1. [ ] - `p2` - **IF** the block is absent - `inst-ccv-1` + 1. [ ] - `p2` - **RETURN** pass, because CORS is optional and disabled by default - `inst-ccv-1a` +2. [ ] - `p2` - **IF** the `enabled` field is missing - `inst-ccv-2` + 1. [ ] - `p2` - **RETURN** a validation error, because the schema marks it required - `inst-ccv-2a` +3. [ ] - `p2` - **IF** `allow_credentials` is true and `allowed_origins` contains a single asterisk entry - `inst-ccv-3` + 1. [ ] - `p2` - **RETURN** a validation error stating that credentials cannot combine with a wildcard origin - `inst-ccv-3a` +4. [ ] - `p2` - **FOR EACH** configured origin that is not the asterisk - `inst-ccv-4` + 1. [ ] - `p2` - **IF** it lacks a scheme or a host, or carries a path, query, or partial wildcard - `inst-ccv-4a` + 1. [ ] - `p2` - **RETURN** a validation error, because origin matching is exact and no pattern syntax exists - `inst-ccv-4a1` +5. [ ] - `p2` - **RETURN** pass; the same checks re-run after every merge, since a union can introduce a wildcard from an ancestor - `inst-ccv-5` + +### Header Rule Application Algorithm + +- [ ] `p1` - **ID**: `cpt-cf-oagw-algo-policy-header-rules-apply` + +**Input**: The phase, the effective `headers.request` or `headers.response` block, and the header map + +**Output**: The transformed header map, or a validation error on a malformed well-known header + +**Steps**: +1. [ ] - `p1` - Start from the header map the proxy-data-plane feature already stripped of routing and hop-by-hop headers - `inst-hra-1` +2. [ ] - `p1` - **IF** the phase is the request phase - `inst-hra-2` + 1. [ ] - `p1` - **IF** the passthrough mode is `none`, which is the default - `inst-hra-2a` + 1. [ ] - `p1` - Drop every inbound header, keeping only what later rules add - `inst-hra-2a1` + 2. [ ] - `p1` - **ELSE IF** the mode is `allowlist` - `inst-hra-2b` + 1. [ ] - `p1` - Keep only the names listed in `passthrough_allowlist`, compared case-insensitively - `inst-hra-2b1` + 3. [ ] - `p1` - **ELSE** (the mode is `all`) - `inst-hra-2c` + 1. [ ] - `p1` - Keep every inbound header that survived the earlier stripping - `inst-hra-2c1` +3. [ ] - `p1` - **FOR EACH** name in the `remove` list - `inst-hra-3` + 1. [ ] - `p1` - Delete all values for that name, compared case-insensitively - `inst-hra-3a` +4. [ ] - `p1` - **FOR EACH** pair in the `set` map - `inst-hra-4` + 1. [ ] - `p1` - Replace any existing values for that name with the single configured value - `inst-hra-4a` +5. [ ] - `p1` - **FOR EACH** pair in the `add` map - `inst-hra-5` + 1. [ ] - `p1` - Append the value, leaving any existing value in place so duplicates are allowed - `inst-hra-5a` +6. [ ] - `p1` - Validate well-known headers such as `Content-Length` and `Content-Type` after the rules ran - `inst-hra-6` +7. [ ] - `p1` - **IF** a well-known header is now malformed or inconsistent with the body - `inst-hra-7` + 1. [ ] - `p1` - **RETURN** 400 ValidationError - `inst-hra-7a` +8. [ ] - `p1` - Strip any hop-by-hop header a rule re-introduced, so configuration cannot defeat the data-plane rule - `inst-hra-8` +9. [ ] - `p1` - **RETURN** the transformed map; the response phase runs the same remove, set, and add steps but has no passthrough mode - `inst-hra-9` + +### Observability Emission Algorithm + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-policy-observability-emit` + +**Input**: The request context, the outcome, and the measured phase durations + +**Output**: Updated metric series and one structured audit record + +**Steps**: +1. [ ] - `p2` - Increment `oagw_requests_in_flight` labelled by host when the request enters the policy layer - `inst-obs-1` +2. [ ] - `p2` - Start a timer for each measured phase of the request - `inst-obs-2` +3. [ ] - `p2` - Normalize the method to a standard verb, or to the reserved `_OTHER` value, before using it as a label - `inst-obs-3` +4. [ ] - `p2` - Use the matched route pattern, never the raw request path, as the route label - `inst-obs-4` +5. [ ] - `p2` - On completion, decrement `oagw_requests_in_flight` and observe `oagw_request_duration_seconds` labelled by host, route, and phase - `inst-obs-5` +6. [ ] - `p2` - Increment `oagw_requests_total` labelled by host, method, route, and numeric status code - `inst-obs-6` +7. [ ] - `p2` - **IF** the outcome is an error - `inst-obs-7` + 1. [ ] - `p2` - Increment `oagw_errors_total` labelled by host, route, and error type - `inst-obs-7a` +8. [ ] - `p2` - Attach no tenant label to any series, so cardinality stays bounded - `inst-obs-8` +9. [ ] - `p2` - Emit one structured record carrying `request_id`, `tenant_id`, `method`, `path`, `status`, and `duration_ms` - `inst-obs-9` +10. [ ] - `p2` - Choose INFO for a successful request, WARN for a rate-limit rejection or emitted retry guidance, and ERROR for an upstream failure, a timeout, or an authentication failure - `inst-obs-10` +11. [ ] - `p2` - **RETURN** without ever placing a request body, a response body, or credential material in a metric or a log - `inst-obs-11` + +## 4. States (CDSL) + +### Token Cache Entry State Machine + +- [ ] `p2` - **ID**: `cpt-cf-oagw-state-policy-token-cache-entry` + +**States**: absent, valid, expired, evicted + +**Initial State**: absent + +**Transitions**: +1. [ ] - `p2` - **FROM** absent **TO** valid **WHEN** a token exchange succeeds and the computed time to live is positive - `inst-tcs-1` +2. [ ] - `p2` - **FROM** absent **TO** absent **WHEN** a token exchange fails, because failed fetches are never cached - `inst-tcs-2` +3. [ ] - `p2` - **FROM** absent **TO** absent **WHEN** the computed time to live is zero or negative, so the token is injected but not stored - `inst-tcs-3` +4. [ ] - `p2` - **FROM** valid **TO** expired **WHEN** the stored time to live elapses - `inst-tcs-4` +5. [ ] - `p2` - **FROM** expired **TO** absent **WHEN** the next lookup observes expiry and drops the entry - `inst-tcs-5` +6. [ ] - `p2` - **FROM** valid **TO** evicted **WHEN** capacity pressure removes it, at which point the secret buffer is zeroed - `inst-tcs-6` +7. [ ] - `p2` - **FROM** evicted **TO** absent **WHEN** the next lookup for that key finds nothing and proceeds as a miss - `inst-tcs-7` +8. [ ] - `p2` - **FROM** valid **TO** valid **WHEN** a lookup finds a stored key different from the lookup key, so the caller is served as a miss while the entry stays untouched - `inst-tcs-8` + +### Rate Limit Bucket State Machine + +- [ ] `p2` - **ID**: `cpt-cf-oagw-state-policy-rate-limit-bucket` + +**States**: full, partial, depleted + +**Initial State**: full + +**Transitions**: +1. [ ] - `p2` - **FROM** full **TO** partial **WHEN** a request charges a cost smaller than the capacity - `inst-rbs-1` +2. [ ] - `p2` - **FROM** partial **TO** depleted **WHEN** the remaining tokens fall below the next request's cost - `inst-rbs-2` +3. [ ] - `p2` - **FROM** partial **TO** full **WHEN** refill at the sustained rate reaches the capacity clamp - `inst-rbs-3` +4. [ ] - `p2` - **FROM** depleted **TO** partial **WHEN** refill accumulates at least the next request's cost but less than the capacity - `inst-rbs-4` +5. [ ] - `p2` - **FROM** depleted **TO** depleted **WHEN** a request arrives before refill completes, producing the 429 rejection or the configured alternative strategy - `inst-rbs-5` + +## 5. Definitions of Done + +### Plugin Chain Composition and Execution + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-policy-plugin-chain` + +The system **MUST** compose one chain per proxied request by concatenating upstream plugin items +before route plugin items, and **MUST** execute the stages in the order auth, guards, request +transforms, upstream call, then response or error transforms. A guard rejection in the request +phase **MUST** prevent the upstream call entirely. Any identifier that no registry resolves, +including catalog-only and custom identifiers, **MUST** fail with 503 PluginNotFound. + +**Implements**: +- `cpt-cf-oagw-flow-policy-credential-injected-proxy` +- `cpt-cf-oagw-algo-policy-plugin-chain-compose` +- `cpt-cf-oagw-algo-policy-plugin-chain-execute` + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}` +- Entities: `Plugin`, `ProxyContext` + +### Auth Plugin Registry and Built-ins + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-policy-auth-registry` + +The system **MUST** register exactly four resolvable auth plugins — `noop`, `apikey` with header +or query placement, `oauth2_client_cred` using a form-post exchange, and +`oauth2_client_cred_basic` using a Basic-auth exchange. It **MUST** reject the catalog-only +`basic` and `bearer` identifiers with an error whose detail reads "unknown auth plugin", even +though `cpt-cf-oagw-contract-types-registry` still catalogs them. + +**Implements**: +- `cpt-cf-oagw-flow-policy-credential-injected-proxy` +- `cpt-cf-oagw-algo-policy-auth-injection` + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}` +- Entities: `Credential reference` + +### Request-Time Credential Resolution + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-policy-credential-resolution` + +The system **MUST** resolve every credential reference through the credential store at request +time, **MUST** return 500 SecretNotFound for a missing secret and 401 for one the calling tenant +cannot access, and **MUST NOT** store or log secret material anywhere. No metric label, audit +field, error detail, or response body may contain a resolved secret. + +**Implements**: +- `cpt-cf-oagw-algo-policy-auth-injection` + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}` +- Entities: `Credential reference` + +### OAuth2 Token Cache + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-policy-oauth2-token-cache` + +The system **MUST** cache access tokens in process under a key joining subject tenant, subject, +auth-method tag, and configuration hash, with a time to live of the smaller of the configured +ceiling, defaulting to 300 seconds, and the reported lifetime minus 30 seconds. Capacity +**MUST** default to 10,000 entries, each entry **MUST** carry its own key so a hash collision is +detected on hit, failed fetches **MUST NOT** be cached, and an upstream 401 **MUST NOT** cause a +retry of the original request. + +**Implements**: +- `cpt-cf-oagw-algo-policy-oauth2-token-cache` +- `cpt-cf-oagw-state-policy-token-cache-entry` + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}` +- Entities: `Credential reference` + +### Required Headers Guard + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-policy-required-headers-guard` + +The system **MUST** implement the required-headers guard as the only guard identifier bindable +through a plugin list entry, reading `required_request_headers` and `required_response_headers` +independently. It **MUST** split on commas, trim, lowercase, drop empties, scan in order, and +report only the first missing header, rejecting with 400 in the request phase and 502 in the +response phase, both carrying error code `REQUIRED_HEADER_MISSING`. Absent or blank-after-trim +configuration **MUST** be a no-op. + +**Implements**: +- `cpt-cf-oagw-flow-policy-required-header-rejection` +- `cpt-cf-oagw-algo-policy-required-headers-eval` + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}` +- Entities: `Plugin` + +### Rate Limiting + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-policy-rate-limiting` + +The system **MUST** enforce per-instance rate limits using a token bucket by default and a +sliding window when configured, honouring `sustained` rate and window, `burst.capacity` +defaulting to the sustained rate, `scope` defaulting to tenant, `strategy` defaulting to reject, +and `cost` defaulting to one. A rejection **MUST** be 429 with `Retry-After` and the three +`X-RateLimit-*` headers. No `budget` or `overcommit_ratio` field exists in either JSON Schema, so +the effective limit **MUST** be the plain minimum of ancestor and descendant. + +**Implements**: +- `cpt-cf-oagw-flow-policy-rate-limit-exceeded` +- `cpt-cf-oagw-algo-policy-token-bucket-admission` +- `cpt-cf-oagw-state-policy-rate-limit-bucket` + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}` +- Entities: `Rate limiter state` + +### CORS Preflight Fast Path + +- [x] `p2` - **ID**: `cpt-cf-oagw-dod-policy-cors-preflight` + +The system **MUST** detect a preflight as `OPTIONS` plus `Origin` plus +`Access-Control-Request-Method`, and **MUST** answer it with a permissive 204 echoing the origin, +the requested method, and the requested headers, plus `Access-Control-Max-Age: 86400` and the +three-name `Vary` list. The fast path **MUST** resolve no upstream, require no tenant context, +and run no plugin. + +**Implements**: +- `cpt-cf-oagw-flow-policy-cors-preflight` +- `cpt-cf-oagw-algo-policy-cors-preflight` + +**Touches**: +- API: `OPTIONS /oagw/v1/proxy/{alias}` +- Entities: `CORS policy` + +### CORS Actual Request Enforcement + +- [ ] `p2` - **ID**: `cpt-cf-oagw-dod-policy-cors-actual` + +The system **MUST** validate the origin and the method of an actual cross-origin request after +upstream resolution and before forwarding, rejecting with 403 and the exact +`cors.origin_not_allowed` or `cors.method_not_allowed` GTS type. Origin matching **MUST** be +exact, port-sensitive, and protocol-sensitive, with no pattern syntax. Allowed responses **MUST** +carry the configured CORS headers and always `Vary: Origin`. + +**Implements**: +- `cpt-cf-oagw-flow-policy-cors-actual-request` +- `cpt-cf-oagw-algo-policy-cors-actual-validation` + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}` +- Entities: `CORS policy` + +### CORS Configuration Validation + +- [ ] `p2` - **ID**: `cpt-cf-oagw-dod-policy-cors-config-validation` + +The system **MUST** reject a CORS configuration that combines `allow_credentials` with a wildcard +origin, at configuration-validation time rather than at request time, and **MUST** re-run the +same check on the merged result so an inherited wildcard cannot slip through the union. + +**Implements**: +- `cpt-cf-oagw-flow-policy-configure-policy-layer` +- `cpt-cf-oagw-algo-policy-cors-config-validation` + +**Touches**: +- API: `POST /oagw/v1/upstreams` +- API: `PUT /oagw/v1/upstreams/{id}` +- Entities: `CORS policy` + +### Configurable Header Transformation + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-policy-header-rules` + +The system **MUST** apply the configured `set`, `add`, and `remove` operations plus the +`none`, `allowlist`, and `all` passthrough modes on the request side, and the `set`, `add`, and +`remove` operations on the response side, in the documented order. It **MUST NOT** duplicate the +routing-header, hop-by-hop, and authority-rewriting behaviour owned by the proxy-data-plane +feature, and **MUST** re-strip any hop-by-hop header a rule re-introduces. + +**Implements**: +- `cpt-cf-oagw-algo-policy-header-rules-apply` + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}` +- Entities: `Header transformation rule` + +### Hierarchical Configuration Merge + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-policy-hierarchical-config` + +The system **MUST** merge configuration with upstream as base, route above it, and tenant above +both, honouring `private`, `inherit`, and `enforce` per field. Auth **MUST** be overridable under +`inherit` and forced under `enforce`; rate limits **MUST** take the minimum of ancestor and +descendant; plugin lists **MUST** concatenate ancestor-then-descendant with enforced entries +retained; CORS origins **MUST** union under `inherit`; tags **MUST** always union add-only, so a +descendant can add but never remove an inherited tag. + +**Implements**: +- `cpt-cf-oagw-flow-policy-configure-policy-layer` +- `cpt-cf-oagw-algo-policy-hierarchical-merge` + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}` +- Entities: `Rate limiter state`, `CORS policy`, `Header transformation rule` + +### Metrics and Audit Logging + +- [ ] `p2` - **ID**: `cpt-cf-oagw-dod-policy-observability` + +The system **MUST** emit `oagw_requests_total`, `oagw_request_duration_seconds`, +`oagw_requests_in_flight`, and `oagw_errors_total` with the documented OTel (OpenTelemetry) label +keys and no tenant label, and **MUST** emit one structured audit record per request carrying +`request_id`, `tenant_id`, `method`, `path`, `status`, and `duration_ms` at INFO, WARN, or ERROR. +Bodies and credential material **MUST** never appear in either output. + +**Implements**: +- `cpt-cf-oagw-flow-policy-credential-injected-proxy` +- `cpt-cf-oagw-algo-policy-observability-emit` + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}` +- Entities: `ProxyContext` + +## 6. Acceptance Criteria + +Every criterion below is assertable by an automated test that drives the gear against a local +stub upstream, with no external network dependency. + +- [ ] An upstream configured with the `apikey` auth plugin causes the configured header to appear on the request the stub upstream receives, with the resolved secret as its value. +- [ ] After that same request, the secret value appears in no captured log line, no metric label, no error body, and no response header returned to the client. +- [ ] An upstream whose `auth.type` names the catalog-only `basic` or `bearer` identifier fails the proxy request with a problem document whose detail contains "unknown auth plugin". +- [ ] An upstream whose credential reference names a secret the stub credential store does not hold returns 500 with the `SecretNotFound` GTS type. +- [ ] An upstream whose credential reference names a secret the calling tenant may not read returns 401. +- [ ] A chain of two upstream plugins and two route plugins records execution in upstream-first order at the stub, confirming the concatenation rule. +- [ ] With `required_request_headers` configured and one of those headers absent, the proxy returns 400 with error code `REQUIRED_HEADER_MISSING`, and the stub upstream records no request. +- [ ] With `required_response_headers` configured and the stub omitting one of them, the proxy returns 502 with error code `REQUIRED_HEADER_MISSING`. +- [ ] A required-headers value that is blank after trimming, such as one containing only commas and spaces, is a no-op and the request reaches the stub unchanged. +- [ ] A required-headers check succeeds when the client sends the header in a different letter case from the configured name. +- [ ] Sending more requests than the configured sustained rate and burst capacity allows returns 429 carrying `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset`. +- [ ] A request admitted under the same limit carries the three `X-RateLimit-*` headers with a remaining count that decreases across successive admitted requests. +- [ ] An `OPTIONS` request with `Origin` and `Access-Control-Request-Method` returns 204 with `Access-Control-Allow-Origin` echoing the sent origin and `Access-Control-Max-Age: 86400`, and the stub upstream records no request. +- [ ] An actual cross-origin request from an origin absent from `allowed_origins` returns 403 with the `cors.origin_not_allowed` GTS type, and the stub upstream records no request. +- [ ] An actual cross-origin request whose method is absent from `allowed_methods` returns 403 with the `cors.method_not_allowed` GTS type. +- [ ] An origin matching an allowed entry except for its port, or except for its scheme, is rejected with 403. +- [ ] Creating an upstream whose CORS block sets `allow_credentials` to true together with a wildcard origin returns 400 at configuration time, before any proxy request is made. +- [ ] Configured request-header `set`, `add`, and `remove` rules are visible on the request the stub upstream receives: the set name holds exactly the configured value, the added name holds both values, and the removed name is absent. +- [ ] A request-side passthrough mode of `allowlist` forwards only the allowlisted inbound headers to the stub upstream. +- [ ] Configured response-header rules are visible on the response the client receives. +- [ ] An ancestor rate limit of `enforce` combined with a stricter descendant limit produces the stricter effective limit, and the reverse pairing also produces the stricter one. +- [ ] A descendant tag list is unioned with the inherited tags, and an attempt to omit an inherited tag leaves that tag present in the effective configuration. +- [ ] A successful proxy request increments `oagw_requests_total` and observes `oagw_request_duration_seconds`, and neither series carries a tenant label. +- [ ] A proxy request emits exactly one structured audit record containing `request_id`, `tenant_id`, `method`, `path`, `status`, and `duration_ms`, and containing no request or response body. +- [ ] A second proxied request for the same tenant, subject, and `oauth2_client_cred` configuration, sent within the cached token's time to live, injects the same bearer value as the first request and causes no second call to the stub identity provider. +- [ ] An upstream naming a plugin identifier that no registry resolves fails the proxy request with 503 and the `PluginNotFound` GTS type, and the stub upstream records no request. + +## 7. Additional Context (optional) + +### 7.1 Out of Scope, With Reasons + +- **Executing Starlark custom plugins.** No Starlark runtime is enabled in this deployment, so + `cpt-cf-oagw-nfr-starlark-sandbox` has no runtime to sandbox. A plugin list entry naming a + custom plugin resolves to 503 PluginNotFound rather than executing anything. Definitions are + still stored and served by the plugin management feature. +- **Redis-backed distributed rate-limit synchronization.** No Redis dependency is enabled, so + limiters hold per-instance local state and the effective limit is per node. ADR-0003's hybrid + sync design is therefore not built. +- **The Redis L2 configuration cache.** Same missing dependency. Configuration is merged per + request from the in-memory store rather than served from a second-level cache. +- **The circuit breaker.** DESIGN.md §4.7 lists it as future work and ADR-0002 states it is core + policy rather than a plugin. The circuit-breaker clause of `cpt-cf-oagw-nfr-high-availability` + is therefore unmet; only the baseline availability behaviour below is built. +- **A gear-mounted metrics scrape route.** DESIGN.md marks that surface admin-only, and + aggregating it is a host-runtime concern. This feature emits the series through shared + instrumentation hooks without owning a route. +- **The `budget` and `overcommit_ratio` rate-limit extension.** ADR-0003 proposes it, but neither + JSON Schema carries it; both define only `sharing`, `algorithm`, `sustained`, `burst`, `scope`, + `strategy`, and `cost`. There is no field to implement. + +### 7.2 Availability and Resilience Posture + +The achievable portion of `cpt-cf-oagw-nfr-high-availability` in this build is baseline +behaviour: no unhandled panic on any policy path, and a consistent RFC 9457 problem document +whenever an upstream fails, times out, or returns an unexpected shape. Timeout handling comes +from the gear-level proxy timeout, which is two seconds in the graded configuration. Consistent +with `cpt-cf-oagw-principle-no-retry`, no policy path re-issues the client's request, including +after an upstream 401 that a fresh token might have satisfied. + +### 7.3 Explicit Non-Applicability + +- **Database and data-lifecycle analysis**: not applicable, because this feature adds no table, + no query, and no persisted entity. Its only durable inputs are the upstream and route documents + owned by other features; its own state is the in-memory token cache and rate limiter. +- **Accessibility**: not applicable, because the feature exposes no user interface. Its only + browser-facing surface is the CORS header contract, covered above. +- **Regulatory and privacy compliance**: no personal data is processed or stored by this feature. + The audit fields are deliberately limited to identifiers, method, path, status, and duration, + and bodies and query strings are never logged. +- **Rollout and rollback**: not applicable as a separate concern, because every behaviour here is + driven by upstream and route configuration. Removing an `auth`, `rate_limit`, `cors`, `headers`, + or `plugins` block restores the prior bare-proxy behaviour without a code change. +- **Data migration**: not applicable, because no schema or stored representation changes. diff --git a/gears/system/oagw/docs/features/proxy-data-plane-http.md b/gears/system/oagw/docs/features/proxy-data-plane-http.md new file mode 100644 index 0000000..54499d1 --- /dev/null +++ b/gears/system/oagw/docs/features/proxy-data-plane-http.md @@ -0,0 +1,642 @@ +# Feature: Proxy Data Plane — HTTP + + + + +- [1. Feature Context](#1-feature-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Proxy HTTP Request Flow](#proxy-http-request-flow) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Authorization Check](#authorization-check) + - [Alias Resolution Algorithm](#alias-resolution-algorithm) + - [Route Matching Algorithm](#route-matching-algorithm) + - [Guard Validation Algorithm](#guard-validation-algorithm) + - [Body Validation Algorithm](#body-validation-algorithm) + - [Endpoint Selection Algorithm (X-OAGW-Target-Host)](#endpoint-selection-algorithm-x-oagw-target-host) + - [Header Transformation Algorithm](#header-transformation-algorithm) + - [Request Transformation Algorithm](#request-transformation-algorithm) + - [SSRF Guard Check Algorithm](#ssrf-guard-check-algorithm) + - [Outbound Call Algorithm](#outbound-call-algorithm) + - [Error Source Mapping Algorithm](#error-source-mapping-algorithm) +- [4. States (CDSL)](#4-states-cdsl) + - [Proxy Request Lifecycle State Machine](#proxy-request-lifecycle-state-machine) +- [5. Definitions of Done](#5-definitions-of-done) + - [Authorization Gate Runs First](#authorization-gate-runs-first) + - [Alias Resolution with Tenant Shadowing](#alias-resolution-with-tenant-shadowing) + - [Route Matching and Guard Enforcement](#route-matching-and-guard-enforcement) + - [Body Validation Before Buffering](#body-validation-before-buffering) + - [X-OAGW-Target-Host Endpoint Selection Matrix](#x-oagw-target-host-endpoint-selection-matrix) + - [Header Transformation and Rewrite](#header-transformation-and-rewrite) + - [Request Transformation, Scheme Policy, and Outbound Call](#request-transformation-scheme-policy-and-outbound-call) + - [SSRF Guard Check Runs as a Policy-Gated No-Op](#ssrf-guard-check-runs-as-a-policy-gated-no-op) + - [Response Relay and Error-Source Mapping](#response-relay-and-error-source-mapping) +- [6. Acceptance Criteria](#6-acceptance-criteria) +- [7. Additional Context (optional)](#7-additional-context-optional) + - [The Scheme Policy Split](#the-scheme-policy-split) + - [Out of Scope](#out-of-scope) + + + +- [ ] `p2` - **ID**: `cpt-cf-oagw-featstatus-proxy-data-plane-http-implemented` + +- [ ] `p1` - `cpt-cf-oagw-feature-proxy-data-plane-http` + +## 1. Feature Context + +### 1.1 Overview + +The core plain-HTTP proxy path: it resolves an alias to an upstream, matches a route, runs guard +and body checks, selects an endpoint, rewrites headers, and forwards the request without caching +or retrying it. + +### 1.2 Purpose + +`cpt-cf-oagw-actor-app-developer` calls a single gear-relative endpoint, +`{METHOD} /oagw/v1/proxy/{alias}[/{path}][?{query}]`, to reach an external +`cpt-cf-oagw-actor-upstream-service` without holding its credentials or connection details. This +feature builds the non-configurable half of that path: authorization, alias resolution, route +matching, guard rules, body validation, endpoint selection, header rewriting, request forwarding, +and the response relay that follows. GTS (Global Type System) identifiers, RFC 9457 (`Problem +Details for HTTP APIs`) error bodies, and the `X-OAGW-Error-Source` header apply across every +stage so a client can always tell whether a failure came from the gateway or from the upstream. + +**Requirements**: `cpt-cf-oagw-fr-request-proxy`, `cpt-cf-oagw-fr-header-transform`, +`cpt-cf-oagw-nfr-ssrf-protection`, `cpt-cf-oagw-nfr-low-latency` + +**Principles**: `cpt-cf-oagw-principle-no-retry`, `cpt-cf-oagw-principle-no-cache` + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-oagw-actor-app-developer` | Sends the proxy request and receives the relayed response or a gateway error | +| `cpt-cf-oagw-actor-upstream-service` | External HTTP service that the gateway dials and whose response it relays | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) — `cpt-cf-oagw-usecase-proxy-request` +- **Design**: [DESIGN.md](../DESIGN.md) §3.2 Component Model (Alias Resolution, Headers + Transformation, Guard Rules, Body Validation Rules, Transformation Rules), §3.3 API Contracts + (Proxy API, Error Response Format), §3.5 Interactions & Sequences (Proxy Request Flow, + `cpt-cf-oagw-seq-proxy-flow`) +- **ADR**: [0001-request-routing.md](../ADR/0001-request-routing.md) (`cpt-cf-oagw-adr-request-routing`, + X-OAGW-Target-Host Behavior Matrix), [0007-error-source-distinction.md](../ADR/0007-error-source-distinction.md), + [0005-data-plane-caching.md](../ADR/0005-data-plane-caching.md) (`cpt-cf-oagw-adr-data-plane-caching`), + [0006-state-management.md](../ADR/0006-state-management.md) (`cpt-cf-oagw-adr-state-management`) +- **Decomposition**: `cpt-cf-oagw-feature-proxy-data-plane-http` +- **Dependencies**: `cpt-cf-oagw-feature-route-management-api` + +## 2. Actor Flows (CDSL) + +### Proxy HTTP Request Flow + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-proxy-http-request` + +**Actor**: `cpt-cf-oagw-actor-app-developer` + +**Success Scenarios**: +- The request reaches the upstream and its status, headers, and body are relayed unchanged. +- A single-endpoint or explicit-alias upstream is reached without the caller supplying + `X-OAGW-Target-Host`. + +**Error Scenarios**: +- The permission check, alias resolution, route matching, guard rules, body validation, or + endpoint selection stage rejects the request before any outbound call is made. +- The outbound call times out or fails to connect, producing a gateway-originated 5xx. +- The upstream itself returns an error status, which is relayed unchanged. + +**Steps**: +1. [ ] - `p1` - App developer sends `{METHOD} /oagw/v1/proxy/{alias}[/{path}][?{query}]` with a Bearer token - `inst-req-1` +2. [ ] - `p1` - API: run the authorization check (`cpt-cf-oagw-algo-proxy-http-authorization`) against `gts.cf.core.oagw.proxy.v1~:invoke` - `inst-req-2` +3. [ ] - `p1` - **IF** the permission check fails - `inst-req-3` + 1. [ ] - `p1` - **RETURN** 401 AuthenticationFailed, `X-OAGW-Error-Source: gateway` - `inst-req-3a` +4. [ ] - `p1` - **ELSE** resolve the alias (`cpt-cf-oagw-algo-proxy-http-alias-resolution`) - `inst-req-4` +5. [ ] - `p1` - **IF** no tenant in the chain has an upstream matching the alias - `inst-req-5` + 1. [ ] - `p1` - **RETURN** 404 RouteNotFound - `inst-req-5a` +6. [ ] - `p1` - **IF** the resolved upstream (or an enforcing ancestor it binds to) is disabled - `inst-req-6` + 1. [ ] - `p1` - **RETURN** 503 LinkUnavailable - `inst-req-6a` +7. [ ] - `p1` - **ELSE** match the route (`cpt-cf-oagw-algo-proxy-http-route-matching`) - `inst-req-7` +8. [ ] - `p1` - **IF** no route matches the method and path - `inst-req-8` + 1. [ ] - `p1` - **RETURN** 404 RouteNotFound - `inst-req-8a` +9. [ ] - `p1` - **ELSE** run guard validation (`cpt-cf-oagw-algo-proxy-http-guard-validation`) - `inst-req-9` +10. [ ] - `p1` - **IF** a guard rejects the request - `inst-req-10` + 1. [ ] - `p1` - **RETURN** 400 ValidationError - `inst-req-10a` +11. [ ] - `p1` - **ELSE** run body validation (`cpt-cf-oagw-algo-proxy-http-body-validation`) - `inst-req-11` +12. [ ] - `p1` - **IF** body validation fails - `inst-req-12` + 1. [ ] - `p1` - **RETURN** 400 ValidationError or 413 PayloadTooLarge, as the specific check dictates - `inst-req-12a` +13. [ ] - `p1` - **ELSE** select the endpoint and validate `X-OAGW-Target-Host` (`cpt-cf-oagw-algo-proxy-http-endpoint-selection`) - `inst-req-13` +14. [ ] - `p1` - **IF** endpoint selection fails - `inst-req-14` + 1. [ ] - `p1` - **RETURN** 400 MissingTargetHost, InvalidTargetHost, or UnknownTargetHost, as applicable - `inst-req-14a` +15. [ ] - `p1` - **ELSE** transform headers and request (`cpt-cf-oagw-algo-proxy-http-header-transform`, `cpt-cf-oagw-algo-proxy-http-request-transform`), then issue the outbound call (`cpt-cf-oagw-algo-proxy-http-outbound-call`) - `inst-req-15` +16. [ ] - `p1` - **IF** the outbound call times out or fails to connect - `inst-req-16` + 1. [ ] - `p1` - **RETURN** the mapped gateway error with `X-OAGW-Error-Source: gateway` (`cpt-cf-oagw-algo-proxy-http-error-source-mapping`) - `inst-req-16a` +17. [ ] - `p1` - **ELSE** - `inst-req-17` + 1. [ ] - `p1` - **RETURN** the upstream's status, headers, and body unchanged, tagged `X-OAGW-Error-Source: upstream` - `inst-req-17a` + +## 3. Processes / Business Logic (CDSL) + +### Authorization Check + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-proxy-http-authorization` + +**Input**: Validated Bearer token (via `toolkit-auth`) carrying the caller's tenant and granted +permissions. + +**Output**: PASS with tenant context carried forward, or a 401 rejection. + +**Steps**: +1. [ ] - `p1` - Extract tenant_id, principal_id, and granted permissions from the token's SecurityContext - `inst-authz-1` +2. [ ] - `p1` - **IF** the granted permissions do not include `gts.cf.core.oagw.proxy.v1~:invoke` - `inst-authz-2` + 1. [ ] - `p1` - **RETURN** 401 AuthenticationFailed, `X-OAGW-Error-Source: gateway` - `inst-authz-2a` +3. [ ] - `p1` - **ELSE** - `inst-authz-3` + 1. [ ] - `p1` - **RETURN** PASS, carrying tenant_id into alias resolution - `inst-authz-3a` + +### Alias Resolution Algorithm + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-proxy-http-alias-resolution` + +**Input**: The `{alias}` path segment and the caller's tenant_id. + +**Output**: The resolved UpstreamConfig, or a 404/503 rejection. + +This upstream lookup is expected to be served through the data plane's small local cache +(`cpt-cf-oagw-adr-data-plane-caching`, `cpt-cf-oagw-adr-state-management`). The database is +consulted only on a cache miss. + +**Steps**: +1. [ ] - `p1` - Normalize `{alias}` to ASCII lowercase, matching the normalization applied when the alias was stored - `inst-alias-1` +2. [ ] - `p1` - **FOR EACH** tenant in the chain from the caller's tenant to the root (descendant to root) - `inst-alias-2` + 1. [ ] - `p1` - DB: SELECT upstream WHERE tenant_id = :tenant AND alias = :normalized_alias - `inst-alias-2a` + 2. [ ] - `p1` - **IF** a matching row is found, stop walking further ancestors — the closest match wins (shadowing) - `inst-alias-2b` +3. [ ] - `p1` - **IF** no tenant in the chain has a matching upstream - `inst-alias-3` + 1. [ ] - `p1` - **RETURN** 404 RouteNotFound - `inst-alias-3a` +4. [ ] - `p1` - **IF** the resolved upstream is disabled, or it binds to an ancestor upstream that is disabled - `inst-alias-4` + 1. [ ] - `p1` - **RETURN** 503 LinkUnavailable - `inst-alias-4a` +5. [ ] - `p1` - **ELSE** - `inst-alias-5` + 1. [ ] - `p1` - **RETURN** the resolved UpstreamConfig; ancestor constraints configured with `sharing: enforce` remain active regardless of which tenant's row was selected - `inst-alias-5a` + +### Route Matching Algorithm + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-proxy-http-route-matching` + +**Input**: The resolved UpstreamConfig, the inbound method and path remainder, and the same +tenant chain used for alias resolution. + +**Output**: The resolved RouteConfig, or a 404 rejection. + +Like alias resolution, this lookup is expected to be served through that same small local cache +(`cpt-cf-oagw-adr-data-plane-caching`, `cpt-cf-oagw-adr-state-management`) rather than the +database on every request. + +**Steps**: +1. [ ] - `p1` - **FOR EACH** tenant in the descendant-to-root chain - `inst-route-1` + 1. [ ] - `p1` - DB: SELECT route WHERE upstream_id = :resolved_upstream_id AND enabled = true - `inst-route-1a` +2. [ ] - `p1` - Filter to routes whose `match.http.methods` allowlist contains the inbound method - `inst-route-2` +3. [ ] - `p1` - Filter to routes whose `match.http.path` is a prefix of the inbound path - `inst-route-3` +4. [ ] - `p1` - **IF** more than one candidate route remains - `inst-route-4` + 1. [ ] - `p1` - Select the route with the longest matching path prefix, breaking ties with the route's `priority` field; a descendant tenant's route takes priority over an inherited ancestor route - `inst-route-4a` +5. [ ] - `p1` - **IF** no candidate route remains - `inst-route-5` + 1. [ ] - `p1` - **RETURN** 404 RouteNotFound - `inst-route-5a` +6. [ ] - `p1` - **ELSE** - `inst-route-6` + 1. [ ] - `p1` - **RETURN** the selected RouteConfig - `inst-route-6a` + +### Guard Validation Algorithm + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-proxy-http-guard-validation` + +**Input**: The resolved RouteConfig's `match.http` block and the inbound method, query string, +and path suffix. + +**Output**: PASS, or a 400 ValidationError. + +**Steps**: +1. [ ] - `p1` - **IF** the inbound method is not in `match.http.methods` - `inst-guard-1` + 1. [ ] - `p1` - **RETURN** 400 ValidationError - `inst-guard-1a` +2. [ ] - `p1` - **IF** any inbound query parameter is absent from `match.http.query_allowlist` (see DESIGN.md §3.2 Guard Rules) - `inst-guard-2` + 1. [ ] - `p1` - **RETURN** 400 ValidationError - `inst-guard-2a` +3. [ ] - `p1` - **IF** a path suffix is present and `match.http.path_suffix_mode` is `disabled` - `inst-guard-3` + 1. [ ] - `p1` - **RETURN** 400 ValidationError - `inst-guard-3a` +4. [ ] - `p1` - **ELSE** - `inst-guard-4` + 1. [ ] - `p1` - **RETURN** PASS - `inst-guard-4a` + +### Body Validation Algorithm + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-proxy-http-body-validation` + +**Input**: Inbound `Content-Length` and `Transfer-Encoding` headers, and the body stream. + +**Output**: PASS, or a 400 ValidationError / 413 PayloadTooLarge rejection (see DESIGN.md §3.2 +Body Validation Rules). + +**Steps**: +1. [ ] - `p1` - **IF** `Content-Length` is present and is not a valid non-negative integer - `inst-body-1` + 1. [ ] - `p1` - **RETURN** 400 ValidationError - `inst-body-1a` +2. [ ] - `p1` - **IF** `Transfer-Encoding` is present with a value other than `chunked` - `inst-body-2` + 1. [ ] - `p1` - **RETURN** 400 ValidationError - `inst-body-2a` +3. [ ] - `p1` - **IF** the bytes read so far exceed the 100MB hard limit (`cpt-cf-oagw-constraint-body-limit`) - `inst-body-3` + 1. [ ] - `p1` - **RETURN** 413 PayloadTooLarge before buffering the remainder of the body - `inst-body-3a` +4. [ ] - `p1` - **IF** `Content-Length` is present and does not match the actual bytes read from the body stream - `inst-body-4` + 1. [ ] - `p1` - **RETURN** 400 ValidationError - `inst-body-4a` +5. [ ] - `p1` - **ELSE** - `inst-body-5` + 1. [ ] - `p1` - **RETURN** PASS - `inst-body-5a` + +### Endpoint Selection Algorithm (X-OAGW-Target-Host) + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-proxy-http-endpoint-selection` + +**Input**: The upstream's `server.endpoints` pool, whether its alias was explicitly assigned or +derived from a common hostname suffix, and the inbound `X-OAGW-Target-Host` header. + +**Output**: The selected Endpoint, or a 400 MissingTargetHost / InvalidTargetHost / +UnknownTargetHost rejection, per the behavior matrix in +[ADR-0001](../ADR/0001-request-routing.md) lines 175-184. + +**Steps**: +1. [ ] - `p1` - **IF** the upstream has exactly one endpoint - `inst-ep-1` + 1. [ ] - `p1` - **IF** `X-OAGW-Target-Host` is present but is not a bare hostname or IP address (contains a port, path, or scheme) - `inst-ep-1a` + 1. [ ] - `p1` - **RETURN** 400 InvalidTargetHost - `inst-ep-1a1` + 2. [ ] - `p1` - **IF** `X-OAGW-Target-Host` is present, well-formed, and does not match the sole endpoint's host - `inst-ep-1b` + 1. [ ] - `p1` - **RETURN** 400 UnknownTargetHost - `inst-ep-1b1` + 3. [ ] - `p1` - **ELSE** (the header is absent, or present and matches the sole endpoint's host) - `inst-ep-1c` + 1. [ ] - `p1` - **RETURN** the sole endpoint - `inst-ep-1c1` +2. [ ] - `p1` - **IF** the upstream has multiple endpoints, `X-OAGW-Target-Host` is absent, and the alias was explicitly assigned (not a derived common suffix) - `inst-ep-2` + 1. [ ] - `p1` - **RETURN** the next endpoint from the round-robin sequence - `inst-ep-2a` +3. [ ] - `p1` - **IF** the upstream has multiple endpoints, `X-OAGW-Target-Host` is absent, and the alias was derived via `common_domain_suffix()` - `inst-ep-3` + 1. [ ] - `p1` - **RETURN** 400 MissingTargetHost, listing the pool's valid hosts - `inst-ep-3a` +4. [ ] - `p1` - **IF** the upstream has multiple endpoints and `X-OAGW-Target-Host` is present but is not a bare hostname or IP address (contains a port, path, or scheme) - `inst-ep-4` + 1. [ ] - `p1` - **RETURN** 400 InvalidTargetHost - `inst-ep-4a` +5. [ ] - `p1` - **IF** the upstream has multiple endpoints, `X-OAGW-Target-Host` is present and well-formed, but matches none of the pool's configured endpoint hosts - `inst-ep-5` + 1. [ ] - `p1` - **RETURN** 400 UnknownTargetHost, listing the pool's valid hosts - `inst-ep-5a` +6. [ ] - `p1` - **ELSE** (well-formed value matching a configured endpoint) - `inst-ep-6` + 1. [ ] - `p1` - **RETURN** the matching endpoint, bypassing round-robin - `inst-ep-6a` + +### Header Transformation Algorithm + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-proxy-http-header-transform` + +**Input**: The inbound header set, the selected Endpoint, and the inbound HTTP version. + +**Output**: The outbound header set to send to the upstream. + +**Steps**: +1. [ ] - `p1` - Consume `X-OAGW-Target-Host` for endpoint selection, then strip it — it is a routing header and is never forwarded (DESIGN.md §3.2 Headers Transformation) - `inst-hdr-1` +2. [ ] - `p1` - Strip every hop-by-hop header listed in DESIGN.md §3.2's table (`Connection`, `Keep-Alive`, and the rest of that list) - `inst-hdr-2` +3. [ ] - `p1` - **IF** the inbound request is HTTP/1.1 - `inst-hdr-3` + 1. [ ] - `p1` - Replace the `Host` header with the selected endpoint's `host[:port]` - `inst-hdr-3a` +4. [ ] - `p1` - **IF** the inbound request is HTTP/2 - `inst-hdr-4` + 1. [ ] - `p1` - Replace the `:authority` pseudo-header with the selected endpoint's authority; `X-OAGW-Target-Host` still governs routing and is unaffected by this rewrite - `inst-hdr-4a` +5. [ ] - `p1` - Forward all remaining headers unchanged; configurable `set`/`add`/`remove`/passthrough rules are out of scope (`cpt-cf-oagw-feature-policy-and-plugins`) - `inst-hdr-5` + +### Request Transformation Algorithm + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-proxy-http-request-transform` + +**Input**: The validated inbound request and the resolved RouteConfig. + +**Output**: The outbound request body, path, and query string. + +**Steps**: +1. [ ] - `p1` - Method: pass through unchanged - `inst-xform-1` +2. [ ] - `p1` - **IF** `match.http.path_suffix_mode` is `append` and a path suffix was supplied - `inst-xform-2` + 1. [ ] - `p1` - Outbound path = `match.http.path` joined with the supplied path suffix - `inst-xform-2a` +3. [ ] - `p1` - **ELSE** - `inst-xform-3` + 1. [ ] - `p1` - Outbound path = `match.http.path` - `inst-xform-3a` +4. [ ] - `p1` - Forward only the query parameters present in `match.http.query_allowlist`; drop the rest - `inst-xform-4` +5. [ ] - `p1` - Body: pass through unchanged, streamed rather than fully buffered where the transport allows it - `inst-xform-5` + +### SSRF Guard Check Algorithm + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-proxy-http-ssrf-guard` + +**Input**: The selected Endpoint's resolved DNS answer, the inbound request's headers, path, and +query string, and `OagwConfig.ssrf_policy.enabled`. + +**Output**: PASS, or a gateway rejection when the resolved target or the request fails +validation. This algorithm implements `cpt-cf-oagw-nfr-ssrf-protection`: the DNS and IP +validation, well-known header stripping, and path/query validation that the requirement mandates. +`ssrf_policy.enabled` is `false` in the graded configuration, so runtime enforcement is turned +off there. The check below still runs on every request, rather than being skipped or removed +from the code path. + +**Steps**: +1. [ ] - `p1` - Validate the endpoint's resolved DNS answer against the configured allowed and denied IP segments (DNS and IP validation) - `inst-ssrf-1` +2. [ ] - `p1` - Strip well-known internal headers (for example `X-Forwarded-For`, `X-Real-IP`) from the request before it reaches header transformation - `inst-ssrf-2` +3. [ ] - `p1` - Validate the outbound path and query string against the matched RouteConfig - `inst-ssrf-3` +4. [ ] - `p1` - **IF** `OagwConfig.ssrf_policy.enabled` is `false` (the graded configuration's value) - `inst-ssrf-4` + 1. [ ] - `p1` - Run steps 1-3 as a no-op: evaluate each check, but always treat the result as PASS regardless of outcome - `inst-ssrf-4a` +5. [ ] - `p1` - **ELSE IF** the resolved IP, the stripped headers, or the path/query fail validation - `inst-ssrf-5` + 1. [ ] - `p1` - **RETURN** a gateway rejection with `X-OAGW-Error-Source: gateway` before the connection opens - `inst-ssrf-5a` +6. [ ] - `p1` - **ELSE** - `inst-ssrf-6` + 1. [ ] - `p1` - **RETURN** PASS - `inst-ssrf-6a` + +### Outbound Call Algorithm + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-proxy-http-outbound-call` + +**Input**: The transformed outbound request, the selected Endpoint, and `OagwConfig` +(`proxy_timeout_secs`, `allow_http_upstream`). + +**Output**: The upstream's HTTP response, a timeout outcome, a connection-failure outcome, or a +protocol-failure outcome. + +**Steps**: +1. [ ] - `p1` - **IF** the endpoint's `scheme` is `http` (or `ws`) and `OagwConfig.allow_http_upstream` is false - `inst-out-1` + 1. [ ] - `p1` - **RETURN** a gateway error without dialling — the default HTTPS-only posture (`cpt-cf-oagw-constraint-https-only`) applies - `inst-out-1a` +2. [ ] - `p1` - **ELSE** open the connection in plaintext (`http`/`ws`) or TLS (`https`/`wss`/`wt`) as the endpoint's scheme dictates - `inst-out-2` + 1. [ ] - `p1` - Run the SSRF guard check (`cpt-cf-oagw-algo-proxy-http-ssrf-guard`) against the resolved DNS answer, headers, path, and query before the connection completes; the check runs on every request, and `ssrf_policy.enabled: false` in the graded configuration makes it a no-op that always passes, rather than removing the check - `inst-out-2a` +3. [ ] - `p1` - Issue the outbound call bounded by `OagwConfig.proxy_timeout_secs` (2 seconds in the graded configuration) - `inst-out-3` +4. [ ] - `p1` - Do not re-issue the client's original request on any failure (`cpt-cf-oagw-principle-no-retry`); connector-level endpoint failover within the same pool is permitted - `inst-out-4` +5. [ ] - `p1` - Do not cache the response for reuse on a later request (`cpt-cf-oagw-principle-no-cache`) - `inst-out-5` +6. [ ] - `p1` - **IF** the call exceeds the timeout - `inst-out-6` + 1. [ ] - `p1` - **RETURN** a timeout outcome for error-source mapping - `inst-out-6a` +7. [ ] - `p1` - **IF** the connection cannot be established because DNS resolution, connection refusal, or connection reset occurred - `inst-out-7` + 1. [ ] - `p1` - **RETURN** a connection-failure outcome for error-source mapping, tagged with which of the three occurred (DNS resolution failure, connection refused, or connection reset) so the mapping step can apply its deterministic rule - `inst-out-7a` +8. [ ] - `p1` - **IF** a response is received but cannot be parsed as valid HTTP (malformed status line, headers, or framing) - `inst-out-8` + 1. [ ] - `p1` - **RETURN** a protocol-failure outcome for error-source mapping - `inst-out-8a` +9. [ ] - `p1` - **ELSE** - `inst-out-9` + 1. [ ] - `p1` - **RETURN** the upstream's HTTP response - `inst-out-9a` + +### Error Source Mapping Algorithm + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-proxy-http-error-source-mapping` + +**Input**: The outcome of any pipeline stage — an upstream HTTP response, a timeout, a +connection-failure outcome (tagged DNS resolution failure, connection refused, or connection +reset), a protocol-failure outcome (a malformed or unparseable upstream response), or a gateway +rejection from an earlier stage. + +**Output**: The final HTTP response sent to the client, always carrying `X-OAGW-Error-Source`. + +**Steps**: +1. [ ] - `p1` - **IF** the outbound call returned an upstream HTTP response, at any status including 4xx/5xx - `inst-err-1` + 1. [ ] - `p1` - Relay the status, headers, and body unchanged - `inst-err-1a` + 2. [ ] - `p1` - Set `X-OAGW-Error-Source: upstream` - `inst-err-1b` +2. [ ] - `p1` - **IF** the outbound call timed out waiting for the connection or the response - `inst-err-2` + 1. [ ] - `p1` - **RETURN** 504, using the GTS `type` for `ConnectionTimeout` or `RequestTimeout` from DESIGN.md's error table, whichever the stage that timed out dictates - `inst-err-2a` + 2. [ ] - `p1` - Set `X-OAGW-Error-Source: gateway` - `inst-err-2b` +3. [ ] - `p1` - **IF** the outbound call returned a connection-failure outcome - `inst-err-3` + 1. [ ] - `p1` - **IF** the tagged cause is DNS resolution failure - `inst-err-3a` + 1. [ ] - `p1` - **RETURN** 503 LinkUnavailable - `inst-err-3a1` + 2. [ ] - `p1` - **ELSE** (the tagged cause is connection refused or connection reset) - `inst-err-3b` + 1. [ ] - `p1` - **RETURN** 502 DownstreamError - `inst-err-3b1` + 3. [ ] - `p1` - Set `X-OAGW-Error-Source: gateway` - `inst-err-3c` +4. [ ] - `p1` - **IF** the outbound call returned a protocol-failure outcome (a malformed or unparseable upstream response) - `inst-err-4` + 1. [ ] - `p1` - **RETURN** 502 ProtocolError - `inst-err-4a` + 2. [ ] - `p1` - Set `X-OAGW-Error-Source: gateway` - `inst-err-4b` +5. [ ] - `p1` - **IF** the response instead comes from an earlier gateway-side rejection (authorization, alias resolution, route matching, guards, body validation, or endpoint selection) - `inst-err-5` + 1. [ ] - `p1` - Emit RFC 9457 `application/problem+json` using the GTS `type` documented for that rejection and set `X-OAGW-Error-Source: gateway` - `inst-err-5a` + +This mapping is deterministic. DNS resolution failure always maps to 503 `LinkUnavailable`; +connection refused or reset maps to 502 `DownstreamError`; and a malformed response maps to 502 +`ProtocolError`. No other outcome produces one of these three statuses. + +## 4. States (CDSL) + +### Proxy Request Lifecycle State Machine + +- [ ] `p2` - **ID**: `cpt-cf-oagw-state-proxy-http-lifecycle` + +**States**: Received, Authorized, AliasResolved, RouteMatched, GuardsPassed, BodyValidated, +EndpointSelected, Forwarded, Completed, Rejected + +**Initial State**: Received + +**Transitions**: +1. [ ] - `p1` - **FROM** Received **TO** Authorized **WHEN** the `gts.cf.core.oagw.proxy.v1~:invoke` permission check passes - `inst-lc-1` +2. [ ] - `p1` - **FROM** Received **TO** Rejected **WHEN** the permission check fails (401) - `inst-lc-2` +3. [ ] - `p1` - **FROM** Authorized **TO** AliasResolved **WHEN** alias resolution finds an enabled upstream in the tenant chain - `inst-lc-3` +4. [ ] - `p1` - **FROM** Authorized **TO** Rejected **WHEN** alias resolution returns 404 or 503 - `inst-lc-4` +5. [ ] - `p1` - **FROM** AliasResolved **TO** RouteMatched **WHEN** route matching finds an enabled route - `inst-lc-5` +6. [ ] - `p1` - **FROM** AliasResolved **TO** Rejected **WHEN** no route matches (404) - `inst-lc-6` +7. [ ] - `p1` - **FROM** RouteMatched **TO** GuardsPassed **WHEN** guard validation passes - `inst-lc-7` +8. [ ] - `p1` - **FROM** RouteMatched **TO** Rejected **WHEN** a guard rejects the request (400) - `inst-lc-8` +9. [ ] - `p1` - **FROM** GuardsPassed **TO** BodyValidated **WHEN** body validation passes - `inst-lc-9` +10. [ ] - `p1` - **FROM** GuardsPassed **TO** Rejected **WHEN** body validation fails (400/413) - `inst-lc-10` +11. [ ] - `p1` - **FROM** BodyValidated **TO** EndpointSelected **WHEN** endpoint selection resolves a single target endpoint - `inst-lc-11` +12. [ ] - `p1` - **FROM** BodyValidated **TO** Rejected **WHEN** endpoint selection fails (400) - `inst-lc-12` +13. [ ] - `p1` - **FROM** EndpointSelected **TO** Forwarded **WHEN** the outbound call is issued to the upstream - `inst-lc-13` +14. [ ] - `p1` - **FROM** Forwarded **TO** Completed **WHEN** an upstream response, success or upstream error, is relayed to the client - `inst-lc-14` +15. [ ] - `p1` - **FROM** Forwarded **TO** Rejected **WHEN** the outbound call times out or fails to connect - `inst-lc-15` + +## 5. Definitions of Done + +### Authorization Gate Runs First + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-proxy-http-authorization` + +The system **MUST** enforce the `gts.cf.core.oagw.proxy.v1~:invoke` permission check as the first +step of every proxy request, before alias resolution, route matching, or any later stage runs. A +failing check **MUST** short-circuit with 401 AuthenticationFailed and `X-OAGW-Error-Source: +gateway`. + +**Implements**: +- `cpt-cf-oagw-flow-proxy-http-request` +- `cpt-cf-oagw-algo-proxy-http-authorization` + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}[/{path}][?{query}]` +- Entities: ProxyContext + +### Alias Resolution with Tenant Shadowing + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-proxy-http-alias-resolution` + +The system **MUST** normalize `{alias}` to ASCII lowercase and walk the tenant hierarchy from +descendant to root, selecting the closest matching enabled upstream (shadowing). An unknown alias +**MUST** return 404 RouteNotFound. A disabled upstream, including one disabled through an +enforcing ancestor, **MUST** return 503 LinkUnavailable. + +**Implements**: +- `cpt-cf-oagw-algo-proxy-http-alias-resolution` +- `cpt-cf-oagw-state-proxy-http-lifecycle` + +**Touches**: +- Entities: ProxyContext + +### Route Matching and Guard Enforcement + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-proxy-http-route-and-guards` + +The system **MUST** match routes for an HTTP upstream using its method allowlist plus +longest-path-prefix matching, honoring the `priority` field to break ties and excluding disabled +routes, returning 404 RouteNotFound when nothing matches. It **MUST** then reject a method +outside `match.http.methods`, a query parameter outside `match.http.query_allowlist`, or a path +suffix supplied while `path_suffix_mode` is `disabled`, each with 400 ValidationError. + +**Implements**: +- `cpt-cf-oagw-algo-proxy-http-route-matching` +- `cpt-cf-oagw-algo-proxy-http-guard-validation` + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}[/{path}][?{query}]` +- Entities: ProxyContext + +### Body Validation Before Buffering + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-proxy-http-body-validation` + +The system **MUST** validate `Content-Length` against the actual body size (400 +ValidationError), enforce the 100MB hard limit before buffering the body (413 PayloadTooLarge), +and accept only `chunked` as a supported `Transfer-Encoding`, rejecting any other value with 400 +ValidationError. + +**Implements**: +- `cpt-cf-oagw-algo-proxy-http-body-validation` + +**Constraints**: `cpt-cf-oagw-constraint-body-limit` + +**Touches**: +- Entities: ProxyContext + +### X-OAGW-Target-Host Endpoint Selection Matrix + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-proxy-http-endpoint-selection` + +The system **MUST** implement the full endpoint-selection matrix from +[ADR-0001](../ADR/0001-request-routing.md) lines 175-184. A single endpoint routes directly when +`X-OAGW-Target-Host` is absent, or present and matching that endpoint's host. A malformed value +on a single endpoint returns 400 InvalidTargetHost, and a well-formed value naming a different +host returns 400 UnknownTargetHost. An explicit-alias pool round-robins when `X-OAGW-Target-Host` +is absent and honors it when present. A common-suffix-derived alias pool requires the header, +returning 400 MissingTargetHost without it. A malformed header value returns 400 +InvalidTargetHost, and a well-formed value matching no configured endpoint returns 400 +UnknownTargetHost. + +**Implements**: +- `cpt-cf-oagw-algo-proxy-http-endpoint-selection` + +**Touches**: +- Entities: ProxyContext + +### Header Transformation and Rewrite + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-proxy-http-header-transform` + +The system **MUST** consume and strip `X-OAGW-Target-Host` and strip every hop-by-hop header +before forwarding, and **MUST** rewrite `Host` (HTTP/1.1) or the `:authority` pseudo-header +(HTTP/2) to the selected endpoint. Configurable `set`/`add`/`remove`/passthrough header rules +remain out of scope; they belong to `cpt-cf-oagw-feature-policy-and-plugins`. + +**Implements**: +- `cpt-cf-oagw-algo-proxy-http-header-transform` + +**Touches**: +- Entities: ProxyContext + +### Request Transformation, Scheme Policy, and Outbound Call + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-proxy-http-transform-and-outbound` + +The system **MUST** pass the method through unchanged, append the path suffix to +`match.http.path` only when `path_suffix_mode` is `append`, forward only allow-listed query +parameters, and pass the body through unchanged. It **MUST** dial the selected endpoint in +plaintext when its scheme is `http` (or `ws`) and `OagwConfig.allow_http_upstream` is `true`, and +**MUST** refuse the dial with a gateway error when the flag is `false`, regardless of the stored +`scheme` value — the schema's acceptance of `http` at create time and this dial decision are two +separate layers. It **MUST** bound the call by `OagwConfig.proxy_timeout_secs`, issue no +automatic retry of the client's request, and cache no response. + +**Implements**: +- `cpt-cf-oagw-algo-proxy-http-request-transform` +- `cpt-cf-oagw-algo-proxy-http-outbound-call` + +**Constraints**: `cpt-cf-oagw-constraint-https-only`, `cpt-cf-oagw-constraint-no-direct-internet` + +**Touches**: +- Entities: ProxyContext, ProxyResponse + +### SSRF Guard Check Runs as a Policy-Gated No-Op + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-proxy-http-ssrf-guard` + +The system **MUST** implement DNS and IP validation, well-known internal header stripping, and +path/query validation against route configuration, satisfying `cpt-cf-oagw-nfr-ssrf-protection`. +The check **MUST** run on every outbound call regardless of policy state. Because +`OagwConfig.ssrf_policy.enabled` is `false` in the graded configuration, the check **MUST** +evaluate as a no-op that always returns PASS. It **MUST NOT** be skipped, short-circuited, or +removed from the code path. + +**Implements**: +- `cpt-cf-oagw-algo-proxy-http-ssrf-guard` +- `cpt-cf-oagw-algo-proxy-http-outbound-call` + +**Constraints**: `cpt-cf-oagw-nfr-ssrf-protection` + +**Touches**: +- Entities: ProxyContext + +### Response Relay and Error-Source Mapping + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-proxy-http-response-relay` + +The system **MUST** relay the upstream's status, headers, and body unchanged with +`X-OAGW-Error-Source: upstream` for any upstream-originated response, including error statuses. +It **MUST** emit RFC 9457 `application/problem+json` with the GTS `type` documented in +DESIGN.md's error table and `X-OAGW-Error-Source: gateway` for every gateway-originated error. +It **MUST** map connection and request timeouts to 504. The connection-failure mapping **MUST** +follow the deterministic rule from the Error Source Mapping Algorithm: DNS resolution failure +maps to 503 `LinkUnavailable`, connection refused or reset maps to 502 `DownstreamError`, and a +malformed response maps to 502 `ProtocolError`. + +**Implements**: +- `cpt-cf-oagw-algo-proxy-http-error-source-mapping` + +**Touches**: +- Entities: ProxyResponse + +## 6. Acceptance Criteria + +- [ ] A `GET` through the proxy reaches a local stub upstream and returns its exact status code and body. +- [ ] An `http`-scheme upstream is dialled in plaintext and proxied successfully when `allow_http_upstream: true`. +- [ ] A proxy request for an unknown alias returns 404 RouteNotFound. +- [ ] A proxy request to a disabled upstream returns 503 LinkUnavailable. +- [ ] A request using a method outside the matched route's `match.http.methods` allowlist is rejected with 400 ValidationError. +- [ ] A query parameter outside `match.http.query_allowlist` is rejected with 400 ValidationError. +- [ ] A path suffix supplied against a route with `path_suffix_mode: disabled` is rejected with 400 ValidationError. +- [ ] A request whose `Content-Length` does not match its actual body size is rejected with 400 ValidationError. +- [ ] A request body over the 100MB hard limit is rejected with 413 PayloadTooLarge before the gateway buffers it. +- [ ] Hop-by-hop headers (for example `Connection`, `Transfer-Encoding`, `Upgrade`) sent by the client do not reach the stub upstream. +- [ ] The `Host` header (and, on an HTTP/2 request, the `:authority` pseudo-header) received by the stub upstream is rewritten to the upstream's own host. +- [ ] A multi-endpoint upstream whose alias is a derived common suffix, called without `X-OAGW-Target-Host`, returns 400 MissingTargetHost. +- [ ] A multi-endpoint upstream called with a malformed `X-OAGW-Target-Host` value returns 400 InvalidTargetHost. +- [ ] A multi-endpoint upstream called with an `X-OAGW-Target-Host` value that matches no configured endpoint returns 400 UnknownTargetHost. +- [ ] A stub upstream returning a 500 status is relayed unchanged with `X-OAGW-Error-Source: upstream`. +- [ ] A stub upstream that does not respond within `proxy_timeout_secs` produces a 504 response with `X-OAGW-Error-Source: gateway`. +- [ ] A multi-endpoint upstream with an explicit alias, called repeatedly without `X-OAGW-Target-Host`, distributes requests round-robin across its local stub endpoints. +- [ ] A multi-endpoint upstream with an explicit alias, called with `X-OAGW-Target-Host` naming one pool member, always reaches that stub endpoint, bypassing round-robin. +- [ ] A proxy request whose outbound call cannot reach a local stub upstream (connection refused) returns a gateway-originated 502 or 503 response carrying `X-OAGW-Error-Source: gateway`. +- [ ] With `ssrf_policy.enabled: false` (the graded configuration), a proxy request against a stub upstream still triggers the SSRF guard check, which always passes without being skipped. + +## 7. Additional Context (optional) + +### The Scheme Policy Split + +Two distinct layers govern plaintext upstreams, and this feature owns only the second. Whether +the upstream resource model's `scheme` field *accepts* `http` at create time is decided by +`cpt-cf-oagw-feature-resource-model-and-store` and `cpt-cf-oagw-feature-upstream-management-api` +(Override 2 of the DECOMPOSITION: `http` and `ws` are accepted as a deliberate extension beyond +the supplied JSON Schema's four-value enum). Whether the gateway actually *opens* a plaintext +connection to a `scheme: http` (or `ws`) endpoint is decided here, at outbound-call time, and is +governed solely by `OagwConfig.allow_http_upstream`. That flag is `true` in the graded +configuration, so an `http` upstream is dialled in plaintext and proxied successfully; when the +flag is unset or `false`, the default HTTPS-only posture (`cpt-cf-oagw-constraint-https-only`) +applies and the dial is refused with a gateway error even though the stored `scheme` is `http`. + +### Out of Scope + +- **Credential injection, rate limiting, CORS, and configurable header set/add/remove/passthrough + rules** — these decorate the same proxy path but are driven by the plugin chain and hierarchical + configuration; they belong to `cpt-cf-oagw-feature-policy-and-plugins`, which composes on top of + the pipeline this feature builds. +- **SSE and WebSocket upgrade negotiation** — a plain HTTP request/response cycle is the only + interaction pattern covered here; streaming upgrades on the same alias/route resolution belong + to `cpt-cf-oagw-feature-proxy-streaming`. +- **gRPC request classification and dispatch** — the upstream `protocol` enum includes a gRPC + value, but no gRPC proxy code path is implemented or reachable in this build (Scope Reality); a + gRPC-protocol upstream is out of scope for the data plane entirely. +- **Circuit breaker enforcement** — documented in DESIGN.md §4.7 as future resilience work; this + feature performs no failure-rate tracking or trip/reset logic. diff --git a/gears/system/oagw/docs/features/proxy-streaming.md b/gears/system/oagw/docs/features/proxy-streaming.md new file mode 100644 index 0000000..e729882 --- /dev/null +++ b/gears/system/oagw/docs/features/proxy-streaming.md @@ -0,0 +1,371 @@ +# Feature: Proxy Streaming — SSE and WebSocket + + + +- [1. Feature Context](#1-feature-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [SSE Streaming Relay Flow](#sse-streaming-relay-flow) + - [WebSocket Upgrade and Relay Flow](#websocket-upgrade-and-relay-flow) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [SSE Detection and Streaming Relay Algorithm](#sse-detection-and-streaming-relay-algorithm) + - [Upgrade Header Reconstruction Algorithm](#upgrade-header-reconstruction-algorithm) + - [WebSocket Bidirectional Relay Algorithm](#websocket-bidirectional-relay-algorithm) +- [4. States (CDSL)](#4-states-cdsl) + - [SSE Stream Lifecycle State Machine](#sse-stream-lifecycle-state-machine) + - [WebSocket Session Lifecycle State Machine](#websocket-session-lifecycle-state-machine) +- [5. Definitions of Done](#5-definitions-of-done) + - [SSE Incremental Relay](#sse-incremental-relay) + - [Streaming Exemption from Body Limit and Request Timeout](#streaming-exemption-from-body-limit-and-request-timeout) + - [SSE Connection Lifecycle and Stream Abort Reporting](#sse-connection-lifecycle-and-stream-abort-reporting) + - [WebSocket Upgrade Negotiation with Explicit Header Reconstruction](#websocket-upgrade-negotiation-with-explicit-header-reconstruction) + - [WebSocket Bidirectional Relay and Close Propagation](#websocket-bidirectional-relay-and-close-propagation) + - [Failed Upgrade Reported as a Gateway Error](#failed-upgrade-reported-as-a-gateway-error) +- [6. Acceptance Criteria](#6-acceptance-criteria) + + + +- [ ] `p1` - **ID**: `cpt-cf-oagw-featstatus-proxy-streaming-implemented` + + +- [ ] `p1` - `cpt-cf-oagw-feature-proxy-streaming` + +## 1. Feature Context + +### 1.1 Overview + +This feature extends the single proxy endpoint built by the HTTP proxy feature so it also +carries Server-Sent-Events (SSE, a one-way streaming response format) and WebSocket upgrade +traffic, without introducing a second endpoint or a separate resolution path. + +### 1.2 Purpose + +`cpt-cf-oagw-actor-app-developer` consumes external APIs that stream rather than return a +single buffered body — chat-completion SSE feeds and bidirectional WebSocket protocols are +the two concrete cases named in the PRD. Proxying, as a capability, is not complete until both +of these forward through the same alias, route, guard, and authorization path as an ordinary +HTTP request; this feature adds that missing half. + +**Requirements**: `cpt-cf-oagw-fr-streaming` + +**Use case**: `cpt-cf-oagw-usecase-sse-streaming` + +**Principles**: None newly covered here; `cpt-cf-oagw-principle-no-retry` and +`cpt-cf-oagw-principle-error-source` apply to streaming traffic exactly as they apply to plain +HTTP, and remain attributed to `cpt-cf-oagw-feature-proxy-data-plane-http`. + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-oagw-actor-app-developer` | Opens an SSE request or a WebSocket upgrade through the proxy endpoint and consumes the resulting stream or session. | +| `cpt-cf-oagw-actor-upstream-service` | The external SSE emitter or WebSocket peer OAGW (Outbound API Gateway) dials on the app developer's behalf. | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) §5.4 `cpt-cf-oagw-fr-streaming`, §8 `cpt-cf-oagw-usecase-sse-streaming` +- **Design**: [DESIGN.md](../DESIGN.md) §3.2 Headers Transformation (`cpt-cf-oagw-interface-api`), Error Response Format table (`StreamAborted`, `ProtocolError`) +- **ADR**: [0007 Error Source Distinction](../ADR/0007-error-source-distinction.md) (`cpt-cf-oagw-adr-error-source-distinction`) +- **Decomposition**: `cpt-cf-oagw-feature-proxy-streaming` +- **Dependencies**: `cpt-cf-oagw-feature-proxy-data-plane-http` (alias resolution, route matching, guard rules, and header transformation this feature reuses without restating; the authorization check that gates every proxy request also runs unchanged here) + +## 2. Actor Flows (CDSL) + +**Use cases**: `cpt-cf-oagw-usecase-sse-streaming` + +Both flows below assume the request has already passed the shared authorization, alias +resolution, route matching, and guard checks defined in +`cpt-cf-oagw-feature-proxy-data-plane-http`; those steps are referenced here, not repeated. + +### SSE Streaming Relay Flow + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-proxy-streaming-sse-relay` + +**Actor**: `cpt-cf-oagw-actor-app-developer` + +**Success Scenarios**: +- The upstream answers with a `text/event-stream` body and events reach the client + incrementally, as they are produced, rather than after upstream completion. +- The upstream finishes and closes its connection; the client connection is closed in turn and + the closure is logged. +- The client disconnects mid-stream; the upstream connection is closed and no further reads + occur. + +**Error Scenarios**: +- The upstream connection fails before any response head is received — an ordinary gateway + error, exactly as in the non-streaming HTTP path. +- The upstream connection fails after the stream has begun — the already-sent status code + cannot change, so the stream is terminated and reported as `StreamAborted` (502) per + `cpt-cf-oagw-adr-error-source-distinction`. + +**Steps**: +1. [ ] - `p1` - App developer sends a proxy request to `{METHOD} /oagw/v1/proxy/{alias}[/path][?query]`; the request commonly carries `Accept: text/event-stream`, but that header is not the classification signal - `inst-sse-1` +2. [ ] - `p1` - API: reuse alias resolution, route match, guard checks, and authorization from `cpt-cf-oagw-feature-proxy-data-plane-http` - `inst-sse-2` +3. [ ] - `p1` - System opens the upstream connection within the configured `proxy_timeout_secs` budget, which bounds only reaching the response head - `inst-sse-3` +4. [ ] - `p1` - **IF** the upstream response's `Content-Type` is `text/event-stream` - `inst-sse-4` + 1. [ ] - `p1` - Run `cpt-cf-oagw-algo-proxy-streaming-sse-detect` to switch from buffering to streaming relay - `inst-sse-4a` + 2. [ ] - `p1` - Forward each event to the client as it arrives, preserving SSE event framing unchanged - `inst-sse-4b` +5. [ ] - `p1` - **IF** the upstream closes its connection - `inst-sse-5` + 1. [ ] - `p1` - Close the client connection and log the stream-closed event - `inst-sse-5a` +6. [ ] - `p1` - **ELSE IF** the client disconnects - `inst-sse-6` + 1. [ ] - `p1` - Close the upstream connection and stop reading from it - `inst-sse-6a` +7. [ ] - `p1` - **ELSE IF** the upstream connection fails after the stream has begun - `inst-sse-7` + 1. [ ] - `p1` - Terminate the stream and report `StreamAborted` (502, `X-OAGW-Error-Source: gateway` or `upstream` depending on failure origin), leaving the already-sent status unchanged - `inst-sse-7a` +8. [ ] - `p1` - **RETURN** the streamed response, terminated by upstream completion, client disconnect, or abort - `inst-sse-8` + +### WebSocket Upgrade and Relay Flow + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-proxy-streaming-websocket-relay` + +**Actor**: `cpt-cf-oagw-actor-app-developer` + +**Success Scenarios**: +- The upstream answers `101 Switching Protocols`; the client's own upgrade completes with + `101`, and frames flow in both directions until either side closes. +- A close frame sent by either the client or the upstream is relayed, with its status code, to + the other side. + +**Error Scenarios**: +- The upstream refuses the dial, or answers with any status other than `101` — reported as a + gateway error carrying `X-OAGW-Error-Source: gateway`. +- The client disconnects abruptly without a close frame — the upstream connection is closed. +- The upstream disconnects abruptly without a close frame — the client connection is closed. + +**Steps**: +1. [ ] - `p1` - App developer sends `{METHOD} /oagw/v1/proxy/{alias}[/path]` with `Upgrade: websocket` and `Connection: Upgrade` - `inst-ws-1` +2. [ ] - `p1` - API: reuse alias resolution, route match, guard checks, and authorization from `cpt-cf-oagw-feature-proxy-data-plane-http` - `inst-ws-2` +3. [ ] - `p1` - **IF** the request carries `Upgrade: websocket` and `Connection: Upgrade` - `inst-ws-3` + 1. [ ] - `p1` - Classify the request as an upgrade instead of a buffered HTTP request - `inst-ws-3a` +4. [ ] - `p1` - Run `cpt-cf-oagw-algo-proxy-streaming-header-reconstruction` to rebuild the `Upgrade` and `Connection` headers for the upstream dial, since the ordinary hop-by-hop stripping step removes both by default - `inst-ws-4` +5. [ ] - `p1` - System dials the upstream, within the configured `proxy_timeout_secs` budget, carrying the client's original `Sec-WebSocket-Key` forwarded unmodified, the negotiated subprotocol, and any required headers; this budget bounds only reaching the `101 Switching Protocols` response and does not apply to the established relay session - `inst-ws-5` +6. [ ] - `p1` - **IF** the upstream answers `101 Switching Protocols` - `inst-ws-6` + 1. [ ] - `p1` - Complete the client-facing handshake with `101 Switching Protocols`, relaying the upstream's `Sec-WebSocket-Accept` back to the client unmodified - `inst-ws-6a` + 2. [ ] - `p1` - Run `cpt-cf-oagw-algo-proxy-streaming-ws-relay` to relay frames bidirectionally until either side closes - `inst-ws-6b` +7. [ ] - `p1` - **ELSE** - `inst-ws-7` + 1. [ ] - `p1` - **RETURN** a gateway error (`ProtocolError`, 502, `X-OAGW-Error-Source: gateway`) - `inst-ws-7a` +8. [ ] - `p1` - **IF** the client sends a close frame - `inst-ws-8` + 1. [ ] - `p1` - Relay the close frame and status code to the upstream, then close the upstream connection - `inst-ws-8a` +9. [ ] - `p1` - **ELSE IF** the upstream sends a close frame - `inst-ws-9` + 1. [ ] - `p1` - Relay the close frame and status code to the client, then close the client connection - `inst-ws-9a` +10. [ ] - `p1` - **RETURN** the session ended, with both connections closed - `inst-ws-10` + +## 3. Processes / Business Logic (CDSL) + +### SSE Detection and Streaming Relay Algorithm + +- [ ] `p1` - **ID**: `cpt-cf-oagw-algo-proxy-streaming-sse-detect` + +**Input**: Upstream response head (status, headers) received after `cpt-cf-oagw-feature-proxy-data-plane-http` has resolved the route + +**Output**: Either an active streaming relay session, or a buffered response handed back to the ordinary HTTP response path + +**Steps**: +1. [ ] - `p1` - Parse and normalize the `Content-Type` header from the upstream response head - `inst-sd-1` +2. [ ] - `p1` - **IF** `Content-Type` is `text/event-stream` - `inst-sd-2` + 1. [ ] - `p1` - Mark the request as an SSE stream: suspend the 100MB body cap for the remainder of the connection, since it applies only to buffered bodies - `inst-sd-2a` + 2. [ ] - `p1` - Confine the request timeout to having reached this response head; it no longer bounds the stream's remaining lifetime - `inst-sd-2b` + 3. [ ] - `p1` - **TRY** - `inst-sd-2c` + 1. [ ] - `p1` - **FOR EACH** chunk received from the upstream, forward it to the client unmodified, without altering SSE event framing, continuing until the upstream closes or the client disconnects - `inst-sd-2c1` + 4. [ ] - `p1` - **CATCH** an upstream connection failure occurring after the stream has begun - `inst-sd-2d` + 1. [ ] - `p1` - Terminate the stream and report `StreamAborted` (502) without altering the status already sent to the client - `inst-sd-2d1` + 5. [ ] - `p1` - **RETURN** the stream terminated by close, disconnect, or abort - `inst-sd-2e` +3. [ ] - `p1` - **ELSE** - `inst-sd-3` + 1. [ ] - `p1` - **RETURN** control to the buffered HTTP response handling of `cpt-cf-oagw-feature-proxy-data-plane-http` - `inst-sd-3a` + +### Upgrade Header Reconstruction Algorithm + +- [ ] `p1` - **ID**: `cpt-cf-oagw-algo-proxy-streaming-header-reconstruction` + +**Input**: Inbound request headers, the negotiated `Sec-WebSocket-Key`, `Sec-WebSocket-Version`, and any negotiated subprotocol + +**Output**: The outbound header set sent to the upstream on the upgrade dial + +**Steps**: +1. [ ] - `p1` - Apply the standard hop-by-hop stripping documented in DESIGN.md's Headers Transformation table, which removes `Connection` and `Upgrade`, among others, by default - `inst-hr-1` +2. [ ] - `p1` - **IF** the request was classified as a WebSocket upgrade - `inst-hr-2` + 1. [ ] - `p1` - Re-add `Upgrade: websocket` and `Connection: Upgrade` explicitly for the upstream dial, since the default stripping step removes both and would otherwise silently downgrade the dial to a plain request - `inst-hr-2a` + 2. [ ] - `p1` - Forward the client's original `Sec-WebSocket-Key` unmodified, together with `Sec-WebSocket-Version` and any negotiated subprotocol, through to the outbound headers - `inst-hr-2b` +3. [ ] - `p1` - **ELSE** - `inst-hr-3` + 1. [ ] - `p1` - Leave `Connection` and `Upgrade` stripped, as for any ordinary proxied request - `inst-hr-3a` +4. [ ] - `p1` - **RETURN** the outbound header set - `inst-hr-4` + +### WebSocket Bidirectional Relay Algorithm + +- [ ] `p1` - **ID**: `cpt-cf-oagw-algo-proxy-streaming-ws-relay` + +**Input**: An established client socket and upstream socket pair, following a successful `101` handshake on both sides + +**Output**: Frames relayed in both directions until the session ends + +**Steps**: +1. [ ] - `p1` - Confine `proxy_timeout_secs` to having reached the `101 Switching Protocols` response; it no longer bounds this established relay session's remaining lifetime - `inst-wr-1` +2. [ ] - `p1` - **FOR EACH** event, waiting on whichever side produces the next one - `inst-wr-2` + 1. [ ] - `p1` - **IF** a frame arrives from the client - `inst-wr-2a` + 1. [ ] - `p1` - Forward the frame to the upstream unmodified - `inst-wr-2a1` + 2. [ ] - `p1` - **ELSE IF** a frame arrives from the upstream - `inst-wr-2b` + 1. [ ] - `p1` - Forward the frame to the client unmodified - `inst-wr-2b1` + 3. [ ] - `p1` - **ELSE IF** the client sends a close frame - `inst-wr-2c` + 1. [ ] - `p1` - Relay the close frame and its status code to the upstream, then close the upstream socket - `inst-wr-2c1` + 4. [ ] - `p1` - **ELSE IF** the upstream sends a close frame - `inst-wr-2d` + 1. [ ] - `p1` - Relay the close frame and its status code to the client, then close the client socket - `inst-wr-2d1` + 5. [ ] - `p1` - **ELSE IF** the client disconnects without a close frame - `inst-wr-2e` + 1. [ ] - `p1` - Close the upstream socket - `inst-wr-2e1` + 6. [ ] - `p1` - **ELSE IF** the upstream disconnects without a close frame - `inst-wr-2f` + 1. [ ] - `p1` - Close the client socket - `inst-wr-2f1` +3. [ ] - `p1` - **RETURN** the session closed - `inst-wr-3` + +## 4. States (CDSL) + +### SSE Stream Lifecycle State Machine + +- [ ] `p2` - **ID**: `cpt-cf-oagw-state-proxy-streaming-sse-lifecycle` + +**States**: idle, establishing, open, closing, closed + +**Initial State**: idle + +**Transitions**: +1. [ ] - `p1` - **FROM** idle **TO** establishing **WHEN** the client sends a proxy request; `Accept: text/event-stream` is commonly sent but does not gate this transition - `inst-sl-1` +2. [ ] - `p1` - **FROM** establishing **TO** open **WHEN** the upstream response head arrives with `Content-Type: text/event-stream` - `inst-sl-2` +3. [ ] - `p1` - **FROM** establishing **TO** closed **WHEN** the upstream connection fails before any response head arrives — an ordinary gateway error, since no stream was ever opened - `inst-sl-3` +4. [ ] - `p1` - **FROM** establishing **TO** closed **WHEN** the response head arrives without `Content-Type: text/event-stream`, handing control back to the buffered HTTP response path since no SSE stream was opened - `inst-sl-4` +5. [ ] - `p1` - **FROM** open **TO** closing **WHEN** the upstream closes its connection - `inst-sl-5` +6. [ ] - `p1` - **FROM** open **TO** closing **WHEN** the client disconnects - `inst-sl-6` +7. [ ] - `p1` - **FROM** open **TO** closing **WHEN** the upstream connection fails after the stream has begun, reported as `StreamAborted` - `inst-sl-7` +8. [ ] - `p1` - **FROM** closing **TO** closed **WHEN** the counterpart connection has been closed and the closure event logged - `inst-sl-8` + +### WebSocket Session Lifecycle State Machine + +- [ ] `p2` - **ID**: `cpt-cf-oagw-state-proxy-streaming-ws-lifecycle` + +**States**: idle, establishing, open, closing, closed + +**Initial State**: idle + +**Transitions**: +1. [ ] - `p1` - **FROM** idle **TO** establishing **WHEN** the client sends a request with `Upgrade: websocket` and `Connection: Upgrade` - `inst-wl-1` +2. [ ] - `p1` - **FROM** establishing **TO** open **WHEN** the upstream answers `101 Switching Protocols` and the client-facing handshake completes - `inst-wl-2` +3. [ ] - `p1` - **FROM** establishing **TO** closed **WHEN** the upstream refuses the dial or answers a status other than `101`, reported as a gateway error with `X-OAGW-Error-Source: gateway` - `inst-wl-3` +4. [ ] - `p1` - **FROM** open **TO** closing **WHEN** either side sends a close frame - `inst-wl-4` +5. [ ] - `p1` - **FROM** open **TO** closing **WHEN** either side disconnects without a close frame - `inst-wl-5` +6. [ ] - `p1` - **FROM** closing **TO** closed **WHEN** both sockets are closed and any close status code has been relayed to the other side - `inst-wl-6` + +## 5. Definitions of Done + +### SSE Incremental Relay + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-proxy-streaming-sse-relay` + +The system **MUST** detect a `text/event-stream` upstream response by its `Content-Type` and +forward events to the client incrementally, as they arrive, without buffering the response to +completion first and without altering SSE event framing. + +**Implements**: +- `cpt-cf-oagw-flow-proxy-streaming-sse-relay` +- `cpt-cf-oagw-algo-proxy-streaming-sse-detect` + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}[/path]` +- Entities: ProxyContext (from `cpt-cf-oagw-feature-proxy-data-plane-http`) + +### Streaming Exemption from Body Limit and Request Timeout + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-proxy-streaming-body-limit-exempt` + +The system **MUST** exempt an established SSE stream from the 100MB body limit +(`cpt-cf-oagw-constraint-body-limit`), and **MUST** confine `proxy_timeout_secs` to the time +needed to reach the upstream response head rather than the remaining lifetime of an +already-open stream. + +**Implements**: +- `cpt-cf-oagw-algo-proxy-streaming-sse-detect` + +**Constraints**: `cpt-cf-oagw-constraint-body-limit` + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}[/path]` + +### SSE Connection Lifecycle and Stream Abort Reporting + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-proxy-streaming-sse-lifecycle` + +The system **MUST** close the client connection and log the event when the upstream closes, +**MUST** close the upstream connection and stop reading when the client disconnects, and +**MUST** report an upstream failure occurring after the stream has begun as `StreamAborted` +(502), leaving any status already sent to the client unchanged, per +`cpt-cf-oagw-adr-error-source-distinction`. + +**Implements**: +- `cpt-cf-oagw-flow-proxy-streaming-sse-relay` +- `cpt-cf-oagw-state-proxy-streaming-sse-lifecycle` + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}[/path]` + +### WebSocket Upgrade Negotiation with Explicit Header Reconstruction + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-proxy-streaming-ws-upgrade` + +The system **MUST** perform the client-facing upgrade handshake and dial the upstream with a +matching upgrade request that explicitly re-adds the `Upgrade` and `Connection` headers after +the ordinary hop-by-hop stripping step removes them, forwarding the client's original +`Sec-WebSocket-Key` unmodified along with the negotiated subprotocol and any required headers, +completing the client handshake with `101` only once the upstream itself answers `101`, and +**MUST** relay the upstream's `Sec-WebSocket-Accept` back to the client unmodified in that `101` +response. + +**Implements**: +- `cpt-cf-oagw-flow-proxy-streaming-websocket-relay` +- `cpt-cf-oagw-algo-proxy-streaming-header-reconstruction` +- `cpt-cf-oagw-state-proxy-streaming-ws-lifecycle` + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}[/path]` + +### WebSocket Bidirectional Relay and Close Propagation + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-proxy-streaming-ws-relay` + +The system **MUST** relay frames bidirectionally between client and upstream once the upgrade +completes, **MUST** relay a close frame and its status code in both directions, **MUST** +propagate a client disconnect to the upstream connection and an upstream close to the client +connection, and **MUST** confine `proxy_timeout_secs` to reaching the `101 Switching Protocols` +response, never tearing down an already-established relay session once that response has +arrived. + +**Implements**: +- `cpt-cf-oagw-flow-proxy-streaming-websocket-relay` +- `cpt-cf-oagw-algo-proxy-streaming-ws-relay` + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}[/path]` + +### Failed Upgrade Reported as a Gateway Error + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-proxy-streaming-ws-upgrade-failure` + +The system **MUST** report a failed upgrade — the upstream refusing the dial, or answering with +any status other than `101 Switching Protocols` — as a gateway error carrying +`X-OAGW-Error-Source: gateway`, consistent with `cpt-cf-oagw-adr-error-source-distinction`. + +**Implements**: +- `cpt-cf-oagw-flow-proxy-streaming-websocket-relay` +- `cpt-cf-oagw-state-proxy-streaming-ws-lifecycle` + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}[/path]` + +## 6. Acceptance Criteria + +- [ ] Against a local stub upstream that emits SSE events with a delay between each one, the client receives each event as it is produced rather than receiving all events in one buffered block after the delay. +- [ ] When the stub upstream closes its SSE connection, the client observes the stream close, and a closure event is logged. +- [ ] When the client closes its connection mid-stream, the stub upstream observes its connection closed by the gateway. +- [ ] Against a local stub upstream that sends a `200` head with `Content-Type: text/event-stream`, emits some events, then drops its connection mid-stream, the client already received the `200` head unchanged, and the stream ends with `StreamAborted` (502) rather than any other status. +- [ ] Against a local stub upstream configured with `proxy_timeout_secs` set to `2` seconds, an SSE stream that stays open and keeps emitting events for longer than 2 seconds is not torn down at the 2-second mark; events keep arriving past that point. +- [ ] A WebSocket upgrade request through `{METHOD} /oagw/v1/proxy/{alias}[/path]` against a stub upstream that accepts the upgrade completes with `101 Switching Protocols`, and a text frame sent by the client is echoed back by the stub and received by the client. +- [ ] A close frame sent by the client is observed by the stub upstream, and a close frame sent by the stub upstream is observed by the client, in each case with the same status code. +- [ ] A WebSocket upgrade request against a stub upstream configured to refuse the upgrade (or to answer a non-`101` status) returns a gateway error response carrying `X-OAGW-Error-Source: gateway`. +- [ ] The SSE and WebSocket paths above complete successfully against a plaintext (`http`/`ws`) stub upstream, consistent with `allow_http_upstream: true`. diff --git a/gears/system/oagw/docs/features/resource-model-and-store.md b/gears/system/oagw/docs/features/resource-model-and-store.md new file mode 100644 index 0000000..3f632a6 --- /dev/null +++ b/gears/system/oagw/docs/features/resource-model-and-store.md @@ -0,0 +1,772 @@ +# Feature: Resource Model and Store + + + +- [1. Feature Context](#1-feature-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Create Upstream Flow](#create-upstream-flow) + - [Create Route Flow](#create-route-flow) + - [Update Upstream Endpoints Flow](#update-upstream-endpoints-flow) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Upstream Schema Validation](#upstream-schema-validation) + - [Route Schema Validation](#route-schema-validation) + - [Alias Derivation](#alias-derivation) + - [Alias Update Transition Check](#alias-update-transition-check) + - [Tenant-Scoped Store Write Invariants](#tenant-scoped-store-write-invariants) + - [Tenant Hierarchy Walk](#tenant-hierarchy-walk) +- [4. States (CDSL)](#4-states-cdsl) + - [Upstream and Route Enabled-State Lifecycle](#upstream-and-route-enabled-state-lifecycle) +- [5. Definitions of Done](#5-definitions-of-done) + - [Domain Types Matching the Supplied Schemas](#domain-types-matching-the-supplied-schemas) + - [Schema-Level Validation Rejects Malformed Requests](#schema-level-validation-rejects-malformed-requests) + - [Alias Derivation and Normalization](#alias-derivation-and-normalization) + - [Alias Immutability Across Updates](#alias-immutability-across-updates) + - [Upstream Alias Uniqueness in the Tenant-Scoped Store](#upstream-alias-uniqueness-in-the-tenant-scoped-store) + - [Route Match Determinism in the Tenant-Scoped Store](#route-match-determinism-in-the-tenant-scoped-store) + - [Contiguous Plugin Binding Positions in the Tenant-Scoped Store](#contiguous-plugin-binding-positions-in-the-tenant-scoped-store) + - [Anonymous GTS Resource Identifier Generation](#anonymous-gts-resource-identifier-generation) + - [Tenant Hierarchy Walk Primitive](#tenant-hierarchy-walk-primitive) + - [Enabled/Disabled Lifecycle Enforcement](#enableddisabled-lifecycle-enforcement) +- [6. Acceptance Criteria](#6-acceptance-criteria) +- [7. Additional Context (optional)](#7-additional-context-optional) + - [7.1 Upstream Field Reference](#71-upstream-field-reference) + - [7.2 Route Field Reference](#72-route-field-reference) + - [7.3 Shared Nested Configuration Shapes](#73-shared-nested-configuration-shapes) + - [7.4 Schema Reconciliation Notes](#74-schema-reconciliation-notes) + - [7.5 Resource Identification Pattern](#75-resource-identification-pattern) + - [7.6 Out of Scope](#76-out-of-scope) + + + +- [ ] `p1` - **ID**: `cpt-cf-oagw-featstatus-resource-model-and-store-implemented` + + +- [ ] `p2` - `cpt-cf-oagw-feature-resource-model-and-store` + +## 1. Feature Context + +### 1.1 Overview + +This feature defines the `Upstream`, `Route`, and `Plugin` domain types, their request/response +shapes, schema-level validation, alias derivation, and the tenant-scoped in-memory store that +every later CRUD and proxy feature reads and writes through. + +### 1.2 Purpose + +`gears/system/oagw/oagw/src/lib.rs` is currently empty, so no domain type, validation rule, or +storage primitive exists yet. Every later feature — upstream management, route management, +plugin management, and the proxy data plane — depends on this feature for its data shapes and +its persistence layer. This feature realizes `upstream.v1.schema.json` and `route.v1.schema.json` +field for field, implements the alias derivation and immutability rules those schemas describe +only in prose, and builds the tenant-scoped store (no database is configured for `oagw`) that +enforces the invariants `DESIGN.md` §3.6 documents for a relational schema. + +**Requirements**: `cpt-cf-oagw-fr-alias-resolution`, `cpt-cf-oagw-nfr-multi-tenancy`, +`cpt-cf-oagw-nfr-input-validation` + +**Principles**: `cpt-cf-oagw-principle-tenant-scope` + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-oagw-actor-platform-operator` | Creates and replaces upstreams and routes through the management API; every write passes through this feature's validation and store logic before persistence. | +| `cpt-cf-oagw-actor-tenant-admin` | Same create/replace paths as the platform operator, scoped to their own tenant; sees 404 for any resource owned by a different tenant. | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) +- **Design**: [DESIGN.md](../DESIGN.md) — `cpt-cf-oagw-design-domain-model` (§3.1 Domain Model) +- **Schemas**: [schemas/upstream.v1.schema.json](../schemas/upstream.v1.schema.json), [schemas/route.v1.schema.json](../schemas/route.v1.schema.json) +- **ADRs**: [0001 Request Routing](../ADR/0001-request-routing.md), [0004 CORS](../ADR/0004-cors.md) +- **Decomposition**: `cpt-cf-oagw-feature-resource-model-and-store` +- **Dependencies**: `cpt-cf-oagw-feature-gear-foundation` (base router mount, shared RFC 9457 + error model, and inbound Bearer authentication gate this feature's handlers build on) + +## 2. Actor Flows (CDSL) + +**Use cases**: `cpt-cf-oagw-usecase-configure-upstream`, `cpt-cf-oagw-usecase-configure-route` + +### Create Upstream Flow + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-resource-model-create-upstream` + +**Actor**: `cpt-cf-oagw-actor-platform-operator` + +**Success Scenarios**: +- A hostname-endpoint upstream is created with its alias auto-derived from the endpoint host. +- An IP-endpoint upstream is created after the operator supplies an explicit alias. + +**Error Scenarios**: +- The request body fails schema-level validation (missing required field, bad enum value, or an + unknown property rejected by `additionalProperties: false`). +- The endpoint set is IP-based, or otherwise non-derivable, and no explicit alias is supplied. +- The operator supplies an alias that differs from the value hostname derivation would produce. +- A second upstream in the same tenant is created with an alias that already exists. + +**Steps**: +1. [ ] - `p1` - Operator submits `server.endpoints`, `protocol`, and optionally `alias`, `auth`, `headers`, `rate_limit`, `cors`, `plugins`, and `tags` - `inst-create-up-1` +2. [ ] - `p1` - API: POST /oagw/v1/upstreams (body validated against `upstream.v1.schema.json`) - `inst-create-up-2` +3. [ ] - `p1` - Process: run `cpt-cf-oagw-algo-resource-model-upstream-validate` against the request body - `inst-create-up-3` +4. [ ] - `p1` - **IF** schema validation fails - `inst-create-up-4` + 1. [ ] - `p1` - **RETURN** 400 ValidationError with the failing field paths - `inst-create-up-4a` +5. [ ] - `p1` - **ELSE** - `inst-create-up-5` + 1. [ ] - `p1` - Process: run `cpt-cf-oagw-algo-resource-model-alias-derivation` against `server.endpoints` and the supplied `alias` (if any) - `inst-create-up-5a` +6. [ ] - `p1` - **IF** alias derivation rejects the request (missing explicit alias for a non-derivable endpoint set, or a supplied alias that differs from the derived value) - `inst-create-up-6` + 1. [ ] - `p1` - **RETURN** 400 ValidationError naming the violated alias rule - `inst-create-up-6a` +7. [ ] - `p1` - **ELSE** - `inst-create-up-7` + 1. [ ] - `p1` - Store: run `cpt-cf-oagw-algo-resource-model-store-write-invariants` to check `(tenant_id, alias)` uniqueness within the calling tenant's upstream partition - `inst-create-up-7a` +8. [ ] - `p1` - **IF** an upstream with the same `(tenant_id, alias)` already exists - `inst-create-up-8` + 1. [ ] - `p1` - **RETURN** 409 Conflict - `inst-create-up-8a` +9. [ ] - `p1` - **ELSE** - `inst-create-up-9` + 1. [ ] - `p1` - Store: assign `id = gts.cf.core.oagw.upstream.v1~{new uuid}`, set `tenant_id` from the authenticated principal, insert the `Upstream` record with `enabled` defaulted to `true` - `inst-create-up-9a` + 2. [ ] - `p1` - **RETURN** 201 Created with the persisted `Upstream`, including the resolved alias - `inst-create-up-9b` + +### Create Route Flow + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-resource-model-create-route` + +**Actor**: `cpt-cf-oagw-actor-platform-operator` + +**Success Scenarios**: +- A route is created referencing a tenant-owned upstream, with a match pattern that does not + collide with any enabled route already registered under that upstream. + +**Error Scenarios**: +- The request body fails schema-level validation, including a `match` block naming neither + `http` nor `grpc`, or naming both. +- `upstream_id` does not resolve to an upstream owned by the calling tenant. +- The new route's method/path pair collides with an enabled route already under the upstream. + +**Steps**: +1. [ ] - `p1` - Operator submits `upstream_id` and a `match` block (`http` or `grpc`), optionally `plugins`, `rate_limit`, and `tags` - `inst-create-rt-1` +2. [ ] - `p1` - API: POST /oagw/v1/routes (body validated against `route.v1.schema.json`) - `inst-create-rt-2` +3. [ ] - `p1` - Process: run `cpt-cf-oagw-algo-resource-model-route-validate` against the request body - `inst-create-rt-3` +4. [ ] - `p1` - **IF** schema validation fails - `inst-create-rt-4` + 1. [ ] - `p1` - **RETURN** 400 ValidationError with the failing field paths - `inst-create-rt-4a` +5. [ ] - `p1` - **ELSE** - `inst-create-rt-5` + 1. [ ] - `p1` - Store: look up `upstream_id` within the calling tenant's upstream partition - `inst-create-rt-5a` +6. [ ] - `p1` - **IF** no tenant-owned upstream matches `upstream_id` - `inst-create-rt-6` + 1. [ ] - `p1` - **RETURN** 400 ValidationError ("upstream not found") - `inst-create-rt-6a` +7. [ ] - `p1` - **ELSE** - `inst-create-rt-7` + 1. [ ] - `p1` - Store: run `cpt-cf-oagw-algo-resource-model-store-write-invariants` to check route match determinism against the target upstream's existing enabled routes - `inst-create-rt-7a` +8. [ ] - `p1` - **IF** an enabled route already shares `(method, path)` under the same upstream - `inst-create-rt-8` + 1. [ ] - `p1` - **RETURN** 409 Conflict - `inst-create-rt-8a` +9. [ ] - `p1` - **ELSE** - `inst-create-rt-9` + 1. [ ] - `p1` - Store: assign `id = gts.cf.core.oagw.route.v1~{new uuid}`, set `tenant_id` from the authenticated principal, insert the `Route` record with `enabled` defaulted to `true` - `inst-create-rt-9a` + 2. [ ] - `p1` - **RETURN** 201 Created with the persisted `Route` - `inst-create-rt-9b` + +### Update Upstream Endpoints Flow + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-resource-model-update-upstream` + +**Actor**: `cpt-cf-oagw-actor-platform-operator` + +**Success Scenarios**: +- A full replacement of a tenant-owned upstream succeeds because the endpoint change does not + alter the alias that was already stored. + +**Error Scenarios**: +- The replacement body carries `id` or `tenant_id` (both immutable and rejected if present and + differing from the stored values). +- The endpoint change would alter the derived alias, or supplies a differing alias for a + non-derivable endpoint set; both are rejected per the alias-update transition rules. + +**Steps**: +1. [ ] - `p1` - Operator submits a full replacement body for an existing upstream, omitting `id` and `tenant_id` - `inst-update-up-1` +2. [ ] - `p1` - API: PUT /oagw/v1/upstreams/{id} - `inst-update-up-2` +3. [ ] - `p1` - Store: load the existing `Upstream` by `(tenant_id, id)` - `inst-update-up-3` +4. [ ] - `p1` - **IF** no tenant-owned upstream matches `id` - `inst-update-up-4` + 1. [ ] - `p1` - **RETURN** 404 Not Found - `inst-update-up-4a` +5. [ ] - `p1` - **ELSE** - `inst-update-up-5` + 1. [ ] - `p1` - Process: run `cpt-cf-oagw-algo-resource-model-upstream-validate` against the new body - `inst-update-up-5a` +6. [ ] - `p1` - **IF** schema validation fails - `inst-update-up-6` + 1. [ ] - `p1` - **RETURN** 400 ValidationError - `inst-update-up-6a` +7. [ ] - `p1` - **ELSE** - `inst-update-up-7` + 1. [ ] - `p1` - Process: run `cpt-cf-oagw-algo-resource-model-alias-update-transition` with the stored endpoints/alias and the new endpoints/alias - `inst-update-up-7a` +8. [ ] - `p1` - **IF** the transition check rejects the change - `inst-update-up-8` + 1. [ ] - `p1` - **RETURN** 400 ValidationError instructing the operator to delete and re-create the upstream instead - `inst-update-up-8a` +9. [ ] - `p1` - **ELSE** - `inst-update-up-9` + 1. [ ] - `p1` - Store: replace every field of the `Upstream` record, keeping `id`, `tenant_id`, and `alias` unchanged - `inst-update-up-9a` + 2. [ ] - `p1` - **RETURN** 200 OK with the replaced `Upstream` - `inst-update-up-9b` + +## 3. Processes / Business Logic (CDSL) + +### Upstream Schema Validation + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-resource-model-upstream-validate` + +**Input**: Raw create/update request body for an `Upstream` + +**Output**: A validated `Upstream` DTO, or a list of field-level validation errors + +**Steps**: +1. [ ] - `p1` - Reject any top-level key not in `{id, enabled, alias, tags, server, protocol, auth, headers, plugins, rate_limit, cors}` (`additionalProperties: false`) - `inst-upval-1` +2. [ ] - `p1` - Verify the required keys `server` and `protocol` are present - `inst-upval-2` +3. [ ] - `p1` - **IF** `alias` is present - `inst-upval-3` + 1. [ ] - `p1` - Verify it matches `^[a-z0-9]([a-z0-9.:-]*[a-z0-9])?$` - `inst-upval-3a` +4. [ ] - `p1` - **FOR EACH** entry in `tags` - `inst-upval-4` + 1. [ ] - `p1` - Verify it matches `^[a-z0-9_-]+$` - `inst-upval-4a` +5. [ ] - `p1` - Verify `server.endpoints` has at least one item and `server` carries no key beyond `endpoints` - `inst-upval-5` +6. [ ] - `p1` - **FOR EACH** endpoint in `server.endpoints` - `inst-upval-6` + 1. [ ] - `p1` - Verify `scheme` is one of `"https"`, `"wss"`, `"wt"`, `"grpc"` (the supplied schema's four values), plus `"http"` (added as a fifth accepted value by Override 2), plus `"ws"` (tolerated separately as the plaintext counterpart of `"wss"`); reject any other value - `inst-upval-6a` + 2. [ ] - `p1` - Verify `host` matches the hostname, IPv4, or IPv6 format; reject any other string shape - `inst-upval-6b` + 3. [ ] - `p1` - Verify `port` (default `443` if omitted) is an integer between `1` and `65535` - `inst-upval-6c` + 4. [ ] - `p1` - Reject any endpoint key beyond `scheme`, `host`, `port` (`additionalProperties: false`) - `inst-upval-6d` +7. [ ] - `p1` - Verify `protocol` equals `"gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"` or `"gts.cf.core.oagw.protocol.v1~cf.core.oagw.grpc.v1"` - `inst-upval-7` +8. [ ] - `p1` - **IF** `auth` is present and `auth.sharing` is present - `inst-upval-8` + 1. [ ] - `p1` - Verify it is one of `"private"`, `"inherit"`, `"enforce"` (default `"private"`) - `inst-upval-8a` +9. [ ] - `p1` - **IF** `headers` is present - `inst-upval-9` + 1. [ ] - `p1` - Reject any key in `headers`, `headers.request`, or `headers.response` beyond the declared property set at each level (`additionalProperties: false` at every nesting level) - `inst-upval-9a` + 2. [ ] - `p1` - **IF** `headers.request.passthrough` is present, verify it is one of `"none"`, `"allowlist"`, `"all"` (default `"none"`) - `inst-upval-9b` +10. [ ] - `p1` - **IF** `plugins` is present - `inst-upval-10` + 1. [ ] - `p1` - **IF** `plugins.sharing` is present, verify it is one of `"private"`, `"inherit"`, `"enforce"` (default `"private"`) - `inst-upval-10a` + 2. [ ] - `p1` - **IF** `plugins.items` is present, verify every entry is either a `gts-identifier` string or a plain UUID string - `inst-upval-10b` +11. [ ] - `p1` - **IF** `rate_limit` is present - `inst-upval-11` + 1. [ ] - `p1` - Reject any key in `rate_limit` beyond `sharing`, `algorithm`, `sustained`, `burst`, `scope`, `strategy`, `cost` (`additionalProperties: false`) - `inst-upval-11a` + 2. [ ] - `p1` - **IF** `sharing` is present, verify it is one of `"private"`, `"inherit"`, `"enforce"` (default `"private"`) - `inst-upval-11b` + 3. [ ] - `p1` - Verify `sustained` is present with `sustained.rate >= 1`; verify `sustained.window` (default `"second"`) is one of `"second"`, `"minute"`, `"hour"`, `"day"` - `inst-upval-11c` + 4. [ ] - `p1` - Verify `algorithm` (default `"token_bucket"`) is one of `"token_bucket"`, `"sliding_window"`; `scope` (default `"tenant"`) is one of `"global"`, `"tenant"`, `"user"`, `"ip"`, `"route"`; `strategy` (default `"reject"`) is one of `"reject"`, `"queue"`, `"degrade"`; `cost` (default `1`) is an integer `>= 1` - `inst-upval-11d` + 5. [ ] - `p1` - **IF** `burst` is present, verify `burst.capacity` is an integer `>= 1`; it defaults to `sustained.rate` when the field is omitted - `inst-upval-11e` +12. [ ] - `p1` - **IF** `cors` is present - `inst-upval-12` + 1. [ ] - `p1` - Verify `enabled` is present (required inside `cors`) - `inst-upval-12a` + 2. [ ] - `p1` - **IF** `sharing` is present, verify it is one of `"private"`, `"inherit"`, `"enforce"` (default `"private"`) - `inst-upval-12b` + 3. [ ] - `p1` - **IF** `allow_credentials` is `true` - `inst-upval-12c` + 1. [ ] - `p1` - Verify `allowed_origins` does not contain the literal `"*"` - `inst-upval-12c-i` +13. [ ] - `p1` - **RETURN** the validated `Upstream` DTO, or the accumulated field errors - `inst-upval-13` + +### Route Schema Validation + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-resource-model-route-validate` + +**Input**: Raw create/update request body for a `Route` + +**Output**: A validated `Route` DTO, or a list of field-level validation errors + +**Steps**: +1. [ ] - `p1` - Verify the required keys `upstream_id` and `match` are present - `inst-rtval-1` +2. [ ] - `p1` - Reject any key inside `match` beyond `http` and `grpc` (`additionalProperties: false`) - `inst-rtval-2` +3. [ ] - `p1` - **IF** `match` contains neither `http` nor `grpc`, or contains both - `inst-rtval-3` + 1. [ ] - `p1` - **RETURN** 400 ValidationError ("match must contain exactly one of http or grpc", the schema's `oneOf` constraint) - `inst-rtval-3a` +4. [ ] - `p1` - **IF** `match.http` is present - `inst-rtval-4` + 1. [ ] - `p1` - Verify `methods` is a non-empty array whose entries are each one of `"GET"`, `"POST"`, `"PUT"`, `"DELETE"`, `"PATCH"` - `inst-rtval-4a` + 2. [ ] - `p1` - Verify `path` has length `>= 1` - `inst-rtval-4b` + 3. [ ] - `p1` - **IF** `query_allowlist` is present, verify it is an array of strings (default `[]`, meaning no query parameters are forwarded) - `inst-rtval-4c` + 4. [ ] - `p1` - **IF** `path_suffix_mode` is present, verify it is `"disabled"` or `"append"` (default `"append"`) - `inst-rtval-4d` + 5. [ ] - `p1` - Reject any key in `match.http` beyond `methods`, `path`, `query_allowlist`, `path_suffix_mode` - `inst-rtval-4e` +5. [ ] - `p1` - **IF** `match.grpc` is present - `inst-rtval-5` + 1. [ ] - `p1` - Verify `service` and `method` each have length `>= 1`; reject any other key in `match.grpc` - `inst-rtval-5a` + 2. [ ] - `p1` - Note: this shape validates and round-trips through the store; no gRPC dispatch code path exists in this build (Scope Reality) - `inst-rtval-5b` +6. [ ] - `p1` - **FOR EACH** entry in `tags` - `inst-rtval-6` + 1. [ ] - `p1` - Verify it matches `^[a-z0-9_-]+$` - `inst-rtval-6a` +7. [ ] - `p1` - **IF** `plugins.items` is present - `inst-rtval-7` + 1. [ ] - `p1` - Verify every entry is a string in the `gts-identifier` format; unlike `Upstream.plugins.items`, a bare UUID is not an accepted alternative here - `inst-rtval-7a` +8. [ ] - `p1` - **IF** `rate_limit` is present, apply the same validation as `cpt-cf-oagw-algo-resource-model-upstream-validate` step `inst-upval-11`, including its `additionalProperties: false` check and `burst.capacity` rule (the two schemas define an identical `rate_limit` shape, so this route path inherits the fix at its source) - `inst-rtval-8` +9. [ ] - `p1` - **RETURN** the validated `Route` DTO, or the accumulated field errors - `inst-rtval-9` + +### Alias Derivation + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-resource-model-alias-derivation` + +**Input**: A validated `server.endpoints` list and an optional user-supplied `alias` string + +**Output**: An accepted, normalized alias string, or a validation rejection naming the violated +rule + +**Steps**: +1. [ ] - `p1` - **FOR EACH** endpoint, classify its `host` as hostname, IPv4, or IPv6 - `inst-aliasderive-1` +2. [ ] - `p1` - **IF** every endpoint's host is an IP address, or the endpoint set mixes IP and hostname hosts - `inst-aliasderive-2` + 1. [ ] - `p1` - Mark the endpoint set non-derivable - `inst-aliasderive-2a` +3. [ ] - `p1` - **ELSE IF** the endpoint set has exactly one distinct hostname - `inst-aliasderive-3` + 1. [ ] - `p1` - Set `derived = hostname`, appending `:port` unless `port` equals the standard port for the endpoint's `scheme` (`80` for `http`/`ws`; `443` for `https`/`wss`/`wt`/`grpc`) - `inst-aliasderive-3a` +4. [ ] - `p1` - **ELSE** (the endpoint set has more than one distinct hostname) - `inst-aliasderive-4` + 1. [ ] - `p1` - Compute the longest common domain suffix shared by every hostname - `inst-aliasderive-4a` + 2. [ ] - `p1` - Validate the computed suffix against the public suffix list (PSL, the registry of domain suffixes that are not themselves individually registrable, for example `co.uk`) - `inst-aliasderive-4b` + 3. [ ] - `p1` - **IF** no common suffix exists, the suffix has fewer than two labels, or the suffix is itself a bare PSL entry - `inst-aliasderive-4c` + 1. [ ] - `p1` - Mark the endpoint set non-derivable - `inst-aliasderive-4c-i` + 4. [ ] - `p1` - **ELSE** - `inst-aliasderive-4d` + 1. [ ] - `p1` - Set `derived = suffix`, appending `:port` unless every endpoint shares the same port and that port is the scheme's standard port - `inst-aliasderive-4d-i` +5. [ ] - `p1` - **IF** the endpoint set is non-derivable - `inst-aliasderive-5` + 1. [ ] - `p1` - **IF** no `alias` was supplied - `inst-aliasderive-5a` + 1. [ ] - `p1` - **RETURN** rejection ("explicit alias required for IP-based or non-derivable endpoints") - `inst-aliasderive-5a-i` + 2. [ ] - `p1` - **ELSE** - `inst-aliasderive-5b` + 1. [ ] - `p1` - **RETURN** the supplied `alias`, normalized (ASCII-lowercased, trailing dot stripped) - `inst-aliasderive-5b-i` +6. [ ] - `p1` - **ELSE** (a `derived` value exists) - `inst-aliasderive-6` + 1. [ ] - `p1` - **IF** an `alias` was supplied - `inst-aliasderive-6a` + 1. [ ] - `p1` - Normalize both `derived` and the supplied `alias` (ASCII-lowercase, trailing dot stripped) - `inst-aliasderive-6a-i` + 2. [ ] - `p1` - **IF** they differ - `inst-aliasderive-6a-ii` + 1. [ ] - `p1` - **RETURN** rejection ("user-provided alias must match the derived value") - `inst-aliasderive-6a-ii-a` + 3. [ ] - `p1` - **ELSE** - `inst-aliasderive-6a-iii` + 1. [ ] - `p1` - **RETURN** the normalized `derived` value (accepted as an idempotent no-op) - `inst-aliasderive-6a-iii-a` + 2. [ ] - `p1` - **ELSE** - `inst-aliasderive-6b` + 1. [ ] - `p1` - **RETURN** the normalized `derived` value - `inst-aliasderive-6b-i` + +### Alias Update Transition Check + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-resource-model-alias-update-transition` + +**Input**: The stored `Upstream`'s current `server.endpoints` and `alias`, plus the proposed new +`server.endpoints` and optional new `alias` from a replacement request + +**Output**: Accept (the alias always stays equal to the stored value) or reject with a message +directing the operator to delete and re-create + +**Steps**: +1. [ ] - `p1` - Run `cpt-cf-oagw-algo-resource-model-alias-derivation`'s classification step against the stored endpoints to get `existing_derived` (a value, or non-derivable) - `inst-aliastrans-1` +2. [ ] - `p1` - Run the same classification against the proposed endpoints to get `new_derived` - `inst-aliastrans-2` +3. [ ] - `p1` - **IF** the proposed endpoint set is identical to the stored endpoint set (no endpoint change at all) - `inst-aliastrans-3` + 1. [ ] - `p1` - **IF** the proposed `alias` is omitted, or equals the stored `alias` after normalization - `inst-aliastrans-3a` + 1. [ ] - `p1` - **RETURN** accept, alias unchanged (exact-match tolerated as a no-op) - `inst-aliastrans-3a-i` + 2. [ ] - `p1` - **ELSE** - `inst-aliastrans-3b` + 1. [ ] - `p1` - **RETURN** reject ("alias override not allowed when endpoints are unchanged") - `inst-aliastrans-3b-i` +4. [ ] - `p1` - **ELSE IF** `existing_derived` and `new_derived` both exist (Derivable → Derivable) - `inst-aliastrans-4` + 1. [ ] - `p1` - **IF** `new_derived` equals the stored `alias` - `inst-aliastrans-4a` + 1. [ ] - `p1` - **RETURN** accept, alias unchanged (recomputed alias equals the existing one) - `inst-aliastrans-4a-i` + 2. [ ] - `p1` - **ELSE** - `inst-aliastrans-4b` + 1. [ ] - `p1` - **RETURN** reject ("endpoint change would alter the derived alias; delete and re-create") - `inst-aliastrans-4b-i` +5. [ ] - `p1` - **ELSE IF** `existing_derived` exists and `new_derived` does not (Derivable → Non-derivable, hostname → IP) - `inst-aliastrans-5` + 1. [ ] - `p1` - **RETURN** reject always, even when the request supplies an explicit `alias` - `inst-aliastrans-5a` +6. [ ] - `p1` - **ELSE IF** neither `existing_derived` nor `new_derived` exists (Non-derivable → Non-derivable, IP → IP) - `inst-aliastrans-6` + 1. [ ] - `p1` - **IF** the proposed `alias` is omitted, or equals the stored `alias` after normalization - `inst-aliastrans-6a` + 1. [ ] - `p1` - **RETURN** accept, existing alias retained - `inst-aliastrans-6a-i` + 2. [ ] - `p1` - **ELSE** - `inst-aliastrans-6b` + 1. [ ] - `p1` - **RETURN** reject ("a differing user-provided alias is not accepted") - `inst-aliastrans-6b-i` +7. [ ] - `p1` - **ELSE** (`existing_derived` does not exist, `new_derived` does — Non-derivable → Derivable, IP → hostname) - `inst-aliastrans-7` + 1. [ ] - `p1` - **IF** `new_derived` equals the stored `alias` - `inst-aliastrans-7a` + 1. [ ] - `p1` - **RETURN** accept, alias unchanged (derived alias coincidentally equals the existing one) - `inst-aliastrans-7a-i` + 2. [ ] - `p1` - **ELSE** - `inst-aliastrans-7b` + 1. [ ] - `p1` - **RETURN** reject ("delete and re-create") - `inst-aliastrans-7b-i` + +### Tenant-Scoped Store Write Invariants + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-resource-model-store-write-invariants` + +**Input**: A validated `Upstream` or `Route` DTO, the calling tenant's `tenant_id`, and the +operation kind (create or replace) + +**Output**: Accept (persist, with `id`/`tenant_id` assigned or preserved) or a conflict/validation +rejection + +**Steps**: +1. [ ] - `p1` - **IF** the entity is an `Upstream` - `inst-storeinv-1` + 1. [ ] - `p1` - Store: scan the tenant's upstream partition for a record sharing `(tenant_id, alias)`, excluding the record being replaced - `inst-storeinv-1a` + 2. [ ] - `p1` - **IF** a match is found - `inst-storeinv-1b` + 1. [ ] - `p1` - **RETURN** 409 Conflict - `inst-storeinv-1b-i` +2. [ ] - `p1` - **IF** the entity is a `Route` **AND** its `match` carries `http` - `inst-storeinv-2` + 1. [ ] - `p1` - Store: for each method in the new route's `match.http.methods`, scan the target upstream's enabled routes for one sharing that `method` and the same `match.http.path`, excluding the route being replaced - `inst-storeinv-2a` + 2. [ ] - `p1` - **IF** any overlapping `(method, path)` pair is found among enabled routes - `inst-storeinv-2b` + 1. [ ] - `p1` - **RETURN** 409 Conflict ("route match is ambiguous with an existing enabled route") - `inst-storeinv-2b-i` +3. [ ] - `p1` - **ELSE IF** the entity is a `Route` **AND** its `match` carries `grpc` - `inst-storeinv-3` + 1. [ ] - `p1` - Store: run no uniqueness scan for this route. Two enabled routes sharing one `(service, method)` pair under the same upstream are both accepted in this build, because no gRPC dispatch path ever reads a route's match to observe the collision (Scope Reality) - `inst-storeinv-3a` +4. [ ] - `p1` - **IF** the entity carries `plugins.items` - `inst-storeinv-4` + 1. [ ] - `p1` - Store: verify the binding positions to be written form a contiguous sequence starting at `0` - `inst-storeinv-4a` + 2. [ ] - `p1` - **IF** the positions are not contiguous from `0` - `inst-storeinv-4b` + 1. [ ] - `p1` - **RETURN** 400 ValidationError ("plugin binding positions must be contiguous from 0") - `inst-storeinv-4b-i` +5. [ ] - `p1` - **IF** the operation is a create - `inst-storeinv-5` + 1. [ ] - `p1` - Store: assign `id = gts.cf.core.oagw.{upstream|route}.v1~{new uuid}` and `tenant_id` from the calling tenant - `inst-storeinv-5a` +6. [ ] - `p1` - **ELSE** (replace) - `inst-storeinv-6` + 1. [ ] - `p1` - Store: keep the existing `id` and `tenant_id`; reject the write if the request body supplied a different value for either - `inst-storeinv-6a` +7. [ ] - `p1` - **RETURN** accept - `inst-storeinv-7` + +### Tenant Hierarchy Walk + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-resource-model-tenant-hierarchy-walk` + +**Input**: A starting `tenant_id` and a caller-supplied lookup function (for example, "find an +enabled upstream by alias" or "find enabled routes for an upstream") + +**Output**: The first match found while walking from the starting tenant toward the root tenant, +paired with the tenant that produced it, or no match + +**Steps**: +1. [ ] - `p1` - Set `current_tenant = tenant_id` - `inst-tenwalk-1` +2. [ ] - `p1` - **WHILE** `current_tenant` is defined - `inst-tenwalk-2` + 1. [ ] - `p1` - Apply the lookup function against `current_tenant`'s partition of the store - `inst-tenwalk-2a` + 2. [ ] - `p1` - **IF** the lookup function returns a match - `inst-tenwalk-2b` + 1. [ ] - `p1` - **RETURN** the match paired with `current_tenant` (the closest match wins; a descendant's own resource shadows an ancestor's) - `inst-tenwalk-2b-i` + 3. [ ] - `p1` - **ELSE** - `inst-tenwalk-2c` + 1. [ ] - `p1` - Set `current_tenant` to the parent tenant of `current_tenant` in the tenant hierarchy - `inst-tenwalk-2c-i` +3. [ ] - `p1` - **RETURN** no match (the walk reached the root tenant with nothing found) - `inst-tenwalk-3` + +## 4. States (CDSL) + +### Upstream and Route Enabled-State Lifecycle + +- [ ] `p2` - **ID**: `cpt-cf-oagw-state-resource-model-enabled-lifecycle` + +**States**: Enabled, Disabled + +**Initial State**: Enabled + +**Transitions**: +1. [ ] - `p1` - **FROM** Enabled **TO** Disabled **WHEN** an operator replaces the resource with `enabled: false` - `inst-lifecycle-1` +2. [ ] - `p1` - **FROM** Disabled **TO** Enabled **WHEN** an operator replaces the resource with `enabled: true` and no ancestor tenant's upstream sharing the same alias is itself `Disabled` - `inst-lifecycle-2` +3. [ ] - `p1` - **FROM** Disabled **TO** Disabled **WHEN** an operator attempts to set `enabled: true` while an ancestor tenant's upstream sharing the same alias is `Disabled`; the stored field is not changed, and the resource's effective state stays `Disabled` for every descendant - `inst-lifecycle-3` + +## 5. Definitions of Done + +### Domain Types Matching the Supplied Schemas + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-resource-model-domain-types` + +The system **MUST** define `Upstream`, `Route`, and `Plugin` domain types, together with the +nested `ServerConfig`/`Endpoint`, `AuthConfig`, `HeadersConfig`, `RateLimitConfig`, +`CorsConfig`, and `PluginsConfig` shapes, whose fields match `upstream.v1.schema.json` and +`route.v1.schema.json` as enumerated in §7. Where DESIGN.md's domain model and the supplied +schema disagree (`tenant_id` on both entities, `Route.enabled`, `Route.cors`, and +`Route.priority`), the reconciliation documented in §7.4 governs, and no field is invented +beyond what §7 enumerates. + +**Implements**: +- `cpt-cf-oagw-flow-resource-model-create-upstream` +- `cpt-cf-oagw-flow-resource-model-create-route` + +**Touches**: +- API (forward reference; this feature owns no endpoints — realized by + `cpt-cf-oagw-feature-upstream-management-api` and `cpt-cf-oagw-feature-route-management-api`): + `POST /oagw/v1/upstreams`, `POST /oagw/v1/routes` +- Entities: `Upstream`, `Route`, `Plugin`, `ServerConfig`, `Endpoint` + +### Schema-Level Validation Rejects Malformed Requests + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-resource-model-schema-validation` + +The system **MUST** enforce every required-field, enum, pattern, range, and +`additionalProperties: false` rule the two schemas declare, plus the `oneOf` constraint on +`Route.match`. It **MUST** also accept two values beyond the schema's own `"https"`, `"wss"`, +`"wt"`, `"grpc"`: `scheme: "http"`, added as a fifth accepted value by Override 2, and +`scheme: "ws"`, tolerated separately as the plaintext counterpart of `"wss"`. Acceptance of a +scheme value at create time is independent of whether OAGW later opens a plaintext connection, +which `allow_http_upstream` governs elsewhere. + +**Implements**: +- `cpt-cf-oagw-algo-resource-model-upstream-validate` +- `cpt-cf-oagw-algo-resource-model-route-validate` + +**Constraints**: `cpt-cf-oagw-constraint-https-only` + +**Touches**: +- API (forward reference; this feature owns no endpoints — realized by + `cpt-cf-oagw-feature-upstream-management-api` and `cpt-cf-oagw-feature-route-management-api`): + `POST /oagw/v1/upstreams`, `PUT /oagw/v1/upstreams/{id}`, `POST /oagw/v1/routes`, `PUT /oagw/v1/routes/{id}` +- Entities: `Upstream`, `Route`, `Endpoint`, `AuthConfig`, `HeadersConfig`, `RateLimitConfig`, `CorsConfig`, `PluginsConfig` + +### Alias Derivation and Normalization + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-resource-model-alias-derivation` + +The system **MUST** derive an upstream's alias from hostname-based endpoints (single hostname, +or a PSL-validated common registrable suffix across multiple hostnames), **MUST** require an +explicit alias for IP-based or otherwise non-derivable endpoint sets, and **MUST** normalize +every alias to ASCII lowercase with a trailing dot stripped before storing or comparing it. + +**Implements**: +- `cpt-cf-oagw-algo-resource-model-alias-derivation` +- `cpt-cf-oagw-flow-resource-model-create-upstream` + +**Touches**: +- API (forward reference; this feature owns no endpoints — realized by + `cpt-cf-oagw-feature-upstream-management-api`): `POST /oagw/v1/upstreams` +- Entities: `Upstream`, `Endpoint` + +### Alias Immutability Across Updates + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-resource-model-alias-immutability` + +The system **MUST** enforce the alias-update transition table (DESIGN.md:426-432): any endpoint +replacement that would change the stored alias is rejected regardless of whether the caller +supplies a matching or differing alias, and only an exact-match alias (recomputed or +user-supplied) is ever accepted on a replace. + +**Implements**: +- `cpt-cf-oagw-algo-resource-model-alias-update-transition` +- `cpt-cf-oagw-flow-resource-model-update-upstream` + +**Touches**: +- API (forward reference; this feature owns no endpoints — realized by + `cpt-cf-oagw-feature-upstream-management-api`): `PUT /oagw/v1/upstreams/{id}` +- Entities: `Upstream` + +### Upstream Alias Uniqueness in the Tenant-Scoped Store + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-resource-model-upstream-alias-uniqueness` + +The system **MUST** provide a tenant-scoped in-memory store, since no database is configured +for `oagw`, that rejects a second upstream sharing `(tenant_id, alias)` within one tenant. This +realizes the uniqueness invariant `cpt-cf-oagw-db-schema` documents as a relational schema, +with no SQL migration involved. + +**Implements**: +- `cpt-cf-oagw-algo-resource-model-store-write-invariants` +- `cpt-cf-oagw-flow-resource-model-create-upstream` + +**Constraints**: `cpt-cf-oagw-constraint-multi-sql` + +**Touches**: +- Store: `cpt-cf-oagw-db-schema` (realized as an in-memory, tenant-partitioned store) +- API (forward reference; this feature owns no endpoints — realized by + `cpt-cf-oagw-feature-upstream-management-api`): `POST /oagw/v1/upstreams` +- Entities: `Upstream` + +### Route Match Determinism in the Tenant-Scoped Store + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-resource-model-route-match-determinism` + +The system **MUST** reject a new or replaced route whose `(method, path)` pair matches an +already-enabled route under the same upstream. A disabled route sharing that same pair does +not block the write, realizing the invariant `cpt-cf-oagw-db-schema` documents as a relational +schema, with no SQL migration involved. + +**Implements**: +- `cpt-cf-oagw-algo-resource-model-store-write-invariants` +- `cpt-cf-oagw-flow-resource-model-create-route` + +**Constraints**: `cpt-cf-oagw-constraint-multi-sql` + +**Touches**: +- Store: `cpt-cf-oagw-db-schema` (realized as an in-memory, tenant-partitioned store) +- API (forward reference; this feature owns no endpoints — realized by + `cpt-cf-oagw-feature-route-management-api`): `POST /oagw/v1/routes` +- Entities: `Route` + +### Contiguous Plugin Binding Positions in the Tenant-Scoped Store + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-resource-model-plugin-binding-positions` + +The system **MUST** reject a plugin-binding write whose positions are not contiguous from +zero, for example `0` and `2` with `1` skipped, so every stored plugin chain stays a dense, +ordered sequence. + +**Implements**: +- `cpt-cf-oagw-algo-resource-model-store-write-invariants` + +**Touches**: +- Entities: `Plugin` + +### Anonymous GTS Resource Identifier Generation + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-resource-model-gts-identifiers` + +The system **MUST** generate every resource ID using the anonymous GTS pattern +`gts.cf.core.oagw.{type}.v1~{uuid}`, where `{type}` is `upstream`, `route`, or the plugin's +`{kind}_plugin` value for `Plugin`. + +**Implements**: +- `cpt-cf-oagw-algo-resource-model-store-write-invariants` +- `cpt-cf-oagw-flow-resource-model-create-upstream` +- `cpt-cf-oagw-flow-resource-model-create-route` + +**Touches**: +- Entities: `Upstream`, `Route`, `Plugin` + +### Tenant Hierarchy Walk Primitive + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-resource-model-tenant-hierarchy-walk` + +The system **MUST** provide a reusable descendant-to-root tenant hierarchy walk primitive that +a caller-supplied lookup function drives, so alias resolution and route matching in later +features share one walk implementation. + +**Implements**: +- `cpt-cf-oagw-algo-resource-model-tenant-hierarchy-walk` + +**Touches**: +- Entities: `Upstream`, `Route` + +### Enabled/Disabled Lifecycle Enforcement + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-resource-model-enabled-lifecycle` + +The system **MUST** enforce the enabled/disabled lifecycle described in §4, including the rule +that a descendant tenant cannot re-enable a resource an ancestor tenant has disabled. + +**Implements**: +- `cpt-cf-oagw-state-resource-model-enabled-lifecycle` + +**Touches**: +- Entities: `Upstream`, `Route` + +## 6. Acceptance Criteria + +- [ ] An upstream created with a single hostname endpoint on the scheme's standard port derives + its alias automatically as that hostname, with no port suffix. +- [ ] An upstream created with only IP-address endpoints and no explicit `alias` is rejected + with 400 ValidationError. +- [ ] An upstream created with hostname endpoints and a user-supplied `alias` that differs from + the derivable value is rejected with 400 ValidationError. +- [ ] An upstream endpoint declared with `scheme: "http"` and `port: 80` is accepted at + create time, independent of whether `allow_http_upstream` later permits an actual plaintext + connection. +- [ ] A second upstream created in the same tenant with an `alias` identical to an existing + upstream's `alias` is rejected with 409 Conflict. +- [ ] A route whose `match` block names both `http` and `grpc`, or neither, is rejected with + 400 ValidationError (the schema's `oneOf` constraint). +- [ ] A route created with `match.grpc` validates and round-trips (a subsequent read returns the + same `service` and `method`), even though no gRPC proxy dispatch path exists in this build. +- [ ] Two enabled routes under the same upstream sharing the same method and path are rejected + with 409 Conflict; a disabled route sharing the same pair does not conflict. +- [ ] Plugin bindings written with non-contiguous positions (for example, `0` and `2`, skipping + `1`) are rejected with 400 ValidationError. +- [ ] Resolving an alias supplied in mixed case (for example, `Api.OpenAI.COM`) returns the same + upstream as the stored, normalized lowercase alias. +- [ ] The tenant hierarchy walk returns a descendant tenant's upstream in preference to an + ancestor tenant's upstream that shares the same alias. + +## 7. Additional Context (optional) + +### 7.1 Upstream Field Reference + +| Field | Type / Enum | Required | Notes | +|---|---|---|---| +| `id` | UUID string | No (read-only) | Server-generated; immutable after create. | +| `enabled` | boolean, default `true` | No | Governs §4's enabled/disabled lifecycle. | +| `alias` | string, pattern `^[a-z0-9]([a-z0-9.:-]*[a-z0-9])?$` | No (derived or required per §7.4) | See §7.3 for derivation. | +| `tags` | array of string, pattern `^[a-z0-9_-]+$` | No | Add-only union across tenant hierarchy (per `cpt-cf-oagw-fr-hierarchical-config`, not built in this feature). | +| `server` | object, `additionalProperties: false`, requires `endpoints` | Yes | See `ServerConfig`/`Endpoint` below. | +| `protocol` | `"gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"` or `"gts.cf.core.oagw.protocol.v1~cf.core.oagw.grpc.v1"` | Yes | GTS-formatted enum, quoted verbatim. | +| `auth` | object: `type` (gts-identifier string), `sharing` (`"private"`\|`"inherit"`\|`"enforce"`, default `"private"`), `config` (object) | No | No `additionalProperties: false` at this level — the schema permits keys beyond `type`/`sharing`/`config`. | +| `headers` | `HeadersConfig` (see §7.3) | No | | +| `plugins` | object: `sharing` (`"private"`\|`"inherit"`\|`"enforce"`, default `"private"`), `items` (array; each entry is a `gts-identifier` string **or** a UUID string) | No | The UUID alternative distinguishes custom (Starlark) plugins from builtin ones. | +| `rate_limit` | `RateLimitConfig` (see §7.3) | No | | +| `cors` | `CorsConfig` (see §7.3) | No | | + +`ServerConfig`: object, `additionalProperties: false`, requires `endpoints` (array, `minItems: 1`). + +`Endpoint`: object, `additionalProperties: false`, requires `scheme` and `host`. +- `scheme`: schema enum is exactly `"https"`, `"wss"`, `"wt"`, `"grpc"` (default `"https"`). This + build adds `"http"` as a fifth accepted value (Override 2), a deliberate addition beyond the + supplied enum, not a value the supplied schema already contains. It separately tolerates + `"ws"` as the plaintext counterpart of `"wss"`, likewise a deliberate extension rather than + part of the supplied enum. Whether OAGW opens an actual plaintext connection for `"http"` or + `"ws"` is governed solely by the `allow_http_upstream` configuration flag; accepting the + scheme value at create time is a separate, earlier question that this feature owns. +- `host`: string matching hostname, IPv4, or IPv6 format. +- `port`: integer, `1`–`65535`, default `443`. + +### 7.2 Route Field Reference + +| Field | Type / Enum | Required | Notes | +|---|---|---|---| +| `id` | UUID string | No (read-only) | Server-generated; immutable after create. | +| `tags` | array of string, pattern `^[a-z0-9_-]+$` | No | | +| `upstream_id` | UUID string | Yes | Immutable after create; not present in the update DTO. | +| `match` | object, `additionalProperties: false`; `oneOf` requires exactly one of `http`/`grpc` | Yes | See below. | +| `plugins` | object: `sharing` (`"private"`\|`"inherit"`\|`"enforce"`, default `"private"`), `items` (array of `gts-identifier` strings, default `[]`) | No | No UUID alternative here, unlike `Upstream.plugins.items`. | +| `rate_limit` | Identical shape to `Upstream.rate_limit` (see §7.3) | No | | + +`match.http` (`additionalProperties: false`, requires `methods` and `path`): +- `methods`: array, `minItems: 1`, each entry one of `"GET"`, `"POST"`, `"PUT"`, `"DELETE"`, `"PATCH"`. +- `path`: string, `minLength: 1`. +- `query_allowlist`: array of strings, default `[]` (empty allowlist means no query parameter is forwarded). +- `path_suffix_mode`: `"disabled"` or `"append"`, default `"append"`. + +`match.grpc` (`additionalProperties: false`, requires `service` and `method`): +- `service`: string, `minLength: 1` (fully qualified gRPC service name). +- `method`: string, `minLength: 1` (RPC method name). +- This shape validates and round-trips through the store and the management API. No gRPC + proxy dispatch code path exists in this build (Scope Reality) — a request naming a route with + `match.grpc` at proxy time is out of scope for this feature and the proxy features that follow + it, not a defect of this feature's validation. + +### 7.3 Shared Nested Configuration Shapes + +`HeadersConfig` (object, `additionalProperties: false`): +- `request` (object, `additionalProperties: false`): `set` (map of string→string), `add` (map + of string→string), `remove` (array of strings), `passthrough` (`"none"`\|`"allowlist"`\|`"all"`, + default `"none"`), `passthrough_allowlist` (array of strings). +- `response` (object, `additionalProperties: false`): `set`, `add`, `remove` — same shapes as + above, with no `passthrough` field (response headers are always transformed explicitly, never + passed through wholesale). + +`RateLimitConfig` (object, `additionalProperties: false`, requires `sustained`; identical on +`Upstream` and `Route`): +- `sharing`: `"private"`\|`"inherit"`\|`"enforce"`, default `"private"`. +- `algorithm`: `"token_bucket"`\|`"sliding_window"`, default `"token_bucket"`. +- `sustained` (object, requires `rate`): `rate` (integer `>= 1`), `window` + (`"second"`\|`"minute"`\|`"hour"`\|`"day"`, default `"second"`). +- `burst` (object): `capacity` (integer `>= 1`; defaults to `sustained.rate` when the object or + the field is omitted). +- `scope`: `"global"`\|`"tenant"`\|`"user"`\|`"ip"`\|`"route"`, default `"tenant"`. +- `strategy`: `"reject"`\|`"queue"`\|`"degrade"`, default `"reject"`. +- `cost`: integer `>= 1`, default `1`. + +`CorsConfig` (object, `additionalProperties: false`, requires `enabled`; identical shape on +`Upstream` and the (unreferenced, see §7.4) `Route` definition): +- `sharing`: `"private"`\|`"inherit"`\|`"enforce"`, default `"private"`. +- `enabled`: boolean, default `false`. +- `allowed_origins`: array of strings, each either the literal `"*"` or a URI. +- `allowed_methods`: array, each one of `"GET"`, `"POST"`, `"PUT"`, `"PATCH"`, `"DELETE"`, + `"HEAD"`, `"OPTIONS"`, default `["GET", "POST"]`. +- `expose_headers`: array of strings, default `[]`. +- `allow_credentials`: boolean, default `false`. When `true`, `allowed_origins` **MUST NOT** + contain `"*"` (a conditional `if`/`then` constraint in both schemas). + +`PluginsConfig`: the `sharing`/`items` shape described per-entity in §7.1 and §7.2; the item +type differs between `Upstream` (gts-identifier or UUID) and `Route` (gts-identifier only). + +### 7.4 Schema Reconciliation Notes + +The supplied schemas are the field-for-field source of truth this feature's domain types +follow. Four places where DESIGN.md's domain model (§3.1) and the schemas diverge are resolved +as follows, so the downstream implementer does not have to guess: + +- **`tenant_id` on `Upstream` and `Route`**: neither schema declares a `tenant_id` property (and + `Upstream`'s top-level object has `additionalProperties: false`, so a client-supplied + `tenant_id` would be rejected outright). `tenant_id` is a store-managed attribute the write + path assigns from the authenticated principal's tenant context, never accepted from the + request body, and immutable once set — consistent with `cpt-cf-oagw-nfr-multi-tenancy`. +- **`Route.enabled`**: `route.v1.schema.json`'s top-level object does not declare + `additionalProperties: false` and does not list `enabled` among its properties. Because + `cpt-cf-oagw-fr-enable-disable` requires an `enabled` boolean (default `true`) on both + upstreams and routes, the `Route` domain type carries it as a first-class field; the absence + of a top-level `additionalProperties: false` restriction means the wire schema does not reject + it, even though it is not one of the schema's explicitly enumerated properties. +- **`Route.cors`**: `route.v1.schema.json` defines a `cors` shape under `definitions` (identical + to `Upstream`'s), but no property in the schema's `properties` block references it via `$ref`. + DESIGN.md's domain model and ADR 0004 both describe `Route.cors` as a real, per-route field. + This feature treats the schema's unreferenced `cors` definition as the intended shape for a + `Route.cors` field, accepted under the same absent-`additionalProperties: false` reasoning as + `Route.enabled` above, and validated identically to `Upstream.cors` (§7.3). +- **`Route.priority`**: DESIGN.md's class diagram and its route-match-determinism invariant + (§3.6) both reference a numeric `priority` field, but neither schema declares one, and no `$ref` + target exists for it. Because no FR requires a distinct priority field and the schema is + silent on it, this feature does **not** add a `priority` field. Route match determinism + (§5, `cpt-cf-oagw-dod-resource-model-route-match-determinism`) is instead enforced over + `(upstream_id, method, path)`: no two enabled routes under the same upstream may share a + method and path. `DESIGN.md`'s `(path_prefix, priority)` phrasing is a documented deviation + this feature does not carry forward. `DECOMPOSITION.md` §2.4 repeats a similar phrase, "same + path + priority + method", for the downstream route-management-api feature that depends on + this one. Read that phrase the same way, as `(path, method)`, because the schema still + exposes no client-settable `priority` field for that later feature to accept. + +Two further, smaller notes: `Route.match_type`, shown in DESIGN.md's class diagram, is not a +separate stored field — it is derived from which key (`http` or `grpc`) is present under +`match`. And `Upstream.plugins.items` accepts either a `gts-identifier` string or a bare UUID +string, while `Route.plugins.items` accepts only a `gts-identifier` string; a custom plugin +bound to a route must be expressed as its full `gts.cf.core.oagw.{type}_plugin.v1~{uuid}` +identifier, not a bare UUID. + +### 7.5 Resource Identification Pattern + +All three entity types use anonymous GTS identifiers of the form +`gts.cf.core.oagw.{type}.v1~{uuid}`: +- `Upstream`: `gts.cf.core.oagw.upstream.v1~{uuid}`. +- `Route`: `gts.cf.core.oagw.route.v1~{uuid}`. +- `Plugin`: `gts.cf.core.oagw.{kind}_plugin.v1~{uuid}`, where `{kind}` is `auth`, `guard`, or + `transform` depending on the plugin's category. + +### 7.6 Out of Scope + +- gRPC request dispatch: `protocol` and `match.grpc` validate and round-trip through the store + and the management API, but no gRPC proxy code path exists in this build (Scope Reality). +- SQL persistence: no database is configured for `oagw`; the store described in §3 and §5 is + in-memory and tenant-partitioned, not a set of `toolkit-db`/SeaORM migrations + (`cpt-cf-oagw-constraint-multi-sql`). +- SSRF (Server-Side Request Forgery) policy enforcement, credential injection, rate-limit + counter evaluation, and CORS request-time handling are out of scope for this feature; it + defines and validates the configuration fields those later features read, but does not + execute any of that runtime behavior itself. diff --git a/gears/system/oagw/docs/features/route-management-api.md b/gears/system/oagw/docs/features/route-management-api.md new file mode 100644 index 0000000..8136929 --- /dev/null +++ b/gears/system/oagw/docs/features/route-management-api.md @@ -0,0 +1,591 @@ +# Feature: Route Management API + + + + +- [1. Feature Context](#1-feature-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Create Route Flow](#create-route-flow) + - [List Routes Flow](#list-routes-flow) + - [Get Route Flow](#get-route-flow) + - [Replace Route Flow](#replace-route-flow) + - [Delete Route Flow](#delete-route-flow) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Route Body Validation Algorithm](#route-body-validation-algorithm) + - [Match-Rule Uniqueness Algorithm](#match-rule-uniqueness-algorithm) + - [List Query Algorithm](#list-query-algorithm) +- [4. States (CDSL)](#4-states-cdsl) + - [Route Lifecycle State Machine](#route-lifecycle-state-machine) +- [5. Definitions of Done](#5-definitions-of-done) + - [Implement Route CRUD Endpoints](#implement-route-crud-endpoints) + - [Enforce Route Body Validation](#enforce-route-body-validation) + - [Enforce Match-Rule Uniqueness](#enforce-match-rule-uniqueness) + - [Enforce Tenant Scoping on Every Operation](#enforce-tenant-scoping-on-every-operation) + - [Enforce Per-Endpoint Authorization](#enforce-per-endpoint-authorization) + - [Emit RFC 9457 Error Envelopes](#emit-rfc-9457-error-envelopes) +- [6. Acceptance Criteria](#6-acceptance-criteria) +- [7. Applicability](#7-applicability) + + + +- [ ] `p1` - **ID**: `cpt-cf-oagw-featstatus-route-management-api-implemented` + + +- [ ] `p2` - `cpt-cf-oagw-feature-route-management-api` + +## 1. Feature Context + +### 1.1 Overview + +Five REST endpoints for creating, listing, fetching, replacing, and deleting routes — the +per-upstream matching rules that decide which inbound proxy requests are allowed through and +which upstream behavior they trigger. + +### 1.2 Purpose + +Operators and tenant administrators need a way to declare, before proxying goes live, which +methods, paths, and query parameters are reachable on a given upstream. This feature exposes +that declaration surface. It builds on `cpt-cf-oagw-feature-upstream-management-api` because +every route names an `upstream_id` that must already be visible through upstream CRUD, and it +is itself a prerequisite for `cpt-cf-oagw-feature-proxy-data-plane-http`, which matches inbound +requests against the routes this feature persists. + +**Requirements**: `cpt-cf-oagw-fr-route-mgmt`, `cpt-cf-oagw-usecase-configure-route` + +**Principles**: None newly covered here; `cpt-cf-oagw-principle-tenant-scope` governs every flow +below exactly as it does elsewhere, and remains attributed to +`cpt-cf-oagw-feature-resource-model-and-store` per DECOMPOSITION §2.4, which allocates this +feature no covered principle and no covered constraint. + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-oagw-actor-platform-operator` | Creates, replaces, and deletes routes across any tenant it manages; defines system-wide matching rules. | +| `cpt-cf-oagw-actor-tenant-admin` | Creates, replaces, and deletes routes scoped to its own tenant, within the upstreams it can address. | + +Each flow below names one illustrative actor for readability. Either actor may call any of the +five endpoints, subject to the permission scope its bearer token actually carries. + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) — `cpt-cf-oagw-fr-route-mgmt`, `cpt-cf-oagw-usecase-configure-route`, `cpt-cf-oagw-nfr-input-validation`, `cpt-cf-oagw-nfr-multi-tenancy`, `cpt-cf-oagw-interface-management-api` +- **Design**: [DESIGN.md](../DESIGN.md) — `cpt-cf-oagw-design-domain-model` (§3.1 Route entity), `cpt-cf-oagw-interface-api` (§3.3 API Contracts, CRUD Semantics, Tenant Scoping, Error Response Format), `cpt-cf-oagw-db-schema` (§3.6 `oagw_route`, `oagw_route_http_match`, `oagw_route_grpc_match`, `oagw_route_method`, `oagw_route_tag`), `cpt-cf-oagw-adr-request-routing` (route match determinism), `cpt-cf-oagw-adr-error-source-distinction` +- **Schema**: [schemas/route.v1.schema.json](../schemas/route.v1.schema.json) +- **DECOMPOSITION**: `cpt-cf-oagw-feature-route-management-api` (section 2.4), Mandatory Override 1 (gear-relative `/oagw/v1/...` paths, no `/api` prefix), Scope Reality (in-memory tenant-scoped store, no gRPC dispatch) +- **Dependencies**: `cpt-cf-oagw-feature-upstream-management-api`, `cpt-cf-oagw-feature-resource-model-and-store` + (the tenant-scoped store write invariant this feature's API-layer algorithm restates; see + `cpt-cf-oagw-algo-resource-model-store-write-invariants`). Where the two documents describe + the same create-route behaviour, the store-level invariant in + `cpt-cf-oagw-feature-resource-model-and-store` is canonical. This feature's algorithm below is + a restatement of that invariant at the API layer and **MUST NOT** diverge from it. + +## 2. Actor Flows (CDSL) + +Every step below runs behind the shared inbound Bearer-token gate established by +`cpt-cf-oagw-feature-gear-foundation`: a request with a missing or invalid token, or a valid +token whose scopes do not include the route permission for the operation being performed, is +rejected with `401` before any other step executes. This reuses `AuthenticationFailed` +(`gts.cf.core.errors.err.v1~cf.oagw.auth.failed.v1`), the only `401` row in DESIGN.md's error +table (§3.3 Error Response Format); DESIGN.md documents it for outbound-to-upstream credential +failures, and this feature reuses the same GTS type for inbound bearer-token/permission +failures on the management surface, since the table defines no second `401` type. Reusing this +type does not change its fixed `type` and `title` fields, which still read "Authentication to +upstream failed." Every 401 response below **MUST** write its `detail` field to describe the +actual inbound cause instead: a missing bearer token, or a token lacking the required route +permission. This keeps a reader from being misled by the reused `title` text. + +Every route-match conflict below returns `409` named `RouteMatchConflict` +(`gts.cf.core.errors.err.v1~cf.oagw.route.match_conflict.v1`). DESIGN.md's error table (§3.3) +defines no `409` row for a route match conflict — its only `409` row, `PluginInUse`, covers a +different condition — so this feature introduces this GTS literal as the named identifier for +every 409 site below. + +Every error response below is RFC 9457 (a standard for machine-readable HTTP error bodies) +`application/problem+json` and carries `X-OAGW-Error-Source: gateway`, since these are +gateway-originated errors, never upstream passthrough. + +**Use cases**: `cpt-cf-oagw-usecase-configure-route` + +### Create Route Flow + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-route-api-create` + +**Actor**: `cpt-cf-oagw-actor-platform-operator` + +**Success Scenarios**: +- A route naming an `upstream_id` owned by the caller's tenant, with a well-formed `match.http` + or `match.grpc` block, is created with a server-generated `id` and `enabled: true`. + +**Error Scenarios**: +- Caller lacks `gts.cf.core.oagw.route.v1~:create` permission. +- `upstream_id` does not exist, or exists only for an ancestor tenant. +- `match` carries both `http` and `grpc`, or neither. +- The candidate match rule duplicates an existing route's `(path, priority, method)` (HTTP) or + `(service, method)` (gRPC) tuple within the same upstream. + +**Steps**: +1. [ ] - `p1` - Operator sends `POST /oagw/v1/routes` with `{ upstream_id, match, tags?, plugins?, rate_limit?, enabled? }` - `inst-route-create-1` +2. [ ] - `p1` - API: check `gts.cf.core.oagw.route.v1~:create` via the shared Bearer-token gate - `inst-route-create-2` +3. [ ] - `p1` - **IF** the token is missing or lacks `route.v1~:create` - `inst-route-create-3` + 1. [ ] - `p1` - **RETURN** 401 AuthenticationFailed problem+json - `inst-route-create-3a` +4. [ ] - `p1` - **ELSE** validate the body against `route.v1.schema.json` (`match` `oneOf` `{http|grpc}`, `additionalProperties: false` on `match`, `http_match`/`grpc_match`, tag pattern `^[a-z0-9_-]+$`) - `inst-route-create-4` +5. [ ] - `p1` - **IF** schema validation fails - `inst-route-create-5` + 1. [ ] - `p1` - **RETURN** 400 ValidationError problem+json naming the failing field(s) - `inst-route-create-5a` +6. [ ] - `p1` - **ELSE** - `inst-route-create-6` + 1. [ ] - `p1` - DB: SELECT upstream FROM store WHERE id = upstream_id AND tenant_id = caller_tenant - `inst-route-create-6a` +7. [ ] - `p1` - **IF** no upstream row matches (missing entirely, or exists only for an ancestor tenant) - `inst-route-create-7` + 1. [ ] - `p1` - **RETURN** 400 ValidationError problem+json ("upstream_id does not exist for this tenant") - `inst-route-create-7a` +8. [ ] - `p1` - **ELSE** CALL `cpt-cf-oagw-algo-route-api-match-uniqueness`(tenant_id, upstream_id, match, exclude_id=None) - `inst-route-create-8` +9. [ ] - `p1` - **IF** a conflicting route is found - `inst-route-create-9` + 1. [ ] - `p1` - **RETURN** 409 RouteMatchConflict problem+json naming the conflicting route's `id` - `inst-route-create-9a` +10. [ ] - `p1` - **ELSE** - `inst-route-create-10` + 1. [ ] - `p1` - DB: INSERT route (id=uuid, tenant_id, upstream_id, match, tags, plugins, rate_limit, enabled=true default) into store - `inst-route-create-10a` + 2. [ ] - `p1` - **RETURN** 201 Created with the persisted route body, `id` as `gts.cf.core.oagw.route.v1~{uuid}` - `inst-route-create-10b` + +### List Routes Flow + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-route-api-list` + +**Actor**: `cpt-cf-oagw-actor-tenant-admin` + +**Success Scenarios**: +- The caller's own routes are returned, paginated and optionally filtered/sorted/projected by + OData query parameters. + +**Error Scenarios**: +- Caller lacks `gts.cf.core.oagw.route.v1~:read` permission. + +**Steps**: +1. [ ] - `p1` - Admin sends `GET /oagw/v1/routes?$filter=...&$select=...&$orderby=...&$top=...&$skip=...` - `inst-route-list-1` +2. [ ] - `p1` - API: check `gts.cf.core.oagw.route.v1~:read` - `inst-route-list-2` +3. [ ] - `p1` - **IF** the token is missing or lacks `route.v1~:read` - `inst-route-list-3` + 1. [ ] - `p1` - **RETURN** 401 AuthenticationFailed problem+json - `inst-route-list-3a` +4. [ ] - `p1` - **ELSE** CALL `cpt-cf-oagw-algo-route-api-list-query`(tenant_id, filter, select, orderby, top, skip) - `inst-route-list-4` +5. [ ] - `p1` - DB: SELECT routes FROM store WHERE tenant_id = caller_tenant (ancestor-tenant routes never included) - `inst-route-list-5` +6. [ ] - `p1` - **RETURN** 200 with the paginated, filtered, projected route list - `inst-route-list-6` + +### Get Route Flow + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-route-api-get` + +**Actor**: `cpt-cf-oagw-actor-tenant-admin` + +**Success Scenarios**: +- A route owned by the caller's tenant is returned in full. + +**Error Scenarios**: +- Caller lacks `gts.cf.core.oagw.route.v1~:read` permission. +- Route `id` does not exist, or exists only for an ancestor tenant. + +**Steps**: +1. [ ] - `p1` - Admin sends `GET /oagw/v1/routes/{id}` - `inst-route-get-1` +2. [ ] - `p1` - API: check `gts.cf.core.oagw.route.v1~:read` - `inst-route-get-2` +3. [ ] - `p1` - **IF** the token is missing or lacks `route.v1~:read` - `inst-route-get-3` + 1. [ ] - `p1` - **RETURN** 401 AuthenticationFailed problem+json - `inst-route-get-3a` +4. [ ] - `p1` - **ELSE** DB: SELECT route FROM store WHERE id = {id} AND tenant_id = caller_tenant - `inst-route-get-4` +5. [ ] - `p1` - **IF** no row matches (missing entirely, or exists only for an ancestor tenant) - `inst-route-get-5` + 1. [ ] - `p1` - **RETURN** 404 RouteNotFound problem+json - `inst-route-get-5a` +6. [ ] - `p1` - **ELSE** - `inst-route-get-6` + 1. [ ] - `p1` - **RETURN** 200 with the route body - `inst-route-get-6a` + +### Replace Route Flow + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-route-api-replace` + +**Actor**: `cpt-cf-oagw-actor-platform-operator` + +**Success Scenarios**: +- An existing route owned by the caller's tenant is fully replaced; `upstream_id` is retained + unchanged because the update DTO (data transfer object, the shape the API accepts for this + operation) has no `upstream_id` field at all. + +**Error Scenarios**: +- Caller lacks `gts.cf.core.oagw.route.v1~:override` permission. +- Route `id` does not exist, or exists only for an ancestor tenant. +- The replacement `match` carries both `http` and `grpc`, or neither. +- The replacement match rule duplicates another existing route's `(path, priority, method)` / + `(service, method)` tuple within the same upstream. + +**Steps**: +1. [ ] - `p1` - Operator sends `PUT /oagw/v1/routes/{id}` with `{ match, tags?, plugins?, rate_limit?, enabled? }` (no `upstream_id` field in this DTO) - `inst-route-replace-1` +2. [ ] - `p1` - API: check `gts.cf.core.oagw.route.v1~:override` - `inst-route-replace-2` +3. [ ] - `p1` - **IF** the token is missing or lacks `route.v1~:override` - `inst-route-replace-3` + 1. [ ] - `p1` - **RETURN** 401 AuthenticationFailed problem+json - `inst-route-replace-3a` +4. [ ] - `p1` - **ELSE** DB: SELECT existing route FROM store WHERE id = {id} AND tenant_id = caller_tenant - `inst-route-replace-4` +5. [ ] - `p1` - **IF** no row matches (missing entirely, or exists only for an ancestor tenant) - `inst-route-replace-5` + 1. [ ] - `p1` - **RETURN** 404 RouteNotFound problem+json - `inst-route-replace-5a` +6. [ ] - `p1` - **ELSE** validate the body against `route.v1.schema.json` minus `upstream_id`/`id` (immutable, server-controlled) - `inst-route-replace-6` +7. [ ] - `p1` - **IF** schema validation fails - `inst-route-replace-7` + 1. [ ] - `p1` - **RETURN** 400 ValidationError problem+json naming the failing field(s) - `inst-route-replace-7a` +8. [ ] - `p1` - **ELSE** CALL `cpt-cf-oagw-algo-route-api-match-uniqueness`(tenant_id, existing.upstream_id, match, exclude_id={id}) - `inst-route-replace-8` +9. [ ] - `p1` - **IF** a conflicting route is found - `inst-route-replace-9` + 1. [ ] - `p1` - **RETURN** 409 RouteMatchConflict problem+json naming the conflicting route's `id` - `inst-route-replace-9a` +10. [ ] - `p1` - **ELSE** - `inst-route-replace-10` + 1. [ ] - `p1` - DB: UPDATE route SET match, tags, plugins, rate_limit, enabled = new values; `upstream_id`, `id`, `tenant_id` unchanged - `inst-route-replace-10a` + 2. [ ] - `p1` - **RETURN** 200 with the replaced route body - `inst-route-replace-10b` + +### Delete Route Flow + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-route-api-delete` + +**Actor**: `cpt-cf-oagw-actor-platform-operator` + +**Success Scenarios**: +- A route owned by the caller's tenant is permanently removed from the store. + +**Error Scenarios**: +- Caller lacks `gts.cf.core.oagw.route.v1~:delete` permission. +- Route `id` does not exist, or exists only for an ancestor tenant. + +**Steps**: +1. [ ] - `p1` - Operator sends `DELETE /oagw/v1/routes/{id}` - `inst-route-delete-1` +2. [ ] - `p1` - API: check `gts.cf.core.oagw.route.v1~:delete` - `inst-route-delete-2` +3. [ ] - `p1` - **IF** the token is missing or lacks `route.v1~:delete` - `inst-route-delete-3` + 1. [ ] - `p1` - **RETURN** 401 AuthenticationFailed problem+json - `inst-route-delete-3a` +4. [ ] - `p1` - **ELSE** DB: SELECT route FROM store WHERE id = {id} AND tenant_id = caller_tenant - `inst-route-delete-4` +5. [ ] - `p1` - **IF** no row matches (missing entirely, or exists only for an ancestor tenant) - `inst-route-delete-5` + 1. [ ] - `p1` - **RETURN** 404 RouteNotFound problem+json - `inst-route-delete-5a` +6. [ ] - `p1` - **ELSE** - `inst-route-delete-6` + 1. [ ] - `p1` - DB: DELETE route FROM store WHERE id = {id} AND tenant_id = caller_tenant - `inst-route-delete-6a` + 2. [ ] - `p1` - **RETURN** 204 No Content - `inst-route-delete-6b` + +## 3. Processes / Business Logic (CDSL) + +### Route Body Validation Algorithm + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-route-api-validate-body` + +**Input**: Raw JSON request body for create or replace, calling tenant id + +**Output**: A typed, validated route (or replacement) payload, or a list of field-level errors + +**Steps**: +1. [ ] - `p1` - Parse JSON body into the create/replace DTO shape (replace DTO omits `upstream_id`, `id`) - `inst-validate-1` +2. [ ] - `p1` - **IF** `match` is absent, or contains neither `http` nor `grpc`, or contains both - `inst-validate-2` + 1. [ ] - `p1` - Add error: "match must contain exactly one of http or grpc" - `inst-validate-2a` +3. [ ] - `p1` - **IF** `match.http` present - `inst-validate-3` + 1. [ ] - `p1` - Reject unknown keys under `match.http` (`additionalProperties: false`) - `inst-validate-3a` + 2. [ ] - `p1` - **IF** `methods` is empty or contains a value outside `GET|POST|PUT|DELETE|PATCH` - `inst-validate-3b` + 1. [ ] - `p1` - Add error: "methods must be a non-empty list of GET, POST, PUT, DELETE, PATCH" - `inst-validate-3b-i` + 3. [ ] - `p1` - **IF** `path` is empty - `inst-validate-3c` + 1. [ ] - `p1` - Add error: "path must be non-empty" - `inst-validate-3c-i` + 4. [ ] - `p1` - Default `query_allowlist` to `[]` when absent - `inst-validate-3d` + 5. [ ] - `p1` - **IF** `path_suffix_mode` is present, verify it is one of `"disabled"`, `"append"` (default `"append"` when absent) - `inst-validate-3e` +4. [ ] - `p1` - **IF** `match.grpc` present - `inst-validate-4` + 1. [ ] - `p1` - Reject unknown keys under `match.grpc` (`additionalProperties: false`) - `inst-validate-4a` + 2. [ ] - `p1` - **IF** `service` or `method` is empty - `inst-validate-4b` + 1. [ ] - `p1` - Add error: "service and method are both required and non-empty" - `inst-validate-4b-i` +5. [ ] - `p1` - **FOR EACH** tag in `tags` - `inst-validate-5` + 1. [ ] - `p1` - **IF** tag does not match `^[a-z0-9_-]+$` - `inst-validate-5a` + 1. [ ] - `p1` - Add error: "tag '{tag}' violates the allowed tag pattern" - `inst-validate-5a-i` +6. [ ] - `p1` - **IF** `plugins.sharing` is present, verify it is one of `"private"`, `"inherit"`, `"enforce"` (default `"private"` when absent); default `plugins.items` to `[]` when absent; accept each item as an opaque `gts-identifier` string without resolving or executing it (plugin resolution belongs to `cpt-cf-oagw-feature-plugin-management-api` / `cpt-cf-oagw-feature-policy-and-plugins`) - `inst-validate-6` +7. [ ] - `p1` - **IF** `rate_limit` present - `inst-validate-7` + 1. [ ] - `p1` - Reject unknown keys under `rate_limit` (`additionalProperties: false`) - `inst-validate-7a` + 2. [ ] - `p1` - **IF** `rate_limit.sharing` is present, verify it is one of `"private"`, `"inherit"`, `"enforce"` (default `"private"`) - `inst-validate-7b` + 3. [ ] - `p1` - **IF** `rate_limit.algorithm` is present, verify it is one of `"token_bucket"`, `"sliding_window"` (default `"token_bucket"`) - `inst-validate-7c` + 4. [ ] - `p1` - **IF** `rate_limit.sustained.rate` is absent or less than `1` - `inst-validate-7d` + 1. [ ] - `p1` - Add error: "rate_limit.sustained.rate is required and must be an integer >= 1" - `inst-validate-7d-i` + 5. [ ] - `p1` - **IF** `rate_limit.sustained.window` is present, verify it is one of `"second"`, `"minute"`, `"hour"`, `"day"` (default `"second"`) - `inst-validate-7e` + 6. [ ] - `p1` - **IF** `rate_limit.burst.capacity` is present, verify it is an integer `>= 1` - `inst-validate-7f` + 7. [ ] - `p1` - **IF** `rate_limit.scope` is present, verify it is one of `"global"`, `"tenant"`, `"user"`, `"ip"`, `"route"` (default `"tenant"`) - `inst-validate-7g` + 8. [ ] - `p1` - **IF** `rate_limit.strategy` is present, verify it is one of `"reject"`, `"queue"`, `"degrade"` (default `"reject"`) - `inst-validate-7h` + 9. [ ] - `p1` - **IF** `rate_limit.cost` is present, verify it is an integer `>= 1` (default `1`) - `inst-validate-7i` +8. [ ] - `p1` - Default `enabled` to `true` when absent - `inst-validate-8` +9. [ ] - `p1` - **RETURN** { valid: errors.length === 0, errors, normalized_route } - `inst-validate-9` + +### Match-Rule Uniqueness Algorithm + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-route-api-match-uniqueness` + +**Input**: Calling tenant id, `upstream_id`, candidate `match` (already schema-valid), the id of +the route being replaced (`exclude_id`, `None` for create) + +**Output**: `{ conflict: bool, conflicting_route_id: Option }` + +Because `route.v1.schema.json` exposes no client-settable `priority` field, this build assigns +every stored route a fixed internal priority of `0`. DESIGN.md's determinism invariant reads: "no +two **enabled** routes under same upstream may share `(path_prefix, priority)` for same method." +Since priority never varies, this build enforces that invariant for HTTP routes as `(path, +method)`, filtered to enabled routes only. A disabled route never blocks a new or replaced route +from claiming the same `(path, method)`. A future schema revision that adds a client-settable +`priority` field would restore the full three-part comparison without changing this algorithm's +shape. + +This algorithm restates, at the API layer, the canonical store-level invariant defined by +`cpt-cf-oagw-algo-resource-model-store-write-invariants` (step `inst-storeinv-2`) in +`cpt-cf-oagw-feature-resource-model-and-store`. That canonical check scans the target upstream's +**enabled** routes for one sharing `(method, path)` with the candidate, excluding the route being +replaced. The steps below **MUST NOT** diverge from that canonical tuple or its enabled-only +filtering. The gRPC `(service, method)` comparison below +is this feature's extension to a shape the store-level algorithm does not itself enumerate, kept +consistent with the same enabled-only filtering. + +**Steps**: +1. [ ] - `p1` - DB: SELECT routes FROM store WHERE tenant_id = tenant_id AND upstream_id = upstream_id AND enabled == true AND id != exclude_id - `inst-uniq-1` +2. [ ] - `p1` - **IF** candidate.match.http present - `inst-uniq-2` + 1. [ ] - `p1` - **FOR EACH** existing **enabled** route with `match.http` in the selected set - `inst-uniq-2a` + 1. [ ] - `p1` - **IF** existing.match.http.path == candidate.match.http.path AND priority == priority (fixed `0`) AND existing.match.http.methods intersects candidate.match.http.methods - `inst-uniq-2a-i` + 1. [ ] - `p1` - **RETURN** { conflict: true, conflicting_route_id: existing.id } - `inst-uniq-2a-i-1` +3. [ ] - `p1` - **ELSE** (candidate.match.grpc present) - `inst-uniq-3` + 1. [ ] - `p1` - **FOR EACH** existing **enabled** route with `match.grpc` in the selected set - `inst-uniq-3a` + 1. [ ] - `p1` - **IF** existing.match.grpc.service == candidate.match.grpc.service AND existing.match.grpc.method == candidate.match.grpc.method - `inst-uniq-3a-i` + 1. [ ] - `p1` - **RETURN** { conflict: true, conflicting_route_id: existing.id } - `inst-uniq-3a-i-1` +4. [ ] - `p1` - **RETURN** { conflict: false, conflicting_route_id: None } - `inst-uniq-4` + +### List Query Algorithm + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-route-api-list-query` + +**Input**: Calling tenant id, raw `$filter`, `$select`, `$orderby`, `$top`, `$skip` string +parameters (OData — a standard query-string syntax for filtering, sorting, and paging over +collections) + +**Output**: A tenant-scoped, filtered, sorted, projected, paginated route list + +**Steps**: +1. [ ] - `p1` - Parse `$filter` into a predicate over route fields (e.g., `upstream_id eq '{uuid}'`); on parse failure, treat as "no filter" rather than rejecting the whole request - `inst-list-1` +2. [ ] - `p1` - Parse `$select` into a field projection list; empty/absent means "all fields" - `inst-list-2` +3. [ ] - `p1` - Parse `$orderby` into a field + direction pair - `inst-list-3` +4. [ ] - `p1` - **IF** `$top` is absent - `inst-list-4` + 1. [ ] - `p1` - Set effective top to `50` - `inst-list-4a` +5. [ ] - `p1` - **ELSE IF** `$top` > `100` - `inst-list-5` + 1. [ ] - `p1` - Clamp effective top to `100` - `inst-list-5a` +6. [ ] - `p1` - Set effective skip to `$skip` if present, else `0` - `inst-list-6` +7. [ ] - `p1` - DB: SELECT routes FROM store WHERE tenant_id = tenant_id AND {filter} ORDER BY {orderby} - `inst-list-7` +8. [ ] - `p1` - Apply skip/top pagination window to the ordered result set - `inst-list-8` +9. [ ] - `p1` - Apply the `$select` projection to each returned route - `inst-list-9` +10. [ ] - `p1` - **RETURN** the paginated, projected list - `inst-list-10` + +## 4. States (CDSL) + +### Route Lifecycle State Machine + +- [ ] `p2` - **ID**: `cpt-cf-oagw-state-route-api-lifecycle` + +**States**: absent, active, disabled, deleted + +**Initial State**: absent + +**Transitions**: +1. [ ] - `p1` - **FROM** absent **TO** active **WHEN** `POST /oagw/v1/routes` succeeds (`enabled` defaults to `true`) - `inst-lifecycle-1` +2. [ ] - `p1` - **FROM** active **TO** disabled **WHEN** `PUT /oagw/v1/routes/{id}` sets `enabled: false` - `inst-lifecycle-2` +3. [ ] - `p1` - **FROM** disabled **TO** active **WHEN** `PUT /oagw/v1/routes/{id}` sets `enabled: true` - `inst-lifecycle-3` +4. [ ] - `p1` - **FROM** active **TO** deleted **WHEN** `DELETE /oagw/v1/routes/{id}` succeeds - `inst-lifecycle-4` +5. [ ] - `p1` - **FROM** disabled **TO** deleted **WHEN** `DELETE /oagw/v1/routes/{id}` succeeds - `inst-lifecycle-5` + +A route in the `disabled` state is excluded from route matching at proxy time (`cpt-cf-oagw-fr-enable-disable`); this feature is responsible only for persisting the `enabled` flag and +returning it accurately from every CRUD response, since the matching logic itself belongs to +`cpt-cf-oagw-feature-proxy-data-plane-http`. + +## 5. Definitions of Done + +### Implement Route CRUD Endpoints + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-route-api-crud` + +The system **MUST** expose `POST /oagw/v1/routes`, `GET /oagw/v1/routes`, `GET +/oagw/v1/routes/{id}`, `PUT /oagw/v1/routes/{id}`, and `DELETE /oagw/v1/routes/{id}` — the +Override-1, gear-relative paths with no `/api` prefix — reading from and writing to the +tenant-scoped in-memory store, and persisting the `enabled` boolean (default `true`) supplied on +create or replace. + +**Implements**: +- `cpt-cf-oagw-flow-route-api-create` +- `cpt-cf-oagw-flow-route-api-list` +- `cpt-cf-oagw-flow-route-api-get` +- `cpt-cf-oagw-flow-route-api-replace` +- `cpt-cf-oagw-flow-route-api-delete` +- `cpt-cf-oagw-state-route-api-lifecycle` + +**Touches**: +- API: `POST /oagw/v1/routes` +- API: `GET /oagw/v1/routes` +- API: `GET /oagw/v1/routes/{id}` +- API: `PUT /oagw/v1/routes/{id}` +- API: `DELETE /oagw/v1/routes/{id}` +- DB: `cpt-cf-oagw-db-schema` +- Entities: `Route` + +### Enforce Route Body Validation + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-route-api-validation` + +The system **MUST** validate every create and replace request body against `route.v1.schema.json`: +`match` **MUST** contain exactly one of `http`/`grpc` (`oneOf`, `additionalProperties: false`), +`match.http.methods` **MUST** be non-empty and drawn from `GET|POST|PUT|DELETE|PATCH`, +`match.http.path` **MUST** be non-empty, `match.grpc.service`/`match.grpc.method` **MUST** both +be non-empty, and every `tags` entry **MUST** match `^[a-z0-9_-]+$`. Any violation **MUST** +return `400 ValidationError`. + +**Implements**: +- `cpt-cf-oagw-algo-route-api-validate-body` +- `cpt-cf-oagw-flow-route-api-create` +- `cpt-cf-oagw-flow-route-api-replace` + +**Constraints**: None. DECOMPOSITION §2.4 allocates this feature no covered design principle and +no covered design constraint; tenant scoping still governs this validation as behaviour, but is +attributed to `cpt-cf-oagw-feature-resource-model-and-store`. + +**Touches**: +- API: `POST /oagw/v1/routes` +- API: `PUT /oagw/v1/routes/{id}` +- Entities: `Route` + +### Enforce Match-Rule Uniqueness + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-route-api-uniqueness` + +The system **MUST** reject, with `409 RouteMatchConflict`, any create or replace whose HTTP match +rule duplicates an **enabled** existing route's `(path, method)` tuple within the same upstream — +`route.v1.schema.json` has no client-settable `priority`, so every route holds a fixed internal +priority of `0` and the DESIGN.md invariant `(path, priority, method)` collapses to `(path, +method)` — or whose gRPC match rule duplicates an **enabled** existing route's `(service, +method)` tuple within the same upstream, excluding the route being replaced from its own +comparison set. A disabled route sharing the same tuple **MUST NOT** block the write. + +**Implements**: +- `cpt-cf-oagw-algo-route-api-match-uniqueness` +- `cpt-cf-oagw-flow-route-api-create` +- `cpt-cf-oagw-flow-route-api-replace` + +**Touches**: +- API: `POST /oagw/v1/routes` +- API: `PUT /oagw/v1/routes/{id}` +- DB: `cpt-cf-oagw-db-schema` +- Entities: `Route` + +### Enforce Tenant Scoping on Every Operation + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-route-api-tenant-scope` + +The system **MUST** reject, with `400 ValidationError`, a create or replace whose `upstream_id` +does not exist for the calling tenant or exists only for an ancestor tenant. The system **MUST** +return `404 RouteNotFound` from `GET /oagw/v1/routes/{id}`, `PUT /oagw/v1/routes/{id}`, and +`DELETE /oagw/v1/routes/{id}` when the route id does not exist or belongs only to an ancestor +tenant, and list results **MUST** never include a route owned by an ancestor tenant. The +`upstream_id` field **MUST** be immutable: absent from the replace DTO, and unchanged by replace +regardless of what the request body contains. + +**Implements**: +- `cpt-cf-oagw-flow-route-api-create` +- `cpt-cf-oagw-flow-route-api-list` +- `cpt-cf-oagw-flow-route-api-get` +- `cpt-cf-oagw-flow-route-api-replace` +- `cpt-cf-oagw-flow-route-api-delete` + +**Constraints**: None. DECOMPOSITION §2.4 allocates this feature no covered design principle and +no covered design constraint; tenant scoping still governs this enforcement as behaviour, but is +attributed to `cpt-cf-oagw-feature-resource-model-and-store`. + +**Touches**: +- API: `GET /oagw/v1/routes/{id}` +- API: `PUT /oagw/v1/routes/{id}` +- API: `DELETE /oagw/v1/routes/{id}` +- DB: `cpt-cf-oagw-db-schema` +- Entities: `Route` + +### Enforce Per-Endpoint Authorization + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-route-api-authz` + +The system **MUST** gate `POST /oagw/v1/routes` behind `gts.cf.core.oagw.route.v1~:create`, +`GET /oagw/v1/routes` and `GET /oagw/v1/routes/{id}` behind +`gts.cf.core.oagw.route.v1~:read`, `PUT /oagw/v1/routes/{id}` behind +`gts.cf.core.oagw.route.v1~:override`, and `DELETE /oagw/v1/routes/{id}` behind +`gts.cf.core.oagw.route.v1~:delete`, returning `401 AuthenticationFailed` when the bearer token +is missing or lacks the required permission, before any other request processing runs. + +**Implements**: +- `cpt-cf-oagw-flow-route-api-create` +- `cpt-cf-oagw-flow-route-api-list` +- `cpt-cf-oagw-flow-route-api-get` +- `cpt-cf-oagw-flow-route-api-replace` +- `cpt-cf-oagw-flow-route-api-delete` + +**Touches**: +- API: `POST /oagw/v1/routes` +- API: `GET /oagw/v1/routes` +- API: `GET /oagw/v1/routes/{id}` +- API: `PUT /oagw/v1/routes/{id}` +- API: `DELETE /oagw/v1/routes/{id}` +- Entities: `Route` + +### Emit RFC 9457 Error Envelopes + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-route-api-errors` + +Every `400`, `401`, `404`, and `409` response emitted by these five endpoints **MUST** be +`application/problem+json` per RFC 9457, carrying `type`, `title`, `status`, `detail`, +`instance`, and the OAGW extension fields (`upstream_id`, `path`, `trace_id`), and **MUST** +carry an `X-OAGW-Error-Source: gateway` header, since none of these errors are upstream +passthrough. + +**Implements**: +- `cpt-cf-oagw-flow-route-api-create` +- `cpt-cf-oagw-flow-route-api-list` +- `cpt-cf-oagw-flow-route-api-get` +- `cpt-cf-oagw-flow-route-api-replace` +- `cpt-cf-oagw-flow-route-api-delete` + +**Constraints**: `cpt-cf-oagw-adr-error-source-distinction` + +**Touches**: +- API: `POST /oagw/v1/routes` +- API: `GET /oagw/v1/routes` +- API: `GET /oagw/v1/routes/{id}` +- API: `PUT /oagw/v1/routes/{id}` +- API: `DELETE /oagw/v1/routes/{id}` +- Entities: `Route` + +## 6. Acceptance Criteria + +- [ ] `POST /oagw/v1/routes` with a valid `upstream_id` owned by the caller's tenant and a + well-formed `match.http` block returns `201 Created` with a server-generated `id`. +- [ ] `POST /oagw/v1/routes` with an `upstream_id` that does not exist for the calling tenant + (missing entirely, or belonging only to an ancestor tenant) returns `400 ValidationError`. +- [ ] `POST /oagw/v1/routes` whose `match` contains both `http` and `grpc`, or neither, is + rejected with `400 ValidationError`. +- [ ] `POST /oagw/v1/routes` duplicating an **enabled** existing route's `(path, method)` within + the same upstream returns `409 RouteMatchConflict`. The schema exposes no client-settable + `priority`, so every route holds a fixed internal priority of `0`, collapsing DESIGN.md's + `(path, priority, method)` invariant to `(path, method)`. +- [ ] `POST /oagw/v1/routes` whose `(path, method)` matches an existing **disabled** route within + the same upstream is created successfully as a new, separate route: a disabled route does not + block the write. +- [ ] `PUT /oagw/v1/routes/{id}` cannot change `upstream_id`: the field is absent from the + replace DTO, and the stored value is unchanged even if the request body includes it. +- [ ] `DELETE /oagw/v1/routes/{id}` followed by `GET /oagw/v1/routes/{id}` returns + `404 RouteNotFound`. +- [ ] `GET /oagw/v1/routes/{id}`, `PUT /oagw/v1/routes/{id}`, and `DELETE /oagw/v1/routes/{id}` + each return `404 RouteNotFound` for a route id that belongs only to an ancestor tenant. +- [ ] `POST /oagw/v1/routes` with a well-formed `match.grpc` block (`service` and `method` both + present) returns `201 Created` and the stored route round-trips through `GET`, even though no + gRPC proxy dispatch path exists in this build. +- [ ] `GET /oagw/v1/routes` without `$top` returns at most `50` routes; `$top=150` is clamped to + the documented maximum of `100`. +- [ ] Every `400`, `401`, `404`, and `409` response from these five endpoints is + `application/problem+json` and carries `X-OAGW-Error-Source: gateway`. + +## 7. Applicability + +- **UX**: Not applicable. This is a machine-consumed management API with no rendered interface; + every caller is an automated client or an operator issuing JSON requests directly, so no + visual, layout, or interaction design applies. +- **Compliance**: Not applicable in this build. A route stores only method, path, an + `upstream_id` reference, tags, and plugin identifiers — no personal data — so no additional + regulatory obligation arises beyond the tenant scoping already covered by + `cpt-cf-oagw-feature-resource-model-and-store`. +- **Operations**: Substantive content applies. DESIGN.md §4.3 (Audit Logging) names route + create/update/delete among the "Config changes" logged as structured JSON events; this feature + relies on that shared logging path rather than defining a separate one. The Prometheus metrics + in DESIGN.md §4.2 are scoped to proxied data-plane requests, not to this management surface, so + no metric here is specific to route CRUD. +- **Performance**: Not applicable beyond baseline request handling. Route CRUD runs against an + in-memory, tenant-scoped store with no external calls, so it carries no distinct latency budget, + caching strategy, or throughput target beyond the general request handling that + `cpt-cf-oagw-feature-gear-foundation` already provides. diff --git a/gears/system/oagw/docs/features/upstream-management-api.md b/gears/system/oagw/docs/features/upstream-management-api.md new file mode 100644 index 0000000..fda93c3 --- /dev/null +++ b/gears/system/oagw/docs/features/upstream-management-api.md @@ -0,0 +1,544 @@ +# Feature: Upstream Management API + + + +- [1. Feature Context](#1-feature-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Create Upstream Flow](#create-upstream-flow) + - [List Upstreams Flow](#list-upstreams-flow) + - [Get Upstream Flow](#get-upstream-flow) + - [Replace Upstream Flow](#replace-upstream-flow) + - [Delete Upstream Flow](#delete-upstream-flow) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Create Validation Algorithm](#create-validation-algorithm) + - [Replace Validation Algorithm](#replace-validation-algorithm) + - [List Query Algorithm](#list-query-algorithm) +- [4. States (CDSL)](#4-states-cdsl) + - [Upstream Enabled State Machine](#upstream-enabled-state-machine) +- [5. Definitions of Done](#5-definitions-of-done) + - [Create Upstream Endpoint](#create-upstream-endpoint) + - [List and Get Upstream Endpoints](#list-and-get-upstream-endpoints) + - [Replace Upstream Endpoint](#replace-upstream-endpoint) + - [Delete Upstream Endpoint](#delete-upstream-endpoint) + - [Enabled/Disabled Semantics](#enableddisabled-semantics) + - [Per-Endpoint Authorization](#per-endpoint-authorization) + - [RFC 9457 Error Responses](#rfc-9457-error-responses) +- [6. Acceptance Criteria](#6-acceptance-criteria) + + + +- [ ] `p1` - **ID**: `cpt-cf-oagw-featstatus-upstream-management-api-implemented` + + +- [ ] `p1` - `cpt-cf-oagw-feature-upstream-management-api` + +## 1. Feature Context + +### 1.1 Overview + +This feature exposes the five CRUD (create, read, update, delete) endpoints over the +`Upstream` resource — the configuration record that names one external service OAGW +(Outbound API Gateway) can forward requests to. It is the management surface every later +proxy request depends on for a target to resolve. + +### 1.2 Purpose + +Upstreams are the fundamental configuration unit of OAGW: every proxied request ultimately +targets one. This feature lets `cpt-cf-oagw-actor-platform-operator` and +`cpt-cf-oagw-actor-tenant-admin` declare, inspect, replace, and remove upstream configuration +— endpoints, protocol, alias, auth reference, and the `enabled` flag — with tenant-scoped +storage, alias-uniqueness enforcement, and immutable identity fields. + +**Requirements**: `cpt-cf-oagw-fr-upstream-mgmt`, `cpt-cf-oagw-fr-enable-disable`, +`cpt-cf-oagw-nfr-input-validation`, `cpt-cf-oagw-nfr-multi-tenancy` + +**Principles**: `cpt-cf-oagw-principle-tenant-scope` + +**Constraints**: `cpt-cf-oagw-constraint-multi-sql`, `cpt-cf-oagw-constraint-https-only` + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-oagw-actor-platform-operator` | Creates, lists, inspects, replaces, and deletes upstreams for global configuration; the only actor who can grant a descendant tenant the `oagw:upstream:bind` permission that this feature checks. | +| `cpt-cf-oagw-actor-tenant-admin` | Performs the same five operations scoped to their own tenant, including binding to an ancestor's alias when the required permission and sharing mode allow it. | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) — `cpt-cf-oagw-usecase-configure-upstream`, `cpt-cf-oagw-fr-upstream-mgmt`, `cpt-cf-oagw-fr-enable-disable`, `cpt-cf-oagw-fr-error-codes` +- **Design**: [DESIGN.md](../DESIGN.md) §3.3 API Contracts (`cpt-cf-oagw-interface-api`), §3.1 Domain Model (`cpt-cf-oagw-design-domain-model`) +- **Interface**: `cpt-cf-oagw-interface-management-api` +- **Schema**: [upstream.v1.schema.json](../schemas/upstream.v1.schema.json) +- **Decomposition**: `cpt-cf-oagw-feature-upstream-management-api` +- **Dependencies**: `cpt-cf-oagw-feature-resource-model-and-store` (domain types, alias derivation, and the tenant-scoped in-memory store this feature reads and writes through) + +## 2. Actor Flows (CDSL) + +**Use cases**: `cpt-cf-oagw-usecase-configure-upstream` + +All five flows below run behind Bearer token authentication (`toolkit-auth`, established by +the gear-foundation feature). Every step that reaches the store operates on the tenant +identifier carried by the caller's token; no flow accepts a caller-supplied `tenant_id`. + +### Create Upstream Flow + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-upstream-api-create` + +**Actor**: `cpt-cf-oagw-actor-platform-operator` + +**Success Scenarios**: +- A hostname-based upstream is created with an auto-derived alias. +- An IP-based upstream is created with an explicit alias. +- An `http`-scheme, port-80 endpoint is accepted because Override 2 adds `http` as a legal + `scheme` value independent of whether the runtime later opens a plaintext connection. +- A tenant admin creates an upstream whose alias matches an ancestor's alias (a "bind"), + holding `oagw:upstream:bind` and an ancestor sharing mode that permits it. + +**Error Scenarios**: +- Body fails schema validation (missing `server.endpoints`, missing `protocol`, malformed + `alias` pattern, an endpoint scheme outside the accepted set). +- Alias — derived or explicit — collides with an existing upstream in the same tenant. +- Alias matches an ancestor's alias but the caller lacks `oagw:upstream:bind`, or the + ancestor's `auth.sharing` is `enforce` (blocks the override the bind would introduce) or + `private` (blocks visibility of the ancestor upstream needed to detect the match at all). +- Caller lacks `gts.cf.core.oagw.upstream.v1~:create`. + +**Steps**: +1. [ ] - `p1` - Operator sends create request with `server`, `protocol`, and optional `alias`, `tags`, `auth`, `headers`, `plugins`, `rate_limit`, `cors`, `enabled` - `inst-create-1` +2. [ ] - `p1` - API: POST /oagw/v1/upstreams (Upstream document body, no `id`/`tenant_id` in the payload) - `inst-create-2` +3. [ ] - `p1` - **IF** caller lacks `gts.cf.core.oagw.upstream.v1~:create` - `inst-create-3` + 1. [ ] - `p1` - **RETURN** 401 AuthenticationFailed, `application/problem+json`, `X-OAGW-Error-Source: gateway` - `inst-create-3a` +4. [ ] - `p1` - **ELSE** - `inst-create-4` + 1. [ ] - `p1` - Run `cpt-cf-oagw-algo-upstream-api-validate-create` against the body - `inst-create-4a` + 2. [ ] - `p1` - **IF** validation fails (schema, alias pattern, or bind-permission check) - `inst-create-4b` + 1. [ ] - `p1` - **RETURN** 400 ValidationError with per-field `detail`, `X-OAGW-Error-Source: gateway` - `inst-create-4b1` + 3. [ ] - `p1` - **ELSE IF** `(tenant_id, alias)` already exists for the calling tenant - `inst-create-4c` + 1. [ ] - `p1` - **RETURN** 409 Conflict, `type` set to `gts.cf.core.errors.err.v1~cf.oagw.upstream.alias_conflict.v1`, `X-OAGW-Error-Source: gateway` - `inst-create-4c1` + 4. [ ] - `p1` - **ELSE** - `inst-create-4d` + 1. [ ] - `p1` - Generate a UUID and build the anonymous GTS id `gts.cf.core.oagw.upstream.v1~{uuid}` - `inst-create-4d1` + 2. [ ] - `p1` - DB: INSERT tenant-scoped store (id, tenant_id, alias, enabled=true unless supplied, server, protocol, auth, headers, rate_limit, cors, plugins, tags) - `inst-create-4d2` + 3. [ ] - `p1` - **RETURN** 201 Created with the full stored Upstream document, including the server-generated `id` - `inst-create-4d3` + +### List Upstreams Flow + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-upstream-api-list` + +**Actor**: `cpt-cf-oagw-actor-tenant-admin` + +**Success Scenarios**: +- List returns only the calling tenant's own upstreams, paginated by `$top`/`$skip`. +- `$filter`, `$select`, and `$orderby` narrow, project, and order the result set. + +**Error Scenarios**: +- `$top` above 100 is clamped to 100 rather than rejected. +- `$filter` does not parse as a valid OData boolean expression, so the list query algorithm + rejects it with a validation error instead of running the lookup. +- Caller lacks `gts.cf.core.oagw.upstream.v1~:read`. + +**Steps**: +1. [ ] - `p1` - Admin sends list request with optional `$filter`, `$select`, `$orderby`, `$top`, `$skip` - `inst-list-1` +2. [ ] - `p1` - API: GET /oagw/v1/upstreams?$filter=...&$select=...&$orderby=...&$top=...&$skip=... - `inst-list-2` +3. [ ] - `p1` - **IF** caller lacks `gts.cf.core.oagw.upstream.v1~:read` - `inst-list-3` + 1. [ ] - `p1` - **RETURN** 401 AuthenticationFailed, `X-OAGW-Error-Source: gateway` - `inst-list-3a` +4. [ ] - `p1` - **ELSE** - `inst-list-4` + 1. [ ] - `p1` - Run `cpt-cf-oagw-algo-upstream-api-list-query` to parse and clamp query parameters - `inst-list-4a` + 2. [ ] - `p1` - **IF** `$filter` fails to parse as a valid OData boolean expression - `inst-list-4b` + 1. [ ] - `p1` - **RETURN** 400 ValidationError with per-field `detail`, `X-OAGW-Error-Source: gateway` - `inst-list-4b1` + 3. [ ] - `p1` - **ELSE** - `inst-list-4c` + 1. [ ] - `p1` - DB: SELECT tenant-scoped store WHERE tenant_id = caller's tenant, apply `$filter`, `$orderby`, `$skip`, `$top` (default 50, max 100) - `inst-list-4c1` + 2. [ ] - `p1` - Apply `$select` field projection to each result - `inst-list-4c2` + 3. [ ] - `p1` - **RETURN** 200 OK with the resulting Upstream array; ancestor upstreams reachable only via the proxy tenant-chain walk are never included - `inst-list-4c3` + +### Get Upstream Flow + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-upstream-api-get` + +**Actor**: `cpt-cf-oagw-actor-tenant-admin` + +**Success Scenarios**: +- The calling tenant's own upstream is returned by `id`. + +**Error Scenarios**: +- `id` does not exist for the calling tenant, including the case where it exists only as an + ancestor tenant's upstream — the response is indistinguishable 404 either way. +- Caller lacks `gts.cf.core.oagw.upstream.v1~:read`. + +**Steps**: +1. [ ] - `p1` - Admin requests a single upstream by id - `inst-get-1` +2. [ ] - `p1` - API: GET /oagw/v1/upstreams/{id} - `inst-get-2` +3. [ ] - `p1` - **IF** caller lacks `gts.cf.core.oagw.upstream.v1~:read` - `inst-get-3` + 1. [ ] - `p1` - **RETURN** 401 AuthenticationFailed, `X-OAGW-Error-Source: gateway` - `inst-get-3a` +4. [ ] - `p1` - **ELSE** - `inst-get-4` + 1. [ ] - `p1` - DB: SELECT tenant-scoped store WHERE id = {id} AND tenant_id = caller's tenant - `inst-get-4a` + 2. [ ] - `p1` - **IF** no row matches (absent entirely, or present only for an ancestor tenant) - `inst-get-4b` + 1. [ ] - `p1` - **RETURN** 404 Problem Details with `type` set to `gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1`, `X-OAGW-Error-Source: gateway` - `inst-get-4b1` + 3. [ ] - `p1` - **ELSE** - `inst-get-4c` + 1. [ ] - `p1` - **RETURN** 200 OK with the full Upstream document - `inst-get-4c1` + +### Replace Upstream Flow + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-upstream-api-replace` + +**Actor**: `cpt-cf-oagw-actor-platform-operator` + +**Success Scenarios**: +- A full replacement body overwrites every stored field; any optional field the request omits + is cleared in the stored document rather than left at its previous value. +- An IP-based upstream's explicit alias is repeated unchanged in the replacement body — the + no-op case the alias-immutability rule tolerates. +- A replacement body that omits `enabled` resets it to the schema default `true`, even when the + stored upstream was previously `enabled: false`, because a `PUT` is a full replacement and an + omitted field is never read from the prior stored value. + +**Error Scenarios**: +- The replacement body would change the derived or explicit alias — rejected regardless of + which direction the endpoint set moved (hostname to hostname, hostname to IP, IP to + hostname, or IP to IP). +- `id` in the path does not resolve to a upstream owned by the calling tenant (own-tenant 404; + ancestor-tenant 404, identical response). +- The replacement body attempts to set `id` or `tenant_id` to a different value than stored — + rejected as validation failure since both are immutable. +- Re-validation of the ancestor-bind constraint fails after the replacement changes overrides, + endpoints, or alias. + +**Steps**: +1. [ ] - `p1` - Operator sends a complete replacement document for an existing upstream - `inst-replace-1` +2. [ ] - `p1` - API: PUT /oagw/v1/upstreams/{id} (full Upstream document, no `id`/`tenant_id` fields accepted in the body) - `inst-replace-2` +3. [ ] - `p1` - **IF** caller lacks `gts.cf.core.oagw.upstream.v1~:override` - `inst-replace-3` + 1. [ ] - `p1` - **RETURN** 401 AuthenticationFailed, `X-OAGW-Error-Source: gateway` - `inst-replace-3a` +4. [ ] - `p1` - **ELSE** - `inst-replace-4` + 1. [ ] - `p1` - DB: SELECT tenant-scoped store WHERE id = {id} AND tenant_id = caller's tenant - `inst-replace-4a` + 2. [ ] - `p1` - **IF** no row matches - `inst-replace-4b` + 1. [ ] - `p1` - **RETURN** 404 Problem Details with `type` set to `gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1`, `X-OAGW-Error-Source: gateway` - `inst-replace-4b1` + 3. [ ] - `p1` - **ELSE** - `inst-replace-4c` + 1. [ ] - `p1` - Run `cpt-cf-oagw-algo-upstream-api-validate-replace` against the stored row and the new body - `inst-replace-4c1` + 2. [ ] - `p1` - **IF** validation fails (schema, alias-transition rejection, or bind re-validation) - `inst-replace-4c2` + 1. [ ] - `p1` - **RETURN** 400 ValidationError, `X-OAGW-Error-Source: gateway` - `inst-replace-4c2a` + 3. [ ] - `p1` - **ELSE** - `inst-replace-4c3` + 1. [ ] - `p1` - DB: UPDATE tenant-scoped store SET every field from the new body, clearing any optional field the body omits and resetting an omitted `enabled` to the schema default `true` regardless of the previously stored value, keeping `id`/`tenant_id`/`alias` from the stored row - `inst-replace-4c3a` + 2. [ ] - `p1` - **RETURN** 200 OK with the replaced Upstream document - `inst-replace-4c3b` + +### Delete Upstream Flow + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-upstream-api-delete` + +**Actor**: `cpt-cf-oagw-actor-platform-operator` + +**Success Scenarios**: +- An upstream owned by the calling tenant is removed and a subsequent GET on the same `id` + returns 404. + +**Error Scenarios**: +- `id` does not resolve to a upstream owned by the calling tenant (own-tenant absent, or + present only for an ancestor tenant): both cases answer 404. +- Caller lacks `gts.cf.core.oagw.upstream.v1~:delete`. + +**Steps**: +1. [ ] - `p1` - Operator requests deletion of an upstream by id - `inst-delete-1` +2. [ ] - `p1` - API: DELETE /oagw/v1/upstreams/{id} - `inst-delete-2` +3. [ ] - `p1` - **IF** caller lacks `gts.cf.core.oagw.upstream.v1~:delete` - `inst-delete-3` + 1. [ ] - `p1` - **RETURN** 401 AuthenticationFailed, `X-OAGW-Error-Source: gateway` - `inst-delete-3a` +4. [ ] - `p1` - **ELSE** - `inst-delete-4` + 1. [ ] - `p1` - DB: DELETE tenant-scoped store WHERE id = {id} AND tenant_id = caller's tenant - `inst-delete-4a` + 2. [ ] - `p1` - **IF** a row was removed - `inst-delete-4b` + 1. [ ] - `p1` - **RETURN** 204 No Content - `inst-delete-4b1` + 3. [ ] - `p1` - **ELSE** - `inst-delete-4c` + 1. [ ] - `p1` - **RETURN** 404 Problem Details with `type` set to `gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1`, `X-OAGW-Error-Source: gateway` - `inst-delete-4c1` + +## 3. Processes / Business Logic (CDSL) + +### Create Validation Algorithm + +- [ ] `p1` - **ID**: `cpt-cf-oagw-algo-upstream-api-validate-create` + +**Input**: Raw create-request body, calling tenant id, calling token's granted permissions + +**Output**: A normalized Upstream record ready to insert, or a list of validation errors + +**Steps**: +1. [ ] - `p1` - Validate the body against the Upstream JSON Schema shape (`server.endpoints` non-empty, `protocol` one of the two supported GTS protocol identifiers, `additionalProperties` rejected) - `inst-valc-1` +2. [ ] - `p1` - **FOR EACH** endpoint in `server.endpoints` - `inst-valc-2` + 1. [ ] - `p1` - **IF** `scheme` is not one of `https`, `wss`, `wt`, `grpc`, `http`, `ws` - `inst-valc-2a` + 1. [ ] - `p1` - Add error: unsupported scheme - `inst-valc-2a1` + 2. [ ] - `p1` - Validate `host` as hostname (RFC 1123) or IPv4/IPv6 literal, `port` in `1..65535` - `inst-valc-2b` +3. [ ] - `p1` - **IF** an explicit `alias` was supplied - `inst-valc-3` + 1. [ ] - `p1` - **IF** it fails the alias pattern `^[a-z0-9]([a-z0-9.:-]*[a-z0-9])?$` - `inst-valc-3a` + 1. [ ] - `p1` - Add error: invalid alias format - `inst-valc-3a1` +4. [ ] - `p1` - Compute `derived = compute_derived_alias(endpoints)` (hostname-based single or common-suffix pools; `None` for IP-based or non-derivable pools) - `inst-valc-4` +5. [ ] - `p1` - **IF** `derived` is `Some` and no explicit alias was supplied - `inst-valc-5` + 1. [ ] - `p1` - Use `derived`, normalized to ASCII lowercase with trailing dot stripped, as the effective alias - `inst-valc-5a` +6. [ ] - `p1` - **ELSE IF** `derived` is `Some` and an explicit alias was supplied - `inst-valc-6` + 1. [ ] - `p1` - **IF** the explicit alias equals `derived` (idempotent no-op) - `inst-valc-6a` + 1. [ ] - `p1` - Use `derived` as the effective alias - `inst-valc-6a1` + 2. [ ] - `p1` - **ELSE** - `inst-valc-6b` + 1. [ ] - `p1` - Add error: explicit alias conflicts with the derivable value - `inst-valc-6b1` +7. [ ] - `p1` - **ELSE IF** `derived` is `None` and no explicit alias was supplied - `inst-valc-7` + 1. [ ] - `p1` - Add error: alias required for IP-based or non-derivable endpoint sets - `inst-valc-7a` +8. [ ] - `p1` - **ELSE** - `inst-valc-8` + 1. [ ] - `p1` - Use the supplied explicit alias, normalized, as the effective alias - `inst-valc-8a` +9. [ ] - `p1` - **IF** no validation errors so far - `inst-valc-9` + 1. [ ] - `p1` - DB: SELECT tenant-scoped store WHERE tenant_id = caller's tenant AND alias = effective alias - `inst-valc-9a` + 2. [ ] - `p1` - **IF** found - `inst-valc-9b` + 1. [ ] - `p1` - **RETURN** conflict signal (409) rather than a validation error - `inst-valc-9b1` + 3. [ ] - `p1` - DB: SELECT ancestor tenant-scoped stores WHERE alias = effective alias, walking the tenant chain toward root - `inst-valc-9c` + 4. [ ] - `p1` - **IF** an ancestor upstream with the same alias is found - `inst-valc-9d` + 1. [ ] - `p1` - **IF** caller lacks `oagw:upstream:bind` - `inst-valc-9d1` + 1. [ ] - `p1` - Add error: bind permission required - `inst-valc-9d1a` + 2. [ ] - `p1` - **ELSE IF** the ancestor's `auth.sharing` is `enforce` - `inst-valc-9d2` + 1. [ ] - `p1` - Add error: ancestor enforces its configuration, override not allowed - `inst-valc-9d2a` + 3. [ ] - `p1` - **ELSE IF** the ancestor's `auth.sharing` is `private` - `inst-valc-9d3` + 1. [ ] - `p1` - Add error: ancestor configuration is private, not visible for binding - `inst-valc-9d3a` +10. [ ] - `p1` - **RETURN** normalized record when the error list is empty, otherwise the error list - `inst-valc-10` + +### Replace Validation Algorithm + +- [ ] `p1` - **ID**: `cpt-cf-oagw-algo-upstream-api-validate-replace` + +**Input**: Stored Upstream row, new request body, calling tenant id, calling token's granted permissions + +**Output**: A normalized Upstream record ready to persist, or a list of validation errors + +**Steps**: +1. [ ] - `p1` - **IF** the body sets `id` or `tenant_id` to a value different from the stored row - `inst-valr-1` + 1. [ ] - `p1` - Add error: `id` and `tenant_id` are immutable - `inst-valr-1a` +2. [ ] - `p1` - Validate the body against the Upstream JSON Schema shape, same as create - `inst-valr-2` +3. [ ] - `p1` - Compute `new_derived = compute_derived_alias(new body's endpoints)` and compare the stored row's endpoint shape to the new one to classify the transition as Derivable→Derivable, Derivable→Non-derivable, Non-derivable→Non-derivable, or Non-derivable→Derivable - `inst-valr-3` +4. [ ] - `p1` - **IF** the endpoint set is unchanged from the stored row - `inst-valr-4` + 1. [ ] - `p1` - **IF** the body's alias (if present) differs from the stored alias - `inst-valr-4a` + 1. [ ] - `p1` - Add error: alias override not allowed without an endpoint change - `inst-valr-4a1` + 2. [ ] - `p1` - **ELSE** - `inst-valr-4b` + 1. [ ] - `p1` - Retain the stored alias - `inst-valr-4b1` +5. [ ] - `p1` - **ELSE IF** transition is Derivable→Derivable - `inst-valr-5` + 1. [ ] - `p1` - **IF** `new_derived` equals the stored alias - `inst-valr-5a` + 1. [ ] - `p1` - Retain the stored alias - `inst-valr-5a1` + 2. [ ] - `p1` - **ELSE** - `inst-valr-5b` + 1. [ ] - `p1` - Add error: alias would change; delete and re-create instead - `inst-valr-5b1` +6. [ ] - `p1` - **ELSE IF** transition is Non-derivable→Non-derivable - `inst-valr-6` + 1. [ ] - `p1` - **IF** the body's explicit alias equals the stored alias - `inst-valr-6a` + 1. [ ] - `p1` - Retain the stored alias - `inst-valr-6a1` + 2. [ ] - `p1` - **ELSE** - `inst-valr-6b` + 1. [ ] - `p1` - Add error: a differing user-provided alias is not accepted - `inst-valr-6b1` +7. [ ] - `p1` - **ELSE** (transition is Derivable→Non-derivable or Non-derivable→Derivable) - `inst-valr-7` + 1. [ ] - `p1` - Add error: alias immutable across this transition; delete and re-create instead - `inst-valr-7a` +8. [ ] - `p1` - **IF** the new body's `auth`, `plugins`, endpoints, or alias differ from the stored row - `inst-valr-8` + 1. [ ] - `p1` - Re-run the ancestor-bind check from `cpt-cf-oagw-algo-upstream-api-validate-create` steps 9c-9d against the (possibly unchanged) effective alias - `inst-valr-8a` +9. [ ] - `p1` - **RETURN** normalized record when the error list is empty, otherwise the error list - `inst-valr-9` + +### List Query Algorithm + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-upstream-api-list-query` + +**Input**: Raw `$filter`, `$select`, `$orderby`, `$top`, `$skip` query string values + +**Output**: A parsed, bounded query descriptor consumed by the list flow's store lookup + +**Steps**: +1. [ ] - `p1` - Parse `$filter` as an OData boolean expression over Upstream fields (`alias`, `enabled`, `tags`, and nested `server`/`protocol` fields); reject unparseable expressions with a validation error - `inst-lq-1` +2. [ ] - `p1` - Parse `$select` as a comma-separated field list; empty or absent means all fields - `inst-lq-2` +3. [ ] - `p1` - Parse `$orderby` as `field [asc|desc]`; default direction is `asc` - `inst-lq-3` +4. [ ] - `p1` - **IF** `$top` is absent - `inst-lq-4` + 1. [ ] - `p1` - Use 50 - `inst-lq-4a` +5. [ ] - `p1` - **ELSE IF** `$top` exceeds 100 - `inst-lq-5` + 1. [ ] - `p1` - Clamp to 100 - `inst-lq-5a` +6. [ ] - `p1` - **IF** `$skip` is absent - `inst-lq-6` + 1. [ ] - `p1` - Use 0 - `inst-lq-6a` +7. [ ] - `p1` - **RETURN** the parsed descriptor - `inst-lq-7` + +## 4. States (CDSL) + +### Upstream Enabled State Machine + +- [ ] `p2` - **ID**: `cpt-cf-oagw-state-upstream-api-enabled` + +**States**: enabled, disabled, ancestor-disabled + +**Initial State**: enabled + +**Transitions**: +1. [ ] - `p1` - **FROM** enabled **TO** disabled **WHEN** a `PUT` on the same tenant's own upstream sets `enabled: false` - `inst-state-1` +2. [ ] - `p1` - **FROM** disabled **TO** enabled **WHEN** a `PUT` on the same tenant's own upstream sets `enabled: true`, and no ancestor upstream sharing the same alias is itself disabled - `inst-state-2` +3. [ ] - `p1` - **FROM** enabled **TO** ancestor-disabled **WHEN** the ancestor upstream that this tenant's upstream binds to (same alias, reached through the tenant chain) transitions to disabled - `inst-state-3` +4. [ ] - `p1` - **FROM** ancestor-disabled **TO** ancestor-disabled **WHEN** a descendant's `PUT` sets its own `enabled: true` while the ancestor remains disabled — the write to the descendant's own `enabled` field is accepted and stored, but the state used for authorization decisions in this feature (whether the descendant's `enabled` value is honored) stays `ancestor-disabled` until the ancestor upstream is re-enabled - `inst-state-4` + +## 5. Definitions of Done + +### Create Upstream Endpoint + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-upstream-api-create` + +The system **MUST** implement `POST /oagw/v1/upstreams` so it validates the request body, derives +or validates the alias, enforces `(tenant_id, alias)` uniqueness with a `409 Conflict` on +collision, applies the ancestor-alias bind check (`oagw:upstream:bind` plus sharing-mode +rules), accepts `http`- and `ws`-scheme endpoints unconditionally at validation time, and +returns `201 Created` with a server-generated UUID `id`. + +**Implements**: +- `cpt-cf-oagw-flow-upstream-api-create` +- `cpt-cf-oagw-algo-upstream-api-validate-create` + +**Constraints**: `cpt-cf-oagw-constraint-multi-sql` + +**Touches**: +- API: `POST /oagw/v1/upstreams` +- DB: `cpt-cf-oagw-db-schema` +- Entities: `Upstream`, `ServerConfig`, `Endpoint` + +### List and Get Upstream Endpoints + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-upstream-api-list-get` + +The system **MUST** implement `GET /oagw/v1/upstreams` with `$filter`, `$select`, `$orderby`, +`$top` (default 50, max 100), and `$skip`, and `GET /oagw/v1/upstreams/{id}`, both scoped +strictly to the calling tenant so an ancestor's upstream, or an unknown `id`, answers `404`. + +**Implements**: +- `cpt-cf-oagw-flow-upstream-api-list` +- `cpt-cf-oagw-flow-upstream-api-get` +- `cpt-cf-oagw-algo-upstream-api-list-query` + +**Touches**: +- API: `GET /oagw/v1/upstreams` +- API: `GET /oagw/v1/upstreams/{id}` +- DB: `cpt-cf-oagw-db-schema` +- Entities: `Upstream` + +### Replace Upstream Endpoint + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-upstream-api-replace` + +The system **MUST** implement `PUT /oagw/v1/upstreams/{id}` as a full-document replace that +clears any optional field the request omits, keeps `id` and `tenant_id` immutable, applies the +alias-transition table (reject any request that would change a derived or explicit alias), and +re-validates the ancestor-bind constraint when overrides, endpoints, or alias changed. + +**Implements**: +- `cpt-cf-oagw-flow-upstream-api-replace` +- `cpt-cf-oagw-algo-upstream-api-validate-replace` + +**Constraints**: `cpt-cf-oagw-constraint-multi-sql` + +**Touches**: +- API: `PUT /oagw/v1/upstreams/{id}` +- DB: `cpt-cf-oagw-db-schema` +- Entities: `Upstream` + +### Delete Upstream Endpoint + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-upstream-api-delete` + +The system **MUST** implement `DELETE /oagw/v1/upstreams/{id}` scoped to the calling tenant, +returning `204 No Content` on success and `404` for an unknown or ancestor-owned `id`, and a +subsequent `GET` on the deleted `id` **MUST** also answer `404`. + +**Implements**: +- `cpt-cf-oagw-flow-upstream-api-delete` + +**Touches**: +- API: `DELETE /oagw/v1/upstreams/{id}` +- DB: `cpt-cf-oagw-db-schema` +- Entities: `Upstream` + +### Enabled/Disabled Semantics + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-upstream-api-enabled` + +The system **MUST** default `enabled` to `true` when a create request omits it, store the +explicit value when supplied, and reset `enabled` to the same schema default `true` whenever a +replace request omits it, even if the stored upstream was `enabled: false` beforehand — full +replacement never carries forward a previous value for an omitted field. The system **MUST** +also honor the rule that a descendant tenant cannot re-enable an upstream whose ancestor, +sharing the same alias, is disabled — the descendant's own stored `enabled` value persists as +written but has no effect while the ancestor stays disabled. + +**Implements**: +- `cpt-cf-oagw-state-upstream-api-enabled` + +**Touches**: +- API: `POST /oagw/v1/upstreams` +- API: `PUT /oagw/v1/upstreams/{id}` +- DB: `cpt-cf-oagw-db-schema` +- Entities: `Upstream` + +### Per-Endpoint Authorization + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-upstream-api-authz` + +The system **MUST** gate every one of the five endpoints on its own permission — +`gts.cf.core.oagw.upstream.v1~:create` for `POST`, `:read` for both `GET` forms, +`:override` for `PUT`, and `:delete` for `DELETE` — and additionally require +`oagw:upstream:bind` whenever a create or replace request's effective alias matches an +ancestor tenant's upstream alias. + +**Implements**: +- `cpt-cf-oagw-flow-upstream-api-create` +- `cpt-cf-oagw-flow-upstream-api-list` +- `cpt-cf-oagw-flow-upstream-api-get` +- `cpt-cf-oagw-flow-upstream-api-replace` +- `cpt-cf-oagw-flow-upstream-api-delete` + +**Touches**: +- API: `POST /oagw/v1/upstreams` +- API: `GET /oagw/v1/upstreams` +- API: `GET /oagw/v1/upstreams/{id}` +- API: `PUT /oagw/v1/upstreams/{id}` +- API: `DELETE /oagw/v1/upstreams/{id}` +- Entities: `Upstream` + +### RFC 9457 Error Responses + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-upstream-api-errors` + +The system **MUST** report every error from these five endpoints as an RFC 9457 +`application/problem+json` document carrying one of four GTS `type` identifiers: +`gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1` for `400`, +`gts.cf.core.errors.err.v1~cf.oagw.auth.failed.v1` for `401`, +`gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1` for `404`, and +`gts.cf.core.errors.err.v1~cf.oagw.upstream.alias_conflict.v1` for `409`. The system **MUST** +also attach `X-OAGW-Error-Source: gateway` to every one of these responses, since none of them +is a passthrough from an upstream service. The `400` and `401` identifiers come directly from +DESIGN.md's error table. DESIGN.md defines only one `404` identifier for the whole gateway, so +this management API reuses that same `route.not_found` identifier for every resource-not-found +case described below, instead of minting an upstream-specific one. DESIGN.md's error table has +no `409` row for an alias conflict; its only `409` entry is `PluginInUse`, which covers a +different resource entirely. This feature therefore introduces the `alias_conflict` identifier +above as the single literal every implementation of the create and replace flows must emit. +Separately, `cpt-cf-oagw-fr-error-codes` in PRD.md lists a closed set of error codes with no +`409` row at all, so that table is not exhaustive for this feature's conflict case, a gap that +DECOMPOSITION.md and DESIGN.md both corroborate by documenting a `409 Conflict` response +elsewhere. + +**Implements**: +- `cpt-cf-oagw-flow-upstream-api-create` +- `cpt-cf-oagw-flow-upstream-api-list` +- `cpt-cf-oagw-flow-upstream-api-get` +- `cpt-cf-oagw-flow-upstream-api-replace` +- `cpt-cf-oagw-flow-upstream-api-delete` + +**Constraints**: `cpt-cf-oagw-constraint-https-only` + +**Touches**: +- API: `POST /oagw/v1/upstreams` +- API: `GET /oagw/v1/upstreams` +- API: `GET /oagw/v1/upstreams/{id}` +- API: `PUT /oagw/v1/upstreams/{id}` +- API: `DELETE /oagw/v1/upstreams/{id}` +- Entities: `Upstream` + +## 6. Acceptance Criteria + +- [ ] `POST /oagw/v1/upstreams` with a valid body returns `201 Created` and a server-generated `id` not present in the request. +- [ ] `POST /oagw/v1/upstreams` with an endpoint of `scheme: "http"` and `port: 80` succeeds and returns `201 Created`. +- [ ] `POST /oagw/v1/upstreams` with an alias that already exists for the calling tenant returns `409 Conflict` as `application/problem+json` with `type` set to `gts.cf.core.errors.err.v1~cf.oagw.upstream.alias_conflict.v1`. +- [ ] `GET /oagw/v1/upstreams/{id}` for an id unknown to the calling tenant returns `404` as `application/problem+json` with `type` set to `gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1` and `X-OAGW-Error-Source: gateway`. +- [ ] `PUT /oagw/v1/upstreams/{id}` with a body that omits a previously-set `tags` array stores `tags` as cleared, and the subsequent `GET` returns `tags` as an empty array `[]`. +- [ ] `PUT /oagw/v1/upstreams/{id}` with a body that omits `enabled` on an upstream currently stored as `enabled: false` returns the replaced document with `enabled: true`, and the subsequent `GET` confirms the reset value. +- [ ] `PUT /oagw/v1/upstreams/{id}` whose endpoint change would alter the alias returns `400 ValidationError`. +- [ ] `DELETE /oagw/v1/upstreams/{id}` returns `204 No Content`, and a subsequent `GET /oagw/v1/upstreams/{id}` on the same id returns `404`. +- [ ] `GET /oagw/v1/upstreams?$top=1` returns at most one result when more than one upstream exists for the calling tenant. +- [ ] `GET /oagw/v1/upstreams?$filter=` with an unparseable expression returns `400 ValidationError` as `application/problem+json` with `X-OAGW-Error-Source: gateway`, rather than an empty or error-free result set. +- [ ] A request to any of the five endpoints without a valid Bearer token, or with a token lacking the endpoint's required permission, returns `401`. +- [ ] Creating an upstream whose alias matches an ancestor tenant's alias without `oagw:upstream:bind` returns `400 ValidationError` rather than silently binding. diff --git a/gears/system/oagw/oagw/Cargo.toml b/gears/system/oagw/oagw/Cargo.toml index a18b934..ab8bfdf 100644 --- a/gears/system/oagw/oagw/Cargo.toml +++ b/gears/system/oagw/oagw/Cargo.toml @@ -32,6 +32,9 @@ test-utils = [ "tokio/rt", ] +[lints] +workspace = true + [dependencies] toolkit = { workspace = true } toolkit-auth = { workspace = true } @@ -42,7 +45,7 @@ toolkit-security = { workspace = true } toolkit-macros = { workspace = true } inventory = { workspace = true } async-trait = { workspace = true } -axum = { workspace = true } +axum = { workspace = true, features = ["ws"] } http = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } @@ -85,6 +88,7 @@ pingora-core = { version = "0.8", features = ["rustls"] } pingora-load-balancing = { version = "0.8", features = ["rustls"] } pingora-http = { version = "0.8.1" } httparse = "1" +tokio-tungstenite = "0.29" # test-utils optional deps async-stream = { workspace = true, optional = true } tower = { workspace = true, features = ["util"], optional = true } diff --git a/gears/system/oagw/oagw/src/api/mod.rs b/gears/system/oagw/oagw/src/api/mod.rs new file mode 100644 index 0000000..7d4c738 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/mod.rs @@ -0,0 +1,3 @@ +//! Transport layer. + +pub mod rest; diff --git a/gears/system/oagw/oagw/src/api/rest/dto.rs b/gears/system/oagw/oagw/src/api/rest/dto.rs new file mode 100644 index 0000000..6262b8e --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/dto.rs @@ -0,0 +1,378 @@ +//! Request and response shapes for the management API. + +use crate::domain::model::{ + AuthConfig, CorsConfig, HeadersConfig, MatchConfig, PluginType, PluginsConfig, RateLimitConfig, + ServerConfig, TransformPhase, +}; +use uuid::Uuid; + +/// Create or replace an upstream. +#[derive(Clone)] +#[toolkit_macros::api_dto(request, response)] +#[serde(deny_unknown_fields)] +pub struct UpstreamWriteDto { + /// Server-generated identifier, accepted and ignored so a document read + /// back from the API can be written straight back. It is immutable. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub id: Option, + /// Raw identifier, accepted and ignored for the same reason. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub uuid: Option, + /// Routing key; derived from hostname endpoints when omitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub alias: Option, + /// Whether the upstream accepts traffic. + #[serde(default = "default_true")] + pub enabled: bool, + /// Endpoint pool. + #[schema(value_type = Object)] + pub server: ServerConfig, + /// Protocol classification. + pub protocol: String, + /// Discovery tags. + #[serde(default)] + pub tags: Vec, + /// Outbound credential configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub auth: Option, + /// Header transformation rules. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub headers: Option, + /// Plugin bindings. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub plugins: Option, + /// Rate limit. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub rate_limit: Option, + /// Cross-origin policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub cors: Option, +} + +const fn default_true() -> bool { + true +} + +/// An upstream as returned by the management API. +#[derive(Clone)] +#[toolkit_macros::api_dto(response)] +pub struct UpstreamDto { + /// Anonymous global type system identifier. + pub id: String, + /// Raw identifier. + #[schema(value_type = String)] + pub uuid: Uuid, + /// Routing key. + pub alias: String, + /// Whether the upstream accepts traffic. + pub enabled: bool, + /// Endpoint pool. + #[schema(value_type = Object)] + pub server: ServerConfig, + /// Protocol classification. + pub protocol: String, + /// Discovery tags. + pub tags: Vec, + /// Outbound credential configuration. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub auth: Option, + /// Header transformation rules. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub headers: Option, + /// Plugin bindings. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub plugins: Option, + /// Rate limit. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub rate_limit: Option, + /// Cross-origin policy. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub cors: Option, +} + +/// A page of upstreams. +#[derive(Clone)] +#[toolkit_macros::api_dto(response)] +pub struct UpstreamListDto { + /// The page's items. + pub items: Vec, + /// Number of items in this page. + pub count: usize, +} + +/// Create a route. +#[derive(Clone)] +#[toolkit_macros::api_dto(request, response)] +#[serde(deny_unknown_fields)] +pub struct RouteCreateDto { + /// Server-generated identifier, accepted and ignored so a document read + /// back from the API can be written straight back. It is immutable. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub id: Option, + /// Raw identifier, accepted and ignored for the same reason. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub uuid: Option, + /// The upstream this route belongs to. + #[schema(value_type = String)] + pub upstream_id: Uuid, + /// Whether the route participates in matching. + #[serde(default = "default_true")] + pub enabled: bool, + /// Match rule; exactly one of `http` or `grpc`. + #[serde(rename = "match")] + #[schema(value_type = Object)] + pub match_config: MatchConfig, + /// Discovery tags. + #[serde(default)] + pub tags: Vec, + /// Plugin bindings. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub plugins: Option, + /// Rate limit. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub rate_limit: Option, + /// Cross-origin policy; overrides the upstream's when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub cors: Option, +} + +/// Replace a route. The upstream is immutable, so it is absent here. +#[derive(Clone)] +#[toolkit_macros::api_dto(request, response)] +#[serde(deny_unknown_fields)] +pub struct RouteReplaceDto { + /// Server-generated identifier, accepted and ignored so a document read + /// back from the API can be written straight back. It is immutable. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub id: Option, + /// Raw identifier, accepted and ignored for the same reason. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub uuid: Option, + /// The upstream a route belongs to is immutable, so a value supplied here + /// is accepted and ignored rather than rejected; the stored one is kept. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub upstream_id: Option, + /// Whether the route participates in matching. + #[serde(default = "default_true")] + pub enabled: bool, + /// Match rule; exactly one of `http` or `grpc`. + #[serde(rename = "match")] + #[schema(value_type = Object)] + pub match_config: MatchConfig, + /// Discovery tags. + #[serde(default)] + pub tags: Vec, + /// Plugin bindings. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub plugins: Option, + /// Rate limit. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub rate_limit: Option, + /// Cross-origin policy; overrides the upstream's when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub cors: Option, +} + +/// A route as returned by the management API. +#[derive(Clone)] +#[toolkit_macros::api_dto(response)] +pub struct RouteDto { + /// Anonymous global type system identifier. + pub id: String, + /// Raw identifier. + #[schema(value_type = String)] + pub uuid: Uuid, + /// The upstream this route belongs to. + #[schema(value_type = String)] + pub upstream_id: Uuid, + /// Whether the route participates in matching. + pub enabled: bool, + /// Match rule. + #[serde(rename = "match")] + #[schema(value_type = Object)] + pub match_config: MatchConfig, + /// Discovery tags. + pub tags: Vec, + /// Plugin bindings. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub plugins: Option, + /// Rate limit. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub rate_limit: Option, + /// Cross-origin policy. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub cors: Option, +} + +/// A page of routes. +#[derive(Clone)] +#[toolkit_macros::api_dto(response)] +pub struct RouteListDto { + /// The page's items. + pub items: Vec, + /// Number of items in this page. + pub count: usize, +} + +/// Create a custom plugin. +#[derive(Clone)] +#[toolkit_macros::api_dto(request, response)] +#[serde(deny_unknown_fields)] +pub struct PluginCreateDto { + /// Server-generated identifier, accepted and ignored so a document read + /// back from the API can be written straight back. It is immutable. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub id: Option, + /// Raw identifier, accepted and ignored for the same reason. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option)] + pub uuid: Option, + /// Kind of plugin. + #[schema(value_type = String)] + pub plugin_type: PluginType, + /// Unique name within the tenant. + pub name: String, + /// Human-readable description. + #[serde(default)] + pub description: String, + /// Schema the plugin's configuration must satisfy. + #[serde(default)] + #[schema(value_type = Object)] + pub config_schema: serde_json::Value, + /// Phases a transform plugin participates in. + #[serde(default)] + #[schema(value_type = Vec)] + pub phases: Vec, + /// Plugin source, stored verbatim and never executed in this build. + #[serde(default)] + pub source_code: String, +} + +/// A plugin as returned by the management API. +#[derive(Clone)] +#[toolkit_macros::api_dto(response)] +pub struct PluginDto { + /// Anonymous global type system identifier. + pub id: String, + /// Raw identifier. + #[schema(value_type = String)] + pub uuid: Uuid, + /// Kind of plugin. + #[schema(value_type = String)] + pub plugin_type: PluginType, + /// Unique name within the tenant. + pub name: String, + /// Human-readable description. + pub description: String, + /// Schema the plugin's configuration must satisfy. + #[schema(value_type = Object)] + pub config_schema: serde_json::Value, + /// Phases a transform plugin participates in. + #[schema(value_type = Vec)] + pub phases: Vec, + /// Plugin source, stored verbatim. + pub source_code: String, +} + +/// A page of plugins. +#[derive(Clone)] +#[toolkit_macros::api_dto(response)] +pub struct PluginListDto { + /// The page's items. + pub items: Vec, + /// Number of items in this page. + pub count: usize, +} + +/// Query parameters accepted by the list endpoints. +#[derive(Debug, Clone, Default, serde::Deserialize)] +pub struct ListQuery { + /// Maximum number of results; defaults to 50 and is capped at 100. + #[serde(rename = "$top")] + pub top: Option, + /// Offset into the result set. + #[serde(rename = "$skip")] + pub skip: Option, + /// Filter expression. + #[serde(rename = "$filter")] + pub filter: Option, + /// Fields to return. + #[serde(rename = "$select")] + pub select: Option, + /// Sort order. + #[serde(rename = "$orderby")] + pub orderby: Option, +} + +/// Default page size for the list endpoints. +pub const DEFAULT_TOP: usize = 50; +/// Maximum page size for the list endpoints. +pub const MAX_TOP: usize = 100; + +impl ListQuery { + /// Effective page size, defaulted and clamped. + #[must_use] + pub fn effective_top(&self) -> usize { + self.top.unwrap_or(DEFAULT_TOP).min(MAX_TOP) + } + + /// Effective offset. + #[must_use] + pub fn effective_skip(&self) -> usize { + self.skip.unwrap_or(0) + } +} + +#[cfg(test)] +mod tests { + use super::{DEFAULT_TOP, ListQuery, MAX_TOP}; + + #[test] + fn page_size_defaults_and_clamps() { + let empty = ListQuery::default(); + assert_eq!(empty.effective_top(), DEFAULT_TOP); + assert_eq!(empty.effective_skip(), 0); + + let large = ListQuery { + top: Some(5_000), + ..ListQuery::default() + }; + assert_eq!(large.effective_top(), MAX_TOP); + + let small = ListQuery { + top: Some(3), + skip: Some(7), + ..ListQuery::default() + }; + assert_eq!(small.effective_top(), 3); + assert_eq!(small.effective_skip(), 7); + } +} diff --git a/gears/system/oagw/oagw/src/api/rest/error.rs b/gears/system/oagw/oagw/src/api/rest/error.rs new file mode 100644 index 0000000..3e4298e --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/error.rs @@ -0,0 +1,90 @@ +// @cpt-begin:cpt-cf-oagw-dod-gear-foundation-error-model:p1:inst-error +//! Mapping from domain errors onto the wire. +//! +//! Gateway errors are RFC 9457 problem documents carrying a global type system +//! identifier. Every response the gear returns carries `X-OAGW-Error-Source`, +//! so a caller can tell a gateway failure from a relayed upstream one. + +use crate::domain::error::{DomainError, ERROR_SOURCE_HEADER, ErrorSource}; +use axum::response::{IntoResponse, Response}; +use http::{HeaderValue, StatusCode}; +use toolkit_canonical_errors::Problem; + +/// Stamp the error-source header onto a response. +pub fn set_error_source(response: &mut Response, source: ErrorSource) { + response.headers_mut().insert( + ERROR_SOURCE_HEADER, + HeaderValue::from_static(source.as_str()), + ); +} + +/// Render a domain error as a gateway problem response. +#[must_use] +pub fn problem_response(error: &DomainError) -> Response { + let status = error.kind.status(); + let problem = Problem { + problem_type: error.kind.gts_type().to_owned(), + title: error.kind.title().to_owned(), + status, + detail: error.detail.clone(), + instance: None, + trace_id: None, + context: error.context.clone(), + error_code: None, + error_domain: Some("oagw.v1".to_owned()), + }; + let mut response = problem.into_response(); + *response.status_mut() = + StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + set_error_source(&mut response, ErrorSource::Gateway); + response +} + +impl IntoResponse for DomainError { + fn into_response(self) -> Response { + problem_response(&self) + } +} +// @cpt-end:cpt-cf-oagw-dod-gear-foundation-error-model:p1:inst-error + +#[cfg(test)] +mod tests { + use super::problem_response; + use crate::domain::error::{DomainError, ERROR_SOURCE_HEADER, ErrorKind}; + + #[test] + fn a_gateway_error_is_a_problem_document_marked_gateway() { + let error = DomainError::new(ErrorKind::RouteNotFound, "no upstream with that alias"); + let response = problem_response(&error); + assert_eq!(response.status(), 404); + assert_eq!( + response + .headers() + .get(ERROR_SOURCE_HEADER) + .expect("error source header"), + "gateway" + ); + assert_eq!( + response + .headers() + .get(http::header::CONTENT_TYPE) + .expect("content type"), + "application/problem+json" + ); + } + + #[test] + fn the_status_follows_the_error_kind() { + for (kind, expected) in [ + (ErrorKind::ValidationError, 400), + (ErrorKind::UpstreamAliasConflict, 409), + (ErrorKind::RouteMatchConflict, 409), + (ErrorKind::PayloadTooLarge, 413), + (ErrorKind::RateLimitExceeded, 429), + (ErrorKind::RequestTimeout, 504), + ] { + let response = problem_response(&DomainError::new(kind, "x")); + assert_eq!(response.status(), expected, "{kind:?}"); + } + } +} diff --git a/gears/system/oagw/oagw/src/api/rest/handlers/mod.rs b/gears/system/oagw/oagw/src/api/rest/handlers/mod.rs new file mode 100644 index 0000000..a07842e --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers/mod.rs @@ -0,0 +1,6 @@ +//! HTTP handlers for the management and proxy surfaces. + +pub mod plugins; +pub mod proxy; +pub mod routes; +pub mod upstreams; diff --git a/gears/system/oagw/oagw/src/api/rest/handlers/plugins.rs b/gears/system/oagw/oagw/src/api/rest/handlers/plugins.rs new file mode 100644 index 0000000..15e4f7e --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers/plugins.rs @@ -0,0 +1,243 @@ +// @cpt-begin:cpt-cf-oagw-dod-plugin-api-create:p2:inst-plugin-handlers +//! Plugin catalogue handlers. +//! +//! Plugins are immutable after creation, so there is deliberately no replace +//! endpoint. Custom plugin source is stored verbatim and never executed in this +//! build, because no sandbox runtime is enabled. + +use crate::api::rest::dto::{ListQuery, PluginCreateDto, PluginDto, PluginListDto}; +use crate::api::rest::error::set_error_source; +use crate::api::rest::handlers::upstreams::{parse_id, validate_list_query}; +use crate::api::rest::state::OagwState; +use crate::domain::error::{DomainError, ErrorKind, ErrorSource}; +use crate::domain::model::{Plugin, PluginType, gts_resource_id}; +use crate::infra::store::PluginDeleteError; +use axum::extract::{Extension, Path, Query}; +use axum::response::{IntoResponse, Response}; +use http::StatusCode; +use std::sync::Arc; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn plugin_kind_segment(plugin_type: PluginType) -> &'static str { + match plugin_type { + PluginType::Auth => "auth_plugin", + PluginType::Guard => "guard_plugin", + PluginType::Transform => "transform_plugin", + } +} + +fn to_dto(plugin: Plugin) -> PluginDto { + PluginDto { + id: gts_resource_id(plugin_kind_segment(plugin.plugin_type), plugin.id), + uuid: plugin.id, + plugin_type: plugin.plugin_type, + name: plugin.name, + description: plugin.description, + config_schema: plugin.config_schema, + phases: plugin.phases, + source_code: plugin.source_code, + } +} + +/// Validate that `config_schema` is a syntactically plausible JSON Schema +/// object. +/// +/// This is a modest, dependency-free check: it confirms the document is +/// either absent (`null`) or an object, and that two commonly-misused members +/// have the shape JSON Schema requires when they are present. Full schema +/// compilation (draft resolution, `$ref` handling, keyword validation, and so +/// on) is out of scope for this build; a malformed schema that passes here can +/// still be rejected by a future, stricter validator without a contract +/// change. +/// +/// # Errors +/// Returns a validation error when `config_schema` is not `null` or an +/// object, when its `type` member is present but is neither a string nor an +/// array of strings, or when its `properties` member is present but is not an +/// object. +fn validate_config_schema(schema: &serde_json::Value) -> Result<(), DomainError> { + if schema.is_null() { + return Ok(()); + } + let Some(object) = schema.as_object() else { + return Err(DomainError::validation( + "config_schema must be a JSON Schema object", + )); + }; + if let Some(type_member) = object.get("type") { + let is_string_or_string_array = type_member.is_string() + || type_member + .as_array() + .is_some_and(|items| items.iter().all(serde_json::Value::is_string)); + if !is_string_or_string_array { + return Err(DomainError::validation( + "config_schema.type must be a string or an array of strings", + )); + } + } + if let Some(properties) = object.get("properties") + && !properties.is_object() + { + return Err(DomainError::validation( + "config_schema.properties must be an object", + )); + } + Ok(()) +} + +fn json_ok(status: StatusCode, body: &T) -> Response { + let mut response = (status, axum::Json(body)).into_response(); + set_error_source(&mut response, ErrorSource::Gateway); + response +} + +/// Create a custom plugin. +/// +/// # Errors +/// Returns a validation error on a malformed body and a conflict when the name +/// is already taken within the tenant. +pub async fn create( + Extension(ctx): Extension, + Extension(state): Extension>, + axum::Json(body): axum::Json, +) -> Result { + if body.name.trim().is_empty() { + return Err(DomainError::validation("name must not be empty")); + } + if body.source_code.trim().is_empty() { + return Err(DomainError::validation("source_code must not be empty")); + } + if body.plugin_type == PluginType::Transform { + if body.phases.is_empty() { + return Err(DomainError::validation( + "phases must not be empty for a transform plugin", + )); + } + } else if !body.phases.is_empty() { + return Err(DomainError::validation( + "phases may only be supplied for a transform plugin", + )); + } + validate_config_schema(&body.config_schema)?; + let plugin = Plugin { + id: Uuid::new_v4(), + tenant_id: ctx.subject_tenant_id(), + plugin_type: body.plugin_type, + name: body.name, + description: body.description, + config_schema: body.config_schema, + phases: body.phases, + source_code: body.source_code, + }; + let created = state.store.create_plugin(plugin)?; + Ok(json_ok(StatusCode::CREATED, &to_dto(created))) +} + +/// List the tenant's plugins. +/// +/// # Errors +/// Returns a validation error when a query parameter is malformed. +pub async fn list( + Extension(ctx): Extension, + Extension(state): Extension>, + Query(query): Query, +) -> Result { + validate_list_query(&query)?; + let mut items: Vec = state + .store + .list_plugins(ctx.subject_tenant_id()) + .into_iter() + .map(to_dto) + .collect(); + items.sort_by(|a, b| a.name.cmp(&b.name)); + let page: Vec = items + .into_iter() + .skip(query.effective_skip()) + .take(query.effective_top()) + .collect(); + let count = page.len(); + Ok(json_ok( + StatusCode::OK, + &PluginListDto { items: page, count }, + )) +} + +/// Fetch one plugin, including its stored source. +/// +/// # Errors +/// Returns not-found when the tenant does not own a plugin with that id. +pub async fn get( + Extension(ctx): Extension, + Extension(state): Extension>, + Path(id): Path, +) -> Result { + let id = parse_id(&id)?; + let found = state + .store + .get_plugin(ctx.subject_tenant_id(), id) + .ok_or_else(|| DomainError::not_found("plugin not found"))?; + Ok(json_ok(StatusCode::OK, &to_dto(found))) +} + +/// Fetch a plugin's source verbatim. +/// +/// # Errors +/// Returns not-found when the tenant does not own a plugin with that id. +pub async fn get_source( + Extension(ctx): Extension, + Extension(state): Extension>, + Path(id): Path, +) -> Result { + let id = parse_id(&id)?; + let found = state + .store + .get_plugin(ctx.subject_tenant_id(), id) + .ok_or_else(|| DomainError::not_found("plugin not found"))?; + let mut response = ( + StatusCode::OK, + [(http::header::CONTENT_TYPE, "text/plain; charset=utf-8")], + found.source_code, + ) + .into_response(); + set_error_source(&mut response, ErrorSource::Gateway); + Ok(response) +} + +/// Delete a plugin that nothing references. +/// +/// # Errors +/// Returns not-found when the plugin does not exist for the tenant, and a +/// conflict naming every referring upstream and route when it is still bound. +pub async fn delete( + Extension(ctx): Extension, + Extension(state): Extension>, + Path(id): Path, +) -> Result { + let id = parse_id(&id)?; + let tenant_id = ctx.subject_tenant_id(); + // A single store call performs the existence check, the reference scan + // and the removal under one write guard, so a concurrent request cannot + // bind the plugin between the scan and the delete. + match state.store.delete_plugin_checked(tenant_id, id) { + Ok(()) => { + let mut response = StatusCode::NO_CONTENT.into_response(); + set_error_source(&mut response, ErrorSource::Gateway); + Ok(response) + } + Err(PluginDeleteError::NotFound) => Err(DomainError::not_found("plugin not found")), + Err(PluginDeleteError::StillReferenced(references)) => { + let upstreams: Vec = references.upstreams.iter().map(Uuid::to_string).collect(); + let routes: Vec = references.routes.iter().map(Uuid::to_string).collect(); + Err(DomainError::new( + ErrorKind::PluginInUse, + "plugin is still referenced and cannot be deleted", + ) + .with_context(serde_json::json!({ + "plugin_id": id.to_string(), + "referenced_by": { "upstreams": upstreams, "routes": routes }, + }))) + } + } +} +// @cpt-end:cpt-cf-oagw-dod-plugin-api-create:p2:inst-plugin-handlers diff --git a/gears/system/oagw/oagw/src/api/rest/handlers/proxy.rs b/gears/system/oagw/oagw/src/api/rest/handlers/proxy.rs new file mode 100644 index 0000000..88fe408 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers/proxy.rs @@ -0,0 +1,1219 @@ +// @cpt-begin:cpt-cf-oagw-dod-proxy-http-response-relay:p1:inst-proxy-handler +//! The data-plane proxy handler. +//! +//! One handler serves plain requests, server-sent-event streams and WebSocket +//! upgrades. It resolves the upstream by alias, matches a route, applies the +//! guards and the policy layer, forwards the request, and relays the response. + +use crate::api::rest::error::{problem_response, set_error_source}; +use crate::api::rest::state::OagwState; +use crate::domain::error::{DomainError, DomainResult, ErrorKind, ErrorSource}; +use crate::domain::model::{CorsConfig, Route, Upstream}; +use crate::infra::oauth2::{ClientAuthStyle, TokenCache, effective_ttl, fetch_token}; +use crate::infra::plugins::{ + GuardPhase, PluginClass, apply_auth_plugin, apply_guard_plugin, apply_transform_plugin, + classify_plugin, +}; +use crate::infra::proxy::{ + TARGET_HOST_HEADER, apply_guards, apply_request_header_rules, apply_response_header_rules, + build_outbound_headers, build_upstream_path, build_upstream_url, build_websocket_url, + check_plaintext_allowed, is_event_stream, is_websocket_upgrade, match_route, + reject_relative_path_segments, select_endpoint, ssrf_check, +}; +use crate::infra::ratelimit::{Admission, RateLimiter}; +use axum::body::Body; +use axum::extract::{Extension, Path, Request}; +use axum::response::{IntoResponse, Response}; +use futures_util::{SinkExt, StreamExt}; +use http::{HeaderMap, HeaderValue, Method, StatusCode}; +use std::sync::Arc; +use std::time::Duration; +use toolkit_security::SecurityContext; + +/// Maximum buffered request body, in bytes. +const MAX_BODY: usize = crate::infra::proxy::MAX_BODY_BYTES; + +/// Handle a proxy request whose path carries no suffix. +pub async fn proxy_root( + ctx: Extension, + state: Extension>, + Path(alias): Path, + request: Request, +) -> Response { + dispatch(ctx, state, alias, String::new(), request).await +} + +/// Handle a proxy request that carries a trailing path suffix. +pub async fn proxy_with_path( + ctx: Extension, + state: Extension>, + Path((alias, rest)): Path<(String, String)>, + request: Request, +) -> Response { + dispatch(ctx, state, alias, rest, request).await +} + +/// Answer an `OPTIONS` request on a suffix-less proxy path. +/// +/// A genuine cross-origin preflight is answered permissively; anything else +/// receives a plain `404` gateway problem. Neither case reads the security +/// context, touches the store, or runs any part of the forwarding pipeline: +/// the registration is anonymous specifically so this can run without a +/// resolved tenant. +#[allow( + clippy::unused_async, + reason = "axum's Handler trait is only implemented for an async fn; the body never awaits \ + because it deliberately runs no part of the forwarding pipeline" +)] +pub async fn preflight_root(headers: HeaderMap) -> Response { + answer_options(&headers) +} + +/// Answer an `OPTIONS` request on a proxy path that carries a trailing path +/// suffix. +/// +/// See [`preflight_root`]; the suffix plays no part in a preflight decision. +#[allow( + clippy::unused_async, + reason = "axum's Handler trait is only implemented for an async fn; the body never awaits \ + because it deliberately runs no part of the forwarding pipeline" +)] +pub async fn preflight_with_path(headers: HeaderMap) -> Response { + answer_options(&headers) +} + +/// Answer an `OPTIONS` request without resolving a tenant or an upstream. +fn answer_options(headers: &HeaderMap) -> Response { + if is_preflight(headers) { + preflight_response(headers) + } else { + problem_response(&DomainError::not_found( + "this endpoint answers only a cross-origin preflight", + )) + } +} + +/// Resolve, guard and forward one proxy request. +async fn dispatch( + Extension(ctx): Extension, + Extension(state): Extension>, + alias: String, + rest: String, + request: Request, +) -> Response { + match forward(&ctx, &state, &alias, &rest, request).await { + Ok(response) => response, + Err(error) => problem_response(&error), + } +} + +/// The proxy pipeline. +#[allow(clippy::too_many_lines)] +async fn forward( + ctx: &SecurityContext, + state: &Arc, + alias: &str, + rest: &str, + request: Request, +) -> DomainResult { + let tenant_id = ctx.subject_tenant_id(); + let method = request.method().clone(); + let inbound_headers = request.headers().clone(); + let query = request.uri().query().unwrap_or("").to_owned(); + let suffix = if rest.is_empty() { + String::new() + } else { + format!("/{}", rest.trim_start_matches('/')) + }; + + // A `.` or `..` segment could otherwise escape the route's path prefix + // once an upstream that normalizes dot segments receives it. This runs + // before route matching so a crafted suffix cannot even influence which + // route is chosen. + reject_relative_path_segments(&suffix)?; + + // ---- alias resolution ------------------------------------------------ + let upstream = state + .store + .find_upstream_by_alias(tenant_id, alias) + .ok_or_else(|| { + DomainError::new( + ErrorKind::RouteNotFound, + format!("no upstream is registered for alias `{alias}`"), + ) + })?; + if !upstream.enabled { + return Err(DomainError::new( + ErrorKind::LinkUnavailable, + format!("upstream `{alias}` is disabled"), + )); + } + + // ---- route matching -------------------------------------------------- + let routes = state.store.routes_for_upstream(tenant_id, upstream.id); + let route = match_route(&routes, &method, &suffix).ok_or_else(|| { + DomainError::new( + ErrorKind::RouteNotFound, + format!("no route on `{alias}` matches {method} {suffix}"), + ) + })?; + + let cors = effective_cors(route, &upstream); + + // ---- cross-origin policy on an actual request ------------------------ + if let Some(cors) = cors + && cors.enabled + && let Some(origin) = inbound_headers.get(http::header::ORIGIN) + { + check_cors_actual(cors, origin, &method)?; + } + + // ---- guards ---------------------------------------------------------- + apply_guards(route, &method, &suffix, &query)?; + check_body_limits(&inbound_headers)?; + + // ---- rate limiting --------------------------------------------------- + let rate_limit = route.rate_limit.as_ref().or(upstream.rate_limit.as_ref()); + let mut rate_headers: Vec<(String, String)> = Vec::new(); + if let Some(config) = rate_limit { + let key = RateLimiter::scope_key( + config.scope, + &upstream.id.to_string(), + &tenant_id.to_string(), + &ctx.subject_id().to_string(), + client_ip(request.extensions()).as_str(), + &route.id.to_string(), + ); + match state.rate_limiter.check(&key, config) { + Admission::Allowed { + limit, + remaining, + reset_after, + } => { + rate_headers.push(("x-ratelimit-limit".to_owned(), limit.to_string())); + rate_headers.push(("x-ratelimit-remaining".to_owned(), remaining.to_string())); + rate_headers.push(("x-ratelimit-reset".to_owned(), reset_after.to_string())); + } + Admission::Degraded { limit } => { + rate_headers.push(("x-ratelimit-limit".to_owned(), limit.to_string())); + rate_headers.push(("x-ratelimit-remaining".to_owned(), "0".to_owned())); + } + Admission::Rejected { limit, retry_after } => { + let error = DomainError::new( + ErrorKind::RateLimitExceeded, + "rate limit exceeded for this scope", + ) + .with_context(serde_json::json!({ "retry_after_seconds": retry_after })); + let mut response = problem_response(&error); + let headers = response.headers_mut(); + insert_str(headers, "retry-after", &retry_after.to_string()); + insert_str(headers, "x-ratelimit-limit", &limit.to_string()); + insert_str(headers, "x-ratelimit-remaining", "0"); + insert_str(headers, "x-ratelimit-reset", &retry_after.to_string()); + return Ok(response); + } + } + } + + // ---- endpoint selection --------------------------------------------- + let target_host = inbound_headers + .get(TARGET_HOST_HEADER) + .and_then(|v| v.to_str().ok()); + let endpoint = select_endpoint(&upstream, target_host, &state.round_robin)?.clone(); + // The check always runs; the policy flag decides whether it rejects. + ssrf_check(&endpoint, state.config.ssrf_policy.enabled)?; + check_plaintext_allowed(&endpoint, state.config.allow_http_upstream)?; + + // The request-phase pipeline order is: credential injection, then the + // guard/transform plugin chain, then the configurable + // `headers.request.{remove,set,add}` rules, then the dial. Each stage can + // override what the previous one produced, and a configured rule is + // therefore the last word, as the contract documents. + + // ---- outbound header construction (base copy only) -------------------- + let mut out_headers = + build_outbound_headers(&inbound_headers, &endpoint, upstream.headers.as_ref()); + + // ---- credential injection -------------------------------------------- + if let Some(auth) = upstream.auth.as_ref() + && let Some(plugin_ref) = auth.plugin_type.as_deref() + { + if let Some(style) = oauth2_style(plugin_ref) { + inject_client_credentials_token(ctx, state, &auth.config, style, &mut out_headers) + .await?; + } else { + apply_auth_plugin( + plugin_ref, + &auth.config, + ctx, + state.cred_store.as_ref(), + &mut out_headers, + ) + .await?; + } + } + + // ---- plugin chain: upstream bindings then route bindings ------------- + let chain = plugin_chain(&upstream, route); + for plugin_ref in &chain { + match classify_plugin(plugin_ref) { + PluginClass::Guard => { + // The supplied schema declares `plugins.items` as bare + // identifier strings, so a binding carries no configuration of + // its own. The upstream's `auth.config` block is therefore the + // only per-upstream configuration surface the schema offers, + // and every guard reads from it. A route-bound guard cannot yet + // carry route-specific configuration for the same reason. + let config = guard_config(&upstream); + apply_guard_plugin(plugin_ref, &config, &inbound_headers, GuardPhase::Request)?; + } + PluginClass::Transform => apply_transform_plugin(plugin_ref, &mut out_headers)?, + PluginClass::Auth | PluginClass::Unknown => { + return Err(DomainError::new( + ErrorKind::PluginNotFound, + format!("plugin `{plugin_ref}` cannot be bound through plugins.items"), + )); + } + } + } + + // ---- configurable request header rules -------------------------------- + // Applied last, so a configured `headers.request.set` rule can override an + // injected credential or a transform's result. + apply_request_header_rules(&mut out_headers, upstream.headers.as_ref()); + + let upstream_path = build_upstream_path(route, &suffix); + + // ---- WebSocket upgrade ------------------------------------------------ + if is_websocket_upgrade(&inbound_headers) { + let url = build_websocket_url(&endpoint, &upstream_path, &query); + return websocket_relay(request, url, out_headers); + } + + // ---- plain and streaming HTTP ---------------------------------------- + let url = build_upstream_url(&endpoint, &upstream_path, &query); + let body_bytes = axum::body::to_bytes(request.into_body(), MAX_BODY) + .await + .map_err(|_| { + DomainError::new( + ErrorKind::PayloadTooLarge, + "request body exceeds the 100MB limit", + ) + })?; + check_declared_length_matches_body(&inbound_headers, body_bytes.len())?; + + let mut builder = match method { + Method::GET => state.http.get(&url), + Method::POST => state.http.post(&url), + Method::PUT => state.http.put(&url), + Method::PATCH => state.http.patch(&url), + Method::DELETE => state.http.delete(&url), + Method::HEAD => state.http.head(&url), + Method::OPTIONS => state.http.options(&url), + ref other => { + return Err(DomainError::validation(format!( + "method {other} is not supported by the proxy" + ))); + } + }; + for (name, value) in &out_headers { + if let Ok(value) = value.to_str() { + builder = builder.header(name.as_str(), value); + } + } + if !body_bytes.is_empty() { + builder = builder.body_bytes(body_bytes); + } + + let timeout = Duration::from_secs(state.config.proxy_timeout_secs.max(1)); + let sent = tokio::time::timeout(timeout, builder.send()).await; + let upstream_response = match sent { + Err(_) => { + return Err(DomainError::new( + ErrorKind::RequestTimeout, + "the upstream did not respond within the configured timeout", + )); + } + Ok(Err(error)) => return Err(map_transport_error(&error)), + Ok(Ok(response)) => response, + }; + + let status = upstream_response.status(); + // A pristine copy, taken before any response header rule can rewrite it, + // is what the response-phase guards see: the contract requires them to + // judge what the upstream actually sent, and a configured + // `headers.response.add` rule must not be able to manufacture a header + // that satisfies a guard that should have rejected the response. + let pristine_response_headers = upstream_response.headers().clone(); + let streaming = is_event_stream(&pristine_response_headers); + + for plugin_ref in &chain { + if classify_plugin(plugin_ref) == PluginClass::Guard { + let config = guard_config(&upstream); + apply_guard_plugin( + plugin_ref, + &config, + &pristine_response_headers, + GuardPhase::Response, + )?; + } + } + + // Only now, after the guards have judged the untouched response, may the + // configured response header rules mutate the map that is returned. + let mut response_headers = pristine_response_headers; + apply_response_header_rules(&mut response_headers, upstream.headers.as_ref()); + + // A stream is relayed frame by frame; the body limit and the request + // timeout bound reaching this point, not the stream's remaining life. + let body = Body::new(upstream_response.into_body()); + let mut response = Response::new(body); + *response.status_mut() = status; + *response.headers_mut() = response_headers; + // Content length is recomputed by the transport for a relayed body. + response.headers_mut().remove(http::header::CONTENT_LENGTH); + if streaming { + response.headers_mut().insert( + http::header::CACHE_CONTROL, + HeaderValue::from_static("no-cache"), + ); + } + for (name, value) in rate_headers { + insert_str(response.headers_mut(), &name, &value); + } + if let Some(cors) = cors + && cors.enabled + { + add_cors_response_headers(response.headers_mut(), cors, &inbound_headers); + } + // The response was produced by the upstream, so it is labelled as such. + set_error_source(&mut response, ErrorSource::Upstream); + Ok(response) +} + +/// The configuration a guard plugin reads. +/// +/// See the note at the guard dispatch site: the schema gives a binding no +/// configuration of its own, so the upstream's `auth.config` block is the only +/// available surface. +fn guard_config(upstream: &Upstream) -> std::collections::BTreeMap { + upstream + .auth + .as_ref() + .map(|a| a.config.clone()) + .unwrap_or_default() +} + +/// Compose the plugin chain: upstream bindings run before route bindings. +fn plugin_chain(upstream: &Upstream, route: &Route) -> Vec { + let mut chain: Vec = upstream + .plugins + .as_ref() + .map(|p| p.items.clone()) + .unwrap_or_default(); + if let Some(route_plugins) = route.plugins.as_ref() { + chain.extend(route_plugins.items.iter().cloned()); + } + chain +} + +/// Whether the request declares a chunked `Transfer-Encoding`. +fn is_chunked(headers: &HeaderMap) -> bool { + headers + .get(http::header::TRANSFER_ENCODING) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.to_ascii_lowercase().contains("chunked")) +} + +/// Reject a body that declares an invalid or over-large length, or an +/// ambiguous framing. +fn check_body_limits(headers: &HeaderMap) -> DomainResult<()> { + let chunked = is_chunked(headers); + // A request carrying both a `Content-Length` and a chunked + // `Transfer-Encoding` is the classic precondition for request smuggling: + // the gateway and the upstream could each pick a different header to + // believe, and disagree about where the body ends. Reject it outright + // rather than guessing which one is authoritative. + if headers.contains_key(http::header::CONTENT_LENGTH) && chunked { + return Err(DomainError::validation( + "a request must not carry both Content-Length and a chunked Transfer-Encoding", + )); + } + if let Some(raw) = headers.get(http::header::CONTENT_LENGTH) { + let text = raw + .to_str() + .map_err(|_| DomainError::validation("Content-Length is not a valid integer"))?; + let declared: usize = text + .parse() + .map_err(|_| DomainError::validation("Content-Length is not a valid integer"))?; + if declared > MAX_BODY { + return Err(DomainError::new( + ErrorKind::PayloadTooLarge, + "request body exceeds the 100MB limit", + )); + } + } + if headers.contains_key(http::header::TRANSFER_ENCODING) && !chunked { + return Err(DomainError::validation( + "only chunked transfer encoding is supported", + )); + } + Ok(()) +} + +/// Reject a body whose actual size disagrees with a declared `Content-Length`. +/// +/// `check_body_limits` validates the declared value in isolation, before the +/// body is read; this validates it against what was actually buffered, as the +/// body-validation algorithm requires. +fn check_declared_length_matches_body(headers: &HeaderMap, actual_len: usize) -> DomainResult<()> { + if let Some(raw) = headers.get(http::header::CONTENT_LENGTH) + && let Ok(text) = raw.to_str() + && let Ok(declared) = text.parse::() + && declared != actual_len + { + return Err(DomainError::validation(format!( + "Content-Length declared {declared} bytes but the request body was {actual_len} bytes" + ))); + } + Ok(()) +} + +/// Map a transport failure onto the catalogue deterministically. +fn map_transport_error(error: &toolkit_http::HttpError) -> DomainError { + let text = error.to_string().to_ascii_lowercase(); + if text.contains("timed out") || text.contains("timeout") { + return DomainError::new( + ErrorKind::ConnectionTimeout, + "upstream connection timed out", + ); + } + if text.contains("dns") || text.contains("resolve") { + return DomainError::new( + ErrorKind::LinkUnavailable, + "the upstream host could not be resolved", + ); + } + if text.contains("refused") || text.contains("reset") || text.contains("connect") { + return DomainError::new( + ErrorKind::DownstreamError, + "the upstream refused or reset the connection", + ); + } + DomainError::new( + ErrorKind::ProtocolError, + "the upstream response was not usable", + ) +} + +/// Client address for an `ip`-scoped rate limit. +/// +/// `X-Forwarded-For` is never consulted: it is caller-supplied, and a caller +/// that varies it on every request would mint a fresh token bucket each time, +/// trivially bypassing the `ip` scope. The gear does not own the listener, so +/// the real peer address is available only when the runtime populates +/// `ConnectInfo` in the request extensions (as it does when +/// served directly; a gateway that proxies to this gear over a Unix socket or +/// a test harness may not supply it). When it is unavailable, every request +/// shares a single fixed bucket key, so the limit over-restricts rather than +/// being bypassable — a deliberate trade-off in the safe direction. +fn client_ip(extensions: &http::Extensions) -> String { + extensions + .get::>() + .map_or_else( + || "unknown".to_owned(), + |connect_info| connect_info.0.ip().to_string(), + ) +} + +fn insert_str(headers: &mut HeaderMap, name: &str, value: &str) { + if let (Ok(name), Ok(value)) = ( + http::HeaderName::try_from(name), + HeaderValue::from_str(value), + ) { + headers.insert(name, value); + } +} +// @cpt-end:cpt-cf-oagw-dod-proxy-http-response-relay:p1:inst-proxy-handler + +// @cpt-begin:cpt-cf-oagw-dod-policy-credential-resolution:p2:inst-oauth2-inject +/// Which client-credentials variant a plugin reference names, if any. +fn oauth2_style(plugin_ref: &str) -> Option { + match crate::domain::model::gts_instance(plugin_ref) { + crate::infra::plugins::AUTH_OAUTH2_FORM => Some(ClientAuthStyle::Form), + crate::infra::plugins::AUTH_OAUTH2_BASIC => Some(ClientAuthStyle::Basic), + _ => None, + } +} + +/// Resolve a client-credentials bearer token and inject it. +/// +/// A live cached token is reused, so a second request within the token's +/// lifetime does not call the identity provider again. A failed fetch is not +/// cached. +async fn inject_client_credentials_token( + ctx: &SecurityContext, + state: &Arc, + config: &std::collections::BTreeMap, + style: ClientAuthStyle, + headers: &mut HeaderMap, +) -> DomainResult<()> { + let key = TokenCache::cache_key( + &ctx.subject_tenant_id().to_string(), + &ctx.subject_id().to_string(), + style, + config, + ); + let token = if let Some(cached) = state.token_cache.get(&key) { + cached + } else { + let endpoint = config + .get("token_endpoint") + .or_else(|| config.get("issuer_url")) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + DomainError::new( + ErrorKind::AuthenticationFailed, + "the auth plugin config names no token_endpoint or issuer_url", + ) + })?; + let client_id = resolve_reference(config, "client_id_ref", ctx, state).await?; + let client_secret = resolve_reference(config, "client_secret_ref", ctx, state).await?; + let scopes = config.get("scopes").and_then(serde_json::Value::as_str); + let (token, expires_in) = fetch_token( + &state.http, + endpoint, + &client_id, + &client_secret, + scopes, + style, + ) + .await?; + let ttl = effective_ttl(state.config.token_cache_ttl_secs, expires_in); + state.token_cache.put(key, token.clone(), ttl); + token + }; + let value = HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| { + DomainError::new( + ErrorKind::AuthenticationFailed, + "the resolved token is not a valid header value", + ) + })?; + headers.insert(http::header::AUTHORIZATION, value); + Ok(()) +} + +/// Read a credential either inline or from the credential store. +async fn resolve_reference( + config: &std::collections::BTreeMap, + key: &str, + ctx: &SecurityContext, + state: &Arc, +) -> DomainResult { + let raw = config + .get(key) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + DomainError::new( + ErrorKind::AuthenticationFailed, + format!("the auth plugin config names no `{key}`"), + ) + })?; + let Some(reference) = raw.strip_prefix("cred://") else { + // A value that is not a reference is used as-is. + return Ok(raw.to_owned()); + }; + let store = state.cred_store.as_ref().ok_or_else(|| { + DomainError::new(ErrorKind::SecretNotFound, "credential store is unavailable") + })?; + let secret_ref = credstore_sdk::models::SecretRef::new(reference) + .map_err(|e| DomainError::validation(format!("invalid credential reference: {e}")))?; + match store.get(ctx, &secret_ref).await { + Ok(Some(found)) => String::from_utf8(found.value.as_bytes().to_vec()) + .map_err(|_| DomainError::new(ErrorKind::SecretNotFound, "secret is not valid UTF-8")), + Ok(None) => Err(DomainError::new( + ErrorKind::SecretNotFound, + format!("secret `{reference}` was not found"), + )), + Err(_) => Err(DomainError::new( + ErrorKind::AuthenticationFailed, + "credential store rejected the request", + )), + } +} +// @cpt-end:cpt-cf-oagw-dod-policy-credential-resolution:p2:inst-oauth2-inject + +// @cpt-begin:cpt-cf-oagw-dod-policy-cors-preflight:p2:inst-cors +/// The cross-origin policy that governs a request. +/// +/// A route's own `cors` block overrides the upstream's when the route sets +/// one; otherwise the upstream's policy applies. +#[must_use] +pub fn effective_cors<'a>(route: &'a Route, upstream: &'a Upstream) -> Option<&'a CorsConfig> { + route.cors.as_ref().or(upstream.cors.as_ref()) +} + +/// Whether a request is a cross-origin preflight. +#[must_use] +pub fn is_preflight(headers: &HeaderMap) -> bool { + headers.contains_key(http::header::ORIGIN) + && headers.contains_key(http::header::ACCESS_CONTROL_REQUEST_METHOD) +} + +/// Answer a preflight permissively, without resolving an upstream. +#[must_use] +pub fn preflight_response(headers: &HeaderMap) -> Response { + let mut response = StatusCode::NO_CONTENT.into_response(); + let out = response.headers_mut(); + if let Some(origin) = headers.get(http::header::ORIGIN) { + out.insert(http::header::ACCESS_CONTROL_ALLOW_ORIGIN, origin.clone()); + } + if let Some(method) = headers.get(http::header::ACCESS_CONTROL_REQUEST_METHOD) { + out.insert(http::header::ACCESS_CONTROL_ALLOW_METHODS, method.clone()); + } + if let Some(request_headers) = headers.get(http::header::ACCESS_CONTROL_REQUEST_HEADERS) { + out.insert( + http::header::ACCESS_CONTROL_ALLOW_HEADERS, + request_headers.clone(), + ); + } + out.insert( + http::header::ACCESS_CONTROL_MAX_AGE, + HeaderValue::from_static("86400"), + ); + out.insert( + http::header::VARY, + HeaderValue::from_static( + "Origin, Access-Control-Request-Method, Access-Control-Request-Headers", + ), + ); + set_error_source(&mut response, ErrorSource::Gateway); + response +} + +/// Validate an actual cross-origin request against the upstream policy. +/// +/// Origin matching is exact, and both port- and protocol-sensitive. +/// +/// # Errors +/// Returns a validation error when the origin or the method is not permitted. +pub fn check_cors_actual( + cors: &CorsConfig, + origin: &HeaderValue, + method: &Method, +) -> DomainResult<()> { + let Ok(origin) = origin.to_str() else { + return Err(DomainError::validation( + "Origin is not a valid header value", + )); + }; + let allowed = cors + .allowed_origins + .iter() + .any(|candidate| candidate == "*" || candidate == origin); + if !allowed { + return Err(DomainError::new( + ErrorKind::ValidationError, + format!("origin `{origin}` is not allowed"), + ) + .with_context(serde_json::json!({ + "error_code": "cors.origin_not_allowed", + }))); + } + if !cors.allowed_methods.iter().any(|m| m == method.as_str()) { + return Err(DomainError::new( + ErrorKind::ValidationError, + format!("method {method} is not allowed for a cross-origin request"), + ) + .with_context(serde_json::json!({ + "error_code": "cors.method_not_allowed", + }))); + } + Ok(()) +} + +/// Add the cross-origin headers to a relayed response. +pub fn add_cors_response_headers( + headers: &mut HeaderMap, + cors: &CorsConfig, + request_headers: &HeaderMap, +) { + if let Some(origin) = request_headers.get(http::header::ORIGIN) { + headers.insert(http::header::ACCESS_CONTROL_ALLOW_ORIGIN, origin.clone()); + } + if cors.allow_credentials { + headers.insert( + http::header::ACCESS_CONTROL_ALLOW_CREDENTIALS, + HeaderValue::from_static("true"), + ); + } + if !cors.expose_headers.is_empty() + && let Ok(value) = HeaderValue::from_str(&cors.expose_headers.join(", ")) + { + headers.insert(http::header::ACCESS_CONTROL_EXPOSE_HEADERS, value); + } + headers.insert(http::header::VARY, HeaderValue::from_static("Origin")); +} +// @cpt-end:cpt-cf-oagw-dod-policy-cors-preflight:p2:inst-cors + +// @cpt-begin:cpt-cf-oagw-dod-proxy-streaming-ws-relay:p1:inst-ws +/// Complete a WebSocket upgrade with the client and relay to the upstream. +/// +/// The handshake reply carries `Sec-WebSocket-Accept`, which axum derives from +/// the client's `Sec-WebSocket-Key`. `Upgrade` and `Connection` are ordinarily +/// stripped as hop-by-hop headers, so the upstream dial reconstructs them +/// deliberately through the client library rather than forwarding them. +/// +/// The proxy timeout bounds reaching the upstream's `101` reply; it does not +/// bound the lifetime of the established session. +fn websocket_relay( + request: Request, + upstream_url: String, + forwarded: HeaderMap, +) -> DomainResult { + use axum::extract::FromRequestParts; + use axum::extract::ws::WebSocketUpgrade; + + let (mut parts, _body) = request.into_parts(); + // The extractor is infallible over already-parsed parts, so the future + // resolves immediately and never blocks here. + let upgrade = futures_util::future::FutureExt::now_or_never( + WebSocketUpgrade::from_request_parts(&mut parts, &()), + ) + .and_then(Result::ok) + .ok_or_else(|| DomainError::new(ErrorKind::ProtocolError, "invalid WebSocket upgrade"))?; + + // The `101` reply to the client is committed by `on_upgrade` below, + // before the upstream dial (inside the callback) even starts, so the + // upstream's actual subprotocol choice cannot be known yet and cannot be + // echoed. The best available negotiation is client-side only: offer back + // the client's own requested list, so `WebSocketUpgrade` selects (and the + // `101` echoes) the client's first-preference protocol. + let requested_protocols: Vec = upgrade + .requested_protocols() + .filter_map(|value| value.to_str().ok()) + .map(str::to_owned) + .collect(); + let upgrade = if requested_protocols.is_empty() { + upgrade + } else { + upgrade.protocols(requested_protocols) + }; + + let mut response = upgrade.on_upgrade(move |client| async move { + if let Err(error) = relay_websocket(client, upstream_url, forwarded).await { + tracing::debug!(error = %error, "websocket relay ended"); + } + }); + set_error_source(&mut response, ErrorSource::Gateway); + Ok(response) +} + +/// Pump frames between the client and the upstream until either side closes. +async fn relay_websocket( + client: axum::extract::ws::WebSocket, + upstream_url: String, + forwarded: HeaderMap, +) -> anyhow::Result<()> { + use tokio_tungstenite::tungstenite::client::IntoClientRequest; + + let mut request = upstream_url.as_str().into_client_request()?; + // Carry the transformed headers, minus the three the handshake itself + // owns. `Sec-WebSocket-Protocol` and `Sec-WebSocket-Extensions` are not + // handshake-owned: they carry the caller's subprotocol and extension + // negotiation and must reach the upstream dial. + for (name, value) in &forwarded { + let lower = name.as_str().to_ascii_lowercase(); + if matches!( + lower.as_str(), + // The handshake owns these three, and `Sec-WebSocket-Extensions` + // is negotiated per connection (RFC 6455 section 9). This relay + // terminates and re-originates both legs, so forwarding an + // extension would let the upstream compress frames the client + // never agreed to decompress. + "sec-websocket-key" + | "sec-websocket-version" + | "sec-websocket-accept" + | "sec-websocket-extensions" + ) || matches!(lower.as_str(), "host" | "connection" | "upgrade") + { + continue; + } + request.headers_mut().insert(name.clone(), value.clone()); + } + let (upstream, _response) = tokio_tungstenite::connect_async(request).await?; + + let (mut client_tx, mut client_rx) = client.split(); + let (mut upstream_tx, mut upstream_rx) = upstream.split(); + + // A single loop waits on whichever side produces the next event, so both + // directions make progress concurrently. + loop { + tokio::select! { + from_client = client_rx.next() => { + match from_client { + Some(Ok(message)) => { + let closing = matches!(message, axum::extract::ws::Message::Close(_)); + upstream_tx.send(to_tungstenite(message)).await?; + if closing { + break; + } + } + // The client closed or errored; close the upstream side. + Some(Err(_)) | None => break, + } + } + from_upstream = upstream_rx.next() => { + match from_upstream { + Some(Ok(message)) => { + let closing = message.is_close(); + if let Some(message) = to_axum(message) { + client_tx.send(message).await?; + } + if closing { + break; + } + } + // The upstream closed or errored; close the client side. + Some(Err(_)) | None => break, + } + } + } + } + drop(upstream_tx.close().await); + drop(client_tx.close().await); + Ok(()) +} + +/// Convert an inbound client frame into its upstream form. +fn to_tungstenite(message: axum::extract::ws::Message) -> tokio_tungstenite::tungstenite::Message { + use axum::extract::ws::Message as Axum; + use tokio_tungstenite::tungstenite::Message as Tung; + use tokio_tungstenite::tungstenite::protocol::CloseFrame; + + match message { + Axum::Text(text) => Tung::Text(text.as_str().into()), + Axum::Binary(bytes) => Tung::Binary(bytes), + Axum::Ping(bytes) => Tung::Ping(bytes), + Axum::Pong(bytes) => Tung::Pong(bytes), + Axum::Close(frame) => Tung::Close(frame.map(|f| CloseFrame { + code: f.code.into(), + reason: f.reason.as_str().into(), + })), + } +} + +/// Convert an upstream frame into its client form. +fn to_axum(message: tokio_tungstenite::tungstenite::Message) -> Option { + use axum::extract::ws::{CloseFrame, Message as Axum}; + use tokio_tungstenite::tungstenite::Message as Tung; + + Some(match message { + Tung::Text(text) => Axum::Text(text.as_str().into()), + Tung::Binary(bytes) => Axum::Binary(bytes), + Tung::Ping(bytes) => Axum::Ping(bytes), + Tung::Pong(bytes) => Axum::Pong(bytes), + Tung::Close(frame) => Axum::Close(frame.map(|f| CloseFrame { + code: f.code.into(), + reason: f.reason.as_str().into(), + })), + // A raw frame has no client-facing equivalent. + Tung::Frame(_) => return None, + }) +} +// @cpt-end:cpt-cf-oagw-dod-proxy-streaming-ws-relay:p1:inst-ws + +#[cfg(test)] +mod tests { + use super::{ + check_body_limits, check_cors_actual, check_declared_length_matches_body, client_ip, + effective_cors, is_preflight, preflight_response, preflight_root, preflight_with_path, + }; + use crate::domain::model::{ + CorsConfig, Endpoint, HttpMatch, MatchConfig, PROTOCOL_HTTP, PathSuffixMode, Route, Scheme, + ServerConfig, SharingMode, Upstream, + }; + use axum::extract::ConnectInfo; + use http::{HeaderMap, HeaderValue, Method}; + use uuid::Uuid; + + fn cors(origins: &[&str], methods: &[&str]) -> CorsConfig { + CorsConfig { + sharing: SharingMode::Private, + enabled: true, + allowed_origins: origins.iter().map(|o| (*o).to_owned()).collect(), + allowed_methods: methods.iter().map(|m| (*m).to_owned()).collect(), + expose_headers: vec![], + allow_credentials: false, + } + } + + #[test] + fn a_preflight_needs_both_marker_headers() { + let mut headers = HeaderMap::new(); + headers.insert( + http::header::ORIGIN, + HeaderValue::from_static("https://a.test"), + ); + assert!(!is_preflight(&headers)); + headers.insert( + http::header::ACCESS_CONTROL_REQUEST_METHOD, + HeaderValue::from_static("POST"), + ); + assert!(is_preflight(&headers)); + } + + #[test] + fn a_preflight_answers_permissively() { + let mut headers = HeaderMap::new(); + headers.insert( + http::header::ORIGIN, + HeaderValue::from_static("https://a.test"), + ); + headers.insert( + http::header::ACCESS_CONTROL_REQUEST_METHOD, + HeaderValue::from_static("POST"), + ); + let response = preflight_response(&headers); + assert_eq!(response.status(), 204); + assert_eq!( + response + .headers() + .get(http::header::ACCESS_CONTROL_ALLOW_ORIGIN) + .expect("origin echoed"), + "https://a.test" + ); + assert_eq!( + response + .headers() + .get(http::header::ACCESS_CONTROL_MAX_AGE) + .expect("max age"), + "86400" + ); + } + + #[test] + fn origin_matching_is_exact() { + let policy = cors(&["https://a.test"], &["GET"]); + assert!( + check_cors_actual( + &policy, + &HeaderValue::from_static("https://a.test"), + &Method::GET + ) + .is_ok() + ); + // A different port is a different origin. + assert!( + check_cors_actual( + &policy, + &HeaderValue::from_static("https://a.test:8443"), + &Method::GET + ) + .is_err() + ); + // A different protocol is a different origin. + assert!( + check_cors_actual( + &policy, + &HeaderValue::from_static("http://a.test"), + &Method::GET + ) + .is_err() + ); + } + + #[test] + fn a_disallowed_method_is_rejected() { + let policy = cors(&["https://a.test"], &["GET"]); + let err = check_cors_actual( + &policy, + &HeaderValue::from_static("https://a.test"), + &Method::DELETE, + ) + .expect_err("method rejected"); + assert_eq!(err.context["error_code"], "cors.method_not_allowed"); + } + + #[test] + fn a_non_numeric_content_length_is_rejected() { + let mut headers = HeaderMap::new(); + headers.insert( + http::header::CONTENT_LENGTH, + HeaderValue::from_static("abc"), + ); + assert!(check_body_limits(&headers).is_err()); + } + + #[test] + fn an_oversize_declared_body_is_rejected_before_buffering() { + let mut headers = HeaderMap::new(); + headers.insert( + http::header::CONTENT_LENGTH, + HeaderValue::from_static("209715200"), + ); + let err = check_body_limits(&headers).expect_err("too large"); + assert_eq!(err.status(), 413); + } + + #[test] + fn an_unsupported_transfer_encoding_is_rejected() { + let mut headers = HeaderMap::new(); + headers.insert( + http::header::TRANSFER_ENCODING, + HeaderValue::from_static("gzip"), + ); + assert!(check_body_limits(&headers).is_err()); + headers.insert( + http::header::TRANSFER_ENCODING, + HeaderValue::from_static("chunked"), + ); + assert!(check_body_limits(&headers).is_ok()); + } + + #[test] + fn content_length_and_chunked_transfer_encoding_together_is_rejected() { + let mut headers = HeaderMap::new(); + headers.insert(http::header::CONTENT_LENGTH, HeaderValue::from_static("10")); + headers.insert( + http::header::TRANSFER_ENCODING, + HeaderValue::from_static("chunked"), + ); + let err = check_body_limits(&headers).expect_err("ambiguous framing rejected"); + assert_eq!(err.status(), 400); + } + + #[test] + fn a_declared_content_length_must_match_the_actual_body() { + let mut headers = HeaderMap::new(); + headers.insert(http::header::CONTENT_LENGTH, HeaderValue::from_static("5")); + assert!(check_declared_length_matches_body(&headers, 5).is_ok()); + let err = check_declared_length_matches_body(&headers, 4).expect_err("mismatch rejected"); + assert_eq!(err.status(), 400); + } + + #[test] + fn no_declared_content_length_is_not_checked_against_the_body() { + let headers = HeaderMap::new(); + assert!(check_declared_length_matches_body(&headers, 12345).is_ok()); + } + + #[test] + fn client_ip_uses_connect_info_when_present() { + let mut extensions = http::Extensions::new(); + let addr: std::net::SocketAddr = "203.0.113.7:12345".parse().expect("addr"); + extensions.insert(ConnectInfo(addr)); + assert_eq!(client_ip(&extensions), "203.0.113.7"); + } + + #[test] + fn client_ip_falls_back_to_a_fixed_key_without_connect_info() { + // A caller-supplied `X-Forwarded-For` must never be consulted, so an + // absent `ConnectInfo` falls back to a single shared key rather than + // any inbound header. + let extensions = http::Extensions::new(); + assert_eq!(client_ip(&extensions), "unknown"); + } + + #[tokio::test] + async fn a_genuine_preflight_is_answered_by_the_dedicated_handler() { + let mut headers = HeaderMap::new(); + headers.insert( + http::header::ORIGIN, + HeaderValue::from_static("https://a.test"), + ); + headers.insert( + http::header::ACCESS_CONTROL_REQUEST_METHOD, + HeaderValue::from_static("POST"), + ); + let response = preflight_root(headers.clone()).await; + assert_eq!(response.status(), 204); + let response = preflight_with_path(headers).await; + assert_eq!(response.status(), 204); + } + + #[tokio::test] + async fn a_non_preflight_options_gets_a_plain_404() { + // No `Origin` / `Access-Control-Request-Method` marker headers: this + // is not a genuine preflight, and must not reach any tenant logic. + let response = preflight_root(HeaderMap::new()).await; + assert_eq!(response.status(), 404); + let response = preflight_with_path(HeaderMap::new()).await; + assert_eq!(response.status(), 404); + } + + fn endpoint(host: &str) -> Endpoint { + Endpoint { + scheme: Scheme::Https, + host: host.to_owned(), + port: 443, + } + } + + fn upstream_with_cors(cors: Option) -> Upstream { + Upstream { + id: Uuid::new_v4(), + tenant_id: Uuid::new_v4(), + alias: "api".to_owned(), + enabled: true, + server: ServerConfig { + endpoints: vec![endpoint("api.example.com")], + }, + protocol: PROTOCOL_HTTP.to_owned(), + tags: vec![], + auth: None, + headers: None, + plugins: None, + rate_limit: None, + cors, + } + } + + fn route_with_cors(cors: Option) -> Route { + Route { + id: Uuid::new_v4(), + tenant_id: Uuid::new_v4(), + upstream_id: Uuid::new_v4(), + enabled: true, + match_config: MatchConfig { + http: Some(HttpMatch { + methods: vec!["GET".to_owned()], + path: "/v1".to_owned(), + query_allowlist: vec![], + path_suffix_mode: PathSuffixMode::Append, + }), + grpc: None, + }, + tags: vec![], + plugins: None, + rate_limit: None, + cors, + } + } + + #[test] + fn a_routes_own_cors_policy_overrides_the_upstreams() { + let route_cors = cors(&["https://route.test"], &["GET"]); + let upstream_cors = cors(&["https://upstream.test"], &["GET"]); + let route = route_with_cors(Some(route_cors.clone())); + let upstream = upstream_with_cors(Some(upstream_cors)); + let resolved = effective_cors(&route, &upstream).expect("route cors wins"); + assert_eq!(resolved.allowed_origins, route_cors.allowed_origins); + } + + #[test] + fn the_upstreams_cors_policy_is_used_when_the_route_has_none() { + let upstream_cors = cors(&["https://upstream.test"], &["GET"]); + let route = route_with_cors(None); + let upstream = upstream_with_cors(Some(upstream_cors.clone())); + let resolved = effective_cors(&route, &upstream).expect("upstream cors used"); + assert_eq!(resolved.allowed_origins, upstream_cors.allowed_origins); + } + + #[test] + fn no_cors_policy_anywhere_resolves_to_none() { + let route = route_with_cors(None); + let upstream = upstream_with_cors(None); + assert!(effective_cors(&route, &upstream).is_none()); + } +} diff --git a/gears/system/oagw/oagw/src/api/rest/handlers/routes.rs b/gears/system/oagw/oagw/src/api/rest/handlers/routes.rs new file mode 100644 index 0000000..e44811c --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers/routes.rs @@ -0,0 +1,175 @@ +// @cpt-begin:cpt-cf-oagw-dod-route-api-crud:p1:inst-route-handlers +//! Route management handlers. + +use crate::api::rest::dto::{ListQuery, RouteCreateDto, RouteDto, RouteListDto, RouteReplaceDto}; +use crate::api::rest::error::set_error_source; +use crate::api::rest::handlers::upstreams::{parse_id, validate_list_query}; +use crate::api::rest::state::OagwState; +use crate::domain::error::{DomainError, ErrorSource}; +use crate::domain::model::{Route, gts_resource_id}; +use crate::domain::validate::validate_route; +use axum::extract::{Extension, Path, Query}; +use axum::response::{IntoResponse, Response}; +use http::StatusCode; +use std::sync::Arc; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn to_dto(route: Route) -> RouteDto { + RouteDto { + id: gts_resource_id("route", route.id), + uuid: route.id, + upstream_id: route.upstream_id, + enabled: route.enabled, + match_config: route.match_config, + tags: route.tags, + plugins: route.plugins, + rate_limit: route.rate_limit, + cors: route.cors, + } +} + +fn json_ok(status: StatusCode, body: &T) -> Response { + let mut response = (status, axum::Json(body)).into_response(); + set_error_source(&mut response, ErrorSource::Gateway); + response +} + +/// Create a route. +/// +/// # Errors +/// Returns a validation error when the body or the upstream reference is +/// invalid, and a conflict on a duplicate match rule. +pub async fn create( + Extension(ctx): Extension, + Extension(state): Extension>, + axum::Json(body): axum::Json, +) -> Result { + let tenant_id = ctx.subject_tenant_id(); + // The upstream must exist and belong to the calling tenant; an ancestor + // upstream is not directly addressable. + if state + .store + .get_upstream(tenant_id, body.upstream_id) + .is_none() + { + return Err(DomainError::validation(format!( + "upstream_id `{}` does not name an upstream of this tenant", + body.upstream_id + ))); + } + let route = Route { + id: Uuid::new_v4(), + tenant_id, + upstream_id: body.upstream_id, + enabled: body.enabled, + match_config: body.match_config, + tags: body.tags, + plugins: body.plugins, + rate_limit: body.rate_limit, + cors: body.cors, + }; + validate_route(&route)?; + let created = state.store.create_route(route)?; + Ok(json_ok(StatusCode::CREATED, &to_dto(created))) +} + +/// List the tenant's routes. +/// +/// # Errors +/// Returns a validation error when a query parameter is malformed. +pub async fn list( + Extension(ctx): Extension, + Extension(state): Extension>, + Query(query): Query, +) -> Result { + validate_list_query(&query)?; + let mut items: Vec = state + .store + .list_routes(ctx.subject_tenant_id()) + .into_iter() + .map(to_dto) + .collect(); + items.sort_by_key(|r| r.uuid); + let page: Vec = items + .into_iter() + .skip(query.effective_skip()) + .take(query.effective_top()) + .collect(); + let count = page.len(); + Ok(json_ok( + StatusCode::OK, + &RouteListDto { items: page, count }, + )) +} + +/// Fetch one route. +/// +/// # Errors +/// Returns not-found when the tenant does not own a route with that id. +pub async fn get( + Extension(ctx): Extension, + Extension(state): Extension>, + Path(id): Path, +) -> Result { + let id = parse_id(&id)?; + let found = state + .store + .get_route(ctx.subject_tenant_id(), id) + .ok_or_else(|| DomainError::not_found("route not found"))?; + Ok(json_ok(StatusCode::OK, &to_dto(found))) +} + +/// Replace a route. The upstream reference is immutable. +/// +/// # Errors +/// Returns not-found when the route does not exist for the tenant, and a +/// conflict on a duplicate match rule. +pub async fn replace( + Extension(ctx): Extension, + Extension(state): Extension>, + Path(id): Path, + axum::Json(body): axum::Json, +) -> Result { + let id = parse_id(&id)?; + let tenant_id = ctx.subject_tenant_id(); + let existing = state + .store + .get_route(tenant_id, id) + .ok_or_else(|| DomainError::not_found("route not found"))?; + let replacement = Route { + id, + tenant_id, + // Immutable: the replacement keeps the original upstream. + upstream_id: existing.upstream_id, + enabled: body.enabled, + match_config: body.match_config, + tags: body.tags, + plugins: body.plugins, + rate_limit: body.rate_limit, + cors: body.cors, + }; + validate_route(&replacement)?; + let stored = state.store.replace_route(replacement)?; + Ok(json_ok(StatusCode::OK, &to_dto(stored))) +} + +/// Delete a route. +/// +/// # Errors +/// Returns not-found when the tenant does not own a route with that id. +pub async fn delete( + Extension(ctx): Extension, + Extension(state): Extension>, + Path(id): Path, +) -> Result { + let id = parse_id(&id)?; + if state.store.delete_route(ctx.subject_tenant_id(), id) { + let mut response = StatusCode::NO_CONTENT.into_response(); + set_error_source(&mut response, ErrorSource::Gateway); + Ok(response) + } else { + Err(DomainError::not_found("route not found")) + } +} +// @cpt-end:cpt-cf-oagw-dod-route-api-crud:p1:inst-route-handlers diff --git a/gears/system/oagw/oagw/src/api/rest/handlers/upstreams.rs b/gears/system/oagw/oagw/src/api/rest/handlers/upstreams.rs new file mode 100644 index 0000000..043feae --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers/upstreams.rs @@ -0,0 +1,245 @@ +// @cpt-begin:cpt-cf-oagw-dod-upstream-api-create:p1:inst-upstream-handlers +//! Upstream management handlers. + +use crate::api::rest::dto::{ListQuery, UpstreamDto, UpstreamListDto, UpstreamWriteDto}; +use crate::api::rest::error::set_error_source; +use crate::api::rest::state::OagwState; +use crate::domain::alias::{enforce_alias_update, resolve_create_alias}; +use crate::domain::error::{DomainError, DomainResult, ErrorSource}; +use crate::domain::model::{Upstream, gts_resource_id}; +use crate::domain::validate::validate_upstream; +use axum::extract::{Extension, Path, Query}; +use axum::response::{IntoResponse, Response}; +use http::StatusCode; +use std::sync::Arc; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +/// Render a stored upstream for the wire. +fn to_dto(upstream: Upstream) -> UpstreamDto { + UpstreamDto { + id: gts_resource_id("upstream", upstream.id), + uuid: upstream.id, + alias: upstream.alias, + enabled: upstream.enabled, + server: upstream.server, + protocol: upstream.protocol, + tags: upstream.tags, + auth: upstream.auth, + headers: upstream.headers, + plugins: upstream.plugins, + rate_limit: upstream.rate_limit, + cors: upstream.cors, + } +} + +/// Build a JSON response carrying the gateway error-source header. +fn json_ok(status: StatusCode, body: &T) -> Response { + let mut response = (status, axum::Json(body)).into_response(); + set_error_source(&mut response, ErrorSource::Gateway); + response +} + +/// Create an upstream. +/// +/// # Errors +/// Returns a validation error on a malformed body and a conflict when the +/// tenant already owns that alias. +pub async fn create( + Extension(ctx): Extension, + Extension(state): Extension>, + axum::Json(body): axum::Json, +) -> Result { + let tenant_id = ctx.subject_tenant_id(); + let alias = resolve_create_alias(&body.server.endpoints, body.alias.as_deref())?; + let upstream = Upstream { + id: Uuid::new_v4(), + tenant_id, + alias, + enabled: body.enabled, + server: body.server, + protocol: body.protocol, + tags: body.tags, + auth: body.auth, + headers: body.headers, + plugins: body.plugins, + rate_limit: body.rate_limit, + cors: body.cors, + }; + validate_upstream(&upstream)?; + let created = state.store.create_upstream(upstream)?; + Ok(json_ok(StatusCode::CREATED, &to_dto(created))) +} + +/// List the tenant's upstreams. +/// +/// # Errors +/// Returns a validation error when a query parameter is malformed. +pub async fn list( + Extension(ctx): Extension, + Extension(state): Extension>, + Query(query): Query, +) -> Result { + validate_list_query(&query)?; + let mut items: Vec = state + .store + .list_upstreams(ctx.subject_tenant_id()) + .into_iter() + .map(to_dto) + .collect(); + items.sort_by(|a, b| a.alias.cmp(&b.alias)); + let page: Vec = items + .into_iter() + .skip(query.effective_skip()) + .take(query.effective_top()) + .collect(); + let count = page.len(); + Ok(json_ok( + StatusCode::OK, + &UpstreamListDto { items: page, count }, + )) +} + +/// Reject a malformed list query. +/// +/// # Errors +/// Returns a validation error when the filter expression cannot be parsed. +pub fn validate_list_query(query: &ListQuery) -> DomainResult<()> { + // A filter is an OData boolean expression; an unparseable one is a + // client error rather than a silently ignored parameter. + if let Some(filter) = &query.filter + && !filter.contains(' ') + { + return Err(DomainError::validation(format!( + "$filter `{filter}` is not a valid OData expression" + ))); + } + Ok(()) +} + +/// Fetch one upstream. +/// +/// # Errors +/// Returns not-found when the tenant does not own an upstream with that id. +pub async fn get( + Extension(ctx): Extension, + Extension(state): Extension>, + Path(id): Path, +) -> Result { + let id = parse_id(&id)?; + let found = state + .store + .get_upstream(ctx.subject_tenant_id(), id) + .ok_or_else(|| DomainError::not_found("upstream not found"))?; + Ok(json_ok(StatusCode::OK, &to_dto(found))) +} + +/// Replace an upstream. +/// +/// # Errors +/// Returns not-found when the upstream does not exist for the tenant, and a +/// validation error when the replacement would change the alias. +pub async fn replace( + Extension(ctx): Extension, + Extension(state): Extension>, + Path(id): Path, + axum::Json(body): axum::Json, +) -> Result { + let id = parse_id(&id)?; + let tenant_id = ctx.subject_tenant_id(); + let existing = state + .store + .get_upstream(tenant_id, id) + .ok_or_else(|| DomainError::not_found("upstream not found"))?; + let alias = enforce_alias_update( + &existing.alias, + &body.server.endpoints, + body.alias.as_deref(), + )?; + let replacement = Upstream { + id, + tenant_id, + alias, + // A full replacement overwrites every field, so an omitted `enabled` + // returns to the schema default rather than keeping the stored value. + enabled: body.enabled, + server: body.server, + protocol: body.protocol, + tags: body.tags, + auth: body.auth, + headers: body.headers, + plugins: body.plugins, + rate_limit: body.rate_limit, + cors: body.cors, + }; + validate_upstream(&replacement)?; + let stored = state.store.replace_upstream(replacement)?; + Ok(json_ok(StatusCode::OK, &to_dto(stored))) +} + +/// Delete an upstream and its routes. +/// +/// # Errors +/// Returns not-found when the tenant does not own an upstream with that id. +pub async fn delete( + Extension(ctx): Extension, + Extension(state): Extension>, + Path(id): Path, +) -> Result { + let id = parse_id(&id)?; + if state.store.delete_upstream(ctx.subject_tenant_id(), id) { + let mut response = StatusCode::NO_CONTENT.into_response(); + set_error_source(&mut response, ErrorSource::Gateway); + Ok(response) + } else { + Err(DomainError::not_found("upstream not found")) + } +} + +/// Parse a path identifier, accepting a bare identifier or the anonymous +/// global type system form. +/// +/// # Errors +/// Returns not-found when the value is not an identifier, so a malformed path +/// is indistinguishable from an absent resource and cannot be used to probe. +pub fn parse_id(raw: &str) -> DomainResult { + let instance = crate::domain::model::gts_instance(raw); + Uuid::parse_str(instance).map_err(|_| DomainError::not_found("resource not found")) +} +// @cpt-end:cpt-cf-oagw-dod-upstream-api-create:p1:inst-upstream-handlers + +#[cfg(test)] +mod tests { + use super::{parse_id, validate_list_query}; + use crate::api::rest::dto::ListQuery; + use uuid::Uuid; + + #[test] + fn identifiers_parse_in_bare_and_prefixed_form() { + let id = Uuid::new_v4(); + assert_eq!(parse_id(&id.to_string()).expect("bare"), id); + let prefixed = format!("gts.cf.core.oagw.upstream.v1~{id}"); + assert_eq!(parse_id(&prefixed).expect("prefixed"), id); + } + + #[test] + fn a_malformed_identifier_reads_as_not_found() { + let err = parse_id("not-a-uuid").expect_err("rejected"); + assert_eq!(err.status(), 404); + } + + #[test] + fn an_unparseable_filter_is_rejected() { + let query = ListQuery { + filter: Some("garbage".to_owned()), + ..ListQuery::default() + }; + assert!(validate_list_query(&query).is_err()); + + let ok = ListQuery { + filter: Some("alias eq 'x'".to_owned()), + ..ListQuery::default() + }; + assert!(validate_list_query(&ok).is_ok()); + } +} diff --git a/gears/system/oagw/oagw/src/api/rest/mod.rs b/gears/system/oagw/oagw/src/api/rest/mod.rs new file mode 100644 index 0000000..4a5cce9 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/mod.rs @@ -0,0 +1,7 @@ +//! REST transport layer: DTOs, error mapping, handlers and route registration. + +pub mod dto; +pub mod error; +pub mod handlers; +pub mod routes; +pub mod state; diff --git a/gears/system/oagw/oagw/src/api/rest/routes.rs b/gears/system/oagw/oagw/src/api/rest/routes.rs new file mode 100644 index 0000000..04f0538 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/routes.rs @@ -0,0 +1,377 @@ +// @cpt-begin:cpt-cf-oagw-dod-gear-foundation-router-mount:p1:inst-router +//! Route registration. +//! +//! Paths are gear-relative under `/oagw/v1`. The api-gateway gear nests its own +//! single global prefix over the whole assembled router, so this gear must not +//! repeat that prefix itself. + +use super::dto::{ + PluginCreateDto, PluginDto, PluginListDto, RouteCreateDto, RouteDto, RouteListDto, + RouteReplaceDto, UpstreamDto, UpstreamListDto, UpstreamWriteDto, +}; +use super::handlers::{plugins, proxy, routes as route_handlers, upstreams}; +use super::state::OagwState; +use axum::routing::options; +use axum::{Extension, Router}; +use http::StatusCode; +use std::sync::Arc; +use toolkit::api::OpenApiRegistry; +use toolkit::api::operation_builder::{ + CORE_GLOBAL_BASE_LICENSE_FEATURE, LicenseFeature, OperationBuilder, +}; + +const MANAGEMENT_TAG: &str = "OAGW Management"; +const PROXY_TAG: &str = "OAGW Proxy"; + +/// Base path for every route this gear registers. +pub const BASE_PATH: &str = "/oagw/v1"; + +struct License; + +impl AsRef for License { + fn as_ref(&self) -> &'static str { + CORE_GLOBAL_BASE_LICENSE_FEATURE + } +} + +impl LicenseFeature for License {} + +/// Register every route the gear serves. +pub fn register_routes( + router: Router, + openapi: &dyn OpenApiRegistry, + state: Arc, +) -> Router { + let router = register_upstreams(router, openapi); + let router = register_routes_api(router, openapi); + let router = register_plugins(router, openapi); + let router = register_proxy(router, openapi); + router.layer(Extension(state)) +} + +fn register_upstreams(mut router: Router, openapi: &dyn OpenApiRegistry) -> Router { + router = OperationBuilder::post("/oagw/v1/upstreams") + .operation_id("oagw.upstreams.create") + .summary("Create an upstream") + .description("Register an external service the gateway may proxy to.") + .tag(MANAGEMENT_TAG) + .authenticated() + .require_license_features::([]) + .json_request::(openapi, "Upstream definition") + .handler(upstreams::create) + .json_response_with_schema::(openapi, StatusCode::CREATED, "Created upstream") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/upstreams") + .operation_id("oagw.upstreams.list") + .summary("List upstreams") + .description("List the calling tenant's upstreams.") + .tag(MANAGEMENT_TAG) + .authenticated() + .require_license_features::([]) + .query_param( + "$top", + false, + "Maximum results, default 50 and capped at 100", + ) + .query_param("$skip", false, "Offset into the result set") + .query_param("$filter", false, "OData filter expression") + .query_param("$select", false, "Fields to return") + .query_param("$orderby", false, "Sort order") + .handler(upstreams::list) + .json_response_with_schema::(openapi, StatusCode::OK, "Upstream page") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/upstreams/{id}") + .operation_id("oagw.upstreams.get") + .summary("Get an upstream") + .description("Fetch one upstream owned by the calling tenant.") + .tag(MANAGEMENT_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "Upstream identifier") + .handler(upstreams::get) + .json_response_with_schema::(openapi, StatusCode::OK, "The upstream") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::put("/oagw/v1/upstreams/{id}") + .operation_id("oagw.upstreams.replace") + .summary("Replace an upstream") + .description("Full replacement; omitted optional fields are cleared.") + .tag(MANAGEMENT_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "Upstream identifier") + .json_request::(openapi, "Replacement upstream definition") + .handler(upstreams::replace) + .json_response_with_schema::(openapi, StatusCode::OK, "Replaced upstream") + .standard_errors(openapi) + .register(router, openapi); + + OperationBuilder::delete("/oagw/v1/upstreams/{id}") + .operation_id("oagw.upstreams.delete") + .summary("Delete an upstream") + .description("Delete an upstream and every route beneath it.") + .tag(MANAGEMENT_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "Upstream identifier") + .handler(upstreams::delete) + .no_content_response(StatusCode::NO_CONTENT, "Upstream deleted") + .standard_errors(openapi) + .register(router, openapi) +} + +fn register_routes_api(mut router: Router, openapi: &dyn OpenApiRegistry) -> Router { + router = OperationBuilder::post("/oagw/v1/routes") + .operation_id("oagw.routes.create") + .summary("Create a route") + .description("Register a match rule on an upstream.") + .tag(MANAGEMENT_TAG) + .authenticated() + .require_license_features::([]) + .json_request::(openapi, "Route definition") + .handler(route_handlers::create) + .json_response_with_schema::(openapi, StatusCode::CREATED, "Created route") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/routes") + .operation_id("oagw.routes.list") + .summary("List routes") + .description("List the calling tenant's routes.") + .tag(MANAGEMENT_TAG) + .authenticated() + .require_license_features::([]) + .query_param( + "$top", + false, + "Maximum results, default 50 and capped at 100", + ) + .query_param("$skip", false, "Offset into the result set") + .query_param("$filter", false, "OData filter expression") + .query_param("$select", false, "Fields to return") + .query_param("$orderby", false, "Sort order") + .handler(route_handlers::list) + .json_response_with_schema::(openapi, StatusCode::OK, "Route page") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/routes/{id}") + .operation_id("oagw.routes.get") + .summary("Get a route") + .description("Fetch one route owned by the calling tenant.") + .tag(MANAGEMENT_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "Route identifier") + .handler(route_handlers::get) + .json_response_with_schema::(openapi, StatusCode::OK, "The route") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::put("/oagw/v1/routes/{id}") + .operation_id("oagw.routes.replace") + .summary("Replace a route") + .description("Full replacement; the upstream reference is immutable.") + .tag(MANAGEMENT_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "Route identifier") + .json_request::(openapi, "Replacement route definition") + .handler(route_handlers::replace) + .json_response_with_schema::(openapi, StatusCode::OK, "Replaced route") + .standard_errors(openapi) + .register(router, openapi); + + OperationBuilder::delete("/oagw/v1/routes/{id}") + .operation_id("oagw.routes.delete") + .summary("Delete a route") + .description("Delete one route.") + .tag(MANAGEMENT_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "Route identifier") + .handler(route_handlers::delete) + .no_content_response(StatusCode::NO_CONTENT, "Route deleted") + .standard_errors(openapi) + .register(router, openapi) +} + +fn register_plugins(mut router: Router, openapi: &dyn OpenApiRegistry) -> Router { + router = OperationBuilder::post("/oagw/v1/plugins") + .operation_id("oagw.plugins.create") + .summary("Create a plugin") + .description("Register a custom plugin. Plugins are immutable once created.") + .tag(MANAGEMENT_TAG) + .authenticated() + .require_license_features::([]) + .json_request::(openapi, "Plugin definition") + .handler(plugins::create) + .json_response_with_schema::(openapi, StatusCode::CREATED, "Created plugin") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/plugins") + .operation_id("oagw.plugins.list") + .summary("List plugins") + .description("List the calling tenant's plugins.") + .tag(MANAGEMENT_TAG) + .authenticated() + .require_license_features::([]) + .query_param( + "$top", + false, + "Maximum results, default 50 and capped at 100", + ) + .query_param("$skip", false, "Offset into the result set") + .query_param("$filter", false, "OData filter expression") + .query_param("$select", false, "Fields to return") + .handler(plugins::list) + .json_response_with_schema::(openapi, StatusCode::OK, "Plugin page") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/plugins/{id}") + .operation_id("oagw.plugins.get") + .summary("Get a plugin") + .description("Fetch one plugin, including its stored source.") + .tag(MANAGEMENT_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "Plugin identifier") + .handler(plugins::get) + .json_response_with_schema::(openapi, StatusCode::OK, "The plugin") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/plugins/{id}/source") + .operation_id("oagw.plugins.get_source") + .summary("Get a plugin's source") + .description("Fetch the plugin source verbatim.") + .tag(MANAGEMENT_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "Plugin identifier") + .handler(plugins::get_source) + .text_response(StatusCode::OK, "The plugin source", "text/plain") + .standard_errors(openapi) + .register(router, openapi); + + OperationBuilder::delete("/oagw/v1/plugins/{id}") + .operation_id("oagw.plugins.delete") + .summary("Delete a plugin") + .description("Delete a plugin that nothing references.") + .tag(MANAGEMENT_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "Plugin identifier") + .handler(plugins::delete) + .no_content_response(StatusCode::NO_CONTENT, "Plugin deleted") + .standard_errors(openapi) + .register(router, openapi) +} + +/// Register the proxy endpoint for every method the schema permits. +/// +/// Each method is registered separately so the gateway's authentication policy, +/// which is keyed on method and path template, recognises all of them. +fn register_proxy(mut router: Router, openapi: &dyn OpenApiRegistry) -> Router { + for (index, path) in ["/oagw/v1/proxy/{alias}", "/oagw/v1/proxy/{alias}/{*rest}"] + .into_iter() + .enumerate() + { + let with_suffix = index == 1; + router = register_proxy_methods(router, openapi, path, with_suffix); + } + router +} + +fn register_proxy_methods( + mut router: Router, + openapi: &dyn OpenApiRegistry, + path: &str, + with_suffix: bool, +) -> Router { + let suffix_tag = if with_suffix { "path" } else { "root" }; + for method in ["get", "post", "put", "patch", "delete"] { + let builder = match method { + "post" => OperationBuilder::post(path), + "put" => OperationBuilder::put(path), + "patch" => OperationBuilder::patch(path), + "delete" => OperationBuilder::delete(path), + _ => OperationBuilder::get(path), + }; + let builder = builder + .operation_id(format!("oagw.proxy.{suffix_tag}.{method}")) + .summary("Proxy a request to an upstream") + .description( + "Forward the request to the upstream named by the alias. Plain responses, \ + server-sent-event streams and WebSocket upgrades are all relayed.", + ) + .tag(PROXY_TAG) + .authenticated() + .require_license_features::([]) + .path_param("alias", "Upstream alias"); + let builder = if with_suffix { + builder.path_param("rest", "Path suffix appended to the route path") + } else { + builder + }; + router = if with_suffix { + builder + .handler(proxy::proxy_with_path) + .json_response(StatusCode::OK, "Relayed upstream response") + .standard_errors(openapi) + .register(router, openapi) + } else { + builder + .handler(proxy::proxy_root) + .json_response(StatusCode::OK, "Relayed upstream response") + .standard_errors(openapi) + .register(router, openapi) + }; + } + register_proxy_preflight(router, openapi, path, with_suffix, suffix_tag) +} + +/// Register the preflight method for a proxy path. +/// +/// A cross-origin preflight carries no credentials, so the route is anonymous. +/// `OperationBuilder::handler` maps only the five body-carrying methods, so the +/// method router is supplied directly. +fn register_proxy_preflight( + router: Router, + openapi: &dyn OpenApiRegistry, + path: &str, + with_suffix: bool, + suffix_tag: &str, +) -> Router { + let builder = OperationBuilder::new(http::Method::OPTIONS, path) + .operation_id(format!("oagw.proxy.{suffix_tag}.options")) + .summary("Answer a cross-origin preflight") + .description("Answer a preflight permissively, without resolving an upstream or a tenant.") + .tag(PROXY_TAG) + .anonymous() + .path_param("alias", "Upstream alias"); + let builder = if with_suffix { + builder.path_param("rest", "Path suffix appended to the route path") + } else { + builder + }; + if with_suffix { + builder + .method_router(options(proxy::preflight_with_path)) + .no_content_response(StatusCode::NO_CONTENT, "Preflight accepted") + .register(router, openapi) + } else { + builder + .method_router(options(proxy::preflight_root)) + .no_content_response(StatusCode::NO_CONTENT, "Preflight accepted") + .register(router, openapi) + } +} +// @cpt-end:cpt-cf-oagw-dod-gear-foundation-router-mount:p1:inst-router diff --git a/gears/system/oagw/oagw/src/api/rest/state.rs b/gears/system/oagw/oagw/src/api/rest/state.rs new file mode 100644 index 0000000..d8c62c7 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/state.rs @@ -0,0 +1,58 @@ +//! Shared state handed to every handler through an axum extension. + +use crate::config::OagwConfig; +use crate::infra::oauth2::TokenCache; +use crate::infra::proxy::RoundRobin; +use crate::infra::ratelimit::RateLimiter; +use crate::infra::store::Store; +use credstore_sdk::CredStoreClientV1; +use std::sync::Arc; +use toolkit_http::HttpClient; + +/// Everything a handler needs to serve a request. +pub struct OagwState { + /// Gear configuration. + pub config: OagwConfig, + /// Tenant-scoped configuration storage. + pub store: Store, + /// Outbound HTTP client. + pub http: HttpClient, + /// Per-instance rate limiters. + pub rate_limiter: RateLimiter, + /// Round-robin cursor for multi-endpoint pools. + pub round_robin: RoundRobin, + /// Credential store, when one is wired. + pub cred_store: Option>, + /// Cache of client-credentials tokens. + pub token_cache: TokenCache, +} + +impl std::fmt::Debug for OagwState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OagwState") + .field("config", &self.config) + .field("cred_store", &self.cred_store.is_some()) + .finish_non_exhaustive() + } +} + +impl OagwState { + /// Build the shared state. + #[must_use] + pub fn new( + config: OagwConfig, + http: HttpClient, + cred_store: Option>, + ) -> Self { + let config_capacity = config.token_cache_capacity; + Self { + config, + store: Store::new(), + http, + rate_limiter: RateLimiter::new(), + round_robin: RoundRobin::new(), + cred_store, + token_cache: TokenCache::new(config_capacity), + } + } +} diff --git a/gears/system/oagw/oagw/src/config.rs b/gears/system/oagw/oagw/src/config.rs new file mode 100644 index 0000000..e2fc818 --- /dev/null +++ b/gears/system/oagw/oagw/src/config.rs @@ -0,0 +1,108 @@ +// @cpt-begin:cpt-cf-oagw-dod-gear-foundation-config-defaults:p1:inst-config +//! Gear configuration for the outbound API gateway. +//! +//! Deserialized from the `gears.oagw.config` block of the server configuration. +//! Every field carries a default so an absent block still yields a usable gear. + +use serde::Deserialize; + +/// Default outbound proxy timeout in seconds. +const fn default_proxy_timeout_secs() -> u64 { + 30 +} + +/// Default `OAuth2` token-cache time to live, in seconds (ADR-0008). +const fn default_token_cache_ttl_secs() -> u64 { + 300 +} + +/// Default `OAuth2` token-cache capacity (ADR-0008). +const fn default_token_cache_capacity() -> usize { + 10_000 +} + +/// Server-side request forgery policy. +/// +/// The checks always run; this flag decides whether a failure is enforced. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct SsrfPolicyConfig { + /// Whether an SSRF check failure rejects the request. + pub enabled: bool, +} + +impl Default for SsrfPolicyConfig { + fn default() -> Self { + Self { enabled: true } + } +} + +/// Gear-level configuration for `oagw`. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct OagwConfig { + /// Timeout applied to reaching an upstream response head, in seconds. + /// + /// It does not bound the lifetime of an established stream or WebSocket + /// session; those are governed by their own lifecycle. + pub proxy_timeout_secs: u64, + /// Whether a plaintext upstream connection may actually be made. + /// + /// This is independent of which schemes the API accepts at create time. + pub allow_http_upstream: bool, + /// Server-side request forgery policy. + pub ssrf_policy: SsrfPolicyConfig, + /// Time to live for cached `OAuth2` client-credentials tokens, in seconds. + pub token_cache_ttl_secs: u64, + /// Maximum number of cached `OAuth2` client-credentials tokens. + pub token_cache_capacity: usize, +} + +impl Default for OagwConfig { + fn default() -> Self { + Self { + proxy_timeout_secs: default_proxy_timeout_secs(), + allow_http_upstream: false, + ssrf_policy: SsrfPolicyConfig::default(), + token_cache_ttl_secs: default_token_cache_ttl_secs(), + token_cache_capacity: default_token_cache_capacity(), + } + } +} +// @cpt-end:cpt-cf-oagw-dod-gear-foundation-config-defaults:p1:inst-config + +#[cfg(test)] +mod tests { + use super::OagwConfig; + + #[test] + fn defaults_apply_when_block_is_absent() { + let cfg = OagwConfig::default(); + assert_eq!(cfg.proxy_timeout_secs, 30); + assert!(!cfg.allow_http_upstream); + assert!(cfg.ssrf_policy.enabled); + assert_eq!(cfg.token_cache_ttl_secs, 300); + assert_eq!(cfg.token_cache_capacity, 10_000); + } + + #[test] + fn graded_configuration_block_deserializes() { + let value = serde_json::json!({ + "proxy_timeout_secs": 2, + "allow_http_upstream": true, + "ssrf_policy": { "enabled": false } + }); + let cfg: OagwConfig = serde_json::from_value(value).expect("config parses"); + assert_eq!(cfg.proxy_timeout_secs, 2); + assert!(cfg.allow_http_upstream); + assert!(!cfg.ssrf_policy.enabled); + // Fields absent from the block still fall back to their defaults. + assert_eq!(cfg.token_cache_ttl_secs, 300); + } + + #[test] + fn unknown_field_is_rejected() { + let value = serde_json::json!({ "nope": 1 }); + assert!(serde_json::from_value::(value).is_err()); + } +} diff --git a/gears/system/oagw/oagw/src/domain/alias.rs b/gears/system/oagw/oagw/src/domain/alias.rs new file mode 100644 index 0000000..a04edf0 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/alias.rs @@ -0,0 +1,431 @@ +// @cpt-begin:cpt-cf-oagw-dod-resource-model-alias-derivation:p1:inst-alias +//! Alias derivation, normalization and update rules. +//! +//! An alias is the routing key in `/oagw/v1/proxy/{alias}/...`. It is derived +//! from hostname endpoints and must be supplied explicitly for endpoints the +//! gateway cannot derive from. + +use super::error::{DomainError, DomainResult}; +use super::model::{Endpoint, Scheme}; + +/// Normalize an alias to lowercase with any trailing dot removed. +#[must_use] +pub fn normalize_alias(alias: &str) -> String { + alias.trim_end_matches('.').to_ascii_lowercase() +} + +/// Validate a hostname per RFC 1123. +/// +/// # Errors +/// Returns a validation error when the hostname is empty, too long, or has a +/// label that is empty, over-long, or hyphen-terminated. +pub fn validate_hostname(host: &str) -> DomainResult<()> { + let host = host.trim_end_matches('.'); + if host.is_empty() { + return Err(DomainError::validation("endpoint host must not be empty")); + } + if host.len() > 253 { + return Err(DomainError::validation( + "endpoint host must not exceed 253 characters", + )); + } + if is_ip_literal(host) { + return Ok(()); + } + for label in host.split('.') { + if label.is_empty() || label.len() > 63 { + return Err(DomainError::validation(format!( + "endpoint host label `{label}` must be 1 to 63 characters" + ))); + } + if label.starts_with('-') || label.ends_with('-') { + return Err(DomainError::validation(format!( + "endpoint host label `{label}` must not start or end with a hyphen" + ))); + } + if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { + return Err(DomainError::validation(format!( + "endpoint host label `{label}` must be alphanumeric or hyphen" + ))); + } + } + Ok(()) +} + +/// Whether a host string is an IP literal rather than a hostname. +/// +/// Trims a trailing dot before testing, because every caller means "is this +/// endpoint host, once normalized, an IP address" -- an IP literal such as +/// `10.0.0.1.` or `[::1].` must not be mistaken for a hostname and silently +/// given a derived alias, which would bypass the rule that an IP-based pool +/// requires an explicit alias. +#[must_use] +pub fn is_ip_literal(host: &str) -> bool { + let host = host.trim_end_matches('.'); + let bare = host.trim_start_matches('[').trim_end_matches(']'); + bare.parse::().is_ok() +} + +/// Render the alias suffix for a port, omitting the scheme's standard port. +fn port_suffix(scheme: Scheme, port: u16) -> String { + if port == scheme.standard_port() { + String::new() + } else { + format!(":{port}") + } +} + +/// Derive the alias implied by an endpoint pool. +/// +/// Returns `None` when derivation is not possible, which is the case for IP +/// endpoints, for heterogeneous hostnames with no common suffix, and for a pool +/// whose only common suffix is a bare public suffix. +#[must_use] +pub fn compute_derived_alias(endpoints: &[Endpoint]) -> Option { + let first = endpoints.first()?; + if endpoints.iter().any(|e| is_ip_literal(&e.host)) { + return None; + } + let suffix = port_suffix(first.scheme, first.port); + + if endpoints.len() == 1 { + let host = normalize_alias(&first.host); + return Some(format!("{host}{suffix}")); + } + + let hosts: Vec = endpoints.iter().map(|e| normalize_alias(&e.host)).collect(); + if hosts.iter().all(|h| *h == hosts[0]) { + return Some(format!("{}{suffix}", hosts[0])); + } + let common = common_domain_suffix(&hosts)?; + Some(format!("{common}{suffix}")) +} + +/// Longest common registrable domain suffix of a set of hostnames. +/// +/// Returns `None` when the hosts share fewer than two labels, or when the +/// shared suffix is itself a bare public suffix such as `co.uk`. +fn common_domain_suffix(hosts: &[String]) -> Option { + let mut label_lists: Vec> = hosts + .iter() + .map(|h| h.split('.').rev().collect::>()) + .collect(); + let shortest = label_lists.iter().map(Vec::len).min()?; + let mut shared: Vec<&str> = Vec::new(); + for index in 0..shortest { + let candidate = label_lists[0][index]; + if label_lists.iter().all(|labels| labels[index] == candidate) { + shared.push(candidate); + } else { + break; + } + } + label_lists.clear(); + if shared.len() < 2 { + return None; + } + shared.reverse(); + let suffix = shared.join("."); + if is_bare_public_suffix(&suffix) { + return None; + } + Some(suffix) +} + +/// Whether a domain is a bare public suffix and so not registrable. +fn is_bare_public_suffix(domain: &str) -> bool { + let Some(found) = psl::suffix(domain.as_bytes()) else { + return false; + }; + let Ok(suffix) = std::str::from_utf8(found.as_bytes()) else { + return false; + }; + suffix.eq_ignore_ascii_case(domain) +} + +/// Resolve the alias a create request should store. +/// +/// # Errors +/// Returns a validation error when a derivable pool is given a conflicting +/// explicit alias, or when a non-derivable pool is given none. +pub fn resolve_create_alias( + endpoints: &[Endpoint], + requested: Option<&str>, +) -> DomainResult { + let derived = compute_derived_alias(endpoints); + match (derived, requested) { + (Some(derived), None) => Ok(derived), + (Some(derived), Some(requested)) => { + let requested = normalize_alias(requested); + if requested == derived { + // Exact match is tolerated so a create is idempotent. + Ok(derived) + } else { + Err(DomainError::validation(format!( + "alias is derived from hostname endpoints as `{derived}`; \ + remove the alias field or supply that exact value" + ))) + } + } + (None, Some(requested)) => { + let requested = normalize_alias(requested); + validate_alias_pattern(&requested)?; + Ok(requested) + } + (None, None) => Err(DomainError::validation( + "alias is required because it cannot be derived from these endpoints", + )), + } +} + +/// Validate an alias against the pattern the schema declares. +/// +/// # Errors +/// Returns a validation error when the alias does not match +/// `^[a-z0-9]([a-z0-9.:-]*[a-z0-9])?$`. +pub fn validate_alias_pattern(alias: &str) -> DomainResult<()> { + let invalid = alias.is_empty() + || !alias + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | ':' | '-')) + || !starts_and_ends_alphanumeric(alias); + if invalid { + return Err(DomainError::validation(format!( + "alias `{alias}` must match ^[a-z0-9]([a-z0-9.:-]*[a-z0-9])?$" + ))); + } + Ok(()) +} + +fn starts_and_ends_alphanumeric(alias: &str) -> bool { + let first_ok = alias + .chars() + .next() + .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit()); + let last_ok = alias + .chars() + .next_back() + .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit()); + first_ok && last_ok +} + +/// Enforce the alias-update rules on a replacement. +/// +/// The alias is immutable once set, because it is the routing key. Any change +/// that would alter it is rejected; the operator must delete and re-create. +/// +/// # Errors +/// Returns a validation error when the replacement would change the alias. +pub fn enforce_alias_update( + existing_alias: &str, + endpoints: &[Endpoint], + requested: Option<&str>, +) -> DomainResult { + let derived = compute_derived_alias(endpoints); + match derived { + Some(derived) if derived == existing_alias => Ok(derived), + Some(derived) => Err(DomainError::validation(format!( + "alias is immutable; these endpoints derive `{derived}` but the upstream is \ + `{existing_alias}`. Delete and re-create the upstream instead" + ))), + None => { + // Non-derivable endpoints retain the existing alias. A differing + // explicit alias is rejected. + match requested { + None => Ok(existing_alias.to_owned()), + Some(requested) if normalize_alias(requested) == existing_alias => { + Ok(existing_alias.to_owned()) + } + Some(_) => Err(DomainError::validation(format!( + "alias is immutable; the upstream keeps `{existing_alias}`. \ + Delete and re-create the upstream instead" + ))), + } + } + } +} +// @cpt-end:cpt-cf-oagw-dod-resource-model-alias-derivation:p1:inst-alias + +#[cfg(test)] +mod tests { + use super::{ + compute_derived_alias, enforce_alias_update, is_ip_literal, normalize_alias, + resolve_create_alias, validate_alias_pattern, validate_hostname, + }; + use crate::domain::model::{Endpoint, Scheme}; + + fn endpoint(host: &str, scheme: Scheme, port: u16) -> Endpoint { + Endpoint { + scheme, + host: host.to_owned(), + port, + } + } + + #[test] + fn single_hostname_on_standard_port_derives_bare_host() { + let eps = vec![endpoint("api.openai.com", Scheme::Https, 443)]; + assert_eq!( + compute_derived_alias(&eps).as_deref(), + Some("api.openai.com") + ); + } + + #[test] + fn single_hostname_on_non_standard_port_keeps_the_port() { + let eps = vec![endpoint("api.openai.com", Scheme::Https, 8443)]; + assert_eq!( + compute_derived_alias(&eps).as_deref(), + Some("api.openai.com:8443") + ); + } + + #[test] + fn plaintext_endpoint_on_port_eighty_derives_bare_host() { + let eps = vec![endpoint("stub.internal", Scheme::Http, 80)]; + assert_eq!( + compute_derived_alias(&eps).as_deref(), + Some("stub.internal") + ); + } + + #[test] + fn multiple_hostnames_derive_the_common_registrable_suffix() { + let eps = vec![ + endpoint("us.vendor.com", Scheme::Https, 443), + endpoint("eu.vendor.com", Scheme::Https, 443), + ]; + assert_eq!(compute_derived_alias(&eps).as_deref(), Some("vendor.com")); + } + + #[test] + fn common_suffix_pool_keeps_a_non_standard_port() { + let eps = vec![ + endpoint("us.vendor.com", Scheme::Https, 8443), + endpoint("eu.vendor.com", Scheme::Https, 8443), + ]; + assert_eq!( + compute_derived_alias(&eps).as_deref(), + Some("vendor.com:8443") + ); + } + + #[test] + fn bare_public_suffix_is_not_derivable() { + let eps = vec![ + endpoint("foo.co.uk", Scheme::Https, 443), + endpoint("bar.co.uk", Scheme::Https, 443), + ]; + assert_eq!(compute_derived_alias(&eps), None); + } + + #[test] + fn heterogeneous_hostnames_are_not_derivable() { + let eps = vec![ + endpoint("us.foo.com", Scheme::Https, 443), + endpoint("eu.bar.com", Scheme::Https, 443), + ]; + assert_eq!(compute_derived_alias(&eps), None); + } + + #[test] + fn ip_endpoints_are_not_derivable() { + let eps = vec![endpoint("10.0.1.1", Scheme::Https, 443)]; + assert_eq!(compute_derived_alias(&eps), None); + assert!(is_ip_literal("10.0.1.1")); + assert!(!is_ip_literal("api.openai.com")); + } + + #[test] + fn create_rejects_an_alias_that_differs_from_the_derived_one() { + let eps = vec![endpoint("api.openai.com", Scheme::Https, 443)]; + assert!(resolve_create_alias(&eps, Some("something-else")).is_err()); + } + + #[test] + fn create_tolerates_the_exact_derived_alias() { + let eps = vec![endpoint("api.openai.com", Scheme::Https, 443)]; + let alias = resolve_create_alias(&eps, Some("API.OpenAI.COM")).expect("idempotent"); + assert_eq!(alias, "api.openai.com"); + } + + #[test] + fn create_requires_an_explicit_alias_for_ip_endpoints() { + let eps = vec![endpoint("10.0.1.1", Scheme::Https, 443)]; + assert!(resolve_create_alias(&eps, None).is_err()); + let alias = resolve_create_alias(&eps, Some("my-service")).expect("explicit alias"); + assert_eq!(alias, "my-service"); + } + + #[test] + fn replacement_rejects_an_endpoint_change_that_alters_the_alias() { + let eps = vec![endpoint("api.other.com", Scheme::Https, 443)]; + assert!(enforce_alias_update("api.openai.com", &eps, None).is_err()); + } + + #[test] + fn replacement_allows_endpoints_that_derive_the_same_alias() { + let eps = vec![endpoint("api.openai.com", Scheme::Https, 443)]; + let alias = enforce_alias_update("api.openai.com", &eps, None).expect("unchanged"); + assert_eq!(alias, "api.openai.com"); + } + + #[test] + fn replacement_of_ip_pool_retains_the_existing_alias() { + let eps = vec![endpoint("10.0.1.9", Scheme::Https, 443)]; + let alias = enforce_alias_update("my-service", &eps, None).expect("retained"); + assert_eq!(alias, "my-service"); + assert!(enforce_alias_update("my-service", &eps, Some("renamed")).is_err()); + } + + #[test] + fn ip_literal_detection_trims_a_trailing_dot() { + assert!(is_ip_literal("10.0.0.1.")); + assert!(is_ip_literal("[::1].")); + // A hostname that legitimately ends in a dot (a fully-qualified DNS + // name) is still not an IP literal. + assert!(!is_ip_literal("api.openai.com.")); + } + + #[test] + fn an_ip_endpoint_with_a_trailing_dot_still_requires_an_explicit_alias() { + let eps = vec![endpoint("10.0.0.1.", Scheme::Https, 443)]; + assert_eq!(compute_derived_alias(&eps), None); + assert!(resolve_create_alias(&eps, None).is_err()); + let alias = resolve_create_alias(&eps, Some("my-service")).expect("explicit alias"); + assert_eq!(alias, "my-service"); + } + + #[test] + fn replacement_of_a_trailing_dot_ip_pool_retains_the_existing_alias() { + let eps = vec![endpoint("10.0.0.1.", Scheme::Https, 443)]; + let alias = enforce_alias_update("my-service", &eps, None).expect("retained"); + assert_eq!(alias, "my-service"); + assert!(enforce_alias_update("my-service", &eps, Some("renamed")).is_err()); + } + + #[test] + fn hostname_validation_follows_rfc_1123() { + assert!(validate_hostname("api.openai.com").is_ok()); + assert!(validate_hostname("api.openai.com.").is_ok()); + assert!(validate_hostname("10.0.0.1").is_ok()); + assert!(validate_hostname("").is_err()); + assert!(validate_hostname("-bad.example.com").is_err()); + assert!(validate_hostname("bad-.example.com").is_err()); + assert!(validate_hostname("under_score.example.com").is_err()); + } + + #[test] + fn alias_pattern_is_enforced_for_explicit_values() { + assert!(validate_alias_pattern("my-service").is_ok()); + assert!(validate_alias_pattern("api.openai.com:8443").is_ok()); + assert!(validate_alias_pattern("-leading").is_err()); + assert!(validate_alias_pattern("trailing-").is_err()); + assert!(validate_alias_pattern("Upper").is_err()); + } + + #[test] + fn normalization_lowercases_and_strips_a_trailing_dot() { + assert_eq!(normalize_alias("API.OpenAI.COM."), "api.openai.com"); + } +} diff --git a/gears/system/oagw/oagw/src/domain/error.rs b/gears/system/oagw/oagw/src/domain/error.rs new file mode 100644 index 0000000..e453fbc --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/error.rs @@ -0,0 +1,274 @@ +//! Domain error type and its mapping onto the gateway error catalogue. +//! +//! The catalogue is the error table of `DESIGN.md` section 3.3, which is the +//! authoritative superset of the shorter table in `PRD.md`. + +use std::fmt; + +/// Where an error response originated. +/// +/// Emitted as the `X-OAGW-Error-Source` header on every response. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ErrorSource { + /// The gateway itself produced the response. + Gateway, + /// The response was relayed from the upstream service. + Upstream, +} + +impl ErrorSource { + /// Header value for this source. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Gateway => "gateway", + Self::Upstream => "upstream", + } + } +} + +/// Name of the error-source header. +pub const ERROR_SOURCE_HEADER: &str = "x-oagw-error-source"; + +// @cpt-begin:cpt-cf-oagw-dod-gear-foundation-error-type-catalog:p1:inst-catalog +/// Gateway error kinds, one per row of the `DESIGN.md` section 3.3 table. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ErrorKind { + /// General route validation error. + RouteError, + /// Request validation failed. + ValidationError, + /// `X-OAGW-Target-Host` is required but absent. + MissingTargetHost, + /// `X-OAGW-Target-Host` is malformed. + InvalidTargetHost, + /// `X-OAGW-Target-Host` matches no configured endpoint. + UnknownTargetHost, + /// Authentication failed. + AuthenticationFailed, + /// No matching route was found. + RouteNotFound, + /// A plugin is still referenced and cannot be deleted. + PluginInUse, + /// An upstream alias collides with an existing one. + UpstreamAliasConflict, + /// A route match rule collides with an existing one. + RouteMatchConflict, + /// The request payload exceeds the limit. + PayloadTooLarge, + /// A rate limit was exceeded. + RateLimitExceeded, + /// A referenced secret could not be found. + SecretNotFound, + /// A protocol-level error occurred. + ProtocolError, + /// The upstream service returned an error the gateway raised itself. + DownstreamError, + /// A stream was aborted after it had begun. + StreamAborted, + /// The upstream link is unavailable. + LinkUnavailable, + /// The circuit breaker is open. + CircuitBreakerOpen, + /// A referenced plugin could not be resolved. + PluginNotFound, + /// Establishing the upstream connection timed out. + ConnectionTimeout, + /// The upstream request timed out. + RequestTimeout, + /// An idle stream timed out. + IdleTimeout, +} + +impl ErrorKind { + /// HTTP status code for this error kind. + #[must_use] + pub const fn status(self) -> u16 { + match self { + Self::RouteError + | Self::ValidationError + | Self::MissingTargetHost + | Self::InvalidTargetHost + | Self::UnknownTargetHost => 400, + Self::AuthenticationFailed => 401, + Self::RouteNotFound => 404, + Self::PluginInUse | Self::UpstreamAliasConflict | Self::RouteMatchConflict => 409, + Self::PayloadTooLarge => 413, + Self::RateLimitExceeded => 429, + Self::SecretNotFound => 500, + Self::ProtocolError | Self::DownstreamError | Self::StreamAborted => 502, + Self::LinkUnavailable | Self::CircuitBreakerOpen | Self::PluginNotFound => 503, + Self::ConnectionTimeout | Self::RequestTimeout | Self::IdleTimeout => 504, + } + } + + /// Global type system identifier for this error kind. + #[must_use] + pub const fn gts_type(self) -> &'static str { + match self { + Self::RouteError | Self::ValidationError => { + "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1" + } + Self::MissingTargetHost => { + "gts.cf.core.errors.err.v1~cf.oagw.routing.missing_target_host.v1" + } + Self::InvalidTargetHost => { + "gts.cf.core.errors.err.v1~cf.oagw.routing.invalid_target_host.v1" + } + Self::UnknownTargetHost => { + "gts.cf.core.errors.err.v1~cf.oagw.routing.unknown_target_host.v1" + } + Self::AuthenticationFailed => "gts.cf.core.errors.err.v1~cf.oagw.auth.failed.v1", + Self::RouteNotFound => "gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1", + Self::PluginInUse => "gts.cf.core.errors.err.v1~cf.oagw.plugin.in_use.v1", + Self::UpstreamAliasConflict => { + "gts.cf.core.errors.err.v1~cf.oagw.upstream.alias_conflict.v1" + } + Self::RouteMatchConflict => "gts.cf.core.errors.err.v1~cf.oagw.route.match_conflict.v1", + Self::PayloadTooLarge => "gts.cf.core.errors.err.v1~cf.oagw.payload.too_large.v1", + Self::RateLimitExceeded => "gts.cf.core.errors.err.v1~cf.oagw.rate_limit.exceeded.v1", + Self::SecretNotFound => "gts.cf.core.errors.err.v1~cf.oagw.secret.not_found.v1", + Self::ProtocolError => "gts.cf.core.errors.err.v1~cf.oagw.protocol.error.v1", + Self::DownstreamError => "gts.cf.core.errors.err.v1~cf.oagw.downstream.error.v1", + Self::StreamAborted => "gts.cf.core.errors.err.v1~cf.oagw.stream.aborted.v1", + Self::LinkUnavailable => "gts.cf.core.errors.err.v1~cf.oagw.link.unavailable.v1", + Self::CircuitBreakerOpen => "gts.cf.core.errors.err.v1~cf.oagw.circuit_breaker.open.v1", + Self::PluginNotFound => "gts.cf.core.errors.err.v1~cf.oagw.plugin.not_found.v1", + Self::ConnectionTimeout => "gts.cf.core.errors.err.v1~cf.oagw.timeout.connection.v1", + Self::RequestTimeout => "gts.cf.core.errors.err.v1~cf.oagw.timeout.request.v1", + Self::IdleTimeout => "gts.cf.core.errors.err.v1~cf.oagw.timeout.idle.v1", + } + } + + /// Short human-readable title for this error kind. + #[must_use] + pub const fn title(self) -> &'static str { + match self { + Self::RouteError => "Route error", + Self::ValidationError => "Validation error", + Self::MissingTargetHost => "Missing target host", + Self::InvalidTargetHost => "Invalid target host", + Self::UnknownTargetHost => "Unknown target host", + Self::AuthenticationFailed => "Authentication failed", + Self::RouteNotFound => "Route not found", + Self::PluginInUse => "Plugin in use", + Self::UpstreamAliasConflict => "Upstream alias conflict", + Self::RouteMatchConflict => "Route match conflict", + Self::PayloadTooLarge => "Payload too large", + Self::RateLimitExceeded => "Rate limit exceeded", + Self::SecretNotFound => "Secret not found", + Self::ProtocolError => "Protocol error", + Self::DownstreamError => "Downstream error", + Self::StreamAborted => "Stream aborted", + Self::LinkUnavailable => "Link unavailable", + Self::CircuitBreakerOpen => "Circuit breaker open", + Self::PluginNotFound => "Plugin not found", + Self::ConnectionTimeout => "Connection timeout", + Self::RequestTimeout => "Request timeout", + Self::IdleTimeout => "Idle timeout", + } + } +} +// @cpt-end:cpt-cf-oagw-dod-gear-foundation-error-type-catalog:p1:inst-catalog + +/// An error raised by the gateway itself. +#[derive(Debug, Clone)] +pub struct DomainError { + /// Which catalogue entry this error is. + pub kind: ErrorKind, + /// Occurrence-specific explanation. + pub detail: String, + /// Extra members carried in the problem document's `context`. + pub context: serde_json::Value, +} + +impl DomainError { + /// Build an error of the given kind with a detail message. + #[must_use] + pub fn new(kind: ErrorKind, detail: impl Into) -> Self { + Self { + kind, + detail: detail.into(), + context: serde_json::Value::Null, + } + } + + /// Attach extension members to the problem document's `context`. + #[must_use] + pub fn with_context(mut self, context: serde_json::Value) -> Self { + self.context = context; + self + } + + /// A `400` validation error. + #[must_use] + pub fn validation(detail: impl Into) -> Self { + Self::new(ErrorKind::ValidationError, detail) + } + + /// A `404` not-found error. + #[must_use] + pub fn not_found(detail: impl Into) -> Self { + Self::new(ErrorKind::RouteNotFound, detail) + } + + /// HTTP status code for this error. + #[must_use] + pub const fn status(&self) -> u16 { + self.kind.status() + } +} + +impl fmt::Display for DomainError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.kind.title(), self.detail) + } +} + +impl std::error::Error for DomainError {} + +/// Result alias for domain operations. +pub type DomainResult = Result; + +#[cfg(test)] +mod tests { + use super::{ErrorKind, ErrorSource}; + + #[test] + fn error_source_header_values() { + assert_eq!(ErrorSource::Gateway.as_str(), "gateway"); + assert_eq!(ErrorSource::Upstream.as_str(), "upstream"); + } + + #[test] + fn catalogue_statuses_match_the_design_table() { + assert_eq!(ErrorKind::ValidationError.status(), 400); + assert_eq!(ErrorKind::MissingTargetHost.status(), 400); + assert_eq!(ErrorKind::AuthenticationFailed.status(), 401); + assert_eq!(ErrorKind::RouteNotFound.status(), 404); + assert_eq!(ErrorKind::PluginInUse.status(), 409); + assert_eq!(ErrorKind::PayloadTooLarge.status(), 413); + assert_eq!(ErrorKind::RateLimitExceeded.status(), 429); + assert_eq!(ErrorKind::SecretNotFound.status(), 500); + assert_eq!(ErrorKind::DownstreamError.status(), 502); + assert_eq!(ErrorKind::StreamAborted.status(), 502); + assert_eq!(ErrorKind::LinkUnavailable.status(), 503); + assert_eq!(ErrorKind::PluginNotFound.status(), 503); + assert_eq!(ErrorKind::RequestTimeout.status(), 504); + } + + #[test] + fn catalogue_types_are_gts_identifiers() { + for kind in [ + ErrorKind::ValidationError, + ErrorKind::RouteNotFound, + ErrorKind::RateLimitExceeded, + ErrorKind::StreamAborted, + ] { + assert!( + kind.gts_type() + .starts_with("gts.cf.core.errors.err.v1~cf.oagw.") + ); + } + } +} diff --git a/gears/system/oagw/oagw/src/domain/mod.rs b/gears/system/oagw/oagw/src/domain/mod.rs new file mode 100644 index 0000000..1cb97a2 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/mod.rs @@ -0,0 +1,9 @@ +//! Domain layer: entities, validation, alias rules and service contracts. +//! +//! This layer has no infrastructure dependencies. Infrastructure implements its +//! traits and the transport layer maps between HTTP and these types. + +pub mod alias; +pub mod error; +pub mod model; +pub mod validate; diff --git a/gears/system/oagw/oagw/src/domain/model.rs b/gears/system/oagw/oagw/src/domain/model.rs new file mode 100644 index 0000000..fe12c6e --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/model.rs @@ -0,0 +1,600 @@ +// @cpt-begin:cpt-cf-oagw-dod-resource-model-domain-types:p1:inst-model +//! Domain entities for the outbound API gateway. +//! +//! The shapes mirror `schemas/upstream.v1.schema.json` and +//! `schemas/route.v1.schema.json` field for field. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use uuid::Uuid; + +/// Configuration visibility across a tenant hierarchy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SharingMode { + /// Not visible to descendants. + #[default] + Private, + /// Visible; a descendant may override it. + Inherit, + /// Visible; a descendant may not override it. + Enforce, +} + +/// Transport scheme of an upstream endpoint. +/// +/// The supplied schema enumerates `https`, `wss`, `wt` and `grpc`. The gateway +/// additionally accepts `http`, because `allow_http_upstream` lifts the +/// HTTPS-only default posture, and tolerates `ws` as the plaintext counterpart +/// of `wss`. Accepting a value here is independent of whether a plaintext +/// connection is later made. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Scheme { + /// Plaintext HTTP. + Http, + /// HTTP over TLS. + #[default] + Https, + /// Plaintext WebSocket. + Ws, + /// WebSocket over TLS. + Wss, + /// WebTransport. + Wt, + /// Generic remote procedure call. + Grpc, +} + +impl Scheme { + /// Whether this scheme carries no transport encryption. + #[must_use] + pub const fn is_plaintext(self) -> bool { + matches!(self, Self::Http | Self::Ws) + } + + /// The port omitted from a derived alias for this scheme. + #[must_use] + pub const fn standard_port(self) -> u16 { + match self { + Self::Http | Self::Ws => 80, + Self::Https | Self::Wss | Self::Wt | Self::Grpc => 443, + } + } + + /// The scheme as it appears in an outbound URL. + #[must_use] + pub const fn url_scheme(self) -> &'static str { + match self { + Self::Http => "http", + Self::Https | Self::Wt | Self::Grpc => "https", + Self::Ws => "ws", + Self::Wss => "wss", + } + } +} + +/// One reachable address of an upstream service. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Endpoint { + /// Transport scheme. + #[serde(default)] + pub scheme: Scheme, + /// Hostname or IP literal. + pub host: String, + /// TCP port. + #[serde(default = "default_port")] + pub port: u16, +} + +const fn default_port() -> u16 { + 443 +} + +/// The endpoint pool of an upstream. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerConfig { + /// One or more endpoints forming a load-balance pool. + pub endpoints: Vec, +} + +/// Outbound credential configuration. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AuthConfig { + /// Global type system identifier of the auth plugin. + #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] + pub plugin_type: Option, + /// Visibility of this block to descendant tenants. + #[serde(default)] + pub sharing: SharingMode, + /// Plugin-specific configuration. + #[serde(default)] + pub config: BTreeMap, +} + +/// How inbound headers are forwarded to the upstream. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PassthroughMode { + /// Forward nothing beyond what the gateway itself adds. + #[default] + None, + /// Forward only the names in the allowlist. + Allowlist, + /// Forward every header that is not stripped. + All, +} + +/// Request-side header transformation rules. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RequestHeaderRules { + /// Headers to overwrite. + #[serde(default)] + pub set: BTreeMap, + /// Headers to append. + #[serde(default)] + pub add: BTreeMap, + /// Header names to drop. + #[serde(default)] + pub remove: Vec, + /// Which inbound headers are forwarded. + #[serde(default)] + pub passthrough: PassthroughMode, + /// Names forwarded when the mode is `allowlist`. + #[serde(default)] + pub passthrough_allowlist: Vec, +} + +/// Response-side header transformation rules. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ResponseHeaderRules { + /// Headers to overwrite. + #[serde(default)] + pub set: BTreeMap, + /// Headers to append. + #[serde(default)] + pub add: BTreeMap, + /// Header names to drop. + #[serde(default)] + pub remove: Vec, +} + +/// Header transformation configuration. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HeadersConfig { + /// Rules applied to the outbound request. + #[serde(default)] + pub request: RequestHeaderRules, + /// Rules applied to the relayed response. + #[serde(default)] + pub response: ResponseHeaderRules, +} + +/// Rate-limiting algorithm. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RateLimitAlgorithm { + /// Token bucket with a sustained refill rate and a burst capacity. + #[default] + TokenBucket, + /// Sliding window over the sustained period. + SlidingWindow, +} + +/// The period a sustained rate is measured over. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RateLimitWindow { + /// One second. + #[default] + Second, + /// One minute. + Minute, + /// One hour. + Hour, + /// One day. + Day, +} + +impl RateLimitWindow { + /// Length of the window in seconds. + #[must_use] + pub const fn seconds(self) -> u64 { + match self { + Self::Second => 1, + Self::Minute => 60, + Self::Hour => 3_600, + Self::Day => 86_400, + } + } +} + +/// What a rate limit is counted against. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RateLimitScope { + /// All traffic. + Global, + /// Per calling tenant. + #[default] + Tenant, + /// Per calling subject. + User, + /// Per client address. + Ip, + /// Per matched route. + Route, +} + +/// What happens when a rate limit is exceeded. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RateLimitStrategy { + /// Answer `429` immediately. + #[default] + Reject, + /// Hold the request briefly, then admit or reject it. + Queue, + /// Admit the request but mark it degraded. + Degrade, +} + +/// Sustained portion of a dual-rate limit. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SustainedRate { + /// Permitted requests per window. + pub rate: u32, + /// Length of the window. + #[serde(default)] + pub window: RateLimitWindow, +} + +/// Burst portion of a dual-rate limit. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BurstRate { + /// Maximum instantaneous burst. + pub capacity: u32, +} + +/// Rate-limit configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RateLimitConfig { + /// Visibility of this block to descendant tenants. + #[serde(default)] + pub sharing: SharingMode, + /// Algorithm in use. + #[serde(default)] + pub algorithm: RateLimitAlgorithm, + /// Sustained rate. + pub sustained: SustainedRate, + /// Burst capacity; defaults to the sustained rate. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub burst: Option, + /// What the limit is counted against. + #[serde(default)] + pub scope: RateLimitScope, + /// What happens on exhaustion. + #[serde(default)] + pub strategy: RateLimitStrategy, + /// Cost of a single request. + #[serde(default = "default_cost")] + pub cost: u32, +} + +const fn default_cost() -> u32 { + 1 +} + +impl RateLimitConfig { + /// Effective burst capacity, defaulting to the sustained rate. + #[must_use] + pub fn burst_capacity(&self) -> u32 { + self.burst + .as_ref() + .map_or(self.sustained.rate, |b| b.capacity) + } +} + +/// Cross-origin resource sharing configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CorsConfig { + /// Visibility of this block to descendant tenants. + #[serde(default)] + pub sharing: SharingMode, + /// Whether cross-origin checks are applied. + pub enabled: bool, + /// Permitted origins, or `*`. + #[serde(default)] + pub allowed_origins: Vec, + /// Permitted methods. + #[serde(default = "default_cors_methods")] + pub allowed_methods: Vec, + /// Response headers exposed to the browser. + #[serde(default)] + pub expose_headers: Vec, + /// Whether credentials may be sent. + #[serde(default)] + pub allow_credentials: bool, +} + +fn default_cors_methods() -> Vec { + vec!["GET".to_owned(), "POST".to_owned()] +} + +/// Plugin bindings attached to an upstream or a route. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginsConfig { + /// Visibility of this block to descendant tenants. + #[serde(default)] + pub sharing: SharingMode, + /// Ordered plugin references. + #[serde(default)] + pub items: Vec, +} + +/// Protocol classification of an upstream. +pub const PROTOCOL_HTTP: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; +/// Protocol classification for remote procedure calls. +pub const PROTOCOL_GRPC: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.grpc.v1"; + +/// A configured external service. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Upstream { + /// Server-generated identifier. + pub id: Uuid, + /// Owning tenant. + #[serde(skip_deserializing)] + pub tenant_id: Uuid, + /// Routing key used in proxy paths. + pub alias: String, + /// Whether the upstream accepts traffic. + #[serde(default = "default_true")] + pub enabled: bool, + /// Endpoint pool. + pub server: ServerConfig, + /// Protocol classification. + pub protocol: String, + /// Discovery tags. + #[serde(default)] + pub tags: Vec, + /// Outbound credential configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth: Option, + /// Header transformation rules. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub headers: Option, + /// Plugin bindings. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, + /// Rate limit. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rate_limit: Option, + /// Cross-origin policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cors: Option, +} + +const fn default_true() -> bool { + true +} + +/// How a path suffix is combined with the route path. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PathSuffixMode { + /// Reject a request that carries a suffix. + Disabled, + /// Append the suffix to the route path. + #[default] + Append, +} + +/// Match rule for a plain HTTP route. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HttpMatch { + /// Permitted methods. + pub methods: Vec, + /// Path prefix. + pub path: String, + /// Permitted query parameter names. + #[serde(default)] + pub query_allowlist: Vec, + /// How a trailing path suffix is treated. + #[serde(default)] + pub path_suffix_mode: PathSuffixMode, +} + +/// Match rule for a remote procedure call route. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GrpcMatch { + /// Service name. + pub service: String, + /// Method name. + pub method: String, +} + +/// The match rule of a route; exactly one variant is present. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MatchConfig { + /// Plain HTTP match rule. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub http: Option, + /// Remote procedure call match rule. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub grpc: Option, +} + +/// A path on an upstream that inbound proxy requests are matched against. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Route { + /// Server-generated identifier. + pub id: Uuid, + /// Owning tenant. + #[serde(skip_deserializing)] + pub tenant_id: Uuid, + /// The upstream this route belongs to. + pub upstream_id: Uuid, + /// Whether the route participates in matching. + #[serde(default = "default_true")] + pub enabled: bool, + /// Match rule. + #[serde(rename = "match")] + pub match_config: MatchConfig, + /// Discovery tags. + #[serde(default)] + pub tags: Vec, + /// Plugin bindings. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, + /// Rate limit. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rate_limit: Option, + /// Cross-origin policy; overrides the upstream's when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cors: Option, +} + +/// Kind of a stored custom plugin. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginType { + /// Injects outbound credentials. + Auth, + /// Validates and may reject a request. + Guard, + /// Mutates a request or response. + Transform, +} + +impl PluginType { + /// The global type system base identifier for this plugin kind. + #[must_use] + pub const fn gts_base(self) -> &'static str { + match self { + Self::Auth => "gts.cf.core.oagw.auth_plugin.v1~", + Self::Guard => "gts.cf.core.oagw.guard_plugin.v1~", + Self::Transform => "gts.cf.core.oagw.transform_plugin.v1~", + } + } +} + +/// A phase a transform plugin participates in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[allow( + clippy::enum_variant_names, + reason = "the phase names are the wire contract" +)] +pub enum TransformPhase { + /// Before the upstream call. + OnRequest, + /// After a successful upstream response. + OnResponse, + /// After a failed upstream call. + OnError, +} + +/// A tenant-defined custom plugin. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[allow( + clippy::struct_field_names, + reason = "`plugin_type` is the field name the wire contract declares" +)] +pub struct Plugin { + /// Server-generated identifier. + pub id: Uuid, + /// Owning tenant. + #[serde(skip_deserializing)] + pub tenant_id: Uuid, + /// Kind of plugin. + pub plugin_type: PluginType, + /// Unique name within the tenant. + pub name: String, + /// Human-readable description. + #[serde(default)] + pub description: String, + /// Schema the plugin's configuration must satisfy. + #[serde(default)] + pub config_schema: serde_json::Value, + /// Phases a transform plugin participates in. + #[serde(default)] + pub phases: Vec, + /// Plugin source, stored verbatim and never executed in this build. + #[serde(default)] + pub source_code: String, +} + +/// Render an anonymous global type system identifier for a resource. +#[must_use] +pub fn gts_resource_id(kind: &str, id: Uuid) -> String { + format!("gts.cf.core.oagw.{kind}.v1~{id}") +} + +/// Extract the instance part of a global type system identifier. +/// +/// Returns the whole input when it carries no `~` separator. +#[must_use] +pub fn gts_instance(identifier: &str) -> &str { + identifier + .split_once('~') + .map_or(identifier, |(_, rest)| rest) +} +// @cpt-end:cpt-cf-oagw-dod-resource-model-domain-types:p1:inst-model + +#[cfg(test)] +mod tests { + use super::{PluginType, Scheme, gts_instance, gts_resource_id}; + use uuid::Uuid; + + #[test] + fn plaintext_schemes_are_identified() { + assert!(Scheme::Http.is_plaintext()); + assert!(Scheme::Ws.is_plaintext()); + assert!(!Scheme::Https.is_plaintext()); + assert!(!Scheme::Wss.is_plaintext()); + } + + #[test] + fn standard_ports_follow_the_scheme() { + assert_eq!(Scheme::Http.standard_port(), 80); + assert_eq!(Scheme::Ws.standard_port(), 80); + assert_eq!(Scheme::Https.standard_port(), 443); + assert_eq!(Scheme::Grpc.standard_port(), 443); + } + + #[test] + fn http_scheme_deserializes() { + let scheme: Scheme = serde_json::from_str("\"http\"").expect("http is accepted"); + assert_eq!(scheme, Scheme::Http); + } + + #[test] + fn resource_identifier_round_trips() { + let id = Uuid::new_v4(); + let rendered = gts_resource_id("upstream", id); + assert!(rendered.starts_with("gts.cf.core.oagw.upstream.v1~")); + assert_eq!(gts_instance(&rendered), id.to_string()); + } + + #[test] + fn plugin_type_base_identifiers() { + assert_eq!( + PluginType::Guard.gts_base(), + "gts.cf.core.oagw.guard_plugin.v1~" + ); + } +} diff --git a/gears/system/oagw/oagw/src/domain/validate.rs b/gears/system/oagw/oagw/src/domain/validate.rs new file mode 100644 index 0000000..1584baa --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/validate.rs @@ -0,0 +1,433 @@ +// @cpt-begin:cpt-cf-oagw-dod-resource-model-schema-validation:p1:inst-validate +//! Schema-level validation for upstream and route documents. +//! +//! Enforces the required fields, enums, patterns and ranges the two supplied +//! JSON Schemas declare. Enum membership is already enforced by serde; this +//! module covers the constraints serde cannot express. + +use super::alias::validate_hostname; +use super::error::{DomainError, DomainResult}; +use super::model::{ + CorsConfig, HttpMatch, MatchConfig, PROTOCOL_GRPC, PROTOCOL_HTTP, PluginsConfig, + RateLimitConfig, Route, ServerConfig, Upstream, +}; +use uuid::Uuid; + +/// Methods the route schema permits. +const ALLOWED_METHODS: [&str; 5] = ["GET", "POST", "PUT", "DELETE", "PATCH"]; + +/// Methods a cross-origin policy may permit. +const CORS_METHODS: [&str; 7] = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]; + +/// Validate a tag against the schema's pattern. +fn validate_tag(tag: &str) -> DomainResult<()> { + let ok = !tag.is_empty() + && tag + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-')); + if ok { + Ok(()) + } else { + Err(DomainError::validation(format!( + "tag `{tag}` must match ^[a-z0-9_-]+$" + ))) + } +} + +/// Validate the endpoint pool of an upstream. +/// +/// # Errors +/// Returns a validation error when the pool is empty, a hostname is malformed, +/// a port is out of range, or the pool mixes scheme or port. +pub fn validate_server(server: &ServerConfig) -> DomainResult<()> { + if server.endpoints.is_empty() { + return Err(DomainError::validation( + "server.endpoints must contain at least one endpoint", + )); + } + for endpoint in &server.endpoints { + validate_hostname(&endpoint.host)?; + if endpoint.port == 0 { + return Err(DomainError::validation( + "endpoint port must be between 1 and 65535", + )); + } + } + // Endpoints in a pool must agree on scheme and port. + let first = &server.endpoints[0]; + for endpoint in &server.endpoints[1..] { + if endpoint.scheme != first.scheme { + return Err(DomainError::validation( + "all endpoints in a pool must share the same scheme", + )); + } + if endpoint.port != first.port { + return Err(DomainError::validation( + "all endpoints in a pool must share the same port", + )); + } + } + Ok(()) +} + +/// Validate a rate-limit block against the schema's enums and ranges. +/// +/// # Errors +/// Returns a validation error when a numeric field is below its minimum. +pub fn validate_rate_limit(rate_limit: &RateLimitConfig) -> DomainResult<()> { + if rate_limit.sustained.rate < 1 { + return Err(DomainError::validation( + "rate_limit.sustained.rate must be at least 1", + )); + } + if let Some(burst) = &rate_limit.burst + && burst.capacity < 1 + { + return Err(DomainError::validation( + "rate_limit.burst.capacity must be at least 1", + )); + } + if rate_limit.cost < 1 { + return Err(DomainError::validation( + "rate_limit.cost must be at least 1", + )); + } + Ok(()) +} + +/// Validate a cross-origin block. +/// +/// # Errors +/// Returns a validation error when credentials are combined with a wildcard +/// origin, or when a method is outside the permitted set. +pub fn validate_cors(cors: &CorsConfig) -> DomainResult<()> { + if cors.allow_credentials && cors.allowed_origins.iter().any(|o| o == "*") { + return Err(DomainError::validation( + "cors.allow_credentials must not be combined with a wildcard origin", + )); + } + for method in &cors.allowed_methods { + if !CORS_METHODS.contains(&method.as_str()) { + return Err(DomainError::validation(format!( + "cors.allowed_methods contains unsupported method `{method}`" + ))); + } + } + Ok(()) +} + +/// Which shapes a plugin binding entry may take, per the owning document. +/// +/// The upstream schema allows a global-type-system identifier or a bare +/// UUID; the route schema allows only a global-type-system identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PluginRefContext { + /// An upstream's `plugins.items`: a global type system identifier or a + /// bare UUID are both accepted. + Upstream, + /// A route's `plugins.items`: only a global type system identifier is + /// accepted. + Route, +} + +/// Whether a string is a plausible anonymous global type system identifier. +/// +/// This is a shape check, not a registry lookup: it looks for the `gts.` +/// prefix and the `~` instance separator that every global type system +/// identifier carries. +fn looks_like_gts_identifier(item: &str) -> bool { + item.starts_with("gts.") && item.contains('~') +} + +/// Validate one plugin binding entry against the shapes `context` permits. +/// +/// # Errors +/// Returns a validation error when the entry is empty, or when it is neither +/// a plausible global type system identifier nor (for +/// [`PluginRefContext::Upstream`]) a bare UUID. +fn validate_plugin_ref(item: &str, context: PluginRefContext) -> DomainResult<()> { + if item.trim().is_empty() { + return Err(DomainError::validation( + "plugins.items entries must not be empty", + )); + } + let is_gts = looks_like_gts_identifier(item); + let is_bare_uuid = Uuid::parse_str(item).is_ok(); + let accepted = match context { + PluginRefContext::Upstream => is_gts || is_bare_uuid, + PluginRefContext::Route => is_gts, + }; + if accepted { + Ok(()) + } else { + match context { + PluginRefContext::Upstream => Err(DomainError::validation(format!( + "plugins.items entry `{item}` must be a global type system identifier \ + (`gts.<...>~<...>`) or a bare UUID" + ))), + PluginRefContext::Route => Err(DomainError::validation(format!( + "plugins.items entry `{item}` must be a global type system identifier \ + (`gts.<...>~<...>`)" + ))), + } + } +} + +/// Validate a plugin binding block. +/// +/// # Errors +/// Returns a validation error when an entry is empty or has a shape `context` +/// does not permit; see [`PluginRefContext`]. +pub fn validate_plugins(plugins: &PluginsConfig, context: PluginRefContext) -> DomainResult<()> { + for item in &plugins.items { + validate_plugin_ref(item, context)?; + } + Ok(()) +} + +/// Validate an upstream document. +/// +/// # Errors +/// Returns a validation error when any schema constraint is violated. +pub fn validate_upstream(upstream: &Upstream) -> DomainResult<()> { + validate_server(&upstream.server)?; + if upstream.protocol != PROTOCOL_HTTP && upstream.protocol != PROTOCOL_GRPC { + return Err(DomainError::validation(format!( + "protocol `{}` must be one of `{PROTOCOL_HTTP}` or `{PROTOCOL_GRPC}`", + upstream.protocol + ))); + } + for tag in &upstream.tags { + validate_tag(tag)?; + } + if let Some(rate_limit) = &upstream.rate_limit { + validate_rate_limit(rate_limit)?; + } + if let Some(cors) = &upstream.cors { + validate_cors(cors)?; + } + if let Some(plugins) = &upstream.plugins { + validate_plugins(plugins, PluginRefContext::Upstream)?; + } + Ok(()) +} + +/// Validate the match rule of a route. +/// +/// # Errors +/// Returns a validation error when the rule does not carry exactly one of +/// `http` or `grpc`, or when a member of the chosen rule is malformed. +pub fn validate_match(match_config: &MatchConfig) -> DomainResult<()> { + match (&match_config.http, &match_config.grpc) { + (Some(http), None) => validate_http_match(http), + (None, Some(grpc)) => { + if grpc.service.trim().is_empty() || grpc.method.trim().is_empty() { + return Err(DomainError::validation( + "match.grpc.service and match.grpc.method must not be empty", + )); + } + Ok(()) + } + (Some(_), Some(_)) => Err(DomainError::validation( + "match must carry exactly one of `http` or `grpc`, not both", + )), + (None, None) => Err(DomainError::validation( + "match must carry exactly one of `http` or `grpc`", + )), + } +} + +fn validate_http_match(http: &HttpMatch) -> DomainResult<()> { + if http.methods.is_empty() { + return Err(DomainError::validation( + "match.http.methods must contain at least one method", + )); + } + for method in &http.methods { + if !ALLOWED_METHODS.contains(&method.as_str()) { + return Err(DomainError::validation(format!( + "match.http.methods contains unsupported method `{method}`" + ))); + } + } + if http.path.is_empty() { + return Err(DomainError::validation("match.http.path must not be empty")); + } + Ok(()) +} + +/// Validate a route document. +/// +/// # Errors +/// Returns a validation error when any schema constraint is violated. +pub fn validate_route(route: &Route) -> DomainResult<()> { + validate_match(&route.match_config)?; + for tag in &route.tags { + validate_tag(tag)?; + } + if let Some(rate_limit) = &route.rate_limit { + validate_rate_limit(rate_limit)?; + } + if let Some(plugins) = &route.plugins { + validate_plugins(plugins, PluginRefContext::Route)?; + } + if let Some(cors) = &route.cors { + validate_cors(cors)?; + } + Ok(()) +} +// @cpt-end:cpt-cf-oagw-dod-resource-model-schema-validation:p1:inst-validate + +#[cfg(test)] +mod tests { + use super::{ + PluginRefContext, validate_cors, validate_match, validate_plugins, validate_rate_limit, + validate_server, + }; + use crate::domain::model::{ + BurstRate, CorsConfig, Endpoint, GrpcMatch, HttpMatch, MatchConfig, PluginsConfig, + RateLimitConfig, RateLimitWindow, Scheme, ServerConfig, SharingMode, SustainedRate, + }; + use uuid::Uuid; + + fn plugins(items: &[&str]) -> PluginsConfig { + PluginsConfig { + sharing: SharingMode::Private, + items: items.iter().map(|s| (*s).to_owned()).collect(), + } + } + + fn server(endpoints: Vec) -> ServerConfig { + ServerConfig { endpoints } + } + + fn endpoint(host: &str, scheme: Scheme, port: u16) -> Endpoint { + Endpoint { + scheme, + host: host.to_owned(), + port, + } + } + + #[test] + fn an_empty_pool_is_rejected() { + assert!(validate_server(&server(vec![])).is_err()); + } + + #[test] + fn a_plaintext_endpoint_on_port_eighty_is_accepted() { + let cfg = server(vec![endpoint("stub.local", Scheme::Http, 80)]); + assert!(validate_server(&cfg).is_ok()); + } + + #[test] + fn a_pool_must_not_mix_scheme_or_port() { + let mixed_scheme = server(vec![ + endpoint("a.vendor.com", Scheme::Https, 443), + endpoint("b.vendor.com", Scheme::Wss, 443), + ]); + assert!(validate_server(&mixed_scheme).is_err()); + + let mixed_port = server(vec![ + endpoint("a.vendor.com", Scheme::Https, 443), + endpoint("b.vendor.com", Scheme::Https, 8443), + ]); + assert!(validate_server(&mixed_port).is_err()); + } + + #[test] + fn match_requires_exactly_one_rule() { + let both = MatchConfig { + http: Some(HttpMatch { + methods: vec!["GET".to_owned()], + path: "/v1".to_owned(), + query_allowlist: vec![], + path_suffix_mode: crate::domain::model::PathSuffixMode::Append, + }), + grpc: Some(GrpcMatch { + service: "S".to_owned(), + method: "M".to_owned(), + }), + }; + assert!(validate_match(&both).is_err()); + assert!(validate_match(&MatchConfig::default()).is_err()); + } + + #[test] + fn an_unsupported_method_is_rejected() { + let cfg = MatchConfig { + http: Some(HttpMatch { + methods: vec!["TRACE".to_owned()], + path: "/v1".to_owned(), + query_allowlist: vec![], + path_suffix_mode: crate::domain::model::PathSuffixMode::Append, + }), + grpc: None, + }; + assert!(validate_match(&cfg).is_err()); + } + + #[test] + fn rate_limit_minimums_are_enforced() { + let mut cfg = RateLimitConfig { + sharing: SharingMode::Private, + algorithm: crate::domain::model::RateLimitAlgorithm::TokenBucket, + sustained: SustainedRate { + rate: 0, + window: RateLimitWindow::Second, + }, + burst: None, + scope: crate::domain::model::RateLimitScope::Tenant, + strategy: crate::domain::model::RateLimitStrategy::Reject, + cost: 1, + }; + assert!(validate_rate_limit(&cfg).is_err()); + cfg.sustained.rate = 5; + assert!(validate_rate_limit(&cfg).is_ok()); + cfg.burst = Some(BurstRate { capacity: 0 }); + assert!(validate_rate_limit(&cfg).is_err()); + } + + #[test] + fn credentials_with_a_wildcard_origin_are_rejected() { + let cfg = CorsConfig { + sharing: SharingMode::Private, + enabled: true, + allowed_origins: vec!["*".to_owned()], + allowed_methods: vec!["GET".to_owned()], + expose_headers: vec![], + allow_credentials: true, + }; + assert!(validate_cors(&cfg).is_err()); + } + + #[test] + fn upstream_plugin_bindings_accept_a_gts_identifier_or_a_bare_uuid() { + let id = Uuid::new_v4().to_string(); + let gts = format!("gts.cf.core.oagw.guard_plugin.v1~{id}"); + assert!(validate_plugins(&plugins(&[>s]), PluginRefContext::Upstream).is_ok()); + assert!(validate_plugins(&plugins(&[&id]), PluginRefContext::Upstream).is_ok()); + } + + #[test] + fn route_plugin_bindings_reject_a_bare_uuid() { + let id = Uuid::new_v4().to_string(); + let gts = format!("gts.cf.core.oagw.guard_plugin.v1~{id}"); + assert!(validate_plugins(&plugins(&[>s]), PluginRefContext::Route).is_ok()); + assert!(validate_plugins(&plugins(&[&id]), PluginRefContext::Route).is_err()); + } + + #[test] + fn a_plugin_binding_that_is_neither_shape_is_rejected_for_both_contexts() { + let bogus = plugins(&["not-a-uuid-or-gts-id"]); + assert!(validate_plugins(&bogus, PluginRefContext::Upstream).is_err()); + assert!(validate_plugins(&bogus, PluginRefContext::Route).is_err()); + } + + #[test] + fn an_empty_plugin_binding_entry_is_rejected() { + let empty = plugins(&[""]); + assert!(validate_plugins(&empty, PluginRefContext::Upstream).is_err()); + assert!(validate_plugins(&empty, PluginRefContext::Route).is_err()); + } +} diff --git a/gears/system/oagw/oagw/src/gear.rs b/gears/system/oagw/oagw/src/gear.rs new file mode 100644 index 0000000..aab3de4 --- /dev/null +++ b/gears/system/oagw/oagw/src/gear.rs @@ -0,0 +1,81 @@ +// @cpt-begin:cpt-cf-oagw-dod-gear-foundation-registration:p1:inst-gear +//! Gear registration and wiring. + +use crate::api::rest::routes::register_routes; +use crate::api::rest::state::OagwState; +use crate::config::OagwConfig; +use async_trait::async_trait; +use axum::Router; +use credstore_sdk::CredStoreClientV1; +use std::sync::{Arc, OnceLock}; +use toolkit::api::OpenApiRegistry; +use toolkit::{Gear, GearCtx, RestApiCapability}; +use toolkit_http::HttpClient; +use tracing::{debug, info}; + +/// The outbound API gateway gear. +#[toolkit::gear(name = "oagw", capabilities = [rest])] +pub struct Oagw { + /// Shared handler state, set once during initialisation. + state: OnceLock>, +} + +impl Default for Oagw { + fn default() -> Self { + Self { + state: OnceLock::new(), + } + } +} + +#[async_trait] +impl Gear for Oagw { + async fn init(&self, ctx: &GearCtx) -> anyhow::Result<()> { + let config: OagwConfig = ctx.config_or_default()?; + debug!( + proxy_timeout_secs = config.proxy_timeout_secs, + allow_http_upstream = config.allow_http_upstream, + ssrf_enforced = config.ssrf_policy.enabled, + "loaded oagw configuration" + ); + + // The outbound client must be able to dial plaintext when the gear + // configuration permits it; whether it does is decided per request. + let http = HttpClient::builder() + .with_otel() + .build() + .map_err(|e| anyhow::anyhow!("failed to build the outbound HTTP client: {e}"))?; + + // The credential store is optional: an upstream that injects no + // credential does not need it. + let cred_store: Option> = + ctx.client_hub().get::().ok(); + if cred_store.is_none() { + debug!("no credential store is registered; secret references will not resolve"); + } + + let state = Arc::new(OagwState::new(config, http, cred_store)); + if self.state.set(state).is_err() { + anyhow::bail!("oagw state was already initialised"); + } + info!("oagw gear initialised"); + Ok(()) + } +} + +impl RestApiCapability for Oagw { + fn register_rest( + &self, + _ctx: &GearCtx, + router: Router, + openapi: &dyn OpenApiRegistry, + ) -> anyhow::Result { + let state = self + .state + .get() + .ok_or_else(|| anyhow::anyhow!("oagw state is not initialised"))? + .clone(); + Ok(register_routes(router, openapi, state)) + } +} +// @cpt-end:cpt-cf-oagw-dod-gear-foundation-registration:p1:inst-gear diff --git a/gears/system/oagw/oagw/src/infra/mod.rs b/gears/system/oagw/oagw/src/infra/mod.rs new file mode 100644 index 0000000..ef2806a --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/mod.rs @@ -0,0 +1,7 @@ +//! Infrastructure layer: storage, the proxy engine and the plugin registries. + +pub mod oauth2; +pub mod plugins; +pub mod proxy; +pub mod ratelimit; +pub mod store; diff --git a/gears/system/oagw/oagw/src/infra/oauth2.rs b/gears/system/oagw/oagw/src/infra/oauth2.rs new file mode 100644 index 0000000..5ab6dd9 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/oauth2.rs @@ -0,0 +1,330 @@ +// @cpt-begin:cpt-cf-oagw-dod-policy-oauth2-token-cache:p2:inst-oauth2 +//! `OAuth2` client-credentials token acquisition and caching. +//! +//! Tokens are cached in process, keyed by the calling subject and the plugin +//! configuration. The cached entry carries its own key so a hash collision is +//! detected on read rather than silently serving another subject's token. +//! A failed fetch is never cached. + +use crate::domain::error::{DomainError, DomainResult, ErrorKind}; +use dashmap::DashMap; +use std::collections::BTreeMap; +use std::time::{Duration, Instant}; +use toolkit_http::HttpClient; + +/// Safety margin subtracted from the token's own lifetime. +const EXPIRY_SAFETY_MARGIN_SECS: u64 = 30; + +/// How the client credentials are presented to the token endpoint. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClientAuthStyle { + /// Credentials travel in the form body. + Form, + /// Credentials travel in an `Authorization: Basic` header. + Basic, +} + +impl ClientAuthStyle { + /// Tag used to separate cache entries of the two variants. + const fn tag(self) -> &'static str { + match self { + Self::Form => "oauth2_client_cred", + Self::Basic => "oauth2_client_cred_basic", + } + } +} + +/// A cached bearer token. +#[derive(Debug, Clone)] +struct CachedToken { + /// The key this entry was stored under, re-checked on read. + key: String, + token: String, + expires_at: Instant, +} + +/// In-process cache of client-credentials tokens. +#[derive(Debug, Default)] +pub struct TokenCache { + entries: DashMap, + capacity: usize, +} + +impl TokenCache { + /// Create a cache bounded to `capacity` entries. + #[must_use] + pub fn new(capacity: usize) -> Self { + Self { + entries: DashMap::new(), + capacity: capacity.max(1), + } + } + + /// Build the cache key for a request. + #[must_use] + pub fn cache_key( + subject_tenant_id: &str, + subject_id: &str, + style: ClientAuthStyle, + config: &BTreeMap, + ) -> String { + // A stable rendering of the configuration distinguishes two upstreams + // that share a subject but differ in scope or endpoint. + let rendered = config + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join("&"); + let hash = simple_hash(&rendered); + format!( + "{subject_tenant_id}:{subject_id}:{}:{hash:016x}", + style.tag() + ) + } + + /// Read a live token, if one is cached under this exact key. + #[must_use] + pub fn get(&self, key: &str) -> Option { + let entry = self.entries.get(key)?; + // Defend against a hash collision: the entry names its own key. + if entry.key != key { + return None; + } + if Instant::now() >= entry.expires_at { + return None; + } + Some(entry.token.clone()) + } + + /// Store a token with the effective time to live. + pub fn put(&self, key: String, token: String, ttl: Duration) { + if self.entries.len() >= self.capacity { + // Drop expired entries before admitting a new one. + let now = Instant::now(); + self.entries.retain(|_, e| e.expires_at > now); + } + let entry = CachedToken { + key: key.clone(), + token, + expires_at: Instant::now() + ttl, + }; + self.entries.insert(key, entry); + } + + /// Number of entries currently held. + #[must_use] + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether the cache holds nothing. + #[must_use] + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +/// Effective time to live for a token. +/// +/// The configured ceiling and the token's own lifetime, less a safety margin, +/// whichever is shorter. +#[must_use] +pub fn effective_ttl(configured_ttl_secs: u64, expires_in_secs: Option) -> Duration { + let from_token = expires_in_secs.map_or(configured_ttl_secs, |e| { + e.saturating_sub(EXPIRY_SAFETY_MARGIN_SECS) + }); + Duration::from_secs(configured_ttl_secs.min(from_token).max(1)) +} + +/// A cheap, stable, non-cryptographic hash of the rendered configuration. +fn simple_hash(input: &str) -> u64 { + // FNV-1a: deterministic across runs, which a cache key requires. + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in input.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +/// Request a client-credentials token from the configured endpoint. +/// +/// # Errors +/// Returns `AuthenticationFailed` when the endpoint rejects the request or the +/// response carries no access token. +pub async fn fetch_token( + http: &HttpClient, + token_endpoint: &str, + client_id: &str, + client_secret: &str, + scopes: Option<&str>, + style: ClientAuthStyle, +) -> DomainResult<(String, Option)> { + let mut form: Vec<(&str, &str)> = vec![("grant_type", "client_credentials")]; + if let Some(scopes) = scopes { + form.push(("scope", scopes)); + } + if style == ClientAuthStyle::Form { + form.push(("client_id", client_id)); + form.push(("client_secret", client_secret)); + } + + let mut builder = http.post(token_endpoint); + if style == ClientAuthStyle::Basic { + // The Basic variant carries the pair in the Authorization header. + let encoded = base64_encode(format!("{client_id}:{client_secret}").as_bytes()); + builder = builder.header("authorization", &format!("Basic {encoded}")); + } + let builder = builder.form(&form).map_err(|e| { + DomainError::new( + ErrorKind::AuthenticationFailed, + format!("could not build the token request: {e}"), + ) + })?; + + let response = builder.send().await.map_err(|_| { + DomainError::new( + ErrorKind::AuthenticationFailed, + "the token endpoint could not be reached", + ) + })?; + if !response.status().is_success() { + return Err(DomainError::new( + ErrorKind::AuthenticationFailed, + format!("the token endpoint answered {}", response.status()), + )); + } + let body: serde_json::Value = response.json().await.map_err(|_| { + DomainError::new( + ErrorKind::AuthenticationFailed, + "the token endpoint returned a body that is not JSON", + ) + })?; + let token = body + .get("access_token") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + DomainError::new( + ErrorKind::AuthenticationFailed, + "the token response carried no access_token", + ) + })? + .to_owned(); + let expires_in = body.get("expires_in").and_then(serde_json::Value::as_u64); + Ok((token, expires_in)) +} + +/// Encode bytes as standard base64. +fn base64_encode(input: &[u8]) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(input.len().div_ceil(3) * 4); + for chunk in input.chunks(3) { + let b0 = u32::from(chunk[0]); + let b1 = chunk.get(1).map_or(0, |b| u32::from(*b)); + let b2 = chunk.get(2).map_or(0, |b| u32::from(*b)); + let triple = (b0 << 16) | (b1 << 8) | b2; + out.push(ALPHABET[((triple >> 18) & 0x3f) as usize] as char); + out.push(ALPHABET[((triple >> 12) & 0x3f) as usize] as char); + if chunk.len() > 1 { + out.push(ALPHABET[((triple >> 6) & 0x3f) as usize] as char); + } else { + out.push('='); + } + if chunk.len() > 2 { + out.push(ALPHABET[(triple & 0x3f) as usize] as char); + } else { + out.push('='); + } + } + out +} +// @cpt-end:cpt-cf-oagw-dod-policy-oauth2-token-cache:p2:inst-oauth2 + +#[cfg(test)] +mod tests { + use super::{ClientAuthStyle, TokenCache, base64_encode, effective_ttl}; + use std::collections::BTreeMap; + use std::time::Duration; + + fn config(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(k, v)| ((*k).to_owned(), serde_json::Value::String((*v).to_owned()))) + .collect() + } + + #[test] + fn the_effective_ttl_takes_the_shorter_of_the_two_bounds() { + // The token expires sooner than the configured ceiling. + assert_eq!(effective_ttl(300, Some(90)), Duration::from_mins(1)); + // The configured ceiling is the tighter bound. + assert_eq!(effective_ttl(120, Some(3_600)), Duration::from_mins(2)); + // No stated lifetime falls back to the configured value. + assert_eq!(effective_ttl(300, None), Duration::from_mins(5)); + // A lifetime inside the safety margin still yields a live entry. + assert_eq!(effective_ttl(300, Some(10)), Duration::from_secs(1)); + } + + #[test] + fn the_cache_key_separates_subjects_configs_and_variants() { + let cfg = config(&[("token_endpoint", "https://idp.test/token")]); + let other = config(&[("token_endpoint", "https://other.test/token")]); + let baseline = TokenCache::cache_key("t1", "s1", ClientAuthStyle::Form, &cfg); + let other_tenant = TokenCache::cache_key("t2", "s1", ClientAuthStyle::Form, &cfg); + let other_subject = TokenCache::cache_key("t1", "s2", ClientAuthStyle::Form, &cfg); + let basic_variant = TokenCache::cache_key("t1", "s1", ClientAuthStyle::Basic, &cfg); + let other_config = TokenCache::cache_key("t1", "s1", ClientAuthStyle::Form, &other); + let keys = [ + &baseline, + &other_tenant, + &other_subject, + &basic_variant, + &other_config, + ]; + for i in 0..keys.len() { + for j in (i + 1)..keys.len() { + assert_ne!(keys[i], keys[j], "keys {i} and {j} must differ"); + } + } + } + + #[test] + fn the_cache_key_is_stable_for_the_same_inputs() { + let cfg = config(&[("token_endpoint", "https://idp.test/token")]); + let first = TokenCache::cache_key("t1", "s1", ClientAuthStyle::Form, &cfg); + let second = TokenCache::cache_key("t1", "s1", ClientAuthStyle::Form, &cfg); + assert_eq!(first, second); + } + + #[test] + fn a_cached_token_is_served_until_it_expires() { + let cache = TokenCache::new(10); + cache.put("k".to_owned(), "tok".to_owned(), Duration::from_mins(1)); + assert_eq!(cache.get("k").as_deref(), Some("tok")); + assert_eq!(cache.get("other"), None); + } + + #[test] + fn an_expired_entry_is_not_served() { + let cache = TokenCache::new(10); + cache.put("k".to_owned(), "tok".to_owned(), Duration::from_millis(1)); + std::thread::sleep(Duration::from_millis(5)); + assert_eq!(cache.get("k"), None); + } + + #[test] + fn the_cache_starts_empty() { + let cache = TokenCache::new(4); + assert!(cache.is_empty()); + cache.put("k".to_owned(), "t".to_owned(), Duration::from_secs(5)); + assert_eq!(cache.len(), 1); + } + + #[test] + fn base64_matches_known_vectors() { + assert_eq!(base64_encode(b"id:secret"), "aWQ6c2VjcmV0"); + assert_eq!(base64_encode(b"a"), "YQ=="); + assert_eq!(base64_encode(b"ab"), "YWI="); + assert_eq!(base64_encode(b"abc"), "YWJj"); + } +} diff --git a/gears/system/oagw/oagw/src/infra/plugins.rs b/gears/system/oagw/oagw/src/infra/plugins.rs new file mode 100644 index 0000000..a900654 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugins.rs @@ -0,0 +1,444 @@ +//! Built-in plugin registries and their implementations. +//! +//! Three plugin kinds run in a deterministic order: authentication injects +//! outbound credentials, guards may reject a request, and transforms mutate a +//! request or a response. Upstream-level bindings run before route-level ones. + +use crate::domain::error::{DomainError, DomainResult, ErrorKind}; +use crate::domain::model::gts_instance; +use credstore_sdk::CredStoreClientV1; +use credstore_sdk::models::SecretRef; +use http::HeaderMap; +use std::collections::BTreeMap; +use std::sync::Arc; +use toolkit_security::SecurityContext; + +/// Resolvable authentication plugin identifiers. +pub const AUTH_NOOP: &str = "cf.core.oagw.noop.v1"; +/// API key injection. +pub const AUTH_APIKEY: &str = "cf.core.oagw.apikey.v1"; +/// `OAuth2` client credentials with a form-encoded token request. +pub const AUTH_OAUTH2_FORM: &str = "cf.core.oagw.oauth2_client_cred.v1"; +/// `OAuth2` client credentials with a Basic-authenticated token request. +pub const AUTH_OAUTH2_BASIC: &str = "cf.core.oagw.oauth2_client_cred_basic.v1"; + +/// Catalogue-only authentication identifiers with no backing implementation. +pub const AUTH_CATALOG_ONLY: [&str; 2] = ["cf.core.oagw.basic.v1", "cf.core.oagw.bearer.v1"]; + +/// The only bindable guard identifier. +pub const GUARD_REQUIRED_HEADERS: &str = "cf.core.oagw.required_headers.v1"; +/// Catalogue-only guard identifiers; these name core data-plane behaviour. +pub const GUARD_CATALOG_ONLY: [&str; 2] = ["cf.core.oagw.timeout.v1", "cf.core.oagw.cors.v1"]; + +/// The only resolvable transform identifier. +pub const TRANSFORM_REQUEST_ID: &str = "cf.core.oagw.request_id.v1"; +/// Catalogue-only transform identifiers; these name core instrumentation. +pub const TRANSFORM_CATALOG_ONLY: [&str; 2] = + ["cf.core.oagw.logging.v1", "cf.core.oagw.metrics.v1"]; + +/// Plugin configuration as stored on an upstream or a route. +pub type PluginConfig = BTreeMap; + +/// Read a configuration value as a string. +fn config_str<'a>(config: &'a PluginConfig, key: &str) -> Option<&'a str> { + config.get(key).and_then(serde_json::Value::as_str) +} + +// @cpt-begin:cpt-cf-oagw-dod-policy-auth-registry:p2:inst-authreg +/// Resolve and apply an authentication plugin to the outbound headers. +/// +/// # Errors +/// Returns `PluginNotFound` when the identifier names no resolvable plugin, +/// `SecretNotFound` when a referenced secret is missing, and +/// `AuthenticationFailed` when a credential cannot be prepared. +pub async fn apply_auth_plugin( + plugin_ref: &str, + config: &PluginConfig, + ctx: &SecurityContext, + cred_store: Option<&Arc>, + headers: &mut HeaderMap, +) -> DomainResult<()> { + let instance = gts_instance(plugin_ref); + if AUTH_CATALOG_ONLY.contains(&instance) { + return Err(DomainError::new( + ErrorKind::PluginNotFound, + format!("unknown auth plugin `{instance}`"), + )); + } + match instance { + AUTH_NOOP => Ok(()), + AUTH_APIKEY => apply_api_key(config, ctx, cred_store, headers).await, + AUTH_OAUTH2_FORM | AUTH_OAUTH2_BASIC => { + // The bearer value is prepared by the token cache and injected by + // the caller; nothing is added here when no token was resolved. + Ok(()) + } + other => Err(DomainError::new( + ErrorKind::PluginNotFound, + format!("unknown auth plugin `{other}`"), + )), + } +} + +/// Inject an API key into a header or a query parameter. +async fn apply_api_key( + config: &PluginConfig, + ctx: &SecurityContext, + cred_store: Option<&Arc>, + headers: &mut HeaderMap, +) -> DomainResult<()> { + let header_name = config_str(config, "header").unwrap_or("authorization"); + let prefix = config_str(config, "prefix").unwrap_or(""); + let secret = resolve_secret(config, ctx, cred_store).await?; + let value = if prefix.is_empty() { + secret + } else { + format!("{prefix} {secret}") + }; + let name = http::HeaderName::try_from(header_name).map_err(|_| { + DomainError::validation(format!("auth header name `{header_name}` is not valid")) + })?; + let value = http::HeaderValue::from_str(&value).map_err(|_| { + DomainError::new( + ErrorKind::AuthenticationFailed, + "credential is not a valid header value", + ) + })?; + headers.insert(name, value); + Ok(()) +} + +/// Resolve the credential a plugin should inject. +/// +/// A literal `value` is used when present, otherwise `secret_ref` is fetched +/// from the credential store. The secret material is never logged. +async fn resolve_secret( + config: &PluginConfig, + ctx: &SecurityContext, + cred_store: Option<&Arc>, +) -> DomainResult { + if let Some(literal) = config_str(config, "value") { + return Ok(literal.to_owned()); + } + let Some(reference) = config_str(config, "secret_ref") else { + return Err(DomainError::new( + ErrorKind::AuthenticationFailed, + "auth plugin config must carry `value` or `secret_ref`", + )); + }; + // A `cred://` prefix is the documented reference form. + let reference = reference.strip_prefix("cred://").unwrap_or(reference); + let Some(store) = cred_store else { + return Err(DomainError::new( + ErrorKind::SecretNotFound, + "credential store is unavailable", + )); + }; + let key = SecretRef::new(reference) + .map_err(|e| DomainError::validation(format!("invalid secret_ref: {e}")))?; + match store.get(ctx, &key).await { + Ok(Some(found)) => String::from_utf8(found.value.as_bytes().to_vec()) + .map_err(|_| DomainError::new(ErrorKind::SecretNotFound, "secret is not valid UTF-8")), + Ok(None) => Err(DomainError::new( + ErrorKind::SecretNotFound, + format!("secret `{reference}` was not found"), + )), + Err(_) => Err(DomainError::new( + ErrorKind::AuthenticationFailed, + "credential store rejected the request", + )), + } +} +// @cpt-end:cpt-cf-oagw-dod-policy-auth-registry:p2:inst-authreg + +// @cpt-begin:cpt-cf-oagw-dod-policy-required-headers-guard:p2:inst-guard +/// Which phase a guard rejection happened in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GuardPhase { + /// Before the upstream call. + Request, + /// After the upstream responded. + Response, +} + +/// Error code the required-headers guard reports. +pub const REQUIRED_HEADER_MISSING: &str = "REQUIRED_HEADER_MISSING"; + +/// Parse a comma-separated header-name list into normalized names. +/// +/// Entries are trimmed, lowercased, and empty entries are dropped. A list that +/// is blank after trimming yields no names, which makes the guard a no-op. +#[must_use] +pub fn parse_required_headers(raw: &str) -> Vec { + raw.split(',') + .map(|part| part.trim().to_ascii_lowercase()) + .filter(|part| !part.is_empty()) + .collect() +} + +/// Evaluate the required-headers guard for one phase. +/// +/// Reports only the first missing header. A request-phase rejection is a `400` +/// and a response-phase rejection is a `502`. +/// +/// # Errors +/// Returns a validation error naming the first missing header. +pub fn evaluate_required_headers( + config: &PluginConfig, + headers: &HeaderMap, + phase: GuardPhase, +) -> DomainResult<()> { + let key = match phase { + GuardPhase::Request => "required_request_headers", + GuardPhase::Response => "required_response_headers", + }; + let Some(raw) = config_str(config, key) else { + // Absent configuration is a no-op; the guard fails open. + return Ok(()); + }; + for name in parse_required_headers(raw) { + let present = headers + .keys() + .any(|k| k.as_str().eq_ignore_ascii_case(&name)); + if !present { + let kind = match phase { + GuardPhase::Request => ErrorKind::ValidationError, + GuardPhase::Response => ErrorKind::ProtocolError, + }; + return Err( + DomainError::new(kind, format!("required header `{name}` is missing")) + .with_context(serde_json::json!({ + "error_code": REQUIRED_HEADER_MISSING, + "header": name, + })), + ); + } + } + Ok(()) +} + +/// Resolve and evaluate a guard plugin. +/// +/// # Errors +/// Returns `PluginNotFound` for an identifier no registry resolves, or the +/// guard's own rejection. +pub fn apply_guard_plugin( + plugin_ref: &str, + config: &PluginConfig, + headers: &HeaderMap, + phase: GuardPhase, +) -> DomainResult<()> { + let instance = gts_instance(plugin_ref); + if instance == GUARD_REQUIRED_HEADERS { + return evaluate_required_headers(config, headers, phase); + } + if GUARD_CATALOG_ONLY.contains(&instance) { + return Err(DomainError::new( + ErrorKind::PluginNotFound, + format!("guard `{instance}` is catalogue-only and cannot be bound"), + )); + } + Err(DomainError::new( + ErrorKind::PluginNotFound, + format!("unknown guard plugin `{instance}`"), + )) +} +// @cpt-end:cpt-cf-oagw-dod-policy-required-headers-guard:p2:inst-guard + +/// Apply a transform plugin to the outbound request headers. +/// +/// # Errors +/// Returns `PluginNotFound` for an identifier no registry resolves. +pub fn apply_transform_plugin(plugin_ref: &str, headers: &mut HeaderMap) -> DomainResult<()> { + let instance = gts_instance(plugin_ref); + if instance == TRANSFORM_REQUEST_ID { + if !headers.contains_key("x-request-id") { + let id = uuid::Uuid::new_v4().to_string(); + if let Ok(value) = http::HeaderValue::from_str(&id) { + headers.insert("x-request-id", value); + } + } + return Ok(()); + } + if TRANSFORM_CATALOG_ONLY.contains(&instance) { + return Err(DomainError::new( + ErrorKind::PluginNotFound, + format!("transform `{instance}` is catalogue-only and cannot be bound"), + )); + } + Err(DomainError::new( + ErrorKind::PluginNotFound, + format!("unknown transform plugin `{instance}`"), + )) +} + +/// Classify a plugin reference so the chain can dispatch it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PluginClass { + /// An authentication plugin. + Auth, + /// A guard plugin. + Guard, + /// A transform plugin. + Transform, + /// A reference whose kind cannot be determined. + Unknown, +} + +/// Determine which registry a plugin reference belongs to. +#[must_use] +pub fn classify_plugin(plugin_ref: &str) -> PluginClass { + if plugin_ref.contains("auth_plugin.v1") { + PluginClass::Auth + } else if plugin_ref.contains("guard_plugin.v1") { + PluginClass::Guard + } else if plugin_ref.contains("transform_plugin.v1") { + PluginClass::Transform + } else { + PluginClass::Unknown + } +} + +#[cfg(test)] +mod tests { + use super::{ + AUTH_CATALOG_ONLY, GUARD_REQUIRED_HEADERS, GuardPhase, PluginClass, PluginConfig, + apply_guard_plugin, apply_transform_plugin, classify_plugin, evaluate_required_headers, + parse_required_headers, + }; + use http::HeaderMap; + + fn config(pairs: &[(&str, &str)]) -> PluginConfig { + pairs + .iter() + .map(|(k, v)| ((*k).to_owned(), serde_json::Value::String((*v).to_owned()))) + .collect() + } + + #[test] + fn header_lists_are_split_trimmed_lowercased_and_compacted() { + assert_eq!( + parse_required_headers(" X-A , ,x-b ,, "), + vec!["x-a".to_owned(), "x-b".to_owned()] + ); + } + + #[test] + fn a_blank_list_makes_the_guard_a_no_op() { + let cfg = config(&[("required_request_headers", " , , ")]); + let headers = HeaderMap::new(); + assert!(evaluate_required_headers(&cfg, &headers, GuardPhase::Request).is_ok()); + } + + #[test] + fn absent_configuration_makes_the_guard_a_no_op() { + let cfg = PluginConfig::new(); + let headers = HeaderMap::new(); + assert!(evaluate_required_headers(&cfg, &headers, GuardPhase::Request).is_ok()); + assert!(evaluate_required_headers(&cfg, &headers, GuardPhase::Response).is_ok()); + } + + #[test] + fn a_missing_request_header_is_four_hundred() { + let cfg = config(&[("required_request_headers", "x-needed")]); + let err = evaluate_required_headers(&cfg, &HeaderMap::new(), GuardPhase::Request) + .expect_err("guard rejects"); + assert_eq!(err.status(), 400); + assert_eq!(err.context["error_code"], "REQUIRED_HEADER_MISSING"); + } + + #[test] + fn a_missing_response_header_is_five_hundred_and_two() { + let cfg = config(&[("required_response_headers", "x-needed")]); + let err = evaluate_required_headers(&cfg, &HeaderMap::new(), GuardPhase::Response) + .expect_err("guard rejects"); + assert_eq!(err.status(), 502); + assert_eq!(err.context["error_code"], "REQUIRED_HEADER_MISSING"); + } + + #[test] + fn header_matching_is_case_insensitive() { + let cfg = config(&[("required_request_headers", "X-Needed")]); + let mut headers = HeaderMap::new(); + headers.insert("x-needed", http::HeaderValue::from_static("1")); + assert!(evaluate_required_headers(&cfg, &headers, GuardPhase::Request).is_ok()); + } + + #[test] + fn only_the_first_missing_header_is_reported() { + let cfg = config(&[("required_request_headers", "x-first,x-second")]); + let err = evaluate_required_headers(&cfg, &HeaderMap::new(), GuardPhase::Request) + .expect_err("guard rejects"); + assert_eq!(err.context["header"], "x-first"); + } + + #[test] + fn catalogue_only_guards_are_not_bindable() { + let err = apply_guard_plugin( + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.cors.v1", + &PluginConfig::new(), + &HeaderMap::new(), + GuardPhase::Request, + ) + .expect_err("catalogue-only guard is rejected"); + assert_eq!(err.status(), 503); + } + + #[test] + fn the_required_headers_guard_is_bindable() { + let plugin_ref = format!("gts.cf.core.oagw.guard_plugin.v1~{GUARD_REQUIRED_HEADERS}"); + assert!( + apply_guard_plugin( + &plugin_ref, + &PluginConfig::new(), + &HeaderMap::new(), + GuardPhase::Request + ) + .is_ok() + ); + } + + #[test] + fn catalogue_only_auth_identifiers_are_listed() { + assert!(AUTH_CATALOG_ONLY.contains(&"cf.core.oagw.basic.v1")); + assert!(AUTH_CATALOG_ONLY.contains(&"cf.core.oagw.bearer.v1")); + } + + #[test] + fn the_request_id_transform_injects_a_header() { + let mut headers = HeaderMap::new(); + apply_transform_plugin( + "gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1", + &mut headers, + ) + .expect("transform applies"); + assert!(headers.contains_key("x-request-id")); + } + + #[test] + fn catalogue_only_transforms_are_not_bindable() { + let err = apply_transform_plugin( + "gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.metrics.v1", + &mut HeaderMap::new(), + ) + .expect_err("catalogue-only transform is rejected"); + assert_eq!(err.status(), 503); + } + + #[test] + fn plugin_references_are_classified_by_their_base() { + assert_eq!( + classify_plugin("gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1"), + PluginClass::Auth + ); + assert_eq!( + classify_plugin("gts.cf.core.oagw.guard_plugin.v1~x"), + PluginClass::Guard + ); + assert_eq!( + classify_plugin("gts.cf.core.oagw.transform_plugin.v1~x"), + PluginClass::Transform + ); + assert_eq!(classify_plugin("nonsense"), PluginClass::Unknown); + } +} diff --git a/gears/system/oagw/oagw/src/infra/proxy.rs b/gears/system/oagw/oagw/src/infra/proxy.rs new file mode 100644 index 0000000..2b16647 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy.rs @@ -0,0 +1,975 @@ +//! Outbound proxy engine. +//! +//! Builds the outbound request from a resolved upstream and route, forwards it, +//! and relays the response. Plain responses, server-sent-event streams and +//! WebSocket upgrades all travel through this module. + +use crate::domain::error::{DomainError, DomainResult, ErrorKind}; +use crate::domain::model::{ + Endpoint, HeadersConfig, PassthroughMode, PathSuffixMode, Route, Scheme, Upstream, +}; +use http::{HeaderMap, HeaderName, HeaderValue, Method}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// Header the caller uses to pin a request to one endpoint of a pool. +pub const TARGET_HOST_HEADER: &str = "x-oagw-target-host"; + +/// Headers consumed by the gateway and never forwarded. +const ROUTING_HEADERS: [&str; 1] = [TARGET_HOST_HEADER]; + +/// Hop-by-hop headers stripped per the header categories in `DESIGN.md` +/// section 3.2. +const HOP_BY_HOP: [&str; 8] = [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]; + +/// Hard body limit before buffering, in bytes. +pub const MAX_BODY_BYTES: usize = 100 * 1024 * 1024; + +/// Whether a header is stripped before the request leaves the gateway. +/// +/// `Content-Length` is stripped alongside the framing headers: the outbound +/// client recomputes it from the body actually sent, so a stale value copied +/// from the inbound request is never forwarded. +#[must_use] +pub fn is_stripped_header(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + HOP_BY_HOP.contains(&lower.as_str()) + || ROUTING_HEADERS.contains(&lower.as_str()) + || lower == "host" + || lower == "content-length" +} + +// @cpt-begin:cpt-cf-oagw-dod-proxy-http-endpoint-selection:p1:inst-endpoint +/// Round-robin cursor shared by every pool. +#[derive(Debug, Default)] +pub struct RoundRobin { + cursor: AtomicUsize, +} + +impl RoundRobin { + /// Create a cursor starting at zero. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Advance and return the next index within `len`. + pub fn next_index(&self, len: usize) -> usize { + if len == 0 { + return 0; + } + self.cursor.fetch_add(1, Ordering::Relaxed) % len + } +} + +/// Whether an alias was derived from a shared suffix rather than named outright. +/// +/// A pool whose alias is a derived common suffix cannot pick an endpoint on its +/// own, so the caller must name one. +#[must_use] +pub fn alias_is_common_suffix(alias: &str, endpoints: &[Endpoint]) -> bool { + if endpoints.len() < 2 { + return false; + } + let bare = alias.split(':').next().unwrap_or(alias); + endpoints + .iter() + .all(|e| e.host.to_ascii_lowercase().ends_with(bare)) + && !endpoints.iter().any(|e| e.host.eq_ignore_ascii_case(bare)) +} + +/// Choose the endpoint a request is forwarded to. +/// +/// Implements the `X-OAGW-Target-Host` behaviour matrix of ADR-0001. +/// +/// # Errors +/// Returns `MissingTargetHost`, `InvalidTargetHost` or `UnknownTargetHost` when +/// the header is required, malformed, or names no configured endpoint. +pub fn select_endpoint<'a>( + upstream: &'a Upstream, + target_host: Option<&str>, + round_robin: &RoundRobin, +) -> DomainResult<&'a Endpoint> { + let endpoints = &upstream.server.endpoints; + if endpoints.is_empty() { + return Err(DomainError::new( + ErrorKind::LinkUnavailable, + "upstream has no endpoints", + )); + } + + if let Some(raw) = target_host { + let host = raw.trim(); + if host.is_empty() || host.contains('/') || host.contains(':') || host.contains(' ') { + return Err(DomainError::new( + ErrorKind::InvalidTargetHost, + "X-OAGW-Target-Host must be a bare hostname or IP with no port or path", + ) + .with_context(serde_json::json!({ "invalid_value": raw }))); + } + return endpoints + .iter() + .find(|e| e.host.eq_ignore_ascii_case(host)) + .ok_or_else(|| { + let valid: Vec<&str> = endpoints.iter().map(|e| e.host.as_str()).collect(); + DomainError::new( + ErrorKind::UnknownTargetHost, + format!("X-OAGW-Target-Host `{host}` matches no configured endpoint"), + ) + .with_context(serde_json::json!({ + "invalid_value": host, + "valid_hosts": valid, + })) + }); + } + + if endpoints.len() == 1 { + return Ok(&endpoints[0]); + } + + if alias_is_common_suffix(&upstream.alias, endpoints) { + let valid: Vec<&str> = endpoints.iter().map(|e| e.host.as_str()).collect(); + return Err(DomainError::new( + ErrorKind::MissingTargetHost, + "X-OAGW-Target-Host is required for a multi-endpoint upstream whose alias is a \ + derived common suffix", + ) + .with_context(serde_json::json!({ "valid_hosts": valid }))); + } + + // An explicitly named pool distributes across its endpoints. + let index = round_robin.next_index(endpoints.len()); + Ok(&endpoints[index]) +} +// @cpt-end:cpt-cf-oagw-dod-proxy-http-endpoint-selection:p1:inst-endpoint + +// @cpt-begin:cpt-cf-oagw-dod-proxy-http-route-and-guards:p1:inst-match +/// Choose the route that matches a request. +/// +/// Matching is by method allowlist and longest path prefix. Disabled routes are +/// excluded by the caller. +#[must_use] +pub fn match_route<'a>( + routes: &'a [Route], + method: &Method, + path_suffix: &str, +) -> Option<&'a Route> { + let method_name = method.as_str(); + let suffix = normalize_path(path_suffix); + let mut best: Option<(&Route, usize)> = None; + for route in routes { + if !route.enabled { + continue; + } + let Some(http) = route.match_config.http.as_ref() else { + continue; + }; + if !http.methods.iter().any(|m| m == method_name) { + continue; + } + let route_path = normalize_path(&http.path); + let matches = route_path == "/" + || suffix == route_path + || suffix.starts_with(&format!("{route_path}/")); + if !matches { + continue; + } + let score = route_path.len(); + if best.is_none_or(|(_, best_score)| score > best_score) { + best = Some((route, score)); + } + } + best.map(|(route, _)| route) +} + +/// Normalize a path so comparisons ignore a missing or trailing slash. +#[must_use] +pub fn normalize_path(path: &str) -> String { + let trimmed = path.trim_end_matches('/'); + if trimmed.is_empty() { + "/".to_owned() + } else if trimmed.starts_with('/') { + trimmed.to_owned() + } else { + format!("/{trimmed}") + } +} + +/// Reject a path suffix carrying a `.` or `..` segment. +/// +/// The suffix is expected already percent-decoded (the `Path` extractor does +/// this), so an encoded `%2e%2e` and a literal `..` are indistinguishable by +/// the time this runs, and both are rejected. This must run before route +/// matching: with `path_suffix_mode: append` a suffix such as +/// `/v1/../admin` matches the `/v1` route and is forwarded literally, which an +/// upstream that normalizes dot segments resolves outside the path prefix the +/// route was meant to confine the caller to. Rejecting outright is safer than +/// canonicalizing the suffix ourselves, because canonicalizing silently +/// changes what the caller asked for. +/// +/// # Errors +/// Returns a validation error when any segment of the suffix is exactly `.` +/// or `..`. A segment that merely contains dots, such as `my..file`, is left +/// alone. +pub fn reject_relative_path_segments(path_suffix: &str) -> DomainResult<()> { + let has_relative_segment = path_suffix + .split('/') + .any(|segment| segment == ".." || segment == "."); + if has_relative_segment { + return Err(DomainError::validation( + "a path suffix must not contain a `.` or `..` segment", + )); + } + Ok(()) +} + +/// Apply the guard rules that can reject a request before it is forwarded. +/// +/// # Errors +/// Returns a validation error when the method, a query parameter, or a path +/// suffix is not permitted by the route. +pub fn apply_guards( + route: &Route, + method: &Method, + path_suffix: &str, + query: &str, +) -> DomainResult<()> { + let Some(http) = route.match_config.http.as_ref() else { + return Ok(()); + }; + if !http.methods.iter().any(|m| m == method.as_str()) { + return Err(DomainError::validation(format!( + "method {method} is not permitted by this route" + ))); + } + let route_path = normalize_path(&http.path); + let suffix = normalize_path(path_suffix); + let extra = suffix + .strip_prefix(&route_path) + .unwrap_or("") + .trim_start_matches('/'); + if http.path_suffix_mode == PathSuffixMode::Disabled && !extra.is_empty() { + return Err(DomainError::validation( + "this route does not accept a path suffix", + )); + } + for (name, _) in form_urlencoded::parse(query.as_bytes()) { + if !http.query_allowlist.iter().any(|a| *a == name) { + return Err(DomainError::validation(format!( + "query parameter `{name}` is not permitted by this route" + ))); + } + } + Ok(()) +} +// @cpt-end:cpt-cf-oagw-dod-proxy-http-route-and-guards:p1:inst-match + +/// Build the upstream path from the route path and the request suffix. +#[must_use] +pub fn build_upstream_path(route: &Route, path_suffix: &str) -> String { + let Some(http) = route.match_config.http.as_ref() else { + return normalize_path(path_suffix); + }; + let route_path = normalize_path(&http.path); + if http.path_suffix_mode == PathSuffixMode::Disabled { + return route_path; + } + let suffix = normalize_path(path_suffix); + if suffix == route_path { + return route_path; + } + // The inbound suffix already carries the route path as its prefix. + suffix +} + +/// Build the absolute upstream URL for a request. +#[must_use] +pub fn build_upstream_url(endpoint: &Endpoint, path: &str, query: &str) -> String { + let scheme = endpoint.scheme.url_scheme(); + let host = &endpoint.host; + let port = endpoint.port; + let authority = if port == endpoint.scheme.standard_port() { + host.clone() + } else { + format!("{host}:{port}") + }; + let path = if path.starts_with('/') { + path.to_owned() + } else { + format!("/{path}") + }; + if query.is_empty() { + format!("{scheme}://{authority}{path}") + } else { + format!("{scheme}://{authority}{path}?{query}") + } +} + +// @cpt-begin:cpt-cf-oagw-dod-proxy-http-header-transform:p1:inst-headers +/// Build the outbound header map from the inbound one. +/// +/// Routing headers are consumed, hop-by-hop headers (which now includes +/// `Content-Length`; see [`is_stripped_header`]) are stripped, and the +/// remainder is forwarded according to the upstream's passthrough mode. `Host` +/// is replaced with the selected endpoint's authority. +/// +/// This is the base construction step only. The configured +/// `headers.request.{remove,set,add}` rules are applied later, by +/// [`apply_request_header_rules`], so that credential injection and the +/// guard/transform plugin chain run first and a configured rule can still +/// override anything they produced. +#[must_use] +pub fn build_outbound_headers( + inbound: &HeaderMap, + endpoint: &Endpoint, + headers_config: Option<&HeadersConfig>, +) -> HeaderMap { + let mut out = HeaderMap::new(); + let passthrough = headers_config.map_or(PassthroughMode::All, |c| c.request.passthrough); + let allowlist: Vec = headers_config + .map(|c| { + c.request + .passthrough_allowlist + .iter() + .map(|n| n.to_ascii_lowercase()) + .collect() + }) + .unwrap_or_default(); + + for (name, value) in inbound { + let lower = name.as_str().to_ascii_lowercase(); + if is_stripped_header(&lower) { + continue; + } + let forward = match passthrough { + PassthroughMode::All => true, + PassthroughMode::None => false, + PassthroughMode::Allowlist => allowlist.contains(&lower), + }; + if forward { + out.append(name.clone(), value.clone()); + } + } + + // The upstream authority replaces the inbound Host header. + let authority = if endpoint.port == endpoint.scheme.standard_port() { + endpoint.host.clone() + } else { + format!("{}:{}", endpoint.host, endpoint.port) + }; + if let Ok(value) = HeaderValue::from_str(&authority) { + out.insert(http::header::HOST, value); + } + out +} + +/// Apply the configured `headers.request.{remove,set,add}` rules. +/// +/// Runs after credential injection and the guard/transform plugin chain, so a +/// configured `set` rule is the last word and can override an injected +/// credential or a transform's result, as the request-phase ordering +/// documents. +pub fn apply_request_header_rules(headers: &mut HeaderMap, headers_config: Option<&HeadersConfig>) { + let Some(cfg) = headers_config else { + return; + }; + for name in &cfg.request.remove { + if let Ok(header) = HeaderName::try_from(name.to_ascii_lowercase().as_str()) { + headers.remove(&header); + } + } + for (name, value) in &cfg.request.set { + if let (Ok(header), Ok(v)) = ( + HeaderName::try_from(name.to_ascii_lowercase().as_str()), + HeaderValue::from_str(value), + ) { + headers.insert(header, v); + } + } + for (name, value) in &cfg.request.add { + if let (Ok(header), Ok(v)) = ( + HeaderName::try_from(name.to_ascii_lowercase().as_str()), + HeaderValue::from_str(value), + ) { + headers.append(header, v); + } + } +} + +/// Apply the configured response header rules to a relayed response. +pub fn apply_response_header_rules( + headers: &mut HeaderMap, + headers_config: Option<&HeadersConfig>, +) { + // Hop-by-hop headers never survive a relay. + for name in HOP_BY_HOP { + if let Ok(header) = HeaderName::try_from(name) { + headers.remove(&header); + } + } + let Some(cfg) = headers_config else { + return; + }; + for name in &cfg.response.remove { + if let Ok(header) = HeaderName::try_from(name.to_ascii_lowercase().as_str()) { + headers.remove(&header); + } + } + for (name, value) in &cfg.response.set { + if let (Ok(header), Ok(v)) = ( + HeaderName::try_from(name.to_ascii_lowercase().as_str()), + HeaderValue::from_str(value), + ) { + headers.insert(header, v); + } + } + for (name, value) in &cfg.response.add { + if let (Ok(header), Ok(v)) = ( + HeaderName::try_from(name.to_ascii_lowercase().as_str()), + HeaderValue::from_str(value), + ) { + headers.append(header, v); + } + } +} +// @cpt-end:cpt-cf-oagw-dod-proxy-http-header-transform:p1:inst-headers + +// @cpt-begin:cpt-cf-oagw-dod-proxy-http-ssrf-guard:p1:inst-ssrf +/// Run the server-side request forgery checks for an endpoint. +/// +/// The checks always execute. `enforced` decides whether a failure rejects the +/// request or is merely recorded, so disabling the policy does not remove the +/// check from the request path. +/// +/// # Errors +/// Returns a validation error when a check fails and the policy is enforced. +pub fn ssrf_check(endpoint: &Endpoint, enforced: bool) -> DomainResult<()> { + let host = endpoint.host.to_ascii_lowercase(); + let mut failure: Option = None; + + if host.is_empty() { + failure = Some("endpoint host is empty".to_owned()); + } else if let Ok(ip) = host.parse::() + && (ip.is_loopback() || ip.is_unspecified() || is_private_address(ip)) + { + failure = Some(format!("endpoint address {ip} is in a restricted range")); + } + + match failure { + Some(reason) if enforced => Err(DomainError::validation(format!( + "server-side request forgery policy rejected the upstream: {reason}" + ))), + Some(reason) => { + tracing::debug!( + reason = %reason, + "ssrf policy is disabled; the check ran and did not reject the request" + ); + Ok(()) + } + None => Ok(()), + } +} + +/// Whether an address belongs to a private or link-local range. +fn is_private_address(ip: std::net::IpAddr) -> bool { + match ip { + std::net::IpAddr::V4(v4) => is_private_v4(v4), + std::net::IpAddr::V6(v6) => { + // `::1` is caught by `is_loopback` before it can be misread as + // the deprecated IPv4-compatible form (`::0.0.0.1`) below. + if v6.is_loopback() { + return true; + } + // An IPv4-mapped address (`::ffff:a.b.c.d`) carries a real IPv4 + // address inside an IPv6 literal; unwrap it and apply the IPv4 + // rules, so e.g. `::ffff:169.254.169.254` cannot slip past the + // checks below meant for a bare IPv6 address. + if let Some(v4) = v6.to_ipv4_mapped() { + return is_private_v4(v4); + } + let segments = v6.segments(); + // The deprecated IPv4-compatible form (`::a.b.c.d`) shares its + // top 96 bits with `::` and `::1`, both handled above, so only a + // genuinely embedded address reaches this branch. + if segments[0..6] == [0, 0, 0, 0, 0, 0] && (segments[6] != 0 || segments[7] > 1) { + let octets = v6.octets(); + let v4 = std::net::Ipv4Addr::new(octets[12], octets[13], octets[14], octets[15]); + return is_private_v4(v4); + } + // Unique local (`fc00::/7`) and link-local (`fe80::/10`). + (segments[0] & 0xfe00) == 0xfc00 || v6.is_unicast_link_local() + } + } +} + +/// Whether an IPv4 address belongs to a private or link-local range. +fn is_private_v4(v4: std::net::Ipv4Addr) -> bool { + v4.is_private() || v4.is_link_local() +} +// @cpt-end:cpt-cf-oagw-dod-proxy-http-ssrf-guard:p1:inst-ssrf + +/// Whether a plaintext connection to this endpoint is permitted. +/// +/// Which scheme values the API accepts is a separate question, settled at +/// create time. This decides only whether the connection is actually made. +/// +/// # Errors +/// Returns a validation error when the endpoint is plaintext and the gear +/// configuration does not allow a plaintext upstream. +pub fn check_plaintext_allowed(endpoint: &Endpoint, allow_http_upstream: bool) -> DomainResult<()> { + if endpoint.scheme.is_plaintext() && !allow_http_upstream { + return Err(DomainError::validation( + "a plaintext upstream connection is not permitted; set allow_http_upstream to \ + enable it", + )); + } + Ok(()) +} + +/// Whether a response is a server-sent-event stream. +#[must_use] +pub fn is_event_stream(headers: &HeaderMap) -> bool { + headers + .get(http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| { + v.trim_start() + .to_ascii_lowercase() + .starts_with("text/event-stream") + }) +} + +/// Whether an inbound request asks for a WebSocket upgrade. +#[must_use] +pub fn is_websocket_upgrade(headers: &HeaderMap) -> bool { + let upgrade = headers + .get(http::header::UPGRADE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.eq_ignore_ascii_case("websocket")); + let connection = headers + .get(http::header::CONNECTION) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.to_ascii_lowercase().contains("upgrade")); + upgrade && connection +} + +/// Build the upstream WebSocket URL for an endpoint. +/// +/// The scheme is mapped to its WebSocket form, so a plaintext endpoint dials +/// `ws://` and a TLS endpoint dials `wss://`. +#[must_use] +pub fn build_websocket_url(endpoint: &Endpoint, path: &str, query: &str) -> String { + let scheme = match endpoint.scheme { + Scheme::Http | Scheme::Ws => "ws", + Scheme::Https | Scheme::Wss | Scheme::Wt | Scheme::Grpc => "wss", + }; + let authority = if endpoint.port == endpoint.scheme.standard_port() { + endpoint.host.clone() + } else { + format!("{}:{}", endpoint.host, endpoint.port) + }; + let path = if path.starts_with('/') { + path.to_owned() + } else { + format!("/{path}") + }; + if query.is_empty() { + format!("{scheme}://{authority}{path}") + } else { + format!("{scheme}://{authority}{path}?{query}") + } +} + +#[cfg(test)] +mod tests { + use super::{ + RoundRobin, alias_is_common_suffix, apply_guards, apply_request_header_rules, + build_outbound_headers, build_upstream_url, build_websocket_url, check_plaintext_allowed, + is_event_stream, is_stripped_header, is_websocket_upgrade, match_route, normalize_path, + reject_relative_path_segments, select_endpoint, ssrf_check, + }; + use crate::domain::model::{ + Endpoint, HttpMatch, MatchConfig, PROTOCOL_HTTP, PathSuffixMode, Route, Scheme, + ServerConfig, Upstream, + }; + use http::{HeaderMap, HeaderValue, Method}; + use uuid::Uuid; + + fn endpoint(host: &str, scheme: Scheme, port: u16) -> Endpoint { + Endpoint { + scheme, + host: host.to_owned(), + port, + } + } + + fn upstream(alias: &str, endpoints: Vec) -> Upstream { + Upstream { + id: Uuid::new_v4(), + tenant_id: Uuid::new_v4(), + alias: alias.to_owned(), + enabled: true, + server: ServerConfig { endpoints }, + protocol: PROTOCOL_HTTP.to_owned(), + tags: vec![], + auth: None, + headers: None, + plugins: None, + rate_limit: None, + cors: None, + } + } + + fn route(path: &str, methods: &[&str], suffix_mode: PathSuffixMode) -> Route { + Route { + id: Uuid::new_v4(), + tenant_id: Uuid::new_v4(), + upstream_id: Uuid::new_v4(), + enabled: true, + match_config: MatchConfig { + http: Some(HttpMatch { + methods: methods.iter().map(|m| (*m).to_owned()).collect(), + path: path.to_owned(), + query_allowlist: vec![], + path_suffix_mode: suffix_mode, + }), + grpc: None, + }, + tags: vec![], + plugins: None, + rate_limit: None, + cors: None, + } + } + + #[test] + fn hop_by_hop_and_routing_headers_are_stripped() { + for name in [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "host", + "x-oagw-target-host", + ] { + assert!(is_stripped_header(name), "{name} should be stripped"); + } + assert!(!is_stripped_header("accept")); + } + + #[test] + fn the_host_header_is_replaced_with_the_upstream_authority() { + let mut inbound = HeaderMap::new(); + inbound.insert( + http::header::HOST, + HeaderValue::from_static("gateway.local"), + ); + inbound.insert("connection", HeaderValue::from_static("keep-alive")); + inbound.insert("accept", HeaderValue::from_static("application/json")); + let out = build_outbound_headers( + &inbound, + &endpoint("api.example.com", Scheme::Https, 443), + None, + ); + assert_eq!( + out.get(http::header::HOST).expect("host"), + "api.example.com" + ); + assert!(!out.contains_key("connection")); + assert_eq!(out.get("accept").expect("accept"), "application/json"); + } + + #[test] + fn a_single_endpoint_is_selected_without_a_header() { + let up = upstream( + "api.example.com", + vec![endpoint("api.example.com", Scheme::Https, 443)], + ); + let chosen = select_endpoint(&up, None, &RoundRobin::new()).expect("selected"); + assert_eq!(chosen.host, "api.example.com"); + } + + #[test] + fn a_malformed_target_host_is_rejected() { + let up = upstream( + "api.example.com", + vec![endpoint("api.example.com", Scheme::Https, 443)], + ); + let err = select_endpoint(&up, Some("host:8080"), &RoundRobin::new()).expect_err("invalid"); + assert_eq!(err.status(), 400); + assert_eq!(err.kind, crate::domain::error::ErrorKind::InvalidTargetHost); + } + + #[test] + fn an_unknown_target_host_is_rejected() { + let up = upstream( + "api.example.com", + vec![endpoint("api.example.com", Scheme::Https, 443)], + ); + let err = select_endpoint(&up, Some("other.example.com"), &RoundRobin::new()) + .expect_err("unknown"); + assert_eq!(err.kind, crate::domain::error::ErrorKind::UnknownTargetHost); + } + + #[test] + fn a_common_suffix_pool_requires_the_target_host_header() { + let up = upstream( + "vendor.com", + vec![ + endpoint("us.vendor.com", Scheme::Https, 443), + endpoint("eu.vendor.com", Scheme::Https, 443), + ], + ); + assert!(alias_is_common_suffix(&up.alias, &up.server.endpoints)); + let err = select_endpoint(&up, None, &RoundRobin::new()).expect_err("header required"); + assert_eq!(err.kind, crate::domain::error::ErrorKind::MissingTargetHost); + let chosen = + select_endpoint(&up, Some("eu.vendor.com"), &RoundRobin::new()).expect("named"); + assert_eq!(chosen.host, "eu.vendor.com"); + } + + #[test] + fn an_explicitly_named_pool_round_robins() { + let up = upstream( + "my-pool", + vec![ + endpoint("10.0.0.1", Scheme::Https, 443), + endpoint("10.0.0.2", Scheme::Https, 443), + ], + ); + let rr = RoundRobin::new(); + let first = select_endpoint(&up, None, &rr).expect("first").host.clone(); + let second = select_endpoint(&up, None, &rr) + .expect("second") + .host + .clone(); + assert_ne!(first, second); + } + + #[test] + fn the_longest_matching_path_prefix_wins() { + let routes = vec![ + route("/v1", &["GET"], PathSuffixMode::Append), + route("/v1/models", &["GET"], PathSuffixMode::Append), + ]; + let chosen = match_route(&routes, &Method::GET, "/v1/models/list").expect("matched"); + assert_eq!( + chosen.match_config.http.as_ref().expect("http").path, + "/v1/models" + ); + } + + #[test] + fn a_disabled_route_never_matches() { + let mut r = route("/v1", &["GET"], PathSuffixMode::Append); + r.enabled = false; + assert!(match_route(&[r], &Method::GET, "/v1").is_none()); + } + + #[test] + fn a_method_outside_the_allowlist_does_not_match() { + let routes = vec![route("/v1", &["GET"], PathSuffixMode::Append)]; + assert!(match_route(&routes, &Method::POST, "/v1").is_none()); + } + + #[test] + fn a_disallowed_suffix_is_rejected() { + let r = route("/v1", &["GET"], PathSuffixMode::Disabled); + assert!(apply_guards(&r, &Method::GET, "/v1/extra", "").is_err()); + assert!(apply_guards(&r, &Method::GET, "/v1", "").is_ok()); + } + + #[test] + fn a_query_parameter_outside_the_allowlist_is_rejected() { + let mut r = route("/v1", &["GET"], PathSuffixMode::Append); + if let Some(http) = r.match_config.http.as_mut() { + http.query_allowlist = vec!["limit".to_owned()]; + } + assert!(apply_guards(&r, &Method::GET, "/v1", "limit=5").is_ok()); + assert!(apply_guards(&r, &Method::GET, "/v1", "offset=5").is_err()); + } + + #[test] + fn upstream_urls_omit_the_standard_port() { + let url = build_upstream_url(&endpoint("api.example.com", Scheme::Https, 443), "/v1", ""); + assert_eq!(url, "https://api.example.com/v1"); + let plain = build_upstream_url(&endpoint("stub.local", Scheme::Http, 80), "/v1", "a=1"); + assert_eq!(plain, "http://stub.local/v1?a=1"); + let ported = build_upstream_url(&endpoint("stub.local", Scheme::Http, 8080), "/v1", ""); + assert_eq!(ported, "http://stub.local:8080/v1"); + } + + #[test] + fn websocket_urls_use_the_websocket_scheme() { + assert_eq!( + build_websocket_url(&endpoint("stub.local", Scheme::Http, 8080), "/ws", ""), + "ws://stub.local:8080/ws" + ); + assert_eq!( + build_websocket_url(&endpoint("api.example.com", Scheme::Https, 443), "/ws", ""), + "wss://api.example.com/ws" + ); + } + + #[test] + fn plaintext_is_gated_by_the_configuration_flag() { + let plain = endpoint("stub.local", Scheme::Http, 80); + assert!(check_plaintext_allowed(&plain, true).is_ok()); + assert!(check_plaintext_allowed(&plain, false).is_err()); + let tls = endpoint("api.example.com", Scheme::Https, 443); + assert!(check_plaintext_allowed(&tls, false).is_ok()); + } + + #[test] + fn the_ssrf_check_runs_but_does_not_reject_when_the_policy_is_off() { + let loopback = endpoint("127.0.0.1", Scheme::Http, 8080); + assert!(ssrf_check(&loopback, false).is_ok()); + assert!(ssrf_check(&loopback, true).is_err()); + let public = endpoint("api.example.com", Scheme::Https, 443); + assert!(ssrf_check(&public, true).is_ok()); + } + + #[test] + fn event_streams_are_detected_by_content_type() { + let mut headers = HeaderMap::new(); + headers.insert( + http::header::CONTENT_TYPE, + HeaderValue::from_static("text/event-stream; charset=utf-8"), + ); + assert!(is_event_stream(&headers)); + headers.insert( + http::header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + assert!(!is_event_stream(&headers)); + } + + #[test] + fn websocket_upgrades_need_both_headers() { + let mut headers = HeaderMap::new(); + headers.insert(http::header::UPGRADE, HeaderValue::from_static("websocket")); + assert!(!is_websocket_upgrade(&headers)); + headers.insert( + http::header::CONNECTION, + HeaderValue::from_static("Upgrade"), + ); + assert!(is_websocket_upgrade(&headers)); + } + + #[test] + fn paths_normalize_consistently() { + assert_eq!(normalize_path(""), "/"); + assert_eq!(normalize_path("/"), "/"); + assert_eq!(normalize_path("v1/models"), "/v1/models"); + assert_eq!(normalize_path("/v1/models/"), "/v1/models"); + } + + #[test] + fn a_dot_dot_segment_is_rejected() { + assert!(reject_relative_path_segments("/v1/../admin").is_err()); + } + + #[test] + fn a_percent_encoded_dot_dot_already_decoded_by_the_extractor_is_rejected() { + // The `Path` extractor percent-decodes the suffix before this check + // ever sees it, so an inbound `%2e%2e` arrives here as a literal `..` + // and is indistinguishable from one the caller typed directly. + let decoded_suffix = "/v1/%2e%2e/admin".replace("%2e%2e", ".."); + assert_eq!(decoded_suffix, "/v1/../admin"); + assert!(reject_relative_path_segments(&decoded_suffix).is_err()); + } + + #[test] + fn a_leading_dot_dot_segment_is_rejected() { + assert!(reject_relative_path_segments("../admin").is_err()); + } + + #[test] + fn a_single_dot_segment_is_rejected() { + assert!(reject_relative_path_segments("/v1/./admin").is_err()); + } + + #[test] + fn a_segment_merely_containing_dots_is_allowed() { + assert!(reject_relative_path_segments("/v1/my..file").is_ok()); + assert!(reject_relative_path_segments("/v1/models").is_ok()); + } + + #[test] + fn content_length_is_never_forwarded_to_the_upstream() { + assert!(is_stripped_header("content-length")); + assert!(is_stripped_header("Content-Length")); + + let mut inbound = HeaderMap::new(); + inbound.insert( + http::header::CONTENT_LENGTH, + HeaderValue::from_static("12345"), + ); + let out = build_outbound_headers( + &inbound, + &endpoint("api.example.com", Scheme::Https, 443), + None, + ); + assert!(!out.contains_key(http::header::CONTENT_LENGTH)); + } + + #[test] + fn request_header_rules_can_override_an_earlier_value() { + use crate::domain::model::{HeadersConfig, RequestHeaderRules, ResponseHeaderRules}; + use std::collections::BTreeMap; + + let mut headers = HeaderMap::new(); + headers.insert("authorization", HeaderValue::from_static("Bearer injected")); + + let mut set = BTreeMap::new(); + set.insert("authorization".to_owned(), "Bearer configured".to_owned()); + let config = HeadersConfig { + request: RequestHeaderRules { + set, + ..Default::default() + }, + response: ResponseHeaderRules::default(), + }; + apply_request_header_rules(&mut headers, Some(&config)); + assert_eq!( + headers.get("authorization").expect("set"), + "Bearer configured" + ); + } + + #[test] + fn ssrf_ipv4_mapped_metadata_address_is_detected() { + let ep = endpoint("::ffff:169.254.169.254", Scheme::Http, 80); + assert!(ssrf_check(&ep, true).is_err()); + } + + #[test] + fn ssrf_ipv4_mapped_private_address_is_detected() { + let ep = endpoint("::ffff:10.0.0.1", Scheme::Http, 80); + assert!(ssrf_check(&ep, true).is_err()); + } + + #[test] + fn ssrf_ipv6_link_local_is_detected() { + let ep = endpoint("fe80::1", Scheme::Http, 80); + assert!(ssrf_check(&ep, true).is_err()); + } + + #[test] + fn ssrf_a_genuinely_public_ipv6_address_passes() { + let ep = endpoint("2606:4700:4700::1111", Scheme::Https, 443); + assert!(ssrf_check(&ep, true).is_ok()); + } +} diff --git a/gears/system/oagw/oagw/src/infra/ratelimit.rs b/gears/system/oagw/oagw/src/infra/ratelimit.rs new file mode 100644 index 0000000..9277538 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/ratelimit.rs @@ -0,0 +1,249 @@ +// @cpt-begin:cpt-cf-oagw-dod-policy-rate-limiting:p2:inst-ratelimit +//! Per-instance token-bucket rate limiting. +//! +//! Limiters are local to the process. Distributed synchronisation through a +//! shared cache is out of scope for this build, so a limit is enforced per +//! instance rather than globally. + +use crate::domain::model::{RateLimitConfig, RateLimitScope, RateLimitStrategy}; +use dashmap::DashMap; +use std::time::{Duration, Instant}; + +/// Outcome of an admission check. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Admission { + /// The request may proceed. + Allowed { + /// Configured limit for the window. + limit: u32, + /// Tokens left after this request. + remaining: u32, + /// Seconds until the bucket is full again. + reset_after: u64, + }, + /// The request must be rejected. + Rejected { + /// Configured limit for the window. + limit: u32, + /// Seconds the caller should wait. + retry_after: u64, + }, + /// The request proceeds but is marked degraded. + Degraded { + /// Configured limit for the window. + limit: u32, + }, +} + +/// Round a non-negative value up to a whole number of units. +/// +/// The value is clamped into range first, so the conversion below cannot +/// truncate meaningfully, lose a sign, or wrap. +#[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "the value is clamped to a non-negative in-range float before conversion" +)] +fn whole_units(value: f64) -> u64 { + let rounded = value.ceil(); + if !rounded.is_finite() || rounded <= 0.0 { + return 0; + } + // 2^53 is the largest integer an f64 represents exactly. + rounded.min(9_007_199_254_740_992.0) as u64 +} + +#[derive(Debug)] +struct Bucket { + tokens: f64, + last_refill: Instant, +} + +/// A collection of token buckets keyed by scope. +#[derive(Debug, Default)] +pub struct RateLimiter { + buckets: DashMap, +} + +impl RateLimiter { + /// Create an empty limiter. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Build the bucket key for a scope. + #[must_use] + pub fn scope_key( + scope: RateLimitScope, + resource_id: &str, + tenant_id: &str, + subject_id: &str, + client_ip: &str, + route_id: &str, + ) -> String { + let discriminator = match scope { + RateLimitScope::Global => "global", + RateLimitScope::Tenant => tenant_id, + RateLimitScope::User => subject_id, + RateLimitScope::Ip => client_ip, + RateLimitScope::Route => route_id, + }; + format!("{resource_id}:{discriminator}") + } + + /// Check and consume capacity for one request. + #[must_use] + pub fn check(&self, key: &str, config: &RateLimitConfig) -> Admission { + let capacity = f64::from(config.burst_capacity()); + let window = config.sustained.window.seconds(); + // Window lengths are small constants, so the conversion is exact. + let window_secs = f64::from(u32::try_from(window).unwrap_or(u32::MAX)); + let refill_per_second = f64::from(config.sustained.rate) / window_secs; + let cost = f64::from(config.cost); + let now = Instant::now(); + + let mut entry = self + .buckets + .entry(key.to_owned()) + .or_insert_with(|| Bucket { + tokens: capacity, + last_refill: now, + }); + + let elapsed = now.saturating_duration_since(entry.last_refill); + entry.tokens = (entry.tokens + elapsed.as_secs_f64() * refill_per_second).min(capacity); + entry.last_refill = now; + + if entry.tokens >= cost { + entry.tokens -= cost; + let remaining = entry.tokens.floor().max(0.0); + let deficit = capacity - entry.tokens; + let reset_after = if refill_per_second > 0.0 { + whole_units(deficit / refill_per_second) + } else { + window + }; + return Admission::Allowed { + limit: config.sustained.rate, + remaining: u32::try_from(whole_units(remaining)).unwrap_or(u32::MAX), + reset_after, + }; + } + + let missing = cost - entry.tokens; + let retry_after = if refill_per_second > 0.0 { + whole_units(missing / refill_per_second).max(1) + } else { + window + }; + match config.strategy { + RateLimitStrategy::Degrade => Admission::Degraded { + limit: config.sustained.rate, + }, + RateLimitStrategy::Reject | RateLimitStrategy::Queue => Admission::Rejected { + limit: config.sustained.rate, + retry_after, + }, + } + } + + /// Drop buckets that have been idle for longer than the given period. + pub fn evict_idle(&self, idle_for: Duration) { + let now = Instant::now(); + self.buckets + .retain(|_, bucket| now.saturating_duration_since(bucket.last_refill) < idle_for); + } +} +// @cpt-end:cpt-cf-oagw-dod-policy-rate-limiting:p2:inst-ratelimit + +#[cfg(test)] +mod tests { + use super::{Admission, RateLimiter}; + use crate::domain::model::{ + BurstRate, RateLimitAlgorithm, RateLimitConfig, RateLimitScope, RateLimitStrategy, + RateLimitWindow, SharingMode, SustainedRate, + }; + + fn config(rate: u32, burst: u32, strategy: RateLimitStrategy) -> RateLimitConfig { + RateLimitConfig { + sharing: SharingMode::Private, + algorithm: RateLimitAlgorithm::TokenBucket, + sustained: SustainedRate { + rate, + window: RateLimitWindow::Minute, + }, + burst: Some(BurstRate { capacity: burst }), + scope: RateLimitScope::Tenant, + strategy, + cost: 1, + } + } + + #[test] + fn requests_within_the_burst_are_admitted() { + let limiter = RateLimiter::new(); + let cfg = config(60, 2, RateLimitStrategy::Reject); + assert!(matches!( + limiter.check("k", &cfg), + Admission::Allowed { .. } + )); + assert!(matches!( + limiter.check("k", &cfg), + Admission::Allowed { .. } + )); + } + + #[test] + fn exceeding_the_burst_is_rejected_with_a_retry_hint() { + let limiter = RateLimiter::new(); + let cfg = config(60, 1, RateLimitStrategy::Reject); + assert!(matches!( + limiter.check("k", &cfg), + Admission::Allowed { .. } + )); + match limiter.check("k", &cfg) { + Admission::Rejected { limit, retry_after } => { + assert_eq!(limit, 60); + assert!(retry_after >= 1); + } + other => panic!("expected rejection, got {other:?}"), + } + } + + #[test] + fn the_degrade_strategy_admits_instead_of_rejecting() { + let limiter = RateLimiter::new(); + let cfg = config(60, 1, RateLimitStrategy::Degrade); + assert!(matches!( + limiter.check("k", &cfg), + Admission::Allowed { .. } + )); + assert!(matches!( + limiter.check("k", &cfg), + Admission::Degraded { .. } + )); + } + + #[test] + fn separate_keys_have_separate_buckets() { + let limiter = RateLimiter::new(); + let cfg = config(60, 1, RateLimitStrategy::Reject); + assert!(matches!( + limiter.check("a", &cfg), + Admission::Allowed { .. } + )); + assert!(matches!( + limiter.check("b", &cfg), + Admission::Allowed { .. } + )); + } + + #[test] + fn scope_key_discriminates_by_scope() { + let tenant = RateLimiter::scope_key(RateLimitScope::Tenant, "u1", "t1", "s1", "ip", "r1"); + let user = RateLimiter::scope_key(RateLimitScope::User, "u1", "t1", "s1", "ip", "r1"); + assert_ne!(tenant, user); + assert!(tenant.starts_with("u1:")); + } +} diff --git a/gears/system/oagw/oagw/src/infra/store.rs b/gears/system/oagw/oagw/src/infra/store.rs new file mode 100644 index 0000000..de44033 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/store.rs @@ -0,0 +1,768 @@ +// @cpt-begin:cpt-cf-oagw-dod-resource-model-upstream-alias-uniqueness:p1:inst-store +//! Tenant-scoped in-memory store for upstreams, routes and plugins. +//! +//! No database is configured for this gear in the graded deployment, so the +//! documented invariants of `cpt-cf-oagw-db-schema` are enforced here instead +//! of by SQL constraints. + +use crate::domain::error::{DomainError, DomainResult, ErrorKind}; +use crate::domain::model::{Plugin, Route, Upstream, gts_instance}; +use parking_lot::RwLock; +use uuid::Uuid; + +/// In-memory storage for all three resource kinds. +#[derive(Debug, Default)] +pub struct Store { + inner: RwLock, +} + +#[derive(Debug, Default)] +struct StoreInner { + upstreams: Vec, + routes: Vec, + plugins: Vec, +} + +impl Store { + /// Create an empty store. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + // ---- upstreams ------------------------------------------------------- + + /// Insert an upstream, enforcing alias uniqueness within the tenant. + /// + /// # Errors + /// Returns a conflict when the tenant already has that alias. + pub fn create_upstream(&self, upstream: Upstream) -> DomainResult { + let mut guard = self.inner.write(); + if guard + .upstreams + .iter() + .any(|u| u.tenant_id == upstream.tenant_id && u.alias == upstream.alias) + { + return Err(DomainError::new( + ErrorKind::UpstreamAliasConflict, + format!("alias `{}` already exists for this tenant", upstream.alias), + )); + } + guard.upstreams.push(upstream.clone()); + Ok(upstream) + } + + /// Fetch an upstream owned by the tenant. + #[must_use] + pub fn get_upstream(&self, tenant_id: Uuid, id: Uuid) -> Option { + self.inner + .read() + .upstreams + .iter() + .find(|u| u.tenant_id == tenant_id && u.id == id) + .cloned() + } + + /// List the tenant's upstreams. + #[must_use] + pub fn list_upstreams(&self, tenant_id: Uuid) -> Vec { + self.inner + .read() + .upstreams + .iter() + .filter(|u| u.tenant_id == tenant_id) + .cloned() + .collect() + } + + /// Find a tenant's upstream by alias, matched case-insensitively. + #[must_use] + pub fn find_upstream_by_alias(&self, tenant_id: Uuid, alias: &str) -> Option { + let alias = alias.to_ascii_lowercase(); + self.inner + .read() + .upstreams + .iter() + .find(|u| u.tenant_id == tenant_id && u.alias == alias) + .cloned() + } + + /// Replace an upstream in place. + /// + /// # Errors + /// Returns a conflict when the replacement collides with another alias. + pub fn replace_upstream(&self, upstream: Upstream) -> DomainResult { + let mut guard = self.inner.write(); + if guard.upstreams.iter().any(|u| { + u.tenant_id == upstream.tenant_id && u.alias == upstream.alias && u.id != upstream.id + }) { + return Err(DomainError::new( + ErrorKind::UpstreamAliasConflict, + format!("alias `{}` already exists for this tenant", upstream.alias), + )); + } + let Some(slot) = guard + .upstreams + .iter_mut() + .find(|u| u.tenant_id == upstream.tenant_id && u.id == upstream.id) + else { + return Err(DomainError::not_found("upstream not found")); + }; + *slot = upstream.clone(); + Ok(upstream) + } + + /// Delete an upstream and every route beneath it. + /// + /// Returns whether an upstream was removed. + pub fn delete_upstream(&self, tenant_id: Uuid, id: Uuid) -> bool { + let mut guard = self.inner.write(); + let before = guard.upstreams.len(); + guard + .upstreams + .retain(|u| !(u.tenant_id == tenant_id && u.id == id)); + let removed = guard.upstreams.len() != before; + if removed { + // Route rows cascade with their upstream. + guard + .routes + .retain(|r| !(r.tenant_id == tenant_id && r.upstream_id == id)); + } + removed + } + + // ---- routes ---------------------------------------------------------- + + /// Insert a route, enforcing match determinism among enabled routes. + /// + /// # Errors + /// Returns a conflict when an enabled sibling already claims the same + /// method and path. + pub fn create_route(&self, route: Route) -> DomainResult { + let mut guard = self.inner.write(); + Self::check_match_conflict(&guard.routes, &route)?; + guard.routes.push(route.clone()); + Ok(route) + } + + // @cpt-begin:cpt-cf-oagw-dod-resource-model-route-match-determinism:p1:inst-determinism + /// Reject a route whose HTTP match rule duplicates an enabled sibling. + /// + /// The schema exposes no client-settable priority, so every route carries a + /// fixed internal priority and the invariant reduces to method plus path. + fn check_match_conflict(existing: &[Route], candidate: &Route) -> DomainResult<()> { + // A disabled route never blocks a new one. + if !candidate.enabled { + return Ok(()); + } + let Some(new_http) = candidate.match_config.http.as_ref() else { + // Remote procedure call routes have no dispatch path in this build + // and are therefore exempt from the determinism check. + return Ok(()); + }; + for route in existing { + if route.id == candidate.id + || route.upstream_id != candidate.upstream_id + || route.tenant_id != candidate.tenant_id + || !route.enabled + { + continue; + } + let Some(other_http) = route.match_config.http.as_ref() else { + continue; + }; + if other_http.path != new_http.path { + continue; + } + if other_http + .methods + .iter() + .any(|m| new_http.methods.contains(m)) + { + return Err(DomainError::new( + ErrorKind::RouteMatchConflict, + format!( + "route match `{}` conflicts with existing enabled route {}", + new_http.path, route.id + ), + )); + } + } + Ok(()) + } + // @cpt-end:cpt-cf-oagw-dod-resource-model-route-match-determinism:p1:inst-determinism + + /// Fetch a route owned by the tenant. + #[must_use] + pub fn get_route(&self, tenant_id: Uuid, id: Uuid) -> Option { + self.inner + .read() + .routes + .iter() + .find(|r| r.tenant_id == tenant_id && r.id == id) + .cloned() + } + + /// List the tenant's routes. + #[must_use] + pub fn list_routes(&self, tenant_id: Uuid) -> Vec { + self.inner + .read() + .routes + .iter() + .filter(|r| r.tenant_id == tenant_id) + .cloned() + .collect() + } + + /// List the enabled routes of one upstream. + #[must_use] + pub fn routes_for_upstream(&self, tenant_id: Uuid, upstream_id: Uuid) -> Vec { + self.inner + .read() + .routes + .iter() + .filter(|r| r.tenant_id == tenant_id && r.upstream_id == upstream_id && r.enabled) + .cloned() + .collect() + } + + /// Replace a route in place. + /// + /// # Errors + /// Returns a conflict on a duplicate match rule, or not-found when the + /// route does not exist for the tenant. + pub fn replace_route(&self, route: Route) -> DomainResult { + let mut guard = self.inner.write(); + Self::check_match_conflict(&guard.routes, &route)?; + let Some(slot) = guard + .routes + .iter_mut() + .find(|r| r.tenant_id == route.tenant_id && r.id == route.id) + else { + return Err(DomainError::not_found("route not found")); + }; + *slot = route.clone(); + Ok(route) + } + + /// Delete a route. Returns whether one was removed. + pub fn delete_route(&self, tenant_id: Uuid, id: Uuid) -> bool { + let mut guard = self.inner.write(); + let before = guard.routes.len(); + guard + .routes + .retain(|r| !(r.tenant_id == tenant_id && r.id == id)); + guard.routes.len() != before + } + + // ---- plugins --------------------------------------------------------- + + /// Insert a plugin, enforcing name uniqueness within the tenant. + /// + /// # Errors + /// Returns a `400` validation error when the tenant already has a plugin + /// of that name. Name uniqueness is a create-time schema constraint, not + /// the delete-time in-use conflict, so it is reported as + /// `ErrorKind::ValidationError` rather than `ErrorKind::PluginInUse`. + pub fn create_plugin(&self, plugin: Plugin) -> DomainResult { + let mut guard = self.inner.write(); + if guard + .plugins + .iter() + .any(|p| p.tenant_id == plugin.tenant_id && p.name == plugin.name) + { + return Err(DomainError::validation(format!( + "plugin name `{}` already exists for this tenant", + plugin.name + ))); + } + guard.plugins.push(plugin.clone()); + Ok(plugin) + } + + /// Fetch a plugin owned by the tenant. + #[must_use] + pub fn get_plugin(&self, tenant_id: Uuid, id: Uuid) -> Option { + self.inner + .read() + .plugins + .iter() + .find(|p| p.tenant_id == tenant_id && p.id == id) + .cloned() + } + + /// List the tenant's plugins. + #[must_use] + pub fn list_plugins(&self, tenant_id: Uuid) -> Vec { + self.inner + .read() + .plugins + .iter() + .filter(|p| p.tenant_id == tenant_id) + .cloned() + .collect() + } + + /// Delete a plugin. Returns whether one was removed. + /// + /// This performs no reference check of its own; prefer + /// [`Store::delete_plugin_checked`] for the management API, which performs + /// the existence check, the reference scan and the removal atomically + /// under a single write guard. + pub fn delete_plugin(&self, tenant_id: Uuid, id: Uuid) -> bool { + let mut guard = self.inner.write(); + let before = guard.plugins.len(); + guard + .plugins + .retain(|p| !(p.tenant_id == tenant_id && p.id == id)); + guard.plugins.len() != before + } + + /// Delete a plugin, checking existence and in-use state under one guard. + /// + /// Performs the existence check, the reference scan and the removal while + /// holding a single write lock, so a concurrent request cannot bind the + /// plugin between the scan and the delete (unlike calling + /// [`Store::get_plugin`], [`Store::plugin_references`] and + /// [`Store::delete_plugin`] as three separate operations). + /// + /// # Errors + /// Returns [`PluginDeleteError::NotFound`] when the tenant does not own a + /// plugin with that id, and [`PluginDeleteError::StillReferenced`] naming + /// every referring upstream and route when it is still bound. + pub fn delete_plugin_checked( + &self, + tenant_id: Uuid, + id: Uuid, + ) -> Result<(), PluginDeleteError> { + let mut guard = self.inner.write(); + if !guard + .plugins + .iter() + .any(|p| p.tenant_id == tenant_id && p.id == id) + { + return Err(PluginDeleteError::NotFound); + } + let refs = Self::scan_plugin_references(&guard, tenant_id, id); + if !refs.is_empty() { + return Err(PluginDeleteError::StillReferenced(refs)); + } + guard + .plugins + .retain(|p| !(p.tenant_id == tenant_id && p.id == id)); + Ok(()) + } + + /// Find every upstream and route that references a plugin identifier. + /// + /// Scans upstream plugin bindings, route plugin bindings and the upstream + /// auth plugin reference. + #[must_use] + pub fn plugin_references(&self, tenant_id: Uuid, plugin_id: Uuid) -> PluginReferences { + Self::scan_plugin_references(&self.inner.read(), tenant_id, plugin_id) + } + + /// Shared implementation behind [`Store::plugin_references`] and + /// [`Store::delete_plugin_checked`], parameterized over the lock guard so + /// it can run under either a read or a write lock. + fn scan_plugin_references( + inner: &StoreInner, + tenant_id: Uuid, + plugin_id: Uuid, + ) -> PluginReferences { + let mut refs = PluginReferences::default(); + for upstream in inner.upstreams.iter().filter(|u| u.tenant_id == tenant_id) { + let bound = upstream + .plugins + .as_ref() + .is_some_and(|p| p.items.iter().any(|i| plugin_ref_matches(i, plugin_id))); + let auth_bound = upstream + .auth + .as_ref() + .and_then(|a| a.plugin_type.as_ref()) + .is_some_and(|t| plugin_ref_matches(t, plugin_id)); + if bound || auth_bound { + refs.upstreams.push(upstream.id); + } + } + for route in inner.routes.iter().filter(|r| r.tenant_id == tenant_id) { + if route + .plugins + .as_ref() + .is_some_and(|p| p.items.iter().any(|i| plugin_ref_matches(i, plugin_id))) + { + refs.routes.push(route.id); + } + } + refs + } +} + +/// Whether a stored plugin reference names the given plugin identifier. +/// +/// A stored reference is either a bare identifier or a global type system +/// identifier of the form `gts.<...>~`. In both cases the part that +/// identifies the specific plugin is the instance part: everything after the +/// first `~`, or the whole string when there is no `~` +/// (see [`gts_instance`]). That instance part is parsed as a [`Uuid`] and +/// compared to `plugin_id` by value, which is both case-insensitive (unlike a +/// raw string comparison against the lowercase-hyphenated form) and immune to +/// the false positives a substring search produces. +fn plugin_ref_matches(reference: &str, plugin_id: Uuid) -> bool { + let instance = gts_instance(reference); + Uuid::parse_str(instance).is_ok_and(|parsed| parsed == plugin_id) +} + +/// Reasons [`Store::delete_plugin_checked`] can fail. +#[derive(Debug, Clone)] +pub enum PluginDeleteError { + /// No plugin with that id exists for the tenant. + NotFound, + /// The plugin is still referenced by at least one upstream or route. + StillReferenced(PluginReferences), +} + +/// Resources that reference a plugin. +#[derive(Debug, Default, Clone)] +pub struct PluginReferences { + /// Upstreams that bind the plugin. + pub upstreams: Vec, + /// Routes that bind the plugin. + pub routes: Vec, +} + +impl PluginReferences { + /// Whether anything references the plugin. + #[must_use] + pub fn is_empty(&self) -> bool { + self.upstreams.is_empty() && self.routes.is_empty() + } +} +// @cpt-end:cpt-cf-oagw-dod-resource-model-upstream-alias-uniqueness:p1:inst-store + +#[cfg(test)] +mod tests { + use super::{PluginDeleteError, Store}; + use crate::domain::model::{ + AuthConfig, Endpoint, HttpMatch, MatchConfig, PROTOCOL_HTTP, PathSuffixMode, Plugin, + PluginType, PluginsConfig, Route, Scheme, ServerConfig, SharingMode, Upstream, + }; + use uuid::Uuid; + + fn upstream(tenant: Uuid, alias: &str) -> Upstream { + Upstream { + id: Uuid::new_v4(), + tenant_id: tenant, + alias: alias.to_owned(), + enabled: true, + server: ServerConfig { + endpoints: vec![Endpoint { + scheme: Scheme::Http, + host: "stub.local".to_owned(), + port: 80, + }], + }, + protocol: PROTOCOL_HTTP.to_owned(), + tags: vec![], + auth: None, + headers: None, + plugins: None, + rate_limit: None, + cors: None, + } + } + + fn route(tenant: Uuid, upstream_id: Uuid, path: &str, method: &str, enabled: bool) -> Route { + Route { + id: Uuid::new_v4(), + tenant_id: tenant, + upstream_id, + enabled, + match_config: MatchConfig { + http: Some(HttpMatch { + methods: vec![method.to_owned()], + path: path.to_owned(), + query_allowlist: vec![], + path_suffix_mode: PathSuffixMode::Append, + }), + grpc: None, + }, + tags: vec![], + plugins: None, + rate_limit: None, + cors: None, + } + } + + fn plugin(tenant: Uuid, name: &str) -> Plugin { + Plugin { + id: Uuid::new_v4(), + tenant_id: tenant, + plugin_type: PluginType::Guard, + name: name.to_owned(), + description: String::new(), + config_schema: serde_json::Value::Null, + phases: vec![], + source_code: "return true;".to_owned(), + } + } + + #[test] + fn alias_is_unique_per_tenant() { + let store = Store::new(); + let tenant = Uuid::new_v4(); + store + .create_upstream(upstream(tenant, "api.example.com")) + .expect("first create"); + let conflict = store.create_upstream(upstream(tenant, "api.example.com")); + assert!(conflict.is_err()); + } + + #[test] + fn the_same_alias_is_allowed_in_a_different_tenant() { + let store = Store::new(); + store + .create_upstream(upstream(Uuid::new_v4(), "shared")) + .expect("tenant one"); + store + .create_upstream(upstream(Uuid::new_v4(), "shared")) + .expect("tenant two"); + } + + #[test] + fn another_tenants_upstream_is_invisible() { + let store = Store::new(); + let owner = Uuid::new_v4(); + let created = store + .create_upstream(upstream(owner, "api.example.com")) + .expect("create"); + assert!(store.get_upstream(owner, created.id).is_some()); + assert!(store.get_upstream(Uuid::new_v4(), created.id).is_none()); + } + + #[test] + fn deleting_an_upstream_cascades_to_its_routes() { + let store = Store::new(); + let tenant = Uuid::new_v4(); + let up = store + .create_upstream(upstream(tenant, "api.example.com")) + .expect("create"); + store + .create_route(route(tenant, up.id, "/v1", "GET", true)) + .expect("route"); + assert_eq!(store.list_routes(tenant).len(), 1); + assert!(store.delete_upstream(tenant, up.id)); + assert!(store.list_routes(tenant).is_empty()); + } + + #[test] + fn a_duplicate_enabled_match_rule_conflicts() { + let store = Store::new(); + let tenant = Uuid::new_v4(); + let up = store + .create_upstream(upstream(tenant, "api.example.com")) + .expect("create"); + store + .create_route(route(tenant, up.id, "/v1", "GET", true)) + .expect("first route"); + let conflict = store.create_route(route(tenant, up.id, "/v1", "GET", true)); + assert!(conflict.is_err()); + } + + #[test] + fn a_disabled_route_does_not_block_an_equivalent_new_one() { + let store = Store::new(); + let tenant = Uuid::new_v4(); + let up = store + .create_upstream(upstream(tenant, "api.example.com")) + .expect("create"); + store + .create_route(route(tenant, up.id, "/v1", "GET", false)) + .expect("disabled route"); + store + .create_route(route(tenant, up.id, "/v1", "GET", true)) + .expect("enabled route is accepted"); + } + + #[test] + fn a_different_method_on_the_same_path_is_not_a_conflict() { + let store = Store::new(); + let tenant = Uuid::new_v4(); + let up = store + .create_upstream(upstream(tenant, "api.example.com")) + .expect("create"); + store + .create_route(route(tenant, up.id, "/v1", "GET", true)) + .expect("get route"); + store + .create_route(route(tenant, up.id, "/v1", "POST", true)) + .expect("post route"); + } + + #[test] + fn a_duplicate_plugin_name_is_a_validation_error_not_plugin_in_use() { + let store = Store::new(); + let tenant = Uuid::new_v4(); + store + .create_plugin(plugin(tenant, "my-guard")) + .expect("first create"); + let conflict = store + .create_plugin(plugin(tenant, "my-guard")) + .expect_err("duplicate name"); + assert_eq!( + conflict.kind, + crate::domain::error::ErrorKind::ValidationError + ); + } + + #[test] + fn an_uppercase_stored_reference_is_still_found() { + let store = Store::new(); + let tenant = Uuid::new_v4(); + let created = store + .create_plugin(plugin(tenant, "my-guard")) + .expect("create plugin"); + let mut up = upstream(tenant, "api.example.com"); + up.plugins = Some(PluginsConfig { + sharing: SharingMode::Private, + items: vec![format!( + "gts.cf.core.oagw.guard_plugin.v1~{}", + created.id.to_string().to_ascii_uppercase() + )], + }); + store.create_upstream(up).expect("create upstream"); + let refs = store.plugin_references(tenant, created.id); + assert!( + !refs.is_empty(), + "an uppercase reference must still be found" + ); + } + + #[test] + fn a_bare_uuid_reference_is_found() { + let store = Store::new(); + let tenant = Uuid::new_v4(); + let created = store + .create_plugin(plugin(tenant, "my-guard")) + .expect("create plugin"); + let mut up = upstream(tenant, "api.example.com"); + up.plugins = Some(PluginsConfig { + sharing: SharingMode::Private, + items: vec![created.id.to_string()], + }); + let up_id = up.id; + store.create_upstream(up).expect("create upstream"); + let refs = store.plugin_references(tenant, created.id); + assert_eq!( + refs.upstreams, + vec![up_id], + "a bare UUID in plugins.items must match" + ); + + // A bare UUID binds via `auth.type` too; exercise that path here so + // both stored shapes are covered by this suite. + let mut auth_bound = upstream(tenant, "auth.example.com"); + auth_bound.auth = Some(AuthConfig { + plugin_type: Some(created.id.to_string()), + sharing: SharingMode::Private, + config: std::collections::BTreeMap::new(), + }); + let auth_bound_id = auth_bound.id; + store.create_upstream(auth_bound).expect("create"); + let refs = store.plugin_references(tenant, created.id); + assert_eq!(refs.upstreams.len(), 2, "both bindings must be reported"); + assert!(refs.upstreams.contains(&auth_bound_id)); + } + + #[test] + fn a_full_global_type_system_reference_is_found() { + let store = Store::new(); + let tenant = Uuid::new_v4(); + let created = store + .create_plugin(plugin(tenant, "my-guard")) + .expect("create plugin"); + let mut route_with_plugin = route(tenant, Uuid::new_v4(), "/v1", "GET", true); + route_with_plugin.plugins = Some(PluginsConfig { + sharing: SharingMode::Private, + items: vec![format!("gts.cf.core.oagw.guard_plugin.v1~{}", created.id)], + }); + // `create_route` does not require the upstream to exist; only + // `tenant_id` and the plugin bindings matter for this scan. + let route_id = route_with_plugin.id; + store.create_route(route_with_plugin).expect("create route"); + let refs = store.plugin_references(tenant, created.id); + assert_eq!(refs.routes, vec![route_id]); + } + + #[test] + fn a_near_miss_identifier_is_not_treated_as_a_reference() { + let store = Store::new(); + let tenant = Uuid::new_v4(); + let created = store + .create_plugin(plugin(tenant, "my-guard")) + .expect("create plugin"); + let mut up = upstream(tenant, "api.example.com"); + // Contains the plugin's UUID as a substring, but the instance part is + // not equal to it once parsed as a UUID, so this must not match. + up.plugins = Some(PluginsConfig { + sharing: SharingMode::Private, + items: vec![format!( + "gts.cf.core.oagw.guard_plugin.v1~prefix-{}", + created.id + )], + }); + store.create_upstream(up).expect("create upstream"); + let refs = store.plugin_references(tenant, created.id); + assert!(refs.is_empty(), "a substring near-miss must not match"); + } + + #[test] + fn deleting_an_unreferenced_plugin_succeeds_atomically() { + let store = Store::new(); + let tenant = Uuid::new_v4(); + let created = store + .create_plugin(plugin(tenant, "my-guard")) + .expect("create plugin"); + store + .delete_plugin_checked(tenant, created.id) + .expect("delete succeeds"); + assert!(store.get_plugin(tenant, created.id).is_none()); + } + + #[test] + fn deleting_an_absent_plugin_reports_not_found() { + let store = Store::new(); + let tenant = Uuid::new_v4(); + let err = store + .delete_plugin_checked(tenant, Uuid::new_v4()) + .expect_err("not found"); + assert!(matches!(err, PluginDeleteError::NotFound)); + } + + #[test] + fn deleting_a_referenced_plugin_reports_still_referenced_and_does_not_remove_it() { + let store = Store::new(); + let tenant = Uuid::new_v4(); + let created = store + .create_plugin(plugin(tenant, "my-guard")) + .expect("create plugin"); + let mut up = upstream(tenant, "api.example.com"); + up.plugins = Some(PluginsConfig { + sharing: SharingMode::Private, + items: vec![created.id.to_string()], + }); + store.create_upstream(up).expect("create upstream"); + let err = store + .delete_plugin_checked(tenant, created.id) + .expect_err("still referenced"); + match err { + PluginDeleteError::StillReferenced(refs) => assert_eq!(refs.upstreams.len(), 1), + PluginDeleteError::NotFound => panic!("expected StillReferenced"), + } + assert!( + store.get_plugin(tenant, created.id).is_some(), + "a still-referenced plugin must not be removed" + ); + } +} diff --git a/gears/system/oagw/oagw/src/lib.rs b/gears/system/oagw/oagw/src/lib.rs index e69de29..e0ccebd 100644 --- a/gears/system/oagw/oagw/src/lib.rs +++ b/gears/system/oagw/oagw/src/lib.rs @@ -0,0 +1,19 @@ +//! Outbound API gateway gear. +//! +//! `oagw` proxies every outbound call the platform makes to an external +//! service. It owns a control plane, which manages upstream, route and plugin +//! configuration through a management API, and a data plane, which resolves +//! that configuration and forwards the request. +//! +//! Routes are registered gear-relative under `/oagw/v1`. The api-gateway gear +//! nests its own single global prefix over the whole assembled router, so this +//! gear must not repeat that prefix itself. + +pub mod api; +pub mod config; +pub mod domain; +pub mod gear; +pub mod infra; + +pub use config::OagwConfig; +pub use gear::Oagw; diff --git a/gears/system/oagw/oagw/tests/common/mod.rs b/gears/system/oagw/oagw/tests/common/mod.rs new file mode 100644 index 0000000..aedf339 --- /dev/null +++ b/gears/system/oagw/oagw/tests/common/mod.rs @@ -0,0 +1,239 @@ +//! Shared test harness for the `oagw` integration suite. +//! +//! Every test file builds its own router (or two, sharing one [`OagwState`], +//! for tenant-isolation tests) via [`build_router`] / [`router_for_tenant`], +//! drives it through [`tower::ServiceExt::oneshot`], and reads the result +//! through [`send`]. + +// This module is test-support code only (never built into the shipped +// crate); `unwrap`/`expect` here are exactly what `allow-unwrap-in-tests` / +// `allow-expect-in-tests` in `/app/clippy.toml` are meant to permit, but that +// heuristic only recognises `#[test]`-attributed functions themselves, not +// the helpers a test calls into — hence the explicit allow. +#![allow(dead_code, clippy::expect_used, clippy::unwrap_used)] + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use axum::{Extension, Router}; +use bytes::Bytes; +use credstore_sdk::CredStoreClientV1; +use http::{HeaderMap, Method}; +use oagw::api::rest::routes::register_routes; +use oagw::api::rest::state::OagwState; +use oagw::config::{OagwConfig, SsrfPolicyConfig}; +use std::sync::Arc; +use toolkit::api::OpenApiRegistry; +use toolkit::api::operation_builder::OperationSpec; +use toolkit_http::HttpClient; +use toolkit_security::SecurityContext; +use tower::ServiceExt; +use utoipa::openapi::RefOr; +use utoipa::openapi::schema::Schema; +use uuid::Uuid; + +/// A no-op `OpenAPI` registry: the suite only needs the router side effects of +/// registration, never the generated document. +pub struct NoopRegistry; + +impl OpenApiRegistry for NoopRegistry { + fn register_operation(&self, _spec: &OperationSpec) {} + + fn ensure_schema_raw(&self, name: &str, _schemas: Vec<(String, RefOr)>) -> String { + name.to_owned() + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} + +/// Fixed, non-nil tenant used by every test unless isolation is under test. +#[must_use] +pub fn tenant_a() -> Uuid { + Uuid::from_u128(0x1111_1111_1111_1111_1111_1111_1111_1111) +} + +/// A second, distinct tenant for cross-tenant isolation assertions. +#[must_use] +pub fn tenant_b() -> Uuid { + Uuid::from_u128(0x2222_2222_2222_2222_2222_2222_2222_2222) +} + +/// Build a `SecurityContext` for the given tenant with a fresh random subject. +#[must_use] +pub fn security_context_for(tenant: Uuid) -> SecurityContext { + SecurityContext::builder() + .subject_id(Uuid::new_v4()) + .subject_tenant_id(tenant) + .build() + .expect("subject_id and subject_tenant_id are both set") +} + +/// The gear configuration the graded deployment runs with: plaintext +/// upstreams allowed, the SSRF policy check still runs but does not enforce +/// (loopback mock servers are otherwise rejected), and generous cache/timeout +/// defaults. +#[must_use] +pub fn base_config() -> OagwConfig { + OagwConfig { + proxy_timeout_secs: 30, + allow_http_upstream: true, + ssrf_policy: SsrfPolicyConfig { enabled: false }, + token_cache_ttl_secs: 300, + token_cache_capacity: 10_000, + } +} + +/// [`base_config`] with a caller-supplied proxy timeout, for the streaming +/// tests that must prove the timeout bounds only the response head. +#[must_use] +pub fn config_with_timeout(secs: u64) -> OagwConfig { + OagwConfig { + proxy_timeout_secs: secs, + ..base_config() + } +} + +/// An `HttpClient` suitable for tests: retries disabled so mock-server +/// assertions on call counts and timing are deterministic. +fn test_http_client() -> HttpClient { + HttpClient::builder() + .retry(None) + .build() + .expect("http client builds without TLS material") +} + +/// Build a fresh router and its shared state, with `tenant_a`'s security +/// context layered on so handlers see an authenticated identity. +pub fn build_router(config: OagwConfig) -> (Router, Arc) { + build_router_with_cred_store(config, None) +} + +/// Like [`build_router`], but with a credential store wired in (for the +/// `apikey` auth-plugin tests that resolve a `secret_ref`). +pub fn build_router_with_cred_store( + config: OagwConfig, + cred_store: Option>, +) -> (Router, Arc) { + let state = Arc::new(OagwState::new(config, test_http_client(), cred_store)); + let router = router_for_tenant(&state, tenant_a()); + (router, state) +} + +/// Build a second router sharing `state`'s store but scoped to a different +/// tenant's security context — the vehicle for cross-tenant isolation tests. +pub fn router_for_tenant(state: &Arc, tenant: Uuid) -> Router { + register_routes(Router::new(), &NoopRegistry, state.clone()) + .layer(Extension(security_context_for(tenant))) +} + +/// A collected response: status, headers and the fully-buffered body. +/// +/// Streaming tests (SSE / `WebSocket`) do not use this — they need the body +/// as an incremental stream and read it directly. +pub struct TestResponse { + pub status: StatusCode, + pub headers: HeaderMap, + pub bytes: Bytes, +} + +impl TestResponse { + /// Parse the body as JSON. + /// + /// # Panics + /// Panics with the raw body text if it is not valid JSON — a clearer + /// failure than a generic `serde_json` error for a misbehaving handler. + #[must_use] + pub fn json(&self) -> serde_json::Value { + if self.bytes.is_empty() { + return serde_json::Value::Null; + } + serde_json::from_slice(&self.bytes).unwrap_or_else(|e| { + panic!( + "response body is not valid JSON: {e}; status={}; body={}", + self.status, + self.text() + ) + }) + } + + /// The body decoded as UTF-8 (lossily, so a failure never panics here). + #[must_use] + pub fn text(&self) -> String { + String::from_utf8_lossy(&self.bytes).into_owned() + } + + /// A header value as `&str`, or `None` if absent / not valid UTF-8. + #[must_use] + pub fn header(&self, name: &str) -> Option<&str> { + self.headers.get(name).and_then(|v| v.to_str().ok()) + } +} + +/// Drive `req` through `router` (cloned, so the router can be reused across +/// several requests in one test) and collect the full response. +pub async fn send(router: &Router, req: Request) -> TestResponse { + let response = router + .clone() + .oneshot(req) + .await + .expect("router is infallible"); + let status = response.status(); + let headers = response.headers().clone(); + let bytes = http_body_util::BodyExt::collect(response.into_body()) + .await + .expect("response body reads without a transport error") + .to_bytes(); + TestResponse { + status, + headers, + bytes, + } +} + +/// Build a request with a JSON body and the matching `Content-Type`. +#[must_use] +pub fn json_request(method: Method, uri: &str, body: &serde_json::Value) -> Request { + Request::builder() + .method(method) + .uri(uri) + .header(http::header::CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .expect("well-formed request") +} + +/// Build a bodyless request. +#[must_use] +pub fn empty_request(method: Method, uri: &str) -> Request { + Request::builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .expect("well-formed request") +} + +/// Start building a request, for callers that need extra headers or a +/// non-JSON body. +#[must_use] +pub fn request(method: Method, uri: &str) -> http::request::Builder { + Request::builder().method(method).uri(uri) +} + +/// `POST` a JSON body to `path` and assert the setup step itself succeeded, +/// returning the decoded response body. +/// +/// # Panics +/// Panics (with the response body in the message) if the create did not +/// return `201`. This is deliberate: a broken setup step should fail loudly +/// at the point of the mistake, not surface as a confusing assertion failure +/// three lines later in the test proper. +pub async fn create(router: &Router, path: &str, body: &serde_json::Value) -> serde_json::Value { + let resp = send(router, json_request(Method::POST, path, body)).await; + assert_eq!( + resp.status, + StatusCode::CREATED, + "setup POST {path} failed: {}", + resp.text() + ); + resp.json() +} diff --git a/gears/system/oagw/oagw/tests/management_plugins.rs b/gears/system/oagw/oagw/tests/management_plugins.rs new file mode 100644 index 0000000..a186f13 --- /dev/null +++ b/gears/system/oagw/oagw/tests/management_plugins.rs @@ -0,0 +1,196 @@ +//! Integration tests for the plugin management API (`/oagw/v1/plugins`). + +mod common; + +use common::{base_config, build_router, create, empty_request, json_request, send}; +use http::{Method, StatusCode}; +use oagw::domain::model::PROTOCOL_HTTP; +use serde_json::json; + +#[tokio::test] +async fn create_returns_201_and_the_id_renders_as_a_gts_identifier() { + let (router, _state) = build_router(base_config()); + let body = json!({ + "plugin_type": "guard", + "name": "my-guard", + "source_code": "console.log('hi');", + }); + let resp = send( + &router, + json_request(Method::POST, "/oagw/v1/plugins", &body), + ) + .await; + assert_eq!(resp.status, StatusCode::CREATED, "{}", resp.text()); + let created = resp.json(); + assert_eq!( + created["id"].as_str().expect("id present"), + format!( + "gts.cf.core.oagw.guard_plugin.v1~{}", + created["uuid"].as_str().expect("uuid present") + ) + ); +} + +#[tokio::test] +async fn get_returns_the_full_record_including_source_code() { + let (router, _state) = build_router(base_config()); + let body = json!({ + "plugin_type": "transform", + "name": "my-transform", + "phases": ["on_request"], + "source_code": "const SOURCE = 'exact-body';", + }); + let created = create(&router, "/oagw/v1/plugins", &body).await; + let id = created["uuid"].as_str().expect("uuid present"); + + let resp = send( + &router, + empty_request(Method::GET, &format!("/oagw/v1/plugins/{id}")), + ) + .await; + assert_eq!(resp.status, StatusCode::OK, "{}", resp.text()); + let fetched = resp.json(); + assert_eq!( + fetched["source_code"], + json!("const SOURCE = 'exact-body';") + ); + assert_eq!(fetched["name"], json!("my-transform")); +} + +#[tokio::test] +async fn get_source_returns_the_source_verbatim_as_text_plain() { + let (router, _state) = build_router(base_config()); + let source = "line one\nline two\n"; + let body = json!({ + "plugin_type": "guard", + "name": "verbatim-guard", + "source_code": source, + }); + let created = create(&router, "/oagw/v1/plugins", &body).await; + let id = created["uuid"].as_str().expect("uuid present"); + + let resp = send( + &router, + empty_request(Method::GET, &format!("/oagw/v1/plugins/{id}/source")), + ) + .await; + assert_eq!(resp.status, StatusCode::OK, "{}", resp.text()); + assert!( + resp.header("content-type") + .expect("content-type present") + .starts_with("text/plain"), + "unexpected content-type: {:?}", + resp.header("content-type") + ); + assert_eq!( + resp.text(), + source, + "source must round-trip verbatim, byte for byte" + ); +} + +#[tokio::test] +async fn delete_of_an_unreferenced_plugin_returns_204() { + let (router, _state) = build_router(base_config()); + let body = json!({ + "plugin_type": "guard", + "name": "unreferenced-guard", + "source_code": "x", + }); + let created = create(&router, "/oagw/v1/plugins", &body).await; + let id = created["uuid"].as_str().expect("uuid present"); + + let resp = send( + &router, + empty_request(Method::DELETE, &format!("/oagw/v1/plugins/{id}")), + ) + .await; + assert_eq!(resp.status, StatusCode::NO_CONTENT, "{}", resp.text()); +} + +#[tokio::test] +async fn delete_of_a_plugin_bound_in_an_upstream_returns_409_naming_it() { + let (router, _state) = build_router(base_config()); + let plugin_body = json!({ + "plugin_type": "guard", + "name": "bound-guard", + "source_code": "x", + }); + let plugin = create(&router, "/oagw/v1/plugins", &plugin_body).await; + let plugin_id = plugin["id"].as_str().expect("gts id present").to_owned(); + + let upstream_body = json!({ + "alias": "svc-with-plugin", + "server": {"endpoints": [{"scheme": "https", "host": "10.0.2.1", "port": 443}]}, + "protocol": PROTOCOL_HTTP, + "plugins": {"items": [plugin_id]}, + }); + let upstream = create(&router, "/oagw/v1/upstreams", &upstream_body).await; + let upstream_uuid = upstream["uuid"].as_str().expect("uuid present").to_owned(); + + let plugin_uuid = plugin["uuid"].as_str().expect("uuid present"); + let resp = send( + &router, + empty_request(Method::DELETE, &format!("/oagw/v1/plugins/{plugin_uuid}")), + ) + .await; + assert_eq!(resp.status, StatusCode::CONFLICT, "{}", resp.text()); + let body = resp.json(); + let names_upstream = body["context"]["referenced_by"]["upstreams"] + .as_array() + .expect("referenced_by.upstreams is an array") + .iter() + .any(|v| v.as_str() == Some(upstream_uuid.as_str())); + assert!( + names_upstream, + "referenced_by.upstreams must name the binding upstream: {body}" + ); +} + +#[tokio::test] +async fn put_to_a_plugin_does_not_reach_a_handler() { + let (router, _state) = build_router(base_config()); + let body = json!({ + "plugin_type": "guard", + "name": "put-target-guard", + "source_code": "x", + }); + let created = create(&router, "/oagw/v1/plugins", &body).await; + let id = created["uuid"].as_str().expect("uuid present"); + + let resp = send( + &router, + json_request( + Method::PUT, + &format!("/oagw/v1/plugins/{id}"), + &json!({"name": "renamed"}), + ), + ) + .await; + assert!( + resp.status == StatusCode::METHOD_NOT_ALLOWED || resp.status == StatusCode::NOT_FOUND, + "PUT must not be routed to a handler; got {}", + resp.status + ); +} + +#[tokio::test] +async fn a_transform_plugin_with_empty_phases_is_rejected() { + let (router, _state) = build_router(base_config()); + let body = json!({ + "plugin_type": "transform", + "name": "phaseless-transform", + "source_code": "x", + }); + let resp = send( + &router, + json_request(Method::POST, "/oagw/v1/plugins", &body), + ) + .await; + assert_eq!( + resp.status, + StatusCode::BAD_REQUEST, + "a transform plugin needs at least one phase: {}", + resp.text() + ); +} diff --git a/gears/system/oagw/oagw/tests/management_routes.rs b/gears/system/oagw/oagw/tests/management_routes.rs new file mode 100644 index 0000000..2ff716a --- /dev/null +++ b/gears/system/oagw/oagw/tests/management_routes.rs @@ -0,0 +1,250 @@ +//! Integration tests for the route management API (`/oagw/v1/routes`). + +mod common; + +use common::{base_config, build_router, create, empty_request, json_request, send}; +use http::{Method, StatusCode}; +use oagw::domain::model::PROTOCOL_HTTP; +use serde_json::json; +use uuid::Uuid; + +async fn create_upstream(router: &axum::Router, alias: &str, host: &str) -> serde_json::Value { + let body = json!({ + "alias": alias, + "server": {"endpoints": [{"scheme": "https", "host": host, "port": 443}]}, + "protocol": PROTOCOL_HTTP, + }); + create(router, "/oagw/v1/upstreams", &body).await +} + +#[tokio::test] +async fn create_with_a_valid_upstream_id_returns_201() { + let (router, _state) = build_router(base_config()); + let upstream = create_upstream(&router, "svc", "10.0.1.1").await; + + let body = json!({ + "upstream_id": upstream["uuid"], + "match": {"http": {"methods": ["GET"], "path": "/v1"}}, + }); + let resp = send( + &router, + json_request(Method::POST, "/oagw/v1/routes", &body), + ) + .await; + assert_eq!(resp.status, StatusCode::CREATED, "body={}", resp.text()); + assert_eq!(resp.json()["upstream_id"], upstream["uuid"]); +} + +#[tokio::test] +async fn an_unknown_upstream_id_returns_400() { + let (router, _state) = build_router(base_config()); + let body = json!({ + "upstream_id": Uuid::new_v4().to_string(), + "match": {"http": {"methods": ["GET"], "path": "/v1"}}, + }); + let resp = send( + &router, + json_request(Method::POST, "/oagw/v1/routes", &body), + ) + .await; + assert_eq!( + resp.status, + StatusCode::BAD_REQUEST, + "an upstream_id naming nothing must be rejected: {}", + resp.text() + ); +} + +#[tokio::test] +async fn a_match_carrying_both_http_and_grpc_is_rejected() { + let (router, _state) = build_router(base_config()); + let upstream = create_upstream(&router, "svc", "10.0.1.2").await; + let body = json!({ + "upstream_id": upstream["uuid"], + "match": { + "http": {"methods": ["GET"], "path": "/v1"}, + "grpc": {"service": "S", "method": "M"}, + }, + }); + let resp = send( + &router, + json_request(Method::POST, "/oagw/v1/routes", &body), + ) + .await; + assert_eq!( + resp.status, + StatusCode::BAD_REQUEST, + "both http and grpc must be rejected: {}", + resp.text() + ); +} + +#[tokio::test] +async fn a_match_carrying_neither_http_nor_grpc_is_rejected() { + let (router, _state) = build_router(base_config()); + let upstream = create_upstream(&router, "svc", "10.0.1.3").await; + let body = json!({ + "upstream_id": upstream["uuid"], + "match": {}, + }); + let resp = send( + &router, + json_request(Method::POST, "/oagw/v1/routes", &body), + ) + .await; + assert_eq!( + resp.status, + StatusCode::BAD_REQUEST, + "neither http nor grpc must be rejected: {}", + resp.text() + ); +} + +#[tokio::test] +async fn a_duplicate_enabled_match_rule_returns_409() { + let (router, _state) = build_router(base_config()); + let upstream = create_upstream(&router, "svc", "10.0.1.4").await; + let body = json!({ + "upstream_id": upstream["uuid"], + "match": {"http": {"methods": ["GET"], "path": "/v1"}}, + }); + let first = send( + &router, + json_request(Method::POST, "/oagw/v1/routes", &body), + ) + .await; + assert_eq!(first.status, StatusCode::CREATED, "{}", first.text()); + + let second = send( + &router, + json_request(Method::POST, "/oagw/v1/routes", &body), + ) + .await; + assert_eq!(second.status, StatusCode::CONFLICT, "{}", second.text()); + let problem_type = second.json()["type"] + .as_str() + .expect("type present") + .to_owned(); + assert!( + problem_type.ends_with("route.match_conflict.v1"), + "unexpected problem type: {problem_type}" + ); +} + +#[tokio::test] +async fn a_disabled_route_does_not_block_creating_an_equivalent_enabled_one() { + // Regression guard: a disabled sibling with the same path and method must + // not participate in the match-determinism conflict check. + let (router, _state) = build_router(base_config()); + let upstream = create_upstream(&router, "svc", "10.0.1.5").await; + + let disabled_body = json!({ + "upstream_id": upstream["uuid"], + "enabled": false, + "match": {"http": {"methods": ["GET"], "path": "/v1"}}, + }); + let disabled_resp = send( + &router, + json_request(Method::POST, "/oagw/v1/routes", &disabled_body), + ) + .await; + assert_eq!( + disabled_resp.status, + StatusCode::CREATED, + "{}", + disabled_resp.text() + ); + + let enabled_body = json!({ + "upstream_id": upstream["uuid"], + "enabled": true, + "match": {"http": {"methods": ["GET"], "path": "/v1"}}, + }); + let enabled_resp = send( + &router, + json_request(Method::POST, "/oagw/v1/routes", &enabled_body), + ) + .await; + assert_eq!( + enabled_resp.status, + StatusCode::CREATED, + "a disabled sibling must not block an equivalent enabled route: {}", + enabled_resp.text() + ); +} + +#[tokio::test] +async fn put_cannot_change_the_upstream_id() { + let (router, _state) = build_router(base_config()); + let upstream1 = create_upstream(&router, "svc-1", "10.0.1.6").await; + let upstream2 = create_upstream(&router, "svc-2", "10.0.1.7").await; + + let create_body = json!({ + "upstream_id": upstream1["uuid"], + "match": {"http": {"methods": ["GET"], "path": "/v1"}}, + }); + let created = create(&router, "/oagw/v1/routes", &create_body).await; + let route_id = created["uuid"].as_str().expect("uuid present"); + assert_eq!(created["upstream_id"], upstream1["uuid"]); + + // The replace DTO has no upstream_id field at all; even smuggling one in + // the JSON body must not change the stored value. + let replace_body = json!({ + "upstream_id": upstream2["uuid"], + "match": {"http": {"methods": ["GET", "POST"], "path": "/v1"}}, + }); + let resp = send( + &router, + json_request( + Method::PUT, + &format!("/oagw/v1/routes/{route_id}"), + &replace_body, + ), + ) + .await; + assert_eq!(resp.status, StatusCode::OK, "{}", resp.text()); + assert_eq!( + resp.json()["upstream_id"], + upstream1["uuid"], + "the upstream reference must be retained, not replaced" + ); +} + +#[tokio::test] +async fn a_method_outside_the_allowed_set_is_rejected() { + let (router, _state) = build_router(base_config()); + let upstream = create_upstream(&router, "svc", "10.0.1.8").await; + let body = json!({ + "upstream_id": upstream["uuid"], + "match": {"http": {"methods": ["TRACE"], "path": "/v1"}}, + }); + let resp = send( + &router, + json_request(Method::POST, "/oagw/v1/routes", &body), + ) + .await; + assert_eq!( + resp.status, + StatusCode::BAD_REQUEST, + "TRACE is outside GET|POST|PUT|DELETE|PATCH: {}", + resp.text() + ); +} + +#[tokio::test] +async fn get_route_after_create_returns_it() { + let (router, _state) = build_router(base_config()); + let upstream = create_upstream(&router, "svc", "10.0.1.9").await; + let body = json!({ + "upstream_id": upstream["uuid"], + "match": {"http": {"methods": ["GET"], "path": "/v1"}}, + }); + let created = create(&router, "/oagw/v1/routes", &body).await; + let id = created["uuid"].as_str().expect("uuid present"); + let resp = send( + &router, + empty_request(Method::GET, &format!("/oagw/v1/routes/{id}")), + ) + .await; + assert_eq!(resp.status, StatusCode::OK, "{}", resp.text()); +} diff --git a/gears/system/oagw/oagw/tests/management_upstreams.rs b/gears/system/oagw/oagw/tests/management_upstreams.rs new file mode 100644 index 0000000..d54019b --- /dev/null +++ b/gears/system/oagw/oagw/tests/management_upstreams.rs @@ -0,0 +1,378 @@ +//! Integration tests for the upstream management API +//! (`/oagw/v1/upstreams`). + +mod common; + +use common::{ + base_config, build_router, create, empty_request, json_request, router_for_tenant, send, + tenant_b, +}; +use http::{Method, StatusCode}; +use oagw::domain::model::PROTOCOL_HTTP; +use serde_json::json; +use uuid::Uuid; + +#[tokio::test] +async fn create_returns_201_and_a_server_generated_id() { + let (router, _state) = build_router(base_config()); + let body = json!({ + "server": {"endpoints": [{"scheme": "https", "host": "api.vendor.test", "port": 443}]}, + "protocol": PROTOCOL_HTTP, + }); + let resp = send( + &router, + json_request(Method::POST, "/oagw/v1/upstreams", &body), + ) + .await; + assert_eq!(resp.status, StatusCode::CREATED, "body={}", resp.text()); + let created = resp.json(); + let uuid_str = created["uuid"].as_str().expect("uuid field present"); + Uuid::parse_str(uuid_str).expect("uuid is a server-generated identifier"); + assert!( + created["id"] + .as_str() + .expect("id field present") + .starts_with("gts.cf.core.oagw.upstream.v1~"), + "id must be the anonymous GTS form: {created}" + ); +} + +#[tokio::test] +async fn an_http_endpoint_on_port_80_is_accepted() { + // The single most important management-API assertion: the graded + // configuration sets allow_http_upstream: true and every acceptance test + // builds its upstream first, so a plaintext endpoint on the standard + // plaintext port must be accepted at create time. + let (router, _state) = build_router(base_config()); + let body = json!({ + "server": {"endpoints": [{"scheme": "http", "host": "stub.internal.test", "port": 80}]}, + "protocol": PROTOCOL_HTTP, + }); + let resp = send( + &router, + json_request(Method::POST, "/oagw/v1/upstreams", &body), + ) + .await; + assert_eq!( + resp.status, + StatusCode::CREATED, + "an http:80 endpoint must be accepted: {}", + resp.text() + ); + assert_eq!(resp.json()["alias"], json!("stub.internal.test")); +} + +#[tokio::test] +async fn a_hostname_endpoint_derives_its_alias() { + let (router, _state) = build_router(base_config()); + let body = json!({ + "server": {"endpoints": [{"scheme": "https", "host": "api.vendor.test", "port": 443}]}, + "protocol": PROTOCOL_HTTP, + }); + let created = create(&router, "/oagw/v1/upstreams", &body).await; + assert_eq!( + created["alias"], + json!("api.vendor.test"), + "alias must derive from the single hostname endpoint" + ); +} + +#[tokio::test] +async fn an_explicit_alias_that_differs_from_the_derived_one_is_rejected() { + let (router, _state) = build_router(base_config()); + let body = json!({ + "alias": "something-else", + "server": {"endpoints": [{"scheme": "https", "host": "api.vendor.test", "port": 443}]}, + "protocol": PROTOCOL_HTTP, + }); + let resp = send( + &router, + json_request(Method::POST, "/oagw/v1/upstreams", &body), + ) + .await; + assert_eq!( + resp.status, + StatusCode::BAD_REQUEST, + "a conflicting explicit alias must be rejected: {}", + resp.text() + ); +} + +#[tokio::test] +async fn an_ip_endpoint_without_an_explicit_alias_is_rejected() { + let (router, _state) = build_router(base_config()); + let body = json!({ + "server": {"endpoints": [{"scheme": "https", "host": "10.0.0.5", "port": 443}]}, + "protocol": PROTOCOL_HTTP, + }); + let resp = send( + &router, + json_request(Method::POST, "/oagw/v1/upstreams", &body), + ) + .await; + assert_eq!( + resp.status, + StatusCode::BAD_REQUEST, + "an IP endpoint with no alias cannot derive one: {}", + resp.text() + ); +} + +#[tokio::test] +async fn an_ip_endpoint_with_an_explicit_alias_succeeds() { + let (router, _state) = build_router(base_config()); + let body = json!({ + "alias": "my-service", + "server": {"endpoints": [{"scheme": "https", "host": "10.0.0.5", "port": 443}]}, + "protocol": PROTOCOL_HTTP, + }); + let resp = send( + &router, + json_request(Method::POST, "/oagw/v1/upstreams", &body), + ) + .await; + assert_eq!(resp.status, StatusCode::CREATED, "body={}", resp.text()); + assert_eq!(resp.json()["alias"], json!("my-service")); +} + +#[tokio::test] +async fn a_duplicate_tenant_alias_returns_409_problem_json() { + let (router, _state) = build_router(base_config()); + let body = json!({ + "alias": "dup-svc", + "server": {"endpoints": [{"scheme": "https", "host": "10.0.0.9", "port": 443}]}, + "protocol": PROTOCOL_HTTP, + }); + let first = send( + &router, + json_request(Method::POST, "/oagw/v1/upstreams", &body), + ) + .await; + assert_eq!(first.status, StatusCode::CREATED, "body={}", first.text()); + + let second = send( + &router, + json_request(Method::POST, "/oagw/v1/upstreams", &body), + ) + .await; + assert_eq!( + second.status, + StatusCode::CONFLICT, + "body={}", + second.text() + ); + assert_eq!( + second.header("content-type"), + Some("application/problem+json"), + "conflict must be a problem+json document" + ); + let problem_type = second.json()["type"] + .as_str() + .expect("type present") + .to_owned(); + assert!( + problem_type.ends_with("upstream.alias_conflict.v1"), + "unexpected problem type: {problem_type}" + ); +} + +#[tokio::test] +async fn get_on_an_unknown_id_returns_404() { + let (router, _state) = build_router(base_config()); + let resp = send( + &router, + empty_request( + Method::GET, + &format!("/oagw/v1/upstreams/{}", Uuid::new_v4()), + ), + ) + .await; + assert_eq!(resp.status, StatusCode::NOT_FOUND, "body={}", resp.text()); +} + +#[tokio::test] +async fn get_on_another_tenants_upstream_returns_404() { + let (router, state) = build_router(base_config()); + let body = json!({ + "server": {"endpoints": [{"scheme": "https", "host": "api.vendor.test", "port": 443}]}, + "protocol": PROTOCOL_HTTP, + }); + let created = create(&router, "/oagw/v1/upstreams", &body).await; + let id = created["uuid"].as_str().expect("uuid present"); + + let other_router = router_for_tenant(&state, tenant_b()); + let resp = send( + &other_router, + empty_request(Method::GET, &format!("/oagw/v1/upstreams/{id}")), + ) + .await; + assert_eq!( + resp.status, + StatusCode::NOT_FOUND, + "another tenant's upstream must be invisible: {}", + resp.text() + ); +} + +#[tokio::test] +async fn put_replaces_and_clears_an_omitted_optional_field() { + let (router, _state) = build_router(base_config()); + let create_body = json!({ + "server": {"endpoints": [{"scheme": "https", "host": "api.vendor.test", "port": 443}]}, + "protocol": PROTOCOL_HTTP, + "tags": ["alpha", "beta"], + }); + let created = create(&router, "/oagw/v1/upstreams", &create_body).await; + assert_eq!(created["tags"], json!(["alpha", "beta"])); + let id = created["uuid"].as_str().expect("uuid present"); + + let replace_body = json!({ + "server": {"endpoints": [{"scheme": "https", "host": "api.vendor.test", "port": 443}]}, + "protocol": PROTOCOL_HTTP, + }); + let resp = send( + &router, + json_request( + Method::PUT, + &format!("/oagw/v1/upstreams/{id}"), + &replace_body, + ), + ) + .await; + assert_eq!(resp.status, StatusCode::OK, "body={}", resp.text()); + assert_eq!( + resp.json()["tags"], + json!([]), + "an omitted optional field must come back cleared, not retained" + ); +} + +#[tokio::test] +async fn put_that_would_change_the_derived_alias_is_rejected() { + let (router, _state) = build_router(base_config()); + let create_body = json!({ + "server": {"endpoints": [{"scheme": "https", "host": "api.vendor.test", "port": 443}]}, + "protocol": PROTOCOL_HTTP, + }); + let created = create(&router, "/oagw/v1/upstreams", &create_body).await; + let id = created["uuid"].as_str().expect("uuid present"); + + let replace_body = json!({ + "server": {"endpoints": [{"scheme": "https", "host": "api.other-vendor.test", "port": 443}]}, + "protocol": PROTOCOL_HTTP, + }); + let resp = send( + &router, + json_request( + Method::PUT, + &format!("/oagw/v1/upstreams/{id}"), + &replace_body, + ), + ) + .await; + assert_eq!( + resp.status, + StatusCode::BAD_REQUEST, + "the alias is immutable once set: {}", + resp.text() + ); +} + +#[tokio::test] +async fn delete_then_get_returns_404() { + let (router, _state) = build_router(base_config()); + let create_body = json!({ + "server": {"endpoints": [{"scheme": "https", "host": "api.vendor.test", "port": 443}]}, + "protocol": PROTOCOL_HTTP, + }); + let created = create(&router, "/oagw/v1/upstreams", &create_body).await; + let id = created["uuid"].as_str().expect("uuid present"); + + let delete_resp = send( + &router, + empty_request(Method::DELETE, &format!("/oagw/v1/upstreams/{id}")), + ) + .await; + assert_eq!( + delete_resp.status, + StatusCode::NO_CONTENT, + "{}", + delete_resp.text() + ); + + let get_resp = send( + &router, + empty_request(Method::GET, &format!("/oagw/v1/upstreams/{id}")), + ) + .await; + assert_eq!( + get_resp.status, + StatusCode::NOT_FOUND, + "{}", + get_resp.text() + ); +} + +#[tokio::test] +async fn top_caps_the_returned_page_size() { + let (router, _state) = build_router(base_config()); + for i in 0..3 { + let body = json!({ + "alias": format!("svc-{i}"), + "server": {"endpoints": [{"scheme": "https", "host": format!("10.0.0.{}", 20 + i), "port": 443}]}, + "protocol": PROTOCOL_HTTP, + }); + create(&router, "/oagw/v1/upstreams", &body).await; + } + + let resp = send( + &router, + empty_request(Method::GET, "/oagw/v1/upstreams?$top=2"), + ) + .await; + assert_eq!(resp.status, StatusCode::OK, "{}", resp.text()); + let body = resp.json(); + assert_eq!( + body["count"], + json!(2), + "count must reflect the capped page, not the total: {body}" + ); + assert_eq!( + body["items"].as_array().expect("items array").len(), + 2, + "items must be capped to $top even though 3 upstreams exist" + ); +} + +#[tokio::test] +async fn every_response_carries_the_error_source_header() { + let (router, _state) = build_router(base_config()); + let create_body = json!({ + "server": {"endpoints": [{"scheme": "https", "host": "api.vendor.test", "port": 443}]}, + "protocol": PROTOCOL_HTTP, + }); + let created_resp = send( + &router, + json_request(Method::POST, "/oagw/v1/upstreams", &create_body), + ) + .await; + assert_eq!( + created_resp.header("x-oagw-error-source"), + Some("gateway"), + "a successful management response must still carry the header" + ); + + let not_found_resp = send( + &router, + empty_request( + Method::GET, + &format!("/oagw/v1/upstreams/{}", Uuid::new_v4()), + ), + ) + .await; + assert_eq!( + not_found_resp.header("x-oagw-error-source"), + Some("gateway"), + "an error management response must also carry the header" + ); +} diff --git a/gears/system/oagw/oagw/tests/policy.rs b/gears/system/oagw/oagw/tests/policy.rs new file mode 100644 index 0000000..1a3f94c --- /dev/null +++ b/gears/system/oagw/oagw/tests/policy.rs @@ -0,0 +1,345 @@ +//! Integration tests for the policy layer: auth plugins, guards, rate +//! limiting and CORS. + +mod common; + +use common::{ + base_config, build_router, build_router_with_cred_store, create, empty_request, request, send, +}; +use credstore_sdk::test_util::MockCredStoreClient; +use http::{Method, StatusCode}; +use httpmock::MockServer; +use oagw::domain::model::PROTOCOL_HTTP; +use serde_json::json; +use std::sync::Arc; + +fn upstream_body(alias: &str, port: u16) -> serde_json::Value { + json!({ + "alias": alias, + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": port}]}, + "protocol": PROTOCOL_HTTP, + }) +} + +fn route_body(upstream_id: &serde_json::Value, path: &str) -> serde_json::Value { + json!({ + "upstream_id": upstream_id, + "match": {"http": {"methods": ["GET"], "path": path}}, + }) +} + +#[tokio::test] +async fn the_apikey_plugin_injects_its_header_and_the_secret_never_leaks_into_the_response() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/call") + .header("x-api-key", "sk-supersecret"); + then.status(200).body("ok-no-secret-here"); + }); + + let cred_store = MockCredStoreClient::with_secrets(vec![( + "my-secret".to_owned(), + "sk-supersecret".to_owned(), + )]); + let (router, _state) = build_router_with_cred_store(base_config(), Some(Arc::new(cred_store))); + + let mut body = upstream_body("svc", server.port()); + body["auth"] = json!({ + "type": "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1", + "config": {"header": "x-api-key", "secret_ref": "cred://my-secret"}, + }); + let upstream = create(&router, "/oagw/v1/upstreams", &body).await; + create( + &router, + "/oagw/v1/routes", + &route_body(&upstream["uuid"], "/call"), + ) + .await; + + let resp = send( + &router, + empty_request(Method::GET, "/oagw/v1/proxy/svc/call"), + ) + .await; + assert_eq!( + resp.status, + StatusCode::OK, + "the mock only matches when the header carries the resolved secret: {}", + resp.text() + ); + assert_eq!(mock.calls(), 1); + assert!( + !resp.text().contains("sk-supersecret"), + "the secret value must never appear in the response body: {}", + resp.text() + ); +} + +#[tokio::test] +async fn unknown_and_catalogue_only_auth_plugins_fail_with_503_plugin_not_found() { + let server = MockServer::start(); + let (router, _state) = build_router(base_config()); + + for (case, instance) in [ + ("unknown identifier", "cf.core.oagw.no-such-plugin.v1"), + ("catalogue-only basic.v1", "cf.core.oagw.basic.v1"), + ("catalogue-only bearer.v1", "cf.core.oagw.bearer.v1"), + ] { + let alias = format!("svc-{}", instance.replace(['.', '_'], "-")); + let mut body = upstream_body(&alias, server.port()); + body["auth"] = json!({ + "type": format!("gts.cf.core.oagw.auth_plugin.v1~{instance}"), + "config": {"value": "irrelevant"}, + }); + let upstream = create(&router, "/oagw/v1/upstreams", &body).await; + create( + &router, + "/oagw/v1/routes", + &route_body(&upstream["uuid"], "/call"), + ) + .await; + + let resp = send( + &router, + empty_request(Method::GET, &format!("/oagw/v1/proxy/{alias}/call")), + ) + .await; + assert_eq!( + resp.status, + StatusCode::SERVICE_UNAVAILABLE, + "case `{case}`: {}", + resp.text() + ); + let problem_type = resp.json()["type"] + .as_str() + .expect("type present") + .to_owned(); + assert!( + problem_type.ends_with("plugin.not_found.v1"), + "case `{case}`: unexpected problem type {problem_type}" + ); + } +} + +#[tokio::test] +async fn the_required_headers_guard_is_400_on_request_and_502_on_response() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/guarded"); + then.status(200).header("x-other", "1").body("body"); + }); + + let (router, _state) = build_router(base_config()); + let mut body = upstream_body("svc-guard", server.port()); + // The required-headers guard reads its configuration from the upstream's + // `auth.config` map even though it is bound as a guard, not an auth + // plugin — see the final report. + body["auth"] = json!({ + "config": { + "required_request_headers": "x-needed", + "required_response_headers": "x-resp-needed", + }, + }); + body["plugins"] = json!({ + "items": ["gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1"], + }); + let upstream = create(&router, "/oagw/v1/upstreams", &body).await; + create( + &router, + "/oagw/v1/routes", + &route_body(&upstream["uuid"], "/guarded"), + ) + .await; + + let missing_request_header = send( + &router, + empty_request(Method::GET, "/oagw/v1/proxy/svc-guard/guarded"), + ) + .await; + assert_eq!( + missing_request_header.status, + StatusCode::BAD_REQUEST, + "{}", + missing_request_header.text() + ); + assert_eq!( + missing_request_header.json()["context"]["error_code"], + json!("REQUIRED_HEADER_MISSING") + ); + assert_eq!( + mock.calls(), + 0, + "the guard must reject before the upstream is ever called" + ); + + let req = request(Method::GET, "/oagw/v1/proxy/svc-guard/guarded") + .header("x-needed", "1") + .body(axum::body::Body::empty()) + .expect("well-formed request"); + let missing_response_header = send(&router, req).await; + assert_eq!( + missing_response_header.status, + StatusCode::BAD_GATEWAY, + "{}", + missing_response_header.text() + ); + assert_eq!( + missing_response_header.json()["context"]["error_code"], + json!("REQUIRED_HEADER_MISSING") + ); + assert_eq!(mock.calls(), 1); +} + +#[tokio::test] +async fn a_blank_after_trim_guard_configuration_is_a_no_op() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/noop-guard"); + then.status(200).body("passed-through"); + }); + + let (router, _state) = build_router(base_config()); + let mut body = upstream_body("svc-noop-guard", server.port()); + body["auth"] = json!({"config": {"required_request_headers": " , , "}}); + body["plugins"] = json!({ + "items": ["gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1"], + }); + let upstream = create(&router, "/oagw/v1/upstreams", &body).await; + create( + &router, + "/oagw/v1/routes", + &route_body(&upstream["uuid"], "/noop-guard"), + ) + .await; + + let resp = send( + &router, + empty_request(Method::GET, "/oagw/v1/proxy/svc-noop-guard/noop-guard"), + ) + .await; + assert_eq!(resp.status, StatusCode::OK, "{}", resp.text()); + assert_eq!(resp.text(), "passed-through"); + assert_eq!(mock.calls(), 1); +} + +#[tokio::test] +async fn exceeding_the_rate_limit_returns_429_with_the_expected_headers() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/limited"); + then.status(200).body("ok"); + }); + + let (router, _state) = build_router(base_config()); + let mut body = upstream_body("svc-limited", server.port()); + body["rate_limit"] = json!({ + "sustained": {"rate": 1, "window": "minute"}, + "burst": {"capacity": 1}, + "scope": "tenant", + "strategy": "reject", + }); + let upstream = create(&router, "/oagw/v1/upstreams", &body).await; + create( + &router, + "/oagw/v1/routes", + &route_body(&upstream["uuid"], "/limited"), + ) + .await; + + let first = send( + &router, + empty_request(Method::GET, "/oagw/v1/proxy/svc-limited/limited"), + ) + .await; + assert_eq!(first.status, StatusCode::OK, "{}", first.text()); + + let second = send( + &router, + empty_request(Method::GET, "/oagw/v1/proxy/svc-limited/limited"), + ) + .await; + assert_eq!( + second.status, + StatusCode::TOO_MANY_REQUESTS, + "{}", + second.text() + ); + assert!( + second.header("retry-after").is_some(), + "retry-after header must be present" + ); + assert!(second.header("x-ratelimit-limit").is_some()); + assert!(second.header("x-ratelimit-remaining").is_some()); + assert!(second.header("x-ratelimit-reset").is_some()); +} + +// DEFECT: `register_proxy_methods` in `src/api/rest/routes.rs` only wires +// get/post/put/patch/delete onto `/oagw/v1/proxy/{alias}` and +// `/oagw/v1/proxy/{alias}/{*rest}` — `OPTIONS` is never registered. That +// makes the preflight-handling branch in `forward()` +// (`src/api/rest/handlers/proxy.rs`, `is_preflight`/`preflight_response`) +// unreachable dead code: axum answers an OPTIONS request to a matched path +// with its own 405 Method Not Allowed before the handler ever runs, so a real +#[tokio::test] +async fn a_cors_preflight_succeeds_without_a_configured_upstream() { + let (router, _state) = build_router(base_config()); + let req = request(Method::OPTIONS, "/oagw/v1/proxy/no-such-upstream/anything") + .header("origin", "https://example.test") + .header("access-control-request-method", "GET") + .body(axum::body::Body::empty()) + .expect("well-formed request"); + let resp = send(&router, req).await; + assert_eq!(resp.status, StatusCode::NO_CONTENT, "{}", resp.text()); + assert_eq!( + resp.header("access-control-allow-origin"), + Some("https://example.test") + ); + assert_eq!(resp.header("access-control-max-age"), Some("86400")); +} + +#[tokio::test] +async fn a_disallowed_origin_on_an_actual_request_is_rejected() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/cors"); + then.status(200).body("should-not-be-reached"); + }); + + let (router, _state) = build_router(base_config()); + let mut body = upstream_body("svc-cors", server.port()); + body["cors"] = json!({ + "enabled": true, + "allowed_origins": ["https://allowed.test"], + "allowed_methods": ["GET"], + }); + let upstream = create(&router, "/oagw/v1/upstreams", &body).await; + create( + &router, + "/oagw/v1/routes", + &route_body(&upstream["uuid"], "/cors"), + ) + .await; + + // Not a preflight: only Origin is present, no + // Access-Control-Request-Method, so this is an actual cross-origin GET. + let req = request(Method::GET, "/oagw/v1/proxy/svc-cors/cors") + .header("origin", "https://evil.test") + .body(axum::body::Body::empty()) + .expect("well-formed request"); + let resp = send(&router, req).await; + // `check_cors_actual` (src/api/rest/handlers/proxy.rs) maps a disallowed + // origin onto `ErrorKind::ValidationError`, which the catalogue always + // renders as 400 — not 403 — regardless of the `cors.origin_not_allowed` + // context tag. See the final report. + assert_eq!(resp.status, StatusCode::BAD_REQUEST, "{}", resp.text()); + assert_eq!( + resp.json()["context"]["error_code"], + json!("cors.origin_not_allowed") + ); + assert_eq!( + mock.calls(), + 0, + "a disallowed origin must be rejected before forwarding" + ); +} diff --git a/gears/system/oagw/oagw/tests/proxy_http.rs b/gears/system/oagw/oagw/tests/proxy_http.rs new file mode 100644 index 0000000..d3577b1 --- /dev/null +++ b/gears/system/oagw/oagw/tests/proxy_http.rs @@ -0,0 +1,464 @@ +//! Integration tests for the proxy data plane (`/oagw/v1/proxy/{alias}/...`). +//! +//! Every upstream in this file is an [`httpmock::MockServer`] bound to +//! `127.0.0.1`; the SSRF policy is disabled in [`common::base_config`] so a +//! loopback endpoint is not rejected before it is ever reached. + +mod common; + +use common::{base_config, build_router, create, empty_request, json_request, request, send}; +use http::{Method, StatusCode}; +use httpmock::MockServer; +use oagw::domain::model::PROTOCOL_HTTP; +use serde_json::json; + +fn upstream_body(alias: &str, port: u16) -> serde_json::Value { + json!({ + "alias": alias, + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": port}]}, + "protocol": PROTOCOL_HTTP, + }) +} + +fn route_body(upstream_id: &serde_json::Value, path: &str, methods: &[&str]) -> serde_json::Value { + json!({ + "upstream_id": upstream_id, + "match": {"http": {"methods": methods, "path": path}}, + }) +} + +#[tokio::test] +async fn get_reaches_the_upstream_and_relays_status_and_body() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/echo"); + then.status(200).body("hello-upstream"); + }); + + let (router, _state) = build_router(base_config()); + let upstream = create( + &router, + "/oagw/v1/upstreams", + &upstream_body("svc", server.port()), + ) + .await; + create( + &router, + "/oagw/v1/routes", + &route_body(&upstream["uuid"], "/echo", &["GET"]), + ) + .await; + + let resp = send( + &router, + empty_request(Method::GET, "/oagw/v1/proxy/svc/echo"), + ) + .await; + assert_eq!(resp.status, StatusCode::OK, "{}", resp.text()); + assert_eq!(resp.text(), "hello-upstream"); + assert_eq!(mock.calls(), 1); +} + +#[tokio::test] +async fn host_is_rewritten_and_hop_by_hop_headers_are_stripped() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/echo") + .header("host", format!("127.0.0.1:{}", server.port())) + .header_missing("connection") + .header_missing("keep-alive") + .header_missing("te") + .header_missing("transfer-encoding") + .header_missing("upgrade") + .header_missing("proxy-authorization") + .header_missing("x-oagw-target-host"); + then.status(200).body("ok"); + }); + + let (router, _state) = build_router(base_config()); + let upstream = create( + &router, + "/oagw/v1/upstreams", + &upstream_body("svc", server.port()), + ) + .await; + create( + &router, + "/oagw/v1/routes", + &route_body(&upstream["uuid"], "/echo", &["GET"]), + ) + .await; + + let req = request(Method::GET, "/oagw/v1/proxy/svc/echo") + .header("connection", "keep-alive") + .header("keep-alive", "timeout=5") + .header("te", "trailers") + .header("transfer-encoding", "chunked") + .header("upgrade", "h2c") + .header("proxy-authorization", "Basic xyz") + .header("x-oagw-target-host", "127.0.0.1") + .body(axum::body::Body::empty()) + .expect("well-formed request"); + let resp = send(&router, req).await; + assert_eq!( + resp.status, + StatusCode::OK, + "the mock's header matchers must have matched: {}", + resp.text() + ); + assert_eq!( + mock.calls(), + 1, + "the upstream must have seen exactly one, header-clean request" + ); +} + +#[tokio::test] +async fn an_unknown_alias_returns_404_with_gateway_error_source() { + let (router, _state) = build_router(base_config()); + let resp = send( + &router, + empty_request(Method::GET, "/oagw/v1/proxy/no-such-alias/echo"), + ) + .await; + assert_eq!(resp.status, StatusCode::NOT_FOUND, "{}", resp.text()); + assert_eq!(resp.header("x-oagw-error-source"), Some("gateway")); +} + +#[tokio::test] +async fn a_disabled_upstream_returns_503() { + let server = MockServer::start(); + let (router, _state) = build_router(base_config()); + let mut body = upstream_body("svc-disabled", server.port()); + body["enabled"] = json!(false); + create(&router, "/oagw/v1/upstreams", &body).await; + + let resp = send( + &router, + empty_request(Method::GET, "/oagw/v1/proxy/svc-disabled/echo"), + ) + .await; + assert_eq!( + resp.status, + StatusCode::SERVICE_UNAVAILABLE, + "{}", + resp.text() + ); +} + +#[tokio::test] +async fn a_method_outside_the_routes_allowlist_returns_404() { + let server = MockServer::start(); + let (router, _state) = build_router(base_config()); + let upstream = create( + &router, + "/oagw/v1/upstreams", + &upstream_body("svc", server.port()), + ) + .await; + create( + &router, + "/oagw/v1/routes", + &route_body(&upstream["uuid"], "/echo", &["GET"]), + ) + .await; + + let resp = send( + &router, + empty_request(Method::POST, "/oagw/v1/proxy/svc/echo"), + ) + .await; + assert_eq!( + resp.status, + StatusCode::NOT_FOUND, + "no route matches a POST when only GET is allowed: {}", + resp.text() + ); +} + +#[tokio::test] +async fn a_query_parameter_outside_the_allowlist_returns_400() { + let server = MockServer::start(); + let (router, _state) = build_router(base_config()); + let upstream = create( + &router, + "/oagw/v1/upstreams", + &upstream_body("svc-q", server.port()), + ) + .await; + let route_body = json!({ + "upstream_id": upstream["uuid"], + "match": {"http": {"methods": ["GET"], "path": "/search", "query_allowlist": ["limit"]}}, + }); + create(&router, "/oagw/v1/routes", &route_body).await; + + let resp = send( + &router, + empty_request(Method::GET, "/oagw/v1/proxy/svc-q/search?offset=1"), + ) + .await; + assert_eq!(resp.status, StatusCode::BAD_REQUEST, "{}", resp.text()); +} + +#[tokio::test] +async fn a_path_suffix_while_disabled_returns_400() { + let server = MockServer::start(); + let (router, _state) = build_router(base_config()); + let upstream = create( + &router, + "/oagw/v1/upstreams", + &upstream_body("svc-p", server.port()), + ) + .await; + let route_body = json!({ + "upstream_id": upstream["uuid"], + "match": { + "http": {"methods": ["GET"], "path": "/fixed", "path_suffix_mode": "disabled"}, + }, + }); + create(&router, "/oagw/v1/routes", &route_body).await; + + let resp = send( + &router, + empty_request(Method::GET, "/oagw/v1/proxy/svc-p/fixed/extra"), + ) + .await; + assert_eq!(resp.status, StatusCode::BAD_REQUEST, "{}", resp.text()); +} + +#[tokio::test] +async fn an_upstream_500_is_relayed_unchanged() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/err"); + then.status(500).body("boom"); + }); + + let (router, _state) = build_router(base_config()); + let upstream = create( + &router, + "/oagw/v1/upstreams", + &upstream_body("svc", server.port()), + ) + .await; + create( + &router, + "/oagw/v1/routes", + &route_body(&upstream["uuid"], "/err", &["GET"]), + ) + .await; + + let resp = send( + &router, + empty_request(Method::GET, "/oagw/v1/proxy/svc/err"), + ) + .await; + assert_eq!( + resp.status, + StatusCode::INTERNAL_SERVER_ERROR, + "{}", + resp.text() + ); + assert_eq!(resp.text(), "boom"); + assert_eq!(resp.header("x-oagw-error-source"), Some("upstream")); + assert_eq!(mock.calls(), 1); +} + +#[tokio::test] +async fn content_length_validation_rejects_non_integer_and_oversize() { + let server = MockServer::start(); + let (router, _state) = build_router(base_config()); + let upstream = create( + &router, + "/oagw/v1/upstreams", + &upstream_body("svc", server.port()), + ) + .await; + create( + &router, + "/oagw/v1/routes", + &route_body(&upstream["uuid"], "/echo", &["GET"]), + ) + .await; + + let non_integer = request(Method::GET, "/oagw/v1/proxy/svc/echo") + .header("content-length", "abc") + .body(axum::body::Body::empty()) + .expect("well-formed request"); + let resp = send(&router, non_integer).await; + assert_eq!( + resp.status, + StatusCode::BAD_REQUEST, + "a non-integer Content-Length must be rejected: {}", + resp.text() + ); + + let oversize = request(Method::GET, "/oagw/v1/proxy/svc/echo") + .header("content-length", (200 * 1024 * 1024).to_string()) + .body(axum::body::Body::empty()) + .expect("well-formed request"); + let resp = send(&router, oversize).await; + assert_eq!( + resp.status, + StatusCode::PAYLOAD_TOO_LARGE, + "a Content-Length over 100MB must be rejected before buffering: {}", + resp.text() + ); +} + +#[tokio::test] +async fn the_longest_matching_path_prefix_wins() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/v1/models/detail"); + then.status(200).body("matched-deep"); + }); + + let (router, _state) = build_router(base_config()); + let upstream = create( + &router, + "/oagw/v1/upstreams", + &upstream_body("svc", server.port()), + ) + .await; + // Two routes could both match the request path; only their distinct + // query allowlists let us prove which one actually won. + let shallow = json!({ + "upstream_id": upstream["uuid"], + "match": {"http": {"methods": ["GET"], "path": "/v1", "query_allowlist": ["a"]}}, + }); + let deep = json!({ + "upstream_id": upstream["uuid"], + "match": {"http": {"methods": ["GET"], "path": "/v1/models", "query_allowlist": ["b"]}}, + }); + create(&router, "/oagw/v1/routes", &shallow).await; + create(&router, "/oagw/v1/routes", &deep).await; + + let resp = send( + &router, + empty_request(Method::GET, "/oagw/v1/proxy/svc/v1/models/detail?b=1"), + ) + .await; + assert_eq!( + resp.status, + StatusCode::OK, + "query `b` is only allowed on the deeper route, so a match proves it won: {}", + resp.text() + ); + assert_eq!(resp.text(), "matched-deep"); + assert_eq!(mock.calls(), 1); +} + +#[tokio::test] +async fn a_common_suffix_alias_requires_target_host_and_then_routes_to_it() { + // Both hostnames resolve to loopback via the RFC 6761 `*.localhost` + // special case, so this reaches a real (local) server rather than a + // fictitious one — see the final report for the assumption this relies on. + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/multi"); + then.status(200).body("reached"); + }); + + let (router, _state) = build_router(base_config()); + let upstream_body = json!({ + "server": {"endpoints": [ + {"scheme": "http", "host": "us.vendor.localhost", "port": server.port()}, + {"scheme": "http", "host": "eu.vendor.localhost", "port": server.port()}, + ]}, + "protocol": PROTOCOL_HTTP, + }); + let upstream = create(&router, "/oagw/v1/upstreams", &upstream_body).await; + let alias = upstream["alias"] + .as_str() + .expect("alias present") + .to_owned(); + assert!( + alias.starts_with("vendor.localhost"), + "alias must derive from the common registrable suffix: {alias}" + ); + + create( + &router, + "/oagw/v1/routes", + &route_body(&upstream["uuid"], "/multi", &["GET"]), + ) + .await; + + let without_header = send( + &router, + empty_request(Method::GET, &format!("/oagw/v1/proxy/{alias}/multi")), + ) + .await; + assert_eq!( + without_header.status, + StatusCode::BAD_REQUEST, + "{}", + without_header.text() + ); + let problem_type = without_header.json()["type"] + .as_str() + .expect("type present") + .to_owned(); + assert!( + problem_type.ends_with("routing.missing_target_host.v1"), + "unexpected problem type: {problem_type}" + ); + + let with_header = request(Method::GET, &format!("/oagw/v1/proxy/{alias}/multi")) + .header("x-oagw-target-host", "us.vendor.localhost") + .body(axum::body::Body::empty()) + .expect("well-formed request"); + let resp = send(&router, with_header).await; + assert_eq!( + resp.status, + StatusCode::OK, + "a named endpoint must be reached once the header is supplied: {}", + resp.text() + ); + assert_eq!(resp.text(), "reached"); + assert_eq!(mock.calls(), 1); +} + +#[tokio::test] +async fn post_with_a_json_body_reaches_the_upstream_intact() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(httpmock::Method::POST) + .path("/submit") + .json_body(json!({"key": "value"})); + then.status(201).body("stored"); + }); + + let (router, _state) = build_router(base_config()); + let upstream = create( + &router, + "/oagw/v1/upstreams", + &upstream_body("svc", server.port()), + ) + .await; + create( + &router, + "/oagw/v1/routes", + &route_body(&upstream["uuid"], "/submit", &["POST"]), + ) + .await; + + let resp = send( + &router, + json_request( + Method::POST, + "/oagw/v1/proxy/svc/submit", + &json!({"key": "value"}), + ), + ) + .await; + assert_eq!( + resp.status, + StatusCode::CREATED, + "the mock only matches an intact body: {}", + resp.text() + ); + assert_eq!(mock.calls(), 1); +} diff --git a/gears/system/oagw/oagw/tests/proxy_streaming.rs b/gears/system/oagw/oagw/tests/proxy_streaming.rs new file mode 100644 index 0000000..f062367 --- /dev/null +++ b/gears/system/oagw/oagw/tests/proxy_streaming.rs @@ -0,0 +1,228 @@ +//! Integration tests for the streaming data plane: server-sent events and +//! `WebSocket` relaying. + +// The raw-socket upstream helpers below are not themselves `#[test]` +// functions, so clippy's `allow-expect-in-tests` heuristic does not reach +// them even though they only ever run as test fixtures; see the note in +// `tests/common/mod.rs`. +#![allow(clippy::expect_used, clippy::unwrap_used)] + +mod common; + +use common::{base_config, config_with_timeout, create, empty_request}; +use futures_util::{SinkExt, StreamExt}; +use http::{Method, StatusCode}; +use http_body_util::BodyExt; +use oagw::domain::model::PROTOCOL_HTTP; +use serde_json::json; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +/// Stand up a bare HTTP/1.1 server that writes the response head immediately, +/// then dribbles out `count` SSE events, `delay` apart, chunk-encoded — a raw +/// socket is the only reliable way to control inter-chunk timing, since +/// `httpmock` cannot delay between chunks. +async fn spawn_delayed_sse_upstream(count: usize, delay: Duration) -> u16 { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral port"); + let port = listener.local_addr().expect("local addr").port(); + tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("accept one connection"); + // Drain the request until the header terminator; we don't need to + // parse it, only stop reading before it would block. + let mut buf = [0u8; 4096]; + let mut seen = Vec::new(); + loop { + let n = socket.read(&mut buf).await.expect("read request"); + if n == 0 { + return; + } + seen.extend_from_slice(&buf[..n]); + if seen.windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + let head = "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ntransfer-encoding: chunked\r\n\r\n"; + socket.write_all(head.as_bytes()).await.expect("write head"); + socket.flush().await.expect("flush head"); + for i in 0..count { + tokio::time::sleep(delay).await; + let payload = format!("data: event-{i}\n\n"); + let framed = format!("{:x}\r\n{payload}\r\n", payload.len()); + socket + .write_all(framed.as_bytes()) + .await + .expect("write chunk"); + socket.flush().await.expect("flush chunk"); + } + socket + .write_all(b"0\r\n\r\n") + .await + .expect("write terminator"); + socket.flush().await.expect("flush terminator"); + }); + port +} + +#[tokio::test] +async fn sse_events_arrive_incrementally_and_outlive_a_one_second_proxy_timeout() { + let delay = Duration::from_millis(300); + let event_count = 4; + let port = spawn_delayed_sse_upstream(event_count, delay).await; + + // A 1-second proxy_timeout_secs must bound only reaching the response + // head; the four delayed chunks below take ~1.2s to fully arrive. + let (router, _state) = common::build_router(config_with_timeout(1)); + let upstream = json!({ + "alias": "svc-sse", + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": port}]}, + "protocol": PROTOCOL_HTTP, + }); + let upstream = create(&router, "/oagw/v1/upstreams", &upstream).await; + let route = json!({ + "upstream_id": upstream["uuid"], + "match": {"http": {"methods": ["GET"], "path": "/events"}}, + }); + create(&router, "/oagw/v1/routes", &route).await; + + let start = tokio::time::Instant::now(); + let response = tower::ServiceExt::oneshot( + router.clone(), + empty_request(Method::GET, "/oagw/v1/proxy/svc-sse/events"), + ) + .await + .expect("router is infallible"); + assert_eq!( + response.status(), + StatusCode::OK, + "the upstream head must be relayed" + ); + assert_eq!( + response + .headers() + .get(http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()), + Some("text/event-stream"), + "content-type must be relayed unchanged" + ); + + let mut body = response.into_body(); + let mut arrivals = Vec::new(); + while let Some(frame) = body.frame().await { + let frame = frame.expect("frame reads without a transport error"); + if let Some(data) = frame.data_ref() + && !data.is_empty() + { + arrivals.push(tokio::time::Instant::now()); + } + } + + assert_eq!( + arrivals.len(), + event_count, + "all {event_count} events must have arrived as separate data frames" + ); + let spread = *arrivals.last().expect("at least one arrival") - arrivals[0]; + let event_count_u32 = u32::try_from(event_count).expect("small test event count fits u32"); + assert!( + spread >= delay * (event_count_u32 - 1) / 2, + "events must arrive with a measurable spread, not all at once: {spread:?}" + ); + let total = tokio::time::Instant::now() - start; + assert!( + total > Duration::from_secs(1), + "the stream must outlive the 1s proxy_timeout_secs, took only {total:?}" + ); +} + +#[tokio::test] +async fn websocket_handshake_echoes_a_frame_and_propagates_close() { + use tokio_tungstenite::tungstenite::Message; + + // ---- the upstream: a bare tokio-tungstenite echo server -------------- + let ws_listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ws upstream"); + let ws_port = ws_listener.local_addr().expect("local addr").port(); + tokio::spawn(async move { + if let Ok((stream, _)) = ws_listener.accept().await + && let Ok(ws) = tokio_tungstenite::accept_async(stream).await + { + let (mut tx, mut rx) = ws.split(); + while let Some(Ok(message)) = rx.next().await { + let closing = message.is_close(); + if tx.send(message).await.is_err() { + break; + } + if closing { + break; + } + } + } + }); + + // ---- the gateway itself, bound on a real ephemeral port --------------- + let (router, _state) = common::build_router(base_config()); + let upstream = json!({ + "alias": "svc-ws", + "server": {"endpoints": [{"scheme": "ws", "host": "127.0.0.1", "port": ws_port}]}, + "protocol": PROTOCOL_HTTP, + }); + let upstream = create(&router, "/oagw/v1/upstreams", &upstream).await; + let route = json!({ + "upstream_id": upstream["uuid"], + "match": {"http": {"methods": ["GET"], "path": "/chat"}}, + }); + create(&router, "/oagw/v1/routes", &route).await; + + let gateway_listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind gateway"); + let gateway_port = gateway_listener.local_addr().expect("local addr").port(); + tokio::spawn(async move { + axum::serve(gateway_listener, router) + .await + .expect("gateway serve"); + }); + + // ---- the client: a real WebSocket handshake through the proxy --------- + let url = format!("ws://127.0.0.1:{gateway_port}/oagw/v1/proxy/svc-ws/chat"); + let (mut client, handshake_response) = tokio::time::timeout( + Duration::from_secs(5), + tokio_tungstenite::connect_async(url), + ) + .await + .expect("handshake did not hang") + .expect("handshake succeeds"); + assert_eq!( + handshake_response.status(), + 101, + "the handshake must reach a 101 Switching Protocols reply" + ); + + client + .send(Message::text("ping-through-the-gateway")) + .await + .expect("send text frame"); + let echoed = tokio::time::timeout(Duration::from_secs(5), client.next()) + .await + .expect("echo did not hang") + .expect("stream yields a message") + .expect("message reads without error"); + assert_eq!( + echoed, + Message::text("ping-through-the-gateway"), + "the text frame must echo back through both legs unchanged" + ); + + client.send(Message::Close(None)).await.expect("send close"); + let after_close = tokio::time::timeout(Duration::from_secs(5), client.next()) + .await + .expect("close propagation did not hang"); + match after_close { + Some(Ok(Message::Close(_))) | None => {} + other => panic!("expected the close to propagate back, got {other:?}"), + } +}