From 4510991ea28101577ba04ba98626a8cdb5e73021 Mon Sep 17 00:00:00 2001 From: Kseniia Alekseitseva Date: Fri, 11 Sep 2026 04:20:42 +0000 Subject: [PATCH] B8-oagw-gateway__claude__claude-opus-5__effort-high__fabric-gears-design-to-code-topup2/B8-oagw-gateway__LcQYhvT --- Cargo.lock | 1 + gears/system/oagw/docs/DECOMPOSITION.md | 489 ++++++++++++++ .../oagw/docs/features/gear-foundation.md | 363 ++++++++++ .../oagw/docs/features/plugin-management.md | 370 +++++++++++ gears/system/oagw/docs/features/proxy-http.md | 471 +++++++++++++ .../oagw/docs/features/proxy-streaming.md | 361 ++++++++++ .../oagw/docs/features/route-management.md | 416 ++++++++++++ .../oagw/docs/features/traffic-policy.md | 563 ++++++++++++++++ .../oagw/docs/features/upstream-management.md | 538 +++++++++++++++ gears/system/oagw/oagw/Cargo.toml | 12 +- gears/system/oagw/oagw/src/api/mod.rs | 3 + gears/system/oagw/oagw/src/api/rest/dto.rs | 253 +++++++ gears/system/oagw/oagw/src/api/rest/error.rs | 239 +++++++ .../system/oagw/oagw/src/api/rest/handlers.rs | 513 ++++++++++++++ gears/system/oagw/oagw/src/api/rest/mod.rs | 13 + gears/system/oagw/oagw/src/api/rest/proxy.rs | 585 ++++++++++++++++ gears/system/oagw/oagw/src/api/rest/routes.rs | 275 ++++++++ .../oagw/oagw/src/api/rest/routes_tests.rs | 564 ++++++++++++++++ gears/system/oagw/oagw/src/config.rs | 114 ++++ gears/system/oagw/oagw/src/domain/alias.rs | 210 ++++++ gears/system/oagw/oagw/src/domain/cors.rs | 243 +++++++ gears/system/oagw/oagw/src/domain/error.rs | 184 +++++ gears/system/oagw/oagw/src/domain/mod.rs | 12 + gears/system/oagw/oagw/src/domain/model.rs | 627 ++++++++++++++++++ gears/system/oagw/oagw/src/domain/plugins.rs | 367 ++++++++++ .../system/oagw/oagw/src/domain/ratelimit.rs | 266 ++++++++ gears/system/oagw/oagw/src/domain/routing.rs | 415 ++++++++++++ gears/system/oagw/oagw/src/domain/store.rs | 457 +++++++++++++ gears/system/oagw/oagw/src/domain/tenant.rs | 62 ++ gears/system/oagw/oagw/src/domain/validate.rs | 390 +++++++++++ gears/system/oagw/oagw/src/gear.rs | 138 ++++ gears/system/oagw/oagw/src/infra/body.rs | 226 +++++++ gears/system/oagw/oagw/src/infra/connect.rs | 142 ++++ gears/system/oagw/oagw/src/infra/headers.rs | 311 +++++++++ gears/system/oagw/oagw/src/infra/mod.rs | 5 + gears/system/oagw/oagw/src/lib.rs | 35 + gears/system/oagw/oagw/tests/common/mod.rs | 424 ++++++++++++ .../oagw/oagw/tests/proxy_acceptance.rs | 564 ++++++++++++++++ gears/system/oagw/oagw/tests/review_fixes.rs | 507 ++++++++++++++ 39 files changed, 11723 insertions(+), 5 deletions(-) create mode 100644 gears/system/oagw/docs/DECOMPOSITION.md create mode 100644 gears/system/oagw/docs/features/gear-foundation.md create mode 100644 gears/system/oagw/docs/features/plugin-management.md create mode 100644 gears/system/oagw/docs/features/proxy-http.md create mode 100644 gears/system/oagw/docs/features/proxy-streaming.md create mode 100644 gears/system/oagw/docs/features/route-management.md create mode 100644 gears/system/oagw/docs/features/traffic-policy.md create mode 100644 gears/system/oagw/docs/features/upstream-management.md create mode 100644 gears/system/oagw/oagw/src/api/mod.rs create mode 100644 gears/system/oagw/oagw/src/api/rest/dto.rs create mode 100644 gears/system/oagw/oagw/src/api/rest/error.rs create mode 100644 gears/system/oagw/oagw/src/api/rest/handlers.rs create mode 100644 gears/system/oagw/oagw/src/api/rest/mod.rs create mode 100644 gears/system/oagw/oagw/src/api/rest/proxy.rs create mode 100644 gears/system/oagw/oagw/src/api/rest/routes.rs create mode 100644 gears/system/oagw/oagw/src/api/rest/routes_tests.rs create mode 100644 gears/system/oagw/oagw/src/config.rs create mode 100644 gears/system/oagw/oagw/src/domain/alias.rs create mode 100644 gears/system/oagw/oagw/src/domain/cors.rs create mode 100644 gears/system/oagw/oagw/src/domain/error.rs create mode 100644 gears/system/oagw/oagw/src/domain/mod.rs create mode 100644 gears/system/oagw/oagw/src/domain/model.rs create mode 100644 gears/system/oagw/oagw/src/domain/plugins.rs create mode 100644 gears/system/oagw/oagw/src/domain/ratelimit.rs create mode 100644 gears/system/oagw/oagw/src/domain/routing.rs create mode 100644 gears/system/oagw/oagw/src/domain/store.rs create mode 100644 gears/system/oagw/oagw/src/domain/tenant.rs create mode 100644 gears/system/oagw/oagw/src/domain/validate.rs create mode 100644 gears/system/oagw/oagw/src/gear.rs create mode 100644 gears/system/oagw/oagw/src/infra/body.rs create mode 100644 gears/system/oagw/oagw/src/infra/connect.rs create mode 100644 gears/system/oagw/oagw/src/infra/headers.rs create mode 100644 gears/system/oagw/oagw/src/infra/mod.rs create mode 100644 gears/system/oagw/oagw/tests/common/mod.rs create mode 100644 gears/system/oagw/oagw/tests/proxy_acceptance.rs create mode 100644 gears/system/oagw/oagw/tests/review_fixes.rs diff --git a/Cargo.lock b/Cargo.lock index 9c02857..2604eae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1591,6 +1591,7 @@ dependencies = [ "psl", "rcgen", "rustls", + "rustls-native-certs 0.8.3", "rustls-pki-types", "serde", "serde_json", diff --git a/gears/system/oagw/docs/DECOMPOSITION.md b/gears/system/oagw/docs/DECOMPOSITION.md new file mode 100644 index 0000000..a692158 --- /dev/null +++ b/gears/system/oagw/docs/DECOMPOSITION.md @@ -0,0 +1,489 @@ +# Decomposition: Outbound API Gateway (OAGW) + +**Overall implementation status:** +- [ ] `p1` - **ID**: `cpt-cf-oagw-status-decomposition` + + + +- [1. Overview](#1-overview) +- [2. Entries](#2-entries) + - [2.1 Gear Foundation and Configuration - HIGH](#21-gear-foundation-and-configuration---high) + - [2.2 Upstream Management - HIGH](#22-upstream-management---high) + - [2.3 Route Management - HIGH](#23-route-management---high) + - [2.4 Plugin Catalog and Bindings - MEDIUM](#24-plugin-catalog-and-bindings---medium) + - [2.5 HTTP Request Proxying - HIGH](#25-http-request-proxying---high) + - [2.6 Streaming and Upgrade Proxying - HIGH](#26-streaming-and-upgrade-proxying---high) + - [2.7 Traffic Policy Enforcement - MEDIUM](#27-traffic-policy-enforcement---medium) +- [3. Feature Dependencies](#3-feature-dependencies) + + + +## 1. Overview + +This decomposition splits the OAGW gear into seven features, ordered by dependency: Gear Foundation and Configuration, Upstream Management, Route Management, Plugin Catalog and Bindings, HTTP Request Proxying, Streaming and Upgrade Proxying, and Traffic Policy Enforcement. The ordering follows the natural build-up of the gear: gear wiring and configuration first, then the control-plane resources that a request needs to resolve (upstreams, then routes, then plugins), then the data-plane path that consumes those resources (plain HTTP proxying, then streaming/upgrade proxying built on top of it), and finally the cross-cutting policy layer (rate limiting, CORS, guard plugins, auth injection) that wraps the proxy path. Every functional and non-functional requirement in PRD.md and every design principle and constraint in DESIGN.md is allocated to at least one entry below; entries that own a requirement only partially (because part of it is deliberately deferred) name the deferred part explicitly in their Out of scope list rather than dropping the ID. HTTP Request Proxying is intentionally the largest entry by scope-bullet count: it is the sole owner of the entire data-plane request path (alias resolution, route matching, endpoint selection, configuration merge, header transformation, body/timeout enforcement, outbound connection handling, and error-source distinction), and both Streaming and Upgrade Proxying and Traffic Policy Enforcement build directly on top of that path rather than duplicating any part of it. + +Three task-level overrides apply to every entry in this document and take precedence over the literal text of PRD.md and DESIGN.md, which are frozen inputs and are not edited to match: + +1. **Gear-relative routes.** PRD.md and DESIGN.md tabulate management and proxy paths as `/api/oagw/v1/...`. That absolute form only holds behind an operator gateway whose `prefix_path` is `/api`; in this workspace the api-gateway nests the assembled router once under its own `prefix_path`, and the graded `config/e2e-local.yaml` leaves `prefix_path` empty. Every API bullet in this document is therefore written gear-relative, as `/oagw/v1/...` (for example `POST /oagw/v1/upstreams`, `{METHOD} /oagw/v1/proxy/{alias}/{path}`), with no `/api` prefix. +2. **`http` and `ws` are legal endpoint schemes at the validation layer.** `config/e2e-local.yaml` sets `oagw.config.allow_http_upstream: true`. DESIGN.md's `cpt-cf-oagw-constraint-https-only` describes the default posture (HTTPS-only, plaintext upstreams blocked); the flag lifts that default for this configuration. This document treats scheme acceptance and connection enforcement as two separate questions: which schemes the upstream `scheme` field accepts (owned by Upstream Management) is independent of whether a plaintext connection is actually made (owned by HTTP Request Proxying and gated by `allow_http_upstream`). A validation layer that only accepted the TLS family would reject a legal request at create time under this configuration. The frozen `upstream.v1.schema.json` declares `scheme` as `enum: [https, wss, wt, grpc]`; under this configuration the accepted set is widened to include the plaintext counterparts `http` and `ws`. The schema is not edited to match — the widening is a deliberate task-level override recorded here. +3. **No database in the graded configuration.** The `gears.oagw` block of `config/e2e-local.yaml` has a `config:` section and no `database:` section, so the gear runs without a database. `cpt-cf-oagw-db-schema` describes the persistent-deployment table plan (`oagw_upstream`, `oagw_route`, `oagw_plugin`, and their binding/tag tables); in the graded build the same entities, invariants, and uniqueness constraints (for example `UNIQUE(tenant_id, alias)`) are realized as in-process memory state instead of database rows. Every entry's Data bullet either cites `cpt-cf-oagw-db-schema` with this in-memory note, or states `None` where the entry genuinely carries no persisted or in-memory domain state of its own. + +Additionally, the graded configuration serves gear-level keys `proxy_timeout_secs` (2 seconds), `allow_http_upstream` (true), and `ssrf_policy.enabled` (false), plus `token_cache_ttl_secs` and `token_cache_capacity` from ADR-0008. `proxy_timeout_secs` and `ssrf_policy.enabled` are supplied by the graded deployment configuration `config/e2e-local.yaml` itself and have no upstream PRD/DESIGN/ADR identifier of their own; `token_cache_ttl_secs` and `token_cache_capacity` trace to ADR-0008. All five are surfaced as part of the typed configuration owned by Gear Foundation and Configuration and consumed by the entries that act on them (HTTP Request Proxying for `proxy_timeout_secs`, `allow_http_upstream`, and `ssrf_policy.enabled`; Traffic Policy Enforcement for the OAuth2 token cache settings). + +## 2. Entries + +### 2.1 [Gear Foundation and Configuration](feature-gear-foundation/) - HIGH + +- [ ] `p1` - **ID**: `cpt-cf-oagw-feature-gear-foundation` + +- **Purpose**: Establishes OAGW as a registered ToolKit gear with typed configuration, shared gear state, canonical mapping of domain errors to RFC 9457 problem+json responses, and a mounted router. Every other feature in this decomposition executes inside the wiring, configuration surface, and error-mapping conventions this entry establishes. + +- **Implementation Approach**: single phase + +- **Depends On**: None + +- **Scope**: + - Gear registration and single-executable deployment wiring + - The gear's typed configuration surface, including the keys actually served in the graded configuration (`proxy_timeout_secs`, `allow_http_upstream`, `ssrf_policy.enabled`) and the OAuth2 token cache settings from ADR-0008 (`token_cache_ttl_secs`, `token_cache_capacity`) + - Shared gear state accessible to the Control Plane and Data Plane internals + - Canonical mapping of domain errors to RFC 9457 `application/problem+json` responses, including the full error-code taxonomy that every other feature's error paths rely on + - Mounting the gear's router under the gear-relative `/oagw/v1` path (see Overview override 1); no `/api` prefix is applied by this gear itself + +- **Out of scope**: + - Persistent database schema and multi-SQL backend behavior beyond the constraint definition — the graded configuration runs without a database (see Overview override 3); persisted-deployment schema ownership is deferred to the entries that own the persisted entities + - Feature-specific business logic (upstream/route/plugin CRUD, proxy execution, policy enforcement) — covered by the other six entries + +- **Requirements Covered**: + + - [ ] `p1` - `cpt-cf-oagw-fr-error-codes` + +- **Design Principles Covered**: + + - `cpt-cf-oagw-principle-rfc9457` + +- **Design Constraints Covered**: + + - `cpt-cf-oagw-constraint-toolkit-deploy` + - `cpt-cf-oagw-constraint-multi-sql` + +- **Domain Model Entities**: + - None (this entry establishes gear wiring and configuration; Upstream, Route, and Plugin are introduced by the entries that own them) + +- **Design Components**: + + - `cpt-cf-oagw-component-model` + +- **API**: + - None (internal gear registration and router mounting; no dedicated management or proxy endpoint of its own) + +- **Sequences**: + - None + +- **Data**: + - None (no persisted or in-memory domain state is owned at this layer) + +### 2.2 [Upstream Management](feature-upstream-management/) - HIGH + +- [ ] `p1` - **ID**: `cpt-cf-oagw-feature-upstream-management` + +- **Purpose**: Provides CRUD management of upstream configurations — the fundamental tenant-scoped unit that every proxy request targets — including schema validation, alias derivation and resolution rules, enable/disable semantics, and hierarchical (bind/override) tenant scoping. + +- **Implementation Approach**: single phase + +- **Depends On**: `cpt-cf-oagw-feature-gear-foundation` + +- **Scope**: + - CRUD operations for upstream configuration (server endpoints, protocol, auth config reference, headers, rate limits, CORS, plugin bindings, tags) + - Schema validation of upstream payloads, including endpoint `scheme` acceptance: per Overview override 2, the `scheme` field validation accepts `http` and `ws` in addition to the TLS-family schemes, independent of whether a plaintext connection is actually made at proxy time — a deliberate widening beyond the frozen `upstream.v1.schema.json` enum (`[https, wss, wt, grpc]`) + - Alias derivation rules (hostname auto-derivation, common-suffix derivation with public-suffix validation, explicit-alias requirement for IP-based or non-derivable endpoints), alias normalization, and alias immutability-on-update rules + - Enable/disable (`enabled`) semantics for upstreams, including ancestor-disables-descendant propagation + - Tenant scoping of upstream resources, including bind-style creation against an ancestor's alias and the sharing-mode permission checks (`enforce`/`private`/`inherit`) that govern it + +- **Out of scope**: + - Alias resolution performed at proxy request time (tenant-hierarchy shadowing search from descendant to root) — covered by HTTP Request Proxying, which consumes the alias and derivation rules defined here + - Actual plaintext (`http`/`ws`) connection establishment — gated by `allow_http_upstream` and covered by HTTP Request Proxying + - Enable/disable semantics for routes - covered by Route Management + - Persisted database storage — the graded configuration keeps upstream state in process memory (see Overview override 3); the entities, invariants, and uniqueness constraints match `cpt-cf-oagw-db-schema`'s persisted-deployment plan + +- **Requirements Covered**: + + - [ ] `p1` - `cpt-cf-oagw-fr-upstream-mgmt` + - [ ] `p1` - `cpt-cf-oagw-fr-enable-disable` + - [x] `p2` - `cpt-cf-oagw-fr-alias-resolution` + - [x] `p2` - `cpt-cf-oagw-fr-hierarchical-config` + - [ ] `p1` - `cpt-cf-oagw-nfr-multi-tenancy` + +- **Design Principles Covered**: + + - `cpt-cf-oagw-principle-tenant-scope` + +- **Design Constraints Covered**: + + - `cpt-cf-oagw-constraint-https-only` + +- **Domain Model Entities**: + - Upstream + - ServerConfig + - Endpoint + - AuthConfig + - HeadersConfig + - RateLimitConfig + - CorsConfig + - PluginsConfig + +- **Design Components**: + + - `cpt-cf-oagw-component-model` + +- **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 (no dedicated sequence diagram covers management CRUD flow; only the proxy request flow is diagrammed in DESIGN.md) + +- **Data**: + + - `cpt-cf-oagw-db-schema` + + Realized as in-process memory state in the graded configuration (Overview override 3): the `oagw_upstream` and `oagw_upstream_tag` entities and the `UNIQUE(tenant_id, alias)` invariant are held in memory rather than in a database table. + +### 2.3 [Route Management](feature-route-management/) - HIGH + +- [ ] `p1` - **ID**: `cpt-cf-oagw-feature-route-management` + +- **Purpose**: Provides CRUD management of routes, which define the HTTP match rules (method, path, query allowlist) that map inbound proxy requests to specific upstream behaviors, and enforces the match-uniqueness invariant that keeps route matching deterministic. + +- **Implementation Approach**: single phase + +- **Depends On**: `cpt-cf-oagw-feature-gear-foundation`, `cpt-cf-oagw-feature-upstream-management` + +- **Scope**: + - CRUD operations for route configuration (upstream linkage, HTTP match rules, priority, route-level rate limit/CORS/plugin overrides, tags) + - HTTP match rule validation: method allowlist, path pattern, query parameter allowlist, path-suffix mode + - Match-uniqueness invariant: no two enabled routes under the same upstream may share `(path_prefix, priority)` for the same method + - Upstream linkage validation: `upstream_id` must reference an upstream owned by the calling tenant; `upstream_id` is immutable after creation + - Enable/disable (`enabled`) semantics for routes, including exclusion of disabled routes from route matching + +- **Out of scope**: + - gRPC match rules (`service`/`method` matching) — the gRPC match schema exists for future use, but PRD.md places gRPC proxying in a later phase and DESIGN.md states no gRPC proxy code path is implemented or reachable; this entry covers HTTP match rule CRUD and validation only, and gRPC match persistence is deferred alongside gRPC proxying itself + - Route matching performed at proxy request time (longest-prefix match against an inbound request) — covered by HTTP Request Proxying, which consumes the match rules defined here + - Enable/disable semantics for upstreams - covered by Upstream Management + - Persisted database storage — the graded configuration keeps route state in process memory (see Overview override 3) + +- **Requirements Covered**: + + - [ ] `p1` - `cpt-cf-oagw-fr-route-mgmt` + - [ ] `p1` - `cpt-cf-oagw-fr-enable-disable` + +- **Design Principles Covered**: + + - `cpt-cf-oagw-principle-tenant-scope` + +- **Design Constraints Covered**: + - None (no design constraint uniquely governs route management beyond those already covered by Gear Foundation and Configuration and Upstream Management) + +- **Domain Model Entities**: + - Route + - MatchConfig + - RateLimitConfig + - CorsConfig + - PluginsConfig + +- **Design Components**: + + - `cpt-cf-oagw-component-model` + +- **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 (no dedicated sequence diagram covers management CRUD flow; only the proxy request flow is diagrammed in DESIGN.md) + +- **Data**: + + - `cpt-cf-oagw-db-schema` + + Realized as in-process memory state in the graded configuration (Overview override 3): the `oagw_route`, `oagw_route_http_match`, `oagw_route_method`, and `oagw_route_tag` entities are held in memory rather than in database tables; `oagw_route_grpc_match` is deferred with gRPC support. + +### 2.4 [Plugin Catalog and Bindings](feature-plugin-management/) - MEDIUM + +- [ ] `p2` - **ID**: `cpt-cf-oagw-feature-plugin-management` + +- **Purpose**: Maintains the catalog of built-in Auth, Guard, and Transform plugins, provides CRUD management of immutable custom plugin definitions, and validates plugin bindings on upstreams and routes, including the in-use conflict rule on delete. + +- **Implementation Approach**: single phase + +- **Depends On**: `cpt-cf-oagw-feature-gear-foundation`, `cpt-cf-oagw-feature-upstream-management`, `cpt-cf-oagw-feature-route-management` + +- **Scope**: + - Built-in plugin catalog: Auth (`noop`, `apikey`, `oauth2_client_cred`, `oauth2_client_cred_basic`, plus the catalog-only `basic`/`bearer` identifiers with no backing implementation), Guard (`required_headers`, plus the catalog-only `timeout`/`cors` identifiers that are core Data Plane logic, not plugin-bindable), and Transform (`request_id`, plus the catalog-only `logging`/`metrics` identifiers that are core instrumentation, not plugin-bindable) + - Custom (tenant-defined) plugin definition CRUD: creation, listing, retrieval (including source retrieval), and deletion; plugin definitions are immutable after creation — updates are performed by creating a new plugin and re-binding references + - Plugin binding validation on upstream and route `plugins.items`: identifier resolution (named vs. UUID-backed), schema-type matching, and ordered-position bookkeeping + - In-use conflict handling: deletion of a plugin that is still referenced by an upstream or route binding is rejected + +- **Out of scope**: + - Sandboxed runtime execution of custom Starlark plugins (network/file I/O denial, timeout and memory enforcement during execution) — this entry covers catalog CRUD, definition storage, and binding validation only; sandboxed execution is deferred to the data-plane plugin-chain execution work that consumes these bindings, which is not yet part of any of this decomposition's entries + - Garbage collection of unlinked plugins after TTL — described in DESIGN.md as a periodic job, deferred as an operational concern beyond this entry's CRUD and binding-validation scope + - Execution of the plugin chain against a live proxy request (Auth -> Guards -> Transform ordering) — covered by Traffic Policy Enforcement, which invokes the plugins this entry catalogs and validates + +- **Requirements Covered**: + + - [ ] `p2` - `cpt-cf-oagw-fr-plugin-system` + - [ ] `p2` - `cpt-cf-oagw-fr-builtin-plugins` + - [ ] `p3` - `cpt-cf-oagw-nfr-starlark-sandbox` + +- **Design Principles Covered**: + + - `cpt-cf-oagw-principle-plugin-immutable` + +- **Design Constraints Covered**: + - None (no design constraint uniquely governs plugin cataloging and binding beyond those already covered elsewhere) + +- **Domain Model Entities**: + - Plugin + +- **Design Components**: + + - `cpt-cf-oagw-component-model` + +- **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 (no dedicated sequence diagram covers management CRUD flow; only the proxy request flow is diagrammed in DESIGN.md) + +- **Data**: + + - `cpt-cf-oagw-db-schema` + + Realized as in-process memory state in the graded configuration (Overview override 3): the `oagw_plugin`, `oagw_upstream_plugin`, and `oagw_route_plugin` entities are held in memory rather than in database tables. + +### 2.5 [HTTP Request Proxying](feature-proxy-http/) - HIGH + +- [ ] `p1` - **ID**: `cpt-cf-oagw-feature-proxy-http` + +- **Purpose**: Executes the data-plane path for plain HTTP proxy requests: resolves the upstream by alias, matches the route, selects an endpoint, merges effective configuration, transforms headers, enforces body and timeout limits, forwards the request, and distinguishes gateway from upstream errors on the way back. + +- **Implementation Approach**: two milestones, reflecting this entry's larger scope (see Overview): milestone 1 covers alias resolution, route matching, endpoint selection, and effective configuration merge (request routing and resolution); milestone 2 covers header transformation, body/timeout enforcement, outbound connection handling, and error-source distinction (request execution and error handling) + +- **Depends On**: `cpt-cf-oagw-feature-upstream-management`, `cpt-cf-oagw-feature-route-management` + +- **Scope**: + - Alias resolution at request time: tenant-hierarchy search from descendant to root, closest-match shadowing, with enforced ancestor limits still applying across shadowing + - Route matching against the inbound request (method allowlist, longest path-prefix match, query allowlist validation) + - Endpoint selection within a multi-endpoint pool and `X-OAGW-Target-Host` handling (reading the header for routing, then stripping it) across HTTP/1.1 `Host` and HTTP/2 `:authority` + - Effective configuration merge in priority order Upstream (base) < Route < Tenant, and hierarchical sharing-mode application (`private`/`inherit`/`enforce`) at request time + - Header transformation: routing headers (consumed and stripped), hop-by-hop headers (stripped per HTTP spec), passthrough headers (forwarded per configuration), and simple set/add/remove rules from `upstream.headers` + - Body validation and the 100MB hard body-size limit, enforced before buffering + - Request/connection timeout enforcement using `proxy_timeout_secs` + - Honoring `allow_http_upstream` and `ssrf_policy.enabled` when establishing the outbound connection (Overview override 2: scheme acceptance is Upstream Management's concern; whether a plaintext connection is actually made is this entry's concern) + - Error-source distinction (`X-OAGW-Error-Source: gateway|upstream`) and RFC 9457 problem+json formatting for gateway-originated errors + - Request logging with correlation IDs and metrics emission for the proxy request path + +- **Out of scope**: + - DNS resolution and IP-pinning rule implementation details for SSRF protection — PRD.md places these out of scope of the gear as a whole; this entry covers header stripping, request path/query validation against route configuration, and honoring the `ssrf_policy.enabled` gate, but not network-layer DNS/IP enforcement + - Circuit breaker behavior — DESIGN.md lists circuit breaker config and fallback strategies under Future Developments as core (not yet designed) functionality; this entry surfaces the `CircuitBreakerOpen` error code but does not implement trip/reset logic + - Automatic request retries — explicitly excluded by design principle; connector-level endpoint failover within a pool is permitted but is not a retry of the client's request + - SSE, WebSocket, and other upgrade/streaming request handling — covered by Streaming and Upgrade Proxying + - Rate limiting, CORS, required-headers guard enforcement, and auth credential injection — covered by Traffic Policy Enforcement, which wraps this entry's request path. This entry's contribution to `cpt-cf-oagw-nfr-input-validation` is path, query, and body-size validation; header-presence validation via the required-headers guard is Traffic Policy Enforcement's concern + - gRPC request classification and proxying — deferred per PRD.md scope (planned for a later phase); no gRPC proxy code path is implemented or reachable + +- **Requirements Covered**: + + - [ ] `p1` - `cpt-cf-oagw-fr-request-proxy` + - [ ] `p1` - `cpt-cf-oagw-fr-header-transform` + - [x] `p2` - `cpt-cf-oagw-fr-config-layering` + - [x] `p2` - `cpt-cf-oagw-fr-alias-resolution` + - [ ] `p1` - `cpt-cf-oagw-nfr-low-latency` + - [ ] `p1` - `cpt-cf-oagw-nfr-input-validation` + - [ ] `p1` - `cpt-cf-oagw-nfr-ssrf-protection` + - [ ] `p1` - `cpt-cf-oagw-nfr-high-availability` + - [ ] `p2` - `cpt-cf-oagw-nfr-observability` + +- **Design Principles Covered**: + + - `cpt-cf-oagw-principle-no-retry` + - `cpt-cf-oagw-principle-no-cache` + - `cpt-cf-oagw-principle-error-source` + +- **Design Constraints Covered**: + + - `cpt-cf-oagw-constraint-https-only` + - `cpt-cf-oagw-constraint-body-limit` + - `cpt-cf-oagw-constraint-no-direct-internet` + +- **Domain Model Entities**: + - Upstream (read-only resolution) + - Route (read-only resolution) + - ServerConfig + - Endpoint + +- **Design Components**: + + - `cpt-cf-oagw-component-model` + +- **API**: + - {METHOD} /oagw/v1/proxy/{alias} + - {METHOD} /oagw/v1/proxy/{alias}/{path} + +- **Sequences**: + + - `cpt-cf-oagw-seq-proxy-flow` + +- **Data**: + + - `cpt-cf-oagw-db-schema` + + Read-only resolution against the in-memory upstream/route state maintained by Upstream Management and Route Management under the graded configuration's no-database posture (Overview override 3); no additional state is owned by this entry. + +### 2.6 [Streaming and Upgrade Proxying](feature-proxy-streaming/) - HIGH + +- [ ] `p1` - **ID**: `cpt-cf-oagw-feature-proxy-streaming` + +- **Purpose**: Extends the HTTP proxy path to server-sent-event stream proxying and WebSocket upgrade proxying, managing connection lifecycle (open/close/error) for long-lived and bidirectional connections. + +- **Implementation Approach**: single phase + +- **Depends On**: `cpt-cf-oagw-feature-proxy-http` + +- **Scope**: + - SSE (Server-Sent Events) response proxying: establishing the upstream connection, forwarding events as received, and managing open/close/error lifecycle + - WebSocket upgrade proxying: forwarding the upgrade handshake and subsequent bidirectional frames, and managing connection lifecycle on both client and upstream disconnect + - Reuse of alias resolution, route matching, header transformation, and error-source distinction from HTTP Request Proxying for the initial request/upgrade handshake + +- **Out of scope**: + - WebTransport session flows — named in `cpt-cf-oagw-fr-streaming`'s requirement text, but DESIGN.md contains no WebTransport design detail beyond the requirement statement and the `wt` scheme enum value; this entry covers SSE and WebSocket only, and WebTransport is deferred pending further design + - gRPC streaming — deferred per PRD.md scope (planned for a later phase); no gRPC proxy code path is implemented or reachable + - Rate limiting, CORS, and auth injection applied to streaming/upgrade connections — covered by Traffic Policy Enforcement + +- **Requirements Covered**: + + - [ ] `p1` - `cpt-cf-oagw-fr-streaming` + +- **Design Principles Covered**: + + - `cpt-cf-oagw-principle-error-source` + +- **Design Constraints Covered**: + - None (no design constraint uniquely governs streaming/upgrade proxying beyond those already covered by HTTP Request Proxying) + +- **Domain Model Entities**: + - None (reuses the Upstream, Route, and Endpoint resolution performed by HTTP Request Proxying; no new domain entities are introduced) + +- **Design Components**: + + - `cpt-cf-oagw-component-model` + +- **API**: + - GET /oagw/v1/proxy/{alias}/{path} (SSE stream upgrade) + - GET /oagw/v1/proxy/{alias}/{path} (WebSocket upgrade) + +- **Sequences**: + + - `cpt-cf-oagw-seq-proxy-flow` + +- **Data**: + - None (connection lifecycle state is transient; no persisted or in-memory domain state beyond what HTTP Request Proxying already resolves) + +### 2.7 [Traffic Policy Enforcement](feature-traffic-policy/) - MEDIUM + +- [ ] `p2` - **ID**: `cpt-cf-oagw-feature-traffic-policy` + +- **Purpose**: Wraps the proxy request path with cross-cutting traffic policy: token-bucket rate limiting with configurable scopes and strategies, CORS preflight and actual-request handling, the required-headers guard plugin, and auth-plugin credential injection. + +- **Implementation Approach**: single phase + +- **Depends On**: `cpt-cf-oagw-feature-proxy-http`, `cpt-cf-oagw-feature-plugin-management` + +- **Scope**: + - Rate limiting: token-bucket (and sliding-window) evaluation against configured rate, window, capacity, and cost; scopes (global/tenant/user/IP/route); strategies (reject with 429 + `Retry-After`, queue, degrade); `X-RateLimit-*` response headers; hierarchical stricter-wins merge (`effective = min(ancestor.enforced, descendant)`) across the tenant chain + - CORS: preflight `OPTIONS` handling (permissive response at the handler level, before upstream resolution) and actual-request origin/method validation against `upstream.cors`/`route.cors` after upstream resolution + - Required-headers guard plugin: binding and enforcement of `required_headers` on upstream/route requests and responses + - Auth credential injection: executing the bound Auth plugin (API Key, OAuth2 Client Credentials, OAuth2 Client Credentials with Basic auth, No-op) ahead of Guards and Transform in the plugin chain, retrieving credentials from the credential store by reference at request time, and applying the OAuth2 token cache (`token_cache_ttl_secs`, `token_cache_capacity` from ADR-0008) + +- **Out of scope**: + - HTTP Basic and Bearer auth plugin execution — `basic.v1` and `bearer.v1` are catalog-only GTS identifiers with no backing auth-plugin implementation in DESIGN.md's plugin catalog; binding either as `auth.plugin_type` is rejected, so this entry enforces that rejection rather than implementing the plugins + - Distributed (e.g., Redis-backed) rate-limit counter synchronization across gear instances — DESIGN.md's ADR-0006 State Management scopes rate-limit state to in-memory per-instance counters for this design; cross-instance synchronization is not part of this entry + - Auth plugin token refresh retrying the original failed upstream request — auth plugins may refresh tokens on 401, but the gateway does not re-issue the original client request + - Path, query, and body-size input validation — covered by HTTP Request Proxying; this entry's contribution to `cpt-cf-oagw-nfr-input-validation` is limited to header-presence validation via the required-headers guard plugin + +- **Requirements Covered**: + + - [ ] `p1` - `cpt-cf-oagw-fr-rate-limiting` + - [ ] `p1` - `cpt-cf-oagw-fr-auth-injection` + - [ ] `p1` - `cpt-cf-oagw-nfr-credential-isolation` + - [ ] `p1` - `cpt-cf-oagw-nfr-input-validation` + +- **Design Principles Covered**: + + - `cpt-cf-oagw-principle-cred-isolation` + +- **Design Constraints Covered**: + - None (no design constraint uniquely governs traffic policy enforcement beyond those already covered by HTTP Request Proxying and Upstream Management) + +- **Domain Model Entities**: + - RateLimitConfig + - CorsConfig + - PluginsConfig + +- **Design Components**: + + - `cpt-cf-oagw-component-model` + +- **API**: + - {METHOD} /oagw/v1/proxy/{alias}/{path} (rate limiting, CORS, guard, and auth-injection enforcement applied) + - OPTIONS /oagw/v1/proxy/{alias}/{path} (CORS preflight) + +- **Sequences**: + + - `cpt-cf-oagw-seq-proxy-flow` + +- **Data**: + - None (rate-limit counters and token cache entries are held in in-memory, per-instance state that is not part of the persisted-deployment schema in `cpt-cf-oagw-db-schema`) + +--- + +## 3. Feature Dependencies + +```text +cpt-cf-oagw-feature-gear-foundation + ↓ + ├─→ cpt-cf-oagw-feature-upstream-management + │ ↓ + │ ├─→ cpt-cf-oagw-feature-route-management + │ │ ↓ + │ │ └─→ cpt-cf-oagw-feature-plugin-management + │ │ + │ └─→ cpt-cf-oagw-feature-proxy-http (also depends on route-management) + │ ↓ + │ ├─→ cpt-cf-oagw-feature-proxy-streaming + │ │ + │ └─→ cpt-cf-oagw-feature-traffic-policy (also depends on plugin-management) + │ + └─→ cpt-cf-oagw-feature-route-management (also depends on upstream-management) +``` + +**Dependency Rationale**: + +- `cpt-cf-oagw-feature-upstream-management` requires `cpt-cf-oagw-feature-gear-foundation`: upstream CRUD executes inside the gear's configuration surface, shared state, and error-mapping conventions established by gear foundation. +- `cpt-cf-oagw-feature-route-management` requires `cpt-cf-oagw-feature-gear-foundation`: same gear-wiring dependency as upstream management. +- `cpt-cf-oagw-feature-route-management` requires `cpt-cf-oagw-feature-upstream-management`: every route validates and links to an `upstream_id` that must already exist and belong to the calling tenant. +- `cpt-cf-oagw-feature-plugin-management` requires `cpt-cf-oagw-feature-gear-foundation`: plugin CRUD executes inside the gear's wiring and error-mapping conventions. +- `cpt-cf-oagw-feature-plugin-management` requires `cpt-cf-oagw-feature-upstream-management` and `cpt-cf-oagw-feature-route-management`: plugin binding validation checks bindings against existing upstream and route `plugins.items`, and the in-use conflict check on delete scans both. +- `cpt-cf-oagw-feature-proxy-http` requires `cpt-cf-oagw-feature-upstream-management` and `cpt-cf-oagw-feature-route-management`: the data-plane request path resolves an upstream by alias and matches a route before it can forward anything. +- `cpt-cf-oagw-feature-proxy-streaming` requires `cpt-cf-oagw-feature-proxy-http`: SSE and WebSocket proxying reuse the alias resolution, route matching, and header transformation established for plain HTTP proxying, extending only the connection-lifecycle handling. +- `cpt-cf-oagw-feature-traffic-policy` requires `cpt-cf-oagw-feature-proxy-http`: rate limiting, CORS, and auth injection wrap the proxy request path and cannot be evaluated before a route and upstream are resolved. +- `cpt-cf-oagw-feature-traffic-policy` requires `cpt-cf-oagw-feature-plugin-management`: the required-headers guard plugin and the auth plugins it injects must already be cataloged and bindable before traffic policy can enforce them. +- `cpt-cf-oagw-feature-route-management` and `cpt-cf-oagw-feature-plugin-management` share `cpt-cf-oagw-feature-upstream-management` as a common prerequisite but do not depend on each other directly except through the ordering above; `cpt-cf-oagw-feature-proxy-streaming` and `cpt-cf-oagw-feature-traffic-policy` are independent of each other (one extends the connection type, the other adds policy enforcement) and can be developed in parallel once `cpt-cf-oagw-feature-proxy-http` is complete. 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..a1c8686 --- /dev/null +++ b/gears/system/oagw/docs/features/gear-foundation.md @@ -0,0 +1,363 @@ +# Feature: Gear Foundation and Configuration + + + + +- [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) + - [1.5 Non-Applicability Dispositions](#15-non-applicability-dispositions) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Platform Operator Deploys and Starts the Gear](#platform-operator-deploys-and-starts-the-gear) + - [Application Developer Receives a Canonically Mapped Error](#application-developer-receives-a-canonically-mapped-error) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Load Typed Gear Configuration](#load-typed-gear-configuration) + - [Resolve the Calling Tenant and Subject from the Security Context](#resolve-the-calling-tenant-and-subject-from-the-security-context) + - [Create the Shared In-Memory Control-Plane Store](#create-the-shared-in-memory-control-plane-store) + - [Map a Domain Error to a Problem+JSON Response](#map-a-domain-error-to-a-problemjson-response) + - [Report Gear Readiness](#report-gear-readiness) +- [4. States (CDSL)](#4-states-cdsl) + - [Gear Initialization Lifecycle State Machine](#gear-initialization-lifecycle-state-machine) +- [5. Definitions of Done](#5-definitions-of-done) + - [Gear Registers With the Host Runtime and Mounts Its Routes](#gear-registers-with-the-host-runtime-and-mounts-its-routes) + - [Typed Configuration Surface With Safe Defaults](#typed-configuration-surface-with-safe-defaults) + - [Tenant Identity Extraction From the Security Context](#tenant-identity-extraction-from-the-security-context) + - [Shared In-Memory Control-Plane Store](#shared-in-memory-control-plane-store) + - [Canonical Domain-Error-to-Problem+JSON Mapping](#canonical-domain-error-to-problemjson-mapping) + - [Gear Readiness Reporting](#gear-readiness-reporting) +- [6. Acceptance Criteria](#6-acceptance-criteria) + + + +- [ ] `p1` - **ID**: `cpt-cf-oagw-featstatus-gf-implemented` + +- [ ] `p1` - `cpt-cf-oagw-feature-gear-foundation` + +## 1. Feature Context + +### 1.1 Overview + +Establishes OAGW as a registered ToolKit gear: it participates in the host runtime's REST phase to mount its management and proxy routes, exposes a typed configuration surface with safe defaults, creates the single in-memory control-plane store every other feature shares, and defines the canonical mapping from domain errors to RFC 9457 `application/problem+json` responses that every other feature reuses. + +### 1.2 Purpose + +Every one of the other six OAGW features executes inside the wiring this feature establishes: none of them can register a route, read a configuration value, hold control-plane state, or return a well-formed error without it. This feature realizes the gear-wiring portion of `cpt-cf-oagw-component-model` and gives the whole gear a single, consistent posture toward the host runtime and toward its callers, so that a client of any management or proxy endpoint sees the same error envelope regardless of which internal feature rejected the request. + +**Requirements**: `cpt-cf-oagw-fr-error-codes` + +**Principles**: `cpt-cf-oagw-principle-rfc9457` + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-oagw-actor-platform-operator` | Supplies (or omits) the gear's deployment configuration and observes whether the gear starts and reports ready. | +| `cpt-cf-oagw-actor-app-developer` | Receives the RFC 9457 `application/problem+json` error responses whose status-code vocabulary this feature defines, regardless of which feature's logic raised the underlying domain error. | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) +- **Design**: [DESIGN.md](../DESIGN.md) +- **Dependencies**: None — this feature has no dependency on any other FEATURE; every other FEATURE depends on it. + +### 1.5 Non-Applicability Dispositions + +- **Inbound authentication and authorization**: performed by the host runtime before a request reaches this gear. This feature does not authenticate callers or evaluate coarse permissions itself; it only maps the failures the host runtime (or a feature's own OAGW-specific permission check) raises to HTTP statuses, per the `unauthenticated` and `permission denied` categories this feature adds to its canonical error mapping (see Section 3). +- **No feature-owned permission decisions in this gear**: this gear performs no OAGW-specific permission checks of its own, in this configuration, beyond the `unauthenticated` (401) mapping described above. The `:create` and bind permission preconditions the PRD's use cases name (Route Management's `gts.cf.core.oagw.route.v1~:create` check, Upstream Management's `oagw:upstream:bind` check) are described in those features' own flows and §1.5 sections, but are not separately enforced by feature-specific logic in this configuration; inbound authentication and coarse authorization performed by the host runtime are the only gate those requests pass through before reaching feature logic. The `permission denied` (403) category exists in this feature's mapping (Section 3) and is exercised by this feature's own unit tests, but this feature only maps whichever category a calling feature's logic raises — it never itself decides when a `403` applies, and no feature currently raises one in this configuration. +- **User interface**: this feature exposes no user interface, so accessibility and UX checklist domains are not applicable. +- **Regulated or personal data**: this feature stores no regulated or personal data; it holds gear configuration values and gear-lifecycle state only. + +## 2. Actor Flows (CDSL) + +**Use cases**: None — this feature underlies every use case in PRD.md rather than owning one of its own. + +### Platform Operator Deploys and Starts the Gear + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-gf-startup` + +**Actor**: `cpt-cf-oagw-actor-platform-operator` + +**Success Scenarios**: +- The deployment configuration includes a `gears.oagw.config` sub-section with one or more recognized keys; the gear starts with the supplied values (falling back to documented defaults for any recognized key left unset) in effect. +- The deployment configuration omits the `gears.oagw.config` sub-section entirely; the gear starts with every configuration key at its documented default. +- The `gears.oagw.config` sub-section includes one or more keys the gear does not recognize; the gear starts normally and ignores them. + +**Error Scenarios**: +- A recognized configuration key holds a value of the wrong type; the gear fails to initialize and reports which key is malformed, before any shared state or route is created. +- No REST host is present in the deployment to mount routes onto; the gear's REST-phase registration fails and the gear does not report ready. + +**Steps**: +1. [ ] - `p1` - Platform Operator supplies, partially supplies, or omits the `gears.oagw.config` sub-section of the host deployment configuration - `inst-gf-startup-01` +2. [ ] - `p1` - The host runtime reaches the init lifecycle phase and this gear resolves its typed configuration (`cpt-cf-oagw-algo-gf-load-config`) from that sub-section - `inst-gf-startup-02` +3. [ ] - `p1` - **IF** configuration resolution fails because a recognized key holds a value of the wrong type - `inst-gf-startup-03` + 1. [ ] - `p1` - The gear reports an initialization error identifying the offending key and does not proceed to create shared state or mount routes - `inst-gf-startup-04` +4. [ ] - `p1` - **ELSE** - `inst-gf-startup-05` + 1. [ ] - `p1` - The gear creates the shared in-memory control-plane store exactly once (`cpt-cf-oagw-algo-gf-init-state`) and retains it for the lifetime of the process - `inst-gf-startup-06` +5. [ ] - `p1` - The host runtime reaches the REST phase and this gear registers its management and proxy routes under the gear-relative `/oagw/v1` prefix onto the single shared router the host composes, with no `/api` segment added by this gear - `inst-gf-startup-07` +6. [ ] - `p1` - **IF** no REST host is present in the deployment to compose that shared router - `inst-gf-startup-08` + 1. [ ] - `p1` - Route registration fails and the gear does not report ready - `inst-gf-startup-09` +7. [ ] - `p1` - **ELSE** - `inst-gf-startup-10` + 1. [ ] - `p1` - The gear registers a named readiness check with the host runtime's readiness aggregator (`cpt-cf-oagw-algo-gf-readiness`) - `inst-gf-startup-11` +8. [ ] - `p1` - **RETURN** the gear reports ready once configuration resolution, shared-state creation, and route registration have all completed without error - `inst-gf-startup-12` + +### Application Developer Receives a Canonically Mapped Error + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-gf-error-response` + +**Actor**: `cpt-cf-oagw-actor-app-developer` + +**Success Scenarios**: +- A management or proxy request fails inside some other feature's logic with a domain error that carries one of the named entries in DESIGN.md's error-code taxonomy; the caller receives the HTTP status the taxonomy documents for that entry. +- A management request fails with a domain error that carries only a generic canonical category (unauthenticated, permission denied, invalid argument, not found, already exists, failed precondition, unavailable, internal); the caller receives the status this feature's category mapping specifies. + +**Error Scenarios**: +- A domain error is raised that matches neither a named taxonomy entry nor a canonical category; the caller still receives a well-formed `application/problem+json` response rather than an unhandled failure. +- A request reaches a feature's own OAGW-specific authorization check (for example Upstream Management's bind-permission decision) and that check fails; the caller receives `403` via the `permission denied` category rather than falling through to a `500`. + +**Steps**: +1. [ ] - `p1` - Application Developer issues a request against a management or proxy path under `/oagw/v1` - `inst-gf-error-01` +2. [ ] - `p1` - **API**: `{METHOD} /oagw/v1/{path}` (the specific path and method belong to the feature that owns the endpoint; this flow starts once that feature's logic raises a domain error while handling the request) - `inst-gf-error-02` +3. [ ] - `p1` - This feature's canonical error-mapping process (`cpt-cf-oagw-algo-gf-error-mapping`) classifies the domain error and resolves the HTTP status and problem body for it - `inst-gf-error-03` +4. [ ] - `p1` - **RETURN** an `application/problem+json` response carrying `type`, `title`, `status`, and `detail`, at the HTTP status the mapping resolved - `inst-gf-error-04` + +## 3. Processes / Business Logic (CDSL) + +### Load Typed Gear Configuration + +- [ ] `p1` - **ID**: `cpt-cf-oagw-algo-gf-load-config` + +**Input**: The `gears.oagw.config` sub-section of the host deployment configuration, or its absence. + +**Output**: A fully-resolved typed configuration exposing `proxy_timeout_secs`, `allow_http_upstream`, `ssrf_policy.enabled`, `token_cache_ttl_secs`, and `token_cache_capacity`. + +**Steps**: +1. [ ] - `p1` - Treat a missing `gears.oagw.config` sub-section as an empty mapping rather than as an error - `inst-gf-config-01` +2. [ ] - `p1` - **FOR EACH** recognized key (`proxy_timeout_secs`, `allow_http_upstream`, `ssrf_policy.enabled`, `token_cache_ttl_secs`, `token_cache_capacity`) - `inst-gf-config-02` + 1. [ ] - `p1` - **IF** the key is present in the mapping, adopt its supplied value - `inst-gf-config-03` + 2. [ ] - `p1` - **ELSE** adopt this feature's documented default: `proxy_timeout_secs` = 30 seconds, `allow_http_upstream` = `false`, `ssrf_policy.enabled` = `true`, `token_cache_ttl_secs` = 300 seconds, `token_cache_capacity` = 10,000 entries - `inst-gf-config-04` +3. [ ] - `p1` - Ignore every key present in the mapping that is not one of the five recognized keys, without treating it as an error - `inst-gf-config-05` +4. [ ] - `p1` - **TRY** - `inst-gf-config-06` + 1. [ ] - `p1` - Validate that each recognized key's supplied value matches its declared type (a duration in seconds, a boolean, or an entry count) - `inst-gf-config-07` +5. [ ] - `p1` - **CATCH** a recognized key holding a value of the wrong type - `inst-gf-config-08` + 1. [ ] - `p1` - Fail gear initialization with an error that identifies the offending key, before shared state or routes are created - `inst-gf-config-09` +6. [ ] - `p1` - **RETURN** the resolved typed configuration, shared with the features that consume individual keys - `inst-gf-config-10` + +### Resolve the Calling Tenant and Subject from the Security Context + +- [ ] `p1` - **ID**: `cpt-cf-oagw-algo-gf-tenant-context` + +**Input**: The inbound request reaching a management or proxy handler under `/oagw/v1`. + +**Output**: The calling subject and tenant scope used to scope every control-plane read and write, or an empty (no-tenant) scope. + +**Steps**: +1. [ ] - `p1` - Determine whether the host runtime has attached a security context to the inbound request - `inst-gf-tenant-01` +2. [ ] - `p1` - **IF** a security context is attached - `inst-gf-tenant-02` + 1. [ ] - `p1` - Obtain the calling subject and the calling tenant from that security context - `inst-gf-tenant-03` + 2. [ ] - `p1` - Scope every control-plane read and write this request performs to that tenant, exactly as "the calling tenant" is referenced throughout every other feature's flows - `inst-gf-tenant-04` +3. [ ] - `p1` - **ELSE** (no security context is attached) - `inst-gf-tenant-05` + 1. [ ] - `p1` - Treat the request as having no tenant scope (no subject, no tenant) rather than guessing or defaulting to a tenant - `inst-gf-tenant-06` + 2. [ ] - `p1` - The request's subsequent authorization step raises the "unauthenticated" canonical category (`cpt-cf-oagw-algo-gf-error-mapping`), mapped to `401` - `inst-gf-tenant-07` +4. [ ] - `p1` - **RETURN** the resolved subject/tenant scope (or its absence) for use by the handling feature's own tenant-scoping and, where a feature defines one, its OAGW-specific permission check - `inst-gf-tenant-08` + +### Create the Shared In-Memory Control-Plane Store + +- [ ] `p1` - **ID**: `cpt-cf-oagw-algo-gf-init-state` + +**Input**: None — this process runs unconditionally, once, during the init lifecycle phase, after configuration has resolved. + +**Output**: A single shared store instance handed to both control-plane and data-plane internals for the remaining lifetime of the process. + +**Steps**: +1. [ ] - `p1` - Allocate one empty in-memory store, since the graded configuration runs without a database - `inst-gf-state-01` +2. [ ] - `p1` - **DB**: this store carries the same entities, cascade-delete relationships, and per-tenant uniqueness invariants that the persisted-deployment schema (`cpt-cf-oagw-db-schema`) documents — for example `UNIQUE(tenant_id, alias)` on upstreams — realized as in-memory constraints instead of database constraints - `inst-gf-state-02` +3. [ ] - `p1` - Share this same store instance with both the control-plane CRUD logic and the data-plane request-resolution logic; no second copy is created - `inst-gf-state-03` +4. [ ] - `p1` - **RETURN** the shared store instance for use by every other feature's control-plane and data-plane logic - `inst-gf-state-04` + +### Map a Domain Error to a Problem+JSON Response + +- [ ] `p1` - **ID**: `cpt-cf-oagw-algo-gf-error-mapping` + +**Input**: A domain error raised by any control-plane or data-plane operation in the gear, carrying either one of the named entries in DESIGN.md's error-code taxonomy or a generic canonical category (unauthenticated, permission denied, invalid argument, not found, already exists, failed precondition, unavailable, internal), plus a human-readable detail message. + +**Output**: An HTTP response with media type `application/problem+json` containing `type`, `title`, `status`, and `detail` members, per the RFC 9457 principle (`cpt-cf-oagw-principle-rfc9457`). + +**Steps**: +1. [ ] - `p1` - Determine whether the domain error carries one of the named error-code-taxonomy entries (for example `RouteNotFound`, `PluginInUse`, `PayloadTooLarge`, `RateLimitExceeded`, `CircuitBreakerOpen`) or only a generic canonical category - `inst-gf-errmap-01` +2. [ ] - `p1` - **IF** the error carries a named taxonomy entry - `inst-gf-errmap-02` + 1. [ ] - `p1` - Use the HTTP status the taxonomy documents for that entry as a fixed, per-entry mapping - `inst-gf-errmap-03` +3. [ ] - `p1` - **ELSE** (a generic canonical category, used chiefly by control-plane CRUD errors that do not need a more specific named code) - `inst-gf-errmap-04` + 1. [ ] - `p1` - **IF** the category is "unauthenticated" (no security context is attached to the request, per `cpt-cf-oagw-algo-gf-tenant-context`) - `inst-gf-errmap-unauth-01` + 1. [ ] - `p1` - Use status 401 - `inst-gf-errmap-unauth-02` + 2. [ ] - `p1` - **IF** the category is "permission denied" (a feature's own OAGW-specific authorization check, for example Upstream Management's bind-permission decision, rejects the caller) - `inst-gf-errmap-permdenied-01` + 1. [ ] - `p1` - Use status 403 - `inst-gf-errmap-permdenied-02` + 3. [ ] - `p1` - **IF** the category is "invalid argument" - `inst-gf-errmap-05` + 1. [ ] - `p1` - Use status 400 - `inst-gf-errmap-06` + 4. [ ] - `p1` - **IF** the category is "not found" - `inst-gf-errmap-07` + 1. [ ] - `p1` - Use status 404 - `inst-gf-errmap-08` + 5. [ ] - `p1` - **IF** the category is "already exists" - `inst-gf-errmap-09` + 1. [ ] - `p1` - Use status 409 - `inst-gf-errmap-10` + 6. [ ] - `p1` - **IF** the category is "failed precondition" - `inst-gf-errmap-11` + 1. [ ] - `p1` - Use status 409, matching DESIGN.md's own examples of this category (for example a plugin blocked from deletion while still referenced) - `inst-gf-errmap-12` + 7. [ ] - `p1` - **IF** the category is "unavailable" - `inst-gf-errmap-13` + 1. [ ] - `p1` - Use status 503 - `inst-gf-errmap-14` + 8. [ ] - `p1` - **IF** the category is "internal" - `inst-gf-errmap-15` + 1. [ ] - `p1` - Use status 500 - `inst-gf-errmap-16` +4. [ ] - `p1` - **IF** the domain error matches neither a named taxonomy entry nor a recognized canonical category (an error type introduced without an explicit mapping) - `inst-gf-errmap-17` + 1. [ ] - `p1` - Default to status 500 under the "internal" category rather than leaking an unmapped error to the caller - `inst-gf-errmap-18` +5. [ ] - `p1` - Build the response body with a `type` member identifying the error, a `title` summarizing the category, the resolved `status`, and a `detail` describing the specific failure - `inst-gf-errmap-19` +6. [ ] - `p1` - Set the response media type to `application/problem+json` - `inst-gf-errmap-20` +7. [ ] - `p1` - **RETURN** the assembled problem body paired with the resolved HTTP status code - `inst-gf-errmap-21` + +### Report Gear Readiness + +- [ ] `p1` - **ID**: `cpt-cf-oagw-algo-gf-readiness` + +**Input**: A readiness probe forwarded by the host runtime's readiness aggregator, or a liveness probe that never reaches gear-specific logic. + +**Output**: A healthy/unhealthy readiness result for this gear, folded into the host's aggregate readiness report. + +**Steps**: +1. [ ] - `p1` - Determine whether configuration resolution, shared-state creation, and route registration have all completed since this gear's initialization began - `inst-gf-ready-01` +2. [ ] - `p1` - **IF** all three have completed without error - `inst-gf-ready-02` + 1. [ ] - `p1` - Report this gear healthy under its registered readiness-check name - `inst-gf-ready-03` +3. [ ] - `p1` - **ELSE** - `inst-gf-ready-04` + 1. [ ] - `p1` - Report this gear unhealthy, naming the stage that has not completed - `inst-gf-ready-05` +4. [ ] - `p1` - **RETURN** the readiness result to the host's readiness aggregator - `inst-gf-ready-06` + +## 4. States (CDSL) + +### Gear Initialization Lifecycle State Machine + +- [ ] `p2` - **ID**: `cpt-cf-oagw-state-gf-lifecycle` + +**States**: Uninitialized, ConfigResolved, StateCreated, RoutesMounted, Ready, Unhealthy + +**Initial State**: Uninitialized + +**Transitions**: +1. [ ] - `p1` - **FROM** Uninitialized **TO** ConfigResolved **WHEN** the typed configuration surface has resolved (documented defaults applied for any absent key or absent section, unrecognized keys ignored) - `inst-gf-lifecycle-01` +2. [ ] - `p1` - **FROM** ConfigResolved **TO** StateCreated **WHEN** the shared in-memory control-plane store has been created - `inst-gf-lifecycle-02` +3. [ ] - `p1` - **FROM** StateCreated **TO** RoutesMounted **WHEN** the host runtime's REST phase has mounted this gear's routes under `/oagw/v1` - `inst-gf-lifecycle-03` +4. [ ] - `p1` - **FROM** RoutesMounted **TO** Ready **WHEN** this gear's readiness check has registered with the host and reports healthy - `inst-gf-lifecycle-04` +5. [ ] - `p1` - **FROM** Uninitialized **TO** Unhealthy **WHEN** configuration resolution fails (a recognized key holds a value of the wrong type) - `inst-gf-lifecycle-05` +6. [ ] - `p1` - **FROM** ConfigResolved **TO** Unhealthy **WHEN** shared-state creation fails - `inst-gf-lifecycle-06` +7. [ ] - `p1` - **FROM** StateCreated **TO** Unhealthy **WHEN** route mounting fails (for example, no REST host is present in the deployment) - `inst-gf-lifecycle-07` + +## 5. Definitions of Done + +### Gear Registers With the Host Runtime and Mounts Its Routes + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-gf-registration` + +The system **MUST** register this gear with the host runtime, participate in the REST phase by contributing its management and proxy routes to the single shared router the host composes, and mount those routes under the gear-relative `/oagw/v1` prefix with no `/api` segment added by this gear itself. + +**Implements**: +- `cpt-cf-oagw-flow-gf-startup` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: +- API: None (this entry establishes the path prefix every other feature's endpoints are mounted under; it owns no endpoint of its own) +- DB: None +- Entities: None + +### Typed Configuration Surface With Safe Defaults + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-gf-config` + +The system **MUST** expose a typed configuration surface for `proxy_timeout_secs`, `allow_http_upstream`, `ssrf_policy.enabled`, `token_cache_ttl_secs`, and `token_cache_capacity`; apply each key's documented default when that key — or the entire `gears.oagw.config` sub-section — is absent; start successfully in that absent-section case; and ignore configuration keys it does not recognize rather than failing startup because of them. + +**Implements**: +- `cpt-cf-oagw-algo-gf-load-config` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: +- API: None +- DB: None +- Entities: None + +### Tenant Identity Extraction From the Security Context + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-gf-tenant-context` + +The system **MUST** obtain the calling subject and tenant from the security context the host runtime attaches to each request, scope every control-plane read and write to that tenant, and treat a request with no attached security context as having no tenant scope rather than defaulting to any tenant. + +**Implements**: +- `cpt-cf-oagw-algo-gf-tenant-context` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: +- API: None +- DB: None +- Entities: None + +### Shared In-Memory Control-Plane Store + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-gf-shared-state` + +The system **MUST** create exactly one in-memory control-plane store during gear initialization, share that same instance between control-plane and data-plane internals for the life of the process, and preserve the per-tenant uniqueness invariants (for example `(tenant_id, alias)` on upstreams) that the persisted-deployment schema documents. + +**Implements**: +- `cpt-cf-oagw-algo-gf-init-state` + +**Constraints**: `cpt-cf-oagw-constraint-multi-sql` + +**Touches**: +- API: None +- DB: `cpt-cf-oagw-db-schema` +- Entities: None + +### Canonical Domain-Error-to-Problem+JSON Mapping + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-gf-error-mapping` + +The system **MUST** map every domain error raised anywhere in this gear to an RFC 9457 `application/problem+json` response, using the fixed per-entry status codes from DESIGN.md's named error-code taxonomy where an error carries a named entry, the canonical category-to-status mapping in Section 3 otherwise — including `unauthenticated` -> 401 and `permission denied` -> 403 — and a 500 "internal" response for any domain error matching neither. + +**Implements**: +- `cpt-cf-oagw-algo-gf-error-mapping` +- `cpt-cf-oagw-flow-gf-error-response` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: +- API: None +- DB: None +- Entities: None + +### Gear Readiness Reporting + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-gf-readiness` + +The system **MUST** register a named readiness check with the host runtime's readiness aggregator that reports this gear healthy only once configuration resolution, shared-state creation, and route registration have all completed, and unhealthy — naming the incomplete stage — otherwise. + +**Implements**: +- `cpt-cf-oagw-algo-gf-readiness` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: +- API: None +- DB: None +- Entities: None + +## 6. Acceptance Criteria + +- [ ] Starting the host runtime with `config/e2e-local.yaml` registers this gear and the server starts without error. +- [ ] With the `gears.oagw.config` sub-section entirely absent from the deployment configuration, the gear still starts successfully and every configuration key takes its documented default value. +- [ ] With `config/e2e-local.yaml`'s `gears.oagw` block in effect, the resolved configuration reports `proxy_timeout_secs` as 2, `allow_http_upstream` as `true`, and `ssrf_policy.enabled` as `false`. +- [ ] With no `token_cache_ttl_secs` or `token_cache_capacity` key supplied, the resolved configuration reports 300 and 10,000 respectively. +- [ ] A `gears.oagw.config` sub-section containing a key the gear does not recognize does not prevent the gear from starting. +- [ ] The gear's management and proxy routes are reachable at `/oagw/v1/...`, and no route is registered under an `/api/oagw/v1/...` path by this gear itself. +- [ ] The shared in-memory control-plane store rejects a second upstream created with an alias already used by another upstream belonging to the same tenant, while permitting the same alias for a different tenant. +- [ ] A write performed through the control-plane store (for example creating an upstream) is visible to a data-plane read against the same store within the same process, without any additional synchronization step — evidencing one shared store rather than independent copies. +- [ ] A domain error carrying a named entry from DESIGN.md's error-code taxonomy returns an `application/problem+json` body whose `status` field matches the HTTP status the taxonomy documents for that entry (for example `RouteNotFound` returns 404). +- [ ] A domain error carrying only a generic canonical category returns the status this feature's mapping specifies for that category (`unauthenticated` -> 401, `permission denied` -> 403, `invalid argument` -> 400, `not found` -> 404, `already exists` -> 409, `failed precondition` -> 409, `unavailable` -> 503, `internal` -> 500). +- [ ] A request that fails a feature's own OAGW-specific authorization check (for example Upstream Management's bind-permission decision) returns `403` via the `permission denied` category, rather than falling through to `500`. +- [ ] A request with no security context attached is treated as having no tenant scope, and any control-plane operation it attempts fails with the `unauthenticated` category, mapped to `401`. +- [ ] An unmapped domain error — one that matches neither a named taxonomy entry nor a recognized canonical category — yields an `application/problem+json` body with a `type`, `title`, `status` of 500, and `detail`, rather than an unhandled server error. +- [ ] Once configuration resolution, shared-state creation, and route registration have all completed, this gear's readiness check reports healthy and the host's aggregate readiness endpoint reflects that health. diff --git a/gears/system/oagw/docs/features/plugin-management.md b/gears/system/oagw/docs/features/plugin-management.md new file mode 100644 index 0000000..882a51d --- /dev/null +++ b/gears/system/oagw/docs/features/plugin-management.md @@ -0,0 +1,370 @@ +# Feature: Plugin Catalog and Bindings + + + + +- [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) + - [1.5 Non-Applicability Dispositions](#15-non-applicability-dispositions) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Create Custom Plugin Definition](#create-custom-plugin-definition) + - [List Plugin Catalog](#list-plugin-catalog) + - [Retrieve Plugin Definition](#retrieve-plugin-definition) + - [Retrieve Custom Plugin Source](#retrieve-custom-plugin-source) + - [Delete Plugin Definition](#delete-plugin-definition) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Validate Plugin Binding List](#validate-plugin-binding-list) + - [Check Plugin In-Use](#check-plugin-in-use) +- [4. States (CDSL)](#4-states-cdsl) + - [Custom Plugin Definition Lifecycle](#custom-plugin-definition-lifecycle) +- [5. Definitions of Done](#5-definitions-of-done) + - [Serve the Built-in and Catalog-Only Plugin Catalog](#serve-the-built-in-and-catalog-only-plugin-catalog) + - [Manage Immutable Custom Plugin Definitions](#manage-immutable-custom-plugin-definitions) + - [Reject Deletion of an In-Use Plugin](#reject-deletion-of-an-in-use-plugin) + - [Validate Ordered Plugin Bindings at Write Time](#validate-ordered-plugin-bindings-at-write-time) + - [Document Deferred Execution of Custom Plugin Source](#document-deferred-execution-of-custom-plugin-source) +- [6. Acceptance Criteria](#6-acceptance-criteria) + + + +- [ ] `p2` - **ID**: `cpt-cf-oagw-featstatus-pm-implemented` + + +- [ ] `p2` - `cpt-cf-oagw-feature-plugin-management` +## 1. Feature Context + +### 1.1 Overview + +This feature maintains the catalog of Auth, Guard, and Transform plugins that upstreams and routes can bind to, manages the create/list/read/delete lifecycle of immutable custom plugin definitions, and validates every plugin binding written onto an upstream or route. + +### 1.2 Purpose + +The gateway needs a single, consistent inventory of what a request-processing chain can be built from: built-in behaviors that ship with the gear, catalog identifiers that are reserved but not runnable, and tenant-authored custom definitions. This feature owns that inventory and the write-time checks (identifier resolution, ordering, in-use protection) that keep an upstream's or route's `plugins` configuration internally consistent before it ever reaches a live request. + +Every store operation named in Sections 2 and 3 below (create, list, get, get-source, delete, in-use check) reads or writes the single shared in-memory control-plane store that Gear Foundation creates (`cpt-cf-oagw-algo-gf-init-state`), not a SQL database: the graded configuration runs with no `database:` section, so `oagw_plugin`, `oagw_upstream_plugin`, and `oagw_route_plugin` — the entities `cpt-cf-oagw-db-schema` documents for the persisted deployment — are held as in-process state with the same identity, tenant-scoping, and referential relationships the persisted-deployment schema describes. + +**Requirements**: `cpt-cf-oagw-fr-plugin-system`, `cpt-cf-oagw-fr-builtin-plugins`, `cpt-cf-oagw-nfr-starlark-sandbox` + +**Principles**: `cpt-cf-oagw-principle-plugin-immutable` + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-oagw-actor-platform-operator` | Reviews the built-in plugin catalog and manages system-wide custom plugin definitions and their bindings. | +| `cpt-cf-oagw-actor-tenant-admin` | Creates, inspects, and deletes tenant-scoped custom plugin definitions and binds them to that tenant's upstreams and routes. | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) +- **Design**: [DESIGN.md](../DESIGN.md) +- **Dependencies**: `cpt-cf-oagw-feature-gear-foundation`, `cpt-cf-oagw-feature-upstream-management`, `cpt-cf-oagw-feature-route-management` + +### 1.5 Non-Applicability Dispositions + +- **Inbound authentication and authorization**: performed by the host runtime before a request reaches this feature; this feature performs no OAGW-specific permission check of its own beyond the ordinary tenant scoping documented in each flow (an identifier that resolves to another tenant's custom definition is `404`, not `403`). +- **User interface**: this feature exposes no user interface, so accessibility and UX checklist domains are not applicable. +- **Regulated or personal data**: this feature stores no regulated or personal data; a custom plugin definition's stored source text is tenant-authored code, not personal data, and this feature does not execute it (`cpt-cf-oagw-dod-pm-execution-deferral`). + +## 2. Actor Flows (CDSL) + +User-facing interactions that start with an actor (human or external system) and describe the end-to-end flow of a use case. Each flow has a triggering actor and shows how the system responds to actor actions. + +**Use cases**: None. PRD.md defines no dedicated use case for plugin catalog management; the flows below realize `cpt-cf-oagw-fr-plugin-system` and `cpt-cf-oagw-fr-builtin-plugins` directly. + +### Create Custom Plugin Definition + +- [ ] `p2` - **ID**: `cpt-cf-oagw-flow-pm-create` + +**Actor**: `cpt-cf-oagw-actor-tenant-admin` + +**Success Scenarios**: +- The actor submits a plugin type (auth, guard, or transform), a name, a configuration schema, and source text; the system stores an immutable definition and returns it with a server-generated identifier. + +**Error Scenarios**: +- The submitted plugin type, name, configuration schema, or source text fails structural validation. +- A definition with the same name already exists for the calling tenant. + +**Steps**: +1. [ ] - `p2` - Actor submits a plugin definition naming its plugin kind (auth, guard, or transform), a display name, a description, a configuration schema, and Starlark source text - `inst-pm-create-submit` +2. [ ] - `p2` - API: POST /oagw/v1/plugins (plugin kind, name, description, config schema, source text -> created plugin definition) - `inst-pm-create-api` +3. [ ] - `p2` - Validate that the plugin kind is one of auth, guard, or transform and that the configuration schema and source text are present and well-formed - `inst-pm-create-validate` +4. [ ] - `p2` - **IF** validation fails - `inst-pm-create-if-invalid` + 1. [ ] - `p2` - **RETURN** 400 validation error identifying the offending field - `inst-pm-create-400` +5. [ ] - `p2` - **ELSE** - `inst-pm-create-else` + 1. [ ] - `p2` - Generate a UUID-backed anonymous GTS identifier scoped to the plugin kind (`gts.cf.core.oagw.{kind}_plugin.v1~{uuid}`) - `inst-pm-create-gen-id` + 2. [ ] - `p2` - Persist the definition, scoped to the calling tenant, together with its identifier, plugin kind, name, description, configuration schema, and source text - `inst-pm-create-insert` +6. [ ] - `p2` - **RETURN** 201 with the stored plugin definition, including its identifier and source text - `inst-pm-create-201` + +### List Plugin Catalog + +- [ ] `p2` - **ID**: `cpt-cf-oagw-flow-pm-list` + +**Actor**: `cpt-cf-oagw-actor-platform-operator` + +**Success Scenarios**: +- The actor lists the combined catalog: built-in entries, catalog-only entries, and the calling tenant's custom definitions, each tagged with its plugin kind and whether it is actually resolvable at binding time. + +**Error Scenarios**: +- None; an empty tenant scope returns the built-in and catalog-only entries with an empty custom-definition set. + +**Steps**: +1. [ ] - `p2` - Actor requests the plugin catalog, optionally filtered by plugin kind, with pagination parameters - `inst-pm-list-submit` +2. [ ] - `p2` - API: GET /oagw/v1/plugins (`$filter`, `$select`, `$top` default 50 max 100, `$skip` -> paginated list) - `inst-pm-list-api` +3. [ ] - `p2` - Assemble the fixed set of built-in and catalog-only entries for all three plugin kinds - `inst-pm-list-builtins` +4. [ ] - `p2` - Retrieve the calling tenant's custom definitions from the plugin store - `inst-pm-list-query` +5. [ ] - `p2` - Merge built-in, catalog-only, and custom entries, apply the requested filter and pagination - `inst-pm-list-merge` +6. [ ] - `p2` - **RETURN** 200 with the merged, paginated list - `inst-pm-list-200` + +### Retrieve Plugin Definition + +- [ ] `p2` - **ID**: `cpt-cf-oagw-flow-pm-get` + +**Actor**: `cpt-cf-oagw-actor-tenant-admin` + +**Success Scenarios**: +- The actor retrieves a single catalog entry (built-in, catalog-only, or the tenant's own custom definition) by its GTS identifier. + +**Error Scenarios**: +- The identifier does not resolve to any catalog entry, or resolves to a custom definition owned by another tenant. + +**Steps**: +1. [ ] - `p2` - Actor requests a plugin definition by its GTS identifier - `inst-pm-get-submit` +2. [ ] - `p2` - API: GET /oagw/v1/plugins/{id} (GTS plugin identifier -> plugin definition) - `inst-pm-get-api` +3. [ ] - `p2` - Parse the identifier's instance part; a UUID resolves against custom definitions, otherwise it resolves against the built-in/catalog-only registry - `inst-pm-get-resolve` +4. [ ] - `p2` - **IF** the identifier is UUID-backed - `inst-pm-get-if-uuid` + 1. [ ] - `p2` - Look up the definition by identifier, scoped to the calling tenant - `inst-pm-get-query` +5. [ ] - `p2` - **IF** no entry is found, or a UUID-backed definition belongs to another tenant - `inst-pm-get-if-missing` + 1. [ ] - `p2` - **RETURN** 404 not found - `inst-pm-get-404` +6. [ ] - `p2` - **ELSE** - `inst-pm-get-else` + 1. [ ] - `p2` - **RETURN** 200 with the resolved definition - `inst-pm-get-200` + +### Retrieve Custom Plugin Source + +- [ ] `p2` - **ID**: `cpt-cf-oagw-flow-pm-get-source` + +**Actor**: `cpt-cf-oagw-actor-tenant-admin` + +**Success Scenarios**: +- The actor retrieves the stored source text of a custom plugin definition owned by their tenant. + +**Error Scenarios**: +- The identifier names a built-in or catalog-only entry (no source text exists), an unknown identifier, or a custom definition owned by another tenant. + +**Steps**: +1. [ ] - `p2` - Actor requests the source text of a plugin definition by its GTS identifier - `inst-pm-src-submit` +2. [ ] - `p2` - API: GET /oagw/v1/plugins/{id}/source (GTS plugin identifier -> source text) - `inst-pm-src-api` +3. [ ] - `p2` - **IF** the identifier is not UUID-backed (built-in or catalog-only) - `inst-pm-src-if-named` + 1. [ ] - `p2` - **RETURN** 404 not found (named plugins carry no source text) - `inst-pm-src-404-named` +4. [ ] - `p2` - **ELSE** - `inst-pm-src-else` + 1. [ ] - `p2` - Look up the definition's source text by identifier, scoped to the calling tenant - `inst-pm-src-query` + 2. [ ] - `p2` - **IF** no row is found, or the row belongs to another tenant - `inst-pm-src-if-missing` + 1. [ ] - `p2` - **RETURN** 404 not found - `inst-pm-src-404` + 3. [ ] - `p2` - **ELSE** - `inst-pm-src-else-found` + 1. [ ] - `p2` - **RETURN** 200 with the stored source text - `inst-pm-src-200` + +### Delete Plugin Definition + +- [ ] `p2` - **ID**: `cpt-cf-oagw-flow-pm-delete` + +**Actor**: `cpt-cf-oagw-actor-tenant-admin` + +**Success Scenarios**: +- The actor deletes a custom plugin definition that is bound to no upstream or route. + +**Error Scenarios**: +- The identifier names a built-in or catalog-only entry, an unknown identifier, or a custom definition owned by another tenant. +- The custom definition is still referenced by at least one upstream or route binding. + +**Steps**: +1. [ ] - `p2` - Actor requests deletion of a plugin definition by its GTS identifier - `inst-pm-del-submit` +2. [ ] - `p2` - API: DELETE /oagw/v1/plugins/{id} (GTS plugin identifier -> no content, or conflict) - `inst-pm-del-api` +3. [ ] - `p2` - **IF** the identifier is not UUID-backed (built-in or catalog-only) - `inst-pm-del-if-named` + 1. [ ] - `p2` - **RETURN** 404 not found (built-in and catalog-only entries are not deletable resources) - `inst-pm-del-404-named` +4. [ ] - `p2` - **ELSE** - `inst-pm-del-else` + 1. [ ] - `p2` - Look up the definition by identifier, scoped to the calling tenant - `inst-pm-del-query` + 2. [ ] - `p2` - **IF** no definition is found, or it belongs to another tenant - `inst-pm-del-if-missing` + 1. [ ] - `p2` - **RETURN** 404 not found - `inst-pm-del-404` + 3. [ ] - `p2` - **ELSE** - `inst-pm-del-else-found` + 1. [ ] - `p2` - Run `cpt-cf-oagw-algo-pm-check-in-use` against the resolved identifier - `inst-pm-del-check` + 2. [ ] - `p2` - **IF** the identifier is referenced by any upstream or route binding - `inst-pm-del-if-inuse` + 1. [ ] - `p2` - **RETURN** 409 with error type PluginInUse and a `referenced_by` body shaped as an object with two arrays, `upstreams` and `routes`, each holding the identifiers of the referencing resources - `inst-pm-del-409` + 3. [ ] - `p2` - **ELSE** - `inst-pm-del-else-free` + 1. [ ] - `p2` - Remove the definition from the plugin store - `inst-pm-del-delete` + 2. [ ] - `p2` - **RETURN** 204 no content - `inst-pm-del-204` + +## 3. Processes / Business Logic (CDSL) + +Internal system functions and procedures that do not interact with actors directly. Examples: database layer operations, authorization logic, middleware, validation routines, library functions, background jobs. These are reusable building blocks called by Actor Flows or other processes. + +### Validate Plugin Binding List + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-pm-validate-binding` + +This process is invoked by the upstream and route write flows owned by `cpt-cf-oagw-feature-upstream-management` and `cpt-cf-oagw-feature-route-management` whenever a `plugins.items` list (guard and transform bindings) or, for an upstream, a standalone auth-plugin reference is submitted; those flows do not duplicate this validation. + +**Deviation from DESIGN.md**: `cpt-cf-oagw-design-domain-model` describes a plugin binding with a `(position, plugin_ref, plugin_uuid, config)` shape — an explicit per-entry position field, an identifier, and a configuration payload. The frozen `upstream.v1.schema.json` and `route.v1.schema.json` both define `plugins.items` as a flat array of plain identifier strings, with no per-entry position field and no per-entry configuration payload on the wire. `(position, plugin_ref, plugin_uuid, config)` describes the persisted deployment's storage shape only; the wire contract this process validates against is the plain string array, an entry's position is simply its index in that array, and no configuration payload accompanies an entry on the wire in this configuration. + +**Input**: The submitting resource's `plugins.items` array — a flat array of plain plugin-identifier strings, each either a full GTS identifier or, on upstream writes only, a bare UUID string (the frozen `route.v1.schema.json` admits only the GTS form for route `plugins.items` entries) — and, separately, the upstream's single auth-plugin identifier field (routes carry no `auth` property, so this half of the input applies to upstream writes only; see PM-3). + +**Output**: The validated bindings, in their submitted array order, ready for storage; plus the validated auth-plugin identifier for an upstream write; or a rejection naming the first offending entry. + +**Steps**: +1. [ ] - `p2` - Treat the auth-plugin identifier as a scalar field on the upstream resource, never as an entry in the `plugins.items` array - `inst-pm-bind-auth-scalar` +2. [ ] - `p2` - **FOR EACH** entry in the submitted `plugins.items` array, in array order (the entry's position is its zero-based array index; the wire format carries no separate position field) - `inst-pm-bind-foreach` + 1. [ ] - `p2` - **IF** the entry contains a `~` separator - `inst-pm-bind-if-gts` + 1. [ ] - `p2` - Parse it as a full GTS identifier and take the instance part following `~` - `inst-pm-bind-parse-gts` + 2. [ ] - `p2` - **ELSE** (the entry is a bare UUID string with no `~`; accepted on upstream writes only) - `inst-pm-bind-else-bare-uuid` + 1. [ ] - `p2` - Treat the entry directly as the instance part - `inst-pm-bind-parse-bare-uuid` + 3. [ ] - `p2` - **IF** the instance part parses as a UUID - `inst-pm-bind-if-uuid` + 1. [ ] - `p2` - Resolve against the tenant's custom plugin definitions and confirm the definition's plugin kind is guard or transform; both the GTS form and the bare-UUID form of the same instance part resolve to the same custom plugin definition - `inst-pm-bind-resolve-uuid` + 4. [ ] - `p2` - **ELSE** - `inst-pm-bind-else-named` + 1. [ ] - `p2` - Resolve against the built-in registry for the guard or transform plugin kinds - `inst-pm-bind-resolve-named` + 5. [ ] - `p2` - **IF** resolution fails because the identifier is unknown, names a catalog-only entry with no backing implementation, or names an entry of a plugin kind other than guard or transform - `inst-pm-bind-if-unresolved` + 1. [ ] - `p2` - **RETURN** 400 validation error identifying the offending array index and identifier, rejecting the entire binding list - `inst-pm-bind-400` +3. [ ] - `p2` - **IF** an auth-plugin identifier is present on the upstream resource (never on a route, which has no `auth` field) - `inst-pm-bind-if-auth` + 1. [ ] - `p2` - Resolve it using the same `~`/bare-UUID parsing rule, requiring plugin kind auth, and reject with a 400 validation error naming the auth field if resolution fails - `inst-pm-bind-auth-resolve` +4. [ ] - `p2` - **RETURN** the validated ordered bindings, in their submitted array order — an entry may resolve to a guard or a transform in either order; `plugins.items` is a flat array with no per-entry slot marker, and partitioning the chain into guard-then-transform execution order is a runtime concern owned by `cpt-cf-oagw-feature-traffic-policy`, not this validation step — plus the validated auth-plugin identifier when present, ready for persistence - `inst-pm-bind-return` + +### Check Plugin In-Use + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-pm-check-in-use` + +**Input**: A UUID-backed custom plugin identifier belonging to the calling tenant. + +**Output**: A `referenced_by` object with two arrays, `upstreams` and `routes`, each holding the distinct identifiers of the resources that currently reference the plugin; both arrays are empty when the plugin is unreferenced. + +**Steps**: +1. [ ] - `p2` - Scan the upstream plugin bindings for entries referencing this identifier, collecting the distinct parent upstream identifiers - `inst-pm-inuse-upstream-plugin` +2. [ ] - `p2` - Scan the route plugin bindings for entries referencing this identifier, collecting the distinct parent route identifiers - `inst-pm-inuse-route-plugin` +3. [ ] - `p2` - Scan upstream records whose auth-plugin field references this identifier, collecting the distinct upstream identifiers bound via that scalar field - `inst-pm-inuse-auth-column` +4. [ ] - `p2` - Merge the upstream identifiers from steps 1 and 3 into `referenced_by.upstreams`, and the route identifiers from step 2 into `referenced_by.routes`, each deduplicated - `inst-pm-inuse-merge` +5. [ ] - `p2` - **RETURN** the `referenced_by` object with its `upstreams` and `routes` arrays - `inst-pm-inuse-return` + +## 4. States (CDSL) + +### Custom Plugin Definition Lifecycle + +- [ ] `p2` - **ID**: `cpt-cf-oagw-state-pm-lifecycle` + +**States**: Active, Deleted + +**Initial State**: Active + +**Transitions**: +1. [ ] - `p2` - **FROM** Active **TO** Deleted **WHEN** the owning tenant requests deletion and `cpt-cf-oagw-algo-pm-check-in-use` reports no referencing upstream or route - `inst-pm-state-delete` + +No Updated or Replaced state exists: `cpt-cf-oagw-principle-plugin-immutable` means a definition has exactly one content revision for its entire Active lifetime; a behavior change is always a new definition (a new Active instance) plus re-binding, never a transition on the existing one. + +## 5. Definitions of Done + +Specific implementation tasks derived from flows/algorithms above. + +### Serve the Built-in and Catalog-Only Plugin Catalog + +- [ ] `p2` - **ID**: `cpt-cf-oagw-dod-pm-catalog` + +The system **MUST** serve a fixed catalog covering all three plugin kinds: for Auth, the served entries `noop`, `apikey`, `oauth2_client_cred`, and `oauth2_client_cred_basic` alongside the catalog-only entries `basic` and `bearer`, which carry no backing implementation; for Guard, the served entry `required_headers` alongside the catalog-only entries `timeout` and `cors`; for Transform, the served entry `request_id` alongside the catalog-only entries `logging` and `metrics`. Each catalog entry **MUST** report whether it is actually resolvable at binding time or is catalog-only. + +**Implements**: +- `cpt-cf-oagw-flow-pm-list` +- `cpt-cf-oagw-flow-pm-get` + +**Touches**: +- API: `GET /oagw/v1/plugins` +- API: `GET /oagw/v1/plugins/{id}` +- DB: `cpt-cf-oagw-db-schema` +- Entities: `Plugin` + +### Manage Immutable Custom Plugin Definitions + +- [x] `p2` - **ID**: `cpt-cf-oagw-dod-pm-custom-crud` + +The system **MUST** support creating, listing, retrieving, retrieving the source text of, and deleting tenant-scoped custom plugin definitions, and **MUST NOT** expose any operation that replaces or mutates a definition's stored plugin kind, configuration schema, or source text after creation; a behavior change is always a new definition plus a re-binding of the resources that reference it. + +**Implements**: +- `cpt-cf-oagw-flow-pm-create` +- `cpt-cf-oagw-flow-pm-list` +- `cpt-cf-oagw-flow-pm-get` +- `cpt-cf-oagw-flow-pm-get-source` +- `cpt-cf-oagw-flow-pm-delete` + +**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}` +- DB: `cpt-cf-oagw-db-schema` +- DB Table: `oagw_plugin` +- Entities: `Plugin` + +### Reject Deletion of an In-Use Plugin + +- [x] `p2` - **ID**: `cpt-cf-oagw-dod-pm-in-use-delete` + +The system **MUST** reject deletion of a custom plugin definition that is referenced by at least one upstream or route binding with a 409 response whose body identifies the error as PluginInUse and carries a `referenced_by` object with two arrays, `upstreams` and `routes`, each listing the identifiers of the referencing resources (empty when that resource type does not reference the plugin). + +**Implements**: +- `cpt-cf-oagw-flow-pm-delete` +- `cpt-cf-oagw-algo-pm-check-in-use` + +**Touches**: +- API: `DELETE /oagw/v1/plugins/{id}` +- DB: `cpt-cf-oagw-db-schema` +- DB Table: `oagw_plugin` +- Entities: `Plugin` + +### Validate Ordered Plugin Bindings at Write Time + +- [x] `p2` - **ID**: `cpt-cf-oagw-dod-pm-binding-validation` + +The system **MUST**, on every upstream or route write that carries a `plugins.items` list, resolve every entry's identifier — a full GTS identifier, or, on upstream writes only, a bare UUID string, per the frozen wire schemas (the route schema admits only the GTS form) — with the UUID-backed instance resolved against custom definitions and the named form resolved against the built-in registry, and reject the whole write with a 400 validation error naming the offending array index when any entry names an identifier that does not resolve, names a catalog-only entry with no backing implementation, or names an entry of a plugin kind other than guard or transform. The system **MUST**, on every upstream write that carries an auth-plugin field (routes carry no `auth` property), keep that identity on the upstream's dedicated `auth` field rather than as an entry in `plugins.items`. `plugins.items` ordering **MUST** be treated as the entry's array index; the wire format carries no per-entry position field or configuration payload (see the Deviation from DESIGN.md note under `cpt-cf-oagw-algo-pm-validate-binding`). + +**Implements**: +- `cpt-cf-oagw-algo-pm-validate-binding` + +**Touches**: +- DB: `cpt-cf-oagw-db-schema` +- DB Table: `oagw_upstream_plugin` +- DB Table: `oagw_route_plugin` +- Entities: `Plugin` + +### Document Deferred Execution of Custom Plugin Source + +- [ ] `p2` - **ID**: `cpt-cf-oagw-dod-pm-execution-deferral` + +The system **MUST** store a custom plugin definition's source text without interpreting or executing it in this configuration: `cpt-cf-oagw-nfr-starlark-sandbox` requires a sandboxed execution environment (no network I/O, no file I/O, no imports, enforced timeout and memory limits), and building that sandbox is explicitly out of this feature's scope, deferred to the data-plane plugin-chain execution work that consumes these bindings. A custom plugin bound to an upstream or route **MUST** therefore behave as a documented no-op wherever the plugin chain would otherwise invoke it at request time, rather than being silently skipped without record or causing a request failure. + +**Implements**: +- `cpt-cf-oagw-flow-pm-create` +- `cpt-cf-oagw-algo-pm-validate-binding` + +**Touches**: +- DB: `cpt-cf-oagw-db-schema` +- DB Table: `oagw_plugin` +- Entities: `Plugin` + +## 6. Acceptance Criteria + +- [ ] `POST /oagw/v1/plugins` with a valid auth, guard, or transform definition returns 201 with a body containing a server-generated UUID-backed GTS identifier matching the submitted plugin kind. +- [ ] `POST /oagw/v1/plugins` with a missing or unrecognized plugin kind, or with malformed configuration schema or source text, returns 400. +- [ ] `GET /oagw/v1/plugins` returns 200 with a body listing, for each of the three plugin kinds, exactly the served built-in identifiers (`noop`, `apikey`, `oauth2_client_cred`, `oauth2_client_cred_basic` for auth; `required_headers` for guard; `request_id` for transform) and the catalog-only identifiers (`basic`, `bearer` for auth; `timeout`, `cors` for guard; `logging`, `metrics` for transform), each entry marked with its resolvable-or-catalog-only status, plus the calling tenant's own custom definitions. +- [ ] `GET /oagw/v1/plugins` respects `$top` (default 50, max 100) and `$skip` pagination on the returned list. +- [ ] `GET /oagw/v1/plugins/{id}` for a served built-in identifier, a catalog-only identifier, or the calling tenant's own custom definition returns 200 with that entry's details. +- [ ] `GET /oagw/v1/plugins/{id}` for an identifier that resolves to no catalog entry, or to a custom definition owned by a different tenant, returns 404. +- [ ] `GET /oagw/v1/plugins/{id}/source` for a UUID-backed custom plugin definition owned by the calling tenant returns 200 with the exact source text supplied at creation. +- [ ] `GET /oagw/v1/plugins/{id}/source` for a built-in or catalog-only identifier, an unresolvable identifier, or a custom definition owned by a different tenant returns 404. +- [ ] There is no `PUT /oagw/v1/plugins/{id}` route; a plugin definition's stored plugin kind, configuration schema, and source text are unchanged for the lifetime of its identifier, and the only way to change plugin behavior is creating a new plugin definition via `POST /oagw/v1/plugins` and re-binding the upstream or route to the new identifier. +- [ ] `DELETE /oagw/v1/plugins/{id}` for a custom plugin definition referenced by no upstream or route binding returns 204, and a subsequent `GET /oagw/v1/plugins/{id}` for that identifier returns 404. +- [ ] `DELETE /oagw/v1/plugins/{id}` for a custom plugin definition currently bound to at least one upstream or route returns 409 with a body identifying the error as PluginInUse and a `referenced_by` object whose `referenced_by.upstreams` and `referenced_by.routes` array fields list the identifiers of every referencing upstream and every referencing route respectively. +- [ ] `DELETE /oagw/v1/plugins/{id}` for a served built-in or catalog-only identifier returns 404, since neither is a deletable resource. +- [ ] Submitting an upstream or route write whose `plugins.items` list contains an identifier that resolves to neither a custom definition nor a built-in registry entry is rejected with 400 and the write does not persist any part of the submitted binding list. +- [ ] Submitting an upstream or route write whose `plugins.items` list names a catalog-only identifier (for example the guard `timeout` or `cors` identifier) is rejected with 400, distinguishing it from a successfully bound served identifier of the same plugin kind. +- [ ] An upstream write whose `plugins.items` entry is a bare UUID string (no `~`) resolves to the same custom plugin definition as the equivalent full GTS identifier form for that instance; a route write whose `plugins.items` entry is a bare UUID string is rejected with 400, since the frozen `route.v1.schema.json` admits only the GTS form. +- [ ] A `plugins.items` array's stored order matches its submitted array order exactly; there is no separate position field on the wire, and an entry may resolve to a guard or a transform in either order within the array. +- [ ] An upstream's auth-plugin identity is accepted and stored as a single scalar field on `auth.type`, never as an entry in the `plugins.items` array; the Route schema carries no `auth` property, so no route write may submit a standalone auth-plugin identifier. +- [ ] A custom plugin definition successfully bound to an upstream or route does not cause request failures or execute its source text at proxy time in this configuration; the plugin-chain execution that would invoke it is owned by `cpt-cf-oagw-feature-traffic-policy`, and this feature's responsibility ends at storing the definition and validating the binding. diff --git a/gears/system/oagw/docs/features/proxy-http.md b/gears/system/oagw/docs/features/proxy-http.md new file mode 100644 index 0000000..41979d4 --- /dev/null +++ b/gears/system/oagw/docs/features/proxy-http.md @@ -0,0 +1,471 @@ +# Feature: HTTP Request Proxying + + + + +- [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) + - [1.5 Non-Applicability and Deferrals](#15-non-applicability-and-deferrals) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Proxy HTTP Request](#proxy-http-request) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Alias Resolution](#alias-resolution) + - [Route Matching](#route-matching) + - [Endpoint Selection and Target Host Handling](#endpoint-selection-and-target-host-handling) + - [Header Transformation](#header-transformation) + - [Body and Timeout Enforcement](#body-and-timeout-enforcement) + - [Outbound Connection and Error-Source Labeling](#outbound-connection-and-error-source-labeling) +- [4. Definitions of Done](#4-definitions-of-done) + - [Milestone 1 — Request Routing and Resolution](#milestone-1--request-routing-and-resolution) + - [Milestone 2 — Request Execution and Error Handling](#milestone-2--request-execution-and-error-handling) +- [5. Acceptance Criteria](#5-acceptance-criteria) + + + +- [ ] `p1` - **ID**: `cpt-cf-oagw-featstatus-ph-implemented` + + +- [ ] `p1` - `cpt-cf-oagw-feature-proxy-http` +## 1. Feature Context + +### 1.1 Overview + +This feature executes the data-plane path for a plain HTTP proxied request made to `{METHOD} /oagw/v1/proxy/{alias}` or `/oagw/v1/proxy/{alias}/{*path}`: it resolves the alias to an upstream, matches a route, selects an endpoint, enforces the configured scheme, body-size, and timeout limits, transforms headers in both directions, forwards the request, and labels every response it returns with its origin. + +### 1.2 Purpose + +This feature is the sole owner of the proxy data-plane request path described in the gear decomposition: it consumes the read-only Upstream and Route configuration resolved by upstream and route management and turns it into an actual outbound call, satisfying the gateway's core unified-proxy value proposition and its error-attribution contract toward callers. + +`cpt-cf-oagw-nfr-low-latency` is satisfied by the streaming, non-buffering request/response path this feature already describes (section 3's body-and-timeout process forwards bytes as they arrive rather than buffering to completion on either leg); this configuration asserts no numeric latency budget beyond that design property — no p95 measurement or enforcement mechanism is defined here, and that omission is deliberate rather than silent. + +**Requirements**: `cpt-cf-oagw-fr-request-proxy`, `cpt-cf-oagw-fr-header-transform`, `cpt-cf-oagw-fr-config-layering`, `cpt-cf-oagw-fr-alias-resolution`, `cpt-cf-oagw-nfr-low-latency`, `cpt-cf-oagw-nfr-input-validation`, `cpt-cf-oagw-nfr-ssrf-protection`, `cpt-cf-oagw-nfr-high-availability`, `cpt-cf-oagw-nfr-observability` + +**Principles**: `cpt-cf-oagw-principle-no-retry`, `cpt-cf-oagw-principle-no-cache`, `cpt-cf-oagw-principle-error-source` + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-oagw-actor-app-developer` | Sends the proxied request to `/oagw/v1/proxy/{alias}[/{path}]` and receives either the relayed upstream response or a gateway-produced error. | +| `cpt-cf-oagw-actor-upstream-service` | The external HTTP endpoint the request is forwarded to; its status code and body are relayed to the caller unchanged when it is reached. | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) +- **Design**: [DESIGN.md](../DESIGN.md) +- **Dependencies**: `cpt-cf-oagw-feature-upstream-management` (supplies the resolved Upstream configuration this feature reads), `cpt-cf-oagw-feature-route-management` (supplies the resolved Route configuration this feature reads) + +### 1.5 Non-Applicability and Deferrals + +- **No user interface**: this feature is a data-plane HTTP path with no rendered surface, so UX and accessibility requirements do not apply. +- **No regulated or personal data**: this feature relays request and response bytes it does not interpret as a data controller or processor; it handles no regulated or personal data of its own beyond what an upstream integration chooses to send through it. +- **Circuit breaking deferred (`cpt-cf-oagw-nfr-high-availability`)**: the `CircuitBreakerOpen` error code exists in the gateway's error-code taxonomy (`cpt-cf-oagw-fr-error-codes`) for this purpose, but circuit-breaking itself is deliberately deferred in this configuration — the graded deployment runs a single gear instance with no failure-threshold tracking or health-aware pool to trip a breaker against, and per-upstream breaker state design is out of scope here. The requirement ID is retained and cited rather than dropped. + +## 2. Actor Flows (CDSL) + +**Use cases**: `cpt-cf-oagw-usecase-proxy-request` + +### Proxy HTTP Request + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-ph-proxy-request` + +**Actor**: `cpt-cf-oagw-actor-app-developer` + +**Success Scenarios**: +- A request to a single-endpoint upstream is forwarded to that endpoint and the upstream's response is relayed unchanged with `X-OAGW-Error-Source: upstream`. +- A request to a multi-endpoint upstream that names a specific pool member via `X-OAGW-Target-Host` is forwarded to that member instead of the round-robin choice. + +**Error Scenarios**: +- The alias segment of the path does not resolve to any upstream in the caller's tenant hierarchy. +- The resolved upstream is disabled. +- No enabled route on the resolved upstream matches the request's method and path. +- A multi-endpoint pool requires disambiguation and `X-OAGW-Target-Host` is missing, malformed, or does not name a pool member. +- The selected endpoint's scheme is plaintext and plaintext upstream connections are not currently allowed. +- The request or response body exceeds the configured size limit. +- The upstream does not complete the exchange within the configured timeout. + +**Steps**: +1. [ ] - `p1` - App developer sends `{METHOD} /oagw/v1/proxy/{alias}` or `{METHOD} /oagw/v1/proxy/{alias}/{path}`, optionally with a query string and an `X-OAGW-Target-Host` header - `inst-ph-flow-send` +2. [ ] - `p1` - **API**: `{METHOD} /oagw/v1/proxy/{alias}/{path}` (forwards the transformed request to the resolved endpoint, or relays the upstream's response back to the caller) - `inst-ph-flow-api` +3. [ ] - `p1` - Resolve the alias to an enabled-or-disabled upstream within the caller's tenant hierarchy using the alias resolution process - `inst-ph-flow-resolve-alias` +4. [ ] - `p1` - **IF** no upstream in the hierarchy matches the alias - `inst-ph-flow-if-unknown` + 1. [ ] - `p1` - **RETURN** 404, `X-OAGW-Error-Source: gateway` - `inst-ph-flow-404` +5. [ ] - `p1` - **IF** the resolved upstream is disabled - `inst-ph-flow-if-disabled` + 1. [ ] - `p1` - **RETURN** 503, `X-OAGW-Error-Source: gateway` - `inst-ph-flow-503` +6. [ ] - `p1` - Match the request's method and path against the resolved upstream's enabled routes using the route matching process - `inst-ph-flow-match-route` +7. [ ] - `p1` - **IF** no enabled route matches - `inst-ph-flow-if-no-route` + 1. [ ] - `p1` - **RETURN** 404 (no matching route), `X-OAGW-Error-Source: gateway` - `inst-ph-flow-404-route` +8. [ ] - `p1` - Select an endpoint from the matched route's upstream pool using the endpoint selection process, honoring `X-OAGW-Target-Host` when present, then strip that header - `inst-ph-flow-select-endpoint` +9. [ ] - `p1` - **IF** endpoint selection reports a missing, invalid, or unknown target host - `inst-ph-flow-if-bad-target-host` + 1. [ ] - `p1` - **RETURN** the corresponding 400, `X-OAGW-Error-Source: gateway` - `inst-ph-flow-400-target-host` +10. [ ] - `p1` - **IF** the selected endpoint's scheme is plaintext and plaintext upstream connections are not allowed - `inst-ph-flow-if-scheme-blocked` + 1. [ ] - `p1` - **RETURN** 502, `X-OAGW-Error-Source: gateway`, without attempting to connect, per the `DownstreamError` mapping in `cpt-cf-oagw-fr-error-codes` - `inst-ph-flow-scheme-blocked` +11. [ ] - `p1` - Transform the inbound headers (strip routing and hop-by-hop headers, replace `Host`, apply configured passthrough/set/add/remove rules) and validate the declared body length against the size limit - `inst-ph-flow-transform-request` +12. [ ] - `p1` - **IF** the declared or observed body length exceeds the size limit - `inst-ph-flow-if-body-too-large` + 1. [ ] - `p1` - **RETURN** 413, `X-OAGW-Error-Source: gateway`, before buffering the body - `inst-ph-flow-413` +13. [ ] - `p1` - Stream the transformed request to the selected endpoint's authority, bounded by the configured request timeout - `inst-ph-flow-forward` +14. [ ] - `p1` - **IF** the timeout elapses before the upstream completes the exchange - `inst-ph-flow-if-timeout` + 1. [ ] - `p1` - **RETURN** 504, `X-OAGW-Error-Source: gateway`, and abort the outbound connection, per the `Timeout` mapping in `cpt-cf-oagw-fr-error-codes` - `inst-ph-flow-timeout` +15. [ ] - `p1` - Transform the upstream's response headers (strip hop-by-hop headers, apply configured response rules) - `inst-ph-flow-transform-response` +16. [ ] - `p1` - **RETURN** the upstream's response with its original status code unchanged and `X-OAGW-Error-Source: upstream` - `inst-ph-flow-relay` + +## 3. Processes / Business Logic (CDSL) + +Reusable internal steps invoked by the actor flow above, grouped by the entry's two milestones: request routing and resolution, then request execution and error handling. + +### Alias Resolution + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-ph-alias-resolution` + +**Input**: the raw path segment following `/oagw/v1/proxy/` and the caller's tenant identifier + +**Output**: a resolved upstream and the remaining path suffix, or an unknown-alias / disabled-upstream signal + +**Steps**: +1. [ ] - `p1` - Extract the first path segment after `/oagw/v1/proxy/` as the alias; treat any remaining path as the path suffix carried into route matching - `inst-ph-alias-extract` +2. [ ] - `p1` - Search the caller's tenant hierarchy for an upstream with that alias, starting at the caller's own tenant and walking upward toward the root - `inst-ph-alias-search` +3. [ ] - `p1` - **IF** the caller's own tenant (or the closest ancestor tried so far) defines an upstream with that alias - `inst-ph-alias-if-found` + 1. [ ] - `p1` - That upstream is the resolved match; it shadows any ancestor upstream sharing the same alias, though limits enforced by an ancestor still apply to the resolved upstream - `inst-ph-alias-shadow` +4. [ ] - `p1` - **ELSE** continue the walk to each ancestor tenant in turn until the alias is found or the root tenant has been searched - `inst-ph-alias-continue` +5. [ ] - `p1` - **IF** no tenant in the hierarchy defines an upstream with that alias - `inst-ph-alias-if-unknown` + 1. [ ] - `p1` - **RETURN** unknown-alias (404) - `inst-ph-alias-unknown` +6. [ ] - `p1` - **IF** the resolved upstream's enabled flag is false - `inst-ph-alias-if-disabled` + 1. [ ] - `p1` - **RETURN** upstream-disabled (503) - `inst-ph-alias-disabled` +7. [ ] - `p1` - **RETURN** the resolved upstream and the remaining path suffix - `inst-ph-alias-return` + +### Route Matching + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-ph-route-matching` + +**Input**: the resolved upstream's enabled routes, the request method, the path suffix from alias resolution, and the inbound query string + +**Output**: a matched route plus the effective forwarded path and query, or a no-match signal + +**Steps**: +1. [ ] - `p1` - Filter the resolved upstream's enabled routes to those whose method allowlist includes the request's method - `inst-ph-route-filter-method` +2. [ ] - `p1` - Among the remaining routes, select the one whose configured path is the longest prefix match of the path suffix - `inst-ph-route-longest-prefix` +3. [ ] - `p1` - **IF** no route satisfies both the method allowlist and the prefix match - `inst-ph-route-if-none` + 1. [ ] - `p1` - **RETURN** no-matching-route (404) - `inst-ph-route-404` +4. [ ] - `p1` - **IF** the matched route's `path_suffix_mode` is `append` - `inst-ph-route-if-append` + 1. [ ] - `p1` - Append the portion of the path suffix beyond the matched route's configured path to the upstream's target path - `inst-ph-route-append` +5. [ ] - `p1` - **ELSE** (`path_suffix_mode` is `disabled`) - `inst-ph-route-else-disabled-suffix` + 1. [ ] - `p1` - Reject any remaining path suffix beyond the matched route's configured path rather than forwarding it - `inst-ph-route-reject-suffix` +6. [ ] - `p1` - **IF** the matched route's `query_allowlist` is non-empty - `inst-ph-route-if-allowlist` + 1. [ ] - `p1` - Forward only the inbound query parameters named in the allowlist; drop the rest - `inst-ph-route-filter-query` +7. [ ] - `p1` - **RETURN** the matched route, the effective forwarded path, and the effective forwarded query - `inst-ph-route-return` + +### Endpoint Selection and Target Host Handling + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-ph-endpoint-selection` + +**Input**: the matched route's upstream endpoint pool and the inbound `X-OAGW-Target-Host` header, if present + +**Output**: a selected endpoint (scheme, host, port), or a missing/invalid/unknown target-host signal + +**Decision matrix** (adapted from the request-routing decision record's target-host behavior matrix): + +| Pool size | Alias origin | Header present | Header well-formed | Behavior | +|---|---|---|---|---| +| Any | Any | Yes | No (not a bare hostname or IP address) | Invalid-target-host (400), regardless of pool size or alias origin | +| Single endpoint | Any | No | — | Route to the sole endpoint; no disambiguation needed | +| Single endpoint | Any | Yes | Yes | Route to the sole endpoint if it matches, otherwise unknown-target-host (400) | +| Multiple endpoints | Not derived from a shared endpoint-hostname suffix | No | — | Round-robin across the pool | +| Multiple endpoints | Not derived from a shared endpoint-hostname suffix | Yes | Yes | Route to the named endpoint, bypassing round-robin | +| Multiple endpoints | Derived from a shared endpoint-hostname suffix | No | — | Missing-target-host (400) | +| Multiple endpoints | Derived from a shared endpoint-hostname suffix | Yes | Yes | Route to the named endpoint if it is a pool member, otherwise unknown-target-host (400) | + +**Steps**: +1. [ ] - `p1` - **IF** `X-OAGW-Target-Host` is present on the inbound request - `inst-ph-endpoint-if-header-present` + 1. [ ] - `p1` - **IF** the value is not a bare hostname or IP address (no scheme, port, path, or other characters) - `inst-ph-endpoint-if-malformed` + 1. [ ] - `p1` - **RETURN** invalid-target-host (400), echoing the offending value - `inst-ph-endpoint-invalid` +2. [ ] - `p1` - **IF** the pool has exactly one endpoint - `inst-ph-endpoint-if-single` + 1. [ ] - `p1` - **IF** the header is absent, or present and matches the sole endpoint's host - `inst-ph-endpoint-single-match` + 1. [ ] - `p1` - **RETURN** the sole endpoint - `inst-ph-endpoint-single-return` + 2. [ ] - `p1` - **ELSE** (header present but does not match) - `inst-ph-endpoint-single-mismatch` + 1. [ ] - `p1` - **RETURN** unknown-target-host (400), listing the one valid host - `inst-ph-endpoint-single-unknown` +3. [ ] - `p1` - **ELSE** (the pool has two or more endpoints) - `inst-ph-endpoint-else-multi` + 1. [ ] - `p1` - **IF** the header is present - `inst-ph-endpoint-multi-if-header` + 1. [ ] - `p1` - **IF** its value matches a pool member's host - `inst-ph-endpoint-multi-if-match` + 1. [ ] - `p1` - **RETURN** the named endpoint, bypassing round-robin - `inst-ph-endpoint-multi-named` + 2. [ ] - `p1` - **ELSE** - `inst-ph-endpoint-multi-else-nomatch` + 1. [ ] - `p1` - **RETURN** unknown-target-host (400), echoing the value and listing valid hosts - `inst-ph-endpoint-multi-unknown` + 2. [ ] - `p1` - **ELSE** (header absent) - `inst-ph-endpoint-multi-else-noheader` + 1. [ ] - `p1` - **IF** the pool's alias is derived from a shared endpoint-hostname suffix - `inst-ph-endpoint-multi-if-suffix` + 1. [ ] - `p1` - **RETURN** missing-target-host (400), listing valid hosts - `inst-ph-endpoint-multi-missing` + 2. [ ] - `p1` - **ELSE** - `inst-ph-endpoint-multi-else-explicit` + 1. [ ] - `p1` - **RETURN** the next endpoint in round-robin order - `inst-ph-endpoint-multi-roundrobin` +4. [ ] - `p1` - Strip `X-OAGW-Target-Host` from the headers before forwarding, whether or not it was present, since it is a routing header the gateway consumes - `inst-ph-endpoint-strip-header` + +### Header Transformation + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-ph-header-transform` + +**Input**: the inbound request headers, the selected endpoint's authority, the upstream's configured request/response header rules, and the upstream's response headers (on the return trip) + +**Output**: the outbound request headers sent to the endpoint, and the outbound response headers returned to the caller + +**Steps**: +1. [ ] - `p1` - Remove routing headers the gateway consumes (`X-OAGW-Target-Host`) from the inbound headers - `inst-ph-header-strip-routing` +2. [ ] - `p1` - Remove the hop-by-hop header set (`Connection`, `Keep-Alive`, `Proxy-Authenticate`, `Proxy-Authorization`, `TE`, `Trailer`, `Transfer-Encoding`, `Upgrade`) from the inbound headers - `inst-ph-header-strip-hop-request` +3. [ ] - `p1` - Apply the upstream's configured request passthrough mode: `none` forwards no remaining inbound header other than the framing-header exception below, `allowlist` forwards only headers named in the passthrough allowlist, `all` forwards every remaining inbound header - `inst-ph-header-passthrough` +4. [ ] - `p1` - Apply the upstream's configured request header rules in order: remove the named headers, then set (overwrite) the named headers, then add (append, duplicates allowed) the named headers - `inst-ph-header-set-add-remove-request` +5. [ ] - `p1` - Replace the request's `Host` (or equivalent authority) with the selected endpoint's authority - `inst-ph-header-replace-host` +6. [ ] - `p1` - **RETURN** the outbound request headers - `inst-ph-header-return-request` +7. [ ] - `p1` - Remove the hop-by-hop header set from the upstream's response headers - `inst-ph-header-strip-hop-response` +8. [ ] - `p1` - Apply the upstream's configured response header rules in order: remove the named headers, then set (overwrite), then add (append) - `inst-ph-header-set-add-remove-response` +9. [ ] - `p1` - **RETURN** the outbound response headers - `inst-ph-header-return-response` + +**Framing-header exception under `passthrough: none`**: the implementation forwards `content-type`, `content-length`, `accept`, and `accept-encoding` even when the upstream's configured request passthrough mode is `none`, because a body cannot be interpreted without its framing headers. This is a documented, deliberate exception to step 3 above, in the same spirit as the `Upgrade`/`Connection` exception `cpt-cf-oagw-feature-proxy-streaming` documents for upgrade requests (`cpt-cf-oagw-dod-ps-upgrade-header-exception`): `none` forwards no remaining inbound header other than these four. + +### Body and Timeout Enforcement + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-ph-body-timeout` + +**Input**: the outbound request headers and body stream, the configured body size cap, and the configured `proxy_timeout_secs` + +**Output**: the streamed upstream exchange, a gateway body-too-large (413) / timeout (504) signal, or — for an undeclared response body that exceeds the cap mid-stream, after relaying has begun — an aborted connection with no status substituted + +**Steps**: +1. [ ] - `p1` - **IF** the request declares a body length exceeding the 100 MB cap - `inst-ph-body-if-declared-too-large` + 1. [ ] - `p1` - **RETURN** body-too-large (413) before any body bytes are buffered - `inst-ph-body-413-declared` +2. [ ] - `p1` - **ELSE** stream the request body to the endpoint without fully buffering it, counting bytes as they pass - `inst-ph-body-stream-request` +3. [ ] - `p1` - **IF** the streamed byte count exceeds the 100 MB cap before the body completes - `inst-ph-body-if-observed-too-large` + 1. [ ] - `p1` - Abort the request and **RETURN** body-too-large (413) - `inst-ph-body-413-observed` +4. [ ] - `p1` - Start a timeout bounded by the configured `proxy_timeout_secs` when the outbound connection begins - `inst-ph-body-start-timeout` +5. [ ] - `p1` - **IF** the upstream does not complete its response within the timeout - `inst-ph-body-if-timeout` + 1. [ ] - `p1` - Abort the outbound connection and **RETURN** 504, `X-OAGW-Error-Source: gateway`, per the `Timeout` mapping in `cpt-cf-oagw-fr-error-codes` - `inst-ph-body-timeout-return` +6. [ ] - `p1` - **ELSE IF** the upstream's response declares a `Content-Length` exceeding the 100 MB cap and no response status or body bytes have yet been relayed to the caller - `inst-ph-body-if-response-declared-too-large` + 1. [ ] - `p1` - **RETURN** body-too-large (413), `X-OAGW-Error-Source: gateway`, without relaying the upstream's status line or any body bytes - `inst-ph-body-413-response-declared` +7. [ ] - `p1` - **ELSE** relay the upstream's status and headers to the caller, then stream the response body back incrementally, counting bytes as they pass - `inst-ph-body-stream-response` +8. [ ] - `p1` - **IF** the response's length is undeclared (chunked or otherwise unknown) and the streamed byte count exceeds the 100 MB cap after relaying has begun - `inst-ph-body-if-response-observed-too-large` + 1. [ ] - `p1` - Stop relaying and abort the client connection without a clean close, since the status and headers already sent cannot be replaced with a 413 - `inst-ph-body-abort-response-observed` +9. [ ] - `p1` - **RETURN** the streamed exchange, or the aborted-connection outcome when the undeclared response body exceeded the cap mid-stream - `inst-ph-body-return` + +### Outbound Connection and Error-Source Labeling + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-ph-outbound-connection` + +**Input**: the selected endpoint (scheme, host, port), the transformed outbound request, the `allow_http_upstream` flag, and the `ssrf_policy.enabled` flag + +**Output**: a response labeled with the correct `X-OAGW-Error-Source` value + +**Steps**: +1. [ ] - `p1` - **IF** `ssrf_policy.enabled` is true - `inst-ph-outbound-if-ssrf-enabled` + 1. [ ] - `p1` - Check the resolved outbound target against the SSRF policy before connecting - `inst-ph-outbound-ssrf-check` + 2. [ ] - `p1` - **IF** the policy blocks the resolved target - `inst-ph-outbound-if-ssrf-blocked` + 1. [ ] - `p1` - **RETURN** 502, `X-OAGW-Error-Source: gateway`, without connecting - `inst-ph-outbound-ssrf-blocked-return` +2. [ ] - `p1` - **ELSE** (`ssrf_policy.enabled` is false, as in the graded configuration) the gate is a pass-through and the resolved target proceeds unchecked - `inst-ph-outbound-ssrf-passthrough` +3. [ ] - `p1` - **IF** the selected endpoint's scheme is plaintext and `allow_http_upstream` is false - `inst-ph-outbound-if-scheme-blocked` + 1. [ ] - `p1` - **RETURN** 502, `X-OAGW-Error-Source: gateway`, without opening a socket to the endpoint, per the `DownstreamError` mapping in `cpt-cf-oagw-fr-error-codes` - `inst-ph-outbound-scheme-blocked` +4. [ ] - `p1` - **ELSE** make exactly one outbound connection attempt to the selected endpoint for this request - `inst-ph-outbound-attempt` +5. [ ] - `p1` - **IF** the connection attempt or request send fails before any upstream response is received - `inst-ph-outbound-if-conn-fail` + 1. [ ] - `p1` - **RETURN** 502, `X-OAGW-Error-Source: gateway`, per the `DownstreamError` mapping in `cpt-cf-oagw-fr-error-codes`; do not automatically retry the client's request - `inst-ph-outbound-conn-fail-return` +6. [ ] - `p1` - **ELSE** (the upstream returns a response, including a 4xx or 5xx status) - `inst-ph-outbound-else-response` + 1. [ ] - `p1` - Relay the response with its original status code unchanged and `X-OAGW-Error-Source: upstream`; do not store the response for reuse on a later request - `inst-ph-outbound-relay` +7. [ ] - `p1` - **RETURN** the labeled response - `inst-ph-outbound-return` + +## 4. Definitions of Done + +### Milestone 1 — Request Routing and Resolution + +#### Alias Resolution, Unknown Alias, and Disabled Upstream + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-ph-alias-resolution` + +The system **MUST** extract the alias segment from the proxy request path and resolve it to an upstream within the calling tenant. The system **MUST** respond 404 when no upstream owned by the calling tenant defines that alias, and **MUST** respond 503 when the resolved upstream's enabled flag is false, in both cases with `X-OAGW-Error-Source: gateway`. Walking the tenant hierarchy from the caller's own tenant toward the root, and shadowing an ancestor's same-alias upstream by a closer tenant, are not served in this configuration — the gear has no access to a tenant-hierarchy source, per the deferral recorded in `cpt-cf-oagw-feature-upstream-management`'s §1.5 — so alias resolution operates within the calling tenant only. + +**Implements**: +- `cpt-cf-oagw-flow-ph-proxy-request` +- `cpt-cf-oagw-algo-ph-alias-resolution` + +**Constraints**: None + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}` +- API: `{METHOD} /oagw/v1/proxy/{alias}/{path}` +- Entities: `Upstream` + +#### Route Matching, Path Suffix, and Query Allowlist + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-ph-route-matching` + +The system **MUST** select among the resolved upstream's enabled routes by filtering on the request method and then choosing the longest path-prefix match against the remaining path. The system **MUST** apply the matched route's `path_suffix_mode` to decide whether the path beyond the matched prefix is appended to the upstream's target path or rejected, and **MUST** filter the forwarded query string to only the parameters named in the matched route's `query_allowlist` when that allowlist is non-empty. The system **MUST** respond 404 with `X-OAGW-Error-Source: gateway` when no enabled route satisfies both the method and prefix conditions. + +**Implements**: +- `cpt-cf-oagw-flow-ph-proxy-request` +- `cpt-cf-oagw-algo-ph-route-matching` + +**Constraints**: None + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}/{path}` +- Entities: `Route` + +#### Endpoint Selection and the X-OAGW-Target-Host Decision Matrix + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-ph-endpoint-selection` + +The system **MUST** select an endpoint from the matched route's upstream pool without requiring any header when the pool has a single endpoint, and **MUST** select by round-robin among a multi-endpoint pool unless `X-OAGW-Target-Host` names a specific pool member, in which case that member is selected and round-robin is bypassed. The system **MUST** read `X-OAGW-Target-Host` for routing and then strip it before forwarding, in both the HTTP/1.1 and HTTP/2 request forms. The system **MUST** produce exactly three distinct 400 outcomes, each with `X-OAGW-Error-Source: gateway`: a missing-target-host response, produced when a multi-endpoint pool whose alias is derived from a shared endpoint-hostname suffix receives no header, listing the pool's valid hosts; an invalid-target-host response, produced when the header value is not a bare hostname or IP address, echoing the offending value; and an unknown-target-host response, produced when a well-formed header value does not match any pool member, echoing the offending value and listing the pool's valid hosts. + +**Implements**: +- `cpt-cf-oagw-flow-ph-proxy-request` +- `cpt-cf-oagw-algo-ph-endpoint-selection` + +**Constraints**: None + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}/{path}` +- Entities: `Upstream`, `Endpoint` + +### Milestone 2 — Request Execution and Error Handling + +#### Scheme Enforcement for Outbound Connections + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-ph-scheme-enforcement` + +The system **MUST** check the selected endpoint's configured scheme immediately before opening the outbound connection: when that scheme is plaintext, the system **MUST** connect only if `allow_http_upstream` is true, and **MUST** refuse the request with 502 (`X-OAGW-Error-Source: gateway`, the `DownstreamError` mapping in `cpt-cf-oagw-fr-error-codes`), without attempting the connection, when `allow_http_upstream` is false. This is the only point in the request path where `allow_http_upstream` has any effect — it does not influence whether an upstream configured with a plaintext scheme can be created and stored, which is a separate, earlier concern. In the graded configuration `allow_http_upstream` is true, so a plaintext endpoint is connected to normally. + +**Implements**: +- `cpt-cf-oagw-flow-ph-proxy-request` +- `cpt-cf-oagw-algo-ph-outbound-connection` + +**Constraints**: `cpt-cf-oagw-constraint-https-only`, `cpt-cf-oagw-constraint-no-direct-internet` + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}/{path}` +- Entities: `Endpoint` + +#### SSRF Policy Gate on the Connection Path + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-ph-ssrf-gate` + +The system **MUST** check the resolved outbound target against the SSRF policy immediately before connecting when `ssrf_policy.enabled` is true, and **MUST** refuse a blocked target with 502, `X-OAGW-Error-Source: gateway`, without connecting. In the graded configuration `ssrf_policy.enabled` is false, so this gate is a pass-through and every resolved target proceeds to connection unchecked; `cpt-cf-oagw-nfr-ssrf-protection` is satisfied by the gate's existence and behavior when enabled, not by its being active in this configuration. + +**Implements**: +- `cpt-cf-oagw-flow-ph-proxy-request` +- `cpt-cf-oagw-algo-ph-outbound-connection` + +**Constraints**: None + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}/{path}` +- Entities: `Endpoint` + +#### Header Transformation in Both Directions + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-ph-header-transform` + +The system **MUST** consume routing headers (`X-OAGW-Target-Host`) and never forward them, **MUST** strip the hop-by-hop header set (`Connection`, `Keep-Alive`, `Proxy-Authenticate`, `Proxy-Authorization`, `TE`, `Trailer`, `Transfer-Encoding`, `Upgrade`) from both the outbound request and the returned response, **MUST** replace the request's host authority with the selected endpoint's authority, and **MUST** apply the upstream's configured request and response header set/add/remove rules together with the configured passthrough mode (`none`, `allowlist`, or `all`) for inbound header forwarding. Under `passthrough: none`, the implementation forwards `content-type`, `content-length`, `accept`, and `accept-encoding` regardless, since a body cannot be interpreted without its framing headers; this is a documented, deliberate exception, in the same spirit as the `Upgrade`/`Connection` exception `cpt-cf-oagw-dod-ps-upgrade-header-exception` documents for upgrade requests. + +**Implements**: +- `cpt-cf-oagw-flow-ph-proxy-request` +- `cpt-cf-oagw-algo-ph-header-transform` + +**Constraints**: None + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}/{path}` +- Entities: `Upstream` + +#### Body Size Cap Enforced Before Buffering + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-ph-body-limit` + +The system **MUST** stream request and response bodies to their destination rather than fully buffering them, and **MUST** cap both directions at 100 MB. On the request side, and on the response side when the upstream declares a `Content-Length` over the cap before any response bytes are relayed, the system **MUST** reject with 413 before any body bytes are buffered or relayed. On the response side, when the upstream's declared length is unknown (chunked or otherwise undeclared) and the cap is exceeded after the status and headers have already been relayed, the system **MUST** stop relaying and abort the connection without a clean close, since no status can be substituted once relaying has begun. + +**Implements**: +- `cpt-cf-oagw-flow-ph-proxy-request` +- `cpt-cf-oagw-algo-ph-body-timeout` + +**Constraints**: `cpt-cf-oagw-constraint-body-limit` + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}/{path}` + +#### Timeout Enforcement + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-ph-timeout` + +The system **MUST** bound each outbound proxy exchange by the configured `proxy_timeout_secs` (2 seconds in the graded configuration) and **MUST** respond with 504, `X-OAGW-Error-Source: gateway`, per the `Timeout` mapping in `cpt-cf-oagw-fr-error-codes`, when the upstream does not complete the exchange within that bound. + +**Implements**: +- `cpt-cf-oagw-flow-ph-proxy-request` +- `cpt-cf-oagw-algo-ph-body-timeout` + +**Constraints**: None + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}/{path}` + +#### Error-Source Distinction on Every Proxy Response + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-ph-error-source` + +The system **MUST** attach `X-OAGW-Error-Source: gateway` to every response the gateway itself produces on the proxy path, and **MUST** attach `X-OAGW-Error-Source: upstream` to every response relayed from the upstream, including when the upstream itself returned a 4xx or 5xx status. The system **MUST** relay the upstream's own status code unchanged and **MUST NOT** rewrite it. + +**Implements**: +- `cpt-cf-oagw-flow-ph-proxy-request` +- `cpt-cf-oagw-algo-ph-outbound-connection` + +**Constraints**: None + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}/{path}` + +#### Correlation Identifier and Outcome Recording + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-ph-observability` + +The system **MUST** assign a correlation identifier to every proxied request and **MUST** record that request's outcome (its resolved status code and its `X-OAGW-Error-Source` label) against that identifier, giving `cpt-cf-oagw-nfr-observability`'s per-request logging requirement real behavior on the proxy data-plane path. In this configuration the correlation identifier is the `x-request-id` the host runtime already attaches to every response, not an identifier this feature mints itself, and the outcome is recorded through the platform's tracing facility (connection and relay outcomes recorded as trace events) rather than through a metrics pipeline this feature owns; a dedicated metrics exporter is deferred, and this DoD is satisfied by the correlation identifier and the traced outcome record, not by exported metrics. + +**Implements**: +- `cpt-cf-oagw-flow-ph-proxy-request` + +**Constraints**: None + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}/{path}` + +#### No Automatic Whole-Request Retries and No Response Caching + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-ph-no-retry-no-cache` + +The system **MUST** make exactly one outbound attempt per client request and **MUST NOT** automatically re-issue the client's request as a whole on failure, and **MUST NOT** cache an upstream response for reuse on a subsequent request. + +**Implements**: +- `cpt-cf-oagw-flow-ph-proxy-request` +- `cpt-cf-oagw-algo-ph-outbound-connection` + +**Constraints**: None + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}/{path}` + +## 5. Acceptance Criteria + +- [ ] A request that resolves to a known, enabled upstream with a matching route and reachable endpoint returns the upstream's original status code and body, with `X-OAGW-Error-Source: upstream` present, including for a 200 response. +- [ ] A request whose upstream returns 500 is relayed to the caller as 500, with the upstream's original body unchanged and `X-OAGW-Error-Source: upstream`. +- [ ] A request naming an alias that does not resolve to any upstream in the caller's tenant hierarchy returns 404 with `X-OAGW-Error-Source: gateway`. +- [ ] A request resolving to a disabled upstream returns 503 with `X-OAGW-Error-Source: gateway`. +- [ ] A request whose method or path does not match any enabled route on the resolved upstream returns 404 with `X-OAGW-Error-Source: gateway`. +- [ ] A multi-endpoint pool whose alias is derived from a shared endpoint-hostname suffix, called without `X-OAGW-Target-Host`, returns 400 listing the pool's valid hosts, with `X-OAGW-Error-Source: gateway`. +- [ ] The same pool, called with an `X-OAGW-Target-Host` value that is not a bare hostname or IP address, returns 400 echoing that value as the invalid value, with `X-OAGW-Error-Source: gateway`. +- [ ] The same pool, called with a well-formed but unrecognized `X-OAGW-Target-Host` value, returns 400 echoing that value and listing the pool's valid hosts, with `X-OAGW-Error-Source: gateway`. +- [ ] The same pool, called with an `X-OAGW-Target-Host` value matching a pool member, is forwarded to that specific member rather than to the round-robin choice. +- [ ] A single-endpoint upstream accepts a request with no `X-OAGW-Target-Host` and forwards it to its one endpoint. +- [ ] The request the upstream receives contains none of the hop-by-hop headers (`Connection`, `Keep-Alive`, `Proxy-Authenticate`, `Proxy-Authorization`, `TE`, `Trailer`, `Transfer-Encoding`, `Upgrade`), and its host header names the upstream's authority rather than the caller's. +- [ ] The request the upstream receives does not contain `X-OAGW-Target-Host`, even when the caller sent it. +- [ ] When the matched route's `query_allowlist` is non-empty, only the listed query parameters reach the upstream; parameters not on the list are dropped. +- [ ] When the matched route's `path_suffix_mode` is `append`, the path beyond the matched route's configured path is appended to the upstream's request path; when it is `disabled`, sending such a suffix is rejected. +- [ ] A request declaring a body length larger than 100 MB is rejected with 413 before any body bytes are buffered. +- [ ] A request to an upstream whose endpoint scheme is plaintext succeeds when `allow_http_upstream` is true, as in the graded configuration. +- [ ] A request that does not receive a complete upstream response within the configured `proxy_timeout_secs` (2 seconds in the graded configuration) returns 504 with `X-OAGW-Error-Source: gateway`. +- [ ] An upstream response that declares a `Content-Length` over 100 MB is rejected with 413, `X-OAGW-Error-Source: gateway`, before the status line or any body bytes are relayed to the caller. +- [ ] An upstream response with no declared length (chunked) whose relayed body exceeds 100 MB after relaying has begun is not completed with a substituted status; the gateway stops relaying and aborts the connection instead. +- [ ] Exactly one outbound connection attempt is made per client request; the gateway never automatically re-issues the same client request as a whole after a failed attempt. +- [ ] Two consecutive, identical requests to the same upstream each produce a fresh outbound call; no response is served from a cache for the second request. +- [ ] With `ssrf_policy.enabled` false, as in the graded configuration, a request to a resolved target that a policy-enabled deployment would block still connects normally, demonstrating the gate is a pass-through here. +- [ ] Every proxied request's log record carries a correlation identifier and the request's resolved outcome (status code and `X-OAGW-Error-Source` label). 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..f6aabe7 --- /dev/null +++ b/gears/system/oagw/docs/features/proxy-streaming.md @@ -0,0 +1,361 @@ +# Feature: Streaming and Upgrade Proxying + + + + +- [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) + - [1.5 Non-Applicability and Deferrals](#15-non-applicability-and-deferrals) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [SSE Stream Relay](#sse-stream-relay) + - [WebSocket Upgrade Relay](#websocket-upgrade-relay) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [SSE Connection Lifecycle Management](#sse-connection-lifecycle-management) + - [WebSocket Handshake and Frame Relay](#websocket-handshake-and-frame-relay) +- [4. States (CDSL)](#4-states-cdsl) + - [Streaming Connection State Machine](#streaming-connection-state-machine) +- [5. Definitions of Done](#5-definitions-of-done) + - [SSE responses are relayed incrementally, not buffered](#sse-responses-are-relayed-incrementally-not-buffered) + - [SSE event framing is relayed byte-for-byte](#sse-event-framing-is-relayed-byte-for-byte) + - [SSE connection lifecycle covers all three endings](#sse-connection-lifecycle-covers-all-three-endings) + - [The proxy request timeout bounds establishment only, not stream lifetime](#the-proxy-request-timeout-bounds-establishment-only-not-stream-lifetime) + - [WebSocket upgrade requests are recognised and handshaked](#websocket-upgrade-requests-are-recognised-and-handshaked) + - [Upgrade and Connection headers are the one documented hop-by-hop exception](#upgrade-and-connection-headers-are-the-one-documented-hop-by-hop-exception) + - [Requested subprotocol negotiation is relayed, not invented](#requested-subprotocol-negotiation-is-relayed-not-invented) + - [WebSocket frames and close codes relay in both directions](#websocket-frames-and-close-codes-relay-in-both-directions) + - [Upstream upgrade refusal is relayed verbatim](#upstream-upgrade-refusal-is-relayed-verbatim) + - [Error-source distinction applies to streaming and upgrade paths](#error-source-distinction-applies-to-streaming-and-upgrade-paths) + - [WebTransport remains undeferred-from-the-record but unserved](#webtransport-remains-undeferred-from-the-record-but-unserved) +- [6. Acceptance Criteria](#6-acceptance-criteria) + + + +- [ ] `p1` - **ID**: `cpt-cf-oagw-featstatus-ps-implemented` + + +- [ ] `p1` - `cpt-cf-oagw-feature-proxy-streaming` +## 1. Feature Context + +### 1.1 Overview + +This feature extends the proxy surface `/oagw/v1/proxy/{alias}/{*path}` to two exchange shapes that are not a single request/response pair: server-sent-event streams and WebSocket protocol upgrades. Alias resolution, route matching, endpoint selection, and inbound/outbound header transformation for the initial request are inherited unchanged from the sibling feature that establishes that surface (`cpt-cf-oagw-feature-proxy-http`) and are not redefined here; this feature covers only what changes once the response is recognised as a stream or the request is recognised as an upgrade. + +### 1.2 Purpose + +The system must relay long-lived, incrementally-produced upstream responses (SSE) and bidirectional, connection-oriented exchanges (WebSocket) without buffering them to completion or treating them like ordinary bounded request/response pairs, while preserving the same error-source and connection-lifecycle observability guarantees that apply to plain HTTP proxying. + +WebTransport session flows are named alongside WebSocket in the requirement text below, but no WebTransport design detail exists beyond the requirement statement and the `wt` upstream scheme enum value. This feature does not serve WebTransport in this configuration: the requirement ID is retained and cited rather than dropped, and the deferral is deliberate — it is out of scope pending further design work, not an oversight. A request that names a `wt`-scheme upstream is treated the same as any other unsupported upgrade type: the gateway does not attempt a WebTransport session and responds with 501, `X-OAGW-Error-Source: gateway`. + +**Requirements**: `cpt-cf-oagw-fr-streaming` + +**Principles**: `cpt-cf-oagw-principle-error-source` + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-oagw-actor-app-developer` | Opens an SSE or WebSocket connection through the proxy alias and consumes the relayed stream or frames. | +| `cpt-cf-oagw-actor-upstream-service` | Produces the SSE event stream or accepts/refuses the WebSocket upgrade and exchanges frames once upgraded. | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) +- **Design**: [DESIGN.md](../DESIGN.md) +- **Dependencies**: `cpt-cf-oagw-feature-proxy-http` — this feature reuses that feature's alias resolution, route matching, endpoint selection, and header transformation for the request/upgrade handshake and only adds streaming- and upgrade-specific behavior on top. + +### 1.5 Non-Applicability and Deferrals + +- **No user interface**: this feature is a data-plane streaming and upgrade path with no rendered surface, so UX and accessibility requirements do not apply. +- **No regulated or personal data**: this feature relays event and frame bytes it does not interpret as a data controller or processor; it handles no regulated or personal data of its own beyond what an upstream integration chooses to send through it. +- **WebTransport deferred**: as described in section 1.2, WebTransport session flows are named alongside WebSocket in the requirement text but are not served in this configuration; the requirement ID is retained and cited rather than dropped, and a `wt`-scheme upgrade request receives 501 rather than an attempted session. + +## 2. Actor Flows (CDSL) + +**Use cases**: `cpt-cf-oagw-usecase-sse-streaming` + +### SSE Stream Relay + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-ps-sse-relay` + +**Actor**: `cpt-cf-oagw-actor-app-developer` + +**Success Scenarios**: +- The application developer's client opens a proxied request to an SSE-producing route; the gateway relays events to the client incrementally, in the order the upstream emits them, until the upstream ends the stream. + +**Error Scenarios**: +- The client disconnects mid-stream and the gateway tears down the upstream connection. +- A transport failure occurs mid-stream (either leg) and both sides of the relay are closed. + +**Steps**: +1. [ ] - `p1` - Application developer's client sends a proxied request to a route whose upstream is expected to respond with an event stream - `inst-ps-sse-relay-01` +2. [ ] - `p1` - {API: GET /oagw/v1/proxy/{alias}/{path} (request forwarded using alias resolution, route matching and header transformation inherited from HTTP Request Proxying)} - `inst-ps-sse-relay-02` +3. [ ] - `p1` - **IF** route or alias resolution fails per HTTP Request Proxying (`cpt-cf-oagw-feature-proxy-http`) - `inst-ps-sse-relay-03` + 1. [ ] - `p1` - Gateway returns that feature's own status for the failure (404, 503, or a route-match 404), `X-OAGW-Error-Source: gateway`; no stream is opened - `inst-ps-sse-relay-04` +4. [ ] - `p1` - **ELSE IF** the gateway fails to establish the upstream connection (for example, the TCP or TLS connect fails) before any response headers arrive - `inst-ps-sse-relay-03b` + 1. [ ] - `p1` - Gateway returns 502, `X-OAGW-Error-Source: gateway`, per the `DownstreamError` mapping in `cpt-cf-oagw-fr-error-codes`; no stream is opened - `inst-ps-sse-relay-04b` +5. [ ] - `p1` - **ELSE IF** establishment does not complete within the proxy request timeout - `inst-ps-sse-relay-03c` + 1. [ ] - `p1` - Gateway returns 504, `X-OAGW-Error-Source: gateway`, per the `Timeout` mapping in `cpt-cf-oagw-fr-error-codes`; no stream is opened - `inst-ps-sse-relay-04c` +6. [ ] - `p1` - **ELSE** (the upstream's response headers arrive within the timeout) - `inst-ps-sse-relay-05` + 1. [ ] - `p1` - Gateway inspects the upstream response's content type; when it is `text/event-stream`, the gateway begins relaying the response body to the client incrementally as bytes arrive, rather than buffering it to completion - `inst-ps-sse-relay-06` + 2. [ ] - `p1` - Gateway preserves the upstream's `text/event-stream` content type and cache-control semantics on the relayed response, and does not apply the whole-body size cap to the ongoing stream - `inst-ps-sse-relay-07` + 3. [ ] - `p1` - Gateway relays each event record's `data:`, `event:`, `id:`, and `retry:` lines and the blank-line record separator byte-for-byte, without reinterpreting or re-serializing event framing - `inst-ps-sse-relay-08` + 4. [ ] - `p1` - Once the stream is established, the connection is no longer bounded by the proxy request timeout that governed establishing it; the stream is allowed to remain open indefinitely, bounded only by one of the three lifecycle endings in `cpt-cf-oagw-algo-ps-sse-lifecycle` - `inst-ps-sse-relay-09` + 5. [ ] - `p1` - Once relaying has begun, any subsequent error surfaced to the client carries `X-OAGW-Error-Source: upstream` - `inst-ps-sse-relay-10` +7. [ ] - `p1` - **RETURN** the relayed event stream, ending per one of the three lifecycle endings in `cpt-cf-oagw-algo-ps-sse-lifecycle` - `inst-ps-sse-relay-11` + +### WebSocket Upgrade Relay + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-ps-ws-upgrade` + +**Actor**: `cpt-cf-oagw-actor-app-developer` + +**Success Scenarios**: +- The client sends an upgrade request; the upstream accepts it; the gateway relays the `101 Switching Protocols` response and then frames in both directions until either side closes. + +**Error Scenarios**: +- The upstream refuses the upgrade and its own status is relayed to the client unchanged. +- The client disconnects and the gateway closes the upstream connection, or the upstream closes and the gateway closes the client connection. + +**Steps**: +1. [ ] - `p1` - Application developer's client sends a proxied request carrying `Upgrade: websocket` and `Connection: Upgrade` request headers, together with the WebSocket key and version headers - `inst-ps-ws-upgrade-01` +2. [ ] - `p1` - {API: GET /oagw/v1/proxy/{alias}/{path} (upgrade request recognised by its Upgrade/Connection headers and WebSocket key/version headers)} - `inst-ps-ws-upgrade-02` +3. [ ] - `p1` - Gateway recognises the request as a WebSocket upgrade and, as the one documented exception to the hop-by-hop header stripping that HTTP Request Proxying otherwise applies, forwards the `Upgrade` and `Connection` request headers to the upstream unchanged rather than stripping them, because they are what makes the upgrade handshake possible - `inst-ps-ws-upgrade-03` +4. [ ] - `p1` - Gateway forwards any requested WebSocket subprotocol header to the upstream without substituting or inventing a subprotocol of its own - `inst-ps-ws-upgrade-04` +5. [ ] - `p1` - **IF** the resolved upstream endpoint's scheme is `wt` (WebTransport) - `inst-ps-ws-upgrade-wt-if` + 1. [ ] - `p1` - **RETURN** 501, `X-OAGW-Error-Source: gateway`, without attempting a WebTransport session, per `cpt-cf-oagw-dod-ps-webtransport-deferral` - `inst-ps-ws-upgrade-wt-return` +6. [ ] - `p1` - **ELSE** Gateway performs the upgrade handshake against the resolved upstream endpoint - `inst-ps-ws-upgrade-05` +7. [ ] - `p1` - **IF** the upstream accepts the upgrade - `inst-ps-ws-upgrade-06` + 1. [ ] - `p1` - Gateway relays a `101 Switching Protocols` response to the client, including whichever subprotocol the upstream negotiated (or none, if the upstream negotiated none) - `inst-ps-ws-upgrade-07` + 2. [ ] - `p1` - Gateway relays frames in both directions between client and upstream until either side sends a close frame or disconnects, per `cpt-cf-oagw-algo-ps-ws-frame-relay` - `inst-ps-ws-upgrade-08` +8. [ ] - `p1` - **ELSE** - `inst-ps-ws-upgrade-09` + 1. [ ] - `p1` - Gateway relays the upstream's own refusal status and body to the client unchanged; it does not synthesize a substitute status, and the response carries `X-OAGW-Error-Source: upstream` because the upstream's own response is what is being relayed - `inst-ps-ws-upgrade-10` +9. [ ] - `p1` - **RETURN** the relayed upgrade outcome (switched protocol with bidirectional frame relay, the upstream's refusal, or the WebTransport-deferral 501) - `inst-ps-ws-upgrade-11` + +## 3. Processes / Business Logic (CDSL) + +### SSE Connection Lifecycle Management + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-ps-sse-lifecycle` + +**Input**: An established, incrementally-relayed SSE connection between client and upstream. + +**Output**: One of three terminal outcomes, each ending both legs of the relay. + +**Steps**: +1. [ ] - `p1` - Monitor both the client-facing connection and the upstream connection for the lifetime of the stream, without applying the proxy request timeout to either leg once the stream is established - `inst-ps-sse-lifecycle-01` +2. [ ] - `p1` - **IF** the upstream closes the event stream - `inst-ps-sse-lifecycle-02` + 1. [ ] - `p1` - Gateway closes the corresponding client connection and records the closure as a normal end-of-stream event - `inst-ps-sse-lifecycle-03` +3. [ ] - `p1` - **ELSE IF** the client disconnects - `inst-ps-sse-lifecycle-04` + 1. [ ] - `p1` - Gateway closes the corresponding upstream connection - `inst-ps-sse-lifecycle-05` +4. [ ] - `p1` - **ELSE IF** a transport failure occurs on either leg while the stream is open - `inst-ps-sse-lifecycle-06` + 1. [ ] - `p1` - Gateway closes both the client connection and the upstream connection and records the failure - `inst-ps-sse-lifecycle-07` +5. [ ] - `p1` - **RETURN** the terminal outcome reached (upstream-initiated close, client-initiated close, or transport failure) - `inst-ps-sse-lifecycle-08` + +### WebSocket Handshake and Frame Relay + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-ps-ws-frame-relay` + +**Input**: A resolved upstream endpoint and an inbound request recognised as a WebSocket upgrade. + +**Output**: Either a relayed `101 Switching Protocols` followed by bidirectional frame relay, or a relayed upstream refusal. + +**Steps**: +1. [ ] - `p1` - Open a connection to the resolved upstream endpoint and send the upgrade handshake, forwarding the `Upgrade` and `Connection` headers and the WebSocket key/version/subprotocol headers unchanged - `inst-ps-ws-frame-relay-01` +2. [ ] - `p1` - **TRY** - `inst-ps-ws-frame-relay-02` + 1. [ ] - `p1` - Await the upstream's handshake response within the proxy request timeout that bounds establishing the connection - `inst-ps-ws-frame-relay-03` +3. [ ] - `p1` - **CATCH** a connection or handshake failure (for example, the TCP or TLS connect fails, or the connection is refused) before any upstream response is received - `inst-ps-ws-frame-relay-04` + 1. [ ] - `p1` - Return 502, `X-OAGW-Error-Source: gateway`, per the `DownstreamError` mapping in `cpt-cf-oagw-fr-error-codes`; no upgrade is relayed to the client - `inst-ps-ws-frame-relay-05` +4. [ ] - `p1` - **CATCH** the proxy request timeout elapsing before the upstream's handshake response arrives - `inst-ps-ws-frame-relay-04b` + 1. [ ] - `p1` - Return 504, `X-OAGW-Error-Source: gateway`, per the `Timeout` mapping in `cpt-cf-oagw-fr-error-codes`; no upgrade is relayed to the client - `inst-ps-ws-frame-relay-05b` +5. [ ] - `p1` - **IF** the upstream's handshake response is `101 Switching Protocols` - `inst-ps-ws-frame-relay-06` + 1. [ ] - `p1` - Relay the `101 Switching Protocols` response to the client, including the negotiated subprotocol exactly as the upstream returned it - `inst-ps-ws-frame-relay-07` + 2. [ ] - `p1` - **FOR EACH** frame received on either the client connection or the upstream connection while both remain open - `inst-ps-ws-frame-relay-08` + 1. [ ] - `p1` - Relay the frame to the other side unchanged, including control frames - `inst-ps-ws-frame-relay-09` + 3. [ ] - `p1` - **IF** either side sends a close frame with a close code - `inst-ps-ws-frame-relay-10` + 1. [ ] - `p1` - Relay the close frame and its close code to the other side and close both connections - `inst-ps-ws-frame-relay-11` + 4. [ ] - `p1` - **ELSE IF** either side disconnects without a close frame - `inst-ps-ws-frame-relay-12` + 1. [ ] - `p1` - Close the connection on the other side - `inst-ps-ws-frame-relay-13` +6. [ ] - `p1` - **ELSE** - `inst-ps-ws-frame-relay-14` + 1. [ ] - `p1` - Relay the upstream's own non-101 status and body to the client unchanged, marking the response `X-OAGW-Error-Source: upstream` - `inst-ps-ws-frame-relay-15` +7. [ ] - `p1` - **RETURN** the relay outcome - `inst-ps-ws-frame-relay-16` + +## 4. States (CDSL) + +### Streaming Connection State Machine + +- [ ] `p2` - **ID**: `cpt-cf-oagw-state-ps-connection-lifecycle` + +**States**: Establishing, Open, ClosingUpstreamInitiated, ClosingClientInitiated, Failed, Closed + +**Initial State**: Establishing + +**Transitions**: +1. [ ] - `p1` - **FROM** Establishing **TO** Open **WHEN** the upstream connection is established (SSE response recognised as `text/event-stream`, or WebSocket upgrade answered `101 Switching Protocols`) within the proxy request timeout - `inst-ps-conn-state-01` +2. [ ] - `p1` - **FROM** Establishing **TO** Failed **WHEN** the upstream connection cannot be established within the proxy request timeout, or the upstream refuses the upgrade - `inst-ps-conn-state-02` +3. [ ] - `p1` - **FROM** Open **TO** ClosingUpstreamInitiated **WHEN** the upstream ends the stream or sends a close frame - `inst-ps-conn-state-03` +4. [ ] - `p1` - **FROM** Open **TO** ClosingClientInitiated **WHEN** the client disconnects or sends a close frame - `inst-ps-conn-state-04` +5. [ ] - `p1` - **FROM** Open **TO** Failed **WHEN** a transport failure occurs on either leg - `inst-ps-conn-state-05` +6. [ ] - `p1` - **FROM** ClosingUpstreamInitiated **TO** Closed **WHEN** the corresponding client connection has been closed and the closure recorded - `inst-ps-conn-state-06` +7. [ ] - `p1` - **FROM** ClosingClientInitiated **TO** Closed **WHEN** the corresponding upstream connection has been closed - `inst-ps-conn-state-07` +8. [ ] - `p1` - **FROM** Failed **TO** Closed **WHEN** both the client connection and the upstream connection have been closed - `inst-ps-conn-state-08` + +## 5. Definitions of Done + +### SSE responses are relayed incrementally, not buffered + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-ps-sse-incremental-relay` + +The system **MUST** recognise an upstream response as SSE by its `text/event-stream` content type and relay its body to the client incrementally as bytes arrive, preserving the content type and cache-control semantics, and **MUST NOT** apply the 100 MB whole-body cap to an established stream. + +**Implements**: +- `cpt-cf-oagw-flow-ps-sse-relay` + +**Touches**: +- API: `GET /oagw/v1/proxy/{alias}/{path}` +- Entities: None (streaming state is transient; no new domain entity) + +### SSE event framing is relayed byte-for-byte + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-ps-sse-event-framing` + +The system **MUST** relay each event record's `data:`, `event:`, `id:`, and `retry:` lines and the blank-line record separator byte-for-byte, without reinterpreting, re-ordering, or re-serializing the framing. + +**Implements**: +- `cpt-cf-oagw-flow-ps-sse-relay` + +**Touches**: +- API: `GET /oagw/v1/proxy/{alias}/{path}` + +### SSE connection lifecycle covers all three endings + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-ps-sse-lifecycle-endings` + +The system **MUST** implement all three SSE termination paths: the upstream closing the stream (gateway closes the client connection and records the event), the client disconnecting (gateway closes the upstream connection), and a mid-stream transport failure (gateway closes both sides). + +**Implements**: +- `cpt-cf-oagw-algo-ps-sse-lifecycle` + +**Touches**: +- API: `GET /oagw/v1/proxy/{alias}/{path}` + +### The proxy request timeout bounds establishment only, not stream lifetime + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-ps-timeout-scope` + +The system **MUST** apply the proxy request timeout only to establishing the upstream connection for a stream or upgrade, and **MUST NOT** apply it to the lifetime of an already-established SSE stream or WebSocket connection. Establishment ends — and the timeout stops applying — when the upstream's response headers arrive (for an SSE stream) or when the upgrade handshake response arrives (for a WebSocket), not when the underlying transport connection merely opens; this distinction matters because the graded configuration sets `proxy_timeout_secs` to 2 seconds, a window that a slow-to-respond-but-quick-to-connect upstream could otherwise exceed unfairly. + +**Implements**: +- `cpt-cf-oagw-flow-ps-sse-relay` +- `cpt-cf-oagw-algo-ps-ws-frame-relay` + +**Touches**: +- API: `GET /oagw/v1/proxy/{alias}/{path}` + +### WebSocket upgrade requests are recognised and handshaked + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-ps-ws-upgrade-handshake` + +The system **MUST** recognise an upgrade request by its `Upgrade: websocket` and `Connection: Upgrade` request headers together with the WebSocket key and version headers, perform the handshake against the resolved upstream, and relay a `101 Switching Protocols` response to the client on success. + +**Implements**: +- `cpt-cf-oagw-flow-ps-ws-upgrade` +- `cpt-cf-oagw-algo-ps-ws-frame-relay` + +**Touches**: +- API: `GET /oagw/v1/proxy/{alias}/{path}` + +### Upgrade and Connection headers are the one documented hop-by-hop exception + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-ps-upgrade-header-exception` + +The system **MUST** forward the `Upgrade` and `Connection` request headers to the upstream unchanged for upgrade requests, as the one documented, reasoned exception to the hop-by-hop header stripping that otherwise applies to every proxied request, because these two headers are what make the upgrade handshake work. + +**Implements**: +- `cpt-cf-oagw-flow-ps-ws-upgrade` + +**Touches**: +- API: `GET /oagw/v1/proxy/{alias}/{path}` + +### Requested subprotocol negotiation is relayed, not invented + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-ps-ws-subprotocol-relay` + +The system **MUST** forward a requested WebSocket subprotocol to the upstream and relay back exactly whichever subprotocol (or none) the upstream negotiated, without substituting or inventing a subprotocol. + +**Implements**: +- `cpt-cf-oagw-algo-ps-ws-frame-relay` + +**Touches**: +- API: `GET /oagw/v1/proxy/{alias}/{path}` + +### WebSocket frames and close codes relay in both directions + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-ps-ws-bidirectional-frame-relay` + +The system **MUST** relay frames, including close frames and their close codes, in both directions between client and upstream until either side closes, and **MUST** close the connection on one side when the other side disconnects. + +**Implements**: +- `cpt-cf-oagw-algo-ps-ws-frame-relay` + +**Constraints**: None + +**Touches**: +- API: `GET /oagw/v1/proxy/{alias}/{path}` + +### Upstream upgrade refusal is relayed verbatim + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-ps-ws-refusal-relay` + +The system **MUST** relay the upstream's own status and body to the client when the upstream refuses an upgrade request, and **MUST NOT** synthesize a substitute status. + +**Implements**: +- `cpt-cf-oagw-flow-ps-ws-upgrade` +- `cpt-cf-oagw-algo-ps-ws-frame-relay` + +**Touches**: +- API: `GET /oagw/v1/proxy/{alias}/{path}` + +### Error-source distinction applies to streaming and upgrade paths + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-ps-error-source-distinction` + +The system **MUST** mark a failure produced before an SSE stream or WebSocket upgrade is established with `X-OAGW-Error-Source: gateway`, and **MUST** mark a failure surfaced once the upstream's response is being relayed (including a relayed refusal status) with `X-OAGW-Error-Source: upstream`. + +**Implements**: +- `cpt-cf-oagw-flow-ps-sse-relay` +- `cpt-cf-oagw-flow-ps-ws-upgrade` + +**Touches**: +- API: `GET /oagw/v1/proxy/{alias}/{path}` + +### WebTransport remains undeferred-from-the-record but unserved + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-ps-webtransport-deferral` + +The system **MUST NOT** attempt to establish a WebTransport session in this configuration; a request targeting a `wt`-scheme upstream **MUST** receive 501, `X-OAGW-Error-Source: gateway`, rather than an attempted or partial session, and this deferral is a recorded, deliberate scope decision rather than an unimplemented gap. + +**Implements**: +- `cpt-cf-oagw-flow-ps-ws-upgrade` + +**Touches**: +- API: `GET /oagw/v1/proxy/{alias}/{path}` + +## 6. Acceptance Criteria + +- [ ] An SSE stream proxied through `/oagw/v1/proxy/{alias}/{path}` relays its events to the client incrementally as the upstream emits them and in the same order the upstream emitted them, rather than as a single buffered response. +- [ ] An SSE stream that remains open longer than the configured `proxy_timeout_secs` is not cut off by the gateway once established; events continue to be relayed past that duration. +- [ ] The relayed SSE response carries the upstream's `text/event-stream` content type and its cache-control header value unchanged. +- [ ] An SSE stream whose total relayed body exceeds 100 MB is not terminated by the gateway's body-size enforcement. +- [ ] When the upstream closes an SSE stream, the client connection is closed by the gateway and the closure is recorded. +- [ ] When the client disconnects from an SSE stream, the upstream connection is closed by the gateway. +- [ ] A WebSocket upgrade request carrying valid `Upgrade`/`Connection`/key/version headers against an upstream that accepts the upgrade receives a `101 Switching Protocols` response from the gateway. +- [ ] Frames sent by the client after a successful WebSocket upgrade are echoed back by the upstream and relayed to the client, and frames sent by the upstream are relayed to the client, in both directions. +- [ ] A close frame with a close code sent by the client after a successful WebSocket upgrade is relayed to the upstream with the same close code, and the connection closes on both sides. +- [ ] A client disconnect after a successful WebSocket upgrade (without a close frame) results in the gateway closing the upstream connection. +- [ ] A WebSocket upgrade request against an upstream that refuses the upgrade results in the client receiving the upstream's own status code and body, not a synthesized gateway status. +- [ ] A gateway-produced failure that occurs before an SSE stream or WebSocket upgrade is established carries `X-OAGW-Error-Source: gateway`. +- [ ] A failure or refusal surfaced once the upstream's response is being relayed carries `X-OAGW-Error-Source: upstream`. +- [ ] A proxy request targeting a `wt`-scheme upstream does not establish any session and receives 501 with `X-OAGW-Error-Source: gateway`. diff --git a/gears/system/oagw/docs/features/route-management.md b/gears/system/oagw/docs/features/route-management.md new file mode 100644 index 0000000..b4ce26d --- /dev/null +++ b/gears/system/oagw/docs/features/route-management.md @@ -0,0 +1,416 @@ +# Feature: Route Management + + + + +- [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) + - [1.5 Authorization Disposition](#15-authorization-disposition) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Create Route](#create-route) + - [List Routes](#list-routes) + - [Get Route](#get-route) + - [Replace Route](#replace-route) + - [Delete Route](#delete-route) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Validate Route Match Payload](#validate-route-match-payload) + - [Check Match-Determinism Invariant](#check-match-determinism-invariant) + - [Remove Routes for a Deleted Upstream](#remove-routes-for-a-deleted-upstream) +- [4. Definitions of Done](#4-definitions-of-done) + - [Create Route Endpoint](#create-route-endpoint) + - [List Routes Endpoint](#list-routes-endpoint) + - [Get Route Endpoint](#get-route-endpoint) + - [Replace Route Endpoint](#replace-route-endpoint) + - [Delete Route Endpoint](#delete-route-endpoint) + - [Match-Determinism Enforcement](#match-determinism-enforcement) + - [Route Enable/Disable Field](#route-enabledisable-field) + - [Route-Level Policy-Field Validation](#route-level-policy-field-validation) + - [Cascade Delete on Upstream Removal](#cascade-delete-on-upstream-removal) +- [5. Acceptance Criteria](#5-acceptance-criteria) + + + +- [ ] `p1` - **ID**: `cpt-cf-oagw-featstatus-rm-implemented` + + +- [ ] `p1` - `cpt-cf-oagw-feature-route-management` + +## 1. Feature Context + +### 1.1 Overview + +The Route Management feature owns the Route resource: the control-plane object that binds an HTTP match rule (method, path, query allowlist) to a specific upstream and carries the route's own rate-limit and plugin overrides. It exposes create, list, read, replace, and delete operations under `/oagw/v1/routes`, and it is the sole enforcer of the match-determinism invariant that keeps route lookup unambiguous. + +### 1.2 Purpose + +This feature implements the CRUD surface that `cpt-cf-oagw-fr-route-mgmt` requires for routes, and the route half of the enable/disable behavior that `cpt-cf-oagw-fr-enable-disable` requires — every route carries its own `enabled` boolean (default `true`), and a disabled route is excluded from route matching. The upstream half of enable/disable (a disabled upstream rejecting all proxy requests with `503`, and ancestor-disabled upstreams staying disabled for descendants) belongs entirely to the Upstream Management feature; this feature neither implements nor re-describes it beyond the boundary just stated. + +All route CRUD operations are scoped to the calling tenant per `cpt-cf-oagw-principle-tenant-scope`: an operator can only create, list, read, replace, or delete routes under upstreams visible to their own tenant, and a route belonging to another tenant is invisible (404), never merely forbidden. + +`match.grpc` is accepted and stored because the frozen schema declares it, but this configuration deliberately defers gRPC proxying: DESIGN.md states no gRPC proxy code path is implemented or reachable. A route created with a `grpc` match is therefore persisted and returned exactly like any other route — it is not rejected and not silently dropped — but it is never a candidate for request matching in this configuration; that exclusion is a deliberate deferral, not an oversight. + +**Requirements**: `cpt-cf-oagw-fr-route-mgmt`, `cpt-cf-oagw-fr-enable-disable` + +**Principles**: `cpt-cf-oagw-principle-tenant-scope` + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-oagw-actor-platform-operator` | Creates, lists, reads, replaces, and deletes routes on behalf of any tenant they administer. | +| `cpt-cf-oagw-actor-tenant-admin` | Performs the same create/list/read/replace/delete operations, scoped to their own tenant's visible upstreams and routes. | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) +- **Design**: [DESIGN.md](../DESIGN.md) +- **Dependencies**: `cpt-cf-oagw-feature-gear-foundation`, `cpt-cf-oagw-feature-upstream-management` + +### 1.5 Authorization Disposition + +Generic request authentication and coarse authorization are performed by the host runtime before a request reaches this feature; this feature does not re-implement them. This feature owns exactly one OAGW-specific authorization decision of its own: the `gts.cf.core.oagw.route.v1~:create` permission precondition on Create Route (`cpt-cf-oagw-usecase-configure-route`), which fails with `403` through `cpt-cf-oagw-algo-gf-error-mapping`'s `permission denied` category. List, Get, Replace, and Delete Route rely solely on tenant-scoping (a route belonging to another tenant is invisible — `404`, never merely forbidden) and the host runtime's coarse authorization; this feature adds no further feature-specific permission check for those operations. + +In this configuration, however, this feature performs no OAGW-specific permission checks of its own: the `gts.cf.core.oagw.route.v1~:create` precondition the PRD's use case names is not separately enforced by this feature's own logic — inbound authentication and coarse authorization performed by the host runtime are the only gate a Create Route request passes through before this feature's tenant-scoped CRUD logic runs. The 401 and 403 canonical categories exist in `cpt-cf-oagw-algo-gf-error-mapping` and are exercised by unit tests, but this feature raises them only for the cases it owns (tenant-scoping's `404` substitutes for what would otherwise be a `403` on List, Get, Replace, and Delete, per the disposition above); it raises no independent `403` of its own on Create Route in this configuration. + +## 2. Actor Flows (CDSL) + +User-facing interactions that start with an actor (human or external system) and describe the end-to-end flow of a use case. Each flow has a triggering actor and shows how the system responds to actor actions. + +**Use cases**: `cpt-cf-oagw-usecase-configure-route` + +### Create Route + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-rm-create-route` + +**Actor**: `cpt-cf-oagw-actor-platform-operator` + +**Success Scenarios**: +- The operator submits a route referencing a visible upstream and a well-formed, non-colliding `match.http` rule; the system stores the route with a server-generated id and returns it. +- The operator submits a route with a `match.grpc` rule; the system stores it structurally without making it reachable by any proxy path. + +**Error Scenarios**: +- The caller does not hold `gts.cf.core.oagw.route.v1~:create` permission. +- `upstream_id` does not resolve to an upstream visible to the calling tenant. +- `match` is missing, has neither `http` nor `grpc`, or has both. +- `match.http` is missing `methods` or `path`, `methods` is empty or contains a value outside the method allowlist, or `path` is empty. +- The candidate's method(s) and `path` collide with another currently-enabled route under the same upstream. + +**Steps**: +1. [ ] - `p1` - Operator submits a route definition with `upstream_id`, `match` (`http` or `grpc`), and optional `tags`, `plugins`, `rate_limit` - `inst-rm-create-submit` +2. [ ] - `p1` - {API: POST /oagw/v1/routes (request: route fields without `id`; response: created route including server-generated `id` and defaulted fields)} - `inst-rm-create-api` +3. [ ] - `p1` - **IF** the caller does not hold `gts.cf.core.oagw.route.v1~:create` permission (the precondition `cpt-cf-oagw-usecase-configure-route` states) - `inst-rm-create-if-no-perm` + 1. [ ] - `p1` - **RETURN** `403` via `cpt-cf-oagw-algo-gf-error-mapping`'s `permission denied` category - `inst-rm-create-return-403` +4. [ ] - `p1` - **ELSE** System resolves `upstream_id` against the set of upstreams visible to the calling tenant - `inst-rm-create-resolve-upstream` +5. [ ] - `p1` - **IF** `upstream_id` does not resolve to a visible upstream - `inst-rm-create-if-upstream-missing` + 1. [ ] - `p1` - **RETURN** `400 ValidationError` - `inst-rm-create-return-400-upstream` +6. [ ] - `p1` - **ELSE** validate `match`, `tags`, and `rate_limit` via `cpt-cf-oagw-algo-rm-validate-match-payload` (exactly one of `http`/`grpc`; for `http`, non-empty `methods` from the method allowlist and a non-empty `path` are required; `query_allowlist` defaults to `[]`; `path_suffix_mode` defaults to `append`) - `inst-rm-create-validate-match` +7. [ ] - `p1` - **IF** validation fails - `inst-rm-create-if-invalid` + 1. [ ] - `p1` - **RETURN** `400 ValidationError` with field-level detail - `inst-rm-create-return-400-match` +8. [ ] - `p1` - **ELSE** check the match-determinism invariant (`cpt-cf-oagw-algo-rm-check-match-determinism`) against the upstream's other currently-enabled routes - `inst-rm-create-check-duplicate` +9. [ ] - `p1` - **IF** a colliding enabled route exists - `inst-rm-create-if-duplicate` + 1. [ ] - `p1` - **RETURN** `409 Conflict` - `inst-rm-create-return-409` +10. [ ] - `p1` - **ELSE** persist the route with a server-generated `id`, `enabled` defaulted to `true`, and the supplied/defaulted fields - `inst-rm-create-persist` +11. [ ] - `p1` - **RETURN** `201 Created` with the stored route - `inst-rm-create-return-201` + +### List Routes + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-rm-list-routes` + +**Actor**: `cpt-cf-oagw-actor-platform-operator` + +**Success Scenarios**: +- The operator lists the routes visible to their tenant, optionally paged with `$top`/`$skip` and filtered/shaped with `$filter`/`$select`/`$orderby`. + +**Error Scenarios**: +- None beyond the host runtime's coarse authentication/authorization handling described in Section 1.5; this feature adds no route-specific permission check for listing. + +**Steps**: +1. [ ] - `p1` - Operator requests the route collection, optionally with `$top`, `$skip`, `$filter`, `$select`, `$orderby` - `inst-rm-list-submit` +2. [ ] - `p1` - {API: GET /oagw/v1/routes (request: OData query parameters; response: paged list of routes)} - `inst-rm-list-api` +3. [ ] - `p1` - System resolves `$top` to the supplied value, defaulting to 50 and capping at 100 - `inst-rm-list-top` +4. [ ] - `p1` - System filters the stored route collection to those visible to the calling tenant, then applies `$skip`/`$top` - `inst-rm-list-filter` +5. [ ] - `p1` - **RETURN** `200 OK` with the resulting page of routes - `inst-rm-list-return` + +### Get Route + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-rm-get-route` + +**Actor**: `cpt-cf-oagw-actor-platform-operator` + +**Success Scenarios**: +- The operator retrieves a route that belongs to their tenant by id. + +**Error Scenarios**: +- The id does not exist, or exists but belongs to another tenant. + +**Steps**: +1. [ ] - `p1` - Operator requests a route by id - `inst-rm-get-submit` +2. [ ] - `p1` - {API: GET /oagw/v1/routes/{id} (request: route id; response: the route)} - `inst-rm-get-api` +3. [ ] - `p1` - System looks up the route by id, scoped to the calling tenant - `inst-rm-get-lookup` +4. [ ] - `p1` - **IF** no matching route is visible to the calling tenant - `inst-rm-get-if-missing` + 1. [ ] - `p1` - **RETURN** `404 NotFound` - `inst-rm-get-return-404` +5. [ ] - `p1` - **ELSE** - `inst-rm-get-else` + 1. [ ] - `p1` - **RETURN** `200 OK` with the route - `inst-rm-get-return-200` + +### Replace Route + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-rm-replace-route` + +**Actor**: `cpt-cf-oagw-actor-platform-operator` + +**Success Scenarios**: +- The operator fully replaces an own-tenant route's `match`, `tags`, `plugins`, `rate_limit`, and `enabled` fields; `upstream_id` is untouched because it is immutable and absent from the replacement payload. + +**Error Scenarios**: +- The id does not exist, or exists but belongs to another tenant. +- The replacement `match` fails the same validation applied at create time. +- The replacement's method(s) and `path` collide with another currently-enabled route under the same upstream. + +**Steps**: +1. [ ] - `p1` - Operator submits a full replacement route definition (`match`, `tags`, `plugins`, `rate_limit`, `enabled`) that carries no `upstream_id` field - `inst-rm-replace-submit` +2. [ ] - `p1` - {API: PUT /oagw/v1/routes/{id} (request: full route DTO without `upstream_id`; response: the replaced route, including its unchanged `upstream_id`)} - `inst-rm-replace-api` +3. [ ] - `p1` - System looks up the route by id, scoped to the calling tenant - `inst-rm-replace-lookup` +4. [ ] - `p1` - **IF** no matching route is visible to the calling tenant - `inst-rm-replace-if-missing` + 1. [ ] - `p1` - **RETURN** `404 NotFound` - `inst-rm-replace-return-404` +5. [ ] - `p1` - **ELSE** validate the replacement `match` via `cpt-cf-oagw-algo-rm-validate-match-payload` - `inst-rm-replace-validate` +6. [ ] - `p1` - **IF** validation fails - `inst-rm-replace-if-invalid` + 1. [ ] - `p1` - **RETURN** `400 ValidationError` with field-level detail - `inst-rm-replace-return-400` +7. [ ] - `p1` - **ELSE** check the match-determinism invariant (`cpt-cf-oagw-algo-rm-check-match-determinism`) against the upstream's other currently-enabled routes, excluding the route being replaced - `inst-rm-replace-check-duplicate` +8. [ ] - `p1` - **IF** a colliding enabled route exists - `inst-rm-replace-if-duplicate` + 1. [ ] - `p1` - **RETURN** `409 Conflict` - `inst-rm-replace-return-409` +9. [ ] - `p1` - **ELSE** overwrite `match`, `tags`, `plugins`, `rate_limit`, and `enabled`, leaving `id`, tenant, and `upstream_id` unchanged - `inst-rm-replace-persist` +10. [ ] - `p1` - **RETURN** `200 OK` with the replaced route - `inst-rm-replace-return-200` + +### Delete Route + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-rm-delete-route` + +**Actor**: `cpt-cf-oagw-actor-platform-operator` + +**Success Scenarios**: +- The operator deletes an own-tenant route by id. + +**Error Scenarios**: +- The id does not exist, or exists but belongs to another tenant. + +**Steps**: +1. [ ] - `p1` - Operator requests deletion of a route by id - `inst-rm-delete-submit` +2. [ ] - `p1` - {API: DELETE /oagw/v1/routes/{id} (request: route id; response: empty body)} - `inst-rm-delete-api` +3. [ ] - `p1` - System looks up the route by id, scoped to the calling tenant - `inst-rm-delete-lookup` +4. [ ] - `p1` - **IF** no matching route is visible to the calling tenant - `inst-rm-delete-if-missing` + 1. [ ] - `p1` - **RETURN** `404 NotFound` - `inst-rm-delete-return-404` +5. [ ] - `p1` - **ELSE** remove the route from the route collection - `inst-rm-delete-remove` +6. [ ] - `p1` - **RETURN** `204 No Content` - `inst-rm-delete-return-204` + +## 3. Processes / Business Logic (CDSL) + +Internal system functions and procedures that do not interact with actors directly. Examples: database layer operations, authorization logic, middleware, validation routines, library functions, background jobs. These are reusable building blocks called by Actor Flows or other processes. + +### Validate Route Match Payload + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-rm-validate-match-payload` + +**Deviation from DESIGN.md**: `cpt-cf-oagw-design-domain-model` and this feature's DECOMPOSITION entry (`cpt-cf-oagw-feature-route-management`, §2.3) list `CorsConfig` among Route's domain entities and place "route-level rate limit/CORS/plugin overrides" in this feature's scope. The frozen `route.v1.schema.json` defines a `cors` object under `definitions`, but never references it from the Route's top-level `properties` — so route-level CORS is not part of the wire contract in this configuration, regardless of what the domain model or DECOMPOSITION scope text describe. This validation step therefore validates `tags`, `plugins`, and `rate_limit` (all of which the schema does expose at the top level) but accepts no `cors` field on a route body at all: an unrecognized `cors` property is rejected the same as any other undeclared property. CORS is configured on the upstream only in this configuration — see `cpt-cf-oagw-feature-upstream-management`'s `cors` field and `cpt-cf-oagw-dod-um-cors-validation`. No route-level CORS field is invented to fill this gap. + +**Input**: a candidate `match` object, plus `tags`, `plugins`, and `rate_limit` if supplied. + +**Output**: a normalized route payload with defaults applied, or a validation error identifying the failing field(s). + +**Steps**: +1. [ ] - `p1` - Reject the payload if `match` is absent, or if it does not have exactly one of `http`/`grpc` - `inst-rm-validate-match-shape` +2. [ ] - `p1` - **IF** `grpc` is present - `inst-rm-validate-match-if-grpc` + 1. [ ] - `p1` - Accept it structurally (require non-empty `service` and `method`) and mark the route as not reachable by any proxy path, per the deliberate deferral of gRPC proxying - `inst-rm-validate-match-grpc-accept` +3. [ ] - `p1` - **ELSE** (`http` is present) - `inst-rm-validate-match-else-http` + 1. [ ] - `p1` - Require `methods` to be a non-empty array drawn only from `GET`, `POST`, `PUT`, `DELETE`, `PATCH` - `inst-rm-validate-match-methods` + 2. [ ] - `p1` - Require `path` to be a non-empty string - `inst-rm-validate-match-path` + 3. [ ] - `p1` - Default `query_allowlist` to `[]` when omitted; an empty allowlist permits no query parameters, not all of them - `inst-rm-validate-match-query-allowlist` + 4. [ ] - `p1` - Default `path_suffix_mode` to `append` when omitted; otherwise require `disabled` or `append` - `inst-rm-validate-match-suffix-mode` +4. [ ] - `p1` - Default `plugins.sharing` to `private` and `plugins.items` to `[]` when `plugins` is supplied without them; identifier resolution and binding validation of `plugins.items` belong to the Plugin Catalog and Bindings feature, not this validation step - `inst-rm-validate-match-plugins` +5. [ ] - `p1` - Validate `tags`, when present, is an array of strings each matching the schema's `^[a-z0-9_-]+$` pattern; reject the payload naming `tags` if any entry does not match - `inst-rm-validate-match-tags` +6. [ ] - `p1` - Validate and default `rate_limit`, when present, exactly as `cpt-cf-oagw-algo-um-validate-payload` does for upstreams: require `rate_limit.sustained.rate`, defaulting `rate_limit.algorithm` to `token_bucket`, `rate_limit.sustained.window` to `second`, `rate_limit.burst.capacity` to `rate_limit.sustained.rate` when absent, `rate_limit.scope` to `tenant`, `rate_limit.strategy` to `reject`, and `rate_limit.cost` to `1`; reject the payload naming `rate_limit` if `sustained.rate` is absent while `rate_limit` is supplied - `inst-rm-validate-match-rate-limit` +7. [ ] - `p1` - Default `enabled` to `true` when omitted - `inst-rm-validate-match-enabled-default` +8. [ ] - `p1` - **RETURN** the normalized payload, or a `400 ValidationError` enumerating the failing field(s) - `inst-rm-validate-match-return` + +### Check Match-Determinism Invariant + +- [ ] `p1` - **ID**: `cpt-cf-oagw-algo-rm-check-match-determinism` + +**Deviation from DESIGN.md**: `cpt-cf-oagw-design-domain-model` models Route with a first-class `priority` integer used to order same-method matches, and `cpt-cf-oagw-db-schema` states the invariant as keying on `(path_prefix, priority)` per method. The frozen `route.v1.schema.json` field contract carries no `priority` property. In this configuration no priority field is accepted, stored, or compared: the invariant below keys on `(upstream_id, method, path)` alone, and the tie-breaking role DESIGN.md assigned to `priority` is instead resolved at proxy time by longest-path-prefix match — the more specific of two non-identical, non-colliding paths always wins, so no numeric tie-breaker is ever needed. Rationale: the frozen schema is this configuration's binding field-level contract, so accepting an undeclared `priority` field would create persisted state with no wire representation and no enforced meaning, while longest-prefix match already yields a deterministic, total order over any set of non-colliding path prefixes. + +**Input**: the target `upstream_id`, the normalized `http` match's `methods` and `path`, and (for a replace) the id of the route being replaced so it can exclude itself. + +**Output**: no conflict, or a `409 Conflict`. + +**Steps**: +1. [ ] - `p1` - Collect the `(method, path)` pairs of every other route that is currently enabled under the same `upstream_id`, excluding the route being replaced, if any - `inst-rm-determinism-collect` +2. [ ] - `p1` - **IF** any of the candidate's `(method, path)` pairs matches a pair in that collection - `inst-rm-determinism-if-collision` + 1. [ ] - `p1` - **RETURN** `409 Conflict` - `inst-rm-determinism-return-409` +3. [ ] - `p1` - **ELSE** **RETURN** no conflict - `inst-rm-determinism-return-ok` + +### Remove Routes for a Deleted Upstream + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-rm-cascade-remove-routes` + +Deleting an upstream (an operation owned by the Upstream Management feature) cascades to every route registered under it. This is distinct from disabling an upstream, which does not delete or otherwise alter its routes and is entirely the Upstream Management feature's own enable/disable behavior under `cpt-cf-oagw-fr-enable-disable`. + +**Input**: the `upstream_id` of an upstream that the Upstream Management feature has just deleted. + +**Output**: the route collection with every route owned by that upstream removed. + +**Steps**: +1. [ ] - `p1` - Upstream Management's delete-upstream operation invokes this process with the deleted upstream's id, after the upstream record itself has been removed - `inst-rm-cascade-invoke` +2. [ ] - `p1` - **FOR EACH** route in the route collection whose `upstream_id` equals the deleted upstream's id - `inst-rm-cascade-for-each` + 1. [ ] - `p1` - Remove the route from the collection - `inst-rm-cascade-remove-one` +3. [ ] - `p1` - **RETURN** the updated route collection, with no route left referencing the deleted `upstream_id` - `inst-rm-cascade-return` + +## 4. Definitions of Done + +### Create Route Endpoint + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-rm-create-route` + +The system **MUST** implement `POST /oagw/v1/routes` per `cpt-cf-oagw-flow-rm-create-route`, applying `cpt-cf-oagw-algo-rm-validate-match-payload` and `cpt-cf-oagw-algo-rm-check-match-determinism`, and returning `201`/`400`/`409` exactly as those flows specify. + +**Implements**: +- `cpt-cf-oagw-flow-rm-create-route` + +**Touches**: +- API: `POST /oagw/v1/routes` +- DB: `cpt-cf-oagw-db-schema` +- Entities: `Route`, `MatchConfig` + +### List Routes Endpoint + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-rm-list-routes` + +The system **MUST** implement `GET /oagw/v1/routes` per `cpt-cf-oagw-flow-rm-list-routes`, returning only routes visible to the calling tenant and honoring `$top` (default 50, max 100) and `$skip`. + +**Implements**: +- `cpt-cf-oagw-flow-rm-list-routes` + +**Touches**: +- API: `GET /oagw/v1/routes` +- Entities: `Route` + +### Get Route Endpoint + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-rm-get-route` + +The system **MUST** implement `GET /oagw/v1/routes/{id}` per `cpt-cf-oagw-flow-rm-get-route`, returning `404` when the id is absent or belongs to another tenant. + +**Implements**: +- `cpt-cf-oagw-flow-rm-get-route` + +**Touches**: +- API: `GET /oagw/v1/routes/{id}` +- Entities: `Route` + +### Replace Route Endpoint + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-rm-replace-route` + +The system **MUST** implement `PUT /oagw/v1/routes/{id}` per `cpt-cf-oagw-flow-rm-replace-route` as a full replacement that accepts no `upstream_id` field, re-validates `match` via `cpt-cf-oagw-algo-rm-validate-match-payload`, and re-checks `cpt-cf-oagw-algo-rm-check-match-determinism` excluding the route itself. + +**Implements**: +- `cpt-cf-oagw-flow-rm-replace-route` + +**Touches**: +- API: `PUT /oagw/v1/routes/{id}` +- DB: `cpt-cf-oagw-db-schema` +- Entities: `Route`, `MatchConfig` + +### Delete Route Endpoint + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-rm-delete-route` + +The system **MUST** implement `DELETE /oagw/v1/routes/{id}` per `cpt-cf-oagw-flow-rm-delete-route`, returning `204` and removing the route from the route collection. + +**Implements**: +- `cpt-cf-oagw-flow-rm-delete-route` + +**Touches**: +- API: `DELETE /oagw/v1/routes/{id}` +- DB: `cpt-cf-oagw-db-schema` +- Entities: `Route` + +### Match-Determinism Enforcement + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-rm-match-determinism` + +The system **MUST** enforce the match-determinism invariant (`cpt-cf-oagw-algo-rm-check-match-determinism`) on every create and every replace, comparing only `(upstream_id, method, path)` — never a `priority` field, which this configuration's frozen schema does not carry — and leaving ordering among non-colliding, non-identical paths to longest-path-prefix resolution at proxy time. + +**Implements**: +- `cpt-cf-oagw-flow-rm-create-route` +- `cpt-cf-oagw-flow-rm-replace-route` +- `cpt-cf-oagw-algo-rm-check-match-determinism` + +**Touches**: +- Entities: `Route`, `MatchConfig` + +### Route Enable/Disable Field + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-rm-enable-disable` + +The system **MUST** carry an `enabled` boolean (default `true`) on every route, exclude disabled routes from the match-determinism check on create and replace, and accept `enabled` toggling through full replacement of the route. + +**Implements**: +- `cpt-cf-oagw-flow-rm-create-route` +- `cpt-cf-oagw-flow-rm-replace-route` +- `cpt-cf-oagw-algo-rm-check-match-determinism` + +**Touches**: +- Entities: `Route` + +### Route-Level Policy-Field Validation + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-rm-policy-validation` + +The system **MUST** validate a route's `tags` array against the schema's `^[a-z0-9_-]+$` pattern, and **MUST** validate and default a route's `rate_limit` object exactly as `cpt-cf-oagw-algo-um-validate-payload` does for upstreams (requiring `sustained.rate`, defaulting `algorithm` to `token_bucket`, `sustained.window` to `second`, `burst.capacity` to `sustained.rate`, `scope` to `tenant`, `strategy` to `reject`, and `cost` to `1`), on both create and replace. The system **MUST NOT** accept a `cors` field on a route body: the frozen `route.v1.schema.json` defines `cors` only under `definitions` and never references it from the Route's top-level `properties`, so route-level CORS is not part of the wire contract in this configuration — this is a documented deviation from `cpt-cf-oagw-design-domain-model`'s Route entity list, and CORS remains configured on the upstream only (see `cpt-cf-oagw-feature-upstream-management`). + +**Implements**: +- `cpt-cf-oagw-flow-rm-create-route` +- `cpt-cf-oagw-flow-rm-replace-route` +- `cpt-cf-oagw-algo-rm-validate-match-payload` + +**Touches**: +- API: `POST /oagw/v1/routes`, `PUT /oagw/v1/routes/{id}` +- Entities: `RateLimitConfig` + +### Cascade Delete on Upstream Removal + +- [ ] `p2` - **ID**: `cpt-cf-oagw-dod-rm-cascade-delete` + +The system **MUST** remove every route owned by an upstream (`cpt-cf-oagw-algo-rm-cascade-remove-routes`) when that upstream is deleted, invoked from the Upstream Management feature's delete-upstream operation, so that no route ever outlives its `upstream_id`. + +**Implements**: +- `cpt-cf-oagw-algo-rm-cascade-remove-routes` + +**Touches**: +- DB: `cpt-cf-oagw-db-schema` +- Entities: `Route` + +## 5. Acceptance Criteria + +- [ ] `POST /oagw/v1/routes` with a valid, visible `upstream_id` and a well-formed `match.http` (non-empty `methods` from the allowlist, non-empty `path`) returns `201` with a server-generated `id`, `enabled: true`, `query_allowlist: []`, and `path_suffix_mode: "append"` when those fields were omitted from the request. +- [ ] `POST /oagw/v1/routes` whose `upstream_id` does not resolve to an upstream visible to the calling tenant returns `400`, not `404`. +- [ ] `POST /oagw/v1/routes` whose `match` contains neither `http` nor `grpc`, or contains both, returns `400`. +- [ ] `POST /oagw/v1/routes` whose `match.http` omits `methods`, supplies an empty `methods` array, supplies a method outside `GET`/`POST`/`PUT`/`DELETE`/`PATCH`, or omits or empties `path`, returns `400`. +- [ ] `POST /oagw/v1/routes` whose `methods`/`path` duplicate a currently-enabled route under the same `upstream_id` returns `409`. +- [ ] `POST /oagw/v1/routes` whose `methods`/`path` duplicate only a currently-disabled route under the same `upstream_id` returns `201`, because disabled routes are excluded from the match-determinism check. +- [ ] `POST /oagw/v1/routes` with a `match.grpc` object (`service`, `method`) returns `201` and the stored route is retrievable by `GET`, even though no proxy path serves it in this configuration. +- [ ] `GET /oagw/v1/routes` returns only routes visible to the calling tenant, defaults `$top` to 50 when omitted, caps `$top` at 100, and honors `$skip`. +- [ ] `GET /oagw/v1/routes/{id}` returns `200` with the route when it belongs to the calling tenant, and `404` when the id is absent or belongs to another tenant. +- [ ] `PUT /oagw/v1/routes/{id}` on an existing own-tenant route returns `200` with the stored route's `upstream_id` unchanged, even though the replacement payload carries no `upstream_id` field. +- [ ] `PUT /oagw/v1/routes/{id}` whose replacement `methods`/`path` duplicate a currently-enabled route other than itself under the same `upstream_id` returns `409`. +- [ ] `PUT /oagw/v1/routes/{id}` that sets `enabled: true` on a route whose `methods`/`path` now duplicate another currently-enabled route under the same `upstream_id` returns `409`. +- [ ] `PUT /oagw/v1/routes/{id}` for an id absent or belonging to another tenant returns `404`. +- [ ] `DELETE /oagw/v1/routes/{id}` returns `204`, and a subsequent `GET /oagw/v1/routes/{id}` on the same id returns `404`. +- [ ] Deleting the parent upstream removes every route whose `upstream_id` referenced it; a subsequent `GET` on any of those route ids returns `404`. +- [ ] A route created without an `enabled` field defaults to `enabled: true`. +- [ ] `POST /oagw/v1/routes` submitted by a caller lacking `gts.cf.core.oagw.route.v1~:create` permission returns `403`. +- [ ] `POST /oagw/v1/routes` whose `tags` array contains an entry not matching `^[a-z0-9_-]+$` returns `400`. +- [ ] `POST /oagw/v1/routes` whose `rate_limit` is supplied without `sustained.rate` returns `400`; supplying only `rate_limit.sustained.rate` returns `201` with `rate_limit.algorithm` defaulted to `token_bucket`, `rate_limit.sustained.window` defaulted to `second`, `rate_limit.burst.capacity` defaulted to the supplied `sustained.rate`, `rate_limit.scope` defaulted to `tenant`, `rate_limit.strategy` defaulted to `reject`, and `rate_limit.cost` defaulted to `1`. +- [ ] `POST /oagw/v1/routes` with a `cors` object in the body returns `400` for an unrecognized property; a route's proxied requests are governed entirely by the upstream's `cors` configuration, never a route-level one. diff --git a/gears/system/oagw/docs/features/traffic-policy.md b/gears/system/oagw/docs/features/traffic-policy.md new file mode 100644 index 0000000..2dd0167 --- /dev/null +++ b/gears/system/oagw/docs/features/traffic-policy.md @@ -0,0 +1,563 @@ +# Feature: Traffic Policy Enforcement + + + +- [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) + - [1.5 Non-Applicability and Deferrals](#15-non-applicability-and-deferrals) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Proxy Request Under Traffic Policy](#proxy-request-under-traffic-policy) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Chain Ordering](#chain-ordering) + - [Rate Limit Token Bucket Evaluation](#rate-limit-token-bucket-evaluation) + - [Hierarchical Rate Limit Merge](#hierarchical-rate-limit-merge) + - [CORS Preflight Detection and Response](#cors-preflight-detection-and-response) + - [CORS Actual Request Validation](#cors-actual-request-validation) + - [Required Headers Guard Evaluation](#required-headers-guard-evaluation) + - [Auth Credential Injection](#auth-credential-injection) + - [Policy on Streaming and Upgrade Exchanges](#policy-on-streaming-and-upgrade-exchanges) +- [4. States (CDSL)](#4-states-cdsl) + - [Rate Limit Bucket Lifecycle](#rate-limit-bucket-lifecycle) + - [Token Cache Entry Lifecycle](#token-cache-entry-lifecycle) +- [5. Definitions of Done](#5-definitions-of-done) + - [Request/Response Plugin Chain Ordering](#requestresponse-plugin-chain-ordering) + - [Token-Bucket Rate Limiting](#token-bucket-rate-limiting) + - [Rate Limit Response Headers and Rejection](#rate-limit-response-headers-and-rejection) + - [Hierarchical Rate Limit Merge](#hierarchical-rate-limit-merge-1) + - [Per-Instance Rate Limit Counters](#per-instance-rate-limit-counters) + - [CORS Preflight Fast Path](#cors-preflight-fast-path) + - [CORS Actual Request Enforcement](#cors-actual-request-enforcement) + - [Required Headers Guard Enforcement](#required-headers-guard-enforcement) + - [Auth Credential Injection and Isolation](#auth-credential-injection-and-isolation) + - [Token Cache Isolation and Bounds](#token-cache-isolation-and-bounds) + - [Policy Evaluation on Streaming and Upgrade Exchanges](#policy-evaluation-on-streaming-and-upgrade-exchanges) + - [Unimplemented Plugin No-Op](#unimplemented-plugin-no-op) +- [6. Acceptance Criteria](#6-acceptance-criteria) + + + +- [ ] `p2` - **ID**: `cpt-cf-oagw-featstatus-tp-implemented` + + +- [ ] `p2` - `cpt-cf-oagw-feature-traffic-policy` +## 1. Feature Context + +### 1.1 Overview + +This feature wraps the proxy request path with the gateway's cross-cutting traffic policy: token-bucket rate limiting, CORS preflight and actual-request handling, the required-headers guard, and auth-plugin credential injection, invoked in a fixed order around the request path that HTTP Request Proxying resolves and forwards. + +### 1.2 Purpose + +The gateway must protect upstreams from overload, let browser-based clients call the proxy safely, let operators enforce header contracts without a bespoke plugin, and inject credentials into outbound requests without ever exposing them. This feature owns invoking that policy layer on every proxy request and CORS preflight; it consumes the plugin catalog and binding validation that `cpt-cf-oagw-feature-plugin-management` owns and the resolved request path that `cpt-cf-oagw-feature-proxy-http` owns, without redefining either. + +**Requirements**: `cpt-cf-oagw-fr-rate-limiting`, `cpt-cf-oagw-fr-auth-injection`, `cpt-cf-oagw-nfr-credential-isolation`, `cpt-cf-oagw-nfr-input-validation`, `cpt-cf-oagw-fr-plugin-system` (`p2`, matching the PRD's own tag and unchecked state for that ID) + +**Principles**: `cpt-cf-oagw-principle-cred-isolation` + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-oagw-actor-app-developer` | Sends the proxy request (and, for browser clients, the CORS preflight) that this feature's policy layer evaluates before and after the upstream call. | +| `cpt-cf-oagw-actor-tenant-admin` | Configures tenant-scoped rate limit, CORS, guard, and auth bindings within the sharing modes permitted by ancestor tenants. | +| `cpt-cf-oagw-actor-platform-operator` | Configures system-wide or `enforce`-shared rate limit and CORS policy that descendant tenants cannot loosen. | +| `cpt-cf-oagw-actor-cred-store` | Resolves the credential references the bound auth plugin supplies, by UUID reference, at request time. | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) +- **Design**: [DESIGN.md](../DESIGN.md) +- **Dependencies**: `cpt-cf-oagw-feature-proxy-http` (owns the request path — alias resolution, route matching, endpoint selection, header transformation, forwarding — that this feature wraps), `cpt-cf-oagw-feature-plugin-management` (owns the plugin catalog, immutable custom plugin definitions, and binding validation this feature invokes but does not redefine), `cpt-cf-oagw-feature-proxy-streaming` (defers rate limiting, CORS, and auth-injection policy on its SSE and WebSocket upgrade exchanges to this feature, per section 3's streaming/upgrade policy subsection) + +### 1.5 Non-Applicability and Deferrals + +- **No user interface**: this feature enforces policy on a data-plane HTTP path with no rendered surface, so UX and accessibility requirements do not apply. +- **No regulated or personal data of its own**: this feature evaluates policy against request/response metadata (headers, origin, method) and injects credentials it resolves by reference; it does not itself store or process regulated or personal data beyond what an upstream integration's own traffic carries. +- **`queue` and `degrade` rate-limit strategies deferred**: as detailed in section 3's Rate Limit Token Bucket Evaluation, `reject` is the only strategy exercised in this configuration; `queue` and `degrade` are accepted as configuration values but resolve to `reject` semantics because neither a bounded wait duration nor a definition of reduced functionality is specified anywhere upstream of this feature. +- **Tenant-hierarchy deferral (`cpt-cf-oagw-algo-tp-rate-limit-hierarchical-merge`)**: this gear has no access to a tenant-hierarchy source in this configuration, so the hierarchical rate-limit merge described in section 3 — walking from the requesting tenant toward the root, taking the minimum of the descendant's own limit and every `inherit`- or `enforce`-shared ancestor limit — is not served. Rate limiting in this configuration therefore operates within the requesting tenant's own configured limit only; there is no ancestor level to walk, and a tenant's `rate_limit.sharing` value is accepted and stored (per `cpt-cf-oagw-feature-upstream-management`) without being acted upon at request time. Enable/disable and alias resolution are likewise scoped to the calling tenant only, for the same reason — see `cpt-cf-oagw-feature-upstream-management`'s §1.5. +- **Auth credential injection and token cache deferred (`cpt-cf-oagw-algo-tp-auth-credential-injection`)**: neither credential-store integration, token exchange, nor a token cache is implemented in this configuration. There is no code path that resolves a `cred_store` reference, exchanges credentials for authorization material, or stores/evicts a cache entry; every bound auth plugin is therefore invoked as the documented no-op described in `cpt-cf-oagw-dod-tp-plugin-noop`, and no request in this configuration ever has credential material injected into it. `token_cache_ttl_secs` and `token_cache_capacity` are accepted, typed configuration values (`cpt-cf-oagw-dod-gf-config`) that nothing currently consumes. `cpt-cf-oagw-nfr-credential-isolation`'s guarantee — that two tenants or two subjects never share credential material — holds trivially in this configuration precisely because no credential material is ever resolved, cached, or handled at all. +- **Required-headers guard configuration surface deviation (`cpt-cf-oagw-algo-tp-required-headers-guard`)**: `cpt-cf-oagw-adr-required-headers-guard-plugin` (ADR-0009) places `required_request_headers` and `required_response_headers` under a per-binding `config` object on each `plugins.items[]` entry. The frozen `upstream.v1.schema.json` and `route.v1.schema.json` define `plugins.items` as a flat array of plain identifier strings (a GTS identifier or a UUID) with no per-entry `config` object, and the upstream object's `additionalProperties: false` rules out adding a new top-level field to carry one either. In this configuration the guard therefore reads its two comma-separated values from the upstream's `auth.config` object instead of a per-binding `config` object — the only free-form object the frozen schema admits. This is a deliberate, reviewed deviation from ADR-0009's original placement, forced by the frozen schema; the guard's 400-request / 502-response status split is unchanged. + +## 2. Actor Flows (CDSL) + +**Use cases**: `cpt-cf-oagw-usecase-proxy-request`, `cpt-cf-oagw-usecase-rate-limit-exceeded` + +### Proxy Request Under Traffic Policy + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-tp-proxy-request` + +**Actor**: `cpt-cf-oagw-actor-app-developer` + +**Success Scenarios**: +- A request within the effective rate limit, from an allowed CORS origin and method when CORS is enabled, carrying every required request header, and completing credential injection is forwarded to the upstream and returned with rate-limit and (when CORS is enabled) CORS response headers attached. +- A CORS preflight request is answered directly by the policy layer without reaching upstream resolution or the plugin chain. + +**Error Scenarios**: +- The request exceeds the effective rate limit under the `reject` strategy. +- The request's origin or method is not allowed by the resolved upstream/route's CORS configuration. +- The request is missing a header named in the guard's `required_request_headers` configuration. +- The upstream's response is missing a header named in the guard's `required_response_headers` configuration. + +**Steps**: +1. [ ] - `p1` - Application Developer sends `{METHOD} /oagw/v1/proxy/{alias}/{path}`, or `OPTIONS` for a browser preflight - `inst-tp-flow-send` +2. [ ] - `p1` - **IF** the request is a CORS preflight (`OPTIONS` carrying both `Origin` and `Access-Control-Request-Method`) - `inst-tp-flow-if-preflight` + 1. [ ] - `p1` - **RETURN** the 204 response produced by CORS Preflight Detection and Response, without resolving an upstream or evaluating the plugin chain - `inst-tp-flow-return-preflight` +3. [ ] - `p1` - **ELSE** - `inst-tp-flow-else` + 1. [ ] - `p1` - Resolve the upstream and route per HTTP Request Proxying (`cpt-cf-oagw-feature-proxy-http`) - `inst-tp-flow-resolve` + 2. [ ] - `p1` - Evaluate the request against Rate Limit Token Bucket Evaluation for the effective, hierarchically merged rate-limit configuration - `inst-tp-flow-eval-rate-limit` + 3. [ ] - `p1` - **IF** the bucket cannot satisfy the request's cost and the effective strategy is `reject` - `inst-tp-flow-if-rate-rejected` + 1. [ ] - `p1` - **RETURN** 429 with `Retry-After` and the `X-RateLimit-*` headers - `inst-tp-flow-return-429` + 4. [ ] - `p1` - **IF** CORS is enabled for the resolved upstream/route - `inst-tp-flow-if-cors-enabled` + 1. [ ] - `p1` - Evaluate CORS Actual Request Validation against the request's `Origin` and method - `inst-tp-flow-eval-cors` + 2. [ ] - `p1` - **IF** the origin or method is not allowed - `inst-tp-flow-if-cors-disallowed` + 1. [ ] - `p1` - **RETURN** 403 - `inst-tp-flow-return-403` + 5. [ ] - `p1` - Execute Chain Ordering's request phase: Auth, then Guards, then Transforms - `inst-tp-flow-exec-chain-request` + 6. [ ] - `p1` - **IF** a request-phase Guard rejects the request (for example, a missing required request header) - `inst-tp-flow-if-guard-reject-request` + 1. [ ] - `p1` - **RETURN** the guard's rejection status (400 for a missing required request header) - `inst-tp-flow-return-guard-request` + 7. [ ] - `p1` - Forward the transformed request to the upstream per HTTP Request Proxying - `inst-tp-flow-forward` + 8. [ ] - `p1` - Execute Chain Ordering's response phase: Transforms, then Guards - `inst-tp-flow-exec-chain-response` + 9. [ ] - `p1` - **IF** a response-phase Guard rejects the response (for example, a missing required response header) - `inst-tp-flow-if-guard-reject-response` + 1. [ ] - `p1` - **RETURN** 502 - `inst-tp-flow-return-guard-response` + 10. [ ] - `p1` - **RETURN** the upstream's response with `X-RateLimit-*` and, when CORS is enabled, `Access-Control-*` and `Vary: Origin` response headers attached - `inst-tp-flow-return-success` + +## 3. Processes / Business Logic (CDSL) + +Internal middleware invoked around every proxy request and preflight. Presented as ordered algorithms because their execution order and header contracts are relied on by other features and by clients. + +### Chain Ordering + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-tp-chain-ordering` + +**Input**: The effective bound plugin chain (one Auth plugin, zero or more Guards, zero or more Transforms) resolved by `cpt-cf-oagw-feature-plugin-management` for the merged upstream/route/tenant configuration, and the request/response being processed. + +**Output**: The request forwarded to the upstream (or an early rejection from the request phase), and the final response returned to the caller. + +**Steps**: +1. [ ] - `p1` - Parse the effective plugin chain resolved for the merged configuration - `inst-tp-chain-parse` +2. [ ] - `p1` - On the request path, invoke Auth Credential Injection first, ahead of every Guard and Transform - `inst-tp-chain-auth-first` +3. [ ] - `p1` - **FOR EACH** bound Guard, in declared order - `inst-tp-chain-foreach-guard-request` + 1. [ ] - `p1` - Invoke the guard's request-phase check; a rejection short-circuits the chain immediately, skipping remaining guards, all transforms, and the upstream call, and returns the guard's status directly - `inst-tp-chain-guard-request-check` +4. [ ] - `p1` - **FOR EACH** bound Transform, in declared order - `inst-tp-chain-foreach-transform-request` + 1. [ ] - `p1` - Apply the transform's request-phase mutation - `inst-tp-chain-transform-request-apply` +5. [ ] - `p1` - Forward the mutated request to the upstream (owned by `cpt-cf-oagw-feature-proxy-http`) - `inst-tp-chain-forward` +6. [ ] - `p1` - On the response path, reached only when the upstream call completes, **FOR EACH** bound Transform, in declared order - `inst-tp-chain-foreach-transform-response` + 1. [ ] - `p1` - Apply the transform's response-phase mutation - `inst-tp-chain-transform-response-apply` +7. [ ] - `p1` - **FOR EACH** bound Guard, in declared order - `inst-tp-chain-foreach-guard-response` + 1. [ ] - `p1` - Invoke the guard's response-phase check; a rejection short-circuits and returns the guard's status directly - `inst-tp-chain-guard-response-check` +8. [ ] - `p1` - **TRY** - `inst-tp-chain-try` + 1. [ ] - `p1` - Invoke the plugin bound at the current chain position normally - `inst-tp-chain-invoke-normal` +9. [ ] - `p1` - **CATCH** the bound plugin is a custom plugin definition with no served implementation in this configuration - `inst-tp-chain-catch-no-impl` + 1. [ ] - `p1` - Treat the invocation as a documented no-op and continue the chain at the next position, rather than raising an error - `inst-tp-chain-noop-continue` +10. [ ] - `p1` - **RETURN** the forwarded request (request phase) or the final response (response phase) - `inst-tp-chain-return` + +Response-phase order is the mirror image of request-phase order: Guards run before Transforms on the request, Transforms run before Guards on the response, keeping the chain symmetric around the upstream call. + +This chain order realizes `cpt-cf-oagw-fr-plugin-system`'s execution-order requirement (Auth -> Guards -> Transform(request) -> Upstream call -> Transform(response/error)); the response-phase Guard pass added at step 7 extends that PRD text, which does not itself mention a response-phase guard, and is a documented extension needed to support the required-headers guard's response phase per `cpt-cf-oagw-adr-required-headers-guard-plugin`. + +### Rate Limit Token Bucket Evaluation + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-tp-rate-limit-evaluation` + +**Input**: The effective, hierarchically merged rate-limit configuration (sustained rate, window, burst capacity, cost, scope, strategy) for the resolved upstream/route/tenant, and the request being evaluated. + +**Output**: Allow, with remaining/reset accounting, or a strategy-specific outcome when the bucket cannot satisfy the request's cost. + +**Steps**: +1. [ ] - `p1` - Resolve the effective scope key from the configured scope: `global` uses one counter for the whole gear instance, `tenant` keys by tenant id, `user` keys by authenticated subject id, `ip` keys by client IP, `route` keys by the matched route id - `inst-tp-rl-resolve-scope` +2. [ ] - `p1` - Look up the token bucket counter for that scope key, creating and seeding it to full burst capacity on first use - `inst-tp-rl-lookup-bucket` +3. [ ] - `p1` - Refill the bucket by elapsed time since its last update, at the sustained rate, capped at the burst capacity — which defaults to the sustained rate when not explicitly configured - `inst-tp-rl-refill` +4. [ ] - `p1` - Resolve the request's cost, defaulting to 1 when not explicitly configured - `inst-tp-rl-resolve-cost` +5. [ ] - `p1` - **IF** the bucket holds at least `cost` tokens - `inst-tp-rl-if-sufficient` + 1. [ ] - `p1` - Deduct `cost` tokens and **RETURN** Allow, with `X-RateLimit-Limit` set to the sustained rate, `X-RateLimit-Remaining` set to the bucket's remaining tokens, and `X-RateLimit-Reset` set to the time the bucket next reaches full capacity - `inst-tp-rl-allow` +6. [ ] - `p1` - **ELSE** the bucket cannot satisfy the cost - `inst-tp-rl-else-insufficient` + 1. [ ] - `p1` - **IF** the effective strategy is `reject`, or is `queue` or `degrade` (both of which resolve to `reject` semantics in this configuration) - `inst-tp-rl-if-reject` + 1. [ ] - `p1` - **RETURN** 429, with `Retry-After` set to the time until the bucket holds `cost` tokens, plus the same `X-RateLimit-*` headers - `inst-tp-rl-return-429` + +`queue` and `degrade` are accepted as configuration values on the rate-limit block — neither is rejected at write time — but both resolve to `reject` semantics when the bucket cannot satisfy the request's cost, because neither a bounded wait duration for `queue` nor a definition of reduced functionality for `degrade` is specified anywhere upstream of this feature. `reject` is the only strategy actually exercised in this configuration. + +### Hierarchical Rate Limit Merge + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-tp-rate-limit-hierarchical-merge` + +**Input**: The rate-limit configuration at every tenant-hierarchy level from the requesting tenant to the root, each carrying its own sharing mode (`private`, `inherit`, `enforce`). + +**Output**: The single effective rate-limit configuration (sustained rate and burst capacity) enforced for the request. + +**Steps**: +1. [ ] - `p1` - **IF** the requesting (descendant) tenant has a configured rate-limit block - `inst-tp-merge-if-tenant-has-limit` + 1. [ ] - `p1` - Start with the requesting tenant's own configured limit as the candidate effective limit - `inst-tp-merge-start` +2. [ ] - `p1` - **ELSE** the requesting tenant has no configured rate-limit block - `inst-tp-merge-else-no-tenant-limit` + 1. [ ] - `p1` - Start with the candidate unset - `inst-tp-merge-start-unset` +3. [ ] - `p1` - **FOR EACH** ancestor level, walking from the requesting tenant toward the root - `inst-tp-merge-foreach-ancestor` + 1. [ ] - `p1` - **IF** the ancestor's sharing mode is `inherit` or `enforce` - `inst-tp-merge-if-inherit-enforce` + 1. [ ] - `p1` - **IF** the candidate is unset - `inst-tp-merge-if-candidate-unset` + 1. [ ] - `p1` - Set the candidate to this ancestor's own limit outright, since there is no defined value yet to combine it with by minimum - `inst-tp-merge-set-outright` + 2. [ ] - `p1` - **ELSE** the candidate is already set - `inst-tp-merge-else-candidate-set` + 1. [ ] - `p1` - Set the candidate to the minimum of the candidate and the ancestor's own limit - `inst-tp-merge-take-min` + 2. [ ] - `p1` - **IF** the ancestor's sharing mode is `private` - `inst-tp-merge-if-private` + 1. [ ] - `p1` - Leave the candidate unchanged; the ancestor's limit does not participate - `inst-tp-merge-unchanged` +4. [ ] - `p1` - **IF** the candidate is still unset after every ancestor has been walked (neither the tenant nor any ancestor configures a limit) - `inst-tp-merge-if-still-unset` + 1. [ ] - `p1` - **RETURN** no effective limit; no rate limiting applies to the request - `inst-tp-merge-return-none` +5. [ ] - `p1` - **RETURN** the candidate as the effective sustained rate and burst capacity enforced for the request - `inst-tp-merge-return` + +Rate-limit counters backing this evaluation are held in per-gear-instance, in-memory state for this configuration, consistent with the gear's no-database posture. The cross-instance (e.g. Redis-backed) counter synchronization protocol described as a future direction for distributed accuracy is deliberately deferred: this design scopes rate-limit state to in-memory per-instance counters, and the graded deployment runs with no shared database or cache store for counters to synchronize through, so a single instance's local counters are the only available state. + +### CORS Preflight Detection and Response + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-tp-cors-preflight` + +**Input**: An inbound request to the proxy path. + +**Output**: Either a terminal 204 preflight response, or a determination that the request is not a preflight and continues to normal processing. + +**Steps**: +1. [ ] - `p1` - Parse the request's method and headers - `inst-tp-cors-pf-parse` +2. [ ] - `p1` - **IF** the method is `OPTIONS` **AND** both an `Origin` header and an `Access-Control-Request-Method` header are present - `inst-tp-cors-pf-if-preflight` + 1. [ ] - `p1` - Treat the request as a CORS preflight - `inst-tp-cors-pf-treat` + 2. [ ] - `p1` - Skip upstream resolution, tenant-context resolution, authentication, and the plugin chain entirely - `inst-tp-cors-pf-skip` + 3. [ ] - `p1` - Build a 204 No Content response echoing the request's `Origin` value in `Access-Control-Allow-Origin`, the requested method (from `Access-Control-Request-Method`) in `Access-Control-Allow-Methods`, and the requested headers (from `Access-Control-Request-Headers`) in `Access-Control-Allow-Headers` - `inst-tp-cors-pf-build-204` + 4. [ ] - `p1` - Set `Access-Control-Max-Age: 86400` - `inst-tp-cors-pf-max-age` + 5. [ ] - `p1` - Set `Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers` - `inst-tp-cors-pf-vary` + 6. [ ] - `p1` - **RETURN** the 204 response - `inst-tp-cors-pf-return` +3. [ ] - `p1` - **ELSE** - `inst-tp-cors-pf-else` + 1. [ ] - `p1` - **RETURN** not-a-preflight; continue with normal request processing - `inst-tp-cors-pf-continue` + +### CORS Actual Request Validation + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-tp-cors-actual-request` + +**Input**: The resolved upstream/route's effective CORS configuration (`enabled`, `allowed_origins`, `allowed_methods`, `expose_headers`, `allow_credentials`) and the actual (non-preflight) request's `Origin` header and method. + +**Output**: Allow, with response headers to attach after the upstream call, or a 403 rejection. + +**Steps**: +1. [ ] - `p1` - **REQUIRE** CORS is enabled for the resolved upstream/route; otherwise skip this evaluation entirely - `inst-tp-cors-ar-require-enabled` +2. [ ] - `p1` - Compare the request's `Origin` value against each configured allowed origin using exact string comparison of scheme, host, and port — no pattern, prefix, or suffix matching - `inst-tp-cors-ar-compare-origin` +3. [ ] - `p1` - **IF** the origin does not exactly match any allowed origin - `inst-tp-cors-ar-if-origin-mismatch` + 1. [ ] - `p1` - **RETURN** 403 with the `origin_not_allowed` problem type defined in `cpt-cf-oagw-adr-cors` - `inst-tp-cors-ar-403-origin` +4. [ ] - `p1` - **IF** the request method is not in `allowed_methods` - `inst-tp-cors-ar-if-method-mismatch` + 1. [ ] - `p1` - **RETURN** 403 with the distinct `method_not_allowed` problem type defined in `cpt-cf-oagw-adr-cors` - `inst-tp-cors-ar-403-method` +5. [ ] - `p1` - Allow the request to proceed to the plugin chain and upstream forwarding - `inst-tp-cors-ar-allow` +6. [ ] - `p1` - After the upstream responds, set `Access-Control-Allow-Origin` to the matched origin, `Access-Control-Expose-Headers` to the configured `expose_headers`, `Access-Control-Allow-Credentials: true` when `allow_credentials` is configured, and add `Vary: Origin` - `inst-tp-cors-ar-set-headers` +7. [ ] - `p1` - **RETURN** the response with these headers attached - `inst-tp-cors-ar-return` + +A wildcard `allowed_origins` entry combined with `allow_credentials: true` is rejected when the CORS configuration is written, not evaluated here. CORS is configured on the upstream only in this configuration — the frozen Route wire contract defines a `cors` object in its schema but never references it from Route's own properties, so there is no route-level CORS surface to validate against; this evaluation can assume the upstream configuration it reads is already internally consistent. + +The origin-not-allowed and method-not-allowed rejections above carry the two distinct problem-detail types `cpt-cf-oagw-adr-cors` defines for CORS actual-request failures; each response's `detail` names which check failed. + +### Required Headers Guard Evaluation + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-tp-required-headers-guard` + +**Input**: The comma-separated `required_request_headers` and `required_response_headers` configuration values bound to the guard, and the header set of the phase currently being checked (request or response). + +**Output**: Allow, or a rejection naming the first missing header. + +**Steps**: +1. [ ] - `p1` - Select the configuration value for the current phase: `required_request_headers` for the request phase, `required_response_headers` for the response phase - `inst-tp-rhg-select` +2. [ ] - `p1` - **IF** the selected value is absent, or entirely blank after trimming - `inst-tp-rhg-if-blank` + 1. [ ] - `p1` - **RETURN** Allow; the phase is a no-op - `inst-tp-rhg-return-noop` +3. [ ] - `p1` - **ELSE** split the value on commas, trim each entry, lowercase each entry, and drop empty entries - `inst-tp-rhg-parse` +4. [ ] - `p1` - **FOR EACH** remaining header name, in the order declared in the configuration - `inst-tp-rhg-foreach` + 1. [ ] - `p1` - Check the phase's header set for that name, matching case-insensitively - `inst-tp-rhg-check` + 2. [ ] - `p1` - **IF** the name is not present - `inst-tp-rhg-if-missing` + 1. [ ] - `p1` - **RETURN** a rejection naming that header, with status 400 for the request phase and 502 for the response phase, both under the same error code, and stop scanning without checking any further names - `inst-tp-rhg-return-reject` +5. [ ] - `p1` - **RETURN** Allow; every configured name was found - `inst-tp-rhg-return-allow` + +Only header presence is checked; header values are never inspected. `required_request_headers` and `required_response_headers` are configured and evaluated independently, so an upstream can enforce one phase without the other. + +**Deviation from `cpt-cf-oagw-adr-required-headers-guard-plugin`**: ADR-0009 places these two keys under a per-binding `config` object on the guard's `plugins.items[]` entry. The frozen `upstream.v1.schema.json` and `route.v1.schema.json` define `plugins.items` as a flat array of plain identifier strings, with no per-entry `config` object, and the upstream object's `additionalProperties: false` admits no new top-level field to carry one either. In this configuration the guard reads `required_request_headers`/`required_response_headers` from the upstream's `auth.config` object instead — the only free-form object the frozen schema admits — rather than from a per-binding plugin `config`. This is a deliberate, reviewed deviation forced by the frozen schema, not an oversight; the 400-request / 502-response split above is unaffected by it. + +### Auth Credential Injection + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-tp-auth-credential-injection` + +**Input**: The bound auth plugin's identity and configuration (including `cred_store` reference fields) for the resolved upstream/route/tenant, and the outbound request being prepared. + +**Output**: The request with authorization material injected, or a rejection when credential resolution fails. + +**Steps**: +1. [ ] - `p1` - Resolve the bound auth plugin from the effective configuration (identifier resolution and binding validation owned by `cpt-cf-oagw-feature-plugin-management`) - `inst-tp-auth-resolve-plugin` +2. [ ] - `p1` - **IF** the bound auth plugin is a catalog identifier with no served implementation in this configuration - `inst-tp-auth-if-no-impl` + 1. [ ] - `p1` - Treat the invocation as a documented no-op and continue the chain without injecting anything - `inst-tp-auth-noop` +3. [ ] - `p1` - **ELSE** the bound plugin has a served implementation - `inst-tp-auth-else-has-impl` + 1. [ ] - `p1` - Derive a cache key from the combination of tenant identity, subject identity, and the plugin's own configuration, so that two tenants or two subjects never resolve to the same cache entry - `inst-tp-auth-derive-key` + 2. [ ] - `p1` - Look up the derived key in the bounded token cache (capacity `token_cache_capacity`) - `inst-tp-auth-lookup-cache` + 3. [ ] - `p1` - **IF** a cache entry exists and its stored key matches the lookup key - `inst-tp-auth-if-hit` + 1. [ ] - `p1` - Inject the cached authorization material into the outbound request and **RETURN** success - `inst-tp-auth-inject-cached` + 4. [ ] - `p1` - **ELSE** (cache miss, including a stored-key mismatch treated as a miss) - `inst-tp-auth-else-miss` + 1. [ ] - `p1` - Resolve each configured credential reference from the credential store (`cpt-cf-oagw-actor-cred-store`) by its reference - `inst-tp-auth-resolve-creds` + 2. [ ] - `p1` - **TRY** - `inst-tp-auth-try` + 1. [ ] - `p1` - Exchange or otherwise derive the authorization material (for example, a bearer token) using the resolved credentials - `inst-tp-auth-exchange` + 3. [ ] - `p1` - **CATCH** the exchange fails - `inst-tp-auth-catch-fail` + 1. [ ] - `p1` - **RETURN** a failure without caching anything, so the next request for the same key retries - `inst-tp-auth-return-fail` + 4. [ ] - `p1` - Compute the derived lifetime as the smaller of `token_cache_ttl_secs` and the derived material's own reported lifetime minus a 30-second safety margin, per `min(config_ttl, expires_in - 30s)` as specified in `cpt-cf-oagw-adr-oauth2-client-credentials-auth-plugin` - `inst-tp-auth-compute-ttl` + 5. [ ] - `p1` - **IF** the derived lifetime is zero or negative (the material's reported lifetime is at or under the 30-second margin) - `inst-tp-auth-if-lifetime-nonpositive` + 1. [ ] - `p1` - Use the material for the current request without storing it in the token cache - `inst-tp-auth-use-uncached` + 6. [ ] - `p1` - **ELSE** store the result in the token cache under the derived key with the computed expiry, evicting per the cache's bounded-capacity policy when at `token_cache_capacity` - `inst-tp-auth-store-cache` + 7. [ ] - `p1` - Inject the authorization material into the outbound request and **RETURN** success - `inst-tp-auth-inject-fresh` +4. [ ] - `p1` - **NEVER** persist, log, or include resolved secret values or derived authorization material anywhere other than the forwarded request's authorization material — not in gateway logs, not in responses, and not in error bodies - `inst-tp-auth-never-persist` + +### Policy on Streaming and Upgrade Exchanges + +- [ ] `p2` - **ID**: `cpt-cf-oagw-algo-tp-streaming-upgrade-policy` + +**Input**: An SSE or WebSocket-upgrade request proxied per `cpt-cf-oagw-feature-proxy-streaming`, and the traffic policy (rate limiting, CORS, required-headers guard, auth injection) bound to the resolved upstream/route. + +**Output**: The same policy enforcement this feature already applies to a bounded request/response, adapted to a request that establishes a long-lived stream or upgraded connection rather than completing in one exchange. + +`cpt-cf-oagw-feature-proxy-streaming` defers rate limiting, CORS, and auth-plugin credential injection on its SSE and WebSocket exchanges to this feature; this feature is otherwise written entirely for a bounded request/response, so this subsection states how each policy applies to the establishing request instead. + +**Steps**: +1. [ ] - `p1` - Evaluate rate limiting, CORS (when enabled), the request-phase required-headers guard, and auth credential injection exactly once, at request time, before the stream or upgrade is established — never per relayed event or per frame - `inst-tp-stream-policy-once` +2. [ ] - `p1` - Charge the token bucket once, for the establishing request's cost, rather than once per SSE event or once per WebSocket frame - `inst-tp-stream-policy-charge-once` +3. [ ] - `p1` - **IF** the establishing request is allowed and the upstream answers `101 Switching Protocols` - `inst-tp-stream-policy-if-101` + 1. [ ] - `p1` - Attach no `X-RateLimit-*` headers to the `101 Switching Protocols` response; those headers attach only to a rejected response or to an ordinary (non-upgraded) response - `inst-tp-stream-policy-no-headers-101` +4. [ ] - `p1` - **ELSE** (a rejected response, or an ordinary SSE response) - `inst-tp-stream-policy-else-ordinary` + 1. [ ] - `p1` - Attach `X-RateLimit-*` and, when CORS is enabled, `Access-Control-*` and `Vary: Origin` headers as this feature already defines for a bounded response - `inst-tp-stream-policy-headers-ordinary` +5. [ ] - `p1` - Apply CORS actual-request validation to the establishing request's `Origin` and method only; there is no per-event or per-frame CORS check - `inst-tp-stream-policy-cors-establishing` +6. [ ] - `p1` - Evaluate the required-headers guard's response phase against the upstream's response headers (for an SSE stream) or handshake response headers (for a WebSocket upgrade); once relaying or frame relay begins, there is no further response phase to evaluate - `inst-tp-stream-policy-guard-response-phase` +7. [ ] - `p1` - **RETURN** the policy-evaluated outcome (forwarded and established, or rejected before establishment) - `inst-tp-stream-policy-return` + +## 4. States (CDSL) + +### Rate Limit Bucket Lifecycle + +- [ ] `p2` - **ID**: `cpt-cf-oagw-state-tp-bucket-lifecycle` + +**States**: Unseeded, Full, Partial, Empty + +**Initial State**: Unseeded + +**Transitions**: +1. [ ] - `p1` - **FROM** Unseeded **TO** Full **WHEN** a scope key is looked up for the first time and its bucket is created and seeded to full burst capacity - `inst-tp-bucket-state-01` +2. [ ] - `p1` - **FROM** Full **TO** Partial **WHEN** a request deducts tokens, leaving at least one but fewer than burst capacity - `inst-tp-bucket-state-02` +3. [ ] - `p1` - **FROM** Partial **TO** Empty **WHEN** a request deducts the bucket's remaining tokens, or a request's cost cannot be satisfied - `inst-tp-bucket-state-03` +4. [ ] - `p1` - **FROM** Empty **TO** Partial **WHEN** elapsed-time refill restores at least one token but fewer than burst capacity - `inst-tp-bucket-state-04` +5. [ ] - `p1` - **FROM** Partial **TO** Full **WHEN** elapsed-time refill restores the bucket to burst capacity - `inst-tp-bucket-state-05` + +This bucket state lives for the lifetime of the gear instance process, held per-instance and in-memory per `cpt-cf-oagw-dod-tp-rate-limit-per-instance`; it is not persisted and does not survive a restart. + +### Token Cache Entry Lifecycle + +- [ ] `p2` - **ID**: `cpt-cf-oagw-state-tp-token-cache-lifecycle` + +**States**: Absent, Cached, Expired, Evicted + +**Initial State**: Absent + +**Transitions**: +1. [ ] - `p1` - **FROM** Absent **TO** Cached **WHEN** a credential exchange succeeds and the derived lifetime minus the 30-second safety margin is positive, so the result is stored under the derived key - `inst-tp-tokencache-state-01` +2. [ ] - `p1` - **FROM** Cached **TO** Expired **WHEN** the computed expiry (`min(config_ttl, expires_in - 30s)`) elapses without the entry being evicted first - `inst-tp-tokencache-state-02` +3. [ ] - `p1` - **FROM** Cached **TO** Evicted **WHEN** the cache is at `token_cache_capacity` and storing a new entry evicts this one under the cache's bounded-capacity policy - `inst-tp-tokencache-state-03` +4. [ ] - `p1` - **FROM** Expired **TO** Absent **WHEN** the next lookup for that key treats the expired entry as a miss - `inst-tp-tokencache-state-04` +5. [ ] - `p1` - **FROM** Evicted **TO** Absent **WHEN** the next lookup for that key treats the evicted entry as a miss - `inst-tp-tokencache-state-05` + +A credential exchange failure never transitions Absent to Cached, per `cpt-cf-oagw-dod-tp-token-cache`'s prohibition on caching a failed fetch; a derived lifetime at or under the 30-second margin also leaves that request's material at Absent rather than transitioning to Cached. + +## 5. Definitions of Done + +### Request/Response Plugin Chain Ordering + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-tp-chain-ordering` + +The system **MUST** invoke the bound plugin chain in the order Auth, then Guards, then Transforms on the request, and Transforms, then Guards, on the response, short-circuiting on the first Guard rejection in either phase and skipping the remainder of that phase, all subsequent phases, and the upstream call when the rejection occurs on the request phase. + +**Implements**: +- `cpt-cf-oagw-flow-tp-proxy-request` +- `cpt-cf-oagw-algo-tp-chain-ordering` + +**Constraints**: None + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}/{path}` +- Entities: `PluginsConfig` + +### Token-Bucket Rate Limiting + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-tp-rate-limit-token-bucket` + +The system **MUST** evaluate every proxy request against a token-bucket rate limit using the effective sustained rate and window, a burst capacity defaulting to the sustained rate, and a per-request cost defaulting to 1, keyed by the configured scope (`global`, `tenant`, `user`, `ip`, or `route`). The system **MUST** accept `reject`, `queue`, and `degrade` as configured strategy values, but in this configuration **MUST** resolve an empty bucket under any of the three to `reject` semantics (429 with `Retry-After`): `queue` and `degrade` are deliberately deferred because neither a bounded wait duration nor a definition of reduced functionality is specified anywhere upstream of this feature. + +**Implements**: +- `cpt-cf-oagw-flow-tp-proxy-request` +- `cpt-cf-oagw-algo-tp-rate-limit-evaluation` + +**Constraints**: None + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}/{path}` +- Entities: `RateLimitConfig` + +### Rate Limit Response Headers and Rejection + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-tp-rate-limit-headers` + +The system **MUST** attach `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` to every rate-limit-evaluated response, and **MUST** additionally attach `Retry-After` and answer 429 when the `reject` strategy applies to a request the bucket cannot satisfy. + +**Implements**: +- `cpt-cf-oagw-flow-tp-proxy-request` +- `cpt-cf-oagw-algo-tp-rate-limit-evaluation` + +**Constraints**: None + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}/{path}` +- Entities: `RateLimitConfig` + +### Hierarchical Rate Limit Merge + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-tp-rate-limit-hierarchical-merge` + +The system **MUST** merge rate limits across the tenant hierarchy by taking the minimum of the descendant's own limit and every ancestor limit whose sharing mode is `inherit` or `enforce`, and **MUST** leave an ancestor limit whose sharing mode is `private` out of the merge so the descendant's own value applies unchanged. When the requesting tenant has no configured rate-limit block, the system **MUST** start the candidate unset and adopt the first `inherit`- or `enforce`-shared ancestor limit reached outright, rather than combining it by minimum with an undefined value; when neither the tenant nor any ancestor configures a limit, the system **MUST** apply no rate limiting to the request. This gear has no access to a tenant-hierarchy source in this configuration, so the ancestor walk this merge depends on is not served: the effective limit in this configuration is always the requesting tenant's own configured limit (or no limit, when the tenant configures none), and `rate_limit.sharing` is accepted and stored without being acted upon. + +**Implements**: +- `cpt-cf-oagw-algo-tp-rate-limit-hierarchical-merge` + +**Constraints**: None + +**Touches**: +- Entities: `RateLimitConfig` + +### Per-Instance Rate Limit Counters + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-tp-rate-limit-per-instance` + +The system **MUST** hold rate-limit token bucket counters as per-gear-instance, in-memory state, with no cross-instance counter synchronization, for this configuration. + +**Implements**: +- `cpt-cf-oagw-algo-tp-rate-limit-evaluation` + +**Constraints**: None + +**Touches**: +- Entities: `RateLimitConfig` + +### CORS Preflight Fast Path + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-tp-cors-preflight` + +The system **MUST** detect a CORS preflight (`OPTIONS` carrying both `Origin` and `Access-Control-Request-Method`) and answer it with 204 before upstream resolution and without authentication or plugin-chain evaluation, echoing the requested origin, method, and headers, and **MUST** set `Access-Control-Max-Age: 86400` and `Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers` on that response. + +**Implements**: +- `cpt-cf-oagw-flow-tp-proxy-request` +- `cpt-cf-oagw-algo-tp-cors-preflight` + +**Constraints**: None + +**Touches**: +- API: `OPTIONS /oagw/v1/proxy/{alias}/{path}` +- Entities: `CorsConfig` + +### CORS Actual Request Enforcement + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-tp-cors-actual-request` + +The system **MUST** reject an actual request whose origin does not exactly match a configured allowed origin (scheme, host, and port all significant, no pattern matching) with 403 carrying the `origin_not_allowed` problem type, **MUST** reject an actual request whose method is not in `allowed_methods` with 403 carrying the distinct `method_not_allowed` problem type (both types defined in `cpt-cf-oagw-adr-cors`, with the `detail` naming which check failed), and otherwise **MUST** attach `Access-Control-Allow-Origin`, `Access-Control-Expose-Headers`, `Access-Control-Allow-Credentials` (when configured), and `Vary: Origin` to the forwarded response. + +**Implements**: +- `cpt-cf-oagw-flow-tp-proxy-request` +- `cpt-cf-oagw-algo-tp-cors-actual-request` + +**Constraints**: None + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}/{path}` +- Entities: `CorsConfig` + +### Required Headers Guard Enforcement + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-tp-required-headers-guard` + +The system **MUST** enforce `required_request_headers` and `required_response_headers` independently, treating an absent or all-blank configuration value as a no-op for that phase, **MUST** check header names case-insensitively in the order declared after trimming, lowercasing, and dropping empty entries, and **MUST** reject on the first missing name only, with 400 for the request phase and 502 for the response phase, both under the same error code. In this configuration these two values are read from the upstream's `auth.config` object rather than from a per-binding plugin `config` object, a deliberate, reviewed deviation from `cpt-cf-oagw-adr-required-headers-guard-plugin` forced by the frozen schema's flat `plugins.items` array and the upstream object's `additionalProperties: false`; the 400/502 split itself is unaffected. + +**Implements**: +- `cpt-cf-oagw-flow-tp-proxy-request` +- `cpt-cf-oagw-algo-tp-required-headers-guard` + +**Constraints**: None + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}/{path}` +- Entities: `PluginsConfig` + +### Auth Credential Injection and Isolation + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-tp-auth-credential-injection` + +The system **MUST** invoke the bound auth plugin to resolve its configured credential references and inject the resulting authorization material into the forwarded request, and **MUST NOT** ever persist, log, or return resolved secret values or derived authorization material in responses, logs, or error bodies. Neither credential-store integration nor token exchange is implemented in this configuration: there is no code path that resolves a `cred_store` reference or exchanges credentials for authorization material, so every bound auth plugin is invoked as the documented no-op of `cpt-cf-oagw-dod-tp-plugin-noop` instead, and no request has credential material injected. The never-persist/never-log guarantee holds trivially in this configuration precisely because no credential material is ever resolved or handled. + +**Implements**: +- `cpt-cf-oagw-flow-tp-proxy-request` +- `cpt-cf-oagw-algo-tp-auth-credential-injection` + +**Constraints**: None + +**Touches**: +- API: `{METHOD} /oagw/v1/proxy/{alias}/{path}` +- Entities: `PluginsConfig` + +### Token Cache Isolation and Bounds + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-tp-token-cache` + +The system **MUST** key the auth token cache so that two tenants or two subjects never share an entry, **MUST** bound the cache to `token_cache_capacity` entries, **MUST** expire each entry at `min(config_ttl, expires_in - 30s)` — the smaller of `token_cache_ttl_secs` and the token's own reported lifetime minus a 30-second safety margin, per `cpt-cf-oagw-adr-oauth2-client-credentials-auth-plugin` — and **MUST NOT** cache a failed token fetch. When the derived lifetime minus the margin is zero or negative, the system **MUST** use the resolved material for the current request without storing it in the cache. No token cache is implemented in this configuration: with no credential-store integration or token exchange (see `cpt-cf-oagw-dod-tp-auth-credential-injection`), there is nothing to cache, so `token_cache_ttl_secs` and `token_cache_capacity` are accepted, typed configuration values (`cpt-cf-oagw-dod-gf-config`) that nothing currently consumes. The isolation guarantee holds trivially in this configuration precisely because no credential material is ever cached at all. + +**Implements**: +- `cpt-cf-oagw-algo-tp-auth-credential-injection` + +**Constraints**: None + +**Touches**: +- Entities: `PluginsConfig` + +### Policy Evaluation on Streaming and Upgrade Exchanges + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-tp-streaming-upgrade-policy` + +The system **MUST** evaluate rate limiting, CORS, the request-phase required-headers guard, and auth credential injection exactly once per SSE or WebSocket-upgrade request, before the stream or upgrade is established, and **MUST** charge the token bucket once for that establishing request rather than per relayed event or per frame. The system **MUST NOT** attach `X-RateLimit-*` headers to a `101 Switching Protocols` response, **MUST** apply CORS actual-request validation to the establishing request only, and **MUST** evaluate the required-headers guard's response phase against the upstream's response or handshake headers, with no further response phase once relaying or frame relay begins. + +**Implements**: +- `cpt-cf-oagw-algo-tp-streaming-upgrade-policy` + +**Constraints**: None + +**Touches**: +- API: `GET /oagw/v1/proxy/{alias}/{path}` +- Entities: `RateLimitConfig`, `CorsConfig`, `PluginsConfig` + +### Unimplemented Plugin No-Op + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-tp-plugin-noop` + +The system **MUST** treat invocation of a bound plugin that has no served implementation in this configuration as a documented no-op at whichever chain position it is bound, rather than raising an error or failing the request. + +**Implements**: +- `cpt-cf-oagw-algo-tp-chain-ordering` +- `cpt-cf-oagw-algo-tp-auth-credential-injection` + +**Constraints**: None + +**Touches**: +- Entities: `PluginsConfig` + +## 6. Acceptance Criteria + +- [ ] A proxy request that exceeds the effective sustained rate under the `reject` strategy answers 429 and carries a `Retry-After` header. +- [ ] An allowed proxy request evaluated against a configured rate limit carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` response headers. +- [ ] An `OPTIONS` request carrying `Origin` and `Access-Control-Request-Method` against a CORS-enabled upstream answers 204 with the request's origin echoed in `Access-Control-Allow-Origin`, `Access-Control-Max-Age: 86400`, and the three-header `Vary` line, without any upstream resolution occurring. +- [ ] An actual request from an origin not present in `allowed_origins` answers 403 with the `origin_not_allowed` problem type. +- [ ] An actual request using a method not present in `allowed_methods` answers 403 with the distinct `method_not_allowed` problem type. +- [ ] A proxy request missing a header named in `required_request_headers` answers 400. +- [ ] An upstream response missing a header named in `required_response_headers` answers 502. +- [ ] A resource whose ancestor tenant shares a rate limit with `sharing: enforce` and whose descendant tenant configures a stricter own limit enforces the descendant's stricter value (the minimum of the two), while a `private` ancestor limit does not affect the descendant's own value. +- [ ] A requesting tenant with no configured rate-limit block, whose nearest ancestor shares a limit with `sharing: enforce`, enforces that ancestor's limit outright; a request chain with no configured limit at the tenant or any ancestor level is not rate limited. +- [ ] A second request within the same (tenant, subject, auth-configuration) key inside the token cache TTL is served from the cache and does not trigger a second credential-store resolution or token exchange. +- [ ] A failed token exchange is not cached, so credential resolution is retried on the next request for the same cache key. +- [ ] A forced credential-resolution or token-exchange failure produces an error response and a log record containing neither the resolved credential value nor any derived authorization material. +- [ ] A rate-limit configuration with `strategy: queue` or `strategy: degrade` is accepted at write time, and a request that exhausts the bucket under either configured strategy answers 429, the same as `reject`. +- [ ] A cached OAuth2 token's expiry never exceeds `expires_in - 30s`; a token whose reported lifetime is at or under 30 seconds is used for the current request but is not found in the cache on a subsequent lookup for the same key. +- [ ] A successful WebSocket upgrade's `101 Switching Protocols` response carries no `X-RateLimit-*` headers, while a rejected upgrade request carries them. diff --git a/gears/system/oagw/docs/features/upstream-management.md b/gears/system/oagw/docs/features/upstream-management.md new file mode 100644 index 0000000..96f8e93 --- /dev/null +++ b/gears/system/oagw/docs/features/upstream-management.md @@ -0,0 +1,538 @@ +# Feature: Upstream Management + + + + +- [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) + - [1.5 Authorization Disposition](#15-authorization-disposition) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Create Upstream](#create-upstream) + - [List Upstreams](#list-upstreams) + - [Get Upstream](#get-upstream) + - [Replace Upstream](#replace-upstream) + - [Delete Upstream](#delete-upstream) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Validate Upstream Payload](#validate-upstream-payload) + - [Derive and Resolve Alias](#derive-and-resolve-alias) + - [Propagate Enable/Disable State](#propagate-enabledisable-state) + - [Cascade Delete Upstream Routes](#cascade-delete-upstream-routes) +- [4. States (CDSL)](#4-states-cdsl) + - [Upstream Availability State Machine](#upstream-availability-state-machine) +- [5. Definitions of Done](#5-definitions-of-done) + - [Create Upstream](#create-upstream-1) + - [Scheme Acceptance Widening](#scheme-acceptance-widening) + - [Alias Derivation and Immutability](#alias-derivation-and-immutability) + - [Alias Uniqueness](#alias-uniqueness) + - [Payload Validation Errors](#payload-validation-errors) + - [CORS Wildcard/Credentials Validation](#cors-wildcardcredentials-validation) + - [List Upstreams](#list-upstreams-1) + - [Get Upstream](#get-upstream-1) + - [Replace Upstream](#replace-upstream-1) + - [Delete Upstream and Cascade Routes](#delete-upstream-and-cascade-routes) + - [Enable/Disable Propagation](#enabledisable-propagation) +- [6. Acceptance Criteria](#6-acceptance-criteria) + + + +- [ ] `p1` - **ID**: `cpt-cf-oagw-featstatus-um-implemented` + + +- [ ] `p1` - `cpt-cf-oagw-feature-upstream-management` +## 1. Feature Context + +### 1.1 Overview + +This feature provides tenant-scoped CRUD management of Upstream resources — the server-endpoint, protocol, auth, and policy configuration that every proxy request ultimately targets — including field-level schema validation, alias derivation/immutability rules, and enable/disable propagation across the tenant hierarchy. + +### 1.2 Purpose + +Upstreams are the fundamental configuration unit of OAGW: before any request can be proxied, an operator or tenant administrator must define where it goes (server endpoints, protocol), how it authenticates, and under what policies (headers, rate limits, CORS, plugins) it runs. This feature realizes that control-plane surface — the four write/read operations (`POST`, `GET`, `PUT`, `DELETE`) against `/oagw/v1/upstreams` — and the validation, derivation and lifecycle rules that keep the resulting configuration well-formed and safely scoped per tenant. Route-level enable/disable, and alias resolution performed at proxy request time (the tenant-hierarchy shadowing search from descendant to root), are explicitly out of scope here and belong to Route Management and HTTP Request Proxying respectively; this feature only defines the configuration and derivation rules those later features consume. + +**Requirements**: `cpt-cf-oagw-fr-upstream-mgmt`, `cpt-cf-oagw-fr-enable-disable`, `cpt-cf-oagw-fr-alias-resolution`, `cpt-cf-oagw-fr-hierarchical-config`, `cpt-cf-oagw-nfr-multi-tenancy` + +**Principles**: `cpt-cf-oagw-principle-tenant-scope` + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-oagw-actor-platform-operator` | Creates, lists, reads, replaces and deletes global upstream configurations; may bind against an ancestor tenant's alias and enforce configuration on descendants. | +| `cpt-cf-oagw-actor-tenant-admin` | Creates, lists, reads, replaces and deletes upstream configurations scoped to their own tenant hierarchy, within the sharing-mode permissions granted by ancestor tenants. | + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) +- **Design**: [DESIGN.md](../DESIGN.md) +- **Dependencies**: Gear Foundation and Configuration feature (`cpt-cf-oagw-feature-gear-foundation`) — supplies gear registration, typed configuration, shared gear state and the RFC 9457 error-mapping conventions this feature's error responses use. + +### 1.5 Authorization Disposition + +Generic request authentication and coarse authorization are performed by the host runtime before a request reaches this feature; this feature does not re-implement them. This feature owns exactly one OAGW-specific authorization decision of its own: the bind-permission check on Create Upstream described in the bind-to-ancestor error branches below, which fails with `403` through `cpt-cf-oagw-algo-gf-error-mapping`'s `permission denied` category (`cpt-cf-oagw-dod-gf-tenant-context`, `cpt-cf-oagw-dod-gf-error-mapping`). In the graded configuration the bind path — and therefore this permission check — is exercised only when an ancestor-owned upstream at a visible (non-`private`) sharing configuration already exists for the resolved alias; an ordinary tenant-scoped create never evaluates bind permission at all. + +Beyond that one bind-permission decision, this feature performs no further OAGW-specific permission checks of its own: the `:create` write itself is gated only by the host runtime's coarse authorization, never by a feature-owned precondition. In this configuration the bind-permission check itself is never reached either — see the tenant-hierarchy deferral below — so this feature currently raises no `403` of its own at all; the 401 and 403 canonical categories exist in `cpt-cf-oagw-algo-gf-error-mapping` and are exercised by unit tests covering other, already-served cases, but no code path in this feature evaluates `oagw:upstream:bind` or otherwise raises `permission denied` in this configuration. + +**Tenant-hierarchy deferral**: this gear has no access to a tenant-hierarchy source in this configuration, so every mechanism above that depends on walking from the calling tenant toward the root is not served. Concretely: the ancestor-alias bind path — and therefore the `oagw:upstream:bind` permission check that gates it — is described above but is never reachable, because there is no ancestor tenant to resolve against; sharing-mode evaluation (`private` / `inherit` / `enforce` on `auth.sharing`, `plugins.sharing`, `rate_limit.sharing`, `cors.sharing`) is accepted and stored at write time (`cpt-cf-oagw-algo-um-validate-payload`) but is not acted upon at request time — the field is inert; ancestor-disable propagation and the `DisabledByAncestor` effective state described in `cpt-cf-oagw-algo-um-enable-disable-propagation` and `cpt-cf-oagw-state-um-availability` do not occur, since there is no ancestor to disable from; and the hierarchical rate-limit merge that `cpt-cf-oagw-feature-traffic-policy` defines is likewise unreachable (see that feature's §1.5). Alias resolution, enable/disable, and rate limiting therefore all operate within the calling tenant only in this configuration. + +## 2. Actor Flows (CDSL) + +**Use cases**: `cpt-cf-oagw-usecase-configure-upstream` + +### Create Upstream + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-um-create-upstream` + +**Actor**: `cpt-cf-oagw-actor-platform-operator` + +**Success Scenarios**: +- A hostname-based endpoint is submitted without an `alias`; the system derives the alias and returns `201` with the server-generated `id` and the derived alias. +- A hostname-based endpoint is submitted with a caller-supplied `alias` that exactly matches the value the system would have derived; the system accepts it as an idempotent no-op and returns `201`. +- An IP-literal endpoint is submitted together with a caller-supplied `alias`; the system accepts the explicit alias as-is (normalized) and returns `201`. +- An endpoint is submitted with `scheme: "http"` and `port: 80`; the system accepts the plaintext scheme value at validation time and returns `201`. (Whether a plaintext connection is later attempted is a data-plane question owned by HTTP Request Proxying and gated by `allow_http_upstream`; this flow only governs acceptance of the field value.) +- The submitted `alias` matches an existing upstream owned by an ancestor tenant whose sharing configuration exposes at least one of `auth.sharing`/`plugins.sharing`/`rate_limit.sharing` as `inherit` or `enforce` (i.e., is not entirely `private`); the system treats the request as a bind to that ancestor's alias, subject to those sharing modes (`enforce` blocks descendant override) and the caller holding bind permission, and returns `201`. +- The submitted `alias` matches an existing upstream owned by an ancestor tenant whose sharing configuration is entirely `private` across `auth`, `plugins`, and `rate_limit`; that ancestor is not visible for binding, so the system does NOT bind and instead proceeds as an ordinary tenant-scoped create, unaffected by the ancestor's alias or configuration, and returns `201`. + +**Error Scenarios**: +- The caller-supplied `alias` differs from the alias the system would derive for a hostname-based endpoint set: `400` problem+json naming the `alias` field. +- All endpoints are IP-literal (or otherwise non-derivable) and no `alias` is supplied: `400` problem+json naming the `alias` field as required. +- The resolved alias (derived or explicit) already exists for another upstream owned by the same tenant: `409` alias-conflict. +- The payload violates the upstream schema (missing `server` or `protocol`, unknown property, invalid enum value, malformed endpoint): `400` problem+json naming the offending field. +- `cors.allow_credentials` is `true` while `cors.allowed_origins` contains the wildcard `"*"`: `400` problem+json naming the `cors` field, rejected at write time. +- The resolved alias matches a visible (non-`private`) ancestor-tenant upstream, but the caller does not hold `oagw:upstream:bind` permission: `403` problem+json (per `cpt-cf-oagw-algo-gf-error-mapping`'s `permission denied` category) — the create does not fall back to an ordinary tenant-scoped create in this case, since the alias is genuinely contested with a visible ancestor. + +**Steps**: +1. [ ] - `p1` - Operator or tenant administrator sends `POST /oagw/v1/upstreams` with a body containing at least `server` and `protocol` - `inst-um-create-1` +2. [ ] - `p1` - System validates the payload against the upstream field contract (see `cpt-cf-oagw-algo-um-validate-payload`) - `inst-um-create-2` +3. [ ] - `p1` - **IF** payload validation fails - `inst-um-create-3` + 1. [ ] - `p1` - **RETURN** `400` problem+json naming the offending field - `inst-um-create-4` +4. [ ] - `p1` - System derives or validates the `alias` for the endpoint set (see `cpt-cf-oagw-algo-um-derive-alias`) - `inst-um-create-5` +5. [ ] - `p1` - **IF** the resolved alias cannot be determined, or a caller-supplied alias mismatches the derived value - `inst-um-create-6` + 1. [ ] - `p1` - **RETURN** `400` problem+json naming the `alias` field - `inst-um-create-7` +6. [ ] - `p1` - **IF** the resolved alias matches an upstream already owned by an ancestor tenant - `inst-um-create-8` + 1. [ ] - `p1` - **IF** that ancestor upstream's sharing configuration is entirely `private` across `auth.sharing`, `plugins.sharing`, and `rate_limit.sharing` (blocking descendant visibility) - `inst-um-create-8a` + 1. [ ] - `p1` - Treat the ancestor as not found for binding purposes and fall through to the ordinary tenant-scoped create path (the **ELSE** branch below), unaffected by the ancestor's alias or configuration - `inst-um-create-8b` + 2. [ ] - `p1` - **ELSE IF** the caller does not hold `oagw:upstream:bind` permission - `inst-um-create-8c` + 1. [ ] - `p1` - **RETURN** `403` problem+json via `cpt-cf-oagw-algo-gf-error-mapping`'s `permission denied` category - `inst-um-create-8d` + 3. [ ] - `p1` - **ELSE** - `inst-um-create-8e` + 1. [ ] - `p1` - System validates the ancestor's `auth`/`plugins`/`rate_limit` sharing modes for this create (`enforce` blocks descendant override) and proceeds with the bind - `inst-um-create-9` +7. [ ] - `p1` - **ELSE** (no ancestor match, including a private-sharing ancestor falling through from step 6.1) - `inst-um-create-10` + 1. [ ] - `p1` - System checks the resolved alias for uniqueness within the calling tenant - `inst-um-create-11` +8. [ ] - `p1` - **IF** the resolved alias already exists for the calling tenant - `inst-um-create-12` + 1. [ ] - `p1` - **RETURN** `409` alias-conflict - `inst-um-create-13` +9. [ ] - `p1` - System assigns a server-generated `id`, applies field defaults (`enabled: true`, `auth.sharing`/`plugins.sharing`/`rate_limit.sharing`/`cors.sharing: private`, `cors.enabled: false`, endpoint `port: 443`), and stores the upstream scoped to the calling tenant - `inst-um-create-14` +10. [ ] - `p1` - **RETURN** `201` with the created upstream representation - `inst-um-create-15` + +### List Upstreams + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-um-list-upstreams` + +**Actor**: `cpt-cf-oagw-actor-platform-operator` + +**Success Scenarios**: +- Caller requests the list with no query parameters; the system returns up to 50 upstreams owned by the calling tenant, ordered by creation. +- Caller supplies `$top`, `$skip`, `$filter`, `$select` and/or `$orderby`; the system applies them and returns a matching page. + +**Error Scenarios**: +- `$top` is supplied above the maximum: the system clamps the effective page size to 100 rather than erroring. + +**Steps**: +1. [ ] - `p1` - Operator or tenant administrator sends `GET /oagw/v1/upstreams` with optional `$filter`, `$select`, `$orderby`, `$top`, `$skip` - `inst-um-list-1` +2. [ ] - `p1` - System resolves `$top` to the caller's value, or `50` if absent, clamped to a maximum of `100` - `inst-um-list-2` +3. [ ] - `p1` - System reads upstreams owned by the calling tenant, applying `$filter`, `$orderby`, `$skip` and the resolved `$top` - `inst-um-list-3` +4. [ ] - `p1` - **IF** `$select` is supplied - `inst-um-list-4` + 1. [ ] - `p1` - System projects only the requested fields per upstream - `inst-um-list-5` +5. [ ] - `p1` - **RETURN** `200` with the resulting page of upstreams - `inst-um-list-6` + +### Get Upstream + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-um-get-upstream` + +**Actor**: `cpt-cf-oagw-actor-platform-operator` + +**Success Scenarios**: +- Caller requests an upstream `id` owned by the calling tenant; the system returns its full representation. + +**Error Scenarios**: +- The `id` does not exist, or exists but is owned by a different tenant: `404` (ancestor and unrelated-tenant upstreams are equally invisible through the management API). + +**Steps**: +1. [ ] - `p1` - Operator or tenant administrator sends `GET /oagw/v1/upstreams/{id}` - `inst-um-get-1` +2. [ ] - `p1` - System reads the upstream by `id`, scoped to the calling tenant - `inst-um-get-2` +3. [ ] - `p1` - **IF** no upstream with that `id` exists for the calling tenant - `inst-um-get-3` + 1. [ ] - `p1` - **RETURN** `404` (identical response whether the `id` is unknown or belongs to another tenant, including an ancestor) - `inst-um-get-4` +4. [ ] - `p1` - **ELSE** - `inst-um-get-5` + 1. [ ] - `p1` - **RETURN** `200` with the upstream representation - `inst-um-get-6` + +### Replace Upstream + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-um-replace-upstream` + +**Actor**: `cpt-cf-oagw-actor-platform-operator` + +**Success Scenarios**: +- Caller submits a full replacement body for an upstream it owns, with the same `alias` as currently stored; the system overwrites all fields and returns `200`. +- Caller omits an optional field present on the stored upstream (e.g. `tags`, `headers`); the system clears that field to its schema default rather than preserving the previous value. + +**Error Scenarios**: +- The `id` does not exist, or belongs to another tenant (including an ancestor): `404`. +- The body's `alias` differs from the upstream's current, stored alias: `400` problem+json naming the `alias` field (`alias` is immutable after creation). This also covers the non-derivable-with-different-explicit-alias transition: neither the stored nor the replacement `server.endpoints` are hostname-derivable, but the caller supplies a different explicit `alias` than the one currently stored. +- The replacement's `server.endpoints` remain hostname-derivable but recompute (per `cpt-cf-oagw-algo-um-derive-alias`) to a value different from the upstream's stored `alias`: `400` problem+json naming both the `alias` and `server` fields — independent of what the body's literal `alias` field contains. +- The replacement's `server.endpoints` change from hostname-derivable (the basis of the currently stored `alias`) to non-derivable (for example a hostname endpoint replaced by an IP literal): `400` problem+json naming both the `alias` and `server` fields — a derivable-to-non-derivable endpoint-set transition is rejected rather than silently keeping the old alias. +- The body includes `enabled: true` while an ancestor-tenant upstream in this resource's bind lineage is currently disabled: `400` problem+json naming the `enabled` field (see `cpt-cf-oagw-algo-um-enable-disable-propagation`). +- The replacement body otherwise fails schema validation, including the `scheme` enum (still accepting the widened `http`/`ws` set) and the CORS wildcard/credentials conflict: `400` problem+json naming the offending field. + +**Steps**: +1. [ ] - `p1` - Operator or tenant administrator sends `PUT /oagw/v1/upstreams/{id}` with a full upstream body - `inst-um-replace-1` +2. [ ] - `p1` - System reads the existing upstream by `id`, scoped to the calling tenant - `inst-um-replace-2` +3. [ ] - `p1` - **IF** no upstream with that `id` exists for the calling tenant - `inst-um-replace-3` + 1. [ ] - `p1` - **RETURN** `404` - `inst-um-replace-4` +4. [ ] - `p1` - System validates the replacement payload against the upstream field contract (see `cpt-cf-oagw-algo-um-validate-payload`) - `inst-um-replace-5` +5. [ ] - `p1` - **IF** payload validation fails - `inst-um-replace-6` + 1. [ ] - `p1` - **RETURN** `400` problem+json naming the offending field - `inst-um-replace-7` +6. [ ] - `p1` - **IF** the body's `alias` differs from the stored `alias` - `inst-um-replace-8` + 1. [ ] - `p1` - **RETURN** `400` problem+json naming the `alias` field - `inst-um-replace-9` +7. [ ] - `p1` - **ELSE** re-run the alias-derivation/consistency check for the replacement's `server.endpoints` against the stored `alias` (`cpt-cf-oagw-algo-um-derive-alias`'s replace-time steps) - `inst-um-replace-9a` +8. [ ] - `p1` - **IF** that check rejects the replacement (the recomputed alias differs from the stored alias, or a derivable-to-non-derivable endpoint-set transition is detected) - `inst-um-replace-9b` + 1. [ ] - `p1` - **RETURN** `400` problem+json naming the `alias` and `server` fields - `inst-um-replace-9c` +9. [ ] - `p1` - **IF** the body sets `enabled: true` while ancestor-disable propagation is currently in effect for this resource - `inst-um-replace-10` + 1. [ ] - `p1` - **RETURN** `400` problem+json naming the `enabled` field - `inst-um-replace-11` +10. [ ] - `p1` - System overwrites the stored upstream with the replacement body, `id` and `alias` unchanged, and clears any optional field the body omits to its schema default - `inst-um-replace-12` +11. [ ] - `p1` - **RETURN** `200` with the replaced upstream representation - `inst-um-replace-13` + +### Delete Upstream + +- [ ] `p1` - **ID**: `cpt-cf-oagw-flow-um-delete-upstream` + +**Actor**: `cpt-cf-oagw-actor-platform-operator` + +**Success Scenarios**: +- Caller deletes an upstream it owns; the system removes the upstream and every route registered against it, then returns `204`. + +**Error Scenarios**: +- The `id` does not exist, or belongs to another tenant (including an ancestor): `404`. + +**Steps**: +1. [ ] - `p1` - Operator or tenant administrator sends `DELETE /oagw/v1/upstreams/{id}` - `inst-um-delete-1` +2. [ ] - `p1` - System reads the existing upstream by `id`, scoped to the calling tenant - `inst-um-delete-2` +3. [ ] - `p1` - **IF** no upstream with that `id` exists for the calling tenant - `inst-um-delete-3` + 1. [ ] - `p1` - **RETURN** `404` - `inst-um-delete-4` +4. [ ] - `p1` - **ELSE** - `inst-um-delete-5` + 1. [ ] - `p1` - System cascades the deletion to every route registered against this upstream (see `cpt-cf-oagw-algo-um-cascade-delete-routes`), then deletes the upstream itself - `inst-um-delete-6` +5. [ ] - `p1` - **RETURN** `204` with an empty body - `inst-um-delete-7` + +## 3. Processes / Business Logic (CDSL) + +### Validate Upstream Payload + +- [ ] `p1` - **ID**: `cpt-cf-oagw-algo-um-validate-payload` + +**Input**: A create or replace request body for an Upstream. + +**Output**: A validated, defaulted Upstream field set, or a `400` problem+json error naming the offending field. + +**Steps**: +1. [ ] - `p1` - Reject the payload if `server` or `protocol` is absent — both are required top-level fields - `inst-um-validate-1` +2. [ ] - `p1` - Reject the payload if it contains any property outside the documented field set (`enabled`, `alias`, `tags`, `server`, `protocol`, `auth`, `headers`, `plugins`, `rate_limit`, `cors`) - `inst-um-validate-2` +3. [ ] - `p1` - Validate `server.endpoints` contains at least one entry, and for each endpoint validate `host` is a hostname, IPv4 or IPv6 literal, `scheme` is one of `https`, `wss`, `wt`, `grpc`, `http` or `ws` (the widened set — see `cpt-cf-oagw-dod-um-scheme-widening`), defaulting to `https` when absent, and `port` is an integer in `1..65535`, defaulting to `443` when absent - `inst-um-validate-3` +4. [ ] - `p1` - Validate `protocol` is one of the two supported protocol GTS identifiers (HTTP or gRPC) - `inst-um-validate-4` +5. [ ] - `p1` - Default `enabled` to `true` when absent - `inst-um-validate-5` +6. [ ] - `p1` - Validate `tags`, when present, is an array of lowercase, hyphen/underscore-safe strings - `inst-um-validate-6` +7. [ ] - `p1` - Validate `auth.sharing`, `plugins.sharing`, `rate_limit.sharing` and `cors.sharing`, when present, are one of `private`, `inherit`, `enforce`, each defaulting to `private` when absent - `inst-um-validate-7` +8. [ ] - `p1` - Validate `headers.request`/`headers.response` `set`/`add`/`remove` shapes and `headers.request.passthrough`, defaulting `passthrough` to `none` when absent - `inst-um-validate-8` +9. [ ] - `p1` - Validate `rate_limit.sustained.rate` is present when `rate_limit` is supplied, defaulting `rate_limit.algorithm` to `token_bucket`, `rate_limit.scope` to `tenant`, `rate_limit.strategy` to `reject`, `rate_limit.cost` to `1`, and `rate_limit.burst.capacity` to `rate_limit.sustained.rate` when absent - `inst-um-validate-9` +10. [ ] - `p1` - Validate `cors.enabled`, defaulting to `false` when absent; when present, default `cors.allowed_methods` to `["GET", "POST"]` and `cors.expose_headers` to `[]` - `inst-um-validate-10` +11. [ ] - `p1` - **IF** `cors.allow_credentials` is `true` and `cors.allowed_origins` contains the wildcard `"*"` - `inst-um-validate-11` + 1. [ ] - `p1` - **RETURN** `400` problem+json naming the `cors` field - `inst-um-validate-12` +12. [ ] - `p1` - **CATCH** any other schema violation - `inst-um-validate-13` + 1. [ ] - `p1` - **RETURN** `400` problem+json naming the specific offending field path - `inst-um-validate-14` +13. [ ] - `p1` - **RETURN** the validated, defaulted field set - `inst-um-validate-15` + +### Derive and Resolve Alias + +- [ ] `p1` - **ID**: `cpt-cf-oagw-algo-um-derive-alias` + +**Input**: `server.endpoints[]` and an optional caller-supplied `alias`, at create time; or, at replace time, the replacement's `server.endpoints[]` together with the upstream's previously stored `alias`. + +**Output**: A resolved, tenant-unique `alias`, or a `400` problem+json error naming the `alias` field (and, at replace time, the `server` field alongside it). + +**Steps**: +1. [ ] - `p1` - **IF** every endpoint's `host` is a hostname (not an IP literal) - `inst-um-alias-1` + 1. [ ] - `p1` - **IF** there is exactly one distinct hostname - `inst-um-alias-2` + 1. [ ] - `p1` - Derive the candidate alias as that hostname, omitting the port when the endpoint's port is the scheme's standard port (`80` for `http`, `443` for `https`/`wss`/`wt`/`grpc`, `80` for `ws`), otherwise appending `:{port}` - `inst-um-alias-3` + 2. [ ] - `p1` - **ELSE** - `inst-um-alias-4` + 1. [ ] - `p1` - Compute the longest common domain suffix (at least 2 labels) across all distinct hostnames, validated against the public suffix list to reject a bare public suffix (e.g. `co.uk`) - `inst-um-alias-5` + 2. [ ] - `p1` - **IF** a valid common suffix exists - `inst-um-alias-6` + 1. [ ] - `p1` - Derive the candidate alias as that suffix, omitting the port when shared and standard, otherwise appending `:{port}` - `inst-um-alias-7` + 3. [ ] - `p1` - **ELSE** - `inst-um-alias-8` + 1. [ ] - `p1` - Mark the alias as non-derivable - `inst-um-alias-9` +2. [ ] - `p1` - **ELSE** - `inst-um-alias-10` + 1. [ ] - `p1` - Mark the alias as non-derivable (any IP-literal endpoint forces explicit alias) - `inst-um-alias-11` +3. [ ] - `p1` - **IF** a candidate alias was derived - `inst-um-alias-12` + 1. [ ] - `p1` - Normalize the candidate to ASCII lowercase with any trailing dot stripped - `inst-um-alias-13` + 2. [ ] - `p1` - **IF** the caller supplied no `alias` - `inst-um-alias-14` + 1. [ ] - `p1` - **RETURN** the normalized candidate as the resolved alias - `inst-um-alias-15` + 3. [ ] - `p1` - **ELSE IF** the caller-supplied `alias`, normalized the same way, exactly equals the candidate - `inst-um-alias-16` + 1. [ ] - `p1` - **RETURN** the candidate as the resolved alias (idempotent no-op) - `inst-um-alias-17` + 4. [ ] - `p1` - **ELSE** - `inst-um-alias-18` + 1. [ ] - `p1` - **RETURN** `400` problem+json naming the `alias` field - `inst-um-alias-19` +4. [ ] - `p1` - **ELSE** - `inst-um-alias-20` + 1. [ ] - `p1` - **IF** the caller supplied no `alias` - `inst-um-alias-21` + 1. [ ] - `p1` - **RETURN** `400` problem+json naming the `alias` field as required - `inst-um-alias-22` + 2. [ ] - `p1` - **ELSE** - `inst-um-alias-23` + 1. [ ] - `p1` - Normalize the caller-supplied `alias` to ASCII lowercase with any trailing dot stripped and **RETURN** it as the resolved alias - `inst-um-alias-24` +5. [ ] - `p1` - **RETURN** `409` alias-conflict if the resolved alias already exists for another upstream owned by the calling tenant - `inst-um-alias-25` +6. [ ] - `p1` - **AT REPLACE TIME** (invoked by `cpt-cf-oagw-flow-um-replace-upstream` once the body's literal `alias` has already been confirmed equal to the stored `alias`), recompute a candidate alias from the replacement's `server.endpoints` using steps 1-2 above (derivation only, no caller-supplied-alias comparison) - `inst-um-alias-26` +7. [ ] - `p1` - **IF** the replacement's endpoint set is hostname-derivable and the recomputed candidate (normalized as in step 3.1) differs from the stored `alias` - `inst-um-alias-27` + 1. [ ] - `p1` - **RETURN** `400` problem+json naming the `alias` and `server` fields — an endpoint change that would alter the derived alias is rejected, independent of what the body's `alias` field contains - `inst-um-alias-28` +8. [ ] - `p1` - **ELSE IF** the replacement's endpoint set is non-derivable (any IP-literal endpoint, or hostnames sharing no valid common suffix) while the stored `alias` is itself equal to the value that would have been derived from the previously stored `server.endpoints` (i.e., the upstream's alias was originally auto-derived, not explicit) - `inst-um-alias-29` + 1. [ ] - `p1` - **RETURN** `400` problem+json naming the `alias` and `server` fields — a derivable-to-non-derivable endpoint-set transition is rejected rather than silently keeping the old alias - `inst-um-alias-30` +9. [ ] - `p1` - **ELSE** (the endpoint set is non-derivable and the previously stored `alias` was itself explicit, i.e., the non-derivable-with-different-explicit-alias case; or the endpoint set is hostname-derivable and the recomputed candidate matches the stored `alias`) - `inst-um-alias-31` + 1. [ ] - `p1` - **RETURN** the stored `alias` unchanged (the ordinary alias-immutability check already performed by the Replace flow is sufficient in this branch) - `inst-um-alias-32` + +### Propagate Enable/Disable State + +- [ ] `p1` - **ID**: `cpt-cf-oagw-algo-um-enable-disable-propagation` + +**Input**: An upstream resource and, for a write, the caller's requested `enabled` value. + +**Output**: The resource's effective enabled state, or a rejection of a write that would violate propagation. + +**Steps**: +1. [ ] - `p1` - Determine whether this upstream was created as a bind against an ancestor tenant's upstream sharing the same alias - `inst-um-enable-1` +2. [ ] - `p1` - **IF** it was bound to an ancestor upstream and that ancestor's own effective state (recursively applying this same process) is disabled - `inst-um-enable-2` + 1. [ ] - `p1` - Treat the effective state as `DisabledByAncestor`, regardless of this resource's own `enabled` value - `inst-um-enable-3` +3. [ ] - `p1` - **ELSE** - `inst-um-enable-4` + 1. [ ] - `p1` - Treat the effective state as `Active` when this resource's own `enabled` is `true`, otherwise `DisabledBySelf` - `inst-um-enable-5` +4. [ ] - `p1` - **IF** this is a write that sets `enabled: true` and the effective state before the write is `DisabledByAncestor` - `inst-um-enable-6` + 1. [ ] - `p1` - **RETURN** rejection: `400` problem+json naming the `enabled` field — a descendant cannot re-enable a resource an ancestor has disabled - `inst-um-enable-7` +5. [ ] - `p1` - **RETURN** the effective state (consumed by HTTP Request Proxying to answer proxy requests with `503` when not `Active`; route-level enable/disable is a separate mechanism owned by Route Management) - `inst-um-enable-8` + +### Cascade Delete Upstream Routes + +- [ ] `p1` - **ID**: `cpt-cf-oagw-algo-um-cascade-delete-routes` + +**Input**: The `id` of an upstream being deleted. + +**Output**: Removal of the upstream and every route registered against it. + +**Steps**: +1. [ ] - `p1` - Find every route whose `upstream_id` equals this upstream's `id`, scoped to the calling tenant - `inst-um-cascade-1` +2. [ ] - `p1` - **FOR EACH** matching route - `inst-um-cascade-2` + 1. [ ] - `p1` - Remove the route and its match rules, method allowlist and tags - `inst-um-cascade-3` +3. [ ] - `p1` - Remove the upstream's own tags and plugin bindings, then remove the upstream record itself, as a single atomic operation - `inst-um-cascade-4` +4. [ ] - `p1` - **RETURN** completion (the caller flow returns `204`) - `inst-um-cascade-5` + +## 4. States (CDSL) + +### Upstream Availability State Machine + +- [ ] `p2` - **ID**: `cpt-cf-oagw-state-um-availability` + +**States**: Active, DisabledBySelf, DisabledByAncestor + +**Initial State**: Active + +**Transitions**: +1. [ ] - `p1` - **FROM** Active **TO** DisabledBySelf **WHEN** the resource owner replaces the upstream with `enabled: false` - `inst-um-state-1` +2. [ ] - `p1` - **FROM** Active **TO** DisabledByAncestor **WHEN** an ancestor-tenant upstream in this resource's bind lineage transitions to a disabled effective state - `inst-um-state-2` +3. [ ] - `p1` - **FROM** DisabledBySelf **TO** Active **WHEN** the resource owner replaces the upstream with `enabled: true` and no ancestor-tenant upstream in this resource's bind lineage is currently disabled - `inst-um-state-3` +4. [ ] - `p1` - **FROM** DisabledByAncestor **TO** Active **WHEN** the ancestor-tenant upstream returns to an `Active` effective state and this resource's own `enabled` value is `true` (a descendant's attempt to force this transition while the ancestor is still disabled is rejected — see `cpt-cf-oagw-algo-um-enable-disable-propagation`) - `inst-um-state-4` + +## 5. Definitions of Done + +### Create Upstream + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-um-create` + +The system **MUST** implement `POST /oagw/v1/upstreams`, assigning a server-generated `id`, applying the documented field defaults, and scoping the created upstream to the calling tenant. In this configuration the ancestor-alias bind path (and its `oagw:upstream:bind` permission check) is not served — the gear has no access to a tenant-hierarchy source — so every create resolves within the calling tenant only, and the `sharing` fields it defaults to `private` are accepted and stored without being acted upon. + +**Implements**: +- `cpt-cf-oagw-flow-um-create-upstream` + +**Constraints**: `cpt-cf-oagw-constraint-https-only` + +**Touches**: +- API: `POST /oagw/v1/upstreams` +- DB: `cpt-cf-oagw-db-schema` +- DB Table: `oagw_upstream`, `oagw_upstream_tag` +- Entities: `Upstream`, `ServerConfig`, `Endpoint`, `AuthConfig`, `HeadersConfig`, `RateLimitConfig`, `CorsConfig`, `PluginsConfig` + +### Scheme Acceptance Widening + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-um-scheme-widening` + +The system **MUST** accept `http` and `ws` as valid values of `server.endpoints[].scheme`, in addition to `https`, `wss`, `wt` and `grpc`, at payload-validation time on both create and replace. An upstream declared with `{"scheme": "http", "port": 80}` **MUST** be created successfully with `201`. This acceptance is independent of whether a plaintext connection is ever actually made to that endpoint — that separate question is owned by HTTP Request Proxying and gated by the `allow_http_upstream` configuration key. + +**Implements**: +- `cpt-cf-oagw-flow-um-create-upstream` +- `cpt-cf-oagw-flow-um-replace-upstream` + +**Constraints**: `cpt-cf-oagw-constraint-https-only` + +**Touches**: +- API: `POST /oagw/v1/upstreams`, `PUT /oagw/v1/upstreams/{id}` +- Entities: `ServerConfig`, `Endpoint` + +### Alias Derivation and Immutability + +- [x] `p2` - **ID**: `cpt-cf-oagw-dod-um-alias-derivation` + +The system **MUST** derive the `alias` from endpoint hostnames when every endpoint is hostname-based, require an explicit `alias` when it cannot be derived (any IP-literal endpoint, or hostnames sharing no valid common suffix), accept a caller-supplied alias that exactly matches the derived value as an idempotent no-op, reject a caller-supplied alias that differs from the derived value with `400`, normalize every alias to ASCII lowercase with trailing dots stripped, and treat `alias` as immutable once the upstream exists (a `PUT` body whose `alias` differs from the stored value is rejected with `400`). On replace, the system **MUST** additionally recompute the alias from the replacement's `server.endpoints` and reject with `400` naming `alias` and `server` whenever that recomputed value differs from the stored alias — independent of the body's literal `alias` field — and **MUST** likewise reject a derivable-to-non-derivable endpoint-set transition, so that no endpoint change can silently invalidate the stored alias's derivation basis. + +**Implements**: +- `cpt-cf-oagw-flow-um-create-upstream` +- `cpt-cf-oagw-flow-um-replace-upstream` +- `cpt-cf-oagw-algo-um-derive-alias` + +**Touches**: +- API: `POST /oagw/v1/upstreams`, `PUT /oagw/v1/upstreams/{id}` +- DB: `cpt-cf-oagw-db-schema` +- DB Table: `oagw_upstream` +- Entities: `Upstream`, `Endpoint` + +### Alias Uniqueness + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-um-alias-conflict` + +The system **MUST** enforce alias uniqueness per tenant: a create whose resolved alias already exists for another upstream owned by the calling tenant **MUST** return `409`. + +**Implements**: +- `cpt-cf-oagw-flow-um-create-upstream` +- `cpt-cf-oagw-algo-um-derive-alias` + +**Touches**: +- API: `POST /oagw/v1/upstreams` +- DB: `cpt-cf-oagw-db-schema` +- DB Table: `oagw_upstream` +- Entities: `Upstream` + +### Payload Validation Errors + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-um-validation-errors` + +The system **MUST** reject a create or replace payload that violates the upstream field contract with `400` and an `application/problem+json` body that names the specific offending field. + +**Implements**: +- `cpt-cf-oagw-flow-um-create-upstream` +- `cpt-cf-oagw-flow-um-replace-upstream` +- `cpt-cf-oagw-algo-um-validate-payload` + +**Touches**: +- API: `POST /oagw/v1/upstreams`, `PUT /oagw/v1/upstreams/{id}` +- Entities: `Upstream` + +### CORS Wildcard/Credentials Validation + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-um-cors-validation` + +The system **MUST** reject, at write time, an upstream whose `cors.allow_credentials` is `true` while `cors.allowed_origins` contains the wildcard `"*"`, with `400` naming the `cors` field. + +**Implements**: +- `cpt-cf-oagw-algo-um-validate-payload` + +**Touches**: +- API: `POST /oagw/v1/upstreams`, `PUT /oagw/v1/upstreams/{id}` +- Entities: `CorsConfig` + +### List Upstreams + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-um-list` + +The system **MUST** implement `GET /oagw/v1/upstreams`, returning only upstreams owned by the calling tenant, supporting `$filter`, `$select`, `$orderby`, `$top` (default `50`, clamped to a maximum of `100`) and `$skip`. + +**Implements**: +- `cpt-cf-oagw-flow-um-list-upstreams` + +**Touches**: +- API: `GET /oagw/v1/upstreams` +- DB: `cpt-cf-oagw-db-schema` +- DB Table: `oagw_upstream` +- Entities: `Upstream` + +### Get Upstream + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-um-get` + +The system **MUST** implement `GET /oagw/v1/upstreams/{id}`, returning `200` with the full representation for an upstream owned by the calling tenant, and `404` when the `id` is unknown or owned by a different tenant (including an ancestor). + +**Implements**: +- `cpt-cf-oagw-flow-um-get-upstream` + +**Touches**: +- API: `GET /oagw/v1/upstreams/{id}` +- DB: `cpt-cf-oagw-db-schema` +- DB Table: `oagw_upstream` +- Entities: `Upstream` + +### Replace Upstream + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-um-replace` + +The system **MUST** implement `PUT /oagw/v1/upstreams/{id}` as a full replacement: `id` and `alias` immutable, omitted optional fields cleared to their schema defaults, the same validation rules as create re-applied, and `404` when the `id` is unknown or owned by a different tenant. + +**Implements**: +- `cpt-cf-oagw-flow-um-replace-upstream` + +**Touches**: +- API: `PUT /oagw/v1/upstreams/{id}` +- DB: `cpt-cf-oagw-db-schema` +- DB Table: `oagw_upstream`, `oagw_upstream_tag` +- Entities: `Upstream`, `ServerConfig`, `Endpoint`, `AuthConfig`, `HeadersConfig`, `RateLimitConfig`, `CorsConfig`, `PluginsConfig` + +### Delete Upstream and Cascade Routes + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-um-delete` + +The system **MUST** implement `DELETE /oagw/v1/upstreams/{id}`, returning `204` and removing every route registered against that upstream in the same atomic operation, and `404` when the `id` is unknown or owned by a different tenant. + +**Implements**: +- `cpt-cf-oagw-flow-um-delete-upstream` +- `cpt-cf-oagw-algo-um-cascade-delete-routes` + +**Touches**: +- API: `DELETE /oagw/v1/upstreams/{id}` +- DB: `cpt-cf-oagw-db-schema` +- DB Table: `oagw_upstream`, `oagw_route` +- Entities: `Upstream` + +### Enable/Disable Propagation + +- [ ] `p1` - **ID**: `cpt-cf-oagw-dod-um-enable-disable-propagation` + +The system **MUST** support the `enabled` boolean field (default `true`) on upstreams, must reject a write that attempts to re-enable a resource while an ancestor-tenant upstream in its bind lineage remains disabled, and must propagate an ancestor's disable to every descendant so that the descendant's effective state is disabled regardless of its own `enabled` value. Route-level enable/disable is a separate mechanism owned by Route Management and is out of scope here. In this configuration the gear has no access to a tenant-hierarchy source, so no upstream ever has an ancestor to be bound to: ancestor-disable propagation and the `DisabledByAncestor` effective state are therefore not served, and every upstream's effective state resolves from its own `enabled` value alone (`Active` or `DisabledBySelf`). + +**Implements**: +- `cpt-cf-oagw-flow-um-replace-upstream` +- `cpt-cf-oagw-algo-um-enable-disable-propagation` +- `cpt-cf-oagw-state-um-availability` + +**Touches**: +- API: `POST /oagw/v1/upstreams`, `PUT /oagw/v1/upstreams/{id}` +- DB: `cpt-cf-oagw-db-schema` +- DB Table: `oagw_upstream` +- Entities: `Upstream` + +## 6. Acceptance Criteria + +- [ ] `POST /oagw/v1/upstreams` with a valid hostname-endpoint body and no `alias` returns `201` with a server-generated `id` and the auto-derived `alias`. +- [ ] `POST /oagw/v1/upstreams` with `server.endpoints[0].scheme` set to `"http"` and `port` set to `80` returns `201`, demonstrating scheme acceptance is widened beyond the TLS family. +- [ ] `POST /oagw/v1/upstreams` with `server.endpoints[0].scheme` set to `"ws"` returns `201`. +- [ ] `POST /oagw/v1/upstreams` for a hostname endpoint whose caller-supplied `alias` exactly matches the derivable value returns `201` (idempotent no-op), while a caller-supplied `alias` that differs from the derivable value returns `400` naming the `alias` field. +- [ ] `POST /oagw/v1/upstreams` with an IP-literal `server.endpoints[0].host` and no `alias` returns `400` naming the `alias` field as required; the same request with an explicit `alias` returns `201`. +- [ ] `POST /oagw/v1/upstreams` whose resolved alias already exists for another upstream owned by the same tenant returns `409`. +- [ ] `POST /oagw/v1/upstreams` missing the required `server` or `protocol` field returns `400` with an `application/problem+json` body naming that field. +- [ ] `POST /oagw/v1/upstreams` with `cors.allow_credentials: true` and `cors.allowed_origins: ["*"]` returns `400` naming the `cors` field. +- [ ] `GET /oagw/v1/upstreams` with no query parameters returns `200` with at most 50 results; a `$top` value above 100 is clamped to 100 results. +- [ ] `GET /oagw/v1/upstreams/{id}` for an upstream owned by a different tenant, or for an ancestor tenant's upstream, returns `404` with the same body shape as an unknown `id`. +- [ ] `PUT /oagw/v1/upstreams/{id}` with a body `alias` different from the stored `alias` returns `400` naming the `alias` field; `id` and `alias` are unchanged after any successful replacement. +- [ ] `PUT /oagw/v1/upstreams/{id}` omitting a previously-set optional field (e.g. `tags`) returns `200` with that field cleared to its schema default in the stored representation. +- [ ] `PUT /oagw/v1/upstreams/{id}` for an unknown `id`, or one owned by a different tenant, returns `404`. +- [ ] `DELETE /oagw/v1/upstreams/{id}` returns `204`, and a subsequent `GET` for any route previously registered against that upstream returns `404`. +- [ ] A `PUT /oagw/v1/upstreams/{id}` that sets `enabled: true` while an ancestor-tenant upstream in this resource's bind lineage is disabled returns `400` naming the `enabled` field. +- [ ] `PUT /oagw/v1/upstreams/{id}` with the body's `alias` left equal to the stored value but `server.endpoints[0].host` changed to a different hostname (so the recomputed derived alias differs from the stored alias) returns `400` naming the `alias` and `server` fields. +- [ ] `PUT /oagw/v1/upstreams/{id}` for an upstream whose alias was originally derived from a hostname endpoint, replacing `server.endpoints` with an IP-literal endpoint (and leaving the body's `alias` equal to the stored value), returns `400` naming the `alias` and `server` fields. +- [ ] `POST /oagw/v1/upstreams` whose resolved alias matches a visible (non-`private`-sharing) ancestor-tenant upstream, submitted by a caller lacking `oagw:upstream:bind` permission, returns `403`. +- [ ] `POST /oagw/v1/upstreams` whose resolved alias matches an ancestor-tenant upstream configured with `private` sharing on `auth`, `plugins`, and `rate_limit` returns `201` as an ordinary tenant-scoped create rather than a bind, and does not require bind permission. diff --git a/gears/system/oagw/oagw/Cargo.toml b/gears/system/oagw/oagw/Cargo.toml index a18b934..2dc363c 100644 --- a/gears/system/oagw/oagw/Cargo.toml +++ b/gears/system/oagw/oagw/Cargo.toml @@ -24,8 +24,6 @@ test-utils = [ "toolkit/bootstrap", "dep:async-stream", "dep:tower", - "dep:rustls", - "dep:rustls-pki-types", "dep:rcgen", "tokio/net", "tokio/sync", @@ -44,6 +42,7 @@ inventory = { workspace = true } async-trait = { workspace = true } axum = { workspace = true } http = { workspace = true } +http-body-util = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } uuid = { workspace = true, features = ["v4", "serde"] } @@ -75,7 +74,9 @@ mime = { workspace = true } form_urlencoded = "1" pingora-memory-cache = "0.8" futures-util = { workspace = true, features = ["sink"] } -tokio = { workspace = true, features = ["time"] } +tokio = { workspace = true, features = ["time", "net", "sync", "rt", "io-util", "macros"] } +tokio-rustls = { workspace = true } +rustls-native-certs = { workspace = true } tokio-retry = { workspace = true } hyper = { workspace = true } hyper-util = { workspace = true } @@ -86,10 +87,11 @@ pingora-load-balancing = { version = "0.8", features = ["rustls"] } pingora-http = { version = "0.8.1" } httparse = "1" # test-utils optional deps +rustls = { workspace = true } +rustls-pki-types = { workspace = true } +# test-utils optional deps async-stream = { workspace = true, optional = true } tower = { workspace = true, features = ["util"], optional = true } -rustls = { workspace = true, optional = true } -rustls-pki-types = { workspace = true, optional = true } rcgen = { workspace = true, optional = true } [dev-dependencies] 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..04e6bd7 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/mod.rs @@ -0,0 +1,3 @@ +//! Transport layer for the OAGW gear. + +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..7ab6c9f --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/dto.rs @@ -0,0 +1,253 @@ +//! Wire shapes for the OAGW management API. +//! +//! Write DTOs mirror the frozen JSON Schemas, with the deliberate scheme +//! widening recorded in the FEATURE documents. Read shapes are the domain +//! entities themselves, whose `tenant_id` is not serialized. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::domain::model::{ + AuthConfig, CorsConfig, Headers, PluginBindings, PluginKind, RateLimit, RouteMatch, Server, +}; + +/// Create or replace an upstream. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpstreamWrite { + /// Ignored on write; the server owns identifiers. + #[serde(default)] + pub id: Option, + /// Whether the upstream serves traffic. + #[serde(default = "default_true")] + pub enabled: bool, + /// Routing alias. Derived from the endpoint host when omitted. + #[serde(default)] + pub alias: Option, + /// Free-form tags. + #[serde(default)] + pub tags: Vec, + /// Endpoint pool. + pub server: Server, + /// Application protocol identifier. + pub protocol: String, + /// Authentication configuration. + #[serde(default)] + pub auth: AuthConfig, + /// Header transformation rules. + #[serde(default)] + pub headers: Headers, + /// Guard and transform plugin bindings. + #[serde(default)] + pub plugins: PluginBindings, + /// Rate-limit configuration. + #[serde(default)] + pub rate_limit: Option, + /// CORS configuration. + #[serde(default)] + pub cors: Option, +} + +/// Create a route. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RouteCreate { + /// Ignored on write. + #[serde(default)] + pub id: Option, + /// Whether the route participates in matching. + #[serde(default = "default_true")] + pub enabled: bool, + /// Free-form tags. + #[serde(default)] + pub tags: Vec, + /// Parent upstream. + pub upstream_id: Uuid, + /// Match criteria. + #[serde(rename = "match")] + pub match_: RouteMatch, + /// Guard and transform plugin bindings. + #[serde(default)] + pub plugins: PluginBindings, + /// Rate-limit configuration. + #[serde(default)] + pub rate_limit: Option, +} + +/// Replace a route. `upstream_id` is immutable and therefore absent. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RouteReplace { + /// Ignored on write. + #[serde(default)] + pub id: Option, + /// Whether the route participates in matching. + #[serde(default = "default_true")] + pub enabled: bool, + /// Free-form tags. + #[serde(default)] + pub tags: Vec, + /// Match criteria. + #[serde(rename = "match")] + pub match_: RouteMatch, + /// Guard and transform plugin bindings. + #[serde(default)] + pub plugins: PluginBindings, + /// Rate-limit configuration. + #[serde(default)] + pub rate_limit: Option, +} + +/// Create a custom plugin definition. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginCreate { + /// Ignored on write. + #[serde(default)] + pub id: Option, + /// Name, unique within the tenant. + pub name: String, + /// Optional description. + #[serde(default)] + pub description: String, + /// Which phase the plugin participates in. + pub plugin_type: PluginKind, + /// Optional configuration schema. + #[serde(default)] + pub config_schema: serde_json::Value, + /// Plugin source text. + #[serde(default)] + pub source_code: String, +} + +/// A page of results. +#[derive(Debug, Clone, Serialize)] +pub struct Page { + /// The items on this page. + pub items: Vec, + /// How many items the tenant owns in total. + pub total: usize, +} + +/// The `referenced_by` body of a 409 `PluginInUse`. +#[derive(Debug, Clone, Serialize)] +pub struct ReferencedBy { + /// Identifiers of referencing upstreams. + pub upstreams: Vec, + /// Identifiers of referencing routes. + pub routes: Vec, +} + +/// OData-style paging parameters. +#[derive(Debug, Clone, Deserialize)] +pub struct ListParams { + /// Page size. Defaults to 50, capped at 100. + #[serde(rename = "$top", default)] + pub top: Option, + /// Offset into the collection. + #[serde(rename = "$skip", default)] + pub skip: Option, +} + +/// Default page size. +pub const DEFAULT_TOP: usize = 50; +/// Maximum page size. +pub const MAX_TOP: usize = 100; + +impl ListParams { + /// The effective page size, defaulted and capped. + #[must_use] + pub fn effective_top(&self) -> usize { + self.top.unwrap_or(DEFAULT_TOP).clamp(1, MAX_TOP) + } + + /// The effective offset. + #[must_use] + pub fn effective_skip(&self) -> usize { + self.skip.unwrap_or(0) + } + + /// Apply paging to a collection. + #[must_use] + pub fn paginate(&self, all: &[T]) -> Page { + let items = all + .iter() + .skip(self.effective_skip()) + .take(self.effective_top()) + .cloned() + .collect(); + Page { + items, + total: all.len(), + } + } +} + +const fn default_true() -> bool { + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn top_defaults_to_fifty_and_caps_at_one_hundred() { + let p = ListParams { + top: None, + skip: None, + }; + assert_eq!(p.effective_top(), 50); + assert_eq!(p.effective_skip(), 0); + + let p = ListParams { + top: Some(1000), + skip: Some(3), + }; + assert_eq!(p.effective_top(), 100); + assert_eq!(p.effective_skip(), 3); + } + + #[test] + fn paging_slices_and_reports_the_full_total() { + let all: Vec = (0..10).collect(); + let p = ListParams { + top: Some(3), + skip: Some(8), + }; + let page = p.paginate(&all); + assert_eq!(page.items, vec![8, 9]); + assert_eq!(page.total, 10); + } + + #[test] + fn an_upstream_write_accepts_a_plaintext_endpoint() { + let w: UpstreamWrite = serde_json::from_value(serde_json::json!({ + "server": {"endpoints": [{"scheme": "http", "host": "example.com", "port": 80}]}, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1" + })) + .unwrap(); + assert!(w.enabled); + assert!(w.alias.is_none()); + assert_eq!(w.server.endpoints.len(), 1); + } + + #[test] + fn an_upstream_write_rejects_an_unknown_field() { + let r: Result = serde_json::from_value(serde_json::json!({ + "server": {"endpoints": [{"scheme": "http", "host": "e"}]}, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", + "surprise": 1 + })); + assert!(r.is_err()); + } + + #[test] + fn a_route_replace_has_no_upstream_id_field() { + let r: Result = serde_json::from_value(serde_json::json!({ + "upstream_id": "00000000-0000-0000-0000-000000000000", + "match": {"http": {"methods": ["GET"], "path": "/"}} + })); + assert!(r.is_err(), "upstream_id is immutable and must be rejected"); + } +} 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..1e800a2 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/error.rs @@ -0,0 +1,239 @@ +//! Canonical error mapping for the OAGW gear. +//! +//! Realizes `cpt-cf-oagw-algo-gf-error-mapping` / `cpt-cf-oagw-dod-gf-error-mapping`: +//! every domain error becomes a canonical error, which the shared middleware +//! projects onto an RFC 9457 `application/problem+json` response. + +use toolkit_canonical_errors::transport::Http; +use toolkit_canonical_errors::{CanonicalError, resource_error}; + +use crate::domain::error::DomainError; + +/// Resource-scoped error factory for OAGW. +#[resource_error(gts_id!("cf.oagw.gateway.resource.v1~"))] +pub struct OagwError; + +// @cpt-begin:cpt-cf-oagw-dod-gf-error-mapping:p1:inst-full +impl From for CanonicalError { + fn from(e: DomainError) -> Self { + match e { + // Invalid argument -> 400. + DomainError::Validation { field, message } => OagwError::invalid_argument() + .with_field_violation(field, message, "INVALID_FIELD") + .create(), + + // Not found -> 404. + DomainError::NotFound { resource, id } => { + OagwError::not_found(format!("{resource} `{id}` not found")) + .with_resource(id) + .create() + } + + // Already exists -> 409. + DomainError::AlreadyExists { resource, key } => { + OagwError::already_exists(format!("{resource} `{key}` already exists")) + .with_resource(key) + .create() + } + + // Conflicting state -> 409. + DomainError::Conflict { message } => OagwError::aborted(message) + .with_reason("CONFLICT") + .create(), + + // A referenced plugin cannot be deleted -> 409, naming the + // referencing upstreams and routes. + DomainError::PluginInUse { + id, + upstreams, + routes, + } => OagwError::aborted(format!( + "plugin `{id}` is referenced by {} upstream(s) and {} route(s)", + upstreams.len(), + routes.len() + )) + .with_resource(id) + .with_reason("PLUGIN_IN_USE") + .create(), + + // Permission denied -> 403. + DomainError::PermissionDenied { message } => OagwError::permission_denied() + .with_reason(message.clone()) + .create(), + + // Unauthenticated -> 401. + DomainError::Unauthenticated => CanonicalError::unauthenticated() + .with_reason("NO_SECURITY_CONTEXT") + .create(), + + // Administratively unavailable -> 503. + DomainError::Unavailable { message } => CanonicalError::service_unavailable() + .with_detail(message) + .create(), + + // Upstream could not be reached -> 502 Bad Gateway. + DomainError::UpstreamUnreachable { message } => { + CanonicalError::internal(format!("upstream unreachable: {message}")) + .with_override(Http::status_code(502)) + .create() + } + + // Upstream exchange exceeded the bound -> 504 Gateway Timeout. + DomainError::UpstreamTimeout => OagwError::deadline_exceeded( + "the upstream did not respond within the configured proxy timeout", + ) + .create(), + + // Declared body over the hard cap -> 413. + DomainError::PayloadTooLarge => { + OagwError::out_of_range("request body exceeds the 100 MB limit") + .with_field_violation("body", "body exceeds the 100 MB limit", "BODY_TOO_LARGE") + .with_override(Http::status_code(413)) + .create() + } + + // Rate limited -> 429 with Retry-After. + DomainError::RateLimited { retry_after_secs } => { + OagwError::resource_exhausted("rate limit exceeded") + .with_quota_violation("rate_limit", "the configured rate limit was exceeded") + .with_quota_violation_retry_after_seconds(retry_after_secs) + .create() + } + + // Deliberately not served -> 501. + DomainError::NotImplemented { message } => OagwError::unimplemented(message).create(), + + // Anything else -> 500. + DomainError::Internal { message } => CanonicalError::internal(message).create(), + } + } +} +// @cpt-end:cpt-cf-oagw-dod-gf-error-mapping:p1:inst-full + +#[cfg(test)] +mod tests { + use super::*; + use toolkit_canonical_errors::Problem; + + fn status_of(e: DomainError) -> u16 { + Problem::from(CanonicalError::from(e)).status + } + + #[test] + fn validation_maps_to_400() { + assert_eq!(status_of(DomainError::validation("alias", "bad")), 400); + } + + #[test] + fn unauthenticated_maps_to_401() { + assert_eq!(status_of(DomainError::Unauthenticated), 401); + } + + #[test] + fn permission_denied_maps_to_403() { + assert_eq!( + status_of(DomainError::PermissionDenied { + message: "MISSING_BIND_PERMISSION".to_owned(), + }), + 403 + ); + } + + #[test] + fn not_found_maps_to_404() { + assert_eq!(status_of(DomainError::not_found("upstream", "abc")), 404); + } + + #[test] + fn already_exists_maps_to_409() { + assert_eq!( + status_of(DomainError::AlreadyExists { + resource: "upstream".to_owned(), + key: "example.com".to_owned(), + }), + 409 + ); + } + + #[test] + fn conflict_maps_to_409() { + assert_eq!( + status_of(DomainError::Conflict { + message: "duplicate route match".to_owned(), + }), + 409 + ); + } + + #[test] + fn plugin_in_use_maps_to_409() { + assert_eq!( + status_of(DomainError::PluginInUse { + id: "p1".to_owned(), + upstreams: vec!["u1".to_owned()], + routes: vec![], + }), + 409 + ); + } + + #[test] + fn payload_too_large_maps_to_413() { + assert_eq!(status_of(DomainError::PayloadTooLarge), 413); + } + + #[test] + fn rate_limited_maps_to_429() { + assert_eq!( + status_of(DomainError::RateLimited { + retry_after_secs: 1 + }), + 429 + ); + } + + #[test] + fn not_implemented_maps_to_501() { + assert_eq!( + status_of(DomainError::NotImplemented { + message: "WebTransport is not served".to_owned(), + }), + 501 + ); + } + + #[test] + fn upstream_unreachable_maps_to_502() { + assert_eq!( + status_of(DomainError::UpstreamUnreachable { + message: "connection refused".to_owned(), + }), + 502 + ); + } + + #[test] + fn unavailable_maps_to_503() { + assert_eq!( + status_of(DomainError::Unavailable { + message: "upstream disabled".to_owned(), + }), + 503 + ); + } + + #[test] + fn upstream_timeout_maps_to_504() { + assert_eq!(status_of(DomainError::UpstreamTimeout), 504); + } + + #[test] + fn internal_maps_to_500() { + assert_eq!( + status_of(DomainError::Internal { + message: "boom".to_owned(), + }), + 500 + ); + } +} diff --git a/gears/system/oagw/oagw/src/api/rest/handlers.rs b/gears/system/oagw/oagw/src/api/rest/handlers.rs new file mode 100644 index 0000000..902bfa8 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers.rs @@ -0,0 +1,513 @@ +//! Control-plane handlers: upstream, route and plugin management. +//! +//! Realizes the actor flows of `upstream-management.md`, `route-management.md` +//! and `plugin-management.md`. + +use std::sync::Arc; + +use axum::extract::{Extension, Path, Query}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::Json; +use toolkit::api::canonical_prelude::ApiResult; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::api::rest::dto::{ + ListParams, PluginCreate, ReferencedBy, RouteCreate, RouteReplace, UpstreamWrite, +}; +use crate::domain::error::DomainError; +use crate::domain::model::{PluginDef, PluginKind, Route, Upstream}; +use crate::domain::tenant::Caller; +use crate::domain::{alias, plugins, validate}; +use crate::gear::OagwState; + +type St = Extension>; +type Sec = Option>; + +fn caller(sec: &Sec) -> Caller { + Caller::from_context(sec.as_ref().map(|e| &e.0)) +} + +/// Validate the shared parts of an upstream write. +fn validate_upstream_write( + state: &OagwState, + tenant: Uuid, + w: &UpstreamWrite, +) -> Result<(), DomainError> { + validate::validate_tags(&w.tags)?; + validate::validate_server(&w.server)?; + validate::validate_protocol(&w.protocol)?; + if let Some(rl) = &w.rate_limit { + validate::validate_rate_limit("rate_limit", rl)?; + } + if let Some(c) = &w.cors { + validate::validate_cors("cors", c)?; + } + let exists = |id: Uuid| state.store.get_plugin(tenant, id).map(|p| p.plugin_type); + for (i, item) in w.plugins.items.iter().enumerate() { + plugins::resolve_binding("plugins.items", i, item, exists)?; + } + if let Some(t) = w.auth.plugin_type.as_deref() { + plugins::resolve_auth_plugin(t, exists)?; + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Upstreams +// --------------------------------------------------------------------------- + +/// `POST /oagw/v1/upstreams` +// @cpt-begin:cpt-cf-oagw-dod-um-create:p1:inst-full +pub(crate) async fn create_upstream( + Extension(state): St, + sec: Sec, + Json(w): Json, +) -> ApiResult { + let c = caller(&sec); + validate_upstream_write(&state, c.tenant_id, &w)?; + let resolved = alias::resolve(&w.server.endpoints, w.alias.as_deref())?; + + let up = Upstream { + id: Uuid::new_v4(), + tenant_id: c.tenant_id, + enabled: w.enabled, + alias: resolved, + tags: w.tags, + server: w.server, + protocol: w.protocol, + auth: w.auth, + headers: w.headers, + plugins: w.plugins, + rate_limit: w.rate_limit, + cors: w.cors, + }; + let stored = state.store.insert_upstream(up)?; + Ok((StatusCode::CREATED, Json(stored))) +} +// @cpt-end:cpt-cf-oagw-dod-um-create:p1:inst-full + +/// `GET /oagw/v1/upstreams` +pub(crate) async fn list_upstreams( + Extension(state): St, + sec: Sec, + Query(params): Query, +) -> ApiResult { + let c = caller(&sec); + let all = state.store.list_upstreams(c.tenant_id); + Ok((StatusCode::OK, Json(params.paginate(&all)))) +} + +/// `GET /oagw/v1/upstreams/{id}` +pub(crate) async fn get_upstream( + Extension(state): St, + sec: Sec, + Path(id): Path, +) -> ApiResult { + let c = caller(&sec); + let up = state + .store + .get_upstream(c.tenant_id, id) + .ok_or_else(|| DomainError::not_found("upstream", id.to_string()))?; + Ok((StatusCode::OK, Json(up))) +} + +/// `PUT /oagw/v1/upstreams/{id}` +/// +/// A full replacement. The alias is immutable: an endpoint change that would +/// alter the derived alias is rejected, independent of what the body's `alias` +/// field literally says. +// @cpt-begin:cpt-cf-oagw-dod-um-replace:p1:inst-full +pub(crate) async fn replace_upstream( + Extension(state): St, + sec: Sec, + Path(id): Path, + Json(w): Json, +) -> ApiResult { + let c = caller(&sec); + let existing = state + .store + .get_upstream(c.tenant_id, id) + .ok_or_else(|| DomainError::not_found("upstream", id.to_string()))?; + validate_upstream_write(&state, c.tenant_id, &w)?; + + // Recompute the alias from the replacement's endpoints rather than + // trusting the body's `alias` field. + let recomputed = alias::resolve(&w.server.endpoints, w.alias.as_deref())?; + if recomputed != existing.alias { + return Err(DomainError::validation( + "alias", + format!( + "alias is immutable; this replacement would change it from `{}` to `{recomputed}` \ + — delete and re-create instead", + existing.alias + ), + ) + .into()); + } + + let up = Upstream { + id: existing.id, + tenant_id: c.tenant_id, + enabled: w.enabled, + alias: existing.alias, + tags: w.tags, + server: w.server, + protocol: w.protocol, + auth: w.auth, + headers: w.headers, + plugins: w.plugins, + rate_limit: w.rate_limit, + cors: w.cors, + }; + Ok((StatusCode::OK, Json(state.store.replace_upstream(up)?))) +} +// @cpt-end:cpt-cf-oagw-dod-um-replace:p1:inst-full + +/// `DELETE /oagw/v1/upstreams/{id}` +pub(crate) async fn delete_upstream( + Extension(state): St, + sec: Sec, + Path(id): Path, +) -> ApiResult { + let c = caller(&sec); + state.store.delete_upstream(c.tenant_id, id)?; + Ok(StatusCode::NO_CONTENT) +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +/// Reject a route whose match collides with an existing enabled sibling. +fn check_match_determinism( + state: &OagwState, + upstream_id: Uuid, + candidate: &Route, +) -> Result<(), DomainError> { + let Some(ch) = candidate.match_.http.as_ref() else { + return Ok(()); + }; + if !candidate.enabled { + return Ok(()); + } + for existing in state.store.routes_for_upstream(upstream_id) { + if existing.id == candidate.id || !existing.enabled { + continue; + } + let Some(eh) = existing.match_.http.as_ref() else { + continue; + }; + if eh.path != ch.path { + continue; + } + let overlap = ch + .methods + .iter() + .any(|m| eh.methods.iter().any(|e| e.eq_ignore_ascii_case(m))); + if overlap { + return Err(DomainError::Conflict { + message: format!( + "another enabled route under this upstream already matches `{}` for one of {:?}", + ch.path, ch.methods + ), + }); + } + } + Ok(()) +} + +fn validate_route_policy( + state: &OagwState, + tenant: Uuid, + tags: &[String], + bindings: &crate::domain::model::PluginBindings, + rate_limit: Option<&crate::domain::model::RateLimit>, +) -> Result<(), DomainError> { + validate::validate_tags(tags)?; + if let Some(rl) = rate_limit { + validate::validate_rate_limit("rate_limit", rl)?; + } + let exists = |id: Uuid| state.store.get_plugin(tenant, id).map(|p| p.plugin_type); + for (i, item) in bindings.items.iter().enumerate() { + plugins::resolve_binding("plugins.items", i, item, exists)?; + } + Ok(()) +} + +/// `POST /oagw/v1/routes` +// @cpt-begin:cpt-cf-oagw-dod-rm-match-determinism:p1:inst-full +pub(crate) async fn create_route( + Extension(state): St, + sec: Sec, + Json(w): Json, +) -> ApiResult { + let c = caller(&sec); + // An unresolvable parent is a validation error, not a 404. + if state.store.get_upstream(c.tenant_id, w.upstream_id).is_none() { + return Err(DomainError::validation( + "upstream_id", + format!("`{}` does not resolve to a visible upstream", w.upstream_id), + ) + .into()); + } + validate::validate_match(&w.match_)?; + validate_route_policy(&state, c.tenant_id, &w.tags, &w.plugins, w.rate_limit.as_ref())?; + + let route = Route { + id: Uuid::new_v4(), + tenant_id: c.tenant_id, + enabled: w.enabled, + tags: w.tags, + upstream_id: w.upstream_id, + match_: w.match_, + plugins: w.plugins, + rate_limit: w.rate_limit, + }; + check_match_determinism(&state, w.upstream_id, &route)?; + Ok((StatusCode::CREATED, Json(state.store.insert_route(route)))) +} +// @cpt-end:cpt-cf-oagw-dod-rm-match-determinism:p1:inst-full + +/// `GET /oagw/v1/routes` +pub(crate) async fn list_routes( + Extension(state): St, + sec: Sec, + Query(params): Query, +) -> ApiResult { + let c = caller(&sec); + let all = state.store.list_routes(c.tenant_id); + Ok((StatusCode::OK, Json(params.paginate(&all)))) +} + +/// `GET /oagw/v1/routes/{id}` +pub(crate) async fn get_route( + Extension(state): St, + sec: Sec, + Path(id): Path, +) -> ApiResult { + let c = caller(&sec); + let r = state + .store + .get_route(c.tenant_id, id) + .ok_or_else(|| DomainError::not_found("route", id.to_string()))?; + Ok((StatusCode::OK, Json(r))) +} + +/// `PUT /oagw/v1/routes/{id}` +pub(crate) async fn replace_route( + Extension(state): St, + sec: Sec, + Path(id): Path, + Json(w): Json, +) -> ApiResult { + let c = caller(&sec); + let existing = state + .store + .get_route(c.tenant_id, id) + .ok_or_else(|| DomainError::not_found("route", id.to_string()))?; + validate::validate_match(&w.match_)?; + validate_route_policy(&state, c.tenant_id, &w.tags, &w.plugins, w.rate_limit.as_ref())?; + + let route = Route { + id: existing.id, + tenant_id: c.tenant_id, + enabled: w.enabled, + tags: w.tags, + upstream_id: existing.upstream_id, + match_: w.match_, + plugins: w.plugins, + rate_limit: w.rate_limit, + }; + check_match_determinism(&state, existing.upstream_id, &route)?; + Ok((StatusCode::OK, Json(state.store.replace_route(route)?))) +} + +/// `DELETE /oagw/v1/routes/{id}` +pub(crate) async fn delete_route( + Extension(state): St, + sec: Sec, + Path(id): Path, +) -> ApiResult { + let c = caller(&sec); + state.store.delete_route(c.tenant_id, id)?; + Ok(StatusCode::NO_CONTENT) +} + +// --------------------------------------------------------------------------- +// Plugin definitions +// --------------------------------------------------------------------------- + +/// `POST /oagw/v1/plugins` +// @cpt-begin:cpt-cf-oagw-dod-pm-custom-crud:p1:inst-full +pub(crate) async fn create_plugin( + Extension(state): St, + sec: Sec, + Json(w): Json, +) -> ApiResult { + let c = caller(&sec); + if w.name.trim().is_empty() { + return Err(DomainError::validation("name", "name must not be empty").into()); + } + if w.source_code.trim().is_empty() { + return Err( + DomainError::validation("source_code", "source text must not be empty").into(), + ); + } + if !(w.config_schema.is_null() || w.config_schema.is_object()) { + return Err(DomainError::validation( + "config_schema", + "config_schema must be an object when present", + ) + .into()); + } + let p = PluginDef { + id: Uuid::new_v4(), + tenant_id: c.tenant_id, + name: w.name, + description: w.description, + plugin_type: w.plugin_type, + config_schema: w.config_schema, + source_code: w.source_code, + }; + Ok((StatusCode::CREATED, Json(state.store.insert_plugin(p)?))) +} +// @cpt-end:cpt-cf-oagw-dod-pm-custom-crud:p1:inst-full + +/// `GET /oagw/v1/plugins` +/// +/// One merged listing: the built-in catalog (marking which entries are served) +/// followed by the calling tenant's own custom definitions. +pub(crate) async fn list_plugins( + Extension(state): St, + sec: Sec, + Query(params): Query, +) -> ApiResult { + let c = caller(&sec); + let mut all: Vec = + plugins::CATALOG.iter().map(catalog_json).collect(); + all.extend( + state + .store + .list_plugins(c.tenant_id) + .into_iter() + .map(|p| custom_json(&p)), + ); + Ok((StatusCode::OK, Json(params.paginate(&all)))) +} + +/// The listing shape of a built-in catalog entry. +fn catalog_json(e: &plugins::CatalogEntry) -> serde_json::Value { + serde_json::json!({ + "id": e.id, + "plugin_type": kind_name(e.kind), + "origin": "builtin", + "served": e.served, + }) +} + +/// The listing shape of a tenant-owned custom definition. +fn custom_json(p: &PluginDef) -> serde_json::Value { + serde_json::json!({ + "id": p.id, + "gts_id": p.gts_id(), + "name": p.name, + "description": p.description, + "plugin_type": kind_name(p.plugin_type), + "origin": "custom", + "served": false, + }) +} + +const fn kind_name(k: PluginKind) -> &'static str { + match k { + PluginKind::Auth => "auth", + PluginKind::Guard => "guard", + PluginKind::Transform => "transform", + } +} + +/// `GET /oagw/v1/plugins/{id}` +/// +/// The identifier may be a custom definition's UUID (bare or wrapped in its +/// GTS form) or a built-in / catalog-only identifier. +pub(crate) async fn get_plugin( + Extension(state): St, + sec: Sec, + Path(id): Path, +) -> ApiResult { + let c = caller(&sec); + if let Some(entry) = plugins::catalog_entry(&id) { + return Ok((StatusCode::OK, Json(catalog_json(entry)))); + } + let uuid = plugins::instance_uuid(&id) + .ok_or_else(|| DomainError::not_found("plugin", id.clone()))?; + let p = state + .store + .get_plugin(c.tenant_id, uuid) + .ok_or_else(|| DomainError::not_found("plugin", id.clone()))?; + Ok((StatusCode::OK, Json(serde_json::to_value(p).unwrap_or_default()))) +} + +/// `GET /oagw/v1/plugins/{id}/source` +pub(crate) async fn get_plugin_source( + Extension(state): St, + sec: Sec, + Path(id): Path, +) -> ApiResult { + let c = caller(&sec); + // A built-in carries no source text, so it is not found here. + let uuid = plugins::instance_uuid(&id) + .ok_or_else(|| DomainError::not_found("plugin source", id.clone()))?; + let p = state + .store + .get_plugin(c.tenant_id, uuid) + .ok_or_else(|| DomainError::not_found("plugin source", id.clone()))?; + Ok((StatusCode::OK, p.source_code)) +} + +/// `DELETE /oagw/v1/plugins/{id}` +/// +/// A definition still bound to an upstream or route is a 409 naming what +/// references it. +// @cpt-begin:cpt-cf-oagw-dod-pm-in-use-delete:p1:inst-full +pub(crate) async fn delete_plugin( + Extension(state): St, + sec: Sec, + Path(id): Path, +) -> ApiResult { + let c = caller(&sec); + // A built-in is not a deletable resource. + let id = plugins::instance_uuid(&id) + .ok_or_else(|| DomainError::not_found("plugin", id.clone()))?; + if state.store.get_plugin(c.tenant_id, id).is_none() { + return Err(DomainError::not_found("plugin", id.to_string()).into()); + } + let (upstreams, routes) = state.store.plugin_references(c.tenant_id, id); + if !upstreams.is_empty() || !routes.is_empty() { + return Err(DomainError::PluginInUse { + id: id.to_string(), + upstreams, + routes, + } + .into()); + } + state.store.delete_plugin(c.tenant_id, id)?; + Ok(StatusCode::NO_CONTENT) +} +// @cpt-end:cpt-cf-oagw-dod-pm-in-use-delete:p1:inst-full + +/// The `referenced_by` payload for a plugin, exposed for tests and callers that +/// want to inspect bindings before deleting. +#[must_use] +pub fn referenced_by(state: &OagwState, tenant: Uuid, plugin: Uuid) -> ReferencedBy { + let (upstreams, routes) = state.store.plugin_references(tenant, plugin); + ReferencedBy { upstreams, routes } +} + +/// The built-in plugin catalog, as served. +pub(crate) async fn list_catalog() -> ApiResult { + let items: Vec = plugins::CATALOG.iter().map(catalog_json).collect(); + Ok((StatusCode::OK, Json(serde_json::json!({ "items": items })))) +} 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..05a113f --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/mod.rs @@ -0,0 +1,13 @@ +//! REST surface: the management API (control plane) and the proxy API (data plane). + +pub mod dto; +pub mod error; +pub mod handlers; +pub mod proxy; +pub mod routes; + +#[cfg(test)] +mod routes_tests; + +pub use error::OagwError; +pub use routes::register_routes; diff --git a/gears/system/oagw/oagw/src/api/rest/proxy.rs b/gears/system/oagw/oagw/src/api/rest/proxy.rs new file mode 100644 index 0000000..9db9a85 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/proxy.rs @@ -0,0 +1,585 @@ +//! The data plane: `{METHOD} /oagw/v1/proxy/{alias}` and `/{alias}/{*path}`. +//! +//! Realizes `cpt-cf-oagw-flow-ph-proxy-request` and, on the same surface, +//! `cpt-cf-oagw-flow-ps-sse-relay` and `cpt-cf-oagw-flow-ps-ws-upgrade`. +//! +//! Plain exchanges, server-sent-event streams and WebSocket upgrades all take +//! the same path. Nothing here buffers a body, so an event stream reaches the +//! client incrementally; a `101` hands both sides' upgraded transports to a +//! byte relay, so frames, subprotocols and close codes pass through untouched. + +use std::sync::Arc; + +use axum::extract::{Extension, OriginalUri, Request}; +use axum::response::{IntoResponse, Response}; +use hyper::header::{HeaderName, HeaderValue}; +use hyper::{HeaderMap, StatusCode}; +use toolkit::api::canonical_prelude::CanonicalError; +use toolkit_security::SecurityContext; + +use crate::domain::error::{DomainError, ERROR_SOURCE_HEADER, ErrorSource, TARGET_HOST_HEADER}; +use crate::domain::model::{Scheme, Upstream}; +use crate::domain::tenant::Caller; +use crate::domain::{cors, plugins, ratelimit, routing, validate}; +use crate::gear::OagwState; +use crate::infra::body::{self, ProxyBody}; +use crate::infra::connect; +use crate::infra::headers as hdr; + +/// The path prefix the proxy is mounted at. +pub const PROXY_PREFIX: &str = "/oagw/v1/proxy"; + +fn header_str<'a>(h: &'a HeaderMap, name: &str) -> Option<&'a str> { + h.get(name).and_then(|v| v.to_str().ok()) +} + +/// Stamp the gateway error-source header onto a response. +fn mark_source(resp: &mut Response, source: ErrorSource) { + if let Ok(v) = HeaderValue::from_str(source.as_str()) { + resp.headers_mut().insert( + HeaderName::from_static("x-oagw-error-source"), + v, + ); + } +} + +/// A gateway-produced error response, always marked `gateway`. +/// +/// A rate-limit rejection additionally carries a wire `Retry-After` header: the +/// canonical error builder only promotes a retry hint to that header for the +/// service-unavailable category, so a 429 needs it set here. +fn gateway_error(e: DomainError) -> Response { + let retry_after = match &e { + DomainError::RateLimited { retry_after_secs } => Some(*retry_after_secs), + _ => None, + }; + let mut resp = CanonicalError::from(e).into_response(); + mark_source(&mut resp, ErrorSource::Gateway); + if let Some(secs) = retry_after + && let Ok(v) = HeaderValue::from_str(&secs.to_string()) + { + resp.headers_mut() + .insert(HeaderName::from_static("retry-after"), v); + } + resp +} + +/// The proxy entry point. +/// +/// Returns a `Response` directly rather than `ApiResult` so that every exit — +/// including the error exits — carries `X-OAGW-Error-Source`. +pub(crate) async fn proxy( + Extension(state): Extension>, + sec: Option>, + OriginalUri(uri): OriginalUri, + req: Request, +) -> Response { + match proxy_inner(state, sec, uri, req).await { + Ok(r) => r, + Err(e) => gateway_error(e), + } +} + +#[allow(clippy::too_many_lines, reason = "one linear request path, kept together for readability")] +async fn proxy_inner( + state: Arc, + sec: Option>, + uri: axum::http::Uri, + req: Request, +) -> Result { + let caller = Caller::from_context(sec.as_ref().map(|e| &e.0)); + let method = req.method().clone(); + let inbound_headers = req.headers().clone(); + + // The alias and suffix come from the original URI so a gateway prefix, if + // one is configured, does not leak into the match. + let path = uri.path(); + let rest = path + .find(PROXY_PREFIX) + .map_or(path, |i| &path[i + PROXY_PREFIX.len()..]); + let (alias, suffix) = routing::split_alias_and_suffix(rest); + + // The client address for an `ip`-scoped rate limit: the left-most entry of + // a forwarded-for header when the edge supplies one, else the peer address + // the host runtime attached. + let client_ip = client_address(&inbound_headers, &req); + + let origin = header_str(&inbound_headers, "origin").map(str::to_owned); + let acrm = header_str(&inbound_headers, "access-control-request-method").map(str::to_owned); + + // A CORS preflight is answered before upstream resolution, without auth or + // plugin evaluation. + // @cpt-begin:cpt-cf-oagw-dod-tp-cors-preflight:p1:inst-full + if cors::is_preflight(method.as_str(), origin.as_deref(), acrm.as_deref()) { + let (Some(o), Some(m)) = (origin.as_deref(), acrm.as_deref()) else { + return Err(DomainError::Internal { + message: "preflight detected without its own headers".to_owned(), + }); + }; + let acrh = header_str(&inbound_headers, "access-control-request-headers"); + let mut resp = StatusCode::NO_CONTENT.into_response(); + for (k, v) in cors::preflight_headers(&cors::Preflight { + origin: o, + method: m, + headers: acrh, + }) { + if let (Ok(name), Ok(value)) = (HeaderName::from_bytes(k.as_bytes()), HeaderValue::from_str(&v)) + { + resp.headers_mut().insert(name, value); + } + } + mark_source(&mut resp, ErrorSource::Gateway); + return Ok(resp); + } + // @cpt-end:cpt-cf-oagw-dod-tp-cors-preflight:p1:inst-full + + if alias.is_empty() { + return Err(DomainError::validation("alias", "a proxy alias is required")); + } + + // @cpt-begin:cpt-cf-oagw-dod-ph-alias-resolution:p1:inst-full + let up = state + .store + .find_upstream_by_alias(caller.tenant_id, &alias) + .ok_or_else(|| DomainError::not_found("upstream", alias.clone()))?; + if !up.enabled { + return Err(DomainError::Unavailable { + message: format!("upstream `{alias}` is disabled"), + }); + } + // @cpt-end:cpt-cf-oagw-dod-ph-alias-resolution:p1:inst-full + + // The chain order is: resolve the upstream and the route, charge the rate + // limit, then evaluate CORS, then the guard chain. Resolution comes first + // so a request that matches no route is reported as such rather than being + // pre-empted by a CORS verdict, and the bucket is charged for every request + // that got as far as a matched route. + let matched = routing::match_route( + &state.store.routes_for_upstream(up.id), + method.as_str(), + &suffix, + )?; + + // Policy is evaluated once, before the exchange is established, and the + // bucket is charged once for the establishing request. + let rl_decision = charge_rate_limit(&state, &up, &matched, &caller, client_ip.as_deref())?; + + // An actual cross-origin request is screened once the upstream, the route + // and the rate limit have been settled. + let mut cors_response_headers: Vec<(&'static str, String)> = Vec::new(); + if let (Some(cfg), Some(o)) = (up.cors.as_ref(), origin.as_deref()) + && cfg.enabled + { + match cors::evaluate_actual(cfg, o, method.as_str()) { + Ok(h) => cors_response_headers = h, + Err(rej) => { + let mut resp = ( + StatusCode::FORBIDDEN, + axum::Json(serde_json::json!({ + "type": rej.problem_type(), + "title": "Forbidden", + "status": 403, + "detail": rej.detail(), + })), + ) + .into_response(); + mark_source(&mut resp, ErrorSource::Gateway); + apply_rate_limit_headers(&mut resp, rl_decision.as_ref()); + return Ok(resp); + } + } + } + + // Guards run against the request before anything is forwarded. + let required_request = required_headers(&up, &matched, "required_request_headers"); + if let Some(missing) = plugins::first_missing_header(&required_request, &header_names(&inbound_headers)) + { + return Err(DomainError::validation( + missing.clone(), + "REQUIRED_HEADER_MISSING", + )); + } + + let target_host = header_str(&inbound_headers, TARGET_HOST_HEADER).map(str::to_owned); + let endpoint = routing::select_endpoint(&up, target_host.as_deref(), &state.round_robin)? + .clone(); + + // Scheme enforcement governs the connection only, never create-time + // acceptance of the scheme value. + // @cpt-begin:cpt-cf-oagw-dod-ph-scheme-enforcement:p1:inst-full + if !validate::connection_permitted(endpoint.scheme, state.config.allow_http_upstream) { + return Err(DomainError::UpstreamUnreachable { + message: format!( + "a plaintext `{}` connection is not permitted; \ + set allow_http_upstream to enable it", + endpoint.scheme.url_scheme() + ), + }); + } + // @cpt-end:cpt-cf-oagw-dod-ph-scheme-enforcement:p1:inst-full + + // WebTransport is accepted as a scheme value but is deliberately not served. + if endpoint.scheme == Scheme::Wt { + return Err(DomainError::NotImplemented { + message: "WebTransport upstreams are not served in this configuration".to_owned(), + }); + } + + if body::declared_length_exceeds_cap(&inbound_headers) { + return Err(DomainError::PayloadTooLarge); + } + + let is_upgrade = is_websocket_upgrade(&inbound_headers); + let authority = endpoint.authority(); + let http_match = matched.route.match_.http.as_ref(); + let query = routing::filter_query( + http_match.map_or(&[][..], |h| h.query_allowlist.as_slice()), + uri.query(), + ); + let target = match query { + Some(q) => format!("{}?{q}", matched.target_path), + None => matched.target_path.clone(), + }; + + let out_headers = hdr::build_request_headers( + &inbound_headers, + &up.headers.request, + &authority, + is_upgrade, + ); + + if is_upgrade { + return upgrade_exchange(state, req, endpoint.scheme, &endpoint.host, endpoint.effective_port(), &target, out_headers).await; + } + + // ---- ordinary exchange, including event streams -------------------- + let mut conn = tokio::time::timeout( + state.config.proxy_timeout(), + connect::Upstream::connect(endpoint.scheme, &endpoint.host, endpoint.effective_port()), + ) + .await + .map_err(|_| DomainError::UpstreamTimeout)??; + + let mut builder = hyper::Request::builder().method(method.clone()).uri(&target); + if let Some(h) = builder.headers_mut() { + *h = out_headers; + } + let outbound = builder + .body(ProxyBody::from_axum(req.into_body())) + .map_err(|e| DomainError::Internal { + message: e.to_string(), + })?; + + // The timeout bounds establishing the exchange and receiving the response + // headers. It deliberately does NOT bound the lifetime of the body that + // follows, so an event stream is not cut off at proxy_timeout_secs. + // @cpt-begin:cpt-cf-oagw-dod-ps-timeout-scope:p1:inst-full + let upstream_resp = tokio::time::timeout(state.config.proxy_timeout(), conn.send(outbound)) + .await + .map_err(|_| DomainError::UpstreamTimeout)??; + // @cpt-end:cpt-cf-oagw-dod-ps-timeout-scope:p1:inst-full + + // A response that declares an over-cap length is refused before its status + // line is relayed; an undeclared one can only be cut off mid-stream, which + // the counting body does. + if body::declared_length_exceeds_cap(upstream_resp.headers()) { + return Err(DomainError::PayloadTooLarge); + } + + let status = upstream_resp.status(); + let relay_headers = + hdr::build_response_headers(upstream_resp.headers(), &up.headers.response, false); + + // Guards run against the upstream's response headers; a missing required + // response header is a 502, distinct from the request phase's 400. + let required_response = required_headers(&up, &matched, "required_response_headers"); + if let Some(missing) = plugins::first_missing_header(&required_response, &header_names(&relay_headers)) + { + return Err(DomainError::UpstreamUnreachable { + message: format!("REQUIRED_HEADER_MISSING: {missing}"), + }); + } + + let mut resp = Response::builder().status(status); + if let Some(h) = resp.headers_mut() { + *h = relay_headers; + } + let mut resp = resp + .body(ProxyBody::from_incoming(upstream_resp.into_body()).into_axum()) + .map_err(|e| DomainError::Internal { + message: e.to_string(), + })?; + + // The upstream's own status is relayed unchanged, and it is the upstream + // that is named as the source even when that status is a 4xx or 5xx. + mark_source(&mut resp, ErrorSource::Upstream); + apply_extra_headers(&mut resp, &cors_response_headers); + apply_rate_limit_headers(&mut resp, rl_decision.as_ref()); + Ok(resp) +} + +/// Relay a WebSocket upgrade. +/// +/// After both sides answer `101`, the two upgraded transports are copied into +/// each other. The gateway never parses a frame, so subprotocol negotiation, +/// ping/pong and close codes propagate untouched, and either side closing tears +/// the other down. +// @cpt-begin:cpt-cf-oagw-dod-ps-ws-bidirectional-frame-relay:p1:inst-full +async fn upgrade_exchange( + state: Arc, + req: Request, + scheme: Scheme, + host: &str, + port: u16, + target: &str, + out_headers: HeaderMap, +) -> Result { + let mut conn = tokio::time::timeout( + state.config.proxy_timeout(), + connect::Upstream::connect(scheme, host, port), + ) + .await + .map_err(|_| DomainError::UpstreamTimeout)??; + + let (parts, incoming_body) = req.into_parts(); + let client_on_upgrade = parts.extensions.get::().cloned(); + + let mut builder = hyper::Request::builder() + .method(parts.method.clone()) + .uri(target); + if let Some(h) = builder.headers_mut() { + *h = out_headers; + } + let outbound = builder + .body(ProxyBody::from_axum(incoming_body)) + .map_err(|e| DomainError::Internal { + message: e.to_string(), + })?; + + let upstream_resp = tokio::time::timeout(state.config.proxy_timeout(), conn.send(outbound)) + .await + .map_err(|_| DomainError::UpstreamTimeout)??; + + let status = upstream_resp.status(); + let relay_headers = hdr::build_response_headers(upstream_resp.headers(), &Default::default(), true); + + if status != StatusCode::SWITCHING_PROTOCOLS { + // The upstream refused the upgrade; its own status is relayed rather + // than a synthetic one. + let mut resp = Response::builder().status(status); + if let Some(h) = resp.headers_mut() { + *h = relay_headers; + } + let mut resp = resp + .body(ProxyBody::from_incoming(upstream_resp.into_body()).into_axum()) + .map_err(|e| DomainError::Internal { + message: e.to_string(), + })?; + mark_source(&mut resp, ErrorSource::Upstream); + return Ok(resp); + } + + // The upstream has switched protocols. Only answer 101 if the client side + // can actually be upgraded too — otherwise the caller would be told the + // protocol switched while no relay ever starts. + let Some(client_on_upgrade) = client_on_upgrade else { + drop(hyper::upgrade::on(upstream_resp)); + return Err(DomainError::UpstreamUnreachable { + message: "the client connection cannot be upgraded, so the accepted \ + upstream upgrade cannot be relayed" + .to_owned(), + }); + }; + + let upstream_on_upgrade = hyper::upgrade::on(upstream_resp); + { + tokio::spawn(async move { + let (client, upstream) = + match tokio::try_join!(client_on_upgrade, upstream_on_upgrade) { + Ok(pair) => pair, + Err(e) => { + tracing::debug!(error = %e, "oagw websocket upgrade failed"); + return; + } + }; + let mut client = hyper_util::rt::TokioIo::new(client); + let mut upstream = hyper_util::rt::TokioIo::new(upstream); + if let Err(e) = tokio::io::copy_bidirectional(&mut client, &mut upstream).await { + tracing::debug!(error = %e, "oagw websocket relay ended"); + } + }); + } + + let mut resp = Response::builder().status(StatusCode::SWITCHING_PROTOCOLS); + if let Some(h) = resp.headers_mut() { + *h = relay_headers; + } + let mut resp = resp + .body(axum::body::Body::empty()) + .map_err(|e| DomainError::Internal { + message: e.to_string(), + })?; + mark_source(&mut resp, ErrorSource::Upstream); + Ok(resp) +} +// @cpt-end:cpt-cf-oagw-dod-ps-ws-bidirectional-frame-relay:p1:inst-full + +/// Whether the inbound request asks for a WebSocket upgrade. +#[must_use] +pub fn is_websocket_upgrade(h: &HeaderMap) -> bool { + let upgrade_is_ws = header_str(h, "upgrade").is_some_and(|v| v.eq_ignore_ascii_case("websocket")); + let connection_has_upgrade = header_str(h, "connection").is_some_and(|v| { + v.split(',') + .any(|t| t.trim().eq_ignore_ascii_case("upgrade")) + }); + upgrade_is_ws && connection_has_upgrade +} + +/// The caller's address, for an `ip`-scoped rate limit. +fn client_address(h: &HeaderMap, req: &Request) -> Option { + if let Some(first) = header_str(h, "x-forwarded-for") + .and_then(|fwd| fwd.split(',').next()) + .map(str::trim) + .filter(|s| !s.is_empty()) + { + return Some(first.to_owned()); + } + if let Some(real) = header_str(h, "x-real-ip") + .map(str::trim) + .filter(|s| !s.is_empty()) + { + return Some(real.to_owned()); + } + req.extensions() + .get::>() + .map(|ci| ci.0.ip().to_string()) +} + +fn header_names(h: &HeaderMap) -> Vec { + h.keys().map(|k| k.as_str().to_ascii_lowercase()).collect() +} + +/// The required-header guard's configured list, when that guard is bound. +fn required_headers(up: &Upstream, matched: &routing::Matched, key: &str) -> Vec { + const GUARD_ID: &str = "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1"; + let bound = up.plugins.items.iter().any(|i| i == GUARD_ID) + || matched.route.plugins.items.iter().any(|i| i == GUARD_ID); + if !bound { + return Vec::new(); + } + let raw = up + .auth + .config + .get(key) + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + plugins::parse_required_headers(raw.as_deref()) +} + +/// Charge the effective rate limit once for this request. +fn charge_rate_limit( + state: &OagwState, + up: &Upstream, + matched: &routing::Matched, + caller: &Caller, + client_ip: Option<&str>, +) -> Result, DomainError> { + // A route limit takes precedence over the upstream's; when neither the + // route nor the upstream configures one, no rate limiting applies. + let Some(rl) = matched.route.rate_limit.as_ref().or(up.rate_limit.as_ref()) else { + return Ok(None); + }; + let key = ratelimit::scope_key( + rl.scope, + &caller.tenant_id.to_string(), + caller.subject_id.map(|s| s.to_string()).as_deref(), + client_ip, + Some(&matched.route.id.to_string()), + &up.id.to_string(), + ); + let d = state.limiter.check(&key, rl); + if d.allowed { + Ok(Some(d)) + } else { + Err(DomainError::RateLimited { + retry_after_secs: d.retry_after_secs, + }) + } +} + +fn apply_extra_headers(resp: &mut Response, extra: &[(&'static str, String)]) { + for (k, v) in extra { + if let (Ok(name), Ok(value)) = (HeaderName::from_bytes(k.as_bytes()), HeaderValue::from_str(v)) + { + resp.headers_mut().insert(name, value); + } + } +} + +/// Attach `X-RateLimit-*` to an ordinary response. A `101` carries none, since +/// the exchange leaves HTTP semantics behind at that point. +fn apply_rate_limit_headers(resp: &mut Response, d: Option<&ratelimit::Decision>) { + let Some(d) = d else { return }; + if resp.status() == StatusCode::SWITCHING_PROTOCOLS { + return; + } + let set = |resp: &mut Response, name: &'static str, value: String| { + if let (Ok(n), Ok(v)) = (HeaderName::from_bytes(name.as_bytes()), HeaderValue::from_str(&value)) + { + resp.headers_mut().insert(n, v); + } + }; + set(resp, "x-ratelimit-limit", d.limit.to_string()); + set(resp, "x-ratelimit-remaining", d.remaining.to_string()); + set(resp, "x-ratelimit-reset", d.reset_secs.to_string()); +} + +/// Re-exported for tests: the error-source header name. +pub const SOURCE_HEADER: &str = ERROR_SOURCE_HEADER; + +#[cfg(test)] +mod tests { + use super::*; + + fn headers(pairs: &[(&str, &str)]) -> HeaderMap { + let mut h = HeaderMap::new(); + for (k, v) in pairs { + h.append( + HeaderName::from_bytes(k.as_bytes()).unwrap(), + HeaderValue::from_str(v).unwrap(), + ); + } + h + } + + #[test] + fn a_websocket_upgrade_needs_both_headers() { + assert!(is_websocket_upgrade(&headers(&[ + ("upgrade", "websocket"), + ("connection", "Upgrade") + ]))); + // Browsers commonly send a token list. + assert!(is_websocket_upgrade(&headers(&[ + ("upgrade", "websocket"), + ("connection", "keep-alive, Upgrade") + ]))); + assert!(!is_websocket_upgrade(&headers(&[("upgrade", "websocket")]))); + assert!(!is_websocket_upgrade(&headers(&[("connection", "Upgrade")]))); + assert!(!is_websocket_upgrade(&headers(&[ + ("upgrade", "h2c"), + ("connection", "Upgrade") + ]))); + assert!(!is_websocket_upgrade(&HeaderMap::new())); + } + + #[test] + fn header_names_are_lowercased() { + let n = header_names(&headers(&[("X-Abc", "1")])); + assert_eq!(n, vec!["x-abc".to_owned()]); + } + + #[test] + fn the_source_header_name_is_the_documented_one() { + assert_eq!(SOURCE_HEADER, "x-oagw-error-source"); + } +} 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..ce2a3ec --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/routes.rs @@ -0,0 +1,275 @@ +//! REST route registration for the OAGW gear. +//! +//! Every path is **gear-relative**: the api-gateway host merges each gear's +//! routes onto one shared router and nests the whole assembled router once +//! under its own `prefix_path`, so this gear must not repeat that prefix. In +//! the graded configuration `prefix_path` is empty, which makes +//! `/oagw/v1/upstreams` the reachable management path. + +use std::sync::Arc; + +use axum::routing::{any, get}; +use axum::{Extension, Router}; +use toolkit::api::OpenApiRegistry; +use toolkit::api::canonical_prelude::StatusCode; +use toolkit::api::operation_builder::{ + CORE_GLOBAL_BASE_LICENSE_FEATURE, LicenseFeature, OperationBuilder, +}; + +use super::{handlers, proxy}; +use crate::gear::OagwState; + +const API_TAG: &str = "OAGW"; + +struct License; + +impl AsRef for License { + fn as_ref(&self) -> &'static str { + CORE_GLOBAL_BASE_LICENSE_FEATURE + } +} + +impl LicenseFeature for License {} + +/// Register the management and proxy routes. +// @cpt-begin:cpt-cf-oagw-dod-gf-registration:p1:inst-full +#[allow(clippy::needless_pass_by_value)] +pub fn register_routes( + mut router: Router, + openapi: &dyn OpenApiRegistry, + state: Arc, +) -> Router { + // ---- upstreams ----------------------------------------------------- + router = OperationBuilder::post("/oagw/v1/upstreams") + .operation_id("oagw.upstreams.create") + .summary("Register an upstream service") + .description("Create an upstream, deriving its alias from the endpoint host when omitted.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .request_optional() + .handler(handlers::create_upstream) + .json_response(StatusCode::CREATED, "The 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(API_TAG) + .authenticated() + .require_license_features::([]) + .query_param("$top", false, "Page size (default 50, max 100)") + .query_param("$skip", false, "Offset into the collection") + .handler(handlers::list_upstreams) + .json_response(StatusCode::OK, "A page of upstreams") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/upstreams/{id}") + .operation_id("oagw.upstreams.get") + .summary("Get an upstream") + .description("Retrieve one upstream by identifier.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "The upstream identifier") + .handler(handlers::get_upstream) + .json_response(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. The identifier and the alias are immutable.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "The upstream identifier") + .request_optional() + .handler(handlers::replace_upstream) + .json_response(StatusCode::OK, "The replaced upstream") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::delete("/oagw/v1/upstreams/{id}") + .operation_id("oagw.upstreams.delete") + .summary("Delete an upstream") + .description("Delete an upstream and its routes.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "The upstream identifier") + .handler(handlers::delete_upstream) + .no_content_response(StatusCode::NO_CONTENT, "Deleted") + .standard_errors(openapi) + .register(router, openapi); + + // ---- routes -------------------------------------------------------- + router = OperationBuilder::post("/oagw/v1/routes") + .operation_id("oagw.routes.create") + .summary("Create a route") + .description("Create a routing rule under an upstream.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .request_optional() + .handler(handlers::create_route) + .json_response(StatusCode::CREATED, "The 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(API_TAG) + .authenticated() + .require_license_features::([]) + .query_param("$top", false, "Page size (default 50, max 100)") + .query_param("$skip", false, "Offset into the collection") + .handler(handlers::list_routes) + .json_response(StatusCode::OK, "A page of routes") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/routes/{id}") + .operation_id("oagw.routes.get") + .summary("Get a route") + .description("Retrieve one route by identifier.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "The route identifier") + .handler(handlers::get_route) + .json_response(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 parent upstream is immutable.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "The route identifier") + .request_optional() + .handler(handlers::replace_route) + .json_response(StatusCode::OK, "The replaced route") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::delete("/oagw/v1/routes/{id}") + .operation_id("oagw.routes.delete") + .summary("Delete a route") + .description("Delete one routing rule.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "The route identifier") + .handler(handlers::delete_route) + .no_content_response(StatusCode::NO_CONTENT, "Deleted") + .standard_errors(openapi) + .register(router, openapi); + + // ---- plugins ------------------------------------------------------- + router = OperationBuilder::post("/oagw/v1/plugins") + .operation_id("oagw.plugins.create") + .summary("Define a custom plugin") + .description("Create an immutable custom plugin definition.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .request_optional() + .handler(handlers::create_plugin) + .json_response(StatusCode::CREATED, "The created plugin definition") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/plugins") + .operation_id("oagw.plugins.list") + .summary("List custom plugin definitions") + .description("List the calling tenant's custom plugin definitions.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .query_param("$top", false, "Page size (default 50, max 100)") + .query_param("$skip", false, "Offset into the collection") + .handler(handlers::list_plugins) + .json_response(StatusCode::OK, "A page of plugin definitions") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/plugins/catalog") + .operation_id("oagw.plugins.catalog") + .summary("List the built-in plugin catalog") + .description("The built-in catalog, marking which entries are served.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .handler(handlers::list_catalog) + .json_response(StatusCode::OK, "The catalog") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/plugins/{id}") + .operation_id("oagw.plugins.get") + .summary("Get a custom plugin definition") + .description("Retrieve one custom plugin definition.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "The plugin identifier") + .handler(handlers::get_plugin) + .json_response(StatusCode::OK, "The plugin definition") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/plugins/{id}/source") + .operation_id("oagw.plugins.source") + .summary("Get a custom plugin's source") + .description("Retrieve the plugin definition's source text.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "The plugin identifier") + .handler(handlers::get_plugin_source) + .text_response(StatusCode::OK, "The plugin source", "text/plain") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::delete("/oagw/v1/plugins/{id}") + .operation_id("oagw.plugins.delete") + .summary("Delete a custom plugin definition") + .description("Delete a definition. A definition still bound is a conflict.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "The plugin identifier") + .handler(handlers::delete_plugin) + .no_content_response(StatusCode::NO_CONTENT, "Deleted") + .standard_errors(openapi) + .register(router, openapi); + + // ---- data plane ---------------------------------------------------- + // The proxy accepts every method on every sub-path, so it is registered + // directly rather than through the operation builder, which models one + // method per operation. + router = router + .route("/oagw/v1/proxy/{alias}", any(proxy::proxy)) + .route("/oagw/v1/proxy/{alias}/{*rest}", any(proxy::proxy)); + + // ---- gear health --------------------------------------------------- + router = router.route("/oagw/v1/health", get(health)); + + router.layer(Extension(state)) +} +// @cpt-end:cpt-cf-oagw-dod-gf-registration:p1:inst-full + +/// Liveness of the gear's own surface. +async fn health() -> axum::Json { + axum::Json(serde_json::json!({ "status": "ok", "gear": "oagw" })) +} diff --git a/gears/system/oagw/oagw/src/api/rest/routes_tests.rs b/gears/system/oagw/oagw/src/api/rest/routes_tests.rs new file mode 100644 index 0000000..717bca8 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/routes_tests.rs @@ -0,0 +1,564 @@ +//! Router-level tests for the OAGW management API. +//! +//! The gear's own router is built and driven directly, so these exercise the +//! real routes, status codes and bodies without booting the whole server. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::Arc; + +use axum::Router; +use axum::body::{Body, to_bytes}; +use axum::http::{Request, StatusCode}; +use toolkit::api::OpenApiRegistryImpl; +use tower::ServiceExt; + +use super::register_routes; +use crate::config::OagwConfig; +use crate::gear::OagwState; + +fn router() -> Router { + let openapi = OpenApiRegistryImpl::new(); + let state = Arc::new(OagwState::new(OagwConfig::default())); + register_routes(Router::new(), &openapi, state) +} + +async fn call(r: &Router, method: &str, uri: &str, body: Option) -> (StatusCode, serde_json::Value) { + let req = Request::builder().method(method).uri(uri); + let req = match body { + Some(b) => req + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&b).unwrap())) + .unwrap(), + None => req.body(Body::empty()).unwrap(), + }; + let resp = r.clone().oneshot(req).await.unwrap(); + let status = resp.status(); + let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + let json = if bytes.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null) + }; + (status, json) +} + +fn upstream_body(alias: Option<&str>, scheme: &str, host: &str, port: u16) -> serde_json::Value { + let mut v = serde_json::json!({ + "server": {"endpoints": [{"scheme": scheme, "host": host, "port": port}]}, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1" + }); + if let Some(a) = alias { + v["alias"] = serde_json::Value::String(a.to_owned()); + } + v +} + +// ---- upstreams --------------------------------------------------------- + +#[tokio::test] +async fn creating_a_plaintext_http_upstream_succeeds_with_201() { + // The task's scheme-widening override: `http` must be accepted at create + // time even though the frozen schema's enum lists only the TLS family. + let r = router(); + let (s, body) = call( + &r, + "POST", + "/oagw/v1/upstreams", + Some(upstream_body(None, "http", "example.com", 80)), + ) + .await; + assert_eq!(s, StatusCode::CREATED); + assert_eq!(body["alias"], "example.com"); + assert_eq!(body["server"]["endpoints"][0]["scheme"], "http"); + assert!(body["id"].is_string()); +} + +#[tokio::test] +async fn creating_a_plaintext_websocket_upstream_succeeds_with_201() { + let r = router(); + let (s, _) = call( + &r, + "POST", + "/oagw/v1/upstreams", + Some(upstream_body(Some("ws-host"), "ws", "10.0.0.7", 80)), + ) + .await; + assert_eq!(s, StatusCode::CREATED); +} + +#[tokio::test] +async fn a_tls_upstream_still_succeeds() { + let r = router(); + let (s, body) = call( + &r, + "POST", + "/oagw/v1/upstreams", + Some(upstream_body(None, "https", "secure.example", 443)), + ) + .await; + assert_eq!(s, StatusCode::CREATED); + assert_eq!(body["alias"], "secure.example"); +} + +#[tokio::test] +async fn an_ip_literal_upstream_without_an_alias_is_400() { + let r = router(); + let (s, body) = call( + &r, + "POST", + "/oagw/v1/upstreams", + Some(upstream_body(None, "http", "10.0.0.1", 80)), + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST); + assert_eq!(body["status"], 400); +} + +#[tokio::test] +async fn a_contradicting_alias_is_400() { + let r = router(); + let (s, _) = call( + &r, + "POST", + "/oagw/v1/upstreams", + Some(upstream_body(Some("something-else"), "http", "example.com", 80)), + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn a_duplicate_alias_is_409() { + let r = router(); + let b = upstream_body(None, "http", "dupe.example", 80); + let (s1, _) = call(&r, "POST", "/oagw/v1/upstreams", Some(b.clone())).await; + assert_eq!(s1, StatusCode::CREATED); + let (s2, _) = call(&r, "POST", "/oagw/v1/upstreams", Some(b)).await; + assert_eq!(s2, StatusCode::CONFLICT); +} + +#[tokio::test] +async fn an_unknown_protocol_is_400() { + let r = router(); + let mut b = upstream_body(None, "http", "example.com", 80); + b["protocol"] = serde_json::Value::String("gts.cf.core.oagw.protocol.v1~nope".to_owned()); + let (s, _) = call(&r, "POST", "/oagw/v1/upstreams", Some(b)).await; + assert_eq!(s, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn an_empty_endpoint_pool_is_400() { + let r = router(); + let b = serde_json::json!({ + "server": {"endpoints": []}, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1" + }); + let (s, _) = call(&r, "POST", "/oagw/v1/upstreams", Some(b)).await; + assert_eq!(s, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn wildcard_origin_with_credentials_is_rejected_at_write_time() { + let r = router(); + let mut b = upstream_body(None, "http", "cors.example", 80); + b["cors"] = serde_json::json!({ + "enabled": true, "allowed_origins": ["*"], "allow_credentials": true + }); + let (s, _) = call(&r, "POST", "/oagw/v1/upstreams", Some(b)).await; + assert_eq!(s, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn get_list_and_delete_round_trip() { + let r = router(); + let (_, created) = call( + &r, + "POST", + "/oagw/v1/upstreams", + Some(upstream_body(None, "http", "round.example", 80)), + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + + let (s, got) = call(&r, "GET", &format!("/oagw/v1/upstreams/{id}"), None).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(got["alias"], "round.example"); + + let (s, page) = call(&r, "GET", "/oagw/v1/upstreams", None).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(page["total"], 1); + assert_eq!(page["items"].as_array().unwrap().len(), 1); + + let (s, _) = call(&r, "DELETE", &format!("/oagw/v1/upstreams/{id}"), None).await; + assert_eq!(s, StatusCode::NO_CONTENT); + + let (s, _) = call(&r, "GET", &format!("/oagw/v1/upstreams/{id}"), None).await; + assert_eq!(s, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn an_unknown_upstream_is_404() { + let r = router(); + let (s, _) = call( + &r, + "GET", + "/oagw/v1/upstreams/11111111-1111-1111-1111-111111111111", + None, + ) + .await; + assert_eq!(s, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn replacing_an_upstream_keeps_the_alias_and_rejects_a_change() { + let r = router(); + let (_, created) = call( + &r, + "POST", + "/oagw/v1/upstreams", + Some(upstream_body(None, "http", "keep.example", 80)), + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + + // Same host: accepted, and the port change lands. + let (s, replaced) = call( + &r, + "PUT", + &format!("/oagw/v1/upstreams/{id}"), + Some(upstream_body(None, "http", "keep.example", 8080)), + ) + .await; + assert_eq!(s, StatusCode::OK); + assert_eq!(replaced["server"]["endpoints"][0]["port"], 8080); + assert_eq!(replaced["alias"], "keep.example"); + + // A different host would derive a different alias: refused. + let (s, _) = call( + &r, + "PUT", + &format!("/oagw/v1/upstreams/{id}"), + Some(upstream_body(None, "http", "other.example", 80)), + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST); +} + +// ---- routes ------------------------------------------------------------ + +async fn make_upstream(r: &Router, alias: &str) -> String { + let (s, created) = call( + r, + "POST", + "/oagw/v1/upstreams", + Some(upstream_body(Some(alias), "http", "10.0.0.9", 80)), + ) + .await; + assert_eq!(s, StatusCode::CREATED); + created["id"].as_str().unwrap().to_owned() +} + +fn route_body(upstream_id: &str, methods: &[&str], path: &str) -> serde_json::Value { + serde_json::json!({ + "upstream_id": upstream_id, + "match": {"http": {"methods": methods, "path": path}} + }) +} + +#[tokio::test] +async fn creating_a_route_succeeds_with_201() { + let r = router(); + let up = make_upstream(&r, "r1").await; + let (s, body) = call( + &r, + "POST", + "/oagw/v1/routes", + Some(route_body(&up, &["GET"], "/v1")), + ) + .await; + assert_eq!(s, StatusCode::CREATED); + assert_eq!(body["upstream_id"], up); + assert_eq!(body["match"]["http"]["path_suffix_mode"], "append"); +} + +#[tokio::test] +async fn a_route_naming_an_unresolvable_upstream_is_400_not_404() { + let r = router(); + let (s, _) = call( + &r, + "POST", + "/oagw/v1/routes", + Some(route_body( + "11111111-1111-1111-1111-111111111111", + &["GET"], + "/v1", + )), + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn a_colliding_route_match_is_409() { + let r = router(); + let up = make_upstream(&r, "r2").await; + let b = route_body(&up, &["GET"], "/v1"); + let (s1, _) = call(&r, "POST", "/oagw/v1/routes", Some(b.clone())).await; + assert_eq!(s1, StatusCode::CREATED); + let (s2, _) = call(&r, "POST", "/oagw/v1/routes", Some(b)).await; + assert_eq!(s2, StatusCode::CONFLICT); +} + +#[tokio::test] +async fn a_route_differing_only_by_method_does_not_collide() { + let r = router(); + let up = make_upstream(&r, "r3").await; + let (s1, _) = call( + &r, + "POST", + "/oagw/v1/routes", + Some(route_body(&up, &["GET"], "/v1")), + ) + .await; + let (s2, _) = call( + &r, + "POST", + "/oagw/v1/routes", + Some(route_body(&up, &["POST"], "/v1")), + ) + .await; + assert_eq!(s1, StatusCode::CREATED); + assert_eq!(s2, StatusCode::CREATED); +} + +#[tokio::test] +async fn a_route_with_neither_match_kind_is_400() { + let r = router(); + let up = make_upstream(&r, "r4").await; + let (s, _) = call( + &r, + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({"upstream_id": up, "match": {}})), + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn replacing_a_route_rejects_an_upstream_id_in_the_body() { + let r = router(); + let up = make_upstream(&r, "r5").await; + let (_, created) = call( + &r, + "POST", + "/oagw/v1/routes", + Some(route_body(&up, &["GET"], "/v1")), + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + // `upstream_id` is immutable and therefore not part of the replace shape. + let (s, _) = call( + &r, + "PUT", + &format!("/oagw/v1/routes/{id}"), + Some(route_body(&up, &["GET"], "/v2")), + ) + .await; + assert!( + s == StatusCode::BAD_REQUEST || s == StatusCode::UNPROCESSABLE_ENTITY, + "an immutable upstream_id in the body must be rejected, got {s}" + ); +} + +#[tokio::test] +async fn deleting_an_upstream_removes_its_routes() { + let r = router(); + let up = make_upstream(&r, "r6").await; + call( + &r, + "POST", + "/oagw/v1/routes", + Some(route_body(&up, &["GET"], "/v1")), + ) + .await; + let (_, page) = call(&r, "GET", "/oagw/v1/routes", None).await; + assert_eq!(page["total"], 1); + + call(&r, "DELETE", &format!("/oagw/v1/upstreams/{up}"), None).await; + let (_, page) = call(&r, "GET", "/oagw/v1/routes", None).await; + assert_eq!(page["total"], 0); +} + +// ---- plugins ----------------------------------------------------------- + +fn plugin_body(name: &str, kind: &str) -> serde_json::Value { + serde_json::json!({ + "name": name, + "plugin_type": kind, + "source_code": "def on_request(ctx): ctx.next()" + }) +} + +#[tokio::test] +async fn a_custom_plugin_definition_round_trips() { + let r = router(); + let (s, created) = call( + &r, + "POST", + "/oagw/v1/plugins", + Some(plugin_body("redactor", "transform")), + ) + .await; + assert_eq!(s, StatusCode::CREATED); + let id = created["id"].as_str().unwrap().to_owned(); + + let (s, got) = call(&r, "GET", &format!("/oagw/v1/plugins/{id}"), None).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(got["name"], "redactor"); + + let (s, _) = call(&r, "GET", &format!("/oagw/v1/plugins/{id}/source"), None).await; + assert_eq!(s, StatusCode::OK); + + let (s, _) = call(&r, "DELETE", &format!("/oagw/v1/plugins/{id}"), None).await; + assert_eq!(s, StatusCode::NO_CONTENT); +} + +#[tokio::test] +async fn there_is_no_put_for_a_plugin_definition() { + let r = router(); + let (_, created) = call( + &r, + "POST", + "/oagw/v1/plugins", + Some(plugin_body("immutable", "guard")), + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + let (s, _) = call( + &r, + "PUT", + &format!("/oagw/v1/plugins/{id}"), + Some(plugin_body("immutable", "guard")), + ) + .await; + assert_eq!(s, StatusCode::METHOD_NOT_ALLOWED); +} + +#[tokio::test] +async fn a_duplicate_plugin_name_is_409() { + let r = router(); + call(&r, "POST", "/oagw/v1/plugins", Some(plugin_body("dup", "guard"))).await; + let (s, _) = call(&r, "POST", "/oagw/v1/plugins", Some(plugin_body("dup", "guard"))).await; + assert_eq!(s, StatusCode::CONFLICT); +} + +#[tokio::test] +async fn deleting_a_bound_plugin_is_409_naming_what_references_it() { + let r = router(); + let (_, plugin) = call( + &r, + "POST", + "/oagw/v1/plugins", + Some(plugin_body("bound", "guard")), + ) + .await; + let pid = plugin["id"].as_str().unwrap().to_owned(); + + let mut up = upstream_body(Some("bindme"), "http", "10.0.0.5", 80); + up["plugins"] = serde_json::json!({"items": [pid]}); + let (s, created_up) = call(&r, "POST", "/oagw/v1/upstreams", Some(up)).await; + assert_eq!(s, StatusCode::CREATED); + let up_id = created_up["id"].as_str().unwrap().to_owned(); + + let (s, body) = call(&r, "DELETE", &format!("/oagw/v1/plugins/{pid}"), None).await; + assert_eq!(s, StatusCode::CONFLICT); + // The body names the referencing upstream. + let rendered = body.to_string(); + assert!( + rendered.contains(&up_id) || rendered.contains("referenced"), + "409 body should identify what references the plugin: {rendered}" + ); +} + +#[tokio::test] +async fn binding_an_unknown_plugin_is_400() { + let r = router(); + let mut up = upstream_body(Some("badbind"), "http", "10.0.0.6", 80); + up["plugins"] = serde_json::json!({"items": ["not-a-plugin"]}); + let (s, _) = call(&r, "POST", "/oagw/v1/upstreams", Some(up)).await; + assert_eq!(s, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn binding_a_catalog_only_plugin_is_400() { + let r = router(); + let mut up = upstream_body(Some("catonly"), "http", "10.0.0.8", 80); + up["plugins"] = serde_json::json!({ + "items": ["gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.timeout.v1"] + }); + let (s, _) = call(&r, "POST", "/oagw/v1/upstreams", Some(up)).await; + assert_eq!(s, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn binding_a_served_builtin_guard_succeeds() { + let r = router(); + let mut up = upstream_body(Some("goodbind"), "http", "10.0.0.11", 80); + up["plugins"] = serde_json::json!({ + "items": ["gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1"] + }); + let (s, _) = call(&r, "POST", "/oagw/v1/upstreams", Some(up)).await; + assert_eq!(s, StatusCode::CREATED); +} + +#[tokio::test] +async fn the_catalog_marks_which_entries_are_served() { + let r = router(); + let (s, body) = call(&r, "GET", "/oagw/v1/plugins/catalog", None).await; + assert_eq!(s, StatusCode::OK); + let items = body["items"].as_array().unwrap(); + assert!(items.iter().any(|i| i["served"] == true)); + assert!(items.iter().any(|i| i["served"] == false)); +} + +// ---- paging ------------------------------------------------------------ + +#[tokio::test] +async fn listing_pages_with_top_and_skip() { + let r = router(); + for i in 0..5 { + call( + &r, + "POST", + "/oagw/v1/upstreams", + Some(upstream_body(None, "http", &format!("h{i}.example"), 80)), + ) + .await; + } + let (_, page) = call(&r, "GET", "/oagw/v1/upstreams?$top=2&$skip=1", None).await; + assert_eq!(page["total"], 5); + assert_eq!(page["items"].as_array().unwrap().len(), 2); +} + +// ---- health ------------------------------------------------------------ + +#[tokio::test] +async fn the_gear_health_route_answers_200() { + let r = router(); + let (s, body) = call(&r, "GET", "/oagw/v1/health", None).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(body["gear"], "oagw"); +} + +#[tokio::test] +async fn routes_are_registered_gear_relative_without_an_api_prefix() { + // The whole point of the gear-relative override: the `/api`-prefixed form + // must NOT be what this gear registers. + let r = router(); + let (s, _) = call(&r, "GET", "/api/oagw/v1/health", None).await; + assert_eq!(s, StatusCode::NOT_FOUND); + let (s, _) = call(&r, "GET", "/oagw/v1/health", None).await; + assert_eq!(s, StatusCode::OK); +} diff --git a/gears/system/oagw/oagw/src/config.rs b/gears/system/oagw/oagw/src/config.rs new file mode 100644 index 0000000..2d79b06 --- /dev/null +++ b/gears/system/oagw/oagw/src/config.rs @@ -0,0 +1,114 @@ +//! Typed configuration surface for the OAGW gear. +//! +//! Realizes `cpt-cf-oagw-algo-gf-load-config` / `cpt-cf-oagw-dod-gf-config`. +//! +//! The struct deliberately does **not** use `deny_unknown_fields`: the gear must +//! start when the deployment configuration carries a key this build does not +//! recognize. + +use serde::Deserialize; + +/// Server-Side Request Forgery policy for outbound connections. +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct SsrfPolicy { + /// When true, resolved outbound targets are screened before connecting. + pub enabled: bool, +} + +impl Default for SsrfPolicy { + fn default() -> Self { + Self { enabled: true } + } +} + +/// Gear-level configuration, deserialized from `gears.oagw.config`. +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct OagwConfig { + /// Bound on establishing an upstream connection and completing a + /// non-streaming exchange, in seconds. + pub proxy_timeout_secs: u64, + /// Whether a plaintext (non-TLS) upstream connection may actually be made. + /// + /// This governs the *connection*, never which scheme values the management + /// API accepts at create time. + pub allow_http_upstream: bool, + /// SSRF screening policy. + pub ssrf_policy: SsrfPolicy, + /// Ceiling on how long a cached authorization token is retained, in seconds. + pub token_cache_ttl_secs: u64, + /// Maximum number of cached authorization tokens. + pub token_cache_capacity: usize, +} + +impl Default for OagwConfig { + fn default() -> Self { + Self { + proxy_timeout_secs: 30, + allow_http_upstream: false, + ssrf_policy: SsrfPolicy::default(), + token_cache_ttl_secs: 300, + token_cache_capacity: 10_000, + } + } +} + +impl OagwConfig { + /// The proxy timeout as a `Duration`. + #[must_use] + pub fn proxy_timeout(&self) -> std::time::Duration { + std::time::Duration::from_secs(self.proxy_timeout_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_config_yields_documented_defaults() { + let cfg: OagwConfig = serde_json::from_value(serde_json::json!({})).unwrap(); + 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_deserializes() { + // Mirrors the `gears.oagw.config` block of config/e2e-local.yaml. + let cfg: OagwConfig = serde_json::from_value(serde_json::json!({ + "proxy_timeout_secs": 2, + "allow_http_upstream": true, + "ssrf_policy": { "enabled": false } + })) + .unwrap(); + assert_eq!(cfg.proxy_timeout_secs, 2); + assert!(cfg.allow_http_upstream); + assert!(!cfg.ssrf_policy.enabled); + // Keys absent from the graded config keep their defaults. + assert_eq!(cfg.token_cache_ttl_secs, 300); + assert_eq!(cfg.token_cache_capacity, 10_000); + } + + #[test] + fn unknown_keys_do_not_break_startup() { + let cfg: OagwConfig = serde_json::from_value(serde_json::json!({ + "proxy_timeout_secs": 7, + "a_key_this_build_does_not_know": {"nested": [1, 2, 3]} + })) + .unwrap(); + assert_eq!(cfg.proxy_timeout_secs, 7); + } + + #[test] + fn proxy_timeout_is_derived_from_seconds() { + let cfg = OagwConfig { + proxy_timeout_secs: 2, + ..OagwConfig::default() + }; + assert_eq!(cfg.proxy_timeout(), std::time::Duration::from_secs(2)); + } +} 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..5720629 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/alias.rs @@ -0,0 +1,210 @@ +//! Alias derivation and resolution. +//! +//! Realizes `cpt-cf-oagw-algo-um-derive-alias`. +//! +//! An alias is derived from the endpoint pool when every endpoint names a +//! hostname: the host is ASCII-lowercased and a trailing dot stripped. When the +//! pool is not derivable — an IP literal, or hosts that disagree — the caller +//! must supply the alias. A caller-supplied alias that differs from a derivable +//! value is rejected; one that matches exactly is an idempotent no-op. + +use crate::domain::error::DomainError; +use crate::domain::model::Endpoint; + +/// Normalize a hostname for use as an alias. +#[must_use] +pub fn normalize_host(host: &str) -> String { + host.trim().trim_end_matches('.').to_ascii_lowercase() +} + +/// Whether a host is an IP literal rather than a name. +#[must_use] +pub fn is_ip_literal(host: &str) -> bool { + let bare = host.trim_start_matches('[').trim_end_matches(']'); + bare.parse::().is_ok() +} + +/// Whether an alias satisfies the schema's pattern +/// `^[a-z0-9]([a-z0-9.:-]*[a-z0-9])?$`. +#[must_use] +pub fn is_valid_alias(alias: &str) -> bool { + if alias.is_empty() { + return false; + } + let ok_inner = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | ':' | '-'); + let ok_edge = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit(); + let mut chars = alias.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !ok_edge(first) { + return false; + } + if alias.len() == 1 { + return true; + } + let last = alias.chars().last().unwrap_or(first); + if !ok_edge(last) { + return false; + } + alias.chars().all(ok_inner) +} + +/// The alias derivable from an endpoint pool, when one exists. +/// +/// Returns `None` when the pool contains an IP literal or the hosts disagree, +/// in which case the caller must supply the alias explicitly. +#[must_use] +pub fn derive(endpoints: &[Endpoint]) -> Option { + let first = endpoints.first()?; + if is_ip_literal(&first.host) { + return None; + } + let candidate = normalize_host(&first.host); + if candidate.is_empty() { + return None; + } + for e in endpoints.iter().skip(1) { + if is_ip_literal(&e.host) || normalize_host(&e.host) != candidate { + return None; + } + } + Some(candidate) +} + +/// Resolve the effective alias for a write. +/// +/// # Errors +/// Returns [`DomainError::Validation`] when the pool is not derivable and no +/// alias was supplied, when a supplied alias contradicts a derivable one, or +/// when a supplied alias is malformed. +// @cpt-begin:cpt-cf-oagw-dod-um-alias-derivation:p1:inst-full +pub fn resolve(endpoints: &[Endpoint], supplied: Option<&str>) -> Result { + let derived = derive(endpoints); + match (derived, supplied) { + // Derivable, nothing supplied: use the derived value. + (Some(d), None) => Ok(d), + // Derivable and supplied: an exact match is an idempotent no-op, + // anything else is rejected. + (Some(d), Some(s)) => { + let s = normalize_host(s); + if s == d { + Ok(d) + } else { + Err(DomainError::validation( + "alias", + format!( + "alias is derived from the endpoint host and cannot be overridden; \ + expected `{d}`, got `{s}`" + ), + )) + } + } + // Not derivable: the caller must supply a well-formed alias. + (None, Some(s)) => { + let s = normalize_host(s); + if is_valid_alias(&s) { + Ok(s) + } else { + Err(DomainError::validation( + "alias", + "alias must match ^[a-z0-9]([a-z0-9.:-]*[a-z0-9])?$", + )) + } + } + (None, None) => Err(DomainError::validation( + "alias", + "alias is required when it cannot be derived from the endpoint host", + )), + } +} +// @cpt-end:cpt-cf-oagw-dod-um-alias-derivation:p1:inst-full + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::model::Scheme; + + fn ep(host: &str) -> Endpoint { + Endpoint { + scheme: Scheme::Https, + host: host.to_owned(), + port: None, + } + } + + #[test] + fn derives_from_a_hostname_endpoint() { + assert_eq!(derive(&[ep("Example.COM.")]).as_deref(), Some("example.com")); + } + + #[test] + fn an_ip_literal_pool_is_not_derivable() { + assert!(derive(&[ep("10.0.0.1")]).is_none()); + assert!(derive(&[ep("::1")]).is_none()); + } + + #[test] + fn disagreeing_hosts_are_not_derivable() { + assert!(derive(&[ep("a.example.com"), ep("b.example.com")]).is_none()); + } + + #[test] + fn identical_hosts_across_endpoints_still_derive() { + assert_eq!( + derive(&[ep("example.com"), ep("example.com")]).as_deref(), + Some("example.com") + ); + } + + #[test] + fn resolve_uses_the_derived_value_when_none_supplied() { + assert_eq!(resolve(&[ep("example.com")], None).unwrap(), "example.com"); + } + + #[test] + fn an_exactly_matching_supplied_alias_is_accepted() { + assert_eq!( + resolve(&[ep("example.com")], Some("example.com")).unwrap(), + "example.com" + ); + // Case and trailing dot normalize before the comparison. + assert_eq!( + resolve(&[ep("example.com")], Some("Example.COM.")).unwrap(), + "example.com" + ); + } + + #[test] + fn a_contradicting_supplied_alias_is_rejected() { + let err = resolve(&[ep("example.com")], Some("other")).unwrap_err(); + match err { + DomainError::Validation { field, .. } => assert_eq!(field, "alias"), + other => panic!("expected validation error, got {other:?}"), + } + } + + #[test] + fn an_ip_pool_requires_a_supplied_alias() { + assert!(resolve(&[ep("10.0.0.1")], None).is_err()); + assert_eq!(resolve(&[ep("10.0.0.1")], Some("billing")).unwrap(), "billing"); + } + + #[test] + fn a_malformed_supplied_alias_is_rejected() { + assert!(resolve(&[ep("10.0.0.1")], Some("-bad-")).is_err()); + assert!(resolve(&[ep("10.0.0.1")], Some("UPPER")).is_ok()); // normalized first + } + + #[test] + fn alias_pattern_accepts_documented_shapes() { + assert!(is_valid_alias("a")); + assert!(is_valid_alias("example.com")); + assert!(is_valid_alias("host-1:8080")); + assert!(!is_valid_alias("")); + assert!(!is_valid_alias("-lead")); + assert!(!is_valid_alias("trail-")); + assert!(!is_valid_alias("Upper")); + assert!(!is_valid_alias("has space")); + } +} diff --git a/gears/system/oagw/oagw/src/domain/cors.rs b/gears/system/oagw/oagw/src/domain/cors.rs new file mode 100644 index 0000000..fa6f2b1 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/cors.rs @@ -0,0 +1,243 @@ +//! CORS handling, per ADR-0004. +//! +//! Realizes `cpt-cf-oagw-algo-tp-cors-preflight` and +//! `cpt-cf-oagw-algo-tp-cors-actual-request`. +//! +//! Origin matching is exact: scheme, host and port are all significant and no +//! pattern matching is performed. + +use crate::domain::model::CorsConfig; + +/// How long a preflight result may be cached, in seconds. +pub const PREFLIGHT_MAX_AGE: &str = "86400"; + +/// The `Vary` value on a preflight response. +pub const PREFLIGHT_VARY: &str = + "Origin, Access-Control-Request-Method, Access-Control-Request-Headers"; + +/// A preflight request's inputs. +#[derive(Debug, Clone)] +pub struct Preflight<'a> { + /// The `Origin` header. + pub origin: &'a str, + /// The `Access-Control-Request-Method` header. + pub method: &'a str, + /// The `Access-Control-Request-Headers` header, when present. + pub headers: Option<&'a str>, +} + +/// Whether a request is a CORS preflight: `OPTIONS` carrying both `Origin` and +/// `Access-Control-Request-Method`. +#[must_use] +pub fn is_preflight(method: &str, origin: Option<&str>, acrm: Option<&str>) -> bool { + method.eq_ignore_ascii_case("OPTIONS") && origin.is_some() && acrm.is_some() +} + +/// The headers a preflight response carries. +/// +/// A preflight is answered before upstream resolution and without evaluating +/// auth or the plugin chain, so it echoes what was asked for. +// @cpt-begin:cpt-cf-oagw-dod-tp-cors-preflight:p1:inst-full +#[must_use] +pub fn preflight_headers(p: &Preflight<'_>) -> Vec<(&'static str, String)> { + let mut out = vec![ + ("access-control-allow-origin", p.origin.to_owned()), + ("access-control-allow-methods", p.method.to_owned()), + ("access-control-max-age", PREFLIGHT_MAX_AGE.to_owned()), + ("vary", PREFLIGHT_VARY.to_owned()), + ]; + if let Some(h) = p.headers { + out.push(("access-control-allow-headers", h.to_owned())); + } + out +} +// @cpt-end:cpt-cf-oagw-dod-tp-cors-preflight:p1:inst-full + +/// Why an actual cross-origin request was refused. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CorsRejection { + /// The origin is not in `allowed_origins`. + OriginNotAllowed, + /// The method is not in `allowed_methods`. + MethodNotAllowed, +} + +impl CorsRejection { + /// The distinct problem type for this rejection, per ADR-0004. + #[must_use] + pub const fn problem_type(self) -> &'static str { + match self { + Self::OriginNotAllowed => "cf.oagw.cors.origin_not_allowed.v1", + Self::MethodNotAllowed => "cf.oagw.cors.method_not_allowed.v1", + } + } + + /// A human-readable detail naming which check failed. + #[must_use] + pub const fn detail(self) -> &'static str { + match self { + Self::OriginNotAllowed => "the request origin is not permitted for this upstream", + Self::MethodNotAllowed => "the request method is not permitted for this upstream", + } + } +} + +/// Whether an origin is permitted. Matching is exact, with `*` as the only +/// wildcard. +#[must_use] +pub fn origin_allowed(cfg: &CorsConfig, origin: &str) -> bool { + cfg.allowed_origins + .iter() + .any(|o| o == "*" || o == origin) +} + +/// Evaluate an actual (non-preflight) cross-origin request. +/// +/// Returns `Ok(response headers)` when the request may proceed. +/// +/// # Errors +/// Returns the specific [`CorsRejection`] that applies. +// @cpt-begin:cpt-cf-oagw-dod-tp-cors-actual-request:p1:inst-full +pub fn evaluate_actual( + cfg: &CorsConfig, + origin: &str, + method: &str, +) -> Result, CorsRejection> { + if !origin_allowed(cfg, origin) { + return Err(CorsRejection::OriginNotAllowed); + } + if !cfg + .allowed_methods + .iter() + .any(|m| m.eq_ignore_ascii_case(method)) + { + return Err(CorsRejection::MethodNotAllowed); + } + + let mut out = vec![ + ("access-control-allow-origin", origin.to_owned()), + ("vary", "Origin".to_owned()), + ]; + if !cfg.expose_headers.is_empty() { + out.push(( + "access-control-expose-headers", + cfg.expose_headers.join(", "), + )); + } + if cfg.allow_credentials { + out.push(("access-control-allow-credentials", "true".to_owned())); + } + Ok(out) +} +// @cpt-end:cpt-cf-oagw-dod-tp-cors-actual-request:p1:inst-full + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::model::Sharing; + + fn cfg(origins: &[&str], methods: &[&str], creds: bool) -> CorsConfig { + CorsConfig { + sharing: Sharing::Private, + enabled: true, + allowed_origins: origins.iter().map(|s| (*s).to_owned()).collect(), + allowed_methods: methods.iter().map(|s| (*s).to_owned()).collect(), + expose_headers: vec!["x-trace".to_owned()], + allow_credentials: creds, + } + } + + #[test] + fn preflight_detection_needs_all_three_signals() { + assert!(is_preflight("OPTIONS", Some("https://a"), Some("GET"))); + assert!(is_preflight("options", Some("https://a"), Some("GET"))); + assert!(!is_preflight("GET", Some("https://a"), Some("GET"))); + assert!(!is_preflight("OPTIONS", None, Some("GET"))); + assert!(!is_preflight("OPTIONS", Some("https://a"), None)); + } + + #[test] + fn preflight_echoes_the_request_and_sets_max_age_and_vary() { + let h = preflight_headers(&Preflight { + origin: "https://app.example", + method: "PUT", + headers: Some("x-a, x-b"), + }); + let get = |k: &str| { + h.iter() + .find(|(n, _)| *n == k) + .map(|(_, v)| v.clone()) + .unwrap() + }; + assert_eq!(get("access-control-allow-origin"), "https://app.example"); + assert_eq!(get("access-control-allow-methods"), "PUT"); + assert_eq!(get("access-control-allow-headers"), "x-a, x-b"); + assert_eq!(get("access-control-max-age"), "86400"); + assert_eq!(get("vary"), PREFLIGHT_VARY); + } + + #[test] + fn preflight_omits_allow_headers_when_none_were_requested() { + let h = preflight_headers(&Preflight { + origin: "https://a", + method: "GET", + headers: None, + }); + assert!(!h.iter().any(|(n, _)| *n == "access-control-allow-headers")); + } + + #[test] + fn origin_matching_is_exact_and_port_sensitive() { + let c = cfg(&["https://app.example"], &["GET"], false); + assert!(origin_allowed(&c, "https://app.example")); + assert!(!origin_allowed(&c, "https://app.example:8443")); + assert!(!origin_allowed(&c, "http://app.example")); + assert!(!origin_allowed(&c, "https://evil.example")); + // No suffix matching: the classic bypass shape is refused. + assert!(!origin_allowed(&c, "https://app.example.evil.test")); + } + + #[test] + fn wildcard_origin_admits_anything() { + let c = cfg(&["*"], &["GET"], false); + assert!(origin_allowed(&c, "https://whatever.example")); + } + + #[test] + fn a_disallowed_origin_and_method_are_distinct_rejections() { + let c = cfg(&["https://a.example"], &["GET"], false); + assert_eq!( + evaluate_actual(&c, "https://b.example", "GET").unwrap_err(), + CorsRejection::OriginNotAllowed + ); + assert_eq!( + evaluate_actual(&c, "https://a.example", "DELETE").unwrap_err(), + CorsRejection::MethodNotAllowed + ); + assert_ne!( + CorsRejection::OriginNotAllowed.problem_type(), + CorsRejection::MethodNotAllowed.problem_type() + ); + } + + #[test] + fn an_allowed_request_gains_the_documented_response_headers() { + let c = cfg(&["https://a.example"], &["GET"], true); + let h = evaluate_actual(&c, "https://a.example", "get").unwrap(); + let names: Vec<&str> = h.iter().map(|(n, _)| *n).collect(); + assert!(names.contains(&"access-control-allow-origin")); + assert!(names.contains(&"vary")); + assert!(names.contains(&"access-control-expose-headers")); + assert!(names.contains(&"access-control-allow-credentials")); + } + + #[test] + fn credentials_header_is_absent_when_not_configured() { + let c = cfg(&["https://a.example"], &["GET"], false); + let h = evaluate_actual(&c, "https://a.example", "GET").unwrap(); + assert!( + !h.iter() + .any(|(n, _)| *n == "access-control-allow-credentials") + ); + } +} 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..5241fd0 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/error.rs @@ -0,0 +1,184 @@ +//! Domain error taxonomy for the OAGW gear. +//! +//! Every failure the gear can produce is expressed here; the REST layer maps +//! these onto canonical errors and RFC 9457 problem responses. + +use thiserror::Error; + +/// Which side of the gateway produced a response. +/// +/// Serialized into the `X-OAGW-Error-Source` response header. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ErrorSource { + /// The gateway itself produced the response. + Gateway, + /// The response was relayed from the upstream. + Upstream, +} + +impl ErrorSource { + /// The header value for this source. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Gateway => "gateway", + Self::Upstream => "upstream", + } + } +} + +/// The response header carrying the error source. +pub const ERROR_SOURCE_HEADER: &str = "x-oagw-error-source"; +/// The request header selecting one endpoint of a multi-endpoint pool. +pub const TARGET_HOST_HEADER: &str = "x-oagw-target-host"; + +/// Errors raised by the OAGW domain layer. +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum DomainError { + /// A request field failed validation. + #[error("validation failed for `{field}`: {message}")] + Validation { + /// The offending field path. + field: String, + /// Why it was rejected. + message: String, + }, + + /// The addressed resource does not exist within the caller's scope. + #[error("{resource} `{id}` not found")] + NotFound { + /// Resource kind, e.g. `upstream`. + resource: String, + /// Identifier that failed to resolve. + id: String, + }, + + /// A uniqueness constraint rejected the write. + #[error("{resource} `{key}` already exists")] + AlreadyExists { + /// Resource kind. + resource: String, + /// The conflicting key. + key: String, + }, + + /// The write conflicts with existing state. + #[error("conflict: {message}")] + Conflict { + /// What conflicted. + message: String, + }, + + /// A plugin definition is still referenced and cannot be deleted. + #[error("plugin `{id}` is still in use")] + PluginInUse { + /// The plugin identifier. + id: String, + /// Referencing upstream identifiers. + upstreams: Vec, + /// Referencing route identifiers. + routes: Vec, + }, + + /// The caller is authenticated but lacks the required permission. + #[error("permission denied: {message}")] + PermissionDenied { + /// Which permission was missing. + message: String, + }, + + /// The caller is not authenticated. + #[error("unauthenticated")] + Unauthenticated, + + /// The target is administratively disabled. + #[error("unavailable: {message}")] + Unavailable { + /// Why the target is unavailable. + message: String, + }, + + /// The upstream connection could not be established or failed mid-exchange. + #[error("upstream unreachable: {message}")] + UpstreamUnreachable { + /// Transport-level detail, safe for the wire. + message: String, + }, + + /// Establishing or completing the upstream exchange exceeded the bound. + #[error("upstream timed out")] + UpstreamTimeout, + + /// The declared body exceeds the hard cap. + #[error("payload too large")] + PayloadTooLarge, + + /// A rate limit rejected the request. + #[error("rate limit exceeded")] + RateLimited { + /// Seconds after which the caller may retry. + retry_after_secs: u64, + }, + + /// The capability is deliberately not served in this configuration. + #[error("not implemented: {message}")] + NotImplemented { + /// What is not served, and why. + message: String, + }, + + /// An unexpected internal failure. + #[error("internal error: {message}")] + Internal { + /// Internal detail. + message: String, + }, +} + +impl DomainError { + /// Convenience constructor for a validation failure. + pub fn validation(field: impl Into, message: impl Into) -> Self { + Self::Validation { + field: field.into(), + message: message.into(), + } + } + + /// Convenience constructor for a missing resource. + pub fn not_found(resource: impl Into, id: impl Into) -> Self { + Self::NotFound { + resource: resource.into(), + id: id.into(), + } + } + + /// Every domain error the gear raises is gateway-produced by definition; + /// upstream-sourced responses are relayed, never converted to a + /// `DomainError`. + #[must_use] + pub const fn source(&self) -> ErrorSource { + ErrorSource::Gateway + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn error_source_header_values() { + assert_eq!(ErrorSource::Gateway.as_str(), "gateway"); + assert_eq!(ErrorSource::Upstream.as_str(), "upstream"); + } + + #[test] + fn domain_errors_are_gateway_sourced() { + assert_eq!(DomainError::Unauthenticated.source(), ErrorSource::Gateway); + } + + #[test] + fn display_is_stable() { + let e = DomainError::validation("server.endpoints", "must not be empty"); + assert!(e.to_string().contains("server.endpoints")); + } +} 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..4175b1c --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/mod.rs @@ -0,0 +1,12 @@ +//! Domain layer for the OAGW gear: entities, invariants and pure logic. + +pub mod alias; +pub mod cors; +pub mod error; +pub mod model; +pub mod plugins; +pub mod ratelimit; +pub mod routing; +pub mod store; +pub mod tenant; +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..52a0e8c --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/model.rs @@ -0,0 +1,627 @@ +//! Domain entities for the OAGW control plane. +//! +//! The field contract mirrors `docs/schemas/upstream.v1.schema.json` and +//! `docs/schemas/route.v1.schema.json`, with one deliberate widening recorded in +//! the FEATURE documents: the accepted endpoint `scheme` set includes the +//! plaintext counterparts `http` and `ws` in addition to the frozen schema's +//! TLS family. Whether a plaintext connection is actually made is a separate +//! question, governed at connect time by `allow_http_upstream`. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Sharing mode for hierarchical configuration. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum Sharing { + /// Not visible to descendants. + #[default] + Private, + /// Descendants may override. + Inherit, + /// Descendants may not override. + Enforce, +} + +/// Transport scheme of an upstream endpoint. +/// +/// The frozen schema enumerates only `https`, `wss`, `wt` and `grpc`. The +/// graded configuration additionally admits the plaintext counterparts, so a +/// legal `{"scheme": "http", "port": 80}` upstream is accepted at create time. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum Scheme { + /// TLS-protected HTTP. + #[default] + Https, + /// Plaintext HTTP. + Http, + /// TLS-protected WebSocket. + Wss, + /// Plaintext WebSocket. + Ws, + /// WebTransport (accepted, not served). + Wt, + /// gRPC (accepted, not served). + Grpc, +} + +impl Scheme { + /// Whether a connection using this scheme is protected by TLS. + #[must_use] + pub const fn is_tls(self) -> bool { + matches!(self, Self::Https | Self::Wss | Self::Grpc | Self::Wt) + } + + /// Whether this scheme denotes a WebSocket transport. + #[must_use] + pub const fn is_websocket(self) -> bool { + matches!(self, Self::Ws | Self::Wss) + } + + /// The default port when the endpoint does not name one. + #[must_use] + pub const fn default_port(self) -> u16 { + match self { + Self::Http | Self::Ws => 80, + _ => 443, + } + } + + /// The URL scheme used when building an absolute upstream URL. + #[must_use] + pub const fn url_scheme(self) -> &'static str { + match self { + Self::Http | Self::Ws => "http", + _ => "https", + } + } +} + +/// One reachable address of an upstream service. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Endpoint { + /// Transport scheme. + #[serde(default)] + pub scheme: Scheme, + /// Hostname or IP literal. + pub host: String, + /// TCP port. Defaults to the scheme's default when absent. + #[serde(default)] + pub port: Option, +} + +impl Endpoint { + /// The effective port for this endpoint. + #[must_use] + pub fn effective_port(&self) -> u16 { + self.port.unwrap_or_else(|| self.scheme.default_port()) + } + + /// The `host:port` authority for this endpoint. + #[must_use] + pub fn authority(&self) -> String { + let port = self.effective_port(); + if self.host.contains(':') && !self.host.starts_with('[') { + // IPv6 literal needs bracketing in an authority. + format!("[{}]:{}", self.host, port) + } else { + format!("{}:{}", self.host, port) + } + } +} + +/// The endpoint pool of an upstream. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Server { + /// At least one endpoint. + pub endpoints: Vec, +} + +/// Per-direction header transformation rules. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct HeaderRules { + /// Headers to set, overwriting any existing value. + #[serde(default)] + pub set: BTreeMap, + /// Headers to append. + #[serde(default)] + pub add: BTreeMap, + /// Header names to strip. + #[serde(default)] + pub remove: Vec, + /// Which inbound headers to forward. + #[serde(default)] + pub passthrough: Passthrough, + /// Names forwarded when `passthrough` is `allowlist`. + #[serde(default)] + pub passthrough_allowlist: Vec, +} + +/// Header passthrough mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum Passthrough { + /// Forward nothing beyond what the rules add. + #[default] + None, + /// Forward only the names in the allowlist. + Allowlist, + /// Forward everything not otherwise stripped. + All, +} + +/// Request and response header rules. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Headers { + /// Rules applied to the forwarded request. + #[serde(default)] + pub request: HeaderRules, + /// Rules applied to the relayed response. + #[serde(default)] + pub response: HeaderRules, +} + +/// Authentication configuration for an upstream. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthConfig { + /// Auth plugin GTS identifier. + #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")] + pub plugin_type: Option, + /// Sharing mode. + #[serde(default)] + pub sharing: Sharing, + /// Plugin-specific configuration. + #[serde(default)] + pub config: serde_json::Value, +} + +/// An ordered plugin binding list. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginBindings { + /// Sharing mode. + #[serde(default)] + pub sharing: Sharing, + /// Plugin identifiers, in execution order. A binding's position is its + /// index; the wire format carries no separate position field. + #[serde(default)] + pub items: Vec, +} + +/// Sustained rate configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Sustained { + /// Tokens replenished per window. + pub rate: u32, + /// The replenishment window. + #[serde(default)] + pub window: Window, +} + +/// Rate-limit replenishment window. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum Window { + /// One second. + #[default] + Second, + /// One minute. + Minute, + /// One hour. + Hour, + /// One day. + Day, +} + +impl Window { + /// The window length in seconds. + #[must_use] + pub const fn as_secs(self) -> u64 { + match self { + Self::Second => 1, + Self::Minute => 60, + Self::Hour => 3_600, + Self::Day => 86_400, + } + } +} + +/// Burst configuration. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Burst { + /// Bucket capacity. Defaults to the sustained rate. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capacity: Option, +} + +/// Rate-limit algorithm. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum RateAlgorithm { + /// Token bucket. + #[default] + TokenBucket, + /// Sliding window. + SlidingWindow, +} + +/// Counter scope selecting the rate-limit key. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum RateScope { + /// One counter for the whole gateway. + Global, + /// One counter per tenant. + #[default] + Tenant, + /// One counter per authenticated subject. + User, + /// One counter per client address. + Ip, + /// One counter per route. + Route, +} + +/// Behaviour when the bucket is empty. +/// +/// `queue` and `degrade` are accepted configuration values but resolve to +/// `reject` semantics in this configuration: neither a bounded wait duration +/// nor a definition of reduced functionality is specified upstream. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum RateStrategy { + /// Reject with 429. + #[default] + Reject, + /// Accepted; resolves to `Reject`. + Queue, + /// Accepted; resolves to `Reject`. + Degrade, +} + +/// Rate-limit configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RateLimit { + /// Sharing mode. + #[serde(default)] + pub sharing: Sharing, + /// Algorithm. + #[serde(default)] + pub algorithm: RateAlgorithm, + /// Sustained rate. Required whenever a rate limit is present. + pub sustained: Sustained, + /// Burst capacity. + #[serde(default)] + pub burst: Burst, + /// Counter scope. + #[serde(default)] + pub scope: RateScope, + /// Behaviour on exhaustion. + #[serde(default)] + pub strategy: RateStrategy, + /// Tokens consumed per request. + #[serde(default = "one")] + pub cost: u32, +} + +const fn one() -> u32 { + 1 +} + +impl RateLimit { + /// Effective bucket capacity. + #[must_use] + pub fn capacity(&self) -> u32 { + self.burst.capacity.unwrap_or(self.sustained.rate).max(1) + } + + /// Token replenishment rate, in tokens per second. + #[must_use] + pub fn refill_per_sec(&self) -> f64 { + f64::from(self.sustained.rate) / self.sustained.window.as_secs() as f64 + } +} + +/// CORS configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CorsConfig { + /// Sharing mode. + #[serde(default)] + pub sharing: Sharing, + /// Whether CORS handling is active. + pub enabled: bool, + /// Allowed origins; `*` denotes any. + #[serde(default)] + pub allowed_origins: Vec, + /// Allowed methods. + #[serde(default = "default_cors_methods")] + pub allowed_methods: Vec, + /// Headers exposed to the browser. + #[serde(default)] + pub expose_headers: Vec, + /// Whether credentials are allowed. + #[serde(default)] + pub allow_credentials: bool, +} + +fn default_cors_methods() -> Vec { + vec!["GET".to_owned(), "POST".to_owned()] +} + +/// An upstream service registration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Upstream { + /// Server-generated identifier. + pub id: Uuid, + /// Owning tenant. + #[serde(skip)] + pub tenant_id: Uuid, + /// Whether the upstream serves traffic. + #[serde(default = "yes")] + pub enabled: bool, + /// Routing alias, unique within the tenant. + pub alias: String, + /// Free-form tags. + #[serde(default)] + pub tags: Vec, + /// Endpoint pool. + pub server: Server, + /// Application protocol identifier. + pub protocol: String, + /// Authentication configuration. + #[serde(default)] + pub auth: AuthConfig, + /// Header transformation rules. + #[serde(default)] + pub headers: Headers, + /// Guard and transform plugin bindings. + #[serde(default)] + pub plugins: PluginBindings, + /// Rate-limit configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rate_limit: Option, + /// CORS configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cors: Option, +} + +const fn yes() -> bool { + true +} + +/// HTTP match criteria for a route. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HttpMatch { + /// Methods this route accepts. + pub methods: Vec, + /// Path prefix to match. + pub path: String, + /// Query parameters forwarded when non-empty. + #[serde(default)] + pub query_allowlist: Vec, + /// Whether the unmatched path suffix is appended to the target. + #[serde(default)] + pub path_suffix_mode: PathSuffixMode, +} + +/// Whether the unmatched path suffix is appended. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum PathSuffixMode { + /// Do not append the suffix. + Disabled, + /// Append the suffix. + #[default] + Append, +} + +/// gRPC match criteria. Accepted structurally; not served. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GrpcMatch { + /// Fully-qualified service name. + pub service: String, + /// Method name. + pub method: String, +} + +/// Exactly one of the supported match kinds. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RouteMatch { + /// HTTP match criteria. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub http: Option, + /// gRPC match criteria. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub grpc: Option, +} + +/// A routing rule under an upstream. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Route { + /// Server-generated identifier. + pub id: Uuid, + /// Owning tenant. + #[serde(skip)] + pub tenant_id: Uuid, + /// Whether the route participates in matching. + #[serde(default = "yes")] + pub enabled: bool, + /// Free-form tags. + #[serde(default)] + pub tags: Vec, + /// Parent upstream. Immutable after creation. + pub upstream_id: Uuid, + /// Match criteria. + #[serde(rename = "match")] + pub match_: RouteMatch, + /// Guard and transform plugin bindings. + #[serde(default)] + pub plugins: PluginBindings, + /// Rate-limit configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rate_limit: Option, +} + +/// Which phase a plugin participates in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PluginKind { + /// Injects authorization material. + Auth, + /// Admits or rejects an exchange. + Guard, + /// Mutates a request or response. + Transform, +} + +/// A custom plugin definition. Immutable after creation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginDef { + /// Server-generated identifier. + pub id: Uuid, + /// Owning tenant. + #[serde(skip)] + pub tenant_id: Uuid, + /// Human-readable name, unique within the tenant. + pub name: String, + /// Optional description. + #[serde(default)] + pub description: String, + /// Which phase the plugin participates in. + pub plugin_type: PluginKind, + /// Optional configuration schema. + #[serde(default)] + pub config_schema: serde_json::Value, + /// Plugin source text. Execution is deferred in this configuration. + #[serde(default)] + pub source_code: String, +} + +impl PluginDef { + /// The anonymous GTS identifier for this definition. + #[must_use] + pub fn gts_id(&self) -> String { + let kind = match self.plugin_type { + PluginKind::Auth => "auth_plugin", + PluginKind::Guard => "guard_plugin", + PluginKind::Transform => "transform_plugin", + }; + format!("gts.cf.core.oagw.{kind}.v1~{}", self.id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plaintext_schemes_are_accepted_and_carry_their_own_defaults() { + let e: Endpoint = + serde_json::from_value(serde_json::json!({"scheme":"http","host":"example.com"})) + .unwrap(); + assert_eq!(e.scheme, Scheme::Http); + assert_eq!(e.effective_port(), 80); + assert!(!e.scheme.is_tls()); + } + + #[test] + fn explicit_port_wins_over_scheme_default() { + let e: Endpoint = serde_json::from_value( + serde_json::json!({"scheme":"http","host":"example.com","port":8080}), + ) + .unwrap(); + assert_eq!(e.effective_port(), 8080); + assert_eq!(e.authority(), "example.com:8080"); + } + + #[test] + fn tls_family_still_defaults_to_443() { + let e: Endpoint = + serde_json::from_value(serde_json::json!({"scheme":"https","host":"example.com"})) + .unwrap(); + assert_eq!(e.effective_port(), 443); + assert!(e.scheme.is_tls()); + } + + #[test] + fn scheme_defaults_to_https_when_absent() { + let e: Endpoint = serde_json::from_value(serde_json::json!({"host":"example.com"})).unwrap(); + assert_eq!(e.scheme, Scheme::Https); + } + + #[test] + fn ipv6_authority_is_bracketed() { + let e = Endpoint { + scheme: Scheme::Http, + host: "::1".to_owned(), + port: Some(8080), + }; + assert_eq!(e.authority(), "[::1]:8080"); + } + + #[test] + fn websocket_schemes_are_recognised() { + assert!(Scheme::Ws.is_websocket()); + assert!(Scheme::Wss.is_websocket()); + assert!(!Scheme::Http.is_websocket()); + assert_eq!(Scheme::Ws.default_port(), 80); + assert_eq!(Scheme::Wss.default_port(), 443); + } + + #[test] + fn rate_limit_capacity_defaults_to_sustained_rate() { + let rl = RateLimit { + sharing: Sharing::Private, + algorithm: RateAlgorithm::TokenBucket, + sustained: Sustained { + rate: 5, + window: Window::Second, + }, + burst: Burst { capacity: None }, + scope: RateScope::Tenant, + strategy: RateStrategy::Reject, + cost: 1, + }; + assert_eq!(rl.capacity(), 5); + assert!((rl.refill_per_sec() - 5.0).abs() < f64::EPSILON); + } + + #[test] + fn rate_limit_window_scales_refill() { + let rl: RateLimit = serde_json::from_value(serde_json::json!({ + "sustained": {"rate": 60, "window": "minute"} + })) + .unwrap(); + assert!((rl.refill_per_sec() - 1.0).abs() < f64::EPSILON); + assert_eq!(rl.cost, 1); + assert_eq!(rl.strategy, RateStrategy::Reject); + assert_eq!(rl.scope, RateScope::Tenant); + } + + #[test] + fn route_match_uses_the_wire_field_name() { + let r: RouteMatch = + serde_json::from_value(serde_json::json!({"http":{"methods":["GET"],"path":"/v1"}})) + .unwrap(); + let http = r.http.unwrap(); + assert_eq!(http.path, "/v1"); + assert_eq!(http.path_suffix_mode, PathSuffixMode::Append); + assert!(http.query_allowlist.is_empty()); + } + + #[test] + fn plugin_gts_id_is_kind_scoped() { + let p = PluginDef { + id: Uuid::nil(), + tenant_id: Uuid::nil(), + name: "n".to_owned(), + description: String::new(), + plugin_type: PluginKind::Guard, + config_schema: serde_json::Value::Null, + source_code: String::new(), + }; + assert!(p.gts_id().starts_with("gts.cf.core.oagw.guard_plugin.v1~")); + } +} diff --git a/gears/system/oagw/oagw/src/domain/plugins.rs b/gears/system/oagw/oagw/src/domain/plugins.rs new file mode 100644 index 0000000..6a33234 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/plugins.rs @@ -0,0 +1,367 @@ +//! The plugin catalog, binding validation and the served guard plugins. +//! +//! Realizes `cpt-cf-oagw-algo-pm-validate-binding`, +//! `cpt-cf-oagw-dod-pm-catalog`, `cpt-cf-oagw-dod-pm-binding-validation` and +//! `cpt-cf-oagw-algo-tp-required-headers-guard`. + +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::model::PluginKind; + +/// A built-in plugin catalog entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CatalogEntry { + /// The GTS identifier callers bind. + pub id: &'static str, + /// Which phase it participates in. + pub kind: PluginKind, + /// Whether an implementation actually backs it in this configuration. + pub served: bool, +} + +/// The built-in plugin catalog. +/// +/// Entries marked `served: false` are catalog-only: the identifier is known but +/// no implementation backs it, and binding one is rejected at write time. +pub const CATALOG: &[CatalogEntry] = &[ + CatalogEntry { + id: "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.noop.v1", + kind: PluginKind::Auth, + served: true, + }, + CatalogEntry { + id: "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1", + kind: PluginKind::Auth, + served: true, + }, + CatalogEntry { + id: "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred.v1", + kind: PluginKind::Auth, + served: true, + }, + CatalogEntry { + id: "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred_basic.v1", + kind: PluginKind::Auth, + served: true, + }, + CatalogEntry { + id: "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.basic.v1", + kind: PluginKind::Auth, + served: false, + }, + CatalogEntry { + id: "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.bearer.v1", + kind: PluginKind::Auth, + served: false, + }, + CatalogEntry { + id: "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1", + kind: PluginKind::Guard, + served: true, + }, + CatalogEntry { + id: "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.timeout.v1", + kind: PluginKind::Guard, + served: false, + }, + CatalogEntry { + id: "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.cors.v1", + kind: PluginKind::Guard, + served: false, + }, + CatalogEntry { + id: "gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1", + kind: PluginKind::Transform, + served: true, + }, +]; + +/// Look a catalog entry up by identifier. +#[must_use] +pub fn catalog_entry(id: &str) -> Option<&'static CatalogEntry> { + CATALOG.iter().find(|e| e.id == id) +} + +/// The instance part of a plugin binding entry. +/// +/// The frozen upstream schema admits either a full GTS identifier or a bare +/// UUID; both resolve to the same custom plugin definition. The route schema +/// admits only the GTS form. +#[must_use] +pub fn instance_uuid(entry: &str) -> Option { + let candidate = entry.rsplit_once('~').map_or(entry, |(_, tail)| tail); + Uuid::parse_str(candidate).ok() +} + +/// The outcome of resolving one binding entry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Binding { + /// A served built-in. + Builtin(&'static CatalogEntry), + /// A tenant-owned custom definition. + Custom(Uuid), +} + +/// Resolve one plugin binding entry. +/// +/// # Errors +/// Returns [`DomainError::Validation`] naming the array index when the entry +/// does not resolve, names a catalog-only identifier, or names an auth plugin +/// in the ordered guard/transform list. +// @cpt-begin:cpt-cf-oagw-dod-pm-binding-validation:p1:inst-full +pub fn resolve_binding( + field: &str, + index: usize, + entry: &str, + custom_exists: impl Fn(Uuid) -> Option, +) -> Result { + let at = format!("{field}[{index}]"); + + if let Some(e) = catalog_entry(entry) { + if !e.served { + return Err(DomainError::validation( + at, + format!("`{entry}` is a catalog-only identifier with no backing implementation"), + )); + } + if e.kind == PluginKind::Auth { + return Err(DomainError::validation( + at, + "an auth plugin is bound through the upstream's `auth` field, \ + not the ordered plugin list", + )); + } + return Ok(Binding::Builtin(e)); + } + + // Either a full GTS identifier wrapping a UUID, or a bare UUID. + if let Some(id) = instance_uuid(entry) { + return match custom_exists(id) { + Some(PluginKind::Auth) => Err(DomainError::validation( + at, + "an auth plugin is bound through the upstream's `auth` field, \ + not the ordered plugin list", + )), + Some(_) => Ok(Binding::Custom(id)), + None => Err(DomainError::validation( + at, + format!("`{entry}` does not resolve to a known plugin definition"), + )), + }; + } + + Err(DomainError::validation( + at, + format!("`{entry}` does not resolve to a known plugin identifier"), + )) +} +// @cpt-end:cpt-cf-oagw-dod-pm-binding-validation:p1:inst-full + +/// Resolve an upstream's auth plugin identifier. +/// +/// # Errors +/// Returns [`DomainError::Validation`] when the identifier is unknown, is +/// catalog-only, or is not an auth plugin. +pub fn resolve_auth_plugin( + entry: &str, + custom_exists: impl Fn(Uuid) -> Option, +) -> Result { + if let Some(e) = catalog_entry(entry) { + if !e.served { + return Err(DomainError::validation( + "auth.type", + format!("`{entry}` is a catalog-only identifier with no backing implementation"), + )); + } + if e.kind != PluginKind::Auth { + return Err(DomainError::validation( + "auth.type", + format!("`{entry}` is not an auth plugin"), + )); + } + return Ok(Binding::Builtin(e)); + } + if let Some(id) = instance_uuid(entry) { + return match custom_exists(id) { + Some(PluginKind::Auth) => Ok(Binding::Custom(id)), + Some(_) => Err(DomainError::validation( + "auth.type", + format!("`{entry}` is not an auth plugin"), + )), + None => Err(DomainError::validation( + "auth.type", + format!("`{entry}` does not resolve to a known plugin definition"), + )), + }; + } + Err(DomainError::validation( + "auth.type", + format!("`{entry}` does not resolve to a known plugin identifier"), + )) +} + +/// Parse a comma-separated required-header list. +/// +/// Entries are trimmed and lowercased and empty entries dropped; an absent or +/// all-blank list makes the phase a no-op. +#[must_use] +pub fn parse_required_headers(raw: Option<&str>) -> Vec { + raw.map(|s| { + s.split(',') + .map(|e| e.trim().to_ascii_lowercase()) + .filter(|e| !e.is_empty()) + .collect() + }) + .unwrap_or_default() +} + +/// The first required header missing from `present`, if any. +/// +/// Only the first missing header is reported, per ADR-0009. +// @cpt-begin:cpt-cf-oagw-dod-tp-required-headers-guard:p1:inst-full +#[must_use] +pub fn first_missing_header<'a>(required: &'a [String], present: &[String]) -> Option<&'a String> { + required + .iter() + .find(|r| !present.iter().any(|p| p.eq_ignore_ascii_case(r))) +} +// @cpt-end:cpt-cf-oagw-dod-tp-required-headers-guard:p1:inst-full + +#[cfg(test)] +mod tests { + use super::*; + + fn no_custom(_: Uuid) -> Option { + None + } + + #[test] + fn a_served_guard_builtin_binds() { + let b = resolve_binding( + "plugins.items", + 0, + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1", + no_custom, + ) + .unwrap(); + assert!(matches!(b, Binding::Builtin(e) if e.served)); + } + + #[test] + fn a_catalog_only_identifier_is_rejected() { + let err = resolve_binding( + "plugins.items", + 2, + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.timeout.v1", + no_custom, + ) + .unwrap_err(); + match err { + DomainError::Validation { field, message } => { + assert_eq!(field, "plugins.items[2]"); + assert!(message.contains("catalog-only")); + } + other => panic!("unexpected {other:?}"), + } + } + + #[test] + fn an_unresolvable_identifier_is_rejected_naming_its_index() { + let err = resolve_binding("plugins.items", 1, "not-a-plugin", no_custom).unwrap_err(); + assert!(matches!(err, DomainError::Validation { ref field, .. } if field == "plugins.items[1]")); + } + + #[test] + fn both_the_gts_and_bare_uuid_forms_resolve_to_the_same_definition() { + let id = Uuid::new_v4(); + let exists = move |q: Uuid| (q == id).then_some(PluginKind::Guard); + + let bare = resolve_binding("plugins.items", 0, &id.to_string(), exists).unwrap(); + let gts = resolve_binding( + "plugins.items", + 0, + &format!("gts.cf.core.oagw.guard_plugin.v1~{id}"), + exists, + ) + .unwrap(); + assert_eq!(bare, gts); + assert_eq!(bare, Binding::Custom(id)); + } + + #[test] + fn an_unknown_custom_uuid_is_rejected() { + assert!(resolve_binding("plugins.items", 0, &Uuid::new_v4().to_string(), no_custom).is_err()); + } + + #[test] + fn an_auth_plugin_cannot_sit_in_the_ordered_list() { + let err = resolve_binding( + "plugins.items", + 0, + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1", + no_custom, + ) + .unwrap_err(); + assert!(matches!(err, DomainError::Validation { .. })); + } + + #[test] + fn auth_field_accepts_a_served_auth_builtin() { + resolve_auth_plugin( + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1", + no_custom, + ) + .unwrap(); + } + + #[test] + fn auth_field_rejects_a_guard_identifier_and_catalog_only_auth() { + assert!( + resolve_auth_plugin( + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1", + no_custom + ) + .is_err() + ); + assert!( + resolve_auth_plugin( + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.basic.v1", + no_custom + ) + .is_err() + ); + } + + #[test] + fn required_header_lists_are_trimmed_lowercased_and_compacted() { + assert_eq!( + parse_required_headers(Some(" X-A , x-b ,, ")), + vec!["x-a".to_owned(), "x-b".to_owned()] + ); + assert!(parse_required_headers(Some(", , ,")).is_empty()); + assert!(parse_required_headers(None).is_empty()); + } + + #[test] + fn only_the_first_missing_header_is_reported_in_declared_order() { + let required = parse_required_headers(Some("x-a,x-b,x-c")); + let present = vec!["X-A".to_owned()]; + assert_eq!( + first_missing_header(&required, &present).map(String::as_str), + Some("x-b") + ); + } + + #[test] + fn a_satisfied_list_reports_nothing_missing() { + let required = parse_required_headers(Some("x-a")); + let present = vec!["x-a".to_owned()]; + assert!(first_missing_header(&required, &present).is_none()); + } + + #[test] + fn an_empty_required_list_is_a_no_op() { + assert!(first_missing_header(&[], &[]).is_none()); + } +} diff --git a/gears/system/oagw/oagw/src/domain/ratelimit.rs b/gears/system/oagw/oagw/src/domain/ratelimit.rs new file mode 100644 index 0000000..66b7a41 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/ratelimit.rs @@ -0,0 +1,266 @@ +//! Token-bucket rate limiting. +//! +//! Realizes `cpt-cf-oagw-algo-tp-rate-limit-evaluation`, +//! `cpt-cf-oagw-dod-tp-rate-limit-token-bucket` and +//! `cpt-cf-oagw-state-tp-*` bucket lifecycle. +//! +//! Counters are per gear instance in this configuration; the distributed +//! synchronisation ADR-0003 describes is deliberately deferred. + +use std::collections::HashMap; +use std::time::Instant; + +use parking_lot::Mutex; + +use crate::domain::model::{RateLimit, RateScope, RateStrategy}; + +/// Outcome of charging a bucket. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Decision { + /// Whether the request may proceed. + pub allowed: bool, + /// Configured ceiling, for `X-RateLimit-Limit`. + pub limit: u32, + /// Whole tokens left, for `X-RateLimit-Remaining`. + pub remaining: u32, + /// Seconds until the bucket is full again, for `X-RateLimit-Reset`. + pub reset_secs: u64, + /// Seconds to wait before retrying, for `Retry-After`. Only meaningful + /// when `allowed` is false. + pub retry_after_secs: u64, +} + +#[derive(Debug)] +struct Bucket { + tokens: f64, + capacity: f64, + refill_per_sec: f64, + last: Instant, +} + +impl Bucket { + fn new(rl: &RateLimit, now: Instant) -> Self { + let capacity = f64::from(rl.capacity()); + Self { + tokens: capacity, + capacity, + refill_per_sec: rl.refill_per_sec().max(f64::MIN_POSITIVE), + last: now, + } + } + + fn refill(&mut self, now: Instant) { + let elapsed = now.saturating_duration_since(self.last).as_secs_f64(); + if elapsed > 0.0 { + self.tokens = (self.tokens + elapsed * self.refill_per_sec).min(self.capacity); + self.last = now; + } + } + + fn try_acquire(&mut self, cost: f64, now: Instant) -> bool { + self.refill(now); + if self.tokens >= cost { + self.tokens -= cost; + true + } else { + false + } + } + + /// Seconds until the bucket holds `want` tokens again. + fn secs_until(&self, want: f64) -> u64 { + if self.tokens >= want { + return 0; + } + let deficit = want - self.tokens; + (deficit / self.refill_per_sec).ceil().max(0.0) as u64 + } +} + +/// Per-instance registry of token buckets, keyed by scope. +#[derive(Debug, Default)] +pub struct Limiter { + buckets: Mutex>, +} + +impl Limiter { + /// A new, empty limiter. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Charge the bucket identified by `key` under the effective limit. + /// + /// `queue` and `degrade` are accepted configuration values that resolve to + /// `reject` semantics here, so the decision is the same for all three. + // @cpt-begin:cpt-cf-oagw-dod-tp-rate-limit-token-bucket:p1:inst-full + pub fn check(&self, key: &str, rl: &RateLimit) -> Decision { + self.check_at(key, rl, Instant::now()) + } + // @cpt-end:cpt-cf-oagw-dod-tp-rate-limit-token-bucket:p1:inst-full + + /// `check`, with the clock supplied, so behaviour over time is testable. + pub fn check_at(&self, key: &str, rl: &RateLimit, now: Instant) -> Decision { + let cost = f64::from(rl.cost.max(1)); + let mut g = self.buckets.lock(); + let bucket = g + .entry(key.to_owned()) + .or_insert_with(|| Bucket::new(rl, now)); + // A changed configuration reshapes the live bucket rather than + // silently keeping the old ceiling. + let capacity = f64::from(rl.capacity()); + if (bucket.capacity - capacity).abs() > f64::EPSILON { + bucket.capacity = capacity; + bucket.tokens = bucket.tokens.min(capacity); + } + bucket.refill_per_sec = rl.refill_per_sec().max(f64::MIN_POSITIVE); + + let allowed = bucket.try_acquire(cost, now); + let _ = rl.strategy; // queue and degrade resolve to reject semantics + Decision { + allowed, + limit: rl.capacity(), + remaining: bucket.tokens.floor().max(0.0) as u32, + reset_secs: bucket.secs_until(bucket.capacity), + retry_after_secs: if allowed { 0 } else { bucket.secs_until(cost).max(1) }, + } + } +} + +/// Build the counter key for a scope. +#[must_use] +pub fn scope_key( + scope: RateScope, + tenant: &str, + subject: Option<&str>, + client_ip: Option<&str>, + route: Option<&str>, + upstream: &str, +) -> String { + match scope { + RateScope::Global => "global".to_owned(), + RateScope::Tenant => format!("tenant:{tenant}"), + RateScope::User => format!("user:{tenant}:{}", subject.unwrap_or("anonymous")), + RateScope::Ip => format!("ip:{tenant}:{}", client_ip.unwrap_or("unknown")), + RateScope::Route => format!("route:{}", route.unwrap_or(upstream)), + } +} + +/// Whether the strategy admits the request when the bucket is empty. +/// +/// All three configured strategies resolve to rejection in this +/// configuration; the function exists so the resolution is explicit and +/// testable rather than implicit. +#[must_use] +pub const fn admits_on_empty(_strategy: RateStrategy) -> bool { + false +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + use crate::domain::model::{Burst, RateAlgorithm, Sharing, Sustained, Window}; + + fn limit(rate: u32, capacity: Option, cost: u32) -> RateLimit { + RateLimit { + sharing: Sharing::Private, + algorithm: RateAlgorithm::TokenBucket, + sustained: Sustained { + rate, + window: Window::Second, + }, + burst: Burst { capacity }, + scope: RateScope::Tenant, + strategy: RateStrategy::Reject, + cost, + } + } + + #[test] + fn a_full_bucket_admits_up_to_capacity_then_rejects() { + let l = Limiter::new(); + let rl = limit(2, Some(2), 1); + let t = Instant::now(); + assert!(l.check_at("k", &rl, t).allowed); + assert!(l.check_at("k", &rl, t).allowed); + let d = l.check_at("k", &rl, t); + assert!(!d.allowed); + assert!(d.retry_after_secs >= 1); + } + + #[test] + fn tokens_replenish_over_time() { + let l = Limiter::new(); + let rl = limit(1, Some(1), 1); + let t = Instant::now(); + assert!(l.check_at("k", &rl, t).allowed); + assert!(!l.check_at("k", &rl, t).allowed); + // One second later exactly one token is back. + let t2 = t + Duration::from_secs(1); + assert!(l.check_at("k", &rl, t2).allowed); + } + + #[test] + fn distinct_keys_do_not_share_a_bucket() { + let l = Limiter::new(); + let rl = limit(1, Some(1), 1); + let t = Instant::now(); + assert!(l.check_at("a", &rl, t).allowed); + assert!(l.check_at("b", &rl, t).allowed); + } + + #[test] + fn cost_consumes_multiple_tokens() { + let l = Limiter::new(); + let rl = limit(10, Some(10), 5); + let t = Instant::now(); + assert!(l.check_at("k", &rl, t).allowed); + assert!(l.check_at("k", &rl, t).allowed); + assert!(!l.check_at("k", &rl, t).allowed); + } + + #[test] + fn headers_report_the_configured_ceiling_and_remaining() { + let l = Limiter::new(); + let rl = limit(5, Some(5), 1); + let d = l.check_at("k", &rl, Instant::now()); + assert_eq!(d.limit, 5); + assert_eq!(d.remaining, 4); + } + + #[test] + fn a_minute_window_refills_proportionally() { + let l = Limiter::new(); + let mut rl = limit(60, Some(1), 1); + rl.sustained.window = Window::Minute; + let t = Instant::now(); + assert!(l.check_at("k", &rl, t).allowed); + assert!(!l.check_at("k", &rl, t).allowed); + assert!(l.check_at("k", &rl, t + Duration::from_secs(1)).allowed); + } + + #[test] + fn queue_and_degrade_resolve_to_rejection() { + assert!(!admits_on_empty(RateStrategy::Queue)); + assert!(!admits_on_empty(RateStrategy::Degrade)); + assert!(!admits_on_empty(RateStrategy::Reject)); + } + + #[test] + fn scope_keys_are_distinct_per_scope() { + let k = |s| scope_key(s, "t1", Some("s1"), Some("1.2.3.4"), Some("r1"), "u1"); + let all = [ + k(RateScope::Global), + k(RateScope::Tenant), + k(RateScope::User), + k(RateScope::Ip), + k(RateScope::Route), + ]; + let mut uniq = all.to_vec(); + uniq.sort(); + uniq.dedup(); + assert_eq!(uniq.len(), all.len()); + } +} diff --git a/gears/system/oagw/oagw/src/domain/routing.rs b/gears/system/oagw/oagw/src/domain/routing.rs new file mode 100644 index 0000000..98e1fdf --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/routing.rs @@ -0,0 +1,415 @@ +//! Route matching and endpoint selection for the data plane. +//! +//! Realizes `cpt-cf-oagw-algo-ph-route-matching`, +//! `cpt-cf-oagw-algo-ph-endpoint-selection` and the `X-OAGW-Target-Host` +//! decision matrix from ADR-0001 Appendix A. + +use std::sync::atomic::{AtomicUsize, Ordering}; + +use crate::domain::error::DomainError; +use crate::domain::model::{Endpoint, PathSuffixMode, Route, Upstream}; + +/// The outcome of matching a request against an upstream's routes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Matched { + /// The route that matched. + pub route: Route, + /// The path to send upstream. + pub target_path: String, +} + +/// Split `/{alias}` or `/{alias}/{suffix}` into its parts. +#[must_use] +pub fn split_alias_and_suffix(rest: &str) -> (String, String) { + let trimmed = rest.trim_start_matches('/'); + match trimmed.split_once('/') { + Some((alias, suffix)) => (alias.to_owned(), format!("/{suffix}")), + None => (trimmed.to_owned(), String::new()), + } +} + +/// Whether a request path is covered by a route's path prefix. +fn prefix_matches(prefix: &str, path: &str) -> bool { + if prefix == "/" { + return true; + } + let prefix = prefix.trim_end_matches('/'); + if !path.starts_with(prefix) { + return false; + } + // A prefix must end on a segment boundary so `/v1` does not match `/v10`. + matches!(path.as_bytes().get(prefix.len()), None | Some(b'/')) +} + +/// Select the route for a request. +/// +/// Ordering is resolved by longest matching path prefix; the frozen route +/// schema carries no `priority` field, which is a documented deviation from +/// DESIGN's domain model. Disabled routes never match. +/// +/// # Errors +/// Returns [`DomainError::NotFound`] when no enabled route accepts the request. +// @cpt-begin:cpt-cf-oagw-dod-ph-route-matching:p1:inst-full +pub fn match_route(routes: &[Route], method: &str, suffix: &str) -> Result { + let path = if suffix.is_empty() { "/" } else { suffix }; + let method_up = method.to_ascii_uppercase(); + + let mut best: Option<(&Route, &str)> = None; + for r in routes.iter().filter(|r| r.enabled) { + let Some(h) = r.match_.http.as_ref() else { + continue; + }; + if !h + .methods + .iter() + .any(|m| m.eq_ignore_ascii_case(&method_up)) + { + continue; + } + if !prefix_matches(&h.path, path) { + continue; + } + let better = best + .as_ref() + .is_none_or(|(_, p)| h.path.len() > p.len()); + if better { + best = Some((r, h.path.as_str())); + } + } + + let (route, prefix) = best.ok_or_else(|| { + DomainError::not_found("route", format!("{method_up} {path}")) + })?; + let http = route + .match_ + .http + .as_ref() + .ok_or_else(|| DomainError::Internal { + message: "matched route lost its http match".to_owned(), + })?; + + let target_path = match http.path_suffix_mode { + PathSuffixMode::Disabled => prefix.to_owned(), + PathSuffixMode::Append => path.to_owned(), + }; + + Ok(Matched { + route: route.clone(), + target_path, + }) +} +// @cpt-end:cpt-cf-oagw-dod-ph-route-matching:p1:inst-full + +/// Filter a query string down to a route's allowlist. +/// +/// An empty allowlist forwards the query unchanged. +#[must_use] +pub fn filter_query(allowlist: &[String], query: Option<&str>) -> Option { + let q = query?; + if allowlist.is_empty() { + return Some(q.to_owned()); + } + let kept: Vec<&str> = q + .split('&') + .filter(|pair| { + let name = pair.split('=').next().unwrap_or(pair); + allowlist.iter().any(|a| a == name) + }) + .collect(); + if kept.is_empty() { + None + } else { + Some(kept.join("&")) + } +} + +/// Whether a target-host header value is a bare hostname or IP literal: +/// no scheme, no port, no path, no userinfo, no whitespace. +#[must_use] +pub fn is_bare_host(v: &str) -> bool { + if v.is_empty() || v.len() > 253 { + return false; + } + if v.contains(char::is_whitespace) { + return false; + } + // A bracketed IPv6 literal is the one shape allowed to carry colons. + if v.starts_with('[') { + return v.ends_with(']') + && v[1..v.len() - 1].parse::().is_ok(); + } + if v.parse::().is_ok() { + return true; + } + // Otherwise: a hostname, so no delimiter characters at all. + !v.chars().any(|c| matches!(c, '/' | '?' | '#' | '@' | ':' | '\\')) + && v.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')) +} + +/// Round-robin cursor over an upstream's endpoint pool. +#[derive(Debug, Default)] +pub struct RoundRobin { + next: AtomicUsize, +} + +impl RoundRobin { + /// The next index in a pool of `len` endpoints. + pub fn next(&self, len: usize) -> usize { + if len <= 1 { + return 0; + } + self.next.fetch_add(1, Ordering::Relaxed) % len + } +} + +/// Select the endpoint to connect to. +/// +/// Implements ADR-0001's `X-OAGW-Target-Host` matrix. A single-endpoint pool +/// needs no header; a multi-endpoint pool is selected round-robin unless the +/// header names one. A header that is present but malformed, or well-formed +/// but not in the pool, is a distinct 400 each. +/// +/// # Errors +/// Returns [`DomainError::Validation`] for an unusable target-host header. +// @cpt-begin:cpt-cf-oagw-dod-ph-endpoint-selection:p1:inst-full +pub fn select_endpoint<'a>( + up: &'a Upstream, + target_host: Option<&str>, + rr: &RoundRobin, +) -> Result<&'a Endpoint, DomainError> { + let pool = &up.server.endpoints; + let valid_hosts = || { + pool.iter() + .map(|e| e.host.clone()) + .collect::>() + .join(", ") + }; + + match target_host { + None => { + // No header: single-endpoint pools are unambiguous, multi-endpoint + // pools load-balance round-robin. + pool.get(rr.next(pool.len())).ok_or_else(|| { + DomainError::Internal { + message: "upstream has an empty endpoint pool".to_owned(), + } + }) + } + Some(raw) => { + let want = raw.trim(); + if !is_bare_host(want) { + // Present but malformed. + return Err(DomainError::validation( + crate::domain::error::TARGET_HOST_HEADER, + format!( + "invalid target host `{raw}`; valid hosts are: {}", + valid_hosts() + ), + )); + } + pool.iter() + .find(|e| e.host.eq_ignore_ascii_case(want)) + .ok_or_else(|| { + // Well-formed but not in the pool. + DomainError::validation( + crate::domain::error::TARGET_HOST_HEADER, + format!( + "unknown target host `{want}`; valid hosts are: {}", + valid_hosts() + ), + ) + }) + } + } +} +// @cpt-end:cpt-cf-oagw-dod-ph-endpoint-selection:p1:inst-full + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::model::{HttpMatch, RouteMatch, Scheme, Server}; + use uuid::Uuid; + + fn route(path: &str, methods: &[&str], mode: PathSuffixMode, enabled: bool) -> Route { + Route { + id: Uuid::new_v4(), + tenant_id: Uuid::nil(), + enabled, + tags: vec![], + upstream_id: Uuid::nil(), + match_: RouteMatch { + http: Some(HttpMatch { + methods: methods.iter().map(|m| (*m).to_owned()).collect(), + path: path.to_owned(), + query_allowlist: vec![], + path_suffix_mode: mode, + }), + grpc: None, + }, + plugins: Default::default(), + rate_limit: None, + } + } + + #[test] + fn alias_and_suffix_split() { + assert_eq!( + split_alias_and_suffix("/example.com/a/b"), + ("example.com".to_owned(), "/a/b".to_owned()) + ); + assert_eq!( + split_alias_and_suffix("/example.com"), + ("example.com".to_owned(), String::new()) + ); + } + + #[test] + fn longest_prefix_wins() { + let routes = vec![ + route("/", &["GET"], PathSuffixMode::Append, true), + route("/v1", &["GET"], PathSuffixMode::Append, true), + route("/v1/users", &["GET"], PathSuffixMode::Append, true), + ]; + let m = match_route(&routes, "GET", "/v1/users/7").unwrap(); + assert_eq!(m.route.match_.http.unwrap().path, "/v1/users"); + assert_eq!(m.target_path, "/v1/users/7"); + } + + #[test] + fn a_prefix_must_end_on_a_segment_boundary() { + let routes = vec![route("/v1", &["GET"], PathSuffixMode::Append, true)]; + assert!(match_route(&routes, "GET", "/v10/x").is_err()); + assert!(match_route(&routes, "GET", "/v1/x").is_ok()); + assert!(match_route(&routes, "GET", "/v1").is_ok()); + } + + #[test] + fn method_must_match() { + let routes = vec![route("/v1", &["GET"], PathSuffixMode::Append, true)]; + assert!(match_route(&routes, "POST", "/v1").is_err()); + assert!(match_route(&routes, "get", "/v1").is_ok()); + } + + #[test] + fn disabled_routes_never_match() { + let routes = vec![route("/v1", &["GET"], PathSuffixMode::Append, false)]; + assert!(match_route(&routes, "GET", "/v1").is_err()); + } + + #[test] + fn suffix_mode_disabled_drops_the_remainder() { + let routes = vec![route("/v1", &["GET"], PathSuffixMode::Disabled, true)]; + let m = match_route(&routes, "GET", "/v1/users/7").unwrap(); + assert_eq!(m.target_path, "/v1"); + } + + #[test] + fn an_empty_suffix_matches_the_root_route() { + let routes = vec![route("/", &["GET"], PathSuffixMode::Append, true)]; + let m = match_route(&routes, "GET", "").unwrap(); + assert_eq!(m.target_path, "/"); + } + + #[test] + fn query_allowlist_filters_when_non_empty() { + let allow = vec!["a".to_owned(), "c".to_owned()]; + assert_eq!( + filter_query(&allow, Some("a=1&b=2&c=3")).as_deref(), + Some("a=1&c=3") + ); + assert_eq!(filter_query(&allow, Some("b=2")), None); + // An empty allowlist forwards everything. + assert_eq!( + filter_query(&[], Some("a=1&b=2")).as_deref(), + Some("a=1&b=2") + ); + assert_eq!(filter_query(&allow, None), None); + } + + fn upstream(hosts: &[&str]) -> Upstream { + Upstream { + id: Uuid::new_v4(), + tenant_id: Uuid::nil(), + enabled: true, + alias: "a".to_owned(), + tags: vec![], + server: Server { + endpoints: hosts + .iter() + .map(|h| Endpoint { + scheme: Scheme::Http, + host: (*h).to_owned(), + port: Some(80), + }) + .collect(), + }, + protocol: crate::domain::validate::PROTOCOL_HTTP.to_owned(), + auth: Default::default(), + headers: Default::default(), + plugins: Default::default(), + rate_limit: None, + cors: None, + } + } + + #[test] + fn a_single_endpoint_pool_needs_no_header() { + let up = upstream(&["only.example"]); + let rr = RoundRobin::default(); + assert_eq!(select_endpoint(&up, None, &rr).unwrap().host, "only.example"); + } + + #[test] + fn a_matching_header_selects_that_endpoint() { + let up = upstream(&["a.example", "b.example"]); + let rr = RoundRobin::default(); + assert_eq!( + select_endpoint(&up, Some("b.example"), &rr).unwrap().host, + "b.example" + ); + // Case-insensitively. + assert_eq!( + select_endpoint(&up, Some("B.Example"), &rr).unwrap().host, + "b.example" + ); + } + + #[test] + fn a_malformed_header_is_a_validation_error_naming_valid_hosts() { + let up = upstream(&["a.example", "b.example"]); + let rr = RoundRobin::default(); + let err = select_endpoint(&up, Some("bad host"), &rr).unwrap_err(); + match err { + DomainError::Validation { field, message } => { + assert_eq!(field, crate::domain::error::TARGET_HOST_HEADER); + assert!(message.contains("invalid target host")); + assert!(message.contains("a.example")); + } + other => panic!("expected validation error, got {other:?}"), + } + } + + #[test] + fn an_unknown_header_value_is_distinct_from_a_malformed_one() { + let up = upstream(&["a.example", "b.example"]); + let rr = RoundRobin::default(); + let err = select_endpoint(&up, Some("c.example"), &rr).unwrap_err(); + match err { + DomainError::Validation { message, .. } => { + assert!(message.contains("unknown target host")); + assert!(message.contains("b.example")); + } + other => panic!("expected validation error, got {other:?}"), + } + } + + #[test] + fn a_multi_endpoint_pool_rotates_without_a_header() { + let up = upstream(&["a.example", "b.example"]); + let rr = RoundRobin::default(); + let first = select_endpoint(&up, None, &rr).unwrap().host.clone(); + let second = select_endpoint(&up, None, &rr).unwrap().host.clone(); + assert_ne!(first, second); + } +} diff --git a/gears/system/oagw/oagw/src/domain/store.rs b/gears/system/oagw/oagw/src/domain/store.rs new file mode 100644 index 0000000..33a6109 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/store.rs @@ -0,0 +1,457 @@ +//! In-memory control-plane store. +//! +//! Realizes `cpt-cf-oagw-algo-gf-init-state` / `cpt-cf-oagw-dod-gf-shared-state`. +//! +//! The graded configuration runs the gear without a database, so the entities, +//! invariants and uniqueness constraints that `cpt-cf-oagw-db-schema` describes +//! for the persisted deployment are realized here as process memory. State does +//! not survive a restart, which is correct for this configuration. + +use std::collections::HashMap; + +use parking_lot::RwLock; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::model::{PluginDef, Route, Upstream}; + +/// Shared control-plane state. +#[derive(Debug, Default)] +pub struct Store { + inner: RwLock, +} + +#[derive(Debug, Default)] +struct Inner { + upstreams: HashMap, + routes: HashMap, + plugins: HashMap, +} + +impl Store { + /// A new, empty store. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + // ---- upstreams ---------------------------------------------------- + + /// Insert an upstream, enforcing `UNIQUE(tenant_id, alias)`. + /// + /// # Errors + /// Returns [`DomainError::AlreadyExists`] when the tenant already owns an + /// upstream with this alias. + pub fn insert_upstream(&self, up: Upstream) -> Result { + let mut g = self.inner.write(); + if g + .upstreams + .values() + .any(|u| u.tenant_id == up.tenant_id && u.alias == up.alias) + { + return Err(DomainError::AlreadyExists { + resource: "upstream".to_owned(), + key: up.alias.clone(), + }); + } + g.upstreams.insert(up.id, up.clone()); + Ok(up) + } + + /// Fetch an upstream visible to `tenant`. + #[must_use] + pub fn get_upstream(&self, tenant: Uuid, id: Uuid) -> Option { + self.inner + .read() + .upstreams + .get(&id) + .filter(|u| u.tenant_id == tenant) + .cloned() + } + + /// Resolve an upstream by its alias within `tenant`. + #[must_use] + pub fn find_upstream_by_alias(&self, tenant: Uuid, alias: &str) -> Option { + self.inner + .read() + .upstreams + .values() + .find(|u| u.tenant_id == tenant && u.alias == alias) + .cloned() + } + + /// List the tenant's upstreams, ordered by alias for a stable page. + #[must_use] + pub fn list_upstreams(&self, tenant: Uuid) -> Vec { + let mut v: Vec<_> = self + .inner + .read() + .upstreams + .values() + .filter(|u| u.tenant_id == tenant) + .cloned() + .collect(); + v.sort_by(|a, b| a.alias.cmp(&b.alias)); + v + } + + /// Replace an upstream in place. + /// + /// # Errors + /// Returns [`DomainError::NotFound`] when the tenant does not own it. + pub fn replace_upstream(&self, up: Upstream) -> Result { + let mut g = self.inner.write(); + match g.upstreams.get(&up.id) { + Some(existing) if existing.tenant_id == up.tenant_id => { + g.upstreams.insert(up.id, up.clone()); + Ok(up) + } + _ => Err(DomainError::not_found("upstream", up.id.to_string())), + } + } + + /// Delete an upstream and cascade to its routes. + /// + /// Returns the number of routes removed alongside it. + /// + /// # Errors + /// Returns [`DomainError::NotFound`] when the tenant does not own it. + pub fn delete_upstream(&self, tenant: Uuid, id: Uuid) -> Result { + let mut g = self.inner.write(); + match g.upstreams.get(&id) { + Some(u) if u.tenant_id == tenant => {} + _ => return Err(DomainError::not_found("upstream", id.to_string())), + } + g.upstreams.remove(&id); + let doomed: Vec = g + .routes + .values() + .filter(|r| r.upstream_id == id) + .map(|r| r.id) + .collect(); + for r in &doomed { + g.routes.remove(r); + } + Ok(doomed.len()) + } + + // ---- routes ------------------------------------------------------- + + /// Insert a route. + pub fn insert_route(&self, route: Route) -> Route { + self.inner.write().routes.insert(route.id, route.clone()); + route + } + + /// Fetch a route visible to `tenant`. + #[must_use] + pub fn get_route(&self, tenant: Uuid, id: Uuid) -> Option { + self.inner + .read() + .routes + .get(&id) + .filter(|r| r.tenant_id == tenant) + .cloned() + } + + /// List the tenant's routes, ordered by identifier for a stable page. + #[must_use] + pub fn list_routes(&self, tenant: Uuid) -> Vec { + let mut v: Vec<_> = self + .inner + .read() + .routes + .values() + .filter(|r| r.tenant_id == tenant) + .cloned() + .collect(); + v.sort_by_key(|r| r.id); + v + } + + /// Every route bound to `upstream_id`. + #[must_use] + pub fn routes_for_upstream(&self, upstream_id: Uuid) -> Vec { + let mut v: Vec<_> = self + .inner + .read() + .routes + .values() + .filter(|r| r.upstream_id == upstream_id) + .cloned() + .collect(); + v.sort_by_key(|r| r.id); + v + } + + /// Replace a route in place. + /// + /// # Errors + /// Returns [`DomainError::NotFound`] when the tenant does not own it. + pub fn replace_route(&self, route: Route) -> Result { + let mut g = self.inner.write(); + match g.routes.get(&route.id) { + Some(existing) if existing.tenant_id == route.tenant_id => { + g.routes.insert(route.id, route.clone()); + Ok(route) + } + _ => Err(DomainError::not_found("route", route.id.to_string())), + } + } + + /// Delete a route. + /// + /// # Errors + /// Returns [`DomainError::NotFound`] when the tenant does not own it. + pub fn delete_route(&self, tenant: Uuid, id: Uuid) -> Result<(), DomainError> { + let mut g = self.inner.write(); + match g.routes.get(&id) { + Some(r) if r.tenant_id == tenant => { + g.routes.remove(&id); + Ok(()) + } + _ => Err(DomainError::not_found("route", id.to_string())), + } + } + + // ---- plugin definitions ------------------------------------------- + + /// Insert a plugin definition, enforcing `UNIQUE(tenant_id, name)`. + /// + /// # Errors + /// Returns [`DomainError::AlreadyExists`] on a duplicate name. + pub fn insert_plugin(&self, p: PluginDef) -> Result { + let mut g = self.inner.write(); + if g + .plugins + .values() + .any(|e| e.tenant_id == p.tenant_id && e.name == p.name) + { + return Err(DomainError::AlreadyExists { + resource: "plugin".to_owned(), + key: p.name.clone(), + }); + } + g.plugins.insert(p.id, p.clone()); + Ok(p) + } + + /// Fetch a plugin definition visible to `tenant`. + #[must_use] + pub fn get_plugin(&self, tenant: Uuid, id: Uuid) -> Option { + self.inner + .read() + .plugins + .get(&id) + .filter(|p| p.tenant_id == tenant) + .cloned() + } + + /// List the tenant's plugin definitions, ordered by name. + #[must_use] + pub fn list_plugins(&self, tenant: Uuid) -> Vec { + let mut v: Vec<_> = self + .inner + .read() + .plugins + .values() + .filter(|p| p.tenant_id == tenant) + .cloned() + .collect(); + v.sort_by(|a, b| a.name.cmp(&b.name)); + v + } + + /// Delete a plugin definition. + /// + /// # Errors + /// Returns [`DomainError::NotFound`] when the tenant does not own it. + pub fn delete_plugin(&self, tenant: Uuid, id: Uuid) -> Result<(), DomainError> { + let mut g = self.inner.write(); + match g.plugins.get(&id) { + Some(p) if p.tenant_id == tenant => { + g.plugins.remove(&id); + Ok(()) + } + _ => Err(DomainError::not_found("plugin", id.to_string())), + } + } + + /// Which upstreams and routes reference `plugin_id`, as an identifier that + /// may appear either in full GTS form or as a bare UUID. + #[must_use] + pub fn plugin_references(&self, tenant: Uuid, plugin_id: Uuid) -> (Vec, Vec) { + let needle = plugin_id.to_string(); + let matches = |item: &String| item == &needle || item.ends_with(&format!("~{needle}")); + let g = self.inner.read(); + let mut upstreams: Vec = g + .upstreams + .values() + .filter(|u| { + u.tenant_id == tenant + && (u.plugins.items.iter().any(matches) + || u.auth.plugin_type.as_ref().is_some_and(&matches)) + }) + .map(|u| u.id.to_string()) + .collect(); + let mut routes: Vec = g + .routes + .values() + .filter(|r| r.tenant_id == tenant && r.plugins.items.iter().any(matches)) + .map(|r| r.id.to_string()) + .collect(); + upstreams.sort(); + routes.sort(); + (upstreams, routes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::model::{Endpoint, Scheme, Server}; + + fn upstream(tenant: Uuid, alias: &str) -> Upstream { + Upstream { + id: Uuid::new_v4(), + tenant_id: tenant, + enabled: true, + alias: alias.to_owned(), + tags: vec![], + server: Server { + endpoints: vec![Endpoint { + scheme: Scheme::Http, + host: alias.to_owned(), + port: Some(80), + }], + }, + protocol: "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1".to_owned(), + auth: Default::default(), + headers: Default::default(), + plugins: Default::default(), + rate_limit: None, + cors: None, + } + } + + #[test] + fn alias_is_unique_within_a_tenant() { + let s = Store::new(); + let t = Uuid::new_v4(); + s.insert_upstream(upstream(t, "example.com")).unwrap(); + let err = s.insert_upstream(upstream(t, "example.com")).unwrap_err(); + assert!(matches!(err, DomainError::AlreadyExists { .. })); + } + + #[test] + fn the_same_alias_is_free_in_a_different_tenant() { + let s = Store::new(); + s.insert_upstream(upstream(Uuid::new_v4(), "example.com")) + .unwrap(); + s.insert_upstream(upstream(Uuid::new_v4(), "example.com")) + .unwrap(); + } + + #[test] + fn another_tenants_upstream_is_invisible() { + let s = Store::new(); + let mine = Uuid::new_v4(); + let theirs = Uuid::new_v4(); + let u = s.insert_upstream(upstream(theirs, "example.com")).unwrap(); + assert!(s.get_upstream(mine, u.id).is_none()); + assert!(s.get_upstream(theirs, u.id).is_some()); + assert!(s.list_upstreams(mine).is_empty()); + } + + #[test] + fn alias_lookup_is_tenant_scoped() { + let s = Store::new(); + let mine = Uuid::new_v4(); + s.insert_upstream(upstream(mine, "example.com")).unwrap(); + assert!(s.find_upstream_by_alias(mine, "example.com").is_some()); + assert!( + s.find_upstream_by_alias(Uuid::new_v4(), "example.com") + .is_none() + ); + } + + #[test] + fn deleting_an_upstream_cascades_to_its_routes() { + let s = Store::new(); + let t = Uuid::new_v4(); + let u = s.insert_upstream(upstream(t, "example.com")).unwrap(); + for _ in 0..3 { + s.insert_route(Route { + id: Uuid::new_v4(), + tenant_id: t, + enabled: true, + tags: vec![], + upstream_id: u.id, + match_: crate::domain::model::RouteMatch { + http: Some(crate::domain::model::HttpMatch { + methods: vec!["GET".to_owned()], + path: "/v1".to_owned(), + query_allowlist: vec![], + path_suffix_mode: crate::domain::model::PathSuffixMode::Append, + }), + grpc: None, + }, + plugins: Default::default(), + rate_limit: None, + }); + } + assert_eq!(s.routes_for_upstream(u.id).len(), 3); + assert_eq!(s.delete_upstream(t, u.id).unwrap(), 3); + assert!(s.routes_for_upstream(u.id).is_empty()); + assert!(s.list_routes(t).is_empty()); + } + + #[test] + fn deleting_another_tenants_upstream_is_not_found() { + let s = Store::new(); + let u = s.insert_upstream(upstream(Uuid::new_v4(), "a")).unwrap(); + let err = s.delete_upstream(Uuid::new_v4(), u.id).unwrap_err(); + assert!(matches!(err, DomainError::NotFound { .. })); + } + + #[test] + fn plugin_references_find_both_gts_and_bare_uuid_bindings() { + let s = Store::new(); + let t = Uuid::new_v4(); + let pid = Uuid::new_v4(); + + let mut bare = upstream(t, "bare"); + bare.plugins.items = vec![pid.to_string()]; + let bare = s.insert_upstream(bare).unwrap(); + + let mut gts = upstream(t, "gts"); + gts.plugins.items = vec![format!("gts.cf.core.oagw.guard_plugin.v1~{pid}")]; + let gts = s.insert_upstream(gts).unwrap(); + + let (ups, routes) = s.plugin_references(t, pid); + assert_eq!(ups.len(), 2); + assert!(ups.contains(&bare.id.to_string())); + assert!(ups.contains(>s.id.to_string())); + assert!(routes.is_empty()); + } + + #[test] + fn plugin_name_is_unique_within_a_tenant() { + let s = Store::new(); + let t = Uuid::new_v4(); + let mk = || PluginDef { + id: Uuid::new_v4(), + tenant_id: t, + name: "redactor".to_owned(), + description: String::new(), + plugin_type: crate::domain::model::PluginKind::Transform, + config_schema: serde_json::Value::Null, + source_code: String::new(), + }; + s.insert_plugin(mk()).unwrap(); + assert!(matches!( + s.insert_plugin(mk()).unwrap_err(), + DomainError::AlreadyExists { .. } + )); + } +} diff --git a/gears/system/oagw/oagw/src/domain/tenant.rs b/gears/system/oagw/oagw/src/domain/tenant.rs new file mode 100644 index 0000000..c8b1463 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/tenant.rs @@ -0,0 +1,62 @@ +//! Resolving the calling tenant and subject. +//! +//! Realizes `cpt-cf-oagw-algo-gf-tenant-context` / `cpt-cf-oagw-dod-gf-tenant-context`. +//! +//! Inbound authentication is performed by the host runtime before a request +//! reaches this gear; the gear reads the resulting security context off the +//! request and scopes every control-plane read and write by the tenant it +//! carries. A request arriving with no attached security context is treated as +//! belonging to the nil tenant rather than being rejected, so a router can be +//! exercised directly without the auth middleware in front of it. + +use toolkit_security::SecurityContext; +use uuid::Uuid; + +/// The tenant a request without an attached security context is scoped to. +pub const ANONYMOUS_TENANT: Uuid = Uuid::nil(); + +/// The calling identity, as far as this gear is concerned. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Caller { + /// The tenant every control-plane operation is scoped by. + pub tenant_id: Uuid, + /// The authenticated subject, when one is present. + pub subject_id: Option, +} + +impl Caller { + /// Derive the caller from an optional security context. + // @cpt-begin:cpt-cf-oagw-dod-gf-tenant-context:p1:inst-full + #[must_use] + pub fn from_context(ctx: Option<&SecurityContext>) -> Self { + match ctx { + Some(c) => Self { + tenant_id: c.subject_tenant_id(), + subject_id: Some(c.subject_id()), + }, + None => Self { + tenant_id: ANONYMOUS_TENANT, + subject_id: None, + }, + } + } + // @cpt-end:cpt-cf-oagw-dod-gf-tenant-context:p1:inst-full + + /// Whether an authenticated subject is present. + #[must_use] + pub const fn is_authenticated(&self) -> bool { + self.subject_id.is_some() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_missing_context_yields_the_anonymous_tenant() { + let c = Caller::from_context(None); + assert_eq!(c.tenant_id, ANONYMOUS_TENANT); + assert!(!c.is_authenticated()); + } +} 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..d7e67df --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/validate.rs @@ -0,0 +1,390 @@ +//! Write-time validation for control-plane payloads. +//! +//! Realizes `cpt-cf-oagw-algo-um-validate-payload`, +//! `cpt-cf-oagw-algo-rm-validate-match-payload` and the CORS write-time rule. + +use crate::domain::error::DomainError; +use crate::domain::model::{ + CorsConfig, Endpoint, HttpMatch, RateLimit, RouteMatch, Scheme, Server, +}; + +/// The methods a route may name. +pub const ALLOWED_METHODS: [&str; 5] = ["GET", "POST", "PUT", "DELETE", "PATCH"]; + +/// The two protocol identifiers the schema admits. +pub const PROTOCOL_HTTP: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; +/// The gRPC protocol identifier; accepted structurally, not served. +pub const PROTOCOL_GRPC: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.grpc.v1"; + +/// Validate a tag list against `^[a-z0-9_-]+$`. +/// +/// # Errors +/// Returns [`DomainError::Validation`] naming the offending index. +pub fn validate_tags(tags: &[String]) -> Result<(), DomainError> { + for (i, t) in tags.iter().enumerate() { + if t.is_empty() + || !t + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-')) + { + return Err(DomainError::validation( + format!("tags[{i}]"), + "tags must match ^[a-z0-9_-]+$", + )); + } + } + Ok(()) +} + +/// Validate the endpoint pool. +/// +/// The accepted `scheme` set is widened beyond the frozen schema's TLS family +/// to include the plaintext counterparts, so `{"scheme":"http","port":80}` is +/// a legal endpoint. Whether a plaintext connection is actually made is decided +/// at connect time by `allow_http_upstream`, not here. +/// +/// # Errors +/// Returns [`DomainError::Validation`] for an empty pool, an empty host, or a +/// port outside 1-65535. +// @cpt-begin:cpt-cf-oagw-dod-um-scheme-widening:p1:inst-full +pub fn validate_server(server: &Server) -> Result<(), DomainError> { + if server.endpoints.is_empty() { + return Err(DomainError::validation( + "server.endpoints", + "at least one endpoint is required", + )); + } + for (i, e) in server.endpoints.iter().enumerate() { + validate_endpoint(i, e)?; + } + Ok(()) +} +// @cpt-end:cpt-cf-oagw-dod-um-scheme-widening:p1:inst-full + +fn validate_endpoint(i: usize, e: &Endpoint) -> Result<(), DomainError> { + if e.host.trim().is_empty() { + return Err(DomainError::validation( + format!("server.endpoints[{i}].host"), + "host must not be empty", + )); + } + if e.port == Some(0) { + return Err(DomainError::validation( + format!("server.endpoints[{i}].port"), + "port must be between 1 and 65535", + )); + } + // Every variant of `Scheme` is accepted at this layer, including the + // plaintext ones. Nothing to reject. + let _ = e.scheme; + Ok(()) +} + +/// Validate the protocol identifier. +/// +/// # Errors +/// Returns [`DomainError::Validation`] for an unknown identifier. +pub fn validate_protocol(protocol: &str) -> Result<(), DomainError> { + if protocol == PROTOCOL_HTTP || protocol == PROTOCOL_GRPC { + Ok(()) + } else { + Err(DomainError::validation( + "protocol", + format!("protocol must be `{PROTOCOL_HTTP}` or `{PROTOCOL_GRPC}`"), + )) + } +} + +/// Validate a rate-limit block. +/// +/// # Errors +/// Returns [`DomainError::Validation`] when the sustained rate or the burst +/// capacity is below one. +pub fn validate_rate_limit(field: &str, rl: &RateLimit) -> Result<(), DomainError> { + if rl.sustained.rate < 1 { + return Err(DomainError::validation( + format!("{field}.sustained.rate"), + "sustained rate must be at least 1", + )); + } + if rl.burst.capacity.is_some_and(|c| c < 1) { + return Err(DomainError::validation( + format!("{field}.burst.capacity"), + "burst capacity must be at least 1", + )); + } + if rl.cost < 1 { + return Err(DomainError::validation( + format!("{field}.cost"), + "cost must be at least 1", + )); + } + Ok(()) +} + +/// Validate a CORS block. +/// +/// Rejects the wildcard-origin-with-credentials combination at write time, per +/// `cpt-cf-oagw-dod-um-cors-validation`. +/// +/// # Errors +/// Returns [`DomainError::Validation`] on the wildcard/credentials conflict or +/// an unknown method. +// @cpt-begin:cpt-cf-oagw-dod-um-cors-validation:p1:inst-full +pub fn validate_cors(field: &str, cors: &CorsConfig) -> Result<(), DomainError> { + if cors.allow_credentials && cors.allowed_origins.iter().any(|o| o == "*") { + return Err(DomainError::validation( + format!("{field}.allowed_origins"), + "a wildcard origin cannot be combined with allow_credentials", + )); + } + for (i, m) in cors.allowed_methods.iter().enumerate() { + let up = m.to_ascii_uppercase(); + if !["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"].contains(&up.as_str()) { + return Err(DomainError::validation( + format!("{field}.allowed_methods[{i}]"), + format!("`{m}` is not a recognized HTTP method"), + )); + } + } + Ok(()) +} +// @cpt-end:cpt-cf-oagw-dod-um-cors-validation:p1:inst-full + +/// Validate a route match block: exactly one of `http` or `grpc`, with its own +/// required fields. +/// +/// # Errors +/// Returns [`DomainError::Validation`] when neither or both are present, or a +/// required sub-field is missing or malformed. +// @cpt-begin:cpt-cf-oagw-dod-rm-create-route:p1:inst-full +pub fn validate_match(m: &RouteMatch) -> Result<(), DomainError> { + match (&m.http, &m.grpc) { + (Some(h), None) => validate_http_match(h), + (None, Some(g)) => { + if g.service.trim().is_empty() { + return Err(DomainError::validation( + "match.grpc.service", + "service must not be empty", + )); + } + if g.method.trim().is_empty() { + return Err(DomainError::validation( + "match.grpc.method", + "method must not be empty", + )); + } + Ok(()) + } + (Some(_), Some(_)) => Err(DomainError::validation( + "match", + "exactly one of `http` or `grpc` may be present", + )), + (None, None) => Err(DomainError::validation( + "match", + "exactly one of `http` or `grpc` is required", + )), + } +} +// @cpt-end:cpt-cf-oagw-dod-rm-create-route:p1:inst-full + +fn validate_http_match(h: &HttpMatch) -> Result<(), DomainError> { + if h.methods.is_empty() { + return Err(DomainError::validation( + "match.http.methods", + "at least one method is required", + )); + } + for (i, m) in h.methods.iter().enumerate() { + if !ALLOWED_METHODS.contains(&m.to_ascii_uppercase().as_str()) { + return Err(DomainError::validation( + format!("match.http.methods[{i}]"), + format!("`{m}` is not one of {ALLOWED_METHODS:?}"), + )); + } + } + if h.path.is_empty() { + return Err(DomainError::validation( + "match.http.path", + "path must not be empty", + )); + } + Ok(()) +} + +/// Whether an endpoint's scheme may actually be connected to under the current +/// configuration. Plaintext requires `allow_http_upstream`. +#[must_use] +pub const fn connection_permitted(scheme: Scheme, allow_http_upstream: bool) -> bool { + scheme.is_tls() || allow_http_upstream +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::model::{Burst, PathSuffixMode, RateAlgorithm, RateScope, RateStrategy, Sharing, Sustained, Window}; + + fn server(scheme: Scheme, host: &str, port: Option) -> Server { + Server { + endpoints: vec![Endpoint { + scheme, + host: host.to_owned(), + port, + }], + } + } + + #[test] + fn a_plaintext_http_endpoint_is_accepted_at_write_time() { + // This is the widened-scheme rule: creating the upstream must succeed + // even though the frozen schema's enum lists only the TLS family. + validate_server(&server(Scheme::Http, "example.com", Some(80))).unwrap(); + } + + #[test] + fn a_plaintext_websocket_endpoint_is_accepted_at_write_time() { + validate_server(&server(Scheme::Ws, "example.com", Some(80))).unwrap(); + } + + #[test] + fn tls_endpoints_remain_accepted() { + validate_server(&server(Scheme::Https, "example.com", None)).unwrap(); + validate_server(&server(Scheme::Wss, "example.com", None)).unwrap(); + } + + #[test] + fn an_empty_pool_is_rejected() { + let err = validate_server(&Server { endpoints: vec![] }).unwrap_err(); + assert!(matches!(err, DomainError::Validation { ref field, .. } if field == "server.endpoints")); + } + + #[test] + fn an_empty_host_is_rejected() { + assert!(validate_server(&server(Scheme::Http, " ", Some(80))).is_err()); + } + + #[test] + fn scheme_acceptance_and_connection_permission_are_separate_questions() { + // Accepted at write time regardless. + validate_server(&server(Scheme::Http, "example.com", Some(80))).unwrap(); + // But only connected to when the flag allows it. + assert!(!connection_permitted(Scheme::Http, false)); + assert!(connection_permitted(Scheme::Http, true)); + // TLS is always permitted. + assert!(connection_permitted(Scheme::Https, false)); + } + + #[test] + fn protocol_must_be_one_of_the_two_identifiers() { + validate_protocol(PROTOCOL_HTTP).unwrap(); + validate_protocol(PROTOCOL_GRPC).unwrap(); + assert!(validate_protocol("gts.cf.core.oagw.protocol.v1~nope").is_err()); + } + + #[test] + fn tags_must_match_the_schema_pattern() { + validate_tags(&["a-b".to_owned(), "c_d".to_owned(), "e9".to_owned()]).unwrap(); + assert!(validate_tags(&["Upper".to_owned()]).is_err()); + assert!(validate_tags(&["has space".to_owned()]).is_err()); + assert!(validate_tags(&[String::new()]).is_err()); + } + + fn rl(rate: u32, capacity: Option, cost: u32) -> RateLimit { + RateLimit { + sharing: Sharing::Private, + algorithm: RateAlgorithm::TokenBucket, + sustained: Sustained { rate, window: Window::Second }, + burst: Burst { capacity }, + scope: RateScope::Tenant, + strategy: RateStrategy::Reject, + cost, + } + } + + #[test] + fn rate_limit_bounds_are_enforced() { + validate_rate_limit("rate_limit", &rl(1, None, 1)).unwrap(); + assert!(validate_rate_limit("rate_limit", &rl(0, None, 1)).is_err()); + assert!(validate_rate_limit("rate_limit", &rl(1, Some(0), 1)).is_err()); + assert!(validate_rate_limit("rate_limit", &rl(1, None, 0)).is_err()); + } + + fn cors(origins: &[&str], creds: bool) -> CorsConfig { + CorsConfig { + sharing: Sharing::Private, + enabled: true, + allowed_origins: origins.iter().map(|s| (*s).to_owned()).collect(), + allowed_methods: vec!["GET".to_owned()], + expose_headers: vec![], + allow_credentials: creds, + } + } + + #[test] + fn wildcard_origin_with_credentials_is_rejected_at_write_time() { + assert!(validate_cors("cors", &cors(&["*"], true)).is_err()); + validate_cors("cors", &cors(&["*"], false)).unwrap(); + validate_cors("cors", &cors(&["https://a.example"], true)).unwrap(); + } + + #[test] + fn an_unknown_cors_method_is_rejected() { + let mut c = cors(&["https://a.example"], false); + c.allowed_methods = vec!["TELEPORT".to_owned()]; + assert!(validate_cors("cors", &c).is_err()); + } + + #[test] + fn a_match_needs_exactly_one_kind() { + let http = RouteMatch { + http: Some(HttpMatch { + methods: vec!["GET".to_owned()], + path: "/v1".to_owned(), + query_allowlist: vec![], + path_suffix_mode: PathSuffixMode::Append, + }), + grpc: None, + }; + validate_match(&http).unwrap(); + + let neither = RouteMatch { http: None, grpc: None }; + assert!(validate_match(&neither).is_err()); + + let both = RouteMatch { + http: http.http.clone(), + grpc: Some(crate::domain::model::GrpcMatch { + service: "s".to_owned(), + method: "m".to_owned(), + }), + }; + assert!(validate_match(&both).is_err()); + } + + #[test] + fn an_unsupported_method_is_rejected() { + let m = RouteMatch { + http: Some(HttpMatch { + methods: vec!["TRACE".to_owned()], + path: "/v1".to_owned(), + query_allowlist: vec![], + path_suffix_mode: PathSuffixMode::Append, + }), + grpc: None, + }; + assert!(validate_match(&m).is_err()); + } + + #[test] + fn an_empty_path_is_rejected() { + let m = RouteMatch { + http: Some(HttpMatch { + methods: vec!["GET".to_owned()], + path: String::new(), + query_allowlist: vec![], + path_suffix_mode: PathSuffixMode::Append, + }), + grpc: None, + }; + assert!(validate_match(&m).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..a5d1eae --- /dev/null +++ b/gears/system/oagw/oagw/src/gear.rs @@ -0,0 +1,138 @@ +//! Gear registration and lifecycle for the OAGW gear. +//! +//! Realizes `cpt-cf-oagw-flow-gf-startup`, `cpt-cf-oagw-dod-gf-registration` +//! and `cpt-cf-oagw-state-gf-lifecycle`. + +use std::sync::{Arc, OnceLock}; + +use async_trait::async_trait; +use toolkit::api::OpenApiRegistry; +use toolkit::{Gear, GearCtx, RestApiCapability}; +use tracing::info; + +use crate::config::OagwConfig; +use crate::domain::store::Store; + +/// State shared by the control plane and the data plane. +#[derive(Debug)] +pub struct OagwState { + /// Resolved gear configuration. + pub config: OagwConfig, + /// The in-memory control-plane store. + pub store: Arc, + /// Per-instance rate-limit buckets. + pub limiter: crate::domain::ratelimit::Limiter, + /// Round-robin cursor over multi-endpoint pools. + pub round_robin: crate::domain::routing::RoundRobin, +} + +impl OagwState { + /// Build state from a resolved configuration. + #[must_use] + pub fn new(config: OagwConfig) -> Self { + Self { + config, + store: Arc::new(Store::new()), + limiter: crate::domain::ratelimit::Limiter::new(), + round_robin: crate::domain::routing::RoundRobin::default(), + } + } +} + +// @cpt-begin:cpt-cf-oagw-dod-gf-registration:p1:inst-full +/// The outbound API gateway gear. +#[toolkit::gear(name = "oagw", capabilities = [rest])] +pub struct OagwGear { + state: OnceLock>, +} +// @cpt-end:cpt-cf-oagw-dod-gf-registration:p1:inst-full + +impl Default for OagwGear { + fn default() -> Self { + Self { + state: OnceLock::new(), + } + } +} + +#[async_trait] +impl Gear for OagwGear { + #[tracing::instrument(skip_all, fields(module = "oagw"))] + async fn init(&self, ctx: &GearCtx) -> anyhow::Result<()> { + // @cpt-begin:cpt-cf-oagw-dod-gf-config:p1:inst-full + // An absent `gears.oagw.config` section yields the documented defaults; + // a key this build does not recognize is ignored rather than fatal. + let config: OagwConfig = ctx.config_or_default()?; + // @cpt-end:cpt-cf-oagw-dod-gf-config:p1:inst-full + info!( + proxy_timeout_secs = config.proxy_timeout_secs, + allow_http_upstream = config.allow_http_upstream, + ssrf_policy_enabled = config.ssrf_policy.enabled, + token_cache_ttl_secs = config.token_cache_ttl_secs, + token_cache_capacity = config.token_cache_capacity, + "initializing oagw gear" + ); + + // @cpt-begin:cpt-cf-oagw-dod-gf-shared-state:p1:inst-full + let state = Arc::new(OagwState::new(config)); + self.state + .set(state) + .map_err(|_| anyhow::anyhow!("oagw gear already initialized"))?; + // @cpt-end:cpt-cf-oagw-dod-gf-shared-state:p1:inst-full + + info!("oagw gear initialized"); + Ok(()) + } +} + +/// The gear's readiness check: healthy once initialization has completed and +/// the shared state the request paths depend on exists. +// @cpt-begin:cpt-cf-oagw-dod-gf-readiness:p1:inst-full +#[derive(Debug)] +struct OagwReadiness { + ready: bool, +} + +#[async_trait] +impl toolkit::Healthcheck for OagwReadiness { + fn name(&self) -> &'static str { + "oagw-state" + } + + async fn check(&self) -> toolkit::HealthcheckResult { + if self.ready { + toolkit::HealthcheckResult::healthy() + } else { + toolkit::HealthcheckResult::unhealthy( + "oagw shared state has not been initialized", + ) + } + } +} +// @cpt-end:cpt-cf-oagw-dod-gf-readiness:p1:inst-full + +impl RestApiCapability for OagwGear { + fn healthcheck( + &self, + _ctx: &GearCtx, + ) -> Option> { + Some(Arc::new(OagwReadiness { + ready: self.state.get().is_some(), + })) + } + + fn register_rest( + &self, + _ctx: &GearCtx, + router: axum::Router, + openapi: &dyn OpenApiRegistry, + ) -> anyhow::Result { + let state = self + .state + .get() + .cloned() + .ok_or_else(|| anyhow::anyhow!("oagw state not initialized"))?; + info!("registering oagw REST routes under /oagw/v1"); + Ok(crate::api::rest::register_routes(router, openapi, state)) + } +} diff --git a/gears/system/oagw/oagw/src/infra/body.rs b/gears/system/oagw/oagw/src/infra/body.rs new file mode 100644 index 0000000..68d58e5 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/body.rs @@ -0,0 +1,226 @@ +//! The body type carried between the client and the upstream. +//! +//! Bodies are relayed rather than accumulated, so a server-sent-event stream +//! reaches the client incrementally and a large upload is never buffered whole. + +use std::pin::Pin; +use std::task::{Context, Poll}; + +use bytes::Bytes; +use hyper::body::{Body, Frame, Incoming, SizeHint}; + +use crate::domain::error::DomainError; + +/// The hard body cap: 100 MB. +pub const MAX_BODY_BYTES: u64 = 100 * 1024 * 1024; + +/// A streaming body that can carry either direction of a proxied exchange. +/// +/// Deliberately a concrete enum rather than a boxed trait object: hyper's +/// client requires the request body to be `Send`, and a boxed `axum::body::Body` +/// is `Send` but not `Sync`, which the boxed combinator would demand. +/// +/// Every variant that carries an unbounded stream counts the bytes it has +/// relayed and fails the body once the 100 MB cap is crossed. A declared +/// over-cap length is refused before any byte moves; an undeclared (chunked) +/// body can only be cut off mid-stream, because the status line and headers +/// have already gone out by the time the overage is observable. +pub enum ProxyBody { + /// No body. + Empty, + /// Exactly these bytes. + Full(http_body_util::Full), + /// An inbound client body, relayed as it arrives and capped. + Client(axum::body::Body, Counter), + /// An upstream response body, relayed as it arrives and capped. + Upstream(Incoming, Counter), +} + +/// Running byte total for one direction of an exchange. +#[derive(Debug, Default)] +pub struct Counter { + seen: u64, +} + +impl Counter { + /// Add `n` bytes and report whether the cap has now been exceeded. + fn add(&mut self, n: u64) -> bool { + self.seen = self.seen.saturating_add(n); + self.seen > MAX_BODY_BYTES + } + + /// Bytes relayed so far. + #[must_use] + pub const fn seen(&self) -> u64 { + self.seen + } +} + +impl ProxyBody { + /// An empty body. + #[must_use] + pub const fn empty() -> Self { + Self::Empty + } + + /// A body holding exactly these bytes. + #[must_use] + pub fn from_bytes(b: Bytes) -> Self { + Self::Full(http_body_util::Full::new(b)) + } + + /// Wrap an inbound axum body, relaying it as it arrives. + #[must_use] + pub fn from_axum(body: axum::body::Body) -> Self { + Self::Client(body, Counter::default()) + } + + /// Wrap an upstream response body, relaying it as it arrives. + #[must_use] + pub fn from_incoming(body: Incoming) -> Self { + Self::Upstream(body, Counter::default()) + } + + /// Convert into an axum body for the response path. + pub fn into_axum(self) -> axum::body::Body { + axum::body::Body::new(self) + } +} + +fn relay_err(what: &str, e: impl std::fmt::Display) -> DomainError { + DomainError::UpstreamUnreachable { + message: format!("{what} body error: {e}"), + } +} + +impl Body for ProxyBody { + type Data = Bytes; + type Error = DomainError; + + fn poll_frame( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + /// Count a data frame and fail the body once the cap is crossed. + fn capped( + polled: Poll, DomainError>>>, + counter: &mut Counter, + ) -> Poll, DomainError>>> { + match polled { + Poll::Ready(Some(Ok(frame))) => { + let n = frame.data_ref().map_or(0, |d| d.len() as u64); + if counter.add(n) { + Poll::Ready(Some(Err(DomainError::PayloadTooLarge))) + } else { + Poll::Ready(Some(Ok(frame))) + } + } + other => other, + } + } + + match self.get_mut() { + Self::Empty => Poll::Ready(None), + Self::Full(f) => Pin::new(f) + .poll_frame(cx) + .map(|o| o.map(|r| r.map_err(|e| relay_err("client", e)))), + Self::Client(b, c) => capped( + Pin::new(b) + .poll_frame(cx) + .map(|o| o.map(|r| r.map_err(|e| relay_err("client", e)))), + c, + ), + Self::Upstream(i, c) => capped( + Pin::new(i) + .poll_frame(cx) + .map(|o| o.map(|r| r.map_err(|e| relay_err("upstream", e)))), + c, + ), + } + } + + fn is_end_stream(&self) -> bool { + match self { + Self::Empty => true, + Self::Full(f) => f.is_end_stream(), + Self::Client(b, _) => b.is_end_stream(), + Self::Upstream(i, _) => i.is_end_stream(), + } + } + + fn size_hint(&self) -> SizeHint { + match self { + Self::Empty => SizeHint::with_exact(0), + Self::Full(f) => f.size_hint(), + Self::Client(b, _) => b.size_hint(), + Self::Upstream(i, _) => i.size_hint(), + } + } +} + +/// Whether a declared content length exceeds the hard cap. +/// +/// A declared over-cap length is refused before any byte is relayed. An +/// undeclared (chunked) body that turns out to exceed the cap can only be cut +/// off mid-stream, because the status and headers have already gone out. +#[must_use] +pub fn declared_length_exceeds_cap(headers: &hyper::HeaderMap) -> bool { + headers + .get(hyper::header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .is_some_and(|n| n > MAX_BODY_BYTES) +} + +#[cfg(test)] +mod tests { + use super::*; + use http_body_util::BodyExt as _; + use hyper::header::{CONTENT_LENGTH, HeaderMap, HeaderValue}; + + #[test] + fn the_cap_is_one_hundred_megabytes() { + assert_eq!(MAX_BODY_BYTES, 104_857_600); + } + + #[test] + fn a_declared_over_cap_length_is_detected() { + let mut h = HeaderMap::new(); + h.insert( + CONTENT_LENGTH, + HeaderValue::from_str(&(MAX_BODY_BYTES + 1).to_string()).unwrap(), + ); + assert!(declared_length_exceeds_cap(&h)); + } + + #[test] + fn a_declared_at_cap_length_is_permitted() { + let mut h = HeaderMap::new(); + h.insert( + CONTENT_LENGTH, + HeaderValue::from_str(&MAX_BODY_BYTES.to_string()).unwrap(), + ); + assert!(!declared_length_exceeds_cap(&h)); + } + + #[test] + fn an_absent_or_unparsable_length_is_not_a_declared_overage() { + assert!(!declared_length_exceeds_cap(&HeaderMap::new())); + let mut h = HeaderMap::new(); + h.insert(CONTENT_LENGTH, HeaderValue::from_static("not-a-number")); + assert!(!declared_length_exceeds_cap(&h)); + } + + #[tokio::test] + async fn a_byte_body_round_trips() { + let b = ProxyBody::from_bytes(Bytes::from_static(b"hello")); + let collected = b.collect().await.unwrap().to_bytes(); + assert_eq!(&collected[..], b"hello"); + } + + #[tokio::test] + async fn an_empty_body_yields_no_bytes() { + let collected = ProxyBody::empty().collect().await.unwrap().to_bytes(); + assert!(collected.is_empty()); + } +} diff --git a/gears/system/oagw/oagw/src/infra/connect.rs b/gears/system/oagw/oagw/src/infra/connect.rs new file mode 100644 index 0000000..a9767d2 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/connect.rs @@ -0,0 +1,142 @@ +//! Outbound connections to upstream services. +//! +//! One code path serves plain HTTP exchanges, server-sent-event streams and +//! WebSocket upgrades alike: a connection is opened (TLS when the scheme calls +//! for it), an HTTP/1.1 handshake is driven over it with upgrades enabled, and +//! the caller decides what to do with the response. Because the gateway relays +//! bytes after a `101` rather than interpreting frames, subprotocol +//! negotiation, ping/pong and close codes all propagate untouched. + +use std::sync::Arc; + +use hyper::body::Incoming; +use hyper::client::conn::http1; +use hyper::{Request, Response}; +use hyper_util::rt::TokioIo; +use tokio::net::TcpStream; +use tokio_rustls::TlsConnector; +use tokio_rustls::rustls::pki_types::ServerName; +use tokio_rustls::rustls::{ClientConfig, RootCertStore}; + +use crate::domain::error::DomainError; +use crate::domain::model::Scheme; + +/// A live connection's request sender, plus the task driving it. +#[allow(missing_debug_implementations)] +pub struct Upstream { + sender: http1::SendRequest, +} + +fn unreachable(e: impl std::fmt::Display) -> DomainError { + DomainError::UpstreamUnreachable { + message: e.to_string(), + } +} + +/// Build the shared TLS client configuration, trusting the platform roots. +fn tls_config() -> Result, DomainError> { + static CACHE: std::sync::OnceLock, String>> = + std::sync::OnceLock::new(); + CACHE + .get_or_init(|| { + let mut roots = RootCertStore::empty(); + let loaded = rustls_native_certs::load_native_certs(); + for cert in loaded.certs { + // A certificate the store rejects is skipped rather than fatal. + let _ = roots.add(cert); + } + if roots.is_empty() { + return Err("no platform trust roots are available".to_owned()); + } + Ok(Arc::new( + ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(), + )) + }) + .clone() + .map_err(|e| DomainError::UpstreamUnreachable { message: e }) +} + +impl Upstream { + /// Open a connection to `host:port` under `scheme` and complete the HTTP/1.1 + /// handshake, with upgrades enabled. + /// + /// # Errors + /// Returns [`DomainError::UpstreamUnreachable`] when the transport or the + /// handshake fails. + // @cpt-begin:cpt-cf-oagw-dod-ph-timeout:p1:inst-full + pub async fn connect(scheme: Scheme, host: &str, port: u16) -> Result { + let tcp = TcpStream::connect((host, port)).await.map_err(unreachable)?; + // Proxying is latency-sensitive and the payloads are relayed rather + // than accumulated, so Nagle only adds delay. + let _ = tcp.set_nodelay(true); + + let sender = if scheme.is_tls() { + let cfg = tls_config()?; + let server_name = ServerName::try_from(host.to_owned()) + .map_err(|_| unreachable(format!("`{host}` is not a valid TLS server name")))?; + let stream = TlsConnector::from(cfg) + .connect(server_name, tcp) + .await + .map_err(unreachable)?; + Self::handshake(TokioIo::new(stream)).await? + } else { + Self::handshake(TokioIo::new(tcp)).await? + }; + + Ok(Self { sender }) + } + // @cpt-end:cpt-cf-oagw-dod-ph-timeout:p1:inst-full + + async fn handshake(io: I) -> Result, DomainError> + where + I: hyper::rt::Read + hyper::rt::Write + Unpin + Send + 'static, + { + let (sender, conn) = http1::handshake(io).await.map_err(unreachable)?; + // `with_upgrades` is what makes a 101 usable: without it the connection + // task would not hand back the upgraded transport. + tokio::spawn(async move { + if let Err(e) = conn.with_upgrades().await { + tracing::debug!(error = %e, "oagw upstream connection ended"); + } + }); + Ok(sender) + } + + /// Send a request over this connection. + /// + /// # Errors + /// Returns [`DomainError::UpstreamUnreachable`] when the exchange fails. + pub async fn send( + &mut self, + req: Request, + ) -> Result, DomainError> { + self.sender.ready().await.map_err(unreachable)?; + self.sender.send_request(req).await.map_err(unreachable) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn connecting_to_a_closed_port_is_an_unreachable_error() { + // Port 1 on the loopback interface has nothing listening. + let r = Upstream::connect(Scheme::Http, "127.0.0.1", 1).await; + assert!(matches!( + r.err(), + Some(DomainError::UpstreamUnreachable { .. }) + )); + } + + #[tokio::test] + async fn an_unresolvable_host_is_an_unreachable_error() { + let r = Upstream::connect(Scheme::Http, "no-such-host.invalid", 80).await; + assert!(matches!( + r.err(), + Some(DomainError::UpstreamUnreachable { .. }) + )); + } +} diff --git a/gears/system/oagw/oagw/src/infra/headers.rs b/gears/system/oagw/oagw/src/infra/headers.rs new file mode 100644 index 0000000..1664213 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/headers.rs @@ -0,0 +1,311 @@ +//! Header transformation on the proxy path. +//! +//! Realizes `cpt-cf-oagw-algo-ph-header-transform`. +//! +//! Three categories are handled distinctly: routing headers are consumed by the +//! gateway and never forwarded; hop-by-hop headers are stripped in both +//! directions; everything else is forwarded according to the upstream's +//! configured passthrough mode. + +use hyper::HeaderMap; +use hyper::header::{HeaderName, HeaderValue}; + +use crate::domain::error::TARGET_HOST_HEADER; +use crate::domain::model::{HeaderRules, Passthrough}; + +/// Connection-scoped headers, stripped in both directions. +pub const HOP_BY_HOP: [&str; 8] = [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]; + +/// Headers the gateway consumes for its own routing decisions. +pub const ROUTING: [&str; 1] = [TARGET_HOST_HEADER]; + +/// Whether a header is hop-by-hop. +#[must_use] +pub fn is_hop_by_hop(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + HOP_BY_HOP.contains(&lower.as_str()) +} + +/// Whether a header is consumed by the gateway rather than forwarded. +#[must_use] +pub fn is_routing(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + ROUTING.contains(&lower.as_str()) +} + +/// Build the header map to send upstream. +/// +/// `preserve_upgrade` keeps `Connection` and `Upgrade` in place, which is the +/// one documented exception to hop-by-hop stripping: those two headers are +/// exactly what makes a WebSocket upgrade work. +// @cpt-begin:cpt-cf-oagw-dod-ph-header-transform:p1:inst-full +#[must_use] +pub fn build_request_headers( + inbound: &HeaderMap, + rules: &HeaderRules, + authority: &str, + preserve_upgrade: bool, +) -> HeaderMap { + let mut out = HeaderMap::new(); + + for (name, value) in inbound { + let n = name.as_str().to_ascii_lowercase(); + if is_routing(&n) { + continue; + } + if is_hop_by_hop(&n) && !(preserve_upgrade && matches!(n.as_str(), "connection" | "upgrade")) + { + continue; + } + if n == "host" { + continue; // replaced with the upstream authority below + } + if rules.remove.iter().any(|r| r.eq_ignore_ascii_case(&n)) { + continue; + } + let forward = match rules.passthrough { + Passthrough::All => true, + Passthrough::None => { + // Even with nothing passed through, the headers that make the + // exchange itself work must survive. + is_essential(&n) || (preserve_upgrade && is_upgrade_related(&n)) + } + Passthrough::Allowlist => { + rules + .passthrough_allowlist + .iter() + .any(|a| a.eq_ignore_ascii_case(&n)) + || is_essential(&n) + || (preserve_upgrade && is_upgrade_related(&n)) + } + }; + if forward { + out.append(name.clone(), value.clone()); + } + } + + apply_set_and_add(&mut out, rules); + + if let Ok(v) = HeaderValue::from_str(authority) { + out.insert(hyper::header::HOST, v); + } + out +} +// @cpt-end:cpt-cf-oagw-dod-ph-header-transform:p1:inst-full + +/// Headers without which the exchange cannot be framed or understood. +/// +/// A documented, deliberate exception to `passthrough: none`: a forwarded body +/// is uninterpretable without its own framing headers, so these four survive +/// every passthrough mode. This mirrors the `Upgrade`/`Connection` exception on +/// the upgrade path and is recorded in `proxy-http.md`. +fn is_essential(name: &str) -> bool { + matches!( + name, + "content-type" | "content-length" | "accept" | "accept-encoding" + ) +} + +/// Headers that carry the WebSocket handshake. +fn is_upgrade_related(name: &str) -> bool { + name.starts_with("sec-websocket") || matches!(name, "connection" | "upgrade") +} + +/// Build the header map to relay back to the client. +#[must_use] +pub fn build_response_headers( + upstream: &HeaderMap, + rules: &HeaderRules, + preserve_upgrade: bool, +) -> HeaderMap { + let mut out = HeaderMap::new(); + for (name, value) in upstream { + let n = name.as_str().to_ascii_lowercase(); + if is_hop_by_hop(&n) && !(preserve_upgrade && matches!(n.as_str(), "connection" | "upgrade")) + { + continue; + } + if rules.remove.iter().any(|r| r.eq_ignore_ascii_case(&n)) { + continue; + } + out.append(name.clone(), value.clone()); + } + apply_set_and_add(&mut out, rules); + out +} + +fn apply_set_and_add(out: &mut HeaderMap, rules: &HeaderRules) { + for (k, v) in &rules.set { + if let (Ok(name), Ok(value)) = ( + HeaderName::from_bytes(k.as_bytes()), + HeaderValue::from_str(v), + ) { + out.insert(name, value); + } + } + for (k, v) in &rules.add { + if let (Ok(name), Ok(value)) = ( + HeaderName::from_bytes(k.as_bytes()), + HeaderValue::from_str(v), + ) { + out.append(name, value); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + fn inbound(pairs: &[(&str, &str)]) -> HeaderMap { + let mut h = HeaderMap::new(); + for (k, v) in pairs { + h.append( + HeaderName::from_bytes(k.as_bytes()).unwrap(), + HeaderValue::from_str(v).unwrap(), + ); + } + h + } + + fn rules(mode: Passthrough) -> HeaderRules { + HeaderRules { + set: BTreeMap::new(), + add: BTreeMap::new(), + remove: vec![], + passthrough: mode, + passthrough_allowlist: vec![], + } + } + + #[test] + fn every_hop_by_hop_header_is_stripped() { + let pairs: Vec<(&str, &str)> = HOP_BY_HOP.iter().map(|h| (*h, "x")).collect(); + let out = build_request_headers(&inbound(&pairs), &rules(Passthrough::All), "u:80", false); + for h in HOP_BY_HOP { + assert!(out.get(h).is_none(), "{h} should have been stripped"); + } + } + + #[test] + fn the_routing_header_is_consumed_not_forwarded() { + let out = build_request_headers( + &inbound(&[(TARGET_HOST_HEADER, "a.example"), ("x-keep", "1")]), + &rules(Passthrough::All), + "u:80", + false, + ); + assert!(out.get(TARGET_HOST_HEADER).is_none()); + assert_eq!(out.get("x-keep").unwrap(), "1"); + } + + #[test] + fn host_is_replaced_with_the_upstream_authority() { + let out = build_request_headers( + &inbound(&[("host", "gateway.example")]), + &rules(Passthrough::All), + "upstream.example:8443", + false, + ); + assert_eq!(out.get("host").unwrap(), "upstream.example:8443"); + } + + #[test] + fn passthrough_none_drops_ordinary_headers_but_keeps_framing() { + let out = build_request_headers( + &inbound(&[("x-custom", "1"), ("content-type", "application/json")]), + &rules(Passthrough::None), + "u:80", + false, + ); + assert!(out.get("x-custom").is_none()); + assert_eq!(out.get("content-type").unwrap(), "application/json"); + } + + #[test] + fn passthrough_allowlist_forwards_only_named_headers() { + let mut r = rules(Passthrough::Allowlist); + r.passthrough_allowlist = vec!["x-wanted".to_owned()]; + let out = build_request_headers( + &inbound(&[("x-wanted", "1"), ("x-unwanted", "2")]), + &r, + "u:80", + false, + ); + assert_eq!(out.get("x-wanted").unwrap(), "1"); + assert!(out.get("x-unwanted").is_none()); + } + + #[test] + fn remove_wins_over_passthrough() { + let mut r = rules(Passthrough::All); + r.remove = vec!["X-Secret".to_owned()]; + let out = build_request_headers(&inbound(&[("x-secret", "s")]), &r, "u:80", false); + assert!(out.get("x-secret").is_none()); + } + + #[test] + fn set_overwrites_and_add_appends() { + let mut r = rules(Passthrough::All); + r.set.insert("x-a".to_owned(), "set".to_owned()); + r.add.insert("x-b".to_owned(), "added".to_owned()); + let out = build_request_headers(&inbound(&[("x-a", "original")]), &r, "u:80", false); + assert_eq!(out.get("x-a").unwrap(), "set"); + assert_eq!(out.get("x-b").unwrap(), "added"); + } + + #[test] + fn an_upgrade_exchange_keeps_connection_and_upgrade() { + let out = build_request_headers( + &inbound(&[ + ("connection", "Upgrade"), + ("upgrade", "websocket"), + ("sec-websocket-key", "abc"), + ("sec-websocket-version", "13"), + ]), + &rules(Passthrough::None), + "u:80", + true, + ); + assert_eq!(out.get("upgrade").unwrap(), "websocket"); + assert_eq!(out.get("connection").unwrap(), "Upgrade"); + assert_eq!(out.get("sec-websocket-key").unwrap(), "abc"); + assert_eq!(out.get("sec-websocket-version").unwrap(), "13"); + } + + #[test] + fn a_non_upgrade_exchange_still_strips_them() { + let out = build_request_headers( + &inbound(&[("connection", "keep-alive"), ("upgrade", "h2c")]), + &rules(Passthrough::All), + "u:80", + false, + ); + assert!(out.get("upgrade").is_none()); + assert!(out.get("connection").is_none()); + } + + #[test] + fn response_headers_strip_hop_by_hop_and_apply_rules() { + let mut r = rules(Passthrough::All); + r.set.insert("x-added".to_owned(), "1".to_owned()); + let out = build_response_headers( + &inbound(&[("transfer-encoding", "chunked"), ("content-type", "text/plain")]), + &r, + false, + ); + assert!(out.get("transfer-encoding").is_none()); + assert_eq!(out.get("content-type").unwrap(), "text/plain"); + assert_eq!(out.get("x-added").unwrap(), "1"); + } +} 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..fd454ca --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/mod.rs @@ -0,0 +1,5 @@ +//! Infrastructure adapters: outbound connections, body relaying, header rules. + +pub mod body; +pub mod connect; +pub mod headers; diff --git a/gears/system/oagw/oagw/src/lib.rs b/gears/system/oagw/oagw/src/lib.rs index e69de29..f7e5e76 100644 --- a/gears/system/oagw/oagw/src/lib.rs +++ b/gears/system/oagw/oagw/src/lib.rs @@ -0,0 +1,35 @@ +//! # OAGW — the outbound API gateway gear +//! +//! OAGW registers upstream services and routing rules, then proxies outbound +//! traffic to them: plain HTTP exchanges, server-sent-event streams and +//! WebSocket upgrades alike. +//! +//! The gear serves two surfaces, both mounted gear-relative under `/oagw/v1`: +//! +//! * a management API (`/oagw/v1/upstreams`, `/oagw/v1/routes`, +//! `/oagw/v1/plugins`) that is the control plane, and +//! * a proxy API (`/oagw/v1/proxy/{alias}/{path}`) that is the data plane. +//! +//! Paths are registered gear-relative on purpose. The api-gateway host merges +//! every gear's routes onto one shared router and nests the whole assembled +//! router once under its own `prefix_path`, so a gear must not repeat that +//! prefix itself. +//! +//! The implementation contract lives in `gears/system/oagw/docs/`: `PRD.md`, +//! `DESIGN.md`, the accepted ADRs, and the seven FEATURE documents under +//! `docs/features/`. Code carries `@cpt-begin` / `@cpt-end` markers tracing +//! back to those documents' CDSL identifiers. + +#![allow(clippy::multiple_crate_versions)] + +pub mod config; +pub mod gear; + +#[doc(hidden)] +pub mod api; +pub mod domain; +#[doc(hidden)] +pub mod infra; + +pub use config::{OagwConfig, SsrfPolicy}; +pub use gear::{OagwGear, OagwState}; 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..8659d1f --- /dev/null +++ b/gears/system/oagw/oagw/tests/common/mod.rs @@ -0,0 +1,424 @@ +//! Shared harness for the OAGW acceptance tests. +//! +//! Spins the gear's own router up in-process and, where a test needs a real +//! upstream, a purpose-built one on a loopback port. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, dead_code)] + +use std::sync::Arc; + +use axum::Router; +use axum::body::{Body, to_bytes}; +use axum::http::{Request, StatusCode}; +use oagw::config::OagwConfig; +use oagw::gear::OagwState; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use toolkit::api::OpenApiRegistryImpl; +use tower::ServiceExt; + +/// A router plus the state behind it. +pub struct Harness { + pub router: Router, +} + +impl Harness { + /// A harness whose configuration mirrors the graded one. + pub fn graded() -> Self { + Self::with_config(OagwConfig { + proxy_timeout_secs: 2, + allow_http_upstream: true, + ssrf_policy: oagw::config::SsrfPolicy { enabled: false }, + ..OagwConfig::default() + }) + } + + /// A harness with plaintext upstream connections refused. + pub fn plaintext_refused() -> Self { + Self::with_config(OagwConfig { + proxy_timeout_secs: 2, + allow_http_upstream: false, + ..OagwConfig::default() + }) + } + + pub fn with_config(config: OagwConfig) -> Self { + let openapi = OpenApiRegistryImpl::new(); + let state = Arc::new(OagwState::new(config)); + Self { + router: oagw::api::rest::register_routes(Router::new(), &openapi, state), + } + } + + /// Issue a JSON request and decode the response. + pub async fn json( + &self, + method: &str, + uri: &str, + body: Option, + ) -> (StatusCode, serde_json::Value) { + let (status, _headers, bytes) = self.raw(method, uri, body, &[]).await; + let json = if bytes.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null) + }; + (status, json) + } + + /// Issue a request and return status, headers and raw bytes. + pub async fn raw( + &self, + method: &str, + uri: &str, + body: Option, + headers: &[(&str, &str)], + ) -> (StatusCode, axum::http::HeaderMap, Vec) { + let mut req = Request::builder().method(method).uri(uri); + for (k, v) in headers { + req = req.header(*k, *v); + } + let req = match body { + Some(b) => req + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&b).unwrap())) + .unwrap(), + None => req.body(Body::empty()).unwrap(), + }; + let resp = self.router.clone().oneshot(req).await.unwrap(); + let status = resp.status(); + let hdrs = resp.headers().clone(); + let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + (status, hdrs, bytes.to_vec()) + } + + /// Register an upstream pointing at `port` and a catch-all route, and + /// return the alias. + pub async fn wire_upstream(&self, alias: &str, scheme: &str, port: u16) -> String { + let (s, up) = self + .json( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": alias, + "server": {"endpoints": [{"scheme": scheme, "host": "127.0.0.1", "port": port}]}, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1" + })), + ) + .await; + assert_eq!(s, StatusCode::CREATED, "upstream create failed: {up}"); + let id = up["id"].as_str().unwrap().to_owned(); + let (s, r) = self + .json( + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({ + "upstream_id": id, + "match": {"http": {"methods": ["GET", "POST", "PUT", "DELETE", "PATCH"], "path": "/"}} + })), + ) + .await; + assert_eq!(s, StatusCode::CREATED, "route create failed: {r}"); + alias.to_owned() + } +} + +/// The value of a response header, if present. +pub fn header(h: &axum::http::HeaderMap, name: &str) -> Option { + h.get(name) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned) +} + +/// A minimal upstream that answers a fixed canned response per path. +pub struct FakeUpstream { + pub port: u16, +} + +impl FakeUpstream { + /// Start an upstream that answers: + /// * `/boom` with a 500 and a JSON body, + /// * `/slow` after a delay longer than the proxy timeout, + /// * `/sse` with an event stream, + /// * `/noheader` with a 200 that omits `x-required`, + /// * anything else with a 200 echoing the request line and headers. + pub async fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + loop { + let Ok((sock, _)) = listener.accept().await else { + break; + }; + tokio::spawn(handle(sock)); + } + }); + Self { port } + } +} + +async fn handle(mut sock: TcpStream) { + let mut buf = vec![0_u8; 16 * 1024]; + let n = match sock.read(&mut buf).await { + Ok(0) | Err(_) => return, + Ok(n) => n, + }; + let req = String::from_utf8_lossy(&buf[..n]).to_string(); + let first = req.lines().next().unwrap_or_default().to_owned(); + let path = first.split_whitespace().nth(1).unwrap_or("/").to_owned(); + + if path.starts_with("/slow") { + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } + + let out = if path.starts_with("/boom") { + let body = br#"{"upstream":"error"}"#; + format!( + "HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .into_bytes() + .into_iter() + .chain(body.iter().copied()) + .collect::>() + } else if path.starts_with("/sse") { + let head = b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nTransfer-Encoding: chunked\r\n\r\n"; + let _ = sock.write_all(head).await; + let _ = sock.flush().await; + for i in 0..3 { + // The third event lands after the 2 s proxy timeout, proving the + // timeout bounds establishment and not the stream's lifetime. + tokio::time::sleep(std::time::Duration::from_millis(1200)).await; + let ev = format!("data: ev{i}\n\n"); + let chunk = format!("{:x}\r\n{ev}\r\n", ev.len()); + if sock.write_all(chunk.as_bytes()).await.is_err() { + return; + } + let _ = sock.flush().await; + } + let _ = sock.write_all(b"0\r\n\r\n").await; + return; + } else if path.starts_with("/noheader") { + b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok".to_vec() + } else { + // Echo what arrived so the test can assert on header transformation. + let seen_hop = req.to_lowercase().contains("x-custom-hop"); + let seen_te = req.to_lowercase().contains("\r\nte:"); + let seen_target = req.to_lowercase().contains("x-oagw-target-host"); + let host = req + .lines() + .find(|l| l.to_lowercase().starts_with("host:")) + .map(|l| l[5..].trim().to_owned()) + .unwrap_or_default(); + let body = serde_json::json!({ + "line": first, + "path": path, + "host": host, + "saw_hop_by_hop_te": seen_te, + "saw_custom_hop": seen_hop, + "saw_target_host": seen_target, + }) + .to_string(); + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nX-Required: yes\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .into_bytes() + }; + let _ = sock.write_all(&out).await; + let _ = sock.flush().await; +} + +/// A minimal WebSocket echo upstream. +pub struct FakeWsUpstream { + pub port: u16, +} + +impl FakeWsUpstream { + /// Start an upstream that completes the handshake, echoes text frames with + /// an `ECHO:` prefix, and mirrors a close frame back. + pub async fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + loop { + let Ok((sock, _)) = listener.accept().await else { + break; + }; + tokio::spawn(ws_handle(sock)); + } + }); + Self { port } + } +} + +const WS_GUID: &[u8] = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +fn ws_accept(key: &str) -> String { + use sha1_of::sha1; + let mut input = key.as_bytes().to_vec(); + input.extend_from_slice(WS_GUID); + base64_encode(&sha1(&input)) +} + +async fn ws_handle(mut sock: TcpStream) { + let mut buf = vec![0_u8; 8192]; + let n = match sock.read(&mut buf).await { + Ok(0) | Err(_) => return, + Ok(n) => n, + }; + let req = String::from_utf8_lossy(&buf[..n]).to_string(); + let mut key = String::new(); + let mut proto = String::new(); + for line in req.lines() { + let lower = line.to_lowercase(); + if let Some(v) = lower.strip_prefix("sec-websocket-key:") { + key = line[line.len() - v.trim().len()..].trim().to_owned(); + } + if let Some(v) = lower.strip_prefix("sec-websocket-protocol:") { + proto = line[line.len() - v.trim().len()..] + .split(',') + .next() + .unwrap_or_default() + .trim() + .to_owned(); + } + } + let mut resp = format!( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {}\r\n", + ws_accept(&key) + ); + if !proto.is_empty() { + resp.push_str(&format!("Sec-WebSocket-Protocol: {proto}\r\n")); + } + resp.push_str("\r\n"); + if sock.write_all(resp.as_bytes()).await.is_err() { + return; + } + + loop { + let mut h = [0_u8; 2]; + if sock.read_exact(&mut h).await.is_err() { + return; + } + let opcode = h[0] & 0x0f; + let masked = h[1] & 0x80 != 0; + let mut len = usize::from(h[1] & 0x7f); + if len == 126 { + let mut e = [0_u8; 2]; + if sock.read_exact(&mut e).await.is_err() { + return; + } + len = usize::from(u16::from_be_bytes(e)); + } + let mut mask = [0_u8; 4]; + if masked && sock.read_exact(&mut mask).await.is_err() { + return; + } + let mut payload = vec![0_u8; len]; + if sock.read_exact(&mut payload).await.is_err() { + return; + } + if masked { + for (i, b) in payload.iter_mut().enumerate() { + *b ^= mask[i % 4]; + } + } + if opcode == 0x8 { + // Mirror the close frame, close code and all. + let mut out = vec![0x88, u8::try_from(payload.len()).unwrap_or(0)]; + out.extend_from_slice(&payload); + let _ = sock.write_all(&out).await; + return; + } + let mut echoed = b"ECHO:".to_vec(); + echoed.extend_from_slice(&payload); + let mut out = vec![0x80 | opcode, u8::try_from(echoed.len()).unwrap_or(0)]; + out.extend_from_slice(&echoed); + if sock.write_all(&out).await.is_err() { + return; + } + } +} + +fn base64_encode(data: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::new(); + for c in data.chunks(3) { + let b = [c[0], *c.get(1).unwrap_or(&0), *c.get(2).unwrap_or(&0)]; + let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]); + out.push(T[((n >> 18) & 63) as usize] as char); + out.push(T[((n >> 12) & 63) as usize] as char); + out.push(if c.len() > 1 { + T[((n >> 6) & 63) as usize] as char + } else { + '=' + }); + out.push(if c.len() > 2 { + T[(n & 63) as usize] as char + } else { + '=' + }); + } + out +} + +/// A tiny SHA-1, so the harness needs no extra dependency. +mod sha1_of { + pub fn sha1(msg: &[u8]) -> [u8; 20] { + let mut h: [u32; 5] = [ + 0x6745_2301, + 0xEFCD_AB89, + 0x98BA_DCFE, + 0x1032_5476, + 0xC3D2_E1F0, + ]; + let ml = (msg.len() as u64) * 8; + let mut data = msg.to_vec(); + data.push(0x80); + while data.len() % 64 != 56 { + data.push(0); + } + data.extend_from_slice(&ml.to_be_bytes()); + + for block in data.chunks(64) { + let mut w = [0_u32; 80]; + for (i, word) in block.chunks(4).enumerate() { + w[i] = u32::from_be_bytes([word[0], word[1], word[2], word[3]]); + } + for i in 16..80 { + w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1); + } + let (mut a, mut b, mut c, mut d, mut e) = (h[0], h[1], h[2], h[3], h[4]); + for (i, wi) in w.iter().enumerate() { + let (f, k) = match i { + 0..=19 => ((b & c) | ((!b) & d), 0x5A82_7999), + 20..=39 => (b ^ c ^ d, 0x6ED9_EBA1), + 40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1B_BCDC), + _ => (b ^ c ^ d, 0xCA62_C1D6), + }; + let tmp = a + .rotate_left(5) + .wrapping_add(f) + .wrapping_add(e) + .wrapping_add(k) + .wrapping_add(*wi); + e = d; + d = c; + c = b.rotate_left(30); + b = a; + a = tmp; + } + h[0] = h[0].wrapping_add(a); + h[1] = h[1].wrapping_add(b); + h[2] = h[2].wrapping_add(c); + h[3] = h[3].wrapping_add(d); + h[4] = h[4].wrapping_add(e); + } + let mut out = [0_u8; 20]; + for (i, v) in h.iter().enumerate() { + out[i * 4..i * 4 + 4].copy_from_slice(&v.to_be_bytes()); + } + out + } +} diff --git a/gears/system/oagw/oagw/tests/proxy_acceptance.rs b/gears/system/oagw/oagw/tests/proxy_acceptance.rs new file mode 100644 index 0000000..f22efae --- /dev/null +++ b/gears/system/oagw/oagw/tests/proxy_acceptance.rs @@ -0,0 +1,564 @@ +//! Acceptance tests for the data plane. +//! +//! These codify the section 6 acceptance criteria of `proxy-http.md`, +//! `proxy-streaming.md` and `traffic-policy.md` against a real upstream. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod common; + +use axum::http::StatusCode; +use common::{FakeUpstream, FakeWsUpstream, Harness, header}; + +const SOURCE: &str = "x-oagw-error-source"; + +// ---- plain HTTP proxying ---------------------------------------------- + +#[tokio::test] +async fn a_proxied_get_is_relayed_and_marked_upstream() { + let up = FakeUpstream::start().await; + let h = Harness::graded(); + h.wire_upstream("svc", "http", up.port).await; + + let (s, hdrs, body) = h.raw("GET", "/oagw/v1/proxy/svc/hello", None, &[]).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(header(&hdrs, SOURCE).as_deref(), Some("upstream")); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(v["path"], "/hello"); +} + +#[tokio::test] +async fn the_path_suffix_and_query_reach_the_upstream() { + let up = FakeUpstream::start().await; + let h = Harness::graded(); + h.wire_upstream("svc", "http", up.port).await; + + let (s, _, body) = h + .raw("GET", "/oagw/v1/proxy/svc/a/b/c?x=1&y=2", None, &[]) + .await; + assert_eq!(s, StatusCode::OK); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(v["path"], "/a/b/c?x=1&y=2"); +} + +#[tokio::test] +async fn the_host_header_is_replaced_with_the_upstream_authority() { + let up = FakeUpstream::start().await; + let h = Harness::graded(); + h.wire_upstream("svc", "http", up.port).await; + + let (_, _, body) = h + .raw("GET", "/oagw/v1/proxy/svc/x", None, &[("host", "gateway.example")]) + .await; + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(v["host"], format!("127.0.0.1:{}", up.port)); +} + +#[tokio::test] +async fn hop_by_hop_and_routing_headers_are_not_forwarded() { + let up = FakeUpstream::start().await; + let h = Harness::graded(); + h.wire_upstream("svc", "http", up.port).await; + + let (_, _, body) = h + .raw( + "GET", + "/oagw/v1/proxy/svc/x", + None, + &[("te", "trailers"), ("x-oagw-target-host", "127.0.0.1")], + ) + .await; + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(v["saw_hop_by_hop_te"], false, "TE must be stripped"); + assert_eq!( + v["saw_target_host"], false, + "the routing header must be consumed, not forwarded" + ); +} + +#[tokio::test] +async fn an_upstream_5xx_is_relayed_unchanged_and_marked_upstream() { + let up = FakeUpstream::start().await; + let h = Harness::graded(); + h.wire_upstream("svc", "http", up.port).await; + + let (s, hdrs, body) = h.raw("GET", "/oagw/v1/proxy/svc/boom", None, &[]).await; + assert_eq!(s, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(header(&hdrs, SOURCE).as_deref(), Some("upstream")); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(v["upstream"], "error"); +} + +#[tokio::test] +async fn an_unknown_alias_is_404_marked_gateway() { + let h = Harness::graded(); + let (s, hdrs, _) = h.raw("GET", "/oagw/v1/proxy/nope/x", None, &[]).await; + assert_eq!(s, StatusCode::NOT_FOUND); + assert_eq!(header(&hdrs, SOURCE).as_deref(), Some("gateway")); +} + +#[tokio::test] +async fn a_disabled_upstream_answers_503() { + let up = FakeUpstream::start().await; + let h = Harness::graded(); + let (_, created) = h + .json( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": "off", + "enabled": false, + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": up.port}]}, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1" + })), + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + h.json( + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({ + "upstream_id": id, + "match": {"http": {"methods": ["GET"], "path": "/"}} + })), + ) + .await; + + let (s, hdrs, _) = h.raw("GET", "/oagw/v1/proxy/off/x", None, &[]).await; + assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(header(&hdrs, SOURCE).as_deref(), Some("gateway")); +} + +#[tokio::test] +async fn a_request_matching_no_route_is_404_marked_gateway() { + let up = FakeUpstream::start().await; + let h = Harness::graded(); + let (_, created) = h + .json( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": "narrow", + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": up.port}]}, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1" + })), + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + h.json( + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({ + "upstream_id": id, + "match": {"http": {"methods": ["GET"], "path": "/allowed"}} + })), + ) + .await; + + let (s, hdrs, _) = h.raw("GET", "/oagw/v1/proxy/narrow/elsewhere", None, &[]).await; + assert_eq!(s, StatusCode::NOT_FOUND); + assert_eq!(header(&hdrs, SOURCE).as_deref(), Some("gateway")); +} + +#[tokio::test] +async fn an_upstream_slower_than_the_timeout_is_504() { + let up = FakeUpstream::start().await; + let h = Harness::graded(); // proxy_timeout_secs = 2 + h.wire_upstream("svc", "http", up.port).await; + + let (s, hdrs, _) = h.raw("GET", "/oagw/v1/proxy/svc/slow", None, &[]).await; + assert_eq!(s, StatusCode::GATEWAY_TIMEOUT); + assert_eq!(header(&hdrs, SOURCE).as_deref(), Some("gateway")); +} + +#[tokio::test] +async fn an_unreachable_upstream_is_502() { + let h = Harness::graded(); + // Port 1 on loopback has nothing listening. + h.wire_upstream("dead", "http", 1).await; + let (s, hdrs, _) = h.raw("GET", "/oagw/v1/proxy/dead/x", None, &[]).await; + assert_eq!(s, StatusCode::BAD_GATEWAY); + assert_eq!(header(&hdrs, SOURCE).as_deref(), Some("gateway")); +} + +#[tokio::test] +async fn a_plaintext_connection_is_refused_when_the_flag_is_off() { + let up = FakeUpstream::start().await; + // The upstream is still *created* successfully with an http scheme — only + // the connection is refused. + let h = Harness::plaintext_refused(); + h.wire_upstream("svc", "http", up.port).await; + + let (s, hdrs, _) = h.raw("GET", "/oagw/v1/proxy/svc/x", None, &[]).await; + assert_eq!(s, StatusCode::BAD_GATEWAY); + assert_eq!(header(&hdrs, SOURCE).as_deref(), Some("gateway")); +} + +#[tokio::test] +async fn a_plaintext_connection_is_made_when_the_flag_is_on() { + let up = FakeUpstream::start().await; + let h = Harness::graded(); // allow_http_upstream = true + h.wire_upstream("svc", "http", up.port).await; + let (s, _, _) = h.raw("GET", "/oagw/v1/proxy/svc/x", None, &[]).await; + assert_eq!(s, StatusCode::OK); +} + +#[tokio::test] +async fn a_target_host_naming_an_endpoint_outside_the_pool_is_400() { + let up = FakeUpstream::start().await; + let h = Harness::graded(); + h.wire_upstream("svc", "http", up.port).await; + + let (s, hdrs, _) = h + .raw( + "GET", + "/oagw/v1/proxy/svc/x", + None, + &[("x-oagw-target-host", "elsewhere.example")], + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST); + assert_eq!(header(&hdrs, SOURCE).as_deref(), Some("gateway")); +} + +#[tokio::test] +async fn a_target_host_naming_the_sole_endpoint_is_accepted() { + let up = FakeUpstream::start().await; + let h = Harness::graded(); + h.wire_upstream("svc", "http", up.port).await; + + let (s, _, _) = h + .raw( + "GET", + "/oagw/v1/proxy/svc/x", + None, + &[("x-oagw-target-host", "127.0.0.1")], + ) + .await; + assert_eq!(s, StatusCode::OK); +} + +// ---- server-sent events ------------------------------------------------ + +#[tokio::test] +async fn an_event_stream_is_relayed_in_order_and_outlives_the_proxy_timeout() { + let up = FakeUpstream::start().await; + let h = Harness::graded(); // proxy_timeout_secs = 2 + h.wire_upstream("svc", "http", up.port).await; + + let started = std::time::Instant::now(); + let (s, hdrs, body) = h.raw("GET", "/oagw/v1/proxy/svc/sse", None, &[]).await; + assert_eq!(s, StatusCode::OK); + assert_eq!( + header(&hdrs, "content-type").as_deref(), + Some("text/event-stream") + ); + let text = String::from_utf8_lossy(&body).to_string(); + assert!(text.contains("data: ev0"), "got: {text}"); + assert!(text.contains("data: ev1"), "got: {text}"); + assert!(text.contains("data: ev2"), "got: {text}"); + // Events are in order. + let (i0, i1, i2) = ( + text.find("ev0").unwrap(), + text.find("ev1").unwrap(), + text.find("ev2").unwrap(), + ); + assert!(i0 < i1 && i1 < i2, "events out of order: {text}"); + // The stream ran past the 2 s timeout without being cut off. + assert!( + started.elapsed() >= std::time::Duration::from_secs(3), + "the stream should have outlived proxy_timeout_secs" + ); +} + +#[tokio::test] +async fn a_stream_carries_the_upstream_error_source() { + let up = FakeUpstream::start().await; + let h = Harness::graded(); + h.wire_upstream("svc", "http", up.port).await; + let (_, hdrs, _) = h.raw("GET", "/oagw/v1/proxy/svc/sse", None, &[]).await; + assert_eq!(header(&hdrs, SOURCE).as_deref(), Some("upstream")); +} + +// ---- WebSocket --------------------------------------------------------- + +#[tokio::test] +async fn a_websocket_upgrade_is_relayed_with_frames_and_close_code() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let ws = FakeWsUpstream::start().await; + + // The upgrade needs a real listening socket, so the gear's router is served + // on a loopback port rather than driven through `oneshot`. + let h = Harness::graded(); + h.wire_upstream("chat", "ws", ws.port).await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let gw_port = listener.local_addr().unwrap().port(); + let app = h.router.clone(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + + let mut sock = tokio::net::TcpStream::connect(("127.0.0.1", gw_port)) + .await + .unwrap(); + let req = "GET /oagw/v1/proxy/chat/room HTTP/1.1\r\n\ + Host: 127.0.0.1\r\n\ + Upgrade: websocket\r\n\ + Connection: Upgrade\r\n\ + Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ + Sec-WebSocket-Version: 13\r\n\ + Sec-WebSocket-Protocol: chat.v1\r\n\r\n"; + sock.write_all(req.as_bytes()).await.unwrap(); + + // Read the handshake response. + let mut buf = vec![0_u8; 4096]; + let n = sock.read(&mut buf).await.unwrap(); + let head = String::from_utf8_lossy(&buf[..n]).to_string(); + assert!( + head.starts_with("HTTP/1.1 101"), + "expected a 101, got: {head}" + ); + assert!( + head.to_lowercase().contains("sec-websocket-accept:"), + "handshake missing accept: {head}" + ); + assert!( + head.to_lowercase().contains("chat.v1"), + "the negotiated subprotocol should be relayed: {head}" + ); + + // Send a masked text frame and read the echo. + let payload = b"hello"; + let mask = [0x01_u8, 0x02, 0x03, 0x04]; + let mut frame = vec![0x81, 0x80 | payload.len() as u8]; + frame.extend_from_slice(&mask); + frame.extend(payload.iter().enumerate().map(|(i, b)| b ^ mask[i % 4])); + sock.write_all(&frame).await.unwrap(); + + let n = sock.read(&mut buf).await.unwrap(); + assert!(n >= 2, "expected an echoed frame"); + let len = usize::from(buf[1] & 0x7f); + let echoed = String::from_utf8_lossy(&buf[2..2 + len]).to_string(); + assert_eq!(echoed, "ECHO:hello"); + + // Close with code 1000 and check the close frame comes back. + let close_payload = [0x03_u8, 0xe8]; // 1000 + let mut close = vec![0x88, 0x80 | close_payload.len() as u8]; + close.extend_from_slice(&mask); + close.extend(close_payload.iter().enumerate().map(|(i, b)| b ^ mask[i % 4])); + sock.write_all(&close).await.unwrap(); + + let n = sock.read(&mut buf).await.unwrap(); + assert!(n >= 4, "expected a close frame back"); + assert_eq!(buf[0] & 0x0f, 0x8, "expected a close opcode"); + assert_eq!( + u16::from_be_bytes([buf[2], buf[3]]), + 1000, + "the close code should propagate" + ); +} + +// ---- CORS -------------------------------------------------------------- + +#[tokio::test] +async fn a_preflight_is_answered_204_without_touching_the_upstream() { + let h = Harness::graded(); + // Deliberately no upstream registered: the preflight must be answered + // before upstream resolution. + let (s, hdrs, _) = h + .raw( + "OPTIONS", + "/oagw/v1/proxy/whatever/x", + None, + &[ + ("origin", "https://app.example"), + ("access-control-request-method", "PUT"), + ("access-control-request-headers", "x-a"), + ], + ) + .await; + assert_eq!(s, StatusCode::NO_CONTENT); + assert_eq!( + header(&hdrs, "access-control-allow-origin").as_deref(), + Some("https://app.example") + ); + assert_eq!( + header(&hdrs, "access-control-allow-methods").as_deref(), + Some("PUT") + ); + assert_eq!( + header(&hdrs, "access-control-max-age").as_deref(), + Some("86400") + ); + assert!(header(&hdrs, "vary").is_some()); +} + +#[tokio::test] +async fn a_disallowed_origin_on_an_actual_request_is_403() { + let up = FakeUpstream::start().await; + let h = Harness::graded(); + let (_, created) = h + .json( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": "corsed", + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": up.port}]}, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", + "cors": {"enabled": true, "allowed_origins": ["https://good.example"], "allowed_methods": ["GET"]} + })), + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + h.json( + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({ + "upstream_id": id, + "match": {"http": {"methods": ["GET"], "path": "/"}} + })), + ) + .await; + + let (s, hdrs, body) = h + .raw( + "GET", + "/oagw/v1/proxy/corsed/x", + None, + &[("origin", "https://evil.example")], + ) + .await; + assert_eq!(s, StatusCode::FORBIDDEN); + assert_eq!(header(&hdrs, SOURCE).as_deref(), Some("gateway")); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(v["type"], "cf.oagw.cors.origin_not_allowed.v1"); + + // An allowed origin passes and gains the CORS response headers. + let (s, hdrs, _) = h + .raw( + "GET", + "/oagw/v1/proxy/corsed/x", + None, + &[("origin", "https://good.example")], + ) + .await; + assert_eq!(s, StatusCode::OK); + assert_eq!( + header(&hdrs, "access-control-allow-origin").as_deref(), + Some("https://good.example") + ); +} + +#[tokio::test] +async fn a_disallowed_method_on_an_actual_request_is_403_with_its_own_type() { + let up = FakeUpstream::start().await; + let h = Harness::graded(); + let (_, created) = h + .json( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": "corsm", + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": up.port}]}, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", + "cors": {"enabled": true, "allowed_origins": ["https://good.example"], "allowed_methods": ["GET"]} + })), + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + h.json( + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({ + "upstream_id": id, + "match": {"http": {"methods": ["GET", "DELETE"], "path": "/"}} + })), + ) + .await; + + let (s, _, body) = h + .raw( + "DELETE", + "/oagw/v1/proxy/corsm/x", + None, + &[("origin", "https://good.example")], + ) + .await; + assert_eq!(s, StatusCode::FORBIDDEN); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(v["type"], "cf.oagw.cors.method_not_allowed.v1"); +} + +// ---- rate limiting ----------------------------------------------------- + +async fn wire_rate_limited(h: &Harness, port: u16, alias: &str, rate: u32) { + let (_, created) = h + .json( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": alias, + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": port}]}, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", + "rate_limit": {"sustained": {"rate": rate, "window": "hour"}, "burst": {"capacity": rate}} + })), + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + h.json( + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({ + "upstream_id": id, + "match": {"http": {"methods": ["GET"], "path": "/"}} + })), + ) + .await; +} + +#[tokio::test] +async fn an_allowed_request_carries_the_rate_limit_headers() { + let up = FakeUpstream::start().await; + let h = Harness::graded(); + wire_rate_limited(&h, up.port, "rl1", 5).await; + + let (s, hdrs, _) = h.raw("GET", "/oagw/v1/proxy/rl1/x", None, &[]).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(header(&hdrs, "x-ratelimit-limit").as_deref(), Some("5")); + assert_eq!(header(&hdrs, "x-ratelimit-remaining").as_deref(), Some("4")); + assert!(header(&hdrs, "x-ratelimit-reset").is_some()); +} + +#[tokio::test] +async fn exceeding_the_rate_limit_answers_429_with_retry_after() { + let up = FakeUpstream::start().await; + let h = Harness::graded(); + // One token per hour: the second request cannot be admitted. + wire_rate_limited(&h, up.port, "rl2", 1).await; + + let (s1, _, _) = h.raw("GET", "/oagw/v1/proxy/rl2/x", None, &[]).await; + assert_eq!(s1, StatusCode::OK); + + let (s2, hdrs, body) = h.raw("GET", "/oagw/v1/proxy/rl2/x", None, &[]).await; + assert_eq!(s2, StatusCode::TOO_MANY_REQUESTS); + assert_eq!(header(&hdrs, SOURCE).as_deref(), Some("gateway")); + let text = String::from_utf8_lossy(&body).to_string(); + assert!( + text.contains("retry_after") || header(&hdrs, "retry-after").is_some(), + "a rejection should carry a retry hint: {text}" + ); +} + +#[tokio::test] +async fn an_upstream_without_a_rate_limit_is_not_limited() { + let up = FakeUpstream::start().await; + let h = Harness::graded(); + h.wire_upstream("free", "http", up.port).await; + for _ in 0..5 { + let (s, hdrs, _) = h.raw("GET", "/oagw/v1/proxy/free/x", None, &[]).await; + assert_eq!(s, StatusCode::OK); + assert!(header(&hdrs, "x-ratelimit-limit").is_none()); + } +} diff --git a/gears/system/oagw/oagw/tests/review_fixes.rs b/gears/system/oagw/oagw/tests/review_fixes.rs new file mode 100644 index 0000000..5b71d5e --- /dev/null +++ b/gears/system/oagw/oagw/tests/review_fixes.rs @@ -0,0 +1,507 @@ +//! Regression tests for the defects the code review found. +//! +//! Each test names the behaviour that was wrong before the fix, so a +//! reintroduction fails loudly rather than silently. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod common; + +use axum::http::StatusCode; +use common::{FakeUpstream, Harness, header}; + +const SOURCE: &str = "x-oagw-error-source"; + +// ---- F1: a 429 must carry a wire Retry-After header -------------------- + +async fn wire_one_per_hour(h: &Harness, port: u16, alias: &str) { + let (_, created) = h + .json( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": alias, + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": port}]}, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", + "rate_limit": {"sustained": {"rate": 1, "window": "hour"}, "burst": {"capacity": 1}} + })), + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + h.json( + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({ + "upstream_id": id, + "match": {"http": {"methods": ["GET"], "path": "/"}} + })), + ) + .await; +} + +#[tokio::test] +async fn a_rate_limit_rejection_carries_a_wire_retry_after_header() { + let up = FakeUpstream::start().await; + let h = Harness::graded(); + wire_one_per_hour(&h, up.port, "rl-header").await; + + let (s1, _, _) = h.raw("GET", "/oagw/v1/proxy/rl-header/x", None, &[]).await; + assert_eq!(s1, StatusCode::OK); + + let (s2, hdrs, _) = h.raw("GET", "/oagw/v1/proxy/rl-header/x", None, &[]).await; + assert_eq!(s2, StatusCode::TOO_MANY_REQUESTS); + // The header itself, not merely the JSON body's retry_after_seconds field. + let retry = header(&hdrs, "retry-after").expect("429 must carry Retry-After"); + assert!( + retry.parse::().is_ok(), + "Retry-After must be a number of seconds, got {retry}" + ); +} + +// ---- F6: chain ordering ------------------------------------------------ + +#[tokio::test] +async fn a_request_matching_no_route_is_404_even_when_cors_would_reject_it() { + // CORS must be evaluated after the route is resolved, so an unmatched + // request reports "no route" rather than being pre-empted by a CORS verdict. + let up = FakeUpstream::start().await; + let h = Harness::graded(); + let (_, created) = h + .json( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": "ordered", + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": up.port}]}, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", + "cors": {"enabled": true, "allowed_origins": ["https://good.example"], "allowed_methods": ["GET"]} + })), + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + h.json( + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({ + "upstream_id": id, + "match": {"http": {"methods": ["GET"], "path": "/allowed"}} + })), + ) + .await; + + let (s, hdrs, _) = h + .raw( + "GET", + "/oagw/v1/proxy/ordered/nowhere", + None, + &[("origin", "https://evil.example")], + ) + .await; + assert_eq!(s, StatusCode::NOT_FOUND, "route resolution comes first"); + assert_eq!(header(&hdrs, SOURCE).as_deref(), Some("gateway")); +} + +// ---- F5/F3: target-host shape validation ------------------------------- + +#[tokio::test] +async fn a_target_host_carrying_a_scheme_or_path_is_a_distinct_invalid_error() { + let up = FakeUpstream::start().await; + let h = Harness::graded(); + h.wire_upstream("shape", "http", up.port).await; + + for bad in [ + "http://127.0.0.1", + "127.0.0.1/../x", + "127.0.0.1:8080", + "user@127.0.0.1", + ] { + let (s, _, body) = h + .raw( + "GET", + "/oagw/v1/proxy/shape/x", + None, + &[("x-oagw-target-host", bad)], + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST, "`{bad}` should be rejected"); + let text = String::from_utf8_lossy(&body).to_string(); + assert!( + text.contains("invalid target host"), + "`{bad}` should be reported as invalid, not unknown: {text}" + ); + } +} + +#[tokio::test] +async fn a_well_formed_but_absent_target_host_is_reported_as_unknown() { + let up = FakeUpstream::start().await; + let h = Harness::graded(); + h.wire_upstream("shape2", "http", up.port).await; + + let (s, _, body) = h + .raw( + "GET", + "/oagw/v1/proxy/shape2/x", + None, + &[("x-oagw-target-host", "other.example")], + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST); + let text = String::from_utf8_lossy(&body).to_string(); + assert!( + text.contains("unknown target host"), + "a well-formed absent host is a distinct outcome: {text}" + ); +} + +// ---- F7/F8: plugin identifiers other than a bare UUID ------------------ + +#[tokio::test] +async fn the_plugin_listing_merges_the_builtin_catalog_with_custom_definitions() { + let h = Harness::graded(); + h.json( + "POST", + "/oagw/v1/plugins", + Some(serde_json::json!({ + "name": "mine", "plugin_type": "guard", "source_code": "x" + })), + ) + .await; + + let (s, page) = h.json("GET", "/oagw/v1/plugins?$top=100", None).await; + assert_eq!(s, StatusCode::OK); + let items = page["items"].as_array().unwrap(); + assert!( + items.iter().any(|i| i["origin"] == "builtin" && i["served"] == true), + "a served built-in must appear" + ); + assert!( + items.iter().any(|i| i["origin"] == "builtin" && i["served"] == false), + "a catalog-only entry must appear" + ); + assert!( + items.iter().any(|i| i["origin"] == "custom" && i["name"] == "mine"), + "the tenant's own definition must appear" + ); +} + +#[tokio::test] +async fn a_builtin_identifier_resolves_on_the_get_route() { + let h = Harness::graded(); + let id = "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1"; + let (s, body) = h.json("GET", &format!("/oagw/v1/plugins/{id}"), None).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(body["served"], true); + assert_eq!(body["plugin_type"], "guard"); +} + +#[tokio::test] +async fn an_unresolvable_named_identifier_is_404_not_a_routing_rejection() { + let h = Harness::graded(); + let (s, _) = h.json("GET", "/oagw/v1/plugins/not-a-real-plugin", None).await; + assert_eq!(s, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn a_builtin_cannot_be_deleted() { + let h = Harness::graded(); + let id = "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1"; + let (s, _) = h.json("DELETE", &format!("/oagw/v1/plugins/{id}"), None).await; + assert_eq!(s, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn a_builtin_has_no_source_text() { + let h = Harness::graded(); + let id = "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1"; + let (s, _) = h + .json("GET", &format!("/oagw/v1/plugins/{id}/source"), None) + .await; + assert_eq!(s, StatusCode::NOT_FOUND); +} + +// ---- F13: plugin definition validation --------------------------------- + +#[tokio::test] +async fn a_plugin_definition_without_source_text_is_400() { + let h = Harness::graded(); + let (s, _) = h + .json( + "POST", + "/oagw/v1/plugins", + Some(serde_json::json!({"name": "empty", "plugin_type": "guard"})), + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn a_plugin_definition_with_a_non_object_config_schema_is_400() { + let h = Harness::graded(); + let (s, _) = h + .json( + "POST", + "/oagw/v1/plugins", + Some(serde_json::json!({ + "name": "badschema", "plugin_type": "guard", + "source_code": "x", "config_schema": "not-an-object" + })), + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST); +} + +// ---- required-headers guard, end to end -------------------------------- + +async fn wire_guarded(h: &Harness, port: u16, alias: &str, request_headers: &str) { + let (s, created) = h + .json( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": alias, + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": port}]}, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", + "plugins": {"items": ["gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1"]}, + "auth": {"config": {"required_request_headers": request_headers}} + })), + ) + .await; + assert_eq!(s, StatusCode::CREATED, "guarded upstream create failed: {created}"); + let id = created["id"].as_str().unwrap().to_owned(); + h.json( + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({ + "upstream_id": id, + "match": {"http": {"methods": ["GET"], "path": "/"}} + })), + ) + .await; +} + +#[tokio::test] +async fn a_missing_required_request_header_is_400() { + let up = FakeUpstream::start().await; + let h = Harness::graded(); + wire_guarded(&h, up.port, "guarded", "x-needed").await; + + let (s, hdrs, _) = h.raw("GET", "/oagw/v1/proxy/guarded/x", None, &[]).await; + assert_eq!(s, StatusCode::BAD_REQUEST); + assert_eq!(header(&hdrs, SOURCE).as_deref(), Some("gateway")); + + // Supplying it lets the request through. + let (s, _, _) = h + .raw("GET", "/oagw/v1/proxy/guarded/x", None, &[("x-needed", "1")]) + .await; + assert_eq!(s, StatusCode::OK); +} + +#[tokio::test] +async fn an_all_blank_required_header_list_is_a_no_op() { + let up = FakeUpstream::start().await; + let h = Harness::graded(); + wire_guarded(&h, up.port, "blankguard", " , , ").await; + let (s, _, _) = h.raw("GET", "/oagw/v1/proxy/blankguard/x", None, &[]).await; + assert_eq!(s, StatusCode::OK); +} + +// ---- body cap ---------------------------------------------------------- + +#[tokio::test] +async fn a_declared_over_cap_request_body_is_413() { + let up = FakeUpstream::start().await; + let h = Harness::graded(); + h.wire_upstream("cap", "http", up.port).await; + + let (s, hdrs, _) = h + .raw( + "POST", + "/oagw/v1/proxy/cap/x", + None, + &[("content-length", "104857601")], + ) + .await; + assert_eq!(s, StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!(header(&hdrs, SOURCE).as_deref(), Some("gateway")); +} + +// ---- WebTransport deferral -------------------------------------------- + +#[tokio::test] +async fn a_webtransport_upstream_is_501() { + let h = Harness::graded(); + let (_, created) = h + .json( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": "wtsvc", + "server": {"endpoints": [{"scheme": "wt", "host": "127.0.0.1", "port": 443}]}, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1" + })), + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + h.json( + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({ + "upstream_id": id, + "match": {"http": {"methods": ["GET"], "path": "/"}} + })), + ) + .await; + + let (s, hdrs, _) = h.raw("GET", "/oagw/v1/proxy/wtsvc/x", None, &[]).await; + assert_eq!(s, StatusCode::NOT_IMPLEMENTED); + assert_eq!(header(&hdrs, SOURCE).as_deref(), Some("gateway")); +} + +// ---- route management gaps the review flagged -------------------------- + +#[tokio::test] +async fn a_route_colliding_only_with_a_disabled_sibling_is_created() { + let h = Harness::graded(); + let (_, up) = h + .json( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": "dis", + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": 9}]}, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1" + })), + ) + .await; + let id = up["id"].as_str().unwrap().to_owned(); + + let (s1, _) = h + .json( + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({ + "upstream_id": id, "enabled": false, + "match": {"http": {"methods": ["GET"], "path": "/v1"}} + })), + ) + .await; + assert_eq!(s1, StatusCode::CREATED); + + let (s2, _) = h + .json( + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({ + "upstream_id": id, + "match": {"http": {"methods": ["GET"], "path": "/v1"}} + })), + ) + .await; + assert_eq!(s2, StatusCode::CREATED, "a disabled sibling must not collide"); +} + +#[tokio::test] +async fn an_invalid_tag_is_400() { + let h = Harness::graded(); + let (s, _) = h + .json( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": "tagged", "tags": ["Not Valid"], + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": 9}]}, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1" + })), + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn a_rate_limit_without_a_sustained_rate_is_400() { + let h = Harness::graded(); + let (s, _) = h + .json( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": "badrl", + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": 9}]}, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", + "rate_limit": {"sustained": {"rate": 0}} + })), + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST); +} + +// ---- proxy behaviours previously only unit-tested ---------------------- + +#[tokio::test] +async fn the_query_allowlist_filters_on_the_live_proxy_path() { + let up = FakeUpstream::start().await; + let h = Harness::graded(); + let (_, created) = h + .json( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": "q", + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": up.port}]}, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1" + })), + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + h.json( + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({ + "upstream_id": id, + "match": {"http": {"methods": ["GET"], "path": "/", "query_allowlist": ["keep"]}} + })), + ) + .await; + + let (s, _, body) = h + .raw("GET", "/oagw/v1/proxy/q/x?keep=1&drop=2", None, &[]) + .await; + assert_eq!(s, StatusCode::OK); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let path = v["path"].as_str().unwrap(); + assert!(path.contains("keep=1"), "allowlisted param must survive: {path}"); + assert!(!path.contains("drop=2"), "other params must be dropped: {path}"); +} + +#[tokio::test] +async fn path_suffix_mode_disabled_drops_the_remainder_on_the_live_path() { + let up = FakeUpstream::start().await; + let h = Harness::graded(); + let (_, created) = h + .json( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": "sfx", + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": up.port}]}, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1" + })), + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + h.json( + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({ + "upstream_id": id, + "match": {"http": {"methods": ["GET"], "path": "/base", "path_suffix_mode": "disabled"}} + })), + ) + .await; + + let (s, _, body) = h.raw("GET", "/oagw/v1/proxy/sfx/base/extra", None, &[]).await; + assert_eq!(s, StatusCode::OK); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(v["path"], "/base"); +}