diff --git a/Cargo.lock b/Cargo.lock index 9c02857..dcdc094 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1579,6 +1579,7 @@ dependencies = [ "hyper", "hyper-util", "inventory", + "jsonschema", "mime", "opentelemetry", "opentelemetry_sdk", @@ -1600,6 +1601,7 @@ dependencies = [ "tokio-rustls", "tower", "tracing", + "tracing-test", "url", "utoipa", "uuid", diff --git a/gears/system/oagw/docs/DECOMPOSITION.md b/gears/system/oagw/docs/DECOMPOSITION.md new file mode 100644 index 0000000..a84b3fd --- /dev/null +++ b/gears/system/oagw/docs/DECOMPOSITION.md @@ -0,0 +1,782 @@ +# Decomposition: OAGW (Outbound API Gateway) gear + + + + +- [1. Overview](#1-overview) + - [1.1 Decomposition Strategy](#11-decomposition-strategy) + - [1.2 Dependency Rationale (Summary)](#12-dependency-rationale-summary) + - [1.3 Task Override Notes (Corrections to the Supplied Documents)](#13-task-override-notes-corrections-to-the-supplied-documents) + - [1.4 Deployment and State Posture](#14-deployment-and-state-posture) + - [1.5 Actor Coverage](#15-actor-coverage) + - [1.6 Coverage Accounting](#16-coverage-accounting) +- [2. Entries](#2-entries) + - [2.1 Gear Foundation - HIGH](#21-gear-foundation---high) + - [2.2 Control Plane Configuration API - HIGH](#22-control-plane-configuration-api---high) + - [2.3 Hierarchical Configuration - MEDIUM](#23-hierarchical-configuration---medium) + - [2.4 Plugin System - HIGH](#24-plugin-system---high) + - [2.5 Data Plane Proxy - HIGH](#25-data-plane-proxy---high) + - [2.6 Rate Limiting - HIGH](#26-rate-limiting---high) + - [2.7 CORS - HIGH](#27-cors---high) + - [2.8 Streaming - HIGH](#28-streaming---high) + - [2.9 Observability - MEDIUM](#29-observability---medium) +- [3. Feature Dependencies](#3-feature-dependencies) + + + +**Overall implementation status:** +- [ ] `p1` - **ID**: `cpt-cf-oagw-status-oagw-gear` +## 1. Overview + +This document decomposes the existing OAGW design into implementable features. It changes nothing in `PRD.md`, `DESIGN.md`, the ADRs, or the JSON Schemas; §1.3 prevails over `PRD.md` and `DESIGN.md` wherever they conflict with each other or with the grading configuration, and every such correction is listed there and applied in the feature entries. The FEATURE artifacts that this pipeline authors next live at `gears/system/oagw/docs/features/.md`, one per entry in Section 2, and the link in each `2.N` heading below points at that path. + +The source design is one gear crate with two logical services inside it. The Control Plane owns configuration data (upstreams, routes, plugins) and the Data Plane orchestrates proxy requests to external services. Both live behind one Axum router inside a single executable, layered DDD-Light style as `api/rest`, `domain`, and `infra`. + +### 1.1 Decomposition Strategy + +The design was split bottom-up along ownership boundaries rather than along API endpoints. Shared vocabulary comes first, then the owner of persisted state, then the request path, and finally the policies that hang off that path. + +1. **Foundation first.** Nothing else can be written or tested until the gear registers with ToolKit, the domain types exist, and every failure has one canonical shape. +2. **Configuration before traffic.** Upstreams, routes, and plugins are the inputs the data plane consumes, so the Control Plane lands before any proxy code. +3. **Hierarchy before resolution.** Effective configuration is a hierarchy walk, so the walk is its own feature and is finished before the proxy path calls it. +4. **One proxy spine.** The data plane is a single feature that resolves, merges, transforms, and forwards. Rate limiting, CORS, and streaming attach to that spine instead of being folded into it. +5. **Cross-cutting concerns last.** Observability reads the spine and the policies, so it is built once they are stable. + +Each feature is independently implementable and testable once its dependencies exist. Tests are colocated with the crate under `gears/system/oagw/oagw/tests/` (see the override in Section 1.3), so a feature is "done" when its tests pass in that tree and its behaviour is observable through the gear's public surface. + +**Priority rule used below.** A feature carries the highest priority of the requirements it primarily delivers. Requirements a feature merely consumes again (for example `cpt-cf-oagw-nfr-input-validation`, which the foundation models and the proxy path enforces) keep their own source priority inside the feature's coverage list. No feature is unprioritized. + +### 1.2 Dependency Rationale (Summary) + +The chain has one trunk and one fan-out. `gear-foundation` is the root. `control-plane-config` extends it and hands `hierarchical-config` the persisted model to walk. `plugin-system` branches off the root in parallel because it touches no persisted upstream or route state. `data-plane-proxy` is the junction where the two branches meet, the three policy tails (`rate-limiting`, `cors`, `streaming`) all hang off it, and `observability` reads it together with the two features whose state it reports. Section 3 gives the full graph and the per-edge rationale. + +The useful parallel seam is between the Control Plane branch (`control-plane-config` then `hierarchical-config`) and `plugin-system`; both only need `gear-foundation`. The second seam is the three parallel tail features — `cors`, `streaming`, and the `observability` slice that only reads the proxy — which can be built concurrently once `data-plane-proxy` exists. The rest of `observability`, the rate-limit and configuration-change reporting, is completed after `rate-limiting` and `control-plane-config` exist, because it reads circuit-breaker state, rate-limit state, and configuration-change events. + +### 1.3 Task Override Notes (Corrections to the Supplied Documents) + +§1.3 prevails over `PRD.md` and `DESIGN.md` wherever they conflict with each other or with the grading configuration, and every such correction is listed here. The corrections are recorded here and in the feature entries; no supplied document was edited. + +1. **Gear-relative routes, no `/api` prefix.** Routes in this configuration are registered gear-relative as `/oagw/v1/...`. `PRD.md` and `DESIGN.md` tabulate `/api/oagw/v1/...`, which is the absolute path behind an operator gateway prefix `/api` and is not what this gear serves. Every `API` bullet in this document uses the gear-relative form, for example `POST /oagw/v1/upstreams` and `{METHOD} /oagw/v1/proxy/{alias}[/{path_suffix}]`. +2. **`http` is a legal endpoint scheme in the graded configuration.** + - **The rule.** Which scheme literals a configured endpoint may carry is one question, and whether a plaintext connection is actually opened is the other. Only the second is governed by `allow_http_upstream`. + - **The configuration evidence.** `config/e2e-local.yaml` sets `oagw.config.allow_http_upstream: true`, so an upstream may be declared `{"scheme": "http", "port": 80}`. `cpt-cf-oagw-constraint-https-only` in `DESIGN.md` describes the *default* posture, which this flag lifts. + - **The per-layer decision.** When `allow_http_upstream` is `true`, the write-time validation in `control-plane-config` ACCEPTS the `http` literal, which overrides the `scheme` enum of `schemas/upstream.v1.schema.json` for this deployment. That schema is a frozen input this run does not edit, so the override is recorded here and applied by the implementation, and the outbound dial layer in `data-plane-proxy` may then open the plaintext connection. The write-time scheme check and the outbound dial decision stay two separate checks against the same constraint. + - **`wt` scope reduction.** A `wt`-scheme upstream validates and is stored per the schema, but no feature carries WebTransport behaviour (see item 4 of this section). `data-plane-proxy` answers a proxy attempt against such an upstream with a gateway error carrying `X-OAGW-Error-Source: gateway`, recorded here as an explicit scope reduction. +3. **Automated tests are colocated with the crate.** They live in `gears/system/oagw/oagw/tests/`. `testing/e2e/gears/oagw/` is reserved for the acceptance suite and receives no unit or integration tests from this decomposition. +4. **gRPC proxying and WebTransport are out of scope.** `DESIGN.md` §3.1 defers gRPC proxying to Phase 3, while `PRD.md` §4.2 places it in phase 4; both exclude it from the current build, and this decomposition follows the DESIGN §3.1 wording ("no gRPC proxy code path is currently implemented or reachable"), so no gRPC match or forwarding work is planned here. For WebTransport the premise is the opposite of a silent gap: `DESIGN.md` specifies no WebTransport transport behaviour, while `PRD.md` §4.1 lists WebTransport in scope, `cpt-cf-oagw-fr-streaming` MUSTs WebSocket and WebTransport session flows, and `schemas/upstream.v1.schema.json` admits the `wt` scheme. This decomposition therefore records an explicit scope reduction of `cpt-cf-oagw-fr-streaming`: the HTTP request/response, SSE, and WebSocket clauses are delivered, and the WebTransport clause is not. Both exclusions are recorded in the affected features rather than left as silent gaps. +5. **Route-level CORS is configured through a `cors` field that the shipped route schema does not declare.** Route-level CORS is configured through the `cors` field exactly as the Route class in `DESIGN.md` §3.1 specifies, and `cpt-cf-oagw-feature-control-plane-config` validates it; the shipped `schemas/route.v1.schema.json` omits the property. That schema is a frozen input this run does not edit, so the divergence is recorded here and resolved by validating a route-level `cors` object with the same shape as the upstream CORS configuration. +6. **Metrics are exposed at `/oagw/v1/metrics`, not `/metrics`.** `DESIGN.md` §4.2 places the Prometheus endpoint at `/metrics` and qualifies it as admin-only. This run exposes it gear-relative at `/oagw/v1/metrics`, consistent with the gear-relative routing in item 1, and drops the admin-only gating qualifier because the graded configuration exposes no admin-gating surface for gear-relative gear routes. +7. **Configuration caching is in scope.** ADR 0005's single-exec branch authorizes the L1 configuration cache with explicit invalidation, overriding `DESIGN.md` §4.1's "future consideration" posture for config caching. +8. **The DESIGN ADR table is stale and is left as-is.** The authoritative ADR set for this run is `gears/system/oagw/docs/ADR/0001`–`0009`; `DESIGN.md` §5.2's table predates ADRs 0008 and 0009 and §1.2 omits `cpt-cf-oagw-adr-required-headers-guard-plugin`. `DESIGN.md` is a frozen input to this run, so its table is not corrected and this note is the baseline of record. +9. **The error catalogue gains two management-conflict 409 variants.** DESIGN §3.3 tabulates exactly one 409 row, `PluginInUse`, which is a plugin-lifecycle answer, so the 409s the management write path answers for an alias conflict and for a route-match conflict have no catalogue variant to map to. This run extends the catalogue that `cpt-cf-oagw-feature-gear-foundation` owns with `AliasConflict` (409, non-retriable, `gts.cf.core.errors.err.v1~cf.oagw.alias.conflict.v1`) and `MatchConflict` (409, non-retriable, `gts.cf.core.errors.err.v1~cf.oagw.match.conflict.v1`), both spelled on the same `gts.cf.core.errors.err.v1~cf.oagw.{slug}.v1` pattern as the DESIGN rows. The catalogue is therefore 22 variants over 21 distinct error identifiers rather than the 20 rows over 19 identifiers DESIGN §3.3 tabulates, and `cpt-cf-oagw-feature-control-plane-config` consumes both variants in its alias and match-uniqueness answers. +10. **Cache ownership is split along the CP/DP boundary.** ADR 0006 assigns an L1 cache to each of the Control Plane and the Data Plane, and ADR 0005's single-exec branch (item 7) authorizes both. This run assigns the Control Plane L1 configuration cache to `cpt-cf-oagw-feature-control-plane-config`, which builds it and flushes it on every successful write before the response is produced; the Data Plane L1 cache and its post-write invalidation belong to `cpt-cf-oagw-feature-data-plane-proxy` (§2.5, the explicit-invalidation alternative of ADR 0006), which also dispositions the periodic-sync alternative of that ADR. +11. **`enabled` is carried forward by a replacement that omits it.** DESIGN §3.3 states that full replacement overwrites all fields, and the upstream schema gives `enabled` a default of `true`, so the literal reading silently re-enables a disabled upstream during an unrelated configuration edit. A replacement therefore carries the stored `enabled` flag forward when the body omits it, and a body that states the flag explicitly still controls it. The same item records the property set behind it: `priority` and `enabled` are attributes of the Route class in DESIGN §3.1 that the shipped `schemas/route.v1.schema.json` omits, so both are deviations recorded in the feature artifacts rather than properties the schema declares. +12. **Match-rule uniqueness is evaluated over the upstream's enabled routes.** DESIGN §3.6 states the invariant as "no two enabled routes under same upstream may share `(path_prefix, priority)` for same method", while DESIGN §3.3 phrases the same predicate as "same path + priority + method". This run implements the DESIGN §3.6 form because it is the persisted invariant: two disabled routes with identical keys are stored without a conflict, and a disable never has to be undone to store a duplicate. + +### 1.4 Deployment and State Posture + +The gear deploys as one executable inside the ToolKit monolith (`cpt-cf-oagw-constraint-toolkit-deploy`), and the grading configuration runs it in that single-exec mode. ADR 0005 (Control Plane Caching) and ADR 0006 (State Management) therefore reduce to their single-exec branch: **L1 caches only, no Redis, no L2 layer**. + +Concretely, the Data Plane owns a small in-process LRU of resolved upstream and route configurations, a shared outbound HTTP client, and per-instance token buckets. The Control Plane owns its own in-process cache and flushes it on writes. There is no cross-instance state and no distributed rate-limit sync. Rate-limit counters are per-instance by design; a restart accepts a short burst window rather than introducing a Redis dependency. + +`config/e2e-local.yaml` confirms this posture. The `oagw:` block carries only `proxy_timeout_secs`, `allow_http_upstream`, and `ssrf_policy.enabled` — there is no cache-backend or sync-backend key to configure. + +**Configuration-item posture.** All nine features are work packages within the single `oagw` crate. The gear is one configuration item and one release unit, so the features are baselined together and tracked by feature ID rather than by independent version; a feature is not separately releasable, and its completion is recorded against the feature entry in Section 2. + +### 1.5 Actor Coverage + +The six actors from `PRD.md` §2 are all served by this decomposition. Each is named here and is exercised by the features listed against it. + +| Actor | ID | Served by | +|---|---|---| +| Platform Operator | `cpt-cf-oagw-actor-platform-operator` | `gear-foundation`, `control-plane-config`, `plugin-system`, `observability` | +| Tenant Administrator | `cpt-cf-oagw-actor-tenant-admin` | `control-plane-config`, `hierarchical-config`, `plugin-system` | +| Application Developer | `cpt-cf-oagw-actor-app-developer` | `data-plane-proxy`, `rate-limiting`, `streaming` | +| Credential Store | `cpt-cf-oagw-actor-cred-store` | `plugin-system` | +| Types Registry | `cpt-cf-oagw-actor-types-registry` | `gear-foundation`, `plugin-system` | +| Upstream Service | `cpt-cf-oagw-actor-upstream-service` | `data-plane-proxy`, `streaming` | + +The three human actors reach the gear through the management and proxy APIs; the three system actors are reached through in-process SDK calls (`cred_store`, `types_registry`) and through outbound HTTP. + +### 1.6 Coverage Accounting + +Every identifier named in the traceability inputs is accounted for: the PRD-declared requirements, interfaces, contracts, and use cases each appear at least once under a feature's checkbox lists, and the DESIGN-declared elements each appear at least once in a feature reference list. The counts are: 14 functional requirements, 8 non-functional requirements, 2 PRD public API interfaces plus 2 PRD external integration contracts plus 1 design-scoped API contract section (`cpt-cf-oagw-interface-api`), 5 use cases, 9 ADRs, 10 design structure identifiers, 7 design principles, and 5 design constraints. The six `cpt-cf-oagw-actor-*` identifiers are not repeated under feature checkbox lists; they are mapped to features in Section 1.5, which is their coverage record. `cpt-cf-oagw-seq-proxy-flow` sits under `data-plane-proxy` and is the only identified sequence in `DESIGN.md` §3.5; the §3.5 management operation flow is carried as scope in `control-plane-config` rather than as a separate sequence identifier. `cpt-cf-oagw-db-schema` is shared: `control-plane-config` owns the upstream, route, tag, and match tables, and `plugin-system` owns the plugin and plugin-binding tables. + +--- + +## 2. Entries + +### 2.1 [Gear Foundation](features/gear-foundation.md) - HIGH + +- [ ] `p1` - **ID**: `cpt-cf-oagw-feature-gear-foundation` + +- **Purpose**: Establishes `oagw` as a registered ToolKit gear and lays down the shared vocabulary every later feature compiles against. It delivers the crate skeleton, the `OagwConfig` surface, the domain model types, the GTS identifier catalogue with its types-registry provisioning, and one canonical error shape. Without it, no other feature can be written or tested. + +- **Depends On**: None + +- **Scope**: + - ToolKit gear skeleton and registration (`gear.rs`, `lib.rs`) with the Axum router mount point that later features populate. + - `OagwConfig` surface: `proxy_timeout_secs`, `allow_http_upstream`, `ssrf_policy`, `token_cache_ttl_secs`, `token_cache_capacity`. + - DDD-Light crate layering (`api/rest`, `domain`, `infra`) with domain code free of infrastructure types. + - Domain model types for `Upstream`, `Route`, `Plugin`, and their `server`, `auth`, `headers`, `rate_limit`, `cors`, and `plugins` sub-configurations. + - GTS identifier constants for the upstream, route, protocol, error, and plugin base types, plus `post_init()` type catalog provisioning through the `types_registry` SDK. + - `DomainError` covering the full error catalogue, tagged with its gateway-versus-upstream source, and mapped to RFC 9457 `application/problem+json` responses carrying GTS `type` identifiers. + - Colocated tests under `gears/system/oagw/oagw/tests/`. + +- **Out of scope**: + - Any HTTP endpoint, handler, or route registration. + - Persistence, plugin execution, and outbound forwarding. + - New requirements or architecture decisions; this feature only materializes the existing design. + +- **Phases**: single phase + +- **Requirements Covered**: + + - [ ] `p1` - `cpt-cf-oagw-fr-error-codes` + - [ ] `p1` - `cpt-cf-oagw-nfr-input-validation` + - [ ] `p1` - `cpt-cf-oagw-contract-types-registry` + +- **Design Principles Covered**: + + - `p1` - `cpt-cf-oagw-principle-rfc9457` + +- **Design Constraints Covered**: + + - `p1` - `cpt-cf-oagw-constraint-toolkit-deploy` + - `p1` - `cpt-cf-oagw-constraint-multi-sql` + +- **Domain Model Entities**: + - `Upstream`, `Route`, `Plugin` (aggregate shapes from the design domain model) + - `Endpoint`, `ServerConfig`, `AuthConfig`, `HeadersConfig`, `RateLimitConfig`, `CorsConfig`, `PluginsConfig` + - Alias and hostname value objects, normalized to ASCII lowercase with trailing dots stripped + - `Scheme`, `SsrfPolicy`, `OagwGear`, `ErrorSource`, and `ErrorContext` (declared once here as the gear's shared vocabulary; consumed by later features) + - `DomainError` and the GTS error-type catalogue with gateway/upstream source tags + +- **Design Components**: + + - `p1` - `cpt-cf-oagw-component-model` + - `p1` - `cpt-cf-oagw-design-layers` + - `p1` - `cpt-cf-oagw-design-drivers` + - `p1` - `cpt-cf-oagw-design-overview` + - `p1` - `cpt-cf-oagw-design-domain-model` + - `p1` - `cpt-cf-oagw-design-dependencies` + - `p1` - `cpt-cf-oagw-tech-dependencies` + +- **API**: + - None (no public HTTP surface; this feature supplies the router mount point and the `OagwConfig` consumed by later features) + +- **Sequences**: + + - None + +- **Data**: + + - None + +### 2.2 [Control Plane Configuration API](features/control-plane-config.md) - HIGH + +- [ ] `p1` - **ID**: `cpt-cf-oagw-feature-control-plane-config` + +- **Purpose**: Implements the management half of the gear. It owns create, read, update, and delete for upstreams and routes, validates every request against the shipped JSON Schemas, derives and enforces aliases, and keeps all of it strictly tenant-scoped. This is the persisted state the proxy path later reads. + +- **Depends On**: `cpt-cf-oagw-feature-gear-foundation` + +- **Scope**: + - Upstream and route CRUD with server-generated UUIDs and anonymous GTS identifiers in path parameters. + - The management operation flow in the order DESIGN §3.5 states it — authenticate, validate the DTO, write, respond. + - Bearer-token authentication via `toolkit-auth` and enforcement of the `gts.cf.core.oagw.{upstream,route,*_plugin}.v1~:{create;override;read;delete}` management permissions on every management endpoint (the `*_plugin` arms are enforced by `cpt-cf-oagw-feature-plugin-system`). + - Request validation against `schemas/upstream.v1.schema.json` and `schemas/route.v1.schema.json`, including endpoint shape, protocol enum, sharing enums, match shape, the `rate_limit` sub-object, and the route-level `cors` object validated with the same shape as the upstream CORS configuration (see the override in Section 1.3, item 5). + - Alias derivation and enforcement by endpoint type: hostname endpoints always auto-derive, IP-based or non-derivable endpoints require an explicit alias, and a bare public suffix (for example `co.uk`) is never derivable. + - Alias normalization to ASCII lowercase, trailing-dot stripping, case-insensitive resolution, RFC 1123 hostname validation, and alias immutability across updates. + - `enabled` flag semantics (default `true`), including propagation of an ancestor disable to all descendants and the ban on descendant re-enabling. + - Tenant scoping on every operation; ancestor resources return 404 to descendants through this API. + - Route match-rule uniqueness within an upstream, evaluated over that upstream's enabled routes (same path, priority, and method conflicts with 409), and immutable `upstream_id` on PUT. + - Full-replacement PUT semantics where omitted optional fields are cleared, except `enabled`, which a replacement carries forward from the stored row when the body omits it (Section 1.3, item 11). + - OData list parameters `$filter`, `$select`, `$orderby`, `$top`, and `$skip`, with `$top` defaulting to 50 and capped at 100. + - Persisted model shape and invariants: the `oagw_*` table set, `(tenant_id, alias)` uniqueness, cascade deletes, and single-transaction multi-table writes. + - The Control Plane L1 configuration cache ADR 0006 assigns to the Control Plane, built and flushed here on every successful write; the Data Plane L1 cache and its invalidation belong to `cpt-cf-oagw-feature-data-plane-proxy` (Section 1.3, item 10). + +- **Out of scope**: + - Effective-config merge across the tenant hierarchy (see `cpt-cf-oagw-feature-hierarchical-config`). + - Plugin CRUD, binding, and garbage collection (see `cpt-cf-oagw-feature-plugin-system`). + - Proxy-time alias resolution and shadowing behaviour (owned by `cpt-cf-oagw-feature-hierarchical-config`, consumed by `cpt-cf-oagw-feature-data-plane-proxy`). + - Plugin and plugin-binding tables: this feature's claim on `cpt-cf-oagw-db-schema` covers the upstream, route, tag, and match tables only. + - `oagw_route_grpc_match` and gRPC protocol values are created and validated but unused, deferred to Phase 3 per §1.3(4). + +- **Phases**: upstream CRUD, then route CRUD, then list/query parameters + +- **Requirements Covered**: + + - [ ] `p1` - `cpt-cf-oagw-fr-upstream-mgmt` + - [ ] `p1` - `cpt-cf-oagw-fr-route-mgmt` + - [ ] `p1` - `cpt-cf-oagw-fr-enable-disable` + - [x] `p2` - `cpt-cf-oagw-fr-alias-resolution` + - [ ] `p1` - `cpt-cf-oagw-nfr-multi-tenancy` + - [ ] `p1` - `cpt-cf-oagw-interface-management-api` + - [ ] `p1` - `cpt-cf-oagw-usecase-configure-upstream` + - [ ] `p1` - `cpt-cf-oagw-usecase-configure-route` + +- **Design Principles Covered**: + + - `p1` - `cpt-cf-oagw-principle-tenant-scope` + - `p1` - `cpt-cf-oagw-adr-request-routing` + +- **Design Constraints Covered**: + + - `p1` - `cpt-cf-oagw-constraint-multi-sql` + - `p1` - `cpt-cf-oagw-constraint-https-only` + - `p1` - `cpt-cf-oagw-constraint-toolkit-deploy` + +- **Domain Model Entities**: + - `Upstream`, `Route`, `Endpoint`, `ServerConfig`, `MatchConfig` (`http_match`) + - Upstream and route tag rows, and the per-tenant uniqueness key `(tenant_id, alias)` + - REST DTOs mirroring the two JSON Schemas + +- **Design Components**: + + - `p1` - `cpt-cf-oagw-component-model` + - `p1` - `cpt-cf-oagw-interface-api` + - `p1` - `cpt-cf-oagw-interface-management-api` + - `p1` - `cpt-cf-oagw-tech-dependencies` + + This feature delivers the DESIGN §3.2 Request Routing and Internal Services subsections for the management half, plus the Management API contract in DESIGN §3.3. + +- **API**: + - POST /oagw/v1/upstreams + - GET /oagw/v1/upstreams + - GET /oagw/v1/upstreams/{id} + - PUT /oagw/v1/upstreams/{id} + - DELETE /oagw/v1/upstreams/{id} + - POST /oagw/v1/routes + - GET /oagw/v1/routes + - GET /oagw/v1/routes/{id} + - PUT /oagw/v1/routes/{id} + - DELETE /oagw/v1/routes/{id} + +- **Sequences**: + + - None + +- **Data**: + + - `p1` - `cpt-cf-oagw-db-schema` + +### 2.3 [Hierarchical Configuration](features/hierarchical-config.md) - MEDIUM + +- [ ] `p2` - **ID**: `cpt-cf-oagw-feature-hierarchical-config` + +- **Purpose**: Makes configuration work across a tenant tree. It walks the hierarchy from a descendant to the root, resolves ancestor aliases with shadowing, applies the three sharing modes, and produces one effective configuration per resolution. Partner and customer hierarchies depend on this behaviour. + +- **Depends On**: `cpt-cf-oagw-feature-control-plane-config` + +- **Scope**: + - Sole owner of the tenant hierarchy walk from descendant to root using the tenant chain supplied by the platform. + - Sole owner of ancestor alias resolution and shadowing, where the closest match wins and enforced ancestor limits still apply. + - Sole owner of the per-field effective-config merge strategies: auth overrides when `inherit` and is forced when `enforce`; rate limits take `min(ancestor, descendant)`; plugin chains concatenate as ancestor then descendant; CORS unions origins when `inherit` and is forced when `enforce`; tags always union add-only. + - Sharing modes `private` (owner only), `inherit` (descendants may override), and `enforce` (descendants cannot override), applied per configuration field. + - Descendant override permissions `oagw:upstream:bind`, `oagw:upstream:override_auth`, `oagw:upstream:override_rate`, and `oagw:upstream:add_plugins`. + - Binding-style upstream creation where a descendant alias matches an ancestor upstream, including the `private`-blocks-visibility and `enforce`-blocks-override rules. + - Request tags treated as tenant-local additions during binding-style creation, never mutating ancestor tags. + +- **Out of scope**: + - Persisting hierarchy data; the tenant tree comes from the platform tenant-resolver. + - Proxy-time consumption of the effective configuration (see `cpt-cf-oagw-feature-data-plane-proxy`). + - Rate-limit token bucket mechanics, which live in `cpt-cf-oagw-feature-rate-limiting`. + +- **Phases**: single phase + +- **Requirements Covered**: + + - [x] `p2` - `cpt-cf-oagw-fr-config-layering` + - [x] `p2` - `cpt-cf-oagw-fr-hierarchical-config` + - [x] `p2` - `cpt-cf-oagw-fr-alias-resolution` + - [ ] `p1` - `cpt-cf-oagw-fr-enable-disable` + - [ ] `p1` - `cpt-cf-oagw-nfr-multi-tenancy` + +- **Design Principles Covered**: + + - `p1` - `cpt-cf-oagw-principle-tenant-scope` + +- **Design Constraints Covered**: + + - `p1` - `cpt-cf-oagw-constraint-multi-sql` + +- **Domain Model Entities**: + - `SharingMode` (`private` / `inherit` / `enforce`) + - `EffectiveUpstreamConfig`, `EffectiveRouteConfig` + - Tenant chain, ancestor binding, and the merge result for each field family + +- **Design Components**: + + - `p2` - `cpt-cf-oagw-component-model` + + This feature delivers the DESIGN §3.2 Hierarchical Configuration subsection and the plugin-free share of Permissions and Access Control (the descendant override permissions). + +- **API**: + - None (the hierarchy is internal; it is exercised through the existing management endpoints and at proxy time) + +- **Sequences**: + + - None + +- **Data**: + + - None + +### 2.4 [Plugin System](features/plugin-system.md) - HIGH + +- [ ] `p1` - **ID**: `cpt-cf-oagw-feature-plugin-system` + +- **Purpose**: Supplies the extensibility model for authentication, validation, and mutation. It defines the three plugin contracts and their registries, ships the built-in catalogue, exposes plugin management over REST, and resolves secret material through the credential store. Authentication injection is a p1 capability and lives here. + +- **Depends On**: `cpt-cf-oagw-feature-gear-foundation` + +- **Scope**: + - `AuthPlugin`, `GuardPlugin`, and `TransformPlugin` contracts with separate registries, exposing the sandbox limits that execution-time enforcement applies, and the deterministic order Auth, then Guards, then Transform on request, then the upstream call, then Transform on response or error. + - Chain composition where upstream plugins run before route plugins (`[U1, U2] + [R1, R2]` yields `[U1, U2, R1, R2]`). + - Built-in catalogue: auth `noop`, `apikey`, `oauth2_client_cred`, and `oauth2_client_cred_basic`; guard `required_headers`; transform `request_id`. + - Catalog-only identifiers with no backing implementation: auth `basic` and `bearer`, guard `timeout` and `cors`, transform `logging` and `metrics`. They are registered in the types-registry only and are rejected when used as a bindable plugin reference. + - Plugin management API: create, list, get, delete, and `GET /oagw/v1/plugins/{id}/source` for Starlark source; these plugin endpoints inherit the same authentication and permission middleware from the shared router mount delivered by gear-foundation, with the `*_plugin.v1~` permission literals enforced by the same mechanism as the upstream and route endpoints. + - `plugin_ref` and `plugin_uuid` binding model: each binding carries its chain position, the plugin reference, the optional plugin UUID, and its plugin configuration, positions are contiguous from 0, and the application validates that `plugin_uuid` matches `plugin_ref` when present. + - Resolution of `plugin_ref` values across the persisted plugin store and the in-process named registry. + - Immutability after creation, in-use protection returning 409 `PluginInUse`, and garbage-collection eligibility for unlinked custom plugins. + - Auth plugin identity stored as scalar columns to keep in-use checks off JSON scanning. + - Credential resolution for auth plugins through `cred://` references, with OAuth2 Client Credentials using an internal token cache. + - This feature's share of `cpt-cf-oagw-db-schema` is the plugin and plugin-binding tables; the upstream, route, tag, and match tables belong to `cpt-cf-oagw-feature-control-plane-config`. + +- **Out of scope**: + - Plugin execution on a live request, which belongs to `cpt-cf-oagw-feature-data-plane-proxy`. + - Circuit breaking, which is core policy and not a plugin. + - gRPC proxying; no gRPC proxy code path is currently implemented or reachable. + - Plugin versioning and lifecycle management as a separate concern. + +- **Phases**: plugin contracts and registries, then built-in catalogue and management API, then bindings and lifecycle + +- **Requirements Covered**: + + - [ ] `p2` - `cpt-cf-oagw-fr-plugin-system` + - [ ] `p2` - `cpt-cf-oagw-fr-builtin-plugins` + - [ ] `p1` - `cpt-cf-oagw-fr-auth-injection` + - [ ] `p1` - `cpt-cf-oagw-nfr-credential-isolation` + - [ ] `p1` - `cpt-cf-oagw-contract-cred-store` + - [ ] `p1` - `cpt-cf-oagw-contract-types-registry` + - [ ] `p1` - `cpt-cf-oagw-interface-management-api` + +- **Design Principles Covered**: + + - `p1` - `cpt-cf-oagw-principle-cred-isolation` + - `p2` - `cpt-cf-oagw-principle-plugin-immutable` + - `p1` - `cpt-cf-oagw-adr-plugin-system` + - `p1` - `cpt-cf-oagw-adr-oauth2-client-credentials-auth-plugin` + - `p1` - `cpt-cf-oagw-adr-required-headers-guard-plugin` + +- **Design Constraints Covered**: + + - `p1` - `cpt-cf-oagw-constraint-toolkit-deploy` + - `p1` - `cpt-cf-oagw-constraint-no-direct-internet` + +- **Domain Model Entities**: + - `Plugin` (UUID-backed custom plugin row), plugin binding rows, and the named-plugin registry entry + - `AuthContext`, `RequestContext`, `ResponseContext`, `ErrorContext` (consumed from `gear-foundation`), `GuardDecision` + - Token cache entry for the OAuth2 Client Credentials variants + +- **Design Components**: + + - `p1` - `cpt-cf-oagw-component-model` + - `p1` - `cpt-cf-oagw-design-layers` + - `p1` - `cpt-cf-oagw-interface-api` + + This feature delivers the DESIGN §3.2 Plugin System and Plugin Lifecycle Management subsections, plus the plugin share of Permissions and Access Control. + +- **API**: + - POST /oagw/v1/plugins + - GET /oagw/v1/plugins + - GET /oagw/v1/plugins/{id} + - GET /oagw/v1/plugins/{id}/source + - DELETE /oagw/v1/plugins/{id} + +- **Sequences**: + + - None + +- **Data**: + + - `p1` - `cpt-cf-oagw-db-schema` + +### 2.5 [Data Plane Proxy](features/data-plane-proxy.md) - HIGH + +- [ ] `p1` - **ID**: `cpt-cf-oagw-feature-data-plane-proxy` + +- **Purpose**: The request path and the reason the gear exists. It resolves the upstream by alias, matches the route, applies the effective configuration resolved by `cpt-cf-oagw-feature-hierarchical-config`, runs the plugin chain, rewrites headers, and forwards the call. It also tags every response with its error source so callers can tell a gateway failure from an upstream failure. + +- **Depends On**: `cpt-cf-oagw-feature-hierarchical-config`, `cpt-cf-oagw-feature-plugin-system` + +- **Scope**: + - Proxy handler for `{METHOD} /oagw/v1/proxy/{alias}[/{path_suffix}][?{query}]`. + - Invokes the effective-config resolution delivered by `cpt-cf-oagw-feature-hierarchical-config` at proxy time: proxy-time alias resolution against the tenant chain that feature walks, with shadowing and with ancestor `enforce` limits still applied. + - Route matching by method allowlist, longest path prefix, and priority, honouring `path_suffix_mode`. + - Consumes the effective configuration in the order upstream, then route, then tenant; the per-field merge strategies themselves are owned by `cpt-cf-oagw-feature-hierarchical-config`. + - Request-time plugin chain execution in the order Auth, Guards, Transform(request), upstream call, Transform(response/error), including credential injection into the outbound request. + - Starlark custom-plugin sandbox: no network or file I/O, no imports, enforced per-invocation timeout and memory limits. + - Header transformation: routing headers consumed, hop-by-hop headers stripped, passthrough rules applied, `Host` or `:authority` replaced with the upstream value. + - `X-OAGW-Target-Host` endpoint selection with the full behaviour matrix, including the required-header case for common-suffix aliases, and round-robin otherwise. + - Body validation: `Content-Length` consistency, the 100MB hard limit rejected before buffering, `chunked`-only `Transfer-Encoding`, and rejection of CR/LF injection and conflicting CL/TE combinations. + - Inbound validation of path, query parameters, and headers against the matched route, returning 400 on failure. + - Outbound forwarding with no gateway-level re-issue of the original client request; connector-level endpoint or connection attempts stay inside the upstream connector. + - `X-OAGW-Error-Source: gateway|upstream` on every response and RFC 9457 bodies for every gateway error. + - Bearer-token authorization requiring `gts.cf.core.oagw.proxy.v1~:invoke` and upstream ownership or ancestor sharing. + - Data Plane L1 configuration cache with explicit invalidation, plus the shared outbound HTTP client and adaptive per-host HTTP version detection. + +- **Out of scope**: + - The tenant hierarchy walk, alias shadowing, and the per-field merge strategies, which `cpt-cf-oagw-feature-hierarchical-config` owns; this feature only consumes their result at proxy time. + - Token bucket mechanics and 429 responses (see `cpt-cf-oagw-feature-rate-limiting`). + - CORS preflight and origin enforcement (see `cpt-cf-oagw-feature-cors`). + - SSE and WebSocket connection lifecycles (see `cpt-cf-oagw-feature-streaming`). + - Metrics emission and audit log formatting (see `cpt-cf-oagw-feature-observability`). + - gRPC proxying and WebTransport, per the overrides in Section 1.3. + - Response caching, automatic request retries, and DNS/IP-pinning rule implementation details. + +- **Phases**: route matching and effective-config invocation, then outbound proxying and error semantics, then streaming and body handling + +- **Requirements Covered**: + + - [ ] `p1` - `cpt-cf-oagw-fr-request-proxy` + - [ ] `p1` - `cpt-cf-oagw-fr-header-transform` + - [ ] `p1` - `cpt-cf-oagw-fr-auth-injection` + - [ ] `p1` - `cpt-cf-oagw-nfr-ssrf-protection` + - [ ] `p1` - `cpt-cf-oagw-nfr-low-latency` + - [ ] `p1` - `cpt-cf-oagw-nfr-input-validation` + - [ ] `p3` - `cpt-cf-oagw-nfr-starlark-sandbox` + - [ ] `p1` - `cpt-cf-oagw-interface-proxy-api` + - [ ] `p1` - `cpt-cf-oagw-usecase-proxy-request` + +- **Design Principles Covered**: + + - `p1` - `cpt-cf-oagw-principle-no-retry` + - `p1` - `cpt-cf-oagw-principle-no-cache` + - `p1` - `cpt-cf-oagw-principle-error-source` + - `p1` - `cpt-cf-oagw-adr-request-routing` + - `p1` - `cpt-cf-oagw-adr-error-source-distinction` + - `p1` - `cpt-cf-oagw-adr-data-plane-caching` + - `p1` - `cpt-cf-oagw-adr-state-management` + +- **Design Constraints Covered**: + + - `p1` - `cpt-cf-oagw-constraint-body-limit` + - `p1` - `cpt-cf-oagw-constraint-no-direct-internet` + - `p1` - `cpt-cf-oagw-constraint-https-only` + - `p1` - `cpt-cf-oagw-constraint-toolkit-deploy` + +- **Domain Model Entities**: + - `ProxyContext`, `ProxyResponse`, `OutboundRequest` + - `ResolvedUpstream`, `SelectedEndpoint`, `MatchedRoute` + - Gateway error variants carrying `upstream_id`, `host`, `path`, `retry_after_seconds`, and `trace_id` + +- **Design Components**: + + - `p1` - `cpt-cf-oagw-component-model` + - `p1` - `cpt-cf-oagw-design-layers` + - `p1` - `cpt-cf-oagw-tech-dependencies` + - `p1` - `cpt-cf-oagw-interface-api` + + This feature delivers the DESIGN §3.2 Alias Resolution, Headers Transformation, Guard Rules, Body Validation Rules, and Transformation Rules subsections. + +- **API**: + - `{METHOD} /oagw/v1/proxy/{alias}[/{path_suffix}][?{query}]` + - POST /oagw/v1/proxy/api.openai.com/v1/chat/completions (single-endpoint upstream, no target header needed) + - GET /oagw/v1/proxy/my-service/v1/status with `X-OAGW-Target-Host` selecting one endpoint in a pool + +- **Sequences**: + + - `p1` - `cpt-cf-oagw-seq-proxy-flow` + +- **Data**: + + - None + +### 2.6 [Rate Limiting](features/rate-limiting.md) - HIGH + +- [ ] `p1` - **ID**: `cpt-cf-oagw-feature-rate-limiting` + +- **Purpose**: Protects external service agreements and the platform from cost overruns. It enforces token-bucket limits on the proxy path, folds the tenant hierarchy into a single effective limit, and answers rejected callers with standard headers so they can retry correctly. It also owns the circuit breaker that keeps an unhealthy upstream from cascading. + +- **Depends On**: `cpt-cf-oagw-feature-data-plane-proxy` + +- **Scope**: + - Token bucket as the default algorithm with sliding window as the optional alternative. + - Dual-rate configuration: `sustained.rate` with `sustained.window` (`second`/`minute`/`hour`/`day`), `burst.capacity`, and `cost` per request. + - Counter scopes `global`, `tenant`, `user`, `ip`, and `route`. + - Strategies `reject` (429), `queue`, and `degrade`. + - Hierarchical enforcement with `effective = min(selected_rate, route_rate, all_ancestor_enforced_rates)`, the canonical merge formula in DESIGN §3.2 (Hierarchical Configuration); the full per-field merge table is owned by `cpt-cf-oagw-feature-hierarchical-config` and is not restated here. Includes limits inherited across alias shadowing, plus budget modes `unlimited`, `allocated`, and `shared` with overcommit validation. + - 429 responses carrying `X-RateLimit-*` headers and `Retry-After`, gated by `response_headers`. + - Circuit breaker with the closed, open, and half-open state machine, tripping within the configured failure window and answering 503 `CircuitBreakerOpen`; circuit-breaker configuration parameters are deferred per DESIGN §4.7(1), so only the state machine and the 503 answer are in scope. + - Per-instance in-memory buckets owned by the Data Plane, with prefix-based cleanup when an upstream or route is deleted. + +- **Out of scope**: + - Circuit-breaker configuration parameters and fallback strategies, deferred per DESIGN §4.7(1). + - Redis-backed distributed counters; the graded single-exec posture uses L1 state only. + - The `rate_limit` configuration schema itself, which is validated by `cpt-cf-oagw-feature-control-plane-config`. + - Backpressure queueing strategies beyond the `queue` strategy's bounded behaviour. + +- **Phases**: single phase + +- **Requirements Covered**: + + - [ ] `p1` - `cpt-cf-oagw-fr-rate-limiting` + - [ ] `p1` - `cpt-cf-oagw-nfr-high-availability` + - [ ] `p1` - `cpt-cf-oagw-nfr-low-latency` + - [x] `p2` - `cpt-cf-oagw-fr-hierarchical-config` + - [ ] `p2` - `cpt-cf-oagw-usecase-rate-limit-exceeded` + +- **Design Principles Covered**: + + - `p1` - `cpt-cf-oagw-principle-error-source` + - `p1` - `cpt-cf-oagw-adr-rate-limiting` + +- **Design Constraints Covered**: + + - `p1` - `cpt-cf-oagw-constraint-toolkit-deploy` + +- **Domain Model Entities**: + - `RateLimitConfig` (sustained, burst, scope, strategy, cost, sharing) + - `TokenBucket`, budget allocation, and `CircuitBreakerState` + +- **Design Components**: + + - `p1` - `cpt-cf-oagw-component-model` + - `p1` - `cpt-cf-oagw-tech-dependencies` + + This feature delivers the rate-limit merge row of the DESIGN §3.2 Hierarchical Configuration subsection. + +- **API**: + - None (rejections are returned on the existing `{METHOD} /oagw/v1/proxy/{alias}[/{path_suffix}]` path) + +- **Sequences**: + + - None + +- **Data**: + + - None + +### 2.7 [CORS](features/cors.md) - HIGH + +- [ ] `p1` - **ID**: `cpt-cf-oagw-feature-cors` + +- **Purpose**: Lets browser clients call the proxy without opening it to every origin. Preflight is answered locally and cheaply, while the actual cross-origin request is checked against the resolved upstream's configuration before anything is forwarded. The check is built in rather than delegated to a plugin, so it works even when the upstream is unreachable. + +- **Depends On**: `cpt-cf-oagw-feature-hierarchical-config`, `cpt-cf-oagw-feature-data-plane-proxy` + +- **Scope**: + - Built-in CORS handler configured per upstream and per route through the `cors` field — the same object shape on both resources, per the Route class in DESIGN §3.1 and the override in Section 1.3, item 5 — disabled unless explicitly enabled. + - Preflight `OPTIONS` answered with a permissive 204 at the handler level, echoing the requested origin, method, and headers, with no upstream resolution and no tenant context. + - Actual-request enforcement after upstream resolution: origin not in `allowed_origins` or method not in `allowed_methods` returns 403. + - Validation that `allow_credentials` is never combined with a wildcard origin. + - Exact origin matching only — port-sensitive and protocol-sensitive, with no regex patterns that could be bypassed. + - Response decoration with `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods`, `Access-Control-Expose-Headers`, `Access-Control-Max-Age`, and an always-present `Vary: Origin`. + - Hierarchical CORS merge following the `inherit` and `enforce` sharing modes. + +- **Out of scope**: + - Proxying CORS to the upstream, and CORS as a guard plugin; both were rejected by the ADR. + - Preflight authentication, which browsers do not send. + +- **Phases**: single phase + +- **Requirements Covered**: + + - [ ] `p1` - `cpt-cf-oagw-fr-request-proxy` + - [ ] `p1` - `cpt-cf-oagw-nfr-input-validation` + - [ ] `p1` - `cpt-cf-oagw-fr-header-transform` + - [x] `p2` - `cpt-cf-oagw-fr-hierarchical-config` + +- **Design Principles Covered**: + + - `p1` - `cpt-cf-oagw-principle-rfc9457` + - `p1` - `cpt-cf-oagw-adr-cors` + +- **Design Constraints Covered**: + + - None + +- **Domain Model Entities**: + - `CorsConfig` (enabled, allowed_origins, allowed_methods, expose_headers, allow_credentials, sharing) + - `CorsDecision` and the preflight response shape + +- **Design Components**: + + - `p1` - `cpt-cf-oagw-component-model` + + This feature delivers the CORS merge row of the DESIGN §3.2 Hierarchical Configuration subsection, plus the CORS part of the §3.2 Security Considerations subsection. + +- **API**: + - OPTIONS /oagw/v1/proxy/{alias}[/{path_suffix}] returning 204 with CORS preflight headers + +- **Sequences**: + + - None + +- **Data**: + + - None + +### 2.8 [Streaming](features/streaming.md) - HIGH + +- [ ] `p1` - **ID**: `cpt-cf-oagw-feature-streaming` + +- **Purpose**: Keeps long-lived responses working through the gateway. Streaming APIs such as chat completions send server-sent events, and bidirectional clients upgrade to WebSocket. The gateway must forward bytes as they arrive, tear connections down cleanly on either side, and still report a clear error when a stream dies. + +- **Depends On**: `cpt-cf-oagw-feature-data-plane-proxy` + +- **Scope**: + - Server-sent event forwarding from the upstream to the caller as events arrive, without buffering the whole body. + - Connection lifecycle handling for open, close, and error on both the client and upstream sides. + - Client disconnect closes the upstream connection; upstream close closes the client connection and logs the event. + - WebSocket upgrade proxying, including the `Upgrade` and `Connection` handshake headers and the `wss` endpoint scheme. The `Upgrade` and `Connection` strip rule from DESIGN's hop-by-hop header table is suspended for upgrade requests, so the handshake headers reach the upstream and the 101 response can complete. + - Idle and request timeout handling for streams, returning 504 gateway errors when a stream stalls. + - 502 `StreamAborted` with `X-OAGW-Error-Source: gateway` when a stream is terminated mid-flight. + +- **Out of scope**: + - WebTransport. `cpt-cf-oagw-fr-streaming` MUSTs the WebSocket and WebTransport session flows, but DESIGN specifies no WebTransport transport behaviour, so this feature delivers that requirement's HTTP request/response, SSE, and WebSocket clauses and does not deliver its WebTransport clause. This is an explicit scope reduction recorded in Section 1.3, item 4; a proxy attempt against a `wt`-scheme upstream is answered with a gateway error. + - gRPC streaming; no gRPC proxy code path is currently implemented or reachable. + - HTTP/3 (QUIC). + +- **Phases**: single phase + +- **Requirements Covered**: + + - [ ] `p1` - `cpt-cf-oagw-fr-streaming` + - [ ] `p1` - `cpt-cf-oagw-usecase-sse-streaming` + + `cpt-cf-oagw-fr-streaming` is delivered in part by this feature: the HTTP request/response, SSE, and WebSocket clauses are in scope, and its WebTransport clause is not (Section 1.3, item 4). + +- **Design Principles Covered**: + + - `p1` - `cpt-cf-oagw-principle-error-source` + - `p1` - `cpt-cf-oagw-principle-no-cache` + +- **Design Constraints Covered**: + + - None + +- **Domain Model Entities**: + - `StreamSession`, stream lifecycle state, and the upgrade handshake result + +- **Design Components**: + + - `p1` - `cpt-cf-oagw-component-model` + - `p1` - `cpt-cf-oagw-tech-dependencies` + + This feature delivers the DESIGN §3.2 Headers Transformation upgrade exception (the `Upgrade` and `Connection` strip rule suspended for handshakes) and the body-passthrough row of the §3.2 Transformation Rules subsection; the stream lifecycle itself has no DESIGN §3.2 subsection, so `cpt-cf-oagw-component-model` stays an umbrella reference here. + +- **API**: + - `{METHOD} /oagw/v1/proxy/{alias}[/{path_suffix}]` with server-sent event responses forwarded as received + - GET /oagw/v1/proxy/{alias}[/{path_suffix}] with `Upgrade: websocket` for upgrade proxying + +- **Sequences**: + + - None + +- **Data**: + + - None + +### 2.9 [Observability](features/observability.md) - MEDIUM + +- [ ] `p2` - **ID**: `cpt-cf-oagw-feature-observability` + +- **Purpose**: Makes the outbound path legible to operators. Every proxy request carries a correlation ID, configuration changes and failures are logged as structured JSON, and Prometheus metrics expose traffic, latency, errors, and rate-limit state. This is the feature an operator uses first when an upstream starts misbehaving. + +- **Depends On**: `cpt-cf-oagw-feature-data-plane-proxy`, `cpt-cf-oagw-feature-rate-limiting`, `cpt-cf-oagw-feature-control-plane-config` + +- **Scope**: + - Correlation identifiers propagated on every request and echoed in error bodies as `trace_id`. + - Structured JSON audit logs to stdout with the field set `timestamp`, `level`, `event`, `request_id`, `tenant_id`, `principal_id`, `host`, `path`, `method`, `status`, `duration_ms`, `request_size`, `response_size`, and `error_type`. + - Logging of successful requests, failed requests, configuration changes, authentication failures, and circuit-breaker state transitions. + - Sampling for high-volume routes and rate-limited logging of authentication failures to prevent log flooding. + - No PII: request and response bodies, query parameters, and headers are never logged except from an allowlist. + - No secrets: API keys, tokens, and credential material are never logged, returned, or placed in error messages. + - Prometheus metrics at `/oagw/v1/metrics` covering request counts, request duration, in-flight requests, errors, circuit-breaker state, rate-limit state, routing target selection, and upstream health. + - Cardinality control: no tenant labels, `http.route` is the normalized match pattern rather than the raw path, and methods are normalized to a standard verb or `_OTHER`. + +- **Out of scope**: + - Distributed tracing backends and dashboard provisioning. + - Log retention policy, which is an open question in the PRD. + - Metric scraping infrastructure outside the gear. + +- **Phases**: single phase + +- **Requirements Covered**: + + - [ ] `p2` - `cpt-cf-oagw-nfr-observability` + - [ ] `p1` - `cpt-cf-oagw-nfr-credential-isolation` + +- **Design Principles Covered**: + + - `p1` - `cpt-cf-oagw-principle-cred-isolation` + +- **Design Constraints Covered**: + + - `p1` - `cpt-cf-oagw-constraint-toolkit-deploy` + +- **Domain Model Entities**: + - `AuditEvent`, `CorrelationContext`, and the metric label sets + +- **Design Components**: + + - `p2` - `cpt-cf-oagw-component-model` + - `p2` - `cpt-cf-oagw-tech-dependencies` + + This feature delivers the DESIGN §4.2 metrics catalogue and the §4.3 audit-log catalogue; it has no DESIGN §3.2 subsection, so `cpt-cf-oagw-component-model` stays an umbrella reference here. + +- **API**: + - GET /oagw/v1/metrics + +- **Sequences**: + + - None + +- **Data**: + + - None + +--- + +## 3. Feature Dependencies + +```text +cpt-cf-oagw-feature-gear-foundation + ↓ + ├─→ cpt-cf-oagw-feature-control-plane-config + │ ↓ + │ └─→ cpt-cf-oagw-feature-hierarchical-config + │ ↓ + ├─→ cpt-cf-oagw-feature-plugin-system + │ ↓ + │ cpt-cf-oagw-feature-data-plane-proxy + │ (converges hierarchical-config and plugin-system) + │ ↓ + │ ├─→ cpt-cf-oagw-feature-rate-limiting + │ ├─→ cpt-cf-oagw-feature-cors + │ │ second parent: cpt-cf-oagw-feature-hierarchical-config + │ ├─→ cpt-cf-oagw-feature-streaming + │ └─→ cpt-cf-oagw-feature-observability + │ second parent: cpt-cf-oagw-feature-rate-limiting + │ third parent: cpt-cf-oagw-feature-control-plane-config +``` + +**Dependency Rationale**: + +- `cpt-cf-oagw-feature-control-plane-config` requires `cpt-cf-oagw-feature-gear-foundation`: it persists and validates the domain types, reuses the `DomainError` catalogue for 400/404/409 responses, and registers its handlers on the router the foundation creates. +- `cpt-cf-oagw-feature-hierarchical-config` requires `cpt-cf-oagw-feature-control-plane-config`: a hierarchy walk is meaningless without persisted upstreams and routes, and the sharing modes are fields on the objects that feature owns. +- `cpt-cf-oagw-feature-plugin-system` requires `cpt-cf-oagw-feature-gear-foundation`: it needs the plugin base-type identifiers, the domain contracts, and the types-registry provisioning, but it does not need upstream or route persistence, so it branches off the root directly. +- `cpt-cf-oagw-feature-data-plane-proxy` requires `cpt-cf-oagw-feature-hierarchical-config`: proxy-time alias resolution and effective-config merge are the hierarchy walk consumed at request time. +- `cpt-cf-oagw-feature-data-plane-proxy` requires `cpt-cf-oagw-feature-plugin-system`: the proxy path executes the auth, guard, and transform chains and resolves secret material through the credential store contract. +- `cpt-cf-oagw-feature-rate-limiting` requires `cpt-cf-oagw-feature-data-plane-proxy`: the check runs inside the resolved proxy context, and 429 and 503 answers replace the response the proxy would have produced. +- `cpt-cf-oagw-feature-cors` requires `cpt-cf-oagw-feature-hierarchical-config`: the effective CORS configuration is the merged per-tenant result, and preflight responses must not depend on tenant resolution. +- `cpt-cf-oagw-feature-cors` requires `cpt-cf-oagw-feature-data-plane-proxy`: origin and method enforcement happens after upstream resolution and before forwarding, on the proxy handler's own path. +- `cpt-cf-oagw-feature-streaming` requires `cpt-cf-oagw-feature-data-plane-proxy`: it changes how the proxy response body is transferred, not what is resolved. +- `cpt-cf-oagw-feature-observability` requires `cpt-cf-oagw-feature-data-plane-proxy`: correlation, audit fields, and the proxy-path metrics are all derived from the proxy request and response lifecycle. +- `cpt-cf-oagw-feature-observability` requires `cpt-cf-oagw-feature-rate-limiting`: it reports rate-limit state and 429 outcomes, which only exist once that feature owns them. +- `cpt-cf-oagw-feature-observability` requires `cpt-cf-oagw-feature-control-plane-config`: it logs configuration changes and reads configuration state, so it cannot be completed before that feature's write path exists. +- `cpt-cf-oagw-feature-control-plane-config` and `cpt-cf-oagw-feature-plugin-system` are independent of each other and can be developed in parallel once `cpt-cf-oagw-feature-gear-foundation` exists. +- `cpt-cf-oagw-feature-rate-limiting`, `cpt-cf-oagw-feature-cors`, `cpt-cf-oagw-feature-streaming`, and the proxy-reading slice of `cpt-cf-oagw-feature-observability` are mutually independent and can be developed in parallel once `cpt-cf-oagw-feature-data-plane-proxy` exists; `cors` additionally waits on `cpt-cf-oagw-feature-hierarchical-config`, and `cpt-cf-oagw-feature-observability` additionally waits on `cpt-cf-oagw-feature-rate-limiting` and `cpt-cf-oagw-feature-control-plane-config`. diff --git a/gears/system/oagw/docs/features/control-plane-config.md b/gears/system/oagw/docs/features/control-plane-config.md new file mode 100644 index 0000000..054f773 --- /dev/null +++ b/gears/system/oagw/docs/features/control-plane-config.md @@ -0,0 +1,838 @@ +# Feature: Control Plane Configuration API + + + + +- [1. Feature Context](#1-feature-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) + - [1.5 Feature-Local Deviations from Shared Baselines](#15-feature-local-deviations-from-shared-baselines) + - [1.6 Explicit Non-Applicability](#16-explicit-non-applicability) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Configure an Upstream](#configure-an-upstream) + - [Configure a Route](#configure-a-route) + - [Read and List Configuration](#read-and-list-configuration) + - [Replace or Delete an Upstream](#replace-or-delete-an-upstream) + - [Delete a Route](#delete-a-route) + - [Enable or Disable Configuration](#enable-or-disable-configuration) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Management Write Validation](#management-write-validation) + - [Alias Derivation and Enforcement](#alias-derivation-and-enforcement) + - [Tenant Scoping and Ancestor Non-Addressability](#tenant-scoping-and-ancestor-non-addressability) + - [OData List Parameter Parsing and Bounding](#odata-list-parameter-parsing-and-bounding) + - [Full-Replacement Diff](#full-replacement-diff) + - [Route Match Uniqueness](#route-match-uniqueness) +- [4. States (CDSL)](#4-states-cdsl) + - [Upstream and Route Effective Lifecycle](#upstream-and-route-effective-lifecycle) +- [5. Definitions of Done](#5-definitions-of-done) + - [Management Route Registration](#management-route-registration) + - [Authentication and Management Permissions](#authentication-and-management-permissions) + - [Request Validation Against the Shipped Schemas](#request-validation-against-the-shipped-schemas) + - [Alias Derivation, Normalization, and Uniqueness](#alias-derivation-normalization-and-uniqueness) + - [Tenant Scoping on Every Operation](#tenant-scoping-on-every-operation) + - [Persisted Model Shape and Transactional Writes](#persisted-model-shape-and-transactional-writes) + - [Full-Replacement Semantics](#full-replacement-semantics) + - [Enable and Disable Semantics](#enable-and-disable-semantics) + - [List and Query Parameters](#list-and-query-parameters) + - [Colocated Tests](#colocated-tests) +- [6. Acceptance Criteria](#6-acceptance-criteria) + + + +- [ ] `p1` - **ID**: `cpt-cf-oagw-featstatus-control-plane-config-implemented` + + +- [ ] `p2` - `cpt-cf-oagw-feature-control-plane-config` +## 1. Feature Context + +### 1.1 Overview + +This feature is the management half of the `oagw` gear. It registers the ten upstream and route endpoints at `/oagw/v1/...`, validates every write against the two shipped JSON Schemas, derives and enforces aliases, keeps every operation strictly tenant-scoped, and persists the result into the `oagw_*` upstream, route, tag, and match tables. It is the persisted state that `cpt-cf-oagw-feature-hierarchical-config` walks and `cpt-cf-oagw-feature-data-plane-proxy` serves. + +### 1.2 Purpose + +DECOMPOSITION §2.2 places this feature second in the feature graph, behind `cpt-cf-oagw-feature-gear-foundation` and ahead of every feature that reads configuration. The foundation supplies the gear registration, the domain types, the alias and hostname value objects, the `Scheme` admission predicate, and the `DomainError` catalogue; this feature turns them into a working write path. Without it there is no upstream and no route anywhere in the gear, so `hierarchical-config` has nothing to walk, `data-plane-proxy` has nothing to resolve, and `observability` has no configuration change to report. + +Deliverables: + +- The ten management endpoints of DECOMPOSITION §2.2, registered gear-relative under `/oagw/v1` with anonymous GTS identifiers in every path parameter. +- Request validation against `schemas/upstream.v1.schema.json` and `schemas/route.v1.schema.json`, including the endpoint shape, the protocol enum, the sharing enums, the match shape, the `rate_limit` sub-object, and the route-level `cors` object. +- Alias derivation by endpoint type, alias normalization, alias immutability across updates, and `(tenant_id, alias)` uniqueness. +- `enabled` semantics: default `true`, the transition rules, and the ancestor-disable guard. +- The persisted model this feature owns: the upstream, route, match, and tag tables with their keys, foreign keys, and single-transaction multi-table writes. +- OData list parameters `$filter`, `$select`, `$orderby`, `$top`, and `$skip`. + +The feature is delivered in the three phases DECOMPOSITION §2.2 names: upstream CRUD, then route CRUD, then the list and query parameters. Nothing in the phase order changes the contract of any endpoint. + +**Requirements**: + +- [ ] `p1` - `cpt-cf-oagw-fr-upstream-mgmt` +- [ ] `p1` - `cpt-cf-oagw-fr-route-mgmt` +- [ ] `p1` - `cpt-cf-oagw-fr-enable-disable` +- [x] `p2` - `cpt-cf-oagw-fr-alias-resolution` +- [ ] `p1` - `cpt-cf-oagw-nfr-multi-tenancy` +- [ ] `p1` - `cpt-cf-oagw-interface-management-api` +- [ ] `p1` - `cpt-cf-oagw-usecase-configure-upstream` +- [ ] `p1` - `cpt-cf-oagw-usecase-configure-route` + +**Principles**: + +- `cpt-cf-oagw-principle-tenant-scope` +- `cpt-cf-oagw-adr-request-routing` + +**Constraints**: + +- `cpt-cf-oagw-constraint-multi-sql` +- `cpt-cf-oagw-constraint-https-only` +- `cpt-cf-oagw-constraint-toolkit-deploy` + +**Design Components**: + +- `cpt-cf-oagw-component-model` +- `cpt-cf-oagw-interface-api` +- `cpt-cf-oagw-interface-management-api` +- `cpt-cf-oagw-tech-dependencies` + +**Data**: + +- `cpt-cf-oagw-db-schema` + +`cpt-cf-oagw-interface-management-api` is a PRD §7.1 declaration and `cpt-cf-oagw-interface-api` is the DESIGN §3.3 contract section whose table carries the ten upstream and route rows this feature implements alongside the five plugin rows that belong to `cpt-cf-oagw-feature-plugin-system`: this feature restates that upstream contract in §2 and §5 and does not redesign a single endpoint. The `cpt-cf-oagw-db-schema` claim is shared — DECOMPOSITION §1.6 assigns this feature the upstream, route, tag, and match tables and leaves the plugin and plugin-binding tables to `cpt-cf-oagw-feature-plugin-system`. + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-oagw-actor-tenant-admin` | Creates, reads, replaces, enables, disables, and deletes the upstreams and routes of its own tenant through the management endpoints, and receives 404 for any resource owned by an ancestor. | +| `cpt-cf-oagw-actor-platform-operator` | Drives the same surface for configuration it owns and for the ancestor-side disable that must propagate to descendants; PRD §8 names it the actor of both use cases this feature implements. | + +Both actors reach this feature through the same ten endpoints; the difference between them is which tenant the bearer token resolves to, not which handler runs. PRD §5.1 names both as the actors of `cpt-cf-oagw-fr-upstream-mgmt`, `cpt-cf-oagw-fr-route-mgmt`, and `cpt-cf-oagw-fr-enable-disable`, and DECOMPOSITION §1.5 lists this feature against both. + +The other four actors do not participate: + +- `cpt-cf-oagw-actor-app-developer` has no management surface. Its endpoint is the proxy, delivered by `cpt-cf-oagw-feature-data-plane-proxy`. +- `cpt-cf-oagw-actor-cred-store` is not called. The credential reference inside the `auth` sub-configuration is validated for shape only, exactly as the foundation's value object does it; resolving the reference happens at proxy time. DECOMPOSITION §1.5 lists the credential store under `cpt-cf-oagw-feature-plugin-system` alone. +- `cpt-cf-oagw-actor-types-registry` is not called during a management operation. The GTS type catalogue was provisioned once by `cpt-cf-oagw-feature-gear-foundation`; no write here registers or re-registers a type. +- `cpt-cf-oagw-actor-upstream-service` is never contacted. No management operation opens an outbound connection. + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) +- **Design**: [DESIGN.md](../DESIGN.md) +- **Dependencies**: `cpt-cf-oagw-feature-gear-foundation` — this feature persists and validates the domain types it declares, reuses its `DomainError` catalogue for the 400/404/409 answers below, calls its alias and hostname normalization routine on every value it stores, and registers its handlers on the router mount point it created (DECOMPOSITION §3). + +Supporting sources this feature stays consistent with: + +- [schemas/upstream.v1.schema.json](../schemas/upstream.v1.schema.json) and [schemas/route.v1.schema.json](../schemas/route.v1.schema.json) — the shapes every management write is validated against. Both are frozen inputs; the places this run overrides them are recorded in §1.5. +- [ADR/0001-request-routing.md](../ADR/0001-request-routing.md) (`cpt-cf-oagw-adr-request-routing`) — path-based routing. Every path this feature registers under `/oagw/v1/upstreams/*` and `/oagw/v1/routes/*` is routed to the Control Plane, and the management operation order below is the ADR's management flow. +- [ADR/0004-cors.md](../ADR/0004-cors.md) — CORS is a dedicated `cors` field on `Upstream` and `Route`, not a plugin; this feature validates that field. +- [ADR/0006-state-management.md](../ADR/0006-state-management.md) (`cpt-cf-oagw-adr-state-management`) — the Control Plane writes to the database, flushes its own cache, and returns. This feature performs that write and that flush, and it owns the Control Plane L1 configuration cache the ADR assigns to the Control Plane: the cache is built here, and it is the only cache this feature owns. The Data Plane L1 cache and its post-write invalidation belong to `cpt-cf-oagw-feature-data-plane-proxy` (DECOMPOSITION §2.5, the explicit-invalidation alternative of ADR 0006), which also dispositions that ADR's periodic-sync alternative (DECOMPOSITION §1.3(10)). +- [ADR/0007-error-source-distinction.md](../ADR/0007-error-source-distinction.md) — every gateway error this feature answers carries `X-OAGW-Error-Source: gateway` and an `application/problem+json` body through the foundation's mapping. +- [config/e2e-local.yaml](../../../../../config/e2e-local.yaml) — the graded configuration. Its `oagw.config` block sets `allow_http_upstream: true`, which is the input that admits the `http` endpoint scheme at write time. + +**Run-level assumptions** — premises this feature relies on that come from the platform runtime rather than from PRD, DESIGN, the ADRs, or DECOMPOSITION: + +- Assumption: the platform middleware stack authenticates the bearer token and resolves the caller's tenant before the request reaches this feature's handlers. `config/e2e-local.yaml` sets `api-gateway.auth_disabled: false` and `require_auth_by_default: true`, and DESIGN §3.3 names `toolkit-auth` as the inbound mechanism, but no supplied document states that the resolved tenant identifier is what this feature receives. If it is not, tenant scoping cannot be applied and every management operation must fail closed rather than answer with another tenant's data. +- Assumption: the platform tenant-resolver supplies the calling tenant's ancestor chain to the hierarchy walk. DECOMPOSITION §2.3 states that the tenant tree comes from the platform tenant-resolver and assigns the walk to `cpt-cf-oagw-feature-hierarchical-config`; this feature relies on that premise for the effective `enabled` state but performs no walk of its own. If the chain is unavailable, the disable propagation of `cpt-cf-oagw-fr-enable-disable` cannot be observed from a descendant tenant. +- Assumption: the `oagw` gear receives a database handle. The persisted model below cannot exist without one, and the graded configuration declares no `database:` section under `gears.oagw` in `config/e2e-local.yaml`. If the runtime provisions no handle for a gear that declares none, every management write fails with a storage error and the gear serves no configuration at all. +- Assumption: the brace notation in the permission family `gts.cf.core.oagw.upstream.v1~:{create;override;read;delete}` denotes four distinct permission identifiers per resource type, matched literally by the platform authorization layer. If the runtime treats the brace form as a single literal string, authorization either always fails or always passes, and the fail direction must be the one that denies the write. + +### 1.5 Feature-Local Deviations from Shared Baselines + +| Deviation | Rationale | Review owner | Validation performed | +|-----------|-----------|--------------|----------------------| +| Routes are registered gear-relative at `/oagw/v1/...` with no `/api` prefix. | DECOMPOSITION §1.3(1) corrects the `/api/oagw/v1/...` tabulation in PRD §7.1 and DESIGN §3.3: `/api` is an operator gateway prefix, not a path this gear serves. Every path in this document is the gear-relative form, and the ten paths are the restatement of the upstream contract, not a new one. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| A route-level `cors` object is validated with the same shape as the upstream CORS configuration, although `schemas/route.v1.schema.json` declares no route-level `cors` property. | DECOMPOSITION §1.3(5): route-level CORS is configured through the `cors` field exactly as the Route class in DESIGN §3.1 specifies, and the shipped schema is a frozen input this run does not edit. The shape applied is the `definitions.cors` object both schemas already carry. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| Write-time endpoint `scheme` validation accepts the `http` literal when `oagw.config.allow_http_upstream` is `true`, overriding the `scheme` enum of the frozen upstream schema. | DECOMPOSITION §1.3(2): which literals a configured endpoint may carry and whether a plaintext connection is opened are two questions, and only the second is governed by the flag. `cpt-cf-oagw-constraint-https-only` describes the default posture the flag lifts. The write-time check performed here and the dial-time decision in `cpt-cf-oagw-feature-data-plane-proxy` stay two separate checks against the same constraint. The graded configuration sets the flag to `true`. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| `oagw_route_grpc_match` and the gRPC protocol value are created and validated but consume nothing. | DECOMPOSITION §1.3(4) and DESIGN §3.1 defer gRPC proxying to Phase 3, and DECOMPOSITION §2.2 keeps the table and the protocol value in this feature's write path so the schema's `oneOf` stays decidable. No gRPC match is ever read by this run; a route whose `match` declares only `grpc` is stored and is unreachable at proxy time. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| Route `priority` is a required field on the route DTO, and route `enabled` is accepted and persisted, although `schemas/route.v1.schema.json` declares neither. | DESIGN §3.1 declares `priority` and `enabled` as attributes of the Route class, and both the match-uniqueness invariant (DESIGN §3.6) and the enable/disable semantics of `cpt-cf-oagw-fr-enable-disable` are stated over them, so neither can be dropped. Requiring `priority` is the reading that keeps the `(path, priority, method)` uniqueness predicate decidable; a missing priority would make two otherwise-distinct routes collide on an undefined key. `enabled` keeps the `true` default that PRD §5.1 states for both resource types. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The alias is immutable across updates, including the DESIGN §3.3 clause that recomputes the alias when hostname endpoints change; a replacement whose endpoints would derive a different alias answers 409. | DECOMPOSITION §2.2 states alias immutability across updates and §1.3 makes the decomposition prevail over DESIGN where they conflict. Immutability also keeps the alias-to-upstream mapping stable for the cached resolutions ADR 0006 describes. The 409 status is this feature's resolution of the conflict: PRD §8 names 409 Conflict for an alias conflict, and an endpoint change that would move the derived alias is an attempt to change that identity rather than a validation failure. An endpoint change that derives the same alias is accepted, so pooling changes remain possible. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| A route whose `upstream_id` names an upstream the calling tenant does not own answers 400, not 404. | PRD §8 lists "Upstream not found: Return 400 ValidationError" for the Configure Route use case, and DESIGN §3.3 requires `upstream_id` to belong to the calling tenant because ancestor upstreams are not directly addressable. The 404 rule in DESIGN §3.3 Tenant Scoping governs path-addressed resources, so the two rules govern different cases and do not collide: 404 for a resource named in the path, 400 for a resource named in the body. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| An omitted `enabled` on a replacement leaves the stored flag unchanged instead of restoring the schema default `true`. | DESIGN §3.3 states that full replacement overwrites all fields, and the upstream schema gives `enabled` a default of `true`, so the literal reading silently re-enables a disabled upstream during an unrelated configuration edit. PRD §5.1 gives `cpt-cf-oagw-fr-enable-disable` the purpose of temporary maintenance and emergency circuit breaking, which makes silent re-enable the unsafe direction. The flag is therefore carried forward when the body omits it, and a body that states it explicitly still controls it. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The `plugins` sub-object of an upstream or route is validated here but no binding row is written by this feature. | The `plugins` field is part of both DTO shapes and both schemas, so it must be validated; the ordered binding rows live in `oagw_upstream_plugin` and `oagw_route_plugin`, which DECOMPOSITION §1.6 and §2.4 assign to `cpt-cf-oagw-feature-plugin-system` together with the plugin tables. Until that feature lands, the validated value is carried on the domain object and reaches no table this feature owns. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| Match-rule uniqueness is evaluated against the enabled routes of the upstream, not against all of them. | DESIGN §3.6 states the invariant as "no two enabled routes under same upstream may share `(path_prefix, priority)` for same method", while DESIGN §3.3 phrases the same predicate as "same path + priority + method". Both sources describe one predicate; this feature implements the DESIGN §3.6 form because it is the persisted invariant, so two disabled routes with identical keys are stored without a conflict and a disable never has to be undone to store a duplicate. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The route replacement DTO is the create DTO minus `upstream_id`: a replacement is validated against `schemas/route.v1.schema.json` with `upstream_id` removed from the schema's `required` set. | DESIGN §3.3 states that a route's `upstream_id` is immutable and not present in the update DTO, while the shipped route schema lists `upstream_id` in `required` with no separate update schema. A replacement that omitted `upstream_id` would otherwise fail the required-property check before the immutability rule could be applied, so the required set is narrowed for the replacement method only, and the stored `upstream_id` is taken from the addressed row. A body that nevertheless carries `upstream_id` is still rejected 400 by the immutable-field rule below. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| Two management-conflict `DomainError` variants, `AliasConflict` and `MatchConflict`, are provisioned by `cpt-cf-oagw-feature-gear-foundation` and consumed here: `AliasConflict` answers 409 with `gts.cf.core.errors.err.v1~cf.oagw.alias.conflict.v1` and `MatchConflict` answers 409 with `gts.cf.core.errors.err.v1~cf.oagw.match.conflict.v1`. | DESIGN §3.3 tabulates exactly one 409 row, `PluginInUse`, which is a plugin-lifecycle answer, so the 409s this feature answers for an alias conflict and for a match conflict have no catalogue row to map to. The catalogue is the foundation's to own, so the extension is recorded there and in DECOMPOSITION §1.3(9); this feature names the variant it returns in its alias and match-uniqueness answers instead of reusing the plugin variant or answering with an unplumbed type. Both are non-retriable. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| DESIGN §3.3's `created_at desc` ordering example is not orderable on either list endpoint, because the persisted model this feature declares carries no timestamp column. | The persisted model is the table set DECOMPOSITION §1.6 assigns to this feature, and no table in it carries a creation timestamp; `created_at` appears in DESIGN §3.3 only as an `$orderby` example. The example is replaced with an orderable field (`alias` for an upstream, `priority` for a route) rather than adding a column no supplied document declares. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The permission family this feature enforces is narrowed to the `upstream` and `route` arms; the `*_plugin` arms of the same family are enforced by `cpt-cf-oagw-feature-plugin-system`. | DECOMPOSITION §2.2 writes the family as `gts.cf.core.oagw.{upstream,route,*_plugin}.v1~:{create;override;read;delete}`, and DESIGN §3.2 grants the plugin arms `{create;read;delete}` only — no `override` arm exists for a plugin, whose immutability after creation is a PRD §5.3 declaration. This feature registers no plugin route (see `cpt-cf-oagw-dod-management-routes`), so it enforces the `upstream` and `route` arms on the ten paths it registers and leaves the plugin arms to the feature that owns them. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| This feature's tests are colocated at `gears/system/oagw/oagw/tests/` instead of `testing/e2e/gears/oagw/`. | DECOMPOSITION §1.3(3) reserves `testing/e2e/gears/oagw/` for the acceptance suite; every unit and integration test this decomposition produces lives with the crate. This is the same deviation `cpt-cf-oagw-feature-gear-foundation` records in its own §1.5, restated here because the tests it governs include this feature's. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| A successful upstream or route deletion notifies `cpt-cf-oagw-flow-rate-limit-cleanup` of `cpt-cf-oagw-feature-rate-limiting` in process and before the delete's response is produced, which is the same in-process post-write ordering the Control Plane cache flush already applies. | ADR 0003's prefix-based cleanup of a deleted resource is a rate-limit-registry act, and DECOMPOSITION §2.6 assigns that cleanup to `cpt-cf-oagw-feature-rate-limiting` while assigning the deletions that trigger it to this feature. No supplied document states who notifies whom, so the call-direction seam this feature already records for the Data Plane cache flush of `cpt-cf-oagw-feature-data-plane-proxy` is extended to the third interested owner: the write is this feature's, the notification is issued by this feature, and the cleanup is the notified feature's. A failed deletion notifies nothing, because the database it failed against is unchanged. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The deletion-cascade and single-transaction criteria are exercised against this run's store — the in-memory row-table set DECOMPOSITION §1.6 assigns to this feature — rather than against each of the PostgreSQL, MySQL, and SQLite backends of `cpt-cf-oagw-constraint-multi-sql`, because the decomposition ships no SQL adapter for this gear. Portability across the three backends is carried the way §5 states it: the store holds no backend-specific feature, so the same table set, cascade, and transaction boundary apply on any backend that persists those rows. The cascade into routes, both match tables, the method rows, and both tag tables, the `204 No Content` with no body, and the no-partial-write rule are each exercised in `tests/store_tests.rs`, `tests/service_tests.rs`, and `tests/api_tests.rs`; the no-partial-write rule on a failing write is the store's batch boundary, which mutates a candidate copy of the tables and swaps it in only after the batch and the invariant check both succeed, so a batch that fails anywhere leaves the stored configuration exactly as it was. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | + +### 1.6 Explicit Non-Applicability + +The areas below apply to the gear as a whole but not to this feature. Each is stated here so the omission is a recorded decision rather than a silent gap. + +- **Events**: no event is published or consumed here. A management write changes persisted state and flushes the Control Plane cache (ADR 0006); the audit log and the configuration-change reporting that describe those writes belong to `cpt-cf-oagw-feature-observability`, which DECOMPOSITION §3 makes dependent on this feature. +- **Rollout and rollback**: the gear is one configuration item and one release unit (DECOMPOSITION §1.4), so this feature ships no rollout of its own and no independent rollback path. A partially applied multi-table write is impossible by construction — every write below is a single transaction. +- **Versioning**: every GTS identifier this feature reads or writes is fixed at `.v1` by DESIGN §3.1, and the breaking-change policy of `cpt-cf-oagw-interface-management-api` (major version bump from v1 to v2) is a PRD-level declaration about the interface, not a mechanism this feature implements. No version negotiation, aliasing, or migration surface exists here. +- **Localization and accessibility**: the `title` and `detail` of every problem body this feature answers are English protocol strings produced by the foundation's error mapping, and there is no locale negotiation, no translated surface, and no actor-facing rendered UI to make accessible. +- **Plugin persistence**: the plugin and plugin-binding tables are deliberately not created, read, or written by this feature. The boundary is recorded as a §1.5 row rather than left to inference, because the DTO shapes carry a `plugins` field whose persistence belongs elsewhere. +- **Outbound connectivity**: no management operation opens a socket, resolves a credential, or contacts an upstream service. The SSRF policy carried in `OagwConfig` is evaluated by `cpt-cf-oagw-feature-data-plane-proxy` at dial time; this feature performs the write-time scheme check only. +- **Credential material and problem detail**: the only credential-bearing field this feature persists is the opaque `auth.secret_ref` reference, which is validated for `cred://` shape and never resolved here. `auth.config` is an unconstrained object per the shipped schema: this feature validates that it is an object, persists it verbatim, returns it verbatim in the 201 and 200 representations, and never writes it to a log or to a problem `detail`. No problem `detail` this feature answers ever echoes request body content — a `detail` carries the failing property names, the addressed resource, or the colliding identifier, and nothing copied from the body. Enforcement of `cpt-cf-oagw-nfr-credential-isolation` for what may legitimately appear inside `auth.config` belongs to `cpt-cf-oagw-feature-plugin-system`, which resolves the reference at proxy time and owns the auth plugin contract; this feature's obligation is the reference-only rule above, so it neither inspects nor polices the content of that object. +- **Compliance and privacy**: no persisted configuration family this feature owns carries personal data or regulated data — the families are endpoint sets, protocol and sharing values, match keys, rate limits, CORS, tags, and header rules, and the only credential-bearing field among them is the opaque reference described above. No retention policy is defined here: what is stored persists until the owning tenant deletes it, and retention of the configuration store is a platform obligation this feature inherits rather than sets. +- **Performance**: the only bounded path this feature owns is the list page, which `$top` caps at 100 results whatever the caller asks for; the read-assembly guidance that keeps a page from costing one query per parent row is given in §3 under `cpt-cf-oagw-algo-odata-list`. No latency or throughput target is set on the write path or on a single read here, because the proxy-path latency targets of `cpt-cf-oagw-nfr-low-latency` belong to `cpt-cf-oagw-feature-data-plane-proxy`, which owns the request hot path. +- **Observability and health**: this feature logs its failure outcomes — a rejected write (validation, authorization, or conflict, with the failing property names and the colliding identifier), and a storage failure, with the correlation identifier — and it logs no request body, no configuration value, and no credential material. It emits no audit record of a successful configuration change, no metric, and no readiness signal: the audit and metrics surface belongs to `cpt-cf-oagw-feature-observability`, and gear readiness is reported by `cpt-cf-oagw-feature-gear-foundation`, which owns the provisioning state machine. + +## 2. Actor Flows (CDSL) + +Every flow below follows the management operation order DESIGN §3.5 states — authenticate, validate the DTO, write, respond — and the path-based routing of `cpt-cf-oagw-adr-request-routing`, which sends `/oagw/v1/upstreams/*` and `/oagw/v1/routes/*` to the Control Plane. Path parameters carry anonymous GTS identifiers, and the ten paths are the gear-relative restatement of the DESIGN §3.3 contract. + +**Use cases**: `cpt-cf-oagw-usecase-configure-upstream`, `cpt-cf-oagw-usecase-configure-route` + +### Configure an Upstream + +- [x] `p1` - **ID**: `cpt-cf-oagw-flow-upstream-create` + +**Actor**: `cpt-cf-oagw-actor-tenant-admin` + +**Success Scenarios**: + +- A hostname upstream with a single endpoint on its standard port is created; the alias is derived from the host, the row is persisted with a server-generated identifier, and the response carries the anonymous GTS identifier and the normalized alias. +- A caller-supplied alias that equals the derived value after normalization is accepted as an idempotent no-op rather than rejected. +- An endpoint set whose hosts share a suffix of at least two labels derives that suffix as the alias. +- An IP-based or otherwise non-derivable endpoint set with an explicit alias is created. +- An endpoint that omits `port` is stored with the schema default of 443; sub-configuration defaults declared by the shipped schema are applied where the body omits them. +- The Control Plane cache is flushed after the successful creation, before the response is produced (ADR 0006). + +**Error Scenarios**: + +- The body fails schema validation — missing `server` or `protocol`, an unknown property, an endpoint without `scheme` or `host`, a `port` outside 1 to 65535, a `protocol` outside the enum, a sharing value outside `private`/`inherit`/`enforce`, a `rate_limit` without `sustained`, or a `cors` object without `enabled` — and is answered 400 with `cpt-cf-oagw-fr-error-codes`' validation type. +- The endpoint set is not derivable — IP-only hosts, no common suffix of at least two labels, or a bare public suffix — and no explicit alias was supplied: 400. +- The caller supplied an alias that differs from the derived value: 400. +- Another upstream of the same tenant already holds the normalized alias: 409 with the `AliasConflict` variant. +- The bearer token is missing or invalid: 401; it lacks `gts.cf.core.oagw.upstream.v1~:create`: 403. + +**Steps**: +1. [x] - `p1` - Actor issues the create request carrying the upstream DTO: the `server` endpoint set, the `protocol`, and any of `alias`, `tags`, `auth`, `headers`, `rate_limit`, `cors`, `plugins`, `enabled` - `inst-us-create-issue` +2. [x] - `p1` - API: POST /oagw/v1/upstreams — the platform middleware authenticates the bearer token and resolves the calling tenant, and the handler enforces `gts.cf.core.oagw.upstream.v1~:create` before any validation runs - `inst-us-create-authz` +3. [x] - `p1` - `cpt-cf-oagw-algo-request-validate` validates the body against `schemas/upstream.v1.schema.json` plus the §1.5 overrides, including the `auth` sub-configuration whose credential reference is checked for shape only - `inst-us-create-validate` +4. [x] - `p1` - `cpt-cf-oagw-algo-alias-derive` derives the alias from the endpoint set, or validates the caller-supplied one, and normalizes it - `inst-us-create-alias` +5. [x] - `p1` - **IF** the endpoint set is non-derivable and the caller supplied no alias - `inst-us-create-alias-if` + 1. [x] - `p1` - **RETURN** 400 naming the endpoint set as non-derivable; no row is written - `inst-us-create-alias-return` +6. [x] - `p1` - **ELSE** - `inst-us-create-alias-else` + 1. [x] - `p1` - Continue with the derived or caller-supplied alias in normalized form - `inst-us-create-alias-continue` +7. [x] - `p1` - `cpt-cf-oagw-algo-tenant-scope` resolves the calling tenant and checks `(tenant_id, alias)` uniqueness - `inst-us-create-scope` +8. [x] - `p1` - **IF** another upstream of the same tenant already holds the normalized alias - `inst-us-create-conflict-if` + 1. [x] - `p1` - **RETURN** 409 with the `AliasConflict` variant (`gts.cf.core.errors.err.v1~cf.oagw.alias.conflict.v1`); an alias held by a different tenant is not a conflict at this layer, and the ancestor-bind decision for one that is belongs to `cpt-cf-oagw-feature-hierarchical-config` - `inst-us-create-conflict-return` +9. [x] - `p1` - **ELSE** - `inst-us-create-else` + 1. [x] - `p1` - DB: INSERT into `oagw_upstream` (server-generated `id`, `tenant_id`, `alias`, `protocol`, `enabled`, and the configuration families as document columns) and into `oagw_upstream_tag` for each tag, in one transaction, then flush the Control Plane cache before the response is produced; no plugin or plugin-binding row is written (§1.5) - `inst-us-create-insert` +10. [x] - `p1` - **RETURN** 201 with the created representation, the `id` as `gts.cf.core.oagw.upstream.v1~{uuid}`, and the normalized `alias` - `inst-us-create-return` + +### Configure a Route + +- [x] `p1` - **ID**: `cpt-cf-oagw-flow-route-create` + +**Actor**: `cpt-cf-oagw-actor-tenant-admin` + +**Success Scenarios**: + +- A route whose `upstream_id` names an upstream of the calling tenant and whose `match` declares exactly one of `http` or `grpc` is created with the supplied `priority`, and the route row together with its match, method, and tag rows lands in one transaction. +- A route whose match keys do not collide with any other enabled route of the same upstream is created. +- A route created for a disabled upstream is stored; storing it does not change the upstream's state. +- The Control Plane cache is flushed after the successful creation, before the response is produced (ADR 0006). + +**Error Scenarios**: + +- The `upstream_id` does not name an upstream owned by the calling tenant — including an ancestor-owned one — and is answered 400 (§1.5). +- The body fails schema validation: no `upstream_id` or `match`, a `match` declaring both or neither of `http` and `grpc`, an `http` match with no `methods` or no `path`, a method outside the enum, a `grpc` match missing `service` or `method`, or a missing `priority` (§1.5). +- The match keys collide with another enabled route of the same upstream: 409 with the `MatchConflict` variant. +- The bearer token is missing or invalid: 401; it lacks `gts.cf.core.oagw.route.v1~:create`: 403. + +**Steps**: +1. [x] - `p1` - Actor issues the create request carrying the route DTO: `upstream_id`, `match`, `priority`, and any of `tags`, `rate_limit`, `cors`, `plugins`, `enabled` - `inst-rt-create-issue` +2. [x] - `p1` - API: POST /oagw/v1/routes — the platform middleware authenticates the bearer token and the handler enforces `gts.cf.core.oagw.route.v1~:create` - `inst-rt-create-authz` +3. [x] - `p1` - `cpt-cf-oagw-algo-request-validate` validates the body against `schemas/route.v1.schema.json` plus the §1.5 overrides, including the route-level `cors` object validated with the upstream CORS shape - `inst-rt-create-validate` +4. [x] - `p1` - `cpt-cf-oagw-algo-tenant-scope` resolves the referenced upstream under the calling tenant's scope - `inst-rt-create-resolve` +5. [x] - `p1` - **IF** no upstream with that `upstream_id` is owned by the calling tenant - `inst-rt-create-resolve-if` + 1. [x] - `p1` - **RETURN** 400; an ancestor-owned upstream is not directly addressable as a route target, so a descendant route can only be created under a route target the caller owns - `inst-rt-create-resolve-return` +6. [x] - `p1` - **ELSE** - `inst-rt-create-resolve-else` + 1. [x] - `p1` - Continue with the resolved upstream - `inst-rt-create-resolve-continue` +7. [x] - `p1` - `cpt-cf-oagw-algo-match-uniqueness` checks the incoming `(path, priority, method)` keys against the other enabled routes of that upstream - `inst-rt-create-unique` +8. [x] - `p1` - **IF** a conflict exists - `inst-rt-create-unique-if` + 1. [x] - `p1` - **RETURN** 409 with the `MatchConflict` variant (`gts.cf.core.errors.err.v1~cf.oagw.match.conflict.v1`), naming the colliding route; no row is written - `inst-rt-create-unique-return` +9. [x] - `p1` - **ELSE** - `inst-rt-create-unique-else` + 1. [x] - `p1` - DB: INSERT into `oagw_route` (server-generated `id`, `tenant_id`, the immutable `upstream_id`, `priority`, `enabled`, and the configuration families) and into `oagw_route_http_match`, `oagw_route_method`, and `oagw_route_tag` as the match keys require, in one transaction, then flush the Control Plane cache before the response is produced - `inst-rt-create-insert` +10. [x] - `p1` - **RETURN** 201 with the created representation and the `id` as `gts.cf.core.oagw.route.v1~{uuid}` - `inst-rt-create-return` + +### Read and List Configuration + +- [x] `p1` - **ID**: `cpt-cf-oagw-flow-config-read-list` + +**Actor**: `cpt-cf-oagw-actor-tenant-admin` + +This flow is the read half of the management surface. It covers the four GET endpoints — `GET /oagw/v1/upstreams`, `GET /oagw/v1/upstreams/{id}`, `GET /oagw/v1/routes`, and `GET /oagw/v1/routes/{id}` — and it writes nothing: no cache is flushed and no row is touched, because a read never changes persisted state. + +**Success Scenarios**: + +- A single resource addressed by identifier and owned by the calling tenant is returned with its representation and its `id` as the anonymous GTS identifier of the resource. +- A list request with no parameters returns the first page of the calling tenant's rows, bounded by the default `$top` of 50. +- A list request carrying `$filter`, `$select`, `$orderby`, `$top`, and `$skip` returns the tenant-scoped page those parameters select, with `$top` capped at 100 whatever it asks for. + +**Error Scenarios**: + +- The `id` in the path names a nonexistent resource, or one owned by another tenant including an ancestor: 404, with the two causes indistinguishable. +- A query parameter is malformed, or an expression names a field the resource kind does not expose: 400. +- The bearer token is missing or invalid: 401; it lacks `gts.cf.core.oagw.upstream.v1~:read` or `gts.cf.core.oagw.route.v1~:read`: 403. + +**Steps**: +1. [x] - `p1` - Actor issues a `GET` against one of the four read paths, with an `{id}` path parameter for a single read and OData query parameters for a list - `inst-read-issue` +2. [x] - `p1` - API: GET /oagw/v1/upstreams, GET /oagw/v1/upstreams/{id}, GET /oagw/v1/routes, or GET /oagw/v1/routes/{id} — the platform middleware authenticates the bearer token and the handler enforces the `read` permission of the resource kind before any query is built - `inst-read-authz` +3. [x] - `p1` - `cpt-cf-oagw-algo-tenant-scope` builds the read predicate so it carries the tenant equality alongside every other key, and resolves 404 for a path-addressed resource the caller does not own - `inst-read-scope` +4. [x] - `p1` - **IF** the operation is a list - `inst-read-list-if` + 1. [x] - `p1` - `cpt-cf-oagw-algo-odata-list` parses and bounds the five parameters and assembles the page - `inst-read-list` +5. [x] - `p1` - **ELSE** - `inst-read-single-else` + 1. [x] - `p1` - Read the one row the predicate resolved, together with its dependent tag rows - `inst-read-single` +6. [x] - `p1` - **RETURN** 200 with the representation for a single read and with the bounded page for a list; a path-addressed resource that resolved to no row was already answered 404 in the scoping step - `inst-read-return` + +### Replace or Delete an Upstream + +- [x] `p1` - **ID**: `cpt-cf-oagw-flow-upstream-replace-delete` + +**Actor**: `cpt-cf-oagw-actor-tenant-admin` + +**Success Scenarios**: + +- A replacement overwrites every configuration family, clears the optional families the body omits, leaves the alias and the identifier untouched, and leaves the stored `enabled` flag untouched when the body omits it (§1.5). +- A replacement that adds an endpoint to the pool while still deriving the same alias is accepted. +- A deletion removes the upstream and, by cascade, its routes, match rows, method rows, and tag rows in one transaction. +- The Control Plane cache is flushed after a successful write, before the response is produced (ADR 0006), and a successful deletion additionally notifies `cpt-cf-oagw-feature-rate-limiting`'s cleanup in the same ordering. + +**Error Scenarios**: + +- The `id` in the path names a nonexistent upstream, or one owned by another tenant including an ancestor: 404, with the two causes indistinguishable. +- The replacement body fails schema validation, or carries `id` or `tenant_id` values that differ from the addressed row: 400. +- The replacement's endpoints would derive an alias different from the stored one: 409 with the `AliasConflict` variant (§1.5). +- The bearer token is missing or invalid: 401; it lacks `gts.cf.core.oagw.upstream.v1~:override` for the replacement or `gts.cf.core.oagw.upstream.v1~:delete` for the deletion: 403. + +**Steps**: +1. [x] - `p1` - Actor issues the operation against `/oagw/v1/upstreams/{id}` with the replacement body for a replacement, or with no body for a deletion - `inst-us-rw-issue` +2. [x] - `p1` - API: PUT /oagw/v1/upstreams/{id} or DELETE /oagw/v1/upstreams/{id} — the platform middleware authenticates the bearer token and the handler enforces `gts.cf.core.oagw.upstream.v1~:override` or `...:delete` respectively - `inst-us-rw-authz` +3. [x] - `p1` - `cpt-cf-oagw-algo-tenant-scope` resolves the row by identifier and calling tenant - `inst-us-rw-scope` +4. [x] - `p1` - **IF** no row matches, because the identifier does not exist or because it belongs to another tenant including an ancestor - `inst-us-rw-scope-if` + 1. [x] - `p1` - **RETURN** 404; the two causes are deliberately indistinguishable so the endpoint discloses nothing about other tenants' resources - `inst-us-rw-scope-return` +5. [x] - `p1` - **ELSE** - `inst-us-rw-scope-else` + 1. [x] - `p1` - DB: SELECT the stored upstream row and its dependent tag rows as the baseline for the diff - `inst-us-rw-load` +6. [x] - `p1` - **IF** the operation is a replacement - `inst-us-rw-put-if` + 1. [x] - `p1` - `cpt-cf-oagw-algo-request-validate` validates the replacement body against the upstream schema and the §1.5 overrides - `inst-us-rw-validate` + 2. [x] - `p1` - `cpt-cf-oagw-algo-put-replace-diff` recomputes the derived alias from the replacement endpoints, confirms the immutable fields, and builds the write set - `inst-us-rw-diff` + 3. [x] - `p1` - **IF** the recomputed alias differs from the stored alias - `inst-us-rw-alias-if` + 1. [x] - `p1` - **RETURN** 409 with the `AliasConflict` variant (`gts.cf.core.errors.err.v1~cf.oagw.alias.conflict.v1`); the alias is immutable across updates and the stored alias is left unchanged - `inst-us-rw-alias-return` + 4. [x] - `p1` - **ELSE** - `inst-us-rw-alias-else` + 1. [x] - `p1` - DB: UPDATE the `oagw_upstream` row and REPLACE its `oagw_upstream_tag` rows in one transaction, clearing every optional family the body omits, then flush the Control Plane cache - `inst-us-rw-put-write` +7. [x] - `p1` - **ELSE** - `inst-us-rw-delete-else` + 1. [x] - `p1` - DB: DELETE the `oagw_upstream` row by identifier; the foreign keys cascade the deletion into `oagw_route`, both match tables, `oagw_route_method`, and both tag tables within the same transaction, then flush the Control Plane cache and notify `cpt-cf-oagw-flow-rate-limit-cleanup` of `cpt-cf-oagw-feature-rate-limiting` of the successful upstream deletion, in process and before the response is produced - `inst-us-rw-delete-write` +8. [x] - `p1` - **RETURN** the replaced representation for a replacement, and `204 No Content` with no body for a deletion - `inst-us-rw-return` + +### Delete a Route + +- [x] `p1` - **ID**: `cpt-cf-oagw-flow-route-delete` + +**Actor**: `cpt-cf-oagw-actor-tenant-admin` + +**Success Scenarios**: + +- A route owned by the calling tenant is deleted with `204 No Content` and no body, and the route row and its dependent match, method, and tag rows disappear together in one transaction. +- No other route of the same upstream is disturbed; the deletion removes exactly the addressed route and its dependents. +- Deleting a route never touches the upstream row, so a route deletion cannot disable or remove the upstream it was created under. +- The Control Plane cache is flushed after the successful deletion, before the response is produced (ADR 0006), and the rate-limit registry of `cpt-cf-oagw-feature-rate-limiting` is notified in the same ordering. + +**Error Scenarios**: + +- The `id` in the path names a nonexistent route, or one owned by another tenant including an ancestor: 404, with the two causes indistinguishable. +- The bearer token is missing or invalid: 401; it lacks `gts.cf.core.oagw.route.v1~:delete`: 403. + +**Steps**: +1. [x] - `p1` - Actor issues `DELETE /oagw/v1/routes/{id}` with no body - `inst-rt-del-issue` +2. [x] - `p1` - API: DELETE /oagw/v1/routes/{id} — the platform middleware authenticates the bearer token and the handler enforces `gts.cf.core.oagw.route.v1~:delete` before any query is built - `inst-rt-del-authz` +3. [x] - `p1` - `cpt-cf-oagw-algo-tenant-scope` resolves the route row by identifier and calling tenant - `inst-rt-del-scope` +4. [x] - `p1` - **IF** no row matches, because the identifier does not exist or because it belongs to another tenant including an ancestor - `inst-rt-del-scope-if` + 1. [x] - `p1` - **RETURN** 404; the two causes are deliberately indistinguishable, so the endpoint discloses nothing about other tenants' routes - `inst-rt-del-scope-return` +5. [x] - `p1` - **ELSE** - `inst-rt-del-scope-else` + 1. [x] - `p1` - DB: DELETE the `oagw_route` row by identifier; the foreign keys cascade the deletion into `oagw_route_http_match`, `oagw_route_grpc_match`, `oagw_route_method`, and `oagw_route_tag` within the same transaction, so the route and everything derived from its match disappear together or not at all, then flush the Control Plane cache and notify `cpt-cf-oagw-flow-rate-limit-cleanup` of `cpt-cf-oagw-feature-rate-limiting` of the successful route deletion, in process and before the response is produced - `inst-rt-del-write` +6. [x] - `p1` - **RETURN** `204 No Content` with no body; a deletion has no representation to return - `inst-rt-del-return` + +### Enable or Disable Configuration + +- [x] `p1` - **ID**: `cpt-cf-oagw-flow-enable-disable` + +**Actor**: `cpt-cf-oagw-actor-platform-operator` + +**Success Scenarios**: + +- The owner sets `enabled` to `false` on its own upstream or route; the resource enters `Disabled` for its owner and, without any further write, for every descendant tenant. +- The owner sets `enabled` back to `true`; the resource returns to `Enabled` when no contributing ancestor row is disabled. +- A disabled upstream keeps its routes; disabling removes no row. +- A replacement that omits `enabled` does not disturb the stored flag (§1.5). + +**Error Scenarios**: + +- The `id` names a nonexistent resource or one owned by another tenant including an ancestor: 404, so no descendant can ever address an ancestor resource to re-enable it. +- The replacement body fails validation: 400. +- The bearer token is missing or invalid: 401; it lacks the `override` permission of the resource type: 403. + +**Steps**: +1. [x] - `p1` - Actor issues a replacement carrying an explicit `enabled` value; the ten management paths of DECOMPOSITION §2.2 contain no dedicated enable or disable operation, so the flag is set through PUT - `inst-en-dis-issue` +2. [x] - `p1` - API: PUT /oagw/v1/upstreams/{id} or PUT /oagw/v1/routes/{id} — the platform middleware authenticates the bearer token and the handler enforces `gts.cf.core.oagw.upstream.v1~:override` or `gts.cf.core.oagw.route.v1~:override` - `inst-en-dis-authz` +3. [x] - `p1` - `cpt-cf-oagw-algo-tenant-scope` resolves the row by identifier and calling tenant - `inst-en-dis-scope` +4. [x] - `p1` - **IF** no row matches - `inst-en-dis-scope-if` + 1. [x] - `p1` - **RETURN** 404; this is the only reason a descendant cannot re-enable an ancestor-disabled resource through this API - `inst-en-dis-scope-return` +5. [x] - `p1` - **ELSE** - `inst-en-dis-scope-else` + 1. [x] - `p1` - `cpt-cf-oagw-algo-request-validate` validates the replacement, and the stored `enabled` flag is carried forward when the body omits it (§1.5) - `inst-en-dis-validate` + 2. [x] - `p1` - DB: UPDATE the `enabled` column of the owned row in the same transaction as the rest of the replacement, then flush the Control Plane cache - `inst-en-dis-write` + 3. [x] - `p1` - **IF** the written flag is `false` - `inst-en-dis-off-if` + 1. [x] - `p1` - The resource enters `Disabled` for its owner and for every descendant tenant without any further write; the proxy path answers 503 for a disabled upstream, which is `cpt-cf-oagw-feature-data-plane-proxy`'s obligation - `inst-en-dis-off` + 4. [x] - `p1` - **ELSE** - `inst-en-dis-on-else` + 1. [x] - `p1` - The resource returns to `Enabled` only where no contributing ancestor row is disabled; where one is, it stays effectively `Disabled`, which is the no-descendant-re-enable guard of `cpt-cf-oagw-state-config-lifecycle` - `inst-en-dis-on` +6. [x] - `p1` - **RETURN** the updated representation - `inst-en-dis-return` + +## 3. Processes / Business Logic (CDSL) + +The routines below are called by the flows in §2, and one of them is additionally called by another routine here: `cpt-cf-oagw-algo-put-replace-diff` re-runs `cpt-cf-oagw-algo-match-uniqueness` during a route replacement, so that routine is reached both from `cpt-cf-oagw-flow-route-create` and from the replacement diff. The routines touch the database only through the repository layer and answer every failure of their own with a `DomainError` from the foundation catalogue, so no flow builds a problem body of its own. + +**Storage failure is not a `DomainError` variant.** A persistence-layer failure — no usable database handle, a failed statement, a transaction that cannot commit — has no row in the foundation catalogue, and inventing one would put a platform failure inside the gear's error contract. It is answered with the platform's RFC 9457 500 problem shape carrying `X-OAGW-Error-Source: gateway`, it is logged with the correlation identifier, and it fails the request without partial writes. The single-transaction rule of every multi-table write below is what makes that last claim true: a transaction that does not commit leaves no row behind, so a failed write leaves the stored configuration exactly as it was. + +### Management Write Validation + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-request-validate` + +**Input**: the HTTP method, the resource kind (`upstream` or `route`), the request body, and `OagwConfig.allow_http_upstream`. + +**Output**: a validated domain write, or a `DomainError::ValidationError` carrying every failing property. + +The families checked, and the source of each check: + +| Family | Check | Source | +|--------|-------|--------| +| Upstream required properties | `server` and `protocol` present | `schemas/upstream.v1.schema.json` `required` | +| Route required properties | on a create, `upstream_id` and `match` present; on a replacement, `match` present and `upstream_id` removed from the required set, because the replacement DTO is the create DTO minus `upstream_id` (§1.5) | `schemas/route.v1.schema.json` `required`, narrowed for the replacement method per §1.5 | +| Unknown properties | rejected at the root and in the `server`, `endpoints` item, `headers`, `rate_limit`, `cors`, and `match` objects | `additionalProperties: false` holds in the upstream schema at the root and in its sub-objects, and in the route schema's sub-objects (`match`, `http_match`, `grpc_match`, `rate_limit`, `cors`); the route schema's root object is open, and the root-level extras a route accepts are exactly the §1.5-added `priority` and `enabled`. The `auth` and `plugins` objects of the upstream schema and the `plugins` object of the route schema leave their own bodies open, which is why `auth.config` can be persisted verbatim | +| Endpoint shape | at least one endpoint; each carries `scheme` and `host`; `host` is a hostname, IPv4, or IPv6; `port` is an integer from 1 to 65535 with the schema default 443 | upstream schema `server.endpoints` | +| Endpoint-pool homogeneity | all endpoints in `server.endpoints` carry the same `protocol`, the same `scheme`, and the same `port`; a pool that mixes two `scheme` values or two `port` values is rejected | PRD §5.5 (`cpt-cf-oagw-fr-alias-resolution`) | +| Scheme admission | `https`, `wss`, `wt`, `grpc`, plus `http` exactly when `allow_http_upstream` is `true` | upstream schema `scheme` enum, overridden per DECOMPOSITION §1.3(2) | +| Protocol enum | one of the two GTS protocol identifiers for HTTP and gRPC | upstream schema `protocol.enum` | +| Sharing enums | `private`, `inherit`, or `enforce` on `auth.sharing`, `plugins.sharing`, `rate_limit.sharing`, and `cors.sharing` | both schemas | +| Match shape | exactly one of `http` or `grpc`; `http` requires `methods` with at least one of GET, POST, PUT, DELETE, PATCH and a non-empty `path`, with optional `query_allowlist` and `path_suffix_mode` of `disabled` or `append`; `grpc` requires non-empty `service` and `method` | route schema `match`, `http_match`, `grpc_match` | +| `rate_limit` sub-object | `sustained` required with `rate` of at least 1; `window` one of second, minute, hour, day; `burst.capacity` at least 1; `algorithm` token bucket or sliding window; `scope` one of the five values; `strategy` reject, queue, or degrade; `cost` at least 1 | `definitions.rate_limit` in both schemas | +| `cors` shape | `enabled` required; `allowed_origins` entries are `*` or a URI; `allowed_methods` from the seven-value enum; `allow_credentials: true` forbids `*` in `allowed_origins` | upstream schema `definitions.cors`, applied to the route-level object per §1.5 | +| Tags | every entry matches the schema's tag pattern | both schemas | +| Credential reference | shape only; never resolved | PRD §8 Configure Upstream; §1.3 | + +**Steps**: +1. [x] - `p1` - Parse the body; a body that is not valid JSON for the resource kind fails here - `inst-val-parse` +2. [x] - `p1` - Reject every property the schema does not declare for the resource kind, with the one exception the §1.5 record of the open route root makes: a route body may carry the §1.5-added `priority` and `enabled` at its root, because the route schema's root object declares no `additionalProperties: false` - `inst-val-unknown` +3. [x] - `p1` - Check the required properties of the resource kind, branching on create versus replacement: a create requires the full set the resource kind's schema declares (`server` and `protocol` for an upstream, `upstream_id` and `match` for a route), while a replacement is checked against the same schema with `upstream_id` removed from the route's `required` set, because the replacement DTO is the create DTO minus `upstream_id` (§1.5) - `inst-val-required` +4. [x] - `p1` - **FOR EACH** endpoint in the `server.endpoints` array - `inst-val-endpoint-loop` + 1. [x] - `p1` - Check `scheme`, `host`, and `port` against the endpoint shape row above - `inst-val-endpoint` +5. [x] - `p1` - **IF** the `server.endpoints` array holds more than one endpoint and the endpoints do not share one `scheme` or one `port` - `inst-val-pool-if` + 1. [x] - `p1` - Reject the pool with a validation error naming the diverging property; a pool is homogeneous by PRD §5.5, so a mixed-`scheme` pool and a mixed-`port` pool are both rejected and neither is ever stored - `inst-val-pool` +6. [x] - `p1` - **IF** any endpoint carries the `http` literal - `inst-val-http-if` + 1. [x] - `p1` - Accept it exactly when `allow_http_upstream` is `true`, and reject it otherwise; the flag is the only input to this decision (§1.5) - `inst-val-http` +7. [x] - `p1` - Check the protocol enum, the sharing enums, and the tag pattern - `inst-val-enums` +8. [x] - `p1` - **IF** the resource kind is `route` - `inst-val-route-if` + 1. [x] - `p1` - Check that `match` declares exactly one of `http` or `grpc`, then check the declared branch against the match shape row above, and check that `priority` is present (§1.5) - `inst-val-match` +9. [x] - `p1` - Check the `rate_limit` sub-object and the `cors` object, the latter with the upstream CORS shape for both resource kinds (§1.5) - `inst-val-subs` +10. [x] - `p1` - **IF** any check failed - `inst-val-fail-if` + 1. [x] - `p1` - **RETURN** one validation error naming every failing property, so a caller is not made to retry once per defect - `inst-val-fail-return` +11. [x] - `p1` - **RETURN** the validated domain write - `inst-val-return` + +### Alias Derivation and Enforcement + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-alias-derive` + +**Input**: the validated endpoint set, an optional caller-supplied alias, and whether the operation is a create or a replacement. + +**Output**: the normalized alias to store, or a validation error. + +**Precondition**: the endpoint set is homogeneous. `cpt-cf-oagw-algo-request-validate` has already established that every endpoint in `server.endpoints` carries the same `protocol`, the same `scheme`, and the same `port` (PRD §5.5, `cpt-cf-oagw-fr-alias-resolution`), so this routine never sees a pool it would have to describe with two ports. The precondition is what makes `suffix:port` derivation well-defined: one shared port yields exactly one `hostname:port` or `suffix:port` form for the whole pool, so the derived value is a property of the pool rather than of one endpoint in it, and the comparison against a stored alias during a replacement compares like with like. + +**Steps**: +1. [x] - `p1` - Normalize every endpoint host through `cpt-cf-oagw-algo-alias-normalize`, so derivation and later resolution can never disagree about the shape of a value - `inst-alias-derive-normalize` +2. [x] - `p1` - Classify each host as a hostname or an IP address - `inst-alias-derive-classify` +3. [x] - `p1` - **IF** the endpoint set holds a single hostname - `inst-alias-derive-single-if` + 1. [x] - `p1` - Derive the hostname itself on a standard port and `hostname:port` otherwise; the standard ports are 80 for HTTP and 443 for HTTPS, WSS, WT, and gRPC - `inst-alias-derive-single` +4. [x] - `p1` - **IF** the endpoint set holds several hostnames - `inst-alias-derive-multi-if` + 1. [x] - `p1` - Compute the longest common suffix of at least two labels and derive the suffix, or `suffix:port` when the port is non-standard - `inst-alias-derive-suffix` + 2. [x] - `p1` - **IF** the candidate suffix is itself a bare public suffix, such as `co.uk` - `inst-alias-derive-suffix-if` + 1. [x] - `p1` - Treat the set as non-derivable; a bare public suffix is never an alias - `inst-alias-derive-suffix-reject` +5. [x] - `p1` - **IF** any host is an IP address, or the set has no common suffix of at least two labels, or the candidate was rejected as a bare public suffix - `inst-alias-derive-nd-if` + 1. [x] - `p1` - Require an explicit alias; absent one, return a validation error naming the endpoint set as non-derivable - `inst-alias-derive-nd` +6. [x] - `p1` - **IF** the caller supplied an alias - `inst-alias-derive-supplied-if` + 1. [x] - `p1` - **IF** the supplied alias equals the derived value after normalization, accept it as an idempotent no-op - `inst-alias-derive-supplied-eq` + 2. [x] - `p1` - **ELSE** reject it with a validation error; for a derivable endpoint set the alias is not a free name - `inst-alias-derive-supplied-ne` +7. [x] - `p1` - **IF** the operation is a replacement - `inst-alias-derive-put-if` + 1. [x] - `p1` - Compare the derived alias with the stored one and report an `AliasConflict` (409, `gts.cf.core.errors.err.v1~cf.oagw.alias.conflict.v1`) when they differ; the alias is immutable across updates (§1.5) - `inst-alias-derive-put` +8. [x] - `p1` - **RETURN** the normalized alias - `inst-alias-derive-return` + +Every alias this routine returns satisfies the schema's alias pattern, is ASCII lowercase, and carries no trailing dot; the port suffix participates in identity, so a hostname on its standard port and the same hostname on 8443 are two different aliases. + +### Tenant Scoping and Ancestor Non-Addressability + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-tenant-scope` + +**Input**: the calling tenant resolved from the SecurityContext, the operation, and the resource selector — a path identifier, a body `upstream_id`, or nothing for a list. + +**Output**: the owned row or rows, or a `DomainError` answering 404 or 400. + +**Steps**: +1. [x] - `p1` - Read the calling tenant from the SecurityContext; a request without one is never executed against the database - `inst-scope-tenant` +2. [x] - `p1` - Build the read or write predicate so that it carries the tenant equality alongside every other key, through the secure ORM and with no raw SQL (`cpt-cf-oagw-principle-tenant-scope`) - `inst-scope-predicate` +3. [x] - `p1` - **IF** the operation addresses a resource by identifier in the path - `inst-scope-path-if` + 1. [x] - `p1` - Match on the identifier and the calling tenant only; an ancestor row can never satisfy the predicate, so a descendant's read, replacement, or deletion of an ancestor resource returns 404 - `inst-scope-path` + 2. [x] - `p1` - **IF** no row matched - `inst-scope-path-empty-if` + 1. [x] - `p1` - **RETURN** 404, with a nonexistent identifier and a foreign-owned one deliberately indistinguishable - `inst-scope-path-empty` +4. [x] - `p1` - **IF** the operation is a route create or route replacement - `inst-scope-upstream-if` + 1. [x] - `p1` - Resolve the referenced upstream on the identifier and the calling tenant; empty resolution answers 400, because a body reference that is not owned by the caller is a validation failure and not a disclosure about another tenant (§1.5) - `inst-scope-upstream` +5. [x] - `p1` - **IF** the operation is a list - `inst-scope-list-if` + 1. [x] - `p1` - Apply the tenant equality as the outermost predicate of the query, before the OData parameters of `cpt-cf-oagw-algo-odata-list` are applied, so no page can contain another tenant's row - `inst-scope-list` +6. [x] - `p1` - **RETURN** the owned row or rows - `inst-scope-return` + +This routine is the only place that decides which rows an operation may see. It never walks the tenant hierarchy: the walk that resolves an ancestor's configuration for a descendant belongs to `cpt-cf-oagw-feature-hierarchical-config`, and the management API's view of an ancestor resource stays empty by construction. + +### OData List Parameter Parsing and Bounding + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-odata-list` + +**Input**: the raw query string and the resource kind. + +**Output**: one bounded, tenant-scoped page, plus the projection, ordering, and filter it was built from, or a validation error. + +The five parameters, their types, and their bounds, as DESIGN §3.3 tabulates them for both resource kinds: + +| Parameter | Type | Default | Bound | +|-----------|------|---------|-------| +| `$filter` | string | none | An OData filter expression over the fields the resource exposes for filtering; the DESIGN examples are `alias eq 'api.openai.com'` for an upstream and `upstream_id eq '{uuid}'` for a route | +| `$select` | string | none | A comma-separated field list limiting the returned representation | +| `$orderby` | string | none | A field with an optional direction, drawn from the orderable fields of the resource kind in the table below; DESIGN §3.3's `created_at desc` example is not orderable in this run because the persisted model carries no timestamp column (§1.5), so the ordering examples are `alias` for an upstream and `priority` for a route | +| `$top` | integer | 50 | At most 100 results are returned, whatever the parameter asks for | +| `$skip` | integer | 0 | A non-negative offset into the tenant-scoped result set | + +The surface each resource kind exposes to those parameters, drawn from the persisted model this feature declares under `cpt-cf-oagw-dod-persisted-model`. `name` appears in neither row because neither resource kind declares one; `path` and `method` for a route live in the route's match and method rows rather than in the route row, and a tag field is satisfied by a parent that holds the tag in its tag table: + +| Resource kind | Filter fields | Orderable fields | Selectable properties | +|---|---|---|---| +| `upstream` | `id`, `alias`, `enabled`, and the upstream tags | `id`, `alias`, `enabled` | `id`, `alias`, `protocol`, `enabled`, `server`, `auth`, `headers`, `rate_limit`, `cors`, `plugins`, `tags` | +| `route` | `id`, `upstream_id`, `path`, `method`, `priority`, `enabled`, and the route tags | `id`, `upstream_id`, `priority`, `enabled` | `id`, `upstream_id`, `priority`, `enabled`, `match`, `rate_limit`, `cors`, `plugins`, `tags` | + +Every orderable field is single-valued per parent row, so the tag fields and the route's `path` and `method` are filterable but not orderable: a parent can hold several of each, and an ordering over a multi-valued field is not defined. + +**Steps**: +1. [x] - `p1` - Parse the five parameter names and ignore their absence; every absent parameter takes the default in the table above - `inst-odata-parse` +2. [x] - `p1` - Validate `$top` and `$skip` as non-negative integers - `inst-odata-paging` +3. [x] - `p1` - **IF** `$top` exceeds 100 - `inst-odata-top-if` + 1. [x] - `p1` - Bound the page to 100 results; the ceiling is a hard bound on the response, so an oversized page can never be served by asking for it - `inst-odata-top-cap` +4. [x] - `p1` - Validate `$filter` and `$orderby` against the fields the resource kind exposes for filtering and ordering, and `$select` against its declared properties - `inst-odata-expressions` +5. [x] - `p1` - **IF** any expression cannot be parsed or names a field the resource kind does not expose - `inst-odata-fail-if` + 1. [x] - `p1` - **RETURN** a validation error naming the offending parameter; a malformed expression is never interpreted as an empty filter - `inst-odata-fail-return` +6. [x] - `p1` - Apply the tenant equality from `cpt-cf-oagw-algo-tenant-scope` first, then the filter, then the ordering, then the offset, and finally the bound - `inst-odata-apply` +7. [x] - `p1` - Assemble the page in one query set rather than one query per parent: the tag rows of the page's parent rows, and for a route page also its match and method rows, are read for the whole page in a single query set, so a page bounded to 100 parents costs a bounded number of queries whatever it holds - `inst-odata-assemble` +8. [x] - `p1` - **RETURN** the page and the projection it was built with - `inst-odata-return` + +### Full-Replacement Diff + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-put-replace-diff` + +**Input**: the stored row with its dependent rows, the validated replacement, and the resource kind. + +**Output**: the write set to apply in one transaction, or a `DomainError` answering 409. + +**Steps**: +1. [x] - `p1` - Confirm the immutable fields: `id` and `tenant_id` are taken from the addressed row and never from the body, and a body that states either with a different value is a validation failure - `inst-diff-immutable` +2. [x] - `p1` - **IF** the resource kind is `route` - `inst-diff-route-if` + 1. [x] - `p1` - Take `upstream_id` from the stored row; the update DTO does not carry it, and a body that supplies one is a validation failure rather than a silent override - `inst-diff-route-upstream` + 2. [x] - `p1` - Re-run `cpt-cf-oagw-algo-match-uniqueness` against the other enabled routes of the same upstream, excluding the row being replaced - `inst-diff-route-unique` +3. [x] - `p1` - **IF** the resource kind is `upstream` - `inst-diff-upstream-if` + 1. [x] - `p1` - Recompute the derived alias from the replacement endpoints and compare it with the stored alias; a difference is a conflict (§1.5) - `inst-diff-upstream-alias` +4. [x] - `p1` - Build the write set by overwriting every configuration family with the body's value and clearing the optional families the body omits, with the single exception of `enabled`, which is carried forward when the body omits it (§1.5) - `inst-diff-clear` +5. [x] - `p1` - Compute the tag replacement set as the full set of tags in the body, so a body with fewer tags removes the difference - `inst-diff-tags` +6. [x] - `p1` - **IF** the write set is empty because nothing differs - `inst-diff-empty-if` + 1. [x] - `p1` - Apply nothing and return the stored representation; the write path is not skipped, so the cache flush of ADR 0006 still runs - `inst-diff-empty` +7. [x] - `p1` - **RETURN** the write set - `inst-diff-return` + +### Route Match Uniqueness + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-match-uniqueness` + +**Input**: the owning upstream identifier, the incoming match keys, the other routes of that upstream with their enabled flags, and whether the incoming route is being created or replaced. + +**Output**: confirmation, or a `DomainError` answering 409 with the `MatchConflict` variant (`gts.cf.core.errors.err.v1~cf.oagw.match.conflict.v1`) and naming the colliding route. + +**Steps**: +1. [x] - `p1` - Expand the incoming route into one key per declared method, each carrying the path and the priority; a route that declares three methods contributes three keys - `inst-match-expand` +2. [x] - `p1` - Restrict the comparison set to the enabled routes of the same upstream, excluding the row being replaced (§1.5) - `inst-match-set` +3. [x] - `p1` - **FOR EACH** incoming key - `inst-match-loop` + 1. [x] - `p1` - **IF** an enabled route of the same upstream holds the same path, the same priority, and the same method - `inst-match-collide-if` + 1. [x] - `p1` - **RETURN** 409 with the `MatchConflict` variant, naming the colliding route - `inst-match-collide` +4. [x] - `p1` - **RETURN** confirmation - `inst-match-return` + +The predicate is the DESIGN §3.6 invariant — no two enabled routes under the same upstream may share a path and priority for the same method — with the DESIGN §3.3 wording of the same predicate. Two routes that differ in any one of the three components never collide, and two disabled routes with identical keys are stored without a conflict. + +## 4. States (CDSL) + +### Upstream and Route Effective Lifecycle + +- [x] `p2` - **ID**: `cpt-cf-oagw-state-config-lifecycle` + +**States**: `Enabled`, `Disabled` + +**Initial State**: `Enabled` + +The machine is the effective lifecycle of one upstream or route as a calling tenant observes it, not the history of the stored boolean. `Enabled` is the initial state because PRD §5.1 gives `enabled` the default `true` on both resource types and the upstream schema repeats that default. There is no third state: no supplied document defines a created-but-inert condition, and the postcondition of PRD §8's Configure Upstream use case is that the resource is created and available for proxy routing, so a successfully persisted row is immediately meaningful. Deletion is not a state either — it removes the row, the cascade removes its dependents, and nothing of the resource remains to be in a state. + +**Transitions**: +1. [x] - `p1` - **FROM** `Enabled` **TO** `Disabled` **WHEN** the owning tenant replaces the resource with `enabled` set to `false` - `inst-state-disable` +2. [x] - `p1` - **FROM** `Enabled` **TO** `Disabled` **WHEN** a contributing ancestor row is disabled — no write reaches this row, the change is observed only from the descendant's side, and the hierarchy walk that detects it belongs to `cpt-cf-oagw-feature-hierarchical-config` - `inst-state-ancestor-disable` +3. [x] - `p1` - **FROM** `Disabled` **TO** `Enabled` **WHEN** the owning tenant replaces the resource with `enabled` set to `true` and no contributing ancestor row is disabled - `inst-state-enable` +4. [x] - `p1` - **FROM** `Disabled` **TO** `Enabled` is refused, and the resource stays `Disabled`, **WHEN** a contributing ancestor row is disabled: this is the no-descendant-re-enable guard of `cpt-cf-oagw-fr-enable-disable`, and the ancestor non-addressability of `cpt-cf-oagw-algo-tenant-scope` is what keeps the guard reachable from this API - `inst-state-ancestor-guard` + +A disabled upstream keeps its routes and its configuration. PRD §5.1 makes a disabled upstream answer 503 at proxy time and a disabled route absent from matching, and both of those are evaluated by the features that own the proxy path; this feature's obligation is that the stored flag is authoritative for its owner, that no management write by a descendant can alter an ancestor's row, and that the flag survives a replacement that does not mention it (§1.5). Because the state is effective rather than stored, transition 2 requires no write and produces no audit record of its own; the audit surface for configuration changes belongs to `cpt-cf-oagw-feature-observability`. + +## 5. Definitions of Done + +### Management Route Registration + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-management-routes` + +The system **MUST** register exactly the ten management endpoints of DECOMPOSITION §2.2 — `POST /oagw/v1/upstreams`, `GET /oagw/v1/upstreams`, `GET /oagw/v1/upstreams/{id}`, `PUT /oagw/v1/upstreams/{id}`, `DELETE /oagw/v1/upstreams/{id}`, `POST /oagw/v1/routes`, `GET /oagw/v1/routes`, `GET /oagw/v1/routes/{id}`, `PUT /oagw/v1/routes/{id}`, `DELETE /oagw/v1/routes/{id}` — on the gear-relative router mount point the foundation created, with `{id}` accepted as the anonymous GTS identifier of the resource (`gts.cf.core.oagw.upstream.v1~{uuid}` or `gts.cf.core.oagw.route.v1~{uuid}`), and **MUST** register no plugin route: the five plugin paths of DESIGN §3.3 belong to `cpt-cf-oagw-feature-plugin-system` (`cpt-cf-oagw-adr-request-routing`). + +**Implements**: + +- `cpt-cf-oagw-flow-upstream-create` +- `cpt-cf-oagw-flow-route-create` +- `cpt-cf-oagw-flow-config-read-list` +- `cpt-cf-oagw-flow-upstream-replace-delete` +- `cpt-cf-oagw-flow-route-delete` +- `cpt-cf-oagw-flow-enable-disable` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: `POST /oagw/v1/upstreams`, `GET /oagw/v1/upstreams`, `GET /oagw/v1/upstreams/{id}`, `PUT /oagw/v1/upstreams/{id}`, `DELETE /oagw/v1/upstreams/{id}`, `POST /oagw/v1/routes`, `GET /oagw/v1/routes`, `GET /oagw/v1/routes/{id}`, `PUT /oagw/v1/routes/{id}`, `DELETE /oagw/v1/routes/{id}` +- DB: none — registration only; the tables are claimed by `cpt-cf-oagw-dod-persisted-model` +- DB Table: none +- Entities: none — the domain types were declared by `cpt-cf-oagw-feature-gear-foundation` + +### Authentication and Management Permissions + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-authz-permissions` + +The system **MUST** require bearer-token authentication through `toolkit-auth` on every management endpoint and **MUST** enforce `gts.cf.core.oagw.upstream.v1~:{create;override;read;delete}` on the upstream endpoints and `gts.cf.core.oagw.route.v1~:{create;override;read;delete}` on the route endpoints, so that a create is answered only with `create`, a replacement and any `enabled` change only with `override`, a deletion only with `delete`, and a read or list only with `read`. A request without a valid token **MUST** be answered 401 and a valid token without the required permission **MUST** be answered 403, in both cases before any validation or database access. The `*_plugin` arm of the permission family named in DECOMPOSITION §2.2 is **NOT** enforced here; `cpt-cf-oagw-feature-plugin-system` owns it. + +**Implements**: + +- `cpt-cf-oagw-flow-upstream-create` +- `cpt-cf-oagw-flow-route-create` +- `cpt-cf-oagw-flow-upstream-replace-delete` +- `cpt-cf-oagw-flow-enable-disable` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: all ten paths listed under `cpt-cf-oagw-dod-management-routes` +- DB: none — authorization precedes every database access +- DB Table: none +- Entities: none + +### Request Validation Against the Shipped Schemas + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-request-validation` + +The system **MUST** validate every create and replacement body against `schemas/upstream.v1.schema.json` or `schemas/route.v1.schema.json` before any write, covering every family tabulated under `cpt-cf-oagw-algo-request-validate`, and **MUST** answer a failing body with a single validation error naming every failing property. Validation **MUST** branch on create versus replacement, and it does so in exactly one place: the required-property step. A create **MUST** satisfy the full set the resource kind's schema declares, and a replacement **MUST** satisfy the same set minus `upstream_id` on a route, because the replacement DTO is the create DTO minus `upstream_id`; a replacement body that nevertheless carries `upstream_id` **MUST** still be rejected 400 by the immutable-field rule rather than silently ignored. The §1.5 deviations this DoD enforces **MUST** hold, enumerated by their deviation text so that no count in this DoD can disagree with the §1.5 table: the write-time `scheme` admission that accepts the `http` literal exactly when `allow_http_upstream` is `true`; the route-level `cors` object validated with the upstream CORS shape although the shipped route schema declares no route-level `cors` property; route `priority` required although the shipped route schema declares no `priority`; and the route replacement DTO validated against the schema with `upstream_id` removed from its `required` set. The `auth` sub-configuration's credential reference **MUST** be validated for shape only and **MUST NOT** be resolved. + +**Implements**: + +- `cpt-cf-oagw-algo-request-validate` +- `cpt-cf-oagw-flow-upstream-create` +- `cpt-cf-oagw-flow-route-create` + +**Constraints**: `cpt-cf-oagw-constraint-https-only`, `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: `POST /oagw/v1/upstreams`, `PUT /oagw/v1/upstreams/{id}`, `POST /oagw/v1/routes`, `PUT /oagw/v1/routes/{id}` +- DB: none — validation precedes every write +- DB Table: none +- Entities: `Upstream`, `Route`, `Endpoint`, `ServerConfig`, `RateLimitConfig`, `CorsConfig` + +### Alias Derivation, Normalization, and Uniqueness + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-alias-derivation` + +The system **MUST** derive the alias of every upstream from its endpoint set — hostname on a standard port, `hostname:port` on a non-standard port, and the longest common suffix of at least two labels for several hostnames — **MUST** require an explicit alias for IP-based and otherwise non-derivable endpoint sets, **MUST** treat a bare public suffix as never derivable, **MUST** accept a caller-supplied alias only when it equals the derived value after normalization, **MUST** store every alias ASCII-lowercase with trailing dots stripped and resolve it case-insensitively, **MUST** keep the alias immutable across replacements, and **MUST** enforce `(tenant_id, alias)` uniqueness so that a duplicate within the calling tenant answers 409 and the same alias in another tenant does not. The uniqueness key **MUST** be enforced in the write transaction as well as by the pre-write check, and a unique-constraint violation on `(tenant_id, alias)` surfaced by that transaction **MUST** map to the same 409 `AliasConflict` answer the pre-write check returns, so two concurrent creates of the same alias produce the same conflict rather than a 500. + +**Implements**: + +- `cpt-cf-oagw-algo-alias-derive` +- `cpt-cf-oagw-algo-put-replace-diff` +- `cpt-cf-oagw-flow-upstream-create` +- `cpt-cf-oagw-flow-upstream-replace-delete` + +**Constraints**: `cpt-cf-oagw-constraint-multi-sql` + +**Touches**: + +- API: `POST /oagw/v1/upstreams`, `PUT /oagw/v1/upstreams/{id}` +- DB: `cpt-cf-oagw-db-schema` — the `(tenant_id, alias)` uniqueness key on the upstream table +- DB Table: `oagw_upstream` +- Entities: `Upstream`, `Endpoint`, `ServerConfig`, `Alias` + +### Tenant Scoping on Every Operation + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-tenant-scoping` + +The system **MUST** scope every management operation to the calling tenant at the data layer, by carrying the tenant equality in the same predicate as every other key through the secure ORM and with no raw SQL, and **MUST** answer a path-addressed resource owned by another tenant — including an ancestor — with 404 that is indistinguishable from a missing resource. A route body referencing an upstream the calling tenant does not own **MUST** be answered 400 per §1.5. No management operation **MUST** read or write a row whose `tenant_id` differs from the caller's, which is the zero-cross-tenant threshold of `cpt-cf-oagw-nfr-multi-tenancy` (`cpt-cf-oagw-principle-tenant-scope`). + +**Implements**: + +- `cpt-cf-oagw-algo-tenant-scope` +- `cpt-cf-oagw-flow-upstream-replace-delete` +- `cpt-cf-oagw-flow-enable-disable` + +**Constraints**: `cpt-cf-oagw-constraint-multi-sql` + +**Touches**: + +- API: all ten paths listed under `cpt-cf-oagw-dod-management-routes` +- DB: `cpt-cf-oagw-db-schema` — tenant-scoped reads and writes on every table this feature owns +- DB Table: `oagw_upstream`, `oagw_route`, `oagw_route_http_match`, `oagw_route_grpc_match`, `oagw_route_method`, `oagw_upstream_tag`, `oagw_route_tag` +- Entities: `Upstream`, `Route` + +### Persisted Model Shape and Transactional Writes + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-persisted-model` + +The system **MUST** persist upstreams and routes in the table set DECOMPOSITION §1.6 assigns to this feature — `oagw_upstream` keyed on `id` and unique on `(tenant_id, alias)`, `oagw_route` keyed on `id` with a cascading foreign key to `upstream_id`, `oagw_route_http_match` keyed on `route_id`, `oagw_route_grpc_match` keyed on `route_id`, `oagw_route_method` keyed on `(route_id, method)`, and `oagw_upstream_tag` with `oagw_route_tag` keyed on `(parent_id, tag)` — and **MUST** apply every multi-table write inside a single transaction so that a failure leaves no partial rows. It **MUST** cascade a deletion in both directions the model defines: an upstream deletion into its routes, match rows, method rows, and tag rows, and a route deletion into its own match, method, and tag rows, the route-side cascade being the one `cpt-cf-oagw-flow-route-delete` relies on. It **MUST** carry the two access paths those reads and checks need, named here in prose rather than declared as DDL: the index that serves the tenant-scoped scan of each table this feature owns, because every read predicate leads with the tenant equality, and the index that serves the `(upstream_id, path, priority, method)` lookup `cpt-cf-oagw-algo-match-uniqueness` performs against the enabled routes of an upstream. It **MUST** stay portable across the PostgreSQL, MySQL, and SQLite backends of `cpt-cf-oagw-constraint-multi-sql` by avoiding backend-specific features, and **MUST NOT** create or write the plugin and plugin-binding tables (§1.5, §1.6). The two `auth_plugin_ref` and `auth_plugin_uuid` columns DESIGN §3.1 declares on the upstream row are the exception to that rule: this feature declares the upstream row that carries them, and `cpt-cf-oagw-feature-plugin-system` writes exactly those two columns inside the single transaction this feature's parent write opens, so that column set has one declaring feature and one writing feature and no third writer. + +A persistence-layer failure is **NOT** a `DomainError` catalogue variant. It **MUST** be answered by the platform's RFC 9457 500 problem shape carrying `X-OAGW-Error-Source: gateway`, **MUST** be logged with the correlation identifier, and **MUST** fail the request without partial writes, which the single-transaction rule above is what guarantees; a successful deletion **MUST** be answered `204 No Content` with no body. + +**Implements**: + +- `cpt-cf-oagw-flow-upstream-create` +- `cpt-cf-oagw-flow-route-create` +- `cpt-cf-oagw-flow-upstream-replace-delete` +- `cpt-cf-oagw-flow-route-delete` +- `cpt-cf-oagw-algo-put-replace-diff` + +**Constraints**: `cpt-cf-oagw-constraint-multi-sql`, `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: none — the tables are written by the handlers registered under `cpt-cf-oagw-dod-management-routes` +- DB: `cpt-cf-oagw-db-schema` — this feature's share of the shared schema +- DB Table: `oagw_upstream`, `oagw_route`, `oagw_route_http_match`, `oagw_route_grpc_match`, `oagw_route_method`, `oagw_upstream_tag`, `oagw_route_tag` +- Entities: `Upstream`, `Route`, `Endpoint`, `ServerConfig`, `MatchConfig`, upstream and route tag rows, the `(tenant_id, alias)` uniqueness key + +### Full-Replacement Semantics + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-full-replacement-put` + +The system **MUST** treat PUT as full replacement: every configuration family is overwritten, every optional family the body omits is cleared, and the tag set is replaced in full. `id`, `tenant_id`, and the route's `upstream_id` **MUST** be immutable, and an update DTO that carries a route `upstream_id` **MUST** be rejected rather than silently ignored. The alias **MUST** be immutable across replacements, so a replacement whose endpoints would derive a different alias answers 409 with the `AliasConflict` variant and leaves the stored alias unchanged, while one that derives the same alias is accepted (§1.5). Match-rule uniqueness **MUST** be revalidated against the other enabled routes of the same upstream, excluding the row being replaced, and a unique-constraint violation on the match keys surfaced by the write transaction **MUST** map to the same 409 `MatchConflict` answer the pre-write check returns. + +**Implements**: + +- `cpt-cf-oagw-algo-put-replace-diff` +- `cpt-cf-oagw-algo-match-uniqueness` +- `cpt-cf-oagw-flow-upstream-replace-delete` + +**Constraints**: `cpt-cf-oagw-constraint-multi-sql` + +**Touches**: + +- API: `PUT /oagw/v1/upstreams/{id}`, `PUT /oagw/v1/routes/{id}` +- DB: `cpt-cf-oagw-db-schema` — replacement writes and tag replacement +- DB Table: `oagw_upstream`, `oagw_route`, `oagw_route_http_match`, `oagw_route_grpc_match`, `oagw_route_method`, `oagw_upstream_tag`, `oagw_route_tag` +- Entities: `Upstream`, `Route`, `MatchConfig` + +### Enable and Disable Semantics + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-enable-disable` + +The system **MUST** store `enabled` on both resource types with the default `true` that PRD §5.1 states, **MUST** accept a transition only from the owning tenant, **MUST** leave the stored flag unchanged when a replacement body omits it (§1.5), **MUST** keep a disabled upstream's routes in place, **MUST** present the effective state as the conjunction of the resource's own flag with the flags of the contributing ancestor rows so that an ancestor disable reaches every descendant without a write, and **MUST NOT** allow any management operation to raise the effective state of a resource while a contributing ancestor row is disabled, which is the no-descendant-re-enable guard of `cpt-cf-oagw-state-config-lifecycle`. The 503 answer for a disabled upstream and the exclusion of a disabled route from matching are evaluated by the features that own the proxy path; the flag persisted here is the signal they consume (`cpt-cf-oagw-fr-enable-disable`). + +**Implements**: + +- `cpt-cf-oagw-state-config-lifecycle` +- `cpt-cf-oagw-flow-enable-disable` +- `cpt-cf-oagw-algo-put-replace-diff` + +**Constraints**: `cpt-cf-oagw-constraint-multi-sql` + +**Touches**: + +- API: `PUT /oagw/v1/upstreams/{id}`, `PUT /oagw/v1/routes/{id}` +- DB: `cpt-cf-oagw-db-schema` — the `enabled` column on both resource rows +- DB Table: `oagw_upstream`, `oagw_route` +- Entities: `Upstream`, `Route` + +### List and Query Parameters + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-list-query-parameters` + +The system **MUST** support `$filter`, `$select`, `$orderby`, `$top`, and `$skip` on both list endpoints, **MUST** default `$top` to 50 and bound every page to 100 results whatever the parameter asks for, **MUST** apply the tenant equality before any of the five parameters so that no page can contain another tenant's row, **MUST** answer a malformed parameter or an expression naming a field the resource kind does not expose with a validation error, and **MUST** leave the response to a list request with no parameters bounded by the default (`cpt-cf-oagw-interface-management-api`). + +**Implements**: + +- `cpt-cf-oagw-algo-odata-list` +- `cpt-cf-oagw-algo-tenant-scope` +- `cpt-cf-oagw-flow-config-read-list` + +**Constraints**: `cpt-cf-oagw-constraint-multi-sql` + +**Touches**: + +- API: `GET /oagw/v1/upstreams`, `GET /oagw/v1/routes` +- DB: `cpt-cf-oagw-db-schema` — bounded, tenant-scoped list reads +- DB Table: `oagw_upstream`, `oagw_route`, `oagw_route_http_match`, `oagw_route_method`, `oagw_upstream_tag`, `oagw_route_tag` +- Entities: `Upstream`, `Route` + +### Colocated Tests + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-colocated-tests` + +The system **MUST** deliver this feature's unit and integration tests colocated under `gears/system/oagw/oagw/tests/`, covering schema validation and every override of §1.5, alias derivation by endpoint type including the bare-public-suffix case, tenant scoping with the 404-for-ancestor behaviour, full-replacement clearing and alias immutability, match uniqueness, the enable and disable transitions, and the OData parameters, and **MUST NOT** add any test under `testing/e2e/gears/oagw/` (DECOMPOSITION §1.3(3)). The coverage **MUST** include an endpoint-pool homogeneity case in which a pool whose endpoints declare two different `scheme` values is rejected with a validation error and a case in which a pool whose endpoints declare two different `port` values is rejected with a validation error, and a credential-isolation case asserting that no credential material is resolved or logged — the `auth.secret_ref` reference stays opaque and `auth.config` is never written to a log — and that a problem `detail` carries no echo of the request body. + +**Implements**: + +- `cpt-cf-oagw-algo-request-validate` +- `cpt-cf-oagw-algo-alias-derive` +- `cpt-cf-oagw-algo-tenant-scope` +- `cpt-cf-oagw-algo-odata-list` +- `cpt-cf-oagw-algo-put-replace-diff` +- `cpt-cf-oagw-algo-match-uniqueness` +- `cpt-cf-oagw-state-config-lifecycle` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: all ten paths listed under `cpt-cf-oagw-dod-management-routes` +- DB: `cpt-cf-oagw-db-schema` — the tables the integration tests exercise +- DB Table: `oagw_upstream`, `oagw_route`, `oagw_route_http_match`, `oagw_route_grpc_match`, `oagw_route_method`, `oagw_upstream_tag`, `oagw_route_tag` +- Entities: none — tests only + +## 6. Acceptance Criteria + +- [x] Exactly the ten management paths of DECOMPOSITION §2.2 are registered, all gear-relative, a request to the `/api/oagw/v1/upstreams` form is answered by no OAGW handler, and `GET /oagw/v1/upstreams/{id}` resolves a resource addressed as `gts.cf.core.oagw.upstream.v1~{uuid}` while a path parameter that is not the anonymous GTS identifier of one of the caller's resources resolves to no resource. +- [x] A management request without a bearer token is answered 401 and reaches no handler and no database access, a valid token without `gts.cf.core.oagw.upstream.v1~:create` is answered 403 and writes no row, and a token holding only `read` succeeds on `GET /oagw/v1/upstreams` while failing on `POST /oagw/v1/upstreams` and on the `PUT` and `DELETE` of an upstream it owns. +- [x] A create body omitting `server` or `protocol`, carrying an unknown property, holding an endpoint without `scheme` or `host`, or holding a `port` of 0 or 65536 is answered 400 with a validation error naming every failing property; the same holds for a route body whose root carries a property outside the route schema's declared set plus the §1.5-added `priority` and `enabled`, while a route body that states `priority` or `enabled` at the root is accepted because the route schema's root object is open. +- [x] A route body whose `match` declares both `http` and `grpc`, neither of them, an `http` branch with no `methods`, or a `grpc` branch without `service` is answered 400. +- [x] A `rate_limit` without `sustained`, a `sustained` with `rate` below 1, a `window` outside the four-value enum, a `cors` object without `enabled`, or a `cors` object with `allow_credentials` set to `true` and `allowed_origins` containing `*` is answered 400, and the route-level `cors` object is accepted when it satisfies the same shape as the upstream one. +- [x] An upstream endpoint with the `http` scheme is accepted exactly when `allow_http_upstream` is `true` and rejected with 400 when it is `false`, with no other input changing the outcome. +- [x] A single hostname endpoint on 443 derives `api.openai.com`, the same hostname on 8443 derives `api.openai.com:8443`, and the two are distinct aliases. +- [x] Endpoint sets of `us.vendor.com` and `eu.vendor.com` on 443 derive `vendor.com`, and an IP-only endpoint set derives nothing and requires an explicit alias, its absence being answered 400. +- [x] Endpoint sets whose only common suffix is a bare public suffix such as `co.uk` are answered 400 as non-derivable and are stored only with an explicit alias. +- [x] A caller-supplied alias equal to the derived value after normalization is accepted and one that differs is answered 400, and aliases are stored ASCII-lowercase with trailing dots stripped so that an upstream created as `API.OpenAI.com.` is resolved by a lookup for `api.openai.com`. +- [x] A second upstream with the same normalized alias in the same tenant is answered 409, and the same alias created by a different tenant is not. +- [x] `POST /oagw/v1/routes` with an `upstream_id` owned by another tenant, including an ancestor, is answered 400 and writes no row. +- [x] Two routes under the same upstream with the same path, priority, and method are answered 409 on the second, while a route differing in any one of the three is accepted; two disabled routes with identical keys are both stored. +- [x] A `PUT /oagw/v1/routes/{id}` body that omits `upstream_id` is a conforming replacement: it is not rejected for the missing `upstream_id`, it clears every optional family it omits, and the stored `upstream_id` stays unchanged, while a replacement body that nevertheless carries `upstream_id` is answered 400. +- [x] `PUT /oagw/v1/upstreams/{id}` whose endpoints would derive an alias different from the stored one is answered 409, the stored alias is unchanged, and a replacement that adds a pooled endpoint while deriving the same alias succeeds. +- [x] A descendant tenant's `GET`, `PUT`, and `DELETE` of an upstream owned by an ancestor tenant are each answered 404, while the ancestor's own requests for the same identifier succeed. +- [x] A list request with no `$top` returns at most 50 rows and one with `$top` above 100 returns at most 100, `$filter`, `$orderby`, `$select`, and `$skip` narrow, order, project, and offset the tenant-scoped result set respectively, a malformed `$top` or `$skip` or a `$filter` naming a field the resource kind does not expose is answered 400 rather than interpreted as an absent parameter, and an `$orderby` naming `created_at` is answered 400 because the persisted model carries no timestamp column. +- [x] Setting `enabled` to `false` on an upstream leaves its routes present, and restoring it to `true` returns the resource to `Enabled` unless a contributing ancestor row is disabled, in which case it stays effectively `Disabled`; a replacement body that omits `enabled` leaves the stored flag unchanged rather than restoring the default `true`. +- [x] Deleting an upstream removes its routes, both match tables' rows, its method rows, and both tag tables' rows in one transaction on each of the PostgreSQL, MySQL, and SQLite backends, the deletion succeeds with `204 No Content` and no body, and a write that fails partway leaves no partial rows. +- [x] Every test for this feature lives under `gears/system/oagw/oagw/tests/`, passes there, and no test is added under `testing/e2e/gears/oagw/`. diff --git a/gears/system/oagw/docs/features/cors.md b/gears/system/oagw/docs/features/cors.md new file mode 100644 index 0000000..cf18ec9 --- /dev/null +++ b/gears/system/oagw/docs/features/cors.md @@ -0,0 +1,560 @@ +# Feature: CORS + + + +- [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 Feature-Local Deviations from Shared Baselines](#15-feature-local-deviations-from-shared-baselines) + - [1.6 Explicit Non-Applicability](#16-explicit-non-applicability) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Answer a Preflight Request at Handler Level](#answer-a-preflight-request-at-handler-level) + - [Enforce CORS on an Actual Cross-Origin Request](#enforce-cors-on-an-actual-cross-origin-request) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Fold the Effective CORS Configuration](#fold-the-effective-cors-configuration) + - [Decide an Actual Cross-Origin Request](#decide-an-actual-cross-origin-request) + - [Build the Preflight Header Set](#build-the-preflight-header-set) +- [4. States (CDSL)](#4-states-cdsl) +- [5. Definitions of Done](#5-definitions-of-done) + - [Preflight Answer at Handler Level](#preflight-answer-at-handler-level) + - [Actual-Request Origin and Method Enforcement](#actual-request-origin-and-method-enforcement) + - [Exact Origin Matching and the Credentials Restriction](#exact-origin-matching-and-the-credentials-restriction) + - [Response Decoration](#response-decoration) + - [Hierarchical CORS Consumption](#hierarchical-cors-consumption) + - [CORS Entities and Layering](#cors-entities-and-layering) + - [Colocated Tests](#colocated-tests) +- [6. Acceptance Criteria](#6-acceptance-criteria) + + + +- [ ] `p1` - **ID**: `cpt-cf-oagw-featstatus-cors-implemented` + + +- [ ] `p2` - `cpt-cf-oagw-feature-cors` + +## 1. Feature Context + +### 1.1 Overview + +This feature is the CORS policy of the `oagw` gear: the built-in handler ADR 0004 (`cpt-cf-oagw-adr-cors`) chooses over proxying the protocol to the upstream and over delegating it to a guard plugin. It attaches to the proxy path `cpt-cf-oagw-feature-data-plane-proxy` owns and answers two different questions about two different requests. A preflight — an `OPTIONS` request carrying `Origin` and `Access-Control-Request-Method` — is answered locally and permissively with 204 at handler level, before that path resolves an upstream, matches a route, authenticates a caller, executes a plugin, or charges a counter, because the browser that sends a preflight sends no credentials and no tenant context exists to resolve with. An actual cross-origin request is checked against the resolved configuration after resolution and before anything is forwarded: an origin the effective `allowed_origins` does not name and a method the effective `allowed_methods` does not list are each answered 403 on the proxy path the caller already called. + +The feature registers no route of its own and holds no state between requests. The actual-request answer is a pure function of the request and of the effective configuration the resolution already produced, and the preflight answer is a pure function of the request alone; every answer it produces is tagged `X-OAGW-Error-Source: gateway` like every other gateway answer, and it is written as an RFC 9457 problem body when it is a refusal. + +### 1.2 Purpose + +DECOMPOSITION §2.7 places this feature as the second of the three policy tails that hang off the proxy spine: `cpt-cf-oagw-feature-data-plane-proxy` resolves, matches, and forwards, and this feature decides whether a cross-origin call may go out and what the browser may read of the answer. DECOMPOSITION §3 makes it a consumer of the proxy feature because the enforcement runs "after upstream resolution and before forwarding", and makes `cpt-cf-oagw-feature-hierarchical-config` a dependency because the configuration it enforces is that feature's merged output. + +This feature delivers the whole of ADR 0004's chosen option — the built-in handler — and the CORS part of the DESIGN §3.2 Security Considerations subsection, which states the split this document implements: "Preflight OPTIONS requests return a permissive 204 at the handler level (no upstream resolution or tenant context required). Origin validation happens on actual requests after upstream resolution, before forwarding." The two CORS rows of the DESIGN §3.2 Guard Rules table are this feature's and not `cpt-cf-oagw-algo-inbound-validate`'s, which `cpt-cf-oagw-feature-data-plane-proxy` records in its own §1.6 (§1.5). ADR 0004's two rejected options stay rejected here: the `cors` guard identifier `gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.cors.v1` that PRD §5.3 lists exists only for types-registry cataloging and cannot be bound through `plugins.items[].plugin_ref`, so the plugin option is not merely declined but unreachable, and no feature of this decomposition proxies a CORS preflight to an upstream. The value measure ADR 0004's decision drivers give is that a browser client completes a cross-origin call through the gear without the operator widening the proxy to every origin, and the answer does not depend on the upstream being reachable. + +Deliverables: + +- The preflight answer at handler level, produced from the request's own three headers and from nothing else, with no upstream resolution, no tenant context, and no per-request authentication or plugin check. +- The actual-request enforcement after resolution: the origin check and the method check, each answering 403, both before anything is forwarded. +- Exact origin matching only — the whole `Origin` value against a configured entry, scheme- and port-significant, with no pattern, no suffix, and no case folding — and the `*` wildcard as the one non-exact entry the shipped schema admits. +- The credentials restriction: `allow_credentials` is never combined with a wildcard origin, refused at write time by the frozen schema's own conditional and never served permissively here. +- Response decoration with `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods`, `Access-Control-Expose-Headers`, `Access-Control-Max-Age`, and an always-present `Vary: Origin`, split between the preflight set and the actual-request set exactly as ADR 0004's two worked examples split them. +- The enforcement-time consumption of the CORS row of the DESIGN §3.2 Hierarchical Configuration merge table, taken from the `EffectiveCors` results `cpt-cf-oagw-feature-hierarchical-config` produces per layer, following the `inherit` and `enforce` sharing modes that feature already applied. +- Colocated tests under `gears/system/oagw/oagw/tests/`. + +**Requirements**: + +- [ ] `p1` - `cpt-cf-oagw-fr-request-proxy` +- [ ] `p1` - `cpt-cf-oagw-nfr-input-validation` +- [ ] `p1` - `cpt-cf-oagw-fr-header-transform` +- [x] `p2` - `cpt-cf-oagw-fr-hierarchical-config` + +`cpt-cf-oagw-fr-hierarchical-config` carries the checked state DECOMPOSITION §2.7 records: its merge behaviour is delivered by `cpt-cf-oagw-feature-hierarchical-config`, and what this feature consumes of it is the per-layer `EffectiveCors` result rather than a second merge (§1.5). `cpt-cf-oagw-fr-header-transform` is covered here because the decoration this feature computes is response header work — ADR 0004's traceability states exactly that — while the set/add/remove operations themselves, the passthrough control, and the hop-by-hop stripping are that requirement's other half and stay with `cpt-cf-oagw-feature-data-plane-proxy`. + +**Principles**: + +- `p1` - `cpt-cf-oagw-principle-rfc9457` +- `p1` - `cpt-cf-oagw-adr-cors` +- `p1` - `cpt-cf-oagw-principle-error-source` +- `p1` - `cpt-cf-oagw-adr-error-source-distinction` + +**Constraints**: + +- `p1` - `cpt-cf-oagw-constraint-toolkit-deploy` + +**Design Components**: + +- `p1` - `cpt-cf-oagw-component-model` + +This feature delivers the CORS merge row of the DESIGN §3.2 Hierarchical Configuration subsection — the row whose strategy is "Union origins if `inherit`; forced if `enforce`" — as its enforcement-time consumption, together with the CORS part of the §3.2 Security Considerations subsection. The merge table itself, the hierarchy walk, and the sharing-mode decision are `cpt-cf-oagw-feature-hierarchical-config`'s and are not restated here. + +**Domain Model Entities**: + +- `CorsDecision` — the verdict of one actual cross-origin request, carrying whether the request is admitted or refused, which of the two reasons refused it, and the response decoration to attach when it is admitted. +- The preflight response shape — the 204 answer and its five CORS header members, which ADR 0004's preflight example spells out, together with the gateway error-source tag §1.5 records, and which this document fixes as the one shape the preflight answer takes. + +`CorsDecision` and the preflight response shape are declared here and DECOMPOSITION §2.7 lists both under this entry; `CorsConfig` is listed by DECOMPOSITION §2.7 because the enforcement semantics of its members are this feature's, and is consumed from `cpt-cf-oagw-feature-gear-foundation`, which declares it as shared vocabulary, rather than redeclared (§1.5). `EffectiveCors` is consumed from `cpt-cf-oagw-feature-hierarchical-config`, which produces it; `ResolvedUpstream`, `MatchedRoute`, and `ProxyResponse` are consumed from `cpt-cf-oagw-feature-data-plane-proxy`, and `ErrorContext` from `cpt-cf-oagw-feature-gear-foundation`. + +**Data**: + +- None. DECOMPOSITION §2.7 declares no table for this feature, and it creates, reads, or writes none. A CORS decision is computed per request from the request and the resolved configuration and is persisted nowhere; the feature holds no counter, no registry, and no cache, and a restart changes nothing about any answer it gives (§1.5). + +**API**: + +- `OPTIONS /oagw/v1/proxy/{alias}[/{path_suffix}]` returning 204 with CORS preflight headers — the one API statement DECOMPOSITION §2.7 makes, which is the proxy path `cpt-cf-oagw-feature-data-plane-proxy` registers taken under the `OPTIONS` method and not a second registration of it. + +This feature invents no path, no method, no query parameter, and no response shape of its own. The handler registration is that feature's, and its Definition of Done already hands the `OPTIONS` preflight answer over: it "MUST leave the `OPTIONS` preflight answer to `cpt-cf-oagw-feature-cors`". An `OPTIONS` request that is not a preflight — one that carries no `Origin` or no `Access-Control-Request-Method` — is not answered by this feature at all and is judged by the matched route's method allowlist and by `cpt-cf-oagw-algo-inbound-validate` like any other proxy request (§2); the hand-back is unchanged, but its outcome is now named: under the shipped route schema's method enum, which admits no `OPTIONS` literal, an ordinary `OPTIONS` request matches no route and is answered 404 with the `RouteNotFound` variant, so the hand-back resolves to that answer rather than to a forwarded request. + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-oagw-actor-app-developer` | Runs the browser client that sends the preflight and the actual cross-origin request, and receives either the 204 preflight answer, the decorated upstream answer, or one of the two 403 answers. PRD §5.2 names this actor for the proxy requirements, and ADR 0004's decision drivers name the browser-based client this feature exists to serve. | +| `cpt-cf-oagw-actor-platform-operator` | Writes the `cors` block on an upstream or a route through the management API of `cpt-cf-oagw-feature-control-plane-config`, which validates it against the frozen schema before it is stored. The write is that feature's act; what this feature does with the stored object is §2's enforcement flow. | +| `cpt-cf-oagw-actor-upstream-service` | Receives the forwarded request only after the enforcement flow has admitted it, and produces the response the decoration is attached to. It is contacted by `cpt-cf-oagw-feature-data-plane-proxy` and never by this feature, and it is never asked about an origin or a method, because ADR 0004's rejected first option is the only design that would. | + +Two actors participate indirectly and are named here so their absence from the table is a record and not a gap: + +- `cpt-cf-oagw-actor-tenant-admin` configures the `cors` block of a descendant upstream or route in its own hierarchy. The four-permission table of DESIGN §3.2 names no permission for the CORS family, so the `cors.sharing` mode alone decides what that actor's row contributes, which `cpt-cf-oagw-feature-hierarchical-config` records in its own §1.5; the configuring is a write-time act this feature performs nothing of. +- `cpt-cf-oagw-actor-types-registry` and `cpt-cf-oagw-actor-cred-store` issue no call this feature answers. The types registry holds the catalog-only guard identifier that PRD §5.3 lists for CORS, which no plugin item can bind, and the credential material a forwarded request carries is resolved by `cpt-cf-oagw-feature-plugin-system` after the enforcement flow has already admitted the request. A preflight carries no credentials at all, so no credential store is ever consulted for one. + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) +- **Design**: [DESIGN.md](../DESIGN.md) +- **Dependencies**: `cpt-cf-oagw-feature-data-plane-proxy` — the proxy path this feature runs on, the handler registration that receives the preflight, the resolution and route matching that precede the enforcement, the `ResolvedUpstream`, `MatchedRoute`, and `ProxyResponse` types it consumes, the response the decoration is attached to, and the error-source classification that tags every answer it produces (DECOMPOSITION §3); and `cpt-cf-oagw-feature-hierarchical-config` — the `EffectiveCors` results and the per-family sharing modes the enforcement consumes, delivered by `cpt-cf-oagw-algo-field-family-merge` of that feature. + +Supporting sources this feature stays consistent with: + +- [ADR/0004-cors.md](../ADR/0004-cors.md) (`cpt-cf-oagw-adr-cors`) — the built-in handler over the two rejected options and the comparison table that states why, the configuration schema with its defaults and its three worked configurations, the preflight detection rule and the preflight header example, the numbered actual-request steps, the origin-matching section with its matched and rejected values, the security considerations including the credentials restriction and the `Vary` rule, the hierarchical merge example, and the two 403 error bodies with their GTS type identifiers. +- [ADR/0007-error-source-distinction.md](../ADR/0007-error-source-distinction.md) (`cpt-cf-oagw-adr-error-source-distinction`) — `X-OAGW-Error-Source: gateway` for both 403 answers this feature produces, and the `application/problem+json` body both carry. +- [schemas/upstream.v1.schema.json](../schemas/upstream.v1.schema.json) and [schemas/route.v1.schema.json](../schemas/route.v1.schema.json) — the frozen `definitions.cors` shape this feature enforces against: `sharing` defaulting to `private`, `enabled` required and defaulting to false, `allowed_origins` holding either `*` or a URI, `allowed_methods` drawn from the seven literals and defaulting to `GET` and `POST`, `expose_headers` defaulting to empty, `allow_credentials` defaulting to false, `additionalProperties: false`, and the conditional that refuses `allow_credentials: true` beside a `*` origin. Both files are frozen inputs this run does not edit, and the route schema declares no `cors` property at all (§1.5). +- [DESIGN.md](../DESIGN.md) §3.1 and §3.2 — the `+CorsConfig cors` member on both the `Upstream` and the `Route` class, the catalogue-only status of the `cors` guard identifier, the CORS merge row of the Hierarchical Configuration table, the two CORS rows of the Guard Rules table, and the CORS paragraph of the Security Considerations subsection. +- [config/e2e-local.yaml](../../../../../config/e2e-local.yaml) — the graded configuration. No upstream, route, or tenant declared in it carries a `cors` block, and the only CORS-adjacent key it contains is the `cors_enabled: false` of the `api-gateway` gear's own `config` block, which configures the inbound platform gateway and is not a member of the OAGW `cors` object at all. Every CORS configuration the graded gear enforces is therefore one written through the management API at run time, and with none written the gear enforces no CORS and decorates no response. + +**Run-level assumptions** — premises this feature relies on that come from the platform runtime rather than from PRD, DESIGN, the ADRs, or DECOMPOSITION. Each states what fails if the premise does not hold: + +- Assumption: the handler that receives a proxy request can classify a request as a preflight from its method and two headers alone, before the permission check of `cpt-cf-oagw-flow-proxy-request`'s step 4 and before that flow's resolution step. ADR 0004's preflight handling puts the detection at its own step 1 with no resolution behind it, and DESIGN §3.2 states the answer requires no tenant context. If the platform authenticates before the handler runs, the preflight **MUST** still be answered 204, because the browser that sends a preflight sends no credentials and a permission requirement would answer every preflight 401, which is the outcome DECOMPOSITION §2.7 excludes by putting preflight authentication out of scope (§1.5). +- Assumption: the platform delivers the `Origin` header value byte-exact, without case folding, without percent-decoding, and without adding or removing an explicit port. ADR 0004's origin matching is port-sensitive and protocol-sensitive — `:443` and `:8443` are different origins, and `http` and `https` of the same host are different origins — so a normalization the caller did not ask for would turn a disallowed origin into an allowed one or the reverse. This feature **MUST NOT** normalize, canonicalize, or default-port-reduce an `Origin` value, and where the platform hands it a value it cannot compare byte-exactly it **MUST** refuse rather than guess, because a guessed match is the bypass the ADR's "no regex patterns" rule exists to prevent. +- Assumption: the effective configuration the resolution produces carries the CORS family per layer as an `EffectiveCors` result with the sharing mode that produced it attached, which is what `cpt-cf-oagw-feature-hierarchical-config`'s Definition of Done promises every downstream consumer. If a layer result arrived without its sharing mode, this feature could not tell an ancestor's `inherit` union from a descendant's own list, and it **MUST** then take the routing target's own list rather than the ancestor's, because refusing to widen is the only direction a missing mode can be resolved in without inventing one. +- Assumption: the write-time validation of `cpt-cf-oagw-feature-control-plane-config` runs against the frozen schema before a `cors` object is stored, so a `cors` object carrying `allow_credentials: true` beside a `*` origin is refused when it is written and never served. If any path reached this feature without that validation, the enforcement-time consequence is the fail-closed one §1.5 records, and this feature **MUST** treat the origin set as empty rather than answer the request permissively, because a wildcard origin with credentials is the one configuration ADR 0004 names as unusable rather than merely discouraged. +- Assumption: the browser sends no credentials on a preflight and no `Origin` header on a request that is not cross-origin, both of which are the CORS protocol's own rules and the reason DESIGN §3.2 qualifies both CORS guard rows "actual cross-origin requests only". If a client sends `Origin` on a same-origin request, the request is treated as cross-origin and enforced, which is the fail-closed direction; if a client omits `Origin` on a genuine cross-origin request, the request is not enforced and the answer carries no CORS header, which is a consequence of the protocol's trigger and not a defect this feature can detect. + +### 1.5 Feature-Local Deviations from Shared Baselines + +| Deviation | Rationale | Review owner | Validation performed | +|-----------|-----------|--------------|----------------------| +| The two CORS rows of the DESIGN §3.2 Guard Rules table are implemented by this feature and not by `cpt-cf-oagw-algo-inbound-validate`, and both are evaluated against the merged effective list rather than against "the upstream's" list the two rows name. | `cpt-cf-oagw-feature-data-plane-proxy` records in its own §1.6 that both rows belong to `cpt-cf-oagw-feature-cors` and that its validation routine implements neither, and its closing note on that routine states the same. DECOMPOSITION §2.7 scopes the enforcement to "per upstream and per route through the `cors` field", and DESIGN §3.1 gives the `Route` class a `+CorsConfig cors` member for route-level overrides, so a list read only from the upstream would make the route override unenforceable and would contradict the merge row of §3.2. "The upstream's" in the guard table is shorthand for the upstream's effective configuration, which is what the resolution produces. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The enforcement flow is invoked from `cpt-cf-oagw-flow-proxy-request` of `cpt-cf-oagw-feature-data-plane-proxy` after that flow's resolution and route match have produced the effective configuration and the matched route, and before its rate-limit check and its composed chain, and that flow records no step for the invocation. | ADR 0004's numbered actual-request steps place the two checks between resolution at step 1 and forwarding at step 4, with no other step between them, and DECOMPOSITION §2.7 fixes the same window as "after upstream resolution" and "before forwarding". The earliest position inside that window is the one that costs a disallowed origin nothing, which is the deny-by-default economy the ADR's preflight paragraph states for the other half of the same protocol. The sibling is a frozen input this run does not edit, and the invocation is the same in-process seam that path already uses for the rate-limit check it does record, so the position is recorded here rather than added there. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The preflight answer is produced before the permission check of `cpt-cf-oagw-flow-proxy-request`'s step 4, and carries no authentication, no plugin execution, and no rate-limit charge, and remains subject to the platform's infrastructure-level controls. | ADR 0004's preflight optimization states "Skip per-request auth/plugin checks for preflight", and DECOMPOSITION §2.7 puts preflight authentication out of scope "which browsers do not send". The browser that sends a preflight sends no credentials, so a permission requirement would answer every preflight 401 and no browser client could use the gear at all, which is the outcome ADR 0004's first decision driver exists to prevent. The conflict is named rather than explained away: `cpt-cf-oagw-dod-proxy-endpoint` of `cpt-cf-oagw-feature-data-plane-proxy` orders its permission **MUST** unconditionally over "every method the handler accepts" and answers 401 for a missing or invalid token, and in the same breath hands the `OPTIONS` preflight answer to this feature, so answering a tokenless preflight 204 departs from that Definition of Done for the preflight branch rather than falling outside its scope. The departure is this run's resolution, justified by DECOMPOSITION §2.7 putting preflight authentication out of scope and by ADR 0004's "Skip per-request auth/plugin checks for preflight". The independence is from the per-request check alone and not from every limiting control: ADR 0004 states the qualification three times — in its Security defaults ("remain subject to infrastructure-level controls (global/edge rate limiting, WAF/DDoS protection)"), in its Consequences ("still pass through global/edge rate limiting and WAF/DDoS controls"), and in its preflight-optimization bullet ("global/edge rate limiting and WAF/DDoS controls still apply") — so what this feature bypasses is only the per-request check of `cpt-cf-oagw-feature-rate-limiting`. The answer discloses nothing but the permissiveness the ADR fixes, so answering it without a permission grants nothing. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The preflight answer is unconditional over the configuration and over the resolution: it is answered 204 for every request that passes the three-part detection, including one whose alias does not resolve, whose resolved upstream carries `cors.enabled: false`, or whose resolved configuration would refuse both the origin and the method. | ADR 0004's preflight handling states "no upstream resolution, no tenant context required", and a configuration-conditional preflight would need the resolution the answer is defined not to perform. A preflight that refused a disallowed origin would also disclose the configuration's shape to a caller who has not yet been checked, and would turn the 204 into a probeable oracle over `allowed_origins`, which is the leak the ADR's deny-by-default posture avoids. Origin and method validation is deferred to the actual request by the ADR's own sentence, and DESIGN §3.2's Security Considerations paragraph states the same split. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| DECOMPOSITION §2.7's five-member decoration list names five of the seven members the two sets carry and is not the header set of either answer: it omits `Access-Control-Allow-Headers` and `Access-Control-Allow-Credentials` and carries `Access-Control-Allow-Methods`, which only the preflight set contains. The two sets are split exactly as ADR 0004's two worked examples split them: the preflight carries `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods`, `Access-Control-Allow-Headers`, `Access-Control-Max-Age`, and the three-member `Vary`, and the gateway error-source tag of `cpt-cf-oagw-adr-error-source-distinction`; the actual request carries `Access-Control-Allow-Origin`, `Access-Control-Expose-Headers`, `Access-Control-Allow-Credentials` when credentials are allowed, and `Vary: Origin` alone. | ADR 0004's preflight example and its actual-request example are the only two header sets any supplied document spells out, and neither contains a member the other omits that the protocol would require. `Access-Control-Allow-Methods` answers a preflight's question about which methods may be sent and is not a response header a non-preflight request needs; DECOMPOSITION §2.7 lists the five members in one bullet because it states the decoration the feature performs as a whole, not the header set of one answer. Carrying all five on every answer would attach preflight-only headers to upstream responses and would change a surface `cpt-cf-oagw-feature-data-plane-proxy` owns. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The 204 preflight answer carries `X-OAGW-Error-Source: gateway` as a sixth header beyond the five CORS members of ADR 0004's preflight example. | ADR 0007's Confirmation requires the header on success responses too, and the 204 is a gateway-produced success answer, so the tag belongs on it; ADR 0004's preflight example enumerates the five CORS headers of that answer and is not an exhaustive response-header list. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| `Access-Control-Allow-Origin` carries the request's own `Origin` value in every allowed answer, including one admitted by a `*` entry, and never the literal `*`. | ADR 0004's one worked actual-request example shows the echoed origin, and it is the only actual-request header set any supplied document gives. Echoing is the form the credentials case requires, so one rule covers both the credentialed and the non-credentialed configuration instead of two, and a caller that is admitted always sees the origin it sent rather than a value it must interpret. The always-present `Vary: Origin` makes the echoed value cache-correct, which is the reason ADR 0004 gives for that header. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The preflight's `Access-Control-Max-Age` is the `86400` of ADR 0004's preflight example, carried as a build-time constant of this feature with no configuration surface and no sourced alternative. | ADR 0004's preflight example is the only place any supplied document states a value, and the shipped `definitions.cors` of both frozen schemas declares no `max_age` member and refuses one with `additionalProperties: false`, so `cpt-cf-oagw-feature-control-plane-config` would reject a configuration that tried to set it. DECOMPOSITION §2.7 names the header in its decoration bullet and states no value. What this document pins is that the header is present and finite; the number is the ADR's own example value recorded in the implementation as a build-time constant. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The set of request headers a preflight may ask about is not configurable: `Access-Control-Allow-Headers` is echoed from the request's own `Access-Control-Request-Headers` value, no allowlist of request headers exists anywhere in this feature, and no echoed value is emitted that the platform cannot form into a response header. | The shipped `definitions.cors` declares no `allowed_headers` member and ADR 0004's configuration schema declares none either, so there is no configuration to enforce against. The platform's own HTTP parsing rejects any header value carrying CR, LF, or NUL before a handler sees it, so the echo cannot carry a header-injection payload into the 204, and a value the response writer cannot form into a response header is omitted from the answer rather than emitted. ADR 0004's preflight example echoes the requested headers verbatim, which is what "permissive" means for that answer, and the actual request's headers are judged elsewhere — by `cpt-cf-oagw-algo-inbound-validate` against the matched route and by the header transformation `cpt-cf-oagw-fr-header-transform` assigns — so a permissive preflight does not admit a header the actual request will not carry. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| `Access-Control-Expose-Headers` carries the effective `expose_headers` list verbatim and is omitted when that list is empty, and the CORS-safelisted headers are never added to it. | ADR 0004's configuration schema describes the member as "Headers exposed to browser (beyond CORS-safelisted headers)", so the safelisted set is the browser's own default and naming it in the header would be a second statement of a rule the protocol already applies. The shipped schema defaults the member to an empty list, and a header that names nothing is worse than no header, because a client cannot distinguish an empty exposure from an unconfigured one. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The scope of DECOMPOSITION §2.7's "always-present `Vary: Origin`" is every response this feature decorates — the preflight answer, an admitted actual request, and both 403 answers — and not every response the proxy path produces. | ADR 0004's `Vary` paragraph states the rule inside its Security Considerations for CORS responses, and its two worked examples attach it to a preflight answer and to a decorated upstream answer. A request that carries no `Origin` header is not a cross-origin request, DESIGN §3.2 qualifies both CORS guard rows "actual cross-origin requests only", and an answer that varied by nothing has no cache-correctness reason to declare a variant. Adding the header to responses this feature does not decorate would be header work on a surface `cpt-cf-oagw-feature-data-plane-proxy` owns. A 403 is decorated, because a refusal that varies by origin is exactly the answer whose caching would poison a caller. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The CORS merge is consumed here as the per-layer `EffectiveCors` results the resolution produced, and this feature applies them at enforcement time; the chain walk, the sharing-mode decision, and the per-family merge table stay in `cpt-cf-oagw-feature-hierarchical-config`, whose Definition of Done forbids a downstream feature from re-walking the chain or re-applying a per-field strategy. | DESIGN §3.2's merge row states "Union origins if `inherit`; forced if `enforce`", and DECOMPOSITION §2.3 assigns the per-field merge table and the sharing modes to `cpt-cf-oagw-feature-hierarchical-config`, which applies the union across the ancestor chain and reports one result per layer. Re-applying the union here would walk the chain a second time for a value each layer result already carries, and under the per-member reading this feature applies no member of the effective configuration can be more permissive than the last layer result that declared it. This is the same consumption shape `cpt-cf-oagw-feature-rate-limiting` records for its own fold. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The enforcement-time fold is a per-member overlay across the two layer results and unions nothing: each member is taken from the last layer result that declares it, in the upstream, then route order of `cpt-cf-oagw-fr-config-layering`, an ancestor `enforce` is never bypassed, and a member no layer result declares takes the shipped schema's declared default. | The union under `inherit` and the forcing under `enforce` were applied across the ancestor chain by `cpt-cf-oagw-algo-field-family-merge` and are already inside each layer result, so the only work left for enforcement is to take each member from the last layer result that declares it. The overlay reading is never more permissive than a second union would be, because a descendant layer result is either its own object or the union its ancestor's mode produced, and never a superset of both, and no member of the effective configuration can be more permissive than the last layer result that declared it. The order is the one `cpt-cf-oagw-fr-config-layering` states and `cpt-cf-oagw-feature-data-plane-proxy` records as applied last and therefore prevailing. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| A configuration that is `enabled` with an absent or empty `allowed_origins` allows no origin and answers every actual cross-origin request with the origin 403, and is not an error and not a substitute for a disabled family. | The shipped `definitions.cors` requires `enabled` and gives `allowed_origins` neither a default nor a minimum length, so the configuration is legal and the empty allowlist is its meaning. ADR 0004's security posture is "Deny by default", and an enabled family that allowed no origin is the deterministic rendering of that posture rather than an intermittent or undefined one. `enabled: false` is the configuration an operator writes to switch the family off, and conflating the two would take the off switch away. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| A `cors` object that carries `allow_credentials: true` beside a `*` origin is refused at write time and, if it ever reaches this feature through a path that skipped that validation, is treated as allowing no origin rather than answered permissively. | The frozen schema's own conditional refuses the combination, ADR 0004's Confirmation item names the rejection as happening "at validation time", and its security considerations state the rule as a configuration error rather than a request outcome. DECOMPOSITION §2.7 carries the bullet into this feature's scope, and what remains for this feature to own is the enforcement-time consequence: failing closed on every request is the only reading that cannot serve a wildcard-credentialed configuration, and a per-request 500 would turn a configuration defect into an availability one. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The two 403 answers are produced as bare 403 problem answers carrying the GTS `type` identifier ADR 0004 spells for each, and are not `DomainError` variants of the foundation catalogue. | DESIGN §3.3's error catalogue has no 403 row — its client-error rows are 400, 401, 404, and the single 409 `PluginInUse` — and DECOMPOSITION §1.3(9) extends that catalogue by exactly two variants, both 409 management-conflict answers, so the catalogue is closed at 22 variants over 21 identifiers and this feature must not add to it. `cpt-cf-oagw-feature-hierarchical-config` records the same resolution for the 403 its permission check produces. The difference here is that ADR 0004 spells a `type` for each of the two answers, so both carry one rather than being bare of a type, and both are serialized through the foundation's single RFC 9457 problem-body path with `X-OAGW-Error-Source: gateway`, which `cpt-cf-oagw-dod-error-catalogue` requires of every gateway error answered anywhere in the gear. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The origin check precedes the method check, and a refusal for a disallowed origin names no allowed origin and no allowed method beyond the values the request itself carried. | ADR 0004's numbered actual-request steps order the origin check at step 2 and the method check at step 3. A caller whose origin is not allowed has no business learning which methods the configuration admits, and a `detail` that enumerated the allowed set would publish the configuration to the one caller it is written to exclude. The two `detail` strings ADR 0004 gives name the offending value and nothing else, which is the shape this feature reproduces. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| `CorsConfig` is consumed and not redeclared, and the ownership of the CORS types is split and named here. | `cpt-cf-oagw-feature-gear-foundation` declares `CorsConfig` with the other sub-configuration types as the gear's shared vocabulary (DECOMPOSITION §2.1) and lists it among the entities its own Definition of Done fixes; `cpt-cf-oagw-feature-hierarchical-config` declares `EffectiveCors` as its merge result and states in its own §1.6 that the preflight answer, the origin check, and the 403 answer belong to this feature; and this feature declares the two enforcement types listed in §1.2. DECOMPOSITION §2.7 lists `CorsConfig` under this entry because the enforcement semantics of its members are this feature's, not because the type is declared twice, and its parenthetical names the six members the frozen schema declares, `sharing` included. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| Route-level `cors` is validated and enforced against the upstream CORS shape although the shipped `schemas/route.v1.schema.json` declares no `cors` property, and the route's `cors` object reaches this feature only through the write-time validation that admitted it. | DECOMPOSITION §1.3(5) records exactly this divergence and resolves it by validating a route-level `cors` object with the same shape as the upstream CORS configuration, and DESIGN §3.1 declares the `+CorsConfig cors` member on the `Route` class. The schema is a frozen input this run does not edit, so the property is carried by the baseline's override rather than by a schema revision. `cpt-cf-oagw-feature-hierarchical-config` records the same property set for the route rows it walks. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| This feature's §1.2 carries `cpt-cf-oagw-principle-error-source`, `cpt-cf-oagw-adr-error-source-distinction`, and `cpt-cf-oagw-constraint-toolkit-deploy` beyond the entries DECOMPOSITION §2.7 records, all three of which this feature implements and cites in §1.4. | The two added elements are load-bearing here: every answer this feature produces carries `X-OAGW-Error-Source: gateway`, and both 403 bodies are written on the error-source distinction ADR 0007 defines. The added constraint is the single-executable deployment that makes the handler-level preflight fast path and the in-process invocation seam the only mechanisms this feature has, and it is the same constraint the sibling policy tails list. Every sibling feature document mirrors its baseline list except where it records the superset, so the additions are recorded rather than silently carried. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| This document's status identifier carries the `-implemented` suffix, reading `cpt-cf-oagw-featstatus-cors-implemented` where the FEATURE template fixes the same identifier without that suffix, and its backreference to the DECOMPOSITION entry is left unchecked where that template fixes a checked one. | All seven FEATURE documents this run authored carry the same two forms, so the departure is a run-wide convention and not a defect of this document alone: the suffix names the status value the identifier reports rather than a second identifier, and the backreference is a traceability pointer whose state the implementation phase owns. The departure is therefore a stated convention rather than a silent one. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| CORS state is held nowhere: no counter, no registry, no cache, and no persistence, and a restart changes no answer this feature produces. | DECOMPOSITION §2.7 declares no table for this feature and names no state, and ADR 0004's decision outcome names preflight speed and upstream independence as the two gains of the built-in handler, both of which are properties of a stateless answer. The only state on the proxy path belongs to the features that own it: the L1 caches, the per-instance rate-limit registries, and the outbound client. A per-request decision that depended on remembered state would also be a decision a configuration write could not change immediately, which the resolution's own invalidation contract forbids. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| This feature's tests are colocated at `gears/system/oagw/oagw/tests/` instead of `testing/e2e/gears/oagw/`. | DECOMPOSITION §1.3(3) reserves `testing/e2e/gears/oagw/` for the acceptance suite; every unit and integration test this decomposition produces lives with the crate. This is the same deviation `cpt-cf-oagw-feature-gear-foundation`, `cpt-cf-oagw-feature-control-plane-config`, `cpt-cf-oagw-feature-hierarchical-config`, `cpt-cf-oagw-feature-data-plane-proxy`, `cpt-cf-oagw-feature-plugin-system`, and `cpt-cf-oagw-feature-rate-limiting` record in their own §1.5 tables, restated here because the tests it governs include this feature's. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | + +### 1.6 Explicit Non-Applicability + +The areas below apply to the gear as a whole but not to this feature. Each is stated here so the omission is a recorded decision rather than a silent gap, and each names the feature that does own it. + +- **The hierarchy walk, alias shadowing, the sharing-mode decision, and the per-family merge table.** DECOMPOSITION §2.3 places all four in `cpt-cf-oagw-feature-hierarchical-config`, and this feature consumes their result through the per-layer `EffectiveCors` values. `cpt-cf-oagw-algo-tenant-chain-walk`, `cpt-cf-oagw-algo-alias-shadow-resolve`, `cpt-cf-oagw-algo-sharing-mode-decision`, and `cpt-cf-oagw-algo-field-family-merge` are that feature's routines; §3 of this document selects among the layer results those routines produced and restates no merge row. +- **The `cors` configuration schema and its write-time validation.** `cpt-cf-oagw-feature-control-plane-config` validates the `cors` sub-object of the upstream schema and of the route shape DECOMPOSITION §1.3(5) fixes, including the conditional that refuses `allow_credentials` beside a wildcard origin, and this feature enforces against a configuration that already passed that validation. No routine of §3 runs at write time. +- **The proxy path itself: resolution, matching, endpoint selection, inbound and body validation, header transformation, forwarding, and error-source classification.** `cpt-cf-oagw-feature-data-plane-proxy` owns all of them. The one exception is the response this feature answers itself — the 204 preflight answer, which carries the `X-OAGW-Error-Source: gateway` tag §1.5 records — and beyond that single answer this feature produces neither the `ProxyResponse` nor the tag the proxy path's own answers carry: it produces the decision and the decoration the response carries, and the assembly and the classification are that feature's. +- **The rate-limit check, the over-limit strategies, and the circuit breaker.** `cpt-cf-oagw-feature-rate-limiting` owns all three, and the preflight this feature answers is never charged and never counted: it reaches no counter because it reaches no resolution, which is the same independence that feature records in its own §1.6. That sentence names the per-request counter of `cpt-cf-oagw-feature-rate-limiting` alone: the platform's global and edge rate limiting and its WAF/DDoS controls still apply to a preflight, as ADR 0004 states, and the independence this feature claims is only from the per-request check. An actual cross-origin request is charged once at that feature's check, and the CORS answer this feature produces consumes no additional allowance and refunds none. +- **The plugin contracts, the registries, the chain composition, and credential resolution.** `cpt-cf-oagw-feature-plugin-system` owns them, and no plugin item can select CORS, because the `gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.cors.v1` identifier DESIGN §3.1 and PRD §5.3 both name is catalog-only and cannot be bound through `plugins.items[].plugin_ref`. ADR 0004's rejected second option is therefore not merely declined here but unreachable, and the enforcement flow runs ahead of the chain that would have composed it. +- **Request-header filtering, body inspection, and content-type negotiation.** No member of the shipped `definitions.cors` names a request header, a body, or a content type, and `Access-Control-Allow-Headers` is echoed rather than judged (§1.5). The actual request's headers are judged by `cpt-cf-oagw-algo-inbound-validate` against the matched route and transformed by the header operations `cpt-cf-oagw-fr-header-transform` assigns to `cpt-cf-oagw-feature-data-plane-proxy`, and this feature adds no header allowlist of its own. +- **gRPC proxying and WebTransport.** Both are out of scope per DECOMPOSITION §1.3(4), and `cpt-cf-oagw-feature-data-plane-proxy` records that a gRPC upstream produces no matching route and a `wt` upstream is refused at dial time. A request that never resolves to a forwardable target never reaches the enforcement flow, and the preflight answer is the same 204 for such an alias as for any other, because it reads nothing that could distinguish them. +- **Stream lifecycles.** `cpt-cf-oagw-feature-streaming` owns them. This feature decorates the HTTP answer the proxy path produces and holds no state for a stream that follows it, and the decoration it computed at enforcement time is not recomputed when the transfer mode changes. +- **Latency targets.** The proxy path's budget is `cpt-cf-oagw-nfr-low-latency`'s, and `cpt-cf-oagw-feature-rate-limiting` carries the Definition of Done that consumes it for the check ahead of this one; this feature states no target of its own, and the one cost it adds to the path is the linear scan the decision makes over the effective `allowed_origins`, so the omission is recorded here rather than left silent. +- **Metrics emission and audit log formatting.** `cpt-cf-oagw-feature-observability` owns the Prometheus surface and the structured record. This feature emits no series and writes no audit line of its own; what it supplies is the decision and the refusal those surfaces would report, recorded in the request's execution context. The correlation identifier the platform middleware assigns, the log surfaces, and the trace surfaces are `cpt-cf-oagw-feature-observability`'s and `cpt-cf-oagw-feature-gear-foundation`'s as well, and this feature contributes only that outcome to them, opening no span, assigning no correlation identifier, and writing no log line of its own. +- **Health and diagnostics.** The gear's health surface is `cpt-cf-oagw-feature-gear-foundation`'s and this feature contributes no check of its own, because it holds no state and depends on nothing whose failure it could report. +- **Persistence.** DECOMPOSITION §2.7 declares no table for this feature, and `cpt-cf-oagw-db-schema` is fully claimed by `cpt-cf-oagw-feature-control-plane-config` and `cpt-cf-oagw-feature-plugin-system`. Nothing this feature computes outlives the request that produced it. +- **Rollout, rollback, versioning, localization, accessibility, and compliance.** The gear is one configuration item and one release unit (DECOMPOSITION §1.4), so this feature ships no rollout of its own. Every identifier it reads and every `type` it writes is fixed at `.v1`. The 204 and the two 403 bodies are English protocol strings, the header names are protocol values an accessibility requirement does not reach, and no credential material, no request body, and no caller identifier other than the `Origin` value the request itself carried enters a decision or a problem `detail`. No data-protection obligation of consent, subject rights, cross-border transfer, or anonymization attaches here either, because none of them reaches a per-request decision whose only personal-adjacent input is the `Origin` value the request itself carried and which persists nothing. +- **Workarounds, deprecation, and migration.** None applies: the two limitations §1.5 records — the non-configurable request-header set and the build-time `Access-Control-Max-Age` — have no workaround short of a schema revision, which is a frozen input this run does not edit, and every identifier this feature reads and every `type` it writes is fixed at `.v1` with no predecessor to migrate from. + +## 2. Actor Flows (CDSL) + +The two flows below run on the proxy path `cpt-cf-oagw-flow-proxy-request` of `cpt-cf-oagw-feature-data-plane-proxy` implements. The first is reached at handler level, before that flow's permission check and before its resolution step, at the position ADR 0004's preflight handling fixes. The second is reached after that flow's resolution and route match have produced the effective configuration and the matched route, and before its rate-limit check and its composed chain, at the position §1.5 records. Neither registers a path; each is reached through the proxy handler `cpt-cf-oagw-feature-data-plane-proxy` registered, whose Definition of Done hands the `OPTIONS` preflight answer to this feature. + +**Use cases**: `cpt-cf-oagw-usecase-proxy-request` + +`cpt-cf-oagw-usecase-proxy-request` is `cpt-cf-oagw-feature-data-plane-proxy`'s and is not restated here; this feature is reached from it and adds no second statement of it. DECOMPOSITION §2.7 names no use case of its own. + +```mermaid +sequenceDiagram + participant B as Browser + participant API as API Handler + participant DP as Data Plane + participant C as CORS + participant US as Upstream Service + + B->>API: OPTIONS /oagw/v1/proxy/{alias}/{path_suffix} + API->>C: preflight detected (OPTIONS + Origin + Access-Control-Request-Method) + C-->>B: 204 with echoed origin, method, headers, and Vary + B->>API: {METHOD} /oagw/v1/proxy/{alias}/{path_suffix} + API->>DP: execute_proxy(alias, path_suffix, query, req) + DP->>DP: resolve, match + DP->>C: decide(origin, method, per-layer EffectiveCors) + alt origin and method allowed + C-->>DP: allow with the decoration to attach + DP->>US: outbound request + US-->>DP: response + DP-->>API: ProxyResponse carrying the CORS decoration + else origin not allowed + C-->>DP: 403 cors.origin_not_allowed + else method not allowed + C-->>DP: 403 cors.method_not_allowed + end + DP-->>API: ProxyResponse with X-OAGW-Error-Source + API-->>B: HTTP response +``` + +### Answer a Preflight Request at Handler Level + +- [x] `p1` - **ID**: `cpt-cf-oagw-flow-cors-preflight` + +**Actor**: `cpt-cf-oagw-actor-app-developer` + +This flow is invoked once per request the proxy handler receives, at handler level and before the permission check and the resolution step of `cpt-cf-oagw-flow-proxy-request`, at the position ADR 0004's preflight handling fixes and §1.5 records. It produces exactly one answer, 204, and never forwards anything; it reads no configuration, and resolving nothing is not an optimization it applies but the definition of the answer. + +**Success Scenarios**: + +- A request whose method is `OPTIONS` and which carries both `Origin` and `Access-Control-Request-Method` is answered 204 with the echoed origin, the echoed method, the echoed requested headers when any are named, the constant max age, and the three-member `Vary`, regardless of which alias the path names. +- The answer is produced with no upstream resolution, no tenant context, no route match, no endpoint selection, no plugin execution, no rate-limit charge, and no permission check (§1.5). +- The same preflight answered twice for the same origin, method, and requested headers produces the same header set, so a browser cache may hold the answer for the `Access-Control-Max-Age` the answer names. +- A preflight for an alias that would not resolve, for an upstream whose CORS family is disabled, or whose effective configuration would refuse both the origin and the method is answered the same 204, because the answer reads none of those things (§1.5). + +**Error Scenarios**: + +- A request that is not a preflight — an `OPTIONS` request with no `Origin` header or no `Access-Control-Request-Method` header, or a request whose method is not `OPTIONS` — is answered by nothing in this flow: it is handed back to the proxy path to be resolved, matched, authenticated, validated, and charged like any other request, and the route's method allowlist and `cpt-cf-oagw-algo-inbound-validate` judge it. The hand-back is unchanged and its outcome is now named: under the shipped route schema's method enum an ordinary `OPTIONS` request matches no route and is answered 404 with the `RouteNotFound` variant, so the hand-back resolves to that answer rather than to a forwarded request. +- The platform middleware authenticates before the handler runs and the bearer token is missing or invalid: the preflight is still answered 204, because the browser sends no credentials and a permission requirement would answer every preflight 401 (§1.4, §1.5). +- The `Access-Control-Request-Method` names a method the effective configuration would refuse: still 204, because ADR 0004 defers origin and method validation to the actual request and a preflight that refused would disclose the configuration's shape. + +**Steps**: + +1. [x] - `p1` - Receive the request the proxy handler holds, carrying the request method, the `Origin` header, and the `Access-Control-Request-Method` and `Access-Control-Request-Headers` headers, before the permission check and the resolution step of `cpt-cf-oagw-flow-proxy-request` run - `inst-cpf-receive` +2. [x] - `p1` - **IF** the method is `OPTIONS`, the `Origin` header is present, and the `Access-Control-Request-Method` header is present, which is the three-part detection ADR 0004 states - `inst-cpf-preflight-if` + 1. [x] - `p1` - `cpt-cf-oagw-algo-cors-preflight-headers` builds the header set from the request's own three header values and from the constant max age, reading no configuration and resolving no upstream - `inst-cpf-headers` + 2. [x] - `p1` - **RETURN** 204 with that header set and no body, produced with no upstream resolution, no tenant context, no route match, no endpoint selection, no plugin execution, no rate-limit charge, and no permission check, so the answer discloses nothing but the permissiveness ADR 0004 fixes (§1.5) - `inst-cpf-return` +3. [x] - `p1` - **ELSE** - `inst-cpf-preflight-else` + 1. [x] - `p1` - **RETURN** nothing, and hand the request back to the proxy path to be resolved, matched, authenticated, validated, and charged like any other request, because a request that fails the three-part test is an ordinary proxy request and not a CORS preflight - `inst-cpf-else-return` + +### Enforce CORS on an Actual Cross-Origin Request + +- [x] `p1` - **ID**: `cpt-cf-oagw-flow-cors-enforce` + +**Actor**: `cpt-cf-oagw-actor-app-developer` + +This flow is invoked once per actual proxy request that carries an `Origin` header, by `cpt-cf-oagw-flow-proxy-request` of `cpt-cf-oagw-feature-data-plane-proxy`, after that flow's resolution and route match have produced the effective configuration and the matched route, and before its rate-limit check and its composed chain at the position §1.5 records. It answers with an admission carrying the decoration, or with one of two 403 answers; it never answers with a passthrough of its own, because the answer it admits is the upstream's. + +**Success Scenarios**: + +- An actual cross-origin request whose `Origin` is named by the effective `allowed_origins` and whose method is named by the effective `allowed_methods` is admitted with the decoration `cpt-cf-oagw-algo-cors-decide` computed, and the proxy path forwards it. +- A request that carries no `Origin` header is admitted by nothing here: this flow is not invoked for it, and the forwarded answer carries no CORS header of any kind, because DESIGN §3.2 qualifies both CORS guard rows "actual cross-origin requests only". +- A `*` entry in the effective `allowed_origins` admits every origin, including one the configuration never named. +- An origin that differs from an allowed one only in its port or in its scheme is refused, which is the exact matching ADR 0004's Origin Matching section demonstrates against `https://app.example.com:8080` and `http://app.example.com`. +- An effective configuration produced under an ancestor `inherit` admits both the ancestor's and the descendant's origins, and one produced under an ancestor `enforce` admits the ancestor's alone, which is the merge ADR 0004's hierarchical example works through. + +**Error Scenarios**: + +- The `Origin` is not named by the effective `allowed_origins`: 403 with `gts.cf.core.errors.err.v1~cf.oagw.cors.origin_not_allowed.v1`, the title ADR 0004 gives that type, `Vary: Origin`, and `X-OAGW-Error-Source: gateway`, answered after resolution and before anything is forwarded, so nothing reaches the upstream. +- The method is not named by the effective `allowed_methods`: 403 with `gts.cf.core.errors.err.v1~cf.oagw.cors.method_not_allowed.v1`, the title ADR 0004 gives that type, the same tag, and the same position in the path. +- The origin check fails and the method check would also fail: the origin answer is produced, because the origin check precedes the method check and a disallowed caller learns nothing about the method list it did not already send (§1.5). +- The effective CORS family is absent at every layer, or its prevailing `enabled` is false: no enforcement and no decoration, and the request proceeds as if the family were not configured, which is the off switch the shipped `enabled` member provides (§1.5). +- The effective configuration carries `allow_credentials: true` beside a `*` origin: every actual cross-origin request for it is answered with the origin 403, because the origin set is treated as empty rather than served permissively (§1.5). +- The resolution fails, no route matches, the resolved upstream is disabled, or endpoint selection fails: this flow is not invoked, because the effective configuration it reads does not exist, and the proxy path answers 404 with the `RouteNotFound` variant, 503 with the `LinkUnavailable` variant, 400 with the target-host variant `cpt-cf-oagw-algo-endpoint-select` names, or the platform 500 problem shape, as its own steps record. + +**Steps**: + +1. [x] - `p1` - Receive the check request from the proxy path carrying the request method, the `Origin` header, the upstream-layer and route-layer `EffectiveCors` results of `cpt-cf-oagw-feature-hierarchical-config` with the per-family sharing modes the resolution attached, and the identity of the routing target the resolution selected - `inst-cfe-receive` +2. [x] - `p1` - **IF** the `Origin` header is absent - `inst-cfe-no-origin-if` + 1. [x] - `p1` - **RETURN** the not-cross-origin outcome with no decoration and no CORS header of any kind, and let the proxy path forward the request, because DESIGN §3.2 qualifies both CORS guard rows "actual cross-origin requests only" and a request with no origin is not one of them - `inst-cfe-no-origin` +3. [x] - `p1` - **ELSE** - `inst-cfe-no-origin-else` + 1. [x] - `p1` - `cpt-cf-oagw-algo-cors-fold` applies its per-member overlay across the two layer results and produces one effective configuration carrying `enabled`, `allowed_origins`, `allowed_methods`, `expose_headers`, and `allow_credentials`, with the shipped defaults for a member no layer result declares (§1.5) - `inst-cfe-fold` + 2. [x] - `p1` - `cpt-cf-oagw-algo-cors-decide` evaluates that configuration against the request's `Origin` and its method - `inst-cfe-decide` + 3. [x] - `p1` - **IF** the decision allows - `inst-cfe-allow-if` + 1. [x] - `p1` - **RETURN** the admission with the decoration the decision computed, carried on the response the proxy path assembles, and let the proxy path forward the request - `inst-cfe-allow` + 4. [x] - `p1` - **ELSE** - `inst-cfe-allow-else` + 1. [x] - `p1` - **RETURN** 403 with the problem body the decision names — `gts.cf.core.errors.err.v1~cf.oagw.cors.origin_not_allowed.v1` for a disallowed origin and `gts.cf.core.errors.err.v1~cf.oagw.cors.method_not_allowed.v1` for a disallowed method — carrying `Vary: Origin` and `X-OAGW-Error-Source: gateway`, answered before anything is forwarded (§1.5) - `inst-cfe-refuse` +4. [x] - `p1` - **RETURN** the admission, the decoration, or the 403, and record the outcome for `cpt-cf-oagw-feature-observability` to report without emitting a metric of its own — the outcome being the admission verdict, the origin refusal, or the method refusal, recorded in the request's execution context, which is the record that feature reads; this feature registers no sink and emits no metric of its own - `inst-cfe-return` + +## 3. Processes / Business Logic (CDSL) + +The routines below are called by the two flows in §2. `cpt-cf-oagw-algo-cors-preflight-headers` runs at handler level before the proxy path resolves anything, and the other two run on that path after resolution and before forwarding. None of them writes to storage, holds state between requests, or reaches a network. The two 403 answers are the one answer class in this feature that leaves the process, and they are produced as bare 403 problem answers carrying the GTS `type` ADR 0004 spells and not as `DomainError` variants (§1.5), serialized through the foundation's single RFC 9457 problem-body path with `X-OAGW-Error-Source: gateway`. The decoration an allowed decision computes is carried to the response the proxy path assembles and is not emitted by any routine here. + +### Fold the Effective CORS Configuration + +- [x] `p1` - **ID**: `cpt-cf-oagw-algo-cors-fold` + +**Input**: the upstream-layer and route-layer `EffectiveCors` results of `cpt-cf-oagw-feature-hierarchical-config`, each already carrying the tenant chain's contribution and the sharing mode that produced it, and the identity of the routing target the resolution selected. + +**Output**: one effective CORS configuration carrying `enabled`, `allowed_origins`, `allowed_methods`, `expose_headers`, and `allow_credentials`; or the outcome that the family is absent at every layer or disabled at the prevailing one. + +This routine is the enforcement-time application of the CORS merge row of DESIGN §3.2 Hierarchical Configuration. It applies an overlay and nothing else: the union under `inherit`, the forcing under `enforce`, and the withholding under `private` were applied across the ancestor chain by `cpt-cf-oagw-algo-field-family-merge`, which reported one result per layer, and re-applying them here would walk the chain a second time for a value each layer result already carries (§1.5). + +**Steps**: + +1. [x] - `p1` - Consume the two layer results in the upstream, then route order of `cpt-cf-oagw-fr-config-layering`, so the last layer result that declares a member prevails, which is the order `cpt-cf-oagw-feature-data-plane-proxy` records as applied last and therefore prevailing and `cpt-cf-oagw-feature-rate-limiting` phrases as the last layer that declares them - `inst-cf-prevail` +2. [x] - `p1` - **IF** a layer result reports an ancestor `enforce` for the CORS family - `inst-cf-enforce-if` + 1. [x] - `p1` - Take that layer's whole result unchanged, including its `enabled`, so no descendant override can widen what the ancestor forced - `inst-cf-enforce` +3. [x] - `p1` - **ELSE** - `inst-cf-enforce-else` + 1. [x] - `p1` - Take each member from the last layer result that declares it, the origins already unioned where an ancestor marked the family `inherit` and the ancestor's value already withheld where it marked it `private` - `inst-cf-inherit` +4. [x] - `p1` - Apply the shipped schema's declared default for a member neither layer result declares: `allowed_methods` of `GET` and `POST`, an empty `expose_headers`, `allow_credentials` of false, and a `sharing` of `private`; apply no default to `allowed_origins`, because an absent or empty `allowed_origins` allows no origin rather than every one (§1.5); and apply the `enabled` default of false knowing it is unreachable here, because `enabled` is a required member the write-time validation of `cpt-cf-oagw-feature-control-plane-config` always stores (§1.5) - `inst-cf-defaults` +5. [x] - `p1` - **IF** no layer carries a `cors` object, or the prevailing `enabled` is false - `inst-cf-none-if` + 1. [x] - `p1` - **RETURN** the absent-family outcome, and let `cpt-cf-oagw-flow-cors-enforce` enforce and decorate nothing - `inst-cf-none` +6. [x] - `p1` - **RETURN** the effective configuration - `inst-cf-return` + +**Error handling**: a method outside the seven literals the shipped schema enumerates cannot occur, because `cpt-cf-oagw-feature-control-plane-config` rejected it at write time; a `sharing` value outside its three-value enum cannot occur for the same reason. A layer result that arrives without the sharing mode that produced it is a defect in the resolution, and this routine **MUST** take the routing target's own list rather than the ancestor's, because refusing to widen is the only direction a missing mode can be resolved in without inventing one (§1.4). A configuration that carries `allow_credentials: true` beside a `*` origin is not corrected here; it is returned unchanged, and the decision routine fails it closed. + +### Decide an Actual Cross-Origin Request + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-cors-decide` + +**Input**: the effective configuration `cpt-cf-oagw-algo-cors-fold` produced, the request's method, and the request's `Origin` header value taken byte-exact as the platform delivered it. + +**Output**: a `CorsDecision` carrying the verdict, the reason when the verdict is a refusal, and the response decoration to attach when it is an admission. + +`CorsDecision` is declared here and DECOMPOSITION §2.7 lists it under this entry. The origin comparison is the exact matching ADR 0004's Origin Matching section demonstrates: the whole value against a configured entry, with scheme and port significant, and the `*` entry as the one value that matches more than itself. + +**Steps**: + +1. [x] - `p1` - **IF** `allow_credentials` is true and `allowed_origins` contains `*` - `inst-cd-credwild-if` + 1. [x] - `p1` - Treat the origin set as empty and refuse every origin with the origin reason, so a configuration the write-time validation refused can never be served permissively if a path reaches this routine without that validation (§1.5) - `inst-cd-credwild` +2. [x] - `p1` - Compare the request's `Origin` against `allowed_origins` under the exact matching ADR 0004 states: the whole value must equal an entry, or an entry must be `*`; the scheme and the port are significant, no pattern, no suffix, no suffix-of, and no case-folding comparison is performed, and no trailing slash is stripped - `inst-cd-origin` +3. [x] - `p1` - **IF** the origin does not match - `inst-cd-origin-if` + 1. [x] - `p1` - **RETURN** a refusal with the reason `origin`, whose problem `detail` names the origin the request carried and no allowed value, so a disallowed caller learns nothing about the list that refused it (§1.5) - `inst-cd-origin-refuse` +4. [x] - `p1` - Compare the request's method against `allowed_methods` as an exact member test against the literals the shipped schema enumerates, answered only after the origin comparison has passed - `inst-cd-method` +5. [x] - `p1` - **IF** the method does not match - `inst-cd-method-if` + 1. [x] - `p1` - **RETURN** a refusal with the reason `method`, whose problem `detail` names the method the request carried and no allowed value - `inst-cd-method-refuse` +6. [x] - `p1` - **RETURN** the admission with the decoration: `Access-Control-Allow-Origin` carrying the request's own `Origin` value (§1.5), `Access-Control-Allow-Credentials` present exactly when `allow_credentials` is true and absent when it is false, `Access-Control-Expose-Headers` carrying the effective `expose_headers` and omitted when that list is empty, and `Vary: Origin` - `inst-cd-allow` + +**Error handling**: an `Origin` value that is not a URI cannot be admitted by anything but a `*` entry, and the comparison above is the only test it receives; this routine neither parses nor repairs it, because a repaired origin is an origin the caller did not send. A method that is not one of the seven literals is refused by the member test, which is the same answer a method outside the effective list receives. The routine reads no state and holds none, so it has no failure mode of its own; the one failure it can report is the refusal it is asked for. + +### Build the Preflight Header Set + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-cors-preflight-headers` + +**Input**: the request's `Origin`, `Access-Control-Request-Method`, and `Access-Control-Request-Headers` header values, and the constant max age §1.5 records. + +**Output**: the 204 preflight status and its header set. + +The routine reads no configuration and resolves no upstream, which is what makes the answer usable when the upstream is unreachable — the second of ADR 0004's two stated gains for the built-in handler over proxying the protocol. + +**Steps**: + +1. [x] - `p1` - Set `Access-Control-Allow-Origin` to the request's own `Origin` value, byte-exact - `inst-cph-origin` +2. [x] - `p1` - Set `Access-Control-Allow-Methods` to the request's own `Access-Control-Request-Method` value, byte-exact - `inst-cph-methods` +3. [x] - `p1` - **IF** the request carries an `Access-Control-Request-Headers` value - `inst-cph-headers-if` + 1. [x] - `p1` - Set `Access-Control-Allow-Headers` to that value verbatim, with no allowlist applied and no name reordered (§1.5) - `inst-cph-headers` +4. [x] - `p1` - **ELSE** - `inst-cph-headers-else` + 1. [x] - `p1` - Omit `Access-Control-Allow-Headers`, because a preflight that names no request header asks about none - `inst-cph-no-headers` +5. [x] - `p1` - Set `Access-Control-Max-Age` to the constant max age, which is the value ADR 0004's preflight example states (§1.5) - `inst-cph-max-age` +6. [x] - `p1` - Set `Vary` to `Origin, Access-Control-Request-Method, Access-Control-Request-Headers`, the three-member value ADR 0004's preflight example shows, so a cache cannot serve one preflight's answer to a request that asked about a different origin, method, or header set - `inst-cph-vary` +7. [x] - `p1` - **RETURN** the 204 status with that header set, the `X-OAGW-Error-Source: gateway` tag §1.5 records, and no body, and no `Access-Control-Allow-Credentials`, whose only appearance in ADR 0004 is on an actual-request response (§1.5) - `inst-cph-return` + +**Error handling**: the routine reads no configuration and no state, so it has no failure mode of its own and cannot fail on one. A header value the platform delivers is echoed byte-exact and never re-encoded, so the answer cannot differ from what the browser asked about. The platform's own HTTP parsing rejects any header value carrying CR, LF, or NUL before a handler sees it, so an echoed value cannot carry a header-injection payload into the 204; and a value the response writer cannot form into a response header — one exceeding the platform's header-value limits — is omitted from the answer rather than failing the permissive 204, with the omission recorded in the request's execution context. A value that arrives empty is echoed empty rather than rejected, because the enforcement this answer defers is the actual request's and a 400 here would be a second validation path on a request this feature is defined to answer permissively. + +## 4. States (CDSL) + +No state machine is defined in this feature. + +This feature is a per-request decision: it reads a request and a resolved configuration, and nothing it touches changes state as a result of running. It holds no counter, no registry, no cache, and no persisted row (§1.5), and DECOMPOSITION §2.7 declares no table for it, so there is no lifecycle to describe and no state that could be observed between two requests. The two states the CORS configuration itself has — the `enabled` and disabled values of the family, and the `Linked`, `Unlinked`, and `Deleted` lifecycle of the plugin rows it deliberately does not use — are already owned elsewhere: the `enabled` flag is a stored member of the `cors` object that `cpt-cf-oagw-feature-control-plane-config` writes and `cpt-cf-oagw-feature-hierarchical-config` merges, and the plugin lifecycle machine `cpt-cf-oagw-state-plugin-lifecycle` describes rows this feature cannot reach, because the catalog-only CORS guard identifier is not bindable through `plugins.items[].plugin_ref`. Declaring a machine over a flag another feature stores would give one state two owners and would leave the stored value and the effective value described by two documents that can drift apart, so the machine stays where the stored flag lives. + +The template marks this section optional ("include when entities have explicit lifecycle states"), and the kit's constraint set does not require it; the section is kept, with this reason, so the omission is a recorded decision rather than a gap in the numbering. + +## 5. Definitions of Done + +### Preflight Answer at Handler Level + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-cors-preflight` + +The system **MUST** answer every request that passes the three-part detection — method `OPTIONS`, an `Origin` header, and an `Access-Control-Request-Method` header — with 204 and the header set `cpt-cf-oagw-algo-cors-preflight-headers` builds, at handler level and before the permission check and the resolution step of `cpt-cf-oagw-flow-proxy-request` (§1.5). It **MUST** resolve no upstream, match no route, read no tenant context, execute no plugin, charge no rate-limit counter, and read no configuration on that path, and **MUST** produce the same header set for the same three header values regardless of which alias the path names or whether that alias resolves. It **MUST** answer a preflight 204 when the caller holds no permission and when the resolved configuration would refuse both the origin and the method, and it **MUST NOT** refuse a preflight for a disallowed origin, for a disallowed method, or for a disabled CORS family, because origin and method validation is deferred to the actual request (§1.5); that tokenless 204 departs from `cpt-cf-oagw-dod-proxy-endpoint` of `cpt-cf-oagw-feature-data-plane-proxy`, whose permission **MUST** is unconditional over every method the handler accepts and answers 401 for a missing token, and the departure is the resolution §1.5 records for the preflight branch. It **MUST** remain subject to the platform's global and edge rate limiting and WAF/DDoS controls while bypassing only the per-request check of `cpt-cf-oagw-feature-rate-limiting`. It **MUST** carry `X-OAGW-Error-Source: gateway` on the 204, **MUST** omit rather than emit a value the platform cannot form into a response header, and **MUST** record that omission in the request's execution context. It **MUST** hand every request that fails the three-part detection back to the proxy path unanswered, and **MUST NOT** answer an ordinary `OPTIONS` proxy request from this flow. + +**Implements**: + +- `cpt-cf-oagw-flow-cors-preflight` +- `cpt-cf-oagw-algo-cors-preflight-headers` +- `cpt-cf-oagw-fr-request-proxy` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: `OPTIONS /oagw/v1/proxy/{alias}[/{path_suffix}]` — answered 204 on the proxy path `cpt-cf-oagw-feature-data-plane-proxy` registers, which is the one path DECOMPOSITION §2.7 declares for this feature and not a second registration of it +- DB: none +- DB Table: none +- Entities: the preflight response shape + +### Actual-Request Origin and Method Enforcement + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-cors-enforcement` + +The system **MUST** run `cpt-cf-oagw-flow-cors-enforce` once per actual proxy request that carries an `Origin` header, at the position §1.5 records — after the resolution and route match have produced the effective configuration and the matched route, and before the rate-limit check and the composed chain — and **MUST NOT** run it for a request that carries no `Origin` header, which DESIGN §3.2 qualifies out of both CORS guard rows. It **MUST** answer an origin the effective `allowed_origins` does not name with 403 and `gts.cf.core.errors.err.v1~cf.oagw.cors.origin_not_allowed.v1`, **MUST** answer a method the effective `allowed_methods` does not name with 403 and `gts.cf.core.errors.err.v1~cf.oagw.cors.method_not_allowed.v1`, **MUST** run the origin check before the method check (§1.5), and **MUST** answer both before anything is forwarded, so no disallowed request reaches the upstream. It **MUST** carry `X-OAGW-Error-Source: gateway` on both answers and serialize both through the foundation's single RFC 9457 problem-body path, and **MUST NOT** introduce a `DomainError` variant for either (§1.5). It **MUST** enforce nothing and decorate nothing when the effective family is absent at every layer or disabled at the prevailing one. + +**Implements**: + +- `cpt-cf-oagw-flow-cors-enforce` +- `cpt-cf-oagw-algo-cors-decide` +- `cpt-cf-oagw-nfr-input-validation` + +**Constraints**: none from DESIGN §2.2; the governing elements are `cpt-cf-oagw-adr-cors`'s numbered actual-request steps and the two CORS rows of the DESIGN §3.2 Guard Rules table. + +**Touches**: + +- API: none — both answers are returned on `{METHOD} /oagw/v1/proxy/{alias}[/{path_suffix}]`, the path `cpt-cf-oagw-feature-data-plane-proxy` registers +- DB: none +- DB Table: none +- Entities: `CorsDecision` + +### Exact Origin Matching and the Credentials Restriction + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-cors-origin-matching` + +The system **MUST** match an origin exactly: the whole `Origin` value against a configured entry, or a `*` entry against anything, with the scheme and the port significant, with no pattern, no suffix, no suffix-of, and no case-folding comparison, and with no trailing slash stripped, so that `https://app.example.com` admits neither `https://app.example.com:8080` nor `http://app.example.com` nor `https://evil.com.example.com` (§1.5). It **MUST** compare the value byte-exact as the platform delivered it and **MUST NOT** normalize, canonicalize, or default-port-reduce it, and **MUST** refuse rather than guess a value it cannot compare byte-exactly (§1.4). It **MUST** refuse every origin for a configuration that carries `allow_credentials: true` beside a `*` origin, and **MUST NOT** answer such a configuration permissively on any request, because the write-time validation that refuses it is the only thing standing between that configuration and the browser (§1.5). It **MUST** treat an `enabled` configuration whose `allowed_origins` is absent or empty as allowing no origin, and **MUST NOT** read it as allowing every origin or as a disabled family (§1.5). + +**Implements**: + +- `cpt-cf-oagw-algo-cors-decide` +- `cpt-cf-oagw-nfr-input-validation` + +**Constraints**: none from DESIGN §2.2; the governing element is `cpt-cf-oagw-adr-cors`'s Origin Matching and Security Considerations sections. + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: `CorsDecision` + +### Response Decoration + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-cors-headers` + +The system **MUST** attach the preflight header set of `cpt-cf-oagw-algo-cors-preflight-headers` to the 204 answer and the actual-request decoration of `cpt-cf-oagw-algo-cors-decide` to an admitted response, split exactly as §1.5 records, and **MUST** carry `Vary: Origin` on every response it decorates including both 403 answers (§1.5). It **MUST** echo the request's own `Origin` value in `Access-Control-Allow-Origin` in every allowed answer including one admitted by a `*` entry, **MUST** emit `Access-Control-Allow-Credentials` exactly when the effective `allow_credentials` is true and omit it when it is false, **MUST** emit `Access-Control-Expose-Headers` from the effective `expose_headers` and omit it when that list is empty, and **MUST NOT** add the CORS-safelisted headers to that header (§1.5). It **MUST** carry the decoration on the response the proxy path assembles and **MUST NOT** assemble, tag, or classify that response itself, which is `cpt-cf-oagw-feature-data-plane-proxy`'s. It **MUST NOT** emit any CORS header on a response to a request that carried no `Origin` header. + +**Implements**: + +- `cpt-cf-oagw-algo-cors-decide` +- `cpt-cf-oagw-algo-cors-preflight-headers` +- `cpt-cf-oagw-fr-header-transform` + +**Constraints**: none from DESIGN §2.2; the governing elements are the two worked header examples of `cpt-cf-oagw-adr-cors` and the `Vary` rule of its Security Considerations. + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: none — the decoration is produced from the decision alone + +### Hierarchical CORS Consumption + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-cors-hierarchy` + +The system **MUST** consume the two per-layer `EffectiveCors` results `cpt-cf-oagw-feature-hierarchical-config` produces — the upstream layer's and the route layer's, each already carrying the tenant chain's contribution and the sharing mode that produced it, with no tenant layer of its own — and **MUST** apply them at enforcement time through `cpt-cf-oagw-algo-cors-fold` as a per-member overlay in the upstream, then route order of `cpt-cf-oagw-fr-config-layering`, so the last layer result that declares a member prevails (§1.5). It **MUST** take an ancestor `enforce` result whole and unchanged including its `enabled`, **MUST** apply the shipped schema's declared default for a member no layer result declares — including the `enabled` default of false, which is unreachable here because `enabled` is a required member the write-time validation of `cpt-cf-oagw-feature-control-plane-config` always stores — and **MUST** take the routing target's own list rather than the ancestor's when a layer result arrives without its sharing mode (§1.4). It **MUST** enforce the union an ancestor `inherit` produced and the forcing an ancestor `enforce` produced, so that a parent's and a child's origins are both admitted under the first and the parent's alone under the second, and it **MUST NOT** re-walk the chain, re-apply a per-field merge strategy, or union across layers, which `cpt-cf-oagw-feature-hierarchical-config`'s Definition of Done forbids a downstream feature from doing (§1.5). + +**Implements**: + +- `cpt-cf-oagw-algo-cors-fold` +- `cpt-cf-oagw-flow-cors-enforce` +- `cpt-cf-oagw-fr-hierarchical-config` + +**Constraints**: none from DESIGN §2.2; the governing elements are the CORS merge row of the DESIGN §3.2 Hierarchical Configuration table and the layer order of `cpt-cf-oagw-fr-config-layering`. + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: none — the fold consumes `EffectiveCors` and produces no type of its own beyond `CorsDecision` + +### CORS Entities and Layering + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-cors-entities` + +The system **MUST** declare `CorsDecision` and the preflight response shape once, in the domain layer, free of transport and persistence types (`cpt-cf-oagw-component-model`, `cpt-cf-oagw-design-layers`), and **MUST** reference `CorsConfig` from `cpt-cf-oagw-feature-gear-foundation`, `EffectiveCors` from `cpt-cf-oagw-feature-hierarchical-config`, and `ResolvedUpstream`, `MatchedRoute`, and `ProxyResponse` from `cpt-cf-oagw-feature-data-plane-proxy` rather than redeclare any of them (§1.5). It **MUST** hold no state between requests, persist nothing, and register no sink, and a restart **MUST** change no answer it produces (§1.5). It **MUST NOT** declare a second `CorsConfig`, a second `EffectiveCors`, or a state machine over the `enabled` flag another feature stores and merges. + +**Implements**: + +- `cpt-cf-oagw-algo-cors-fold` +- `cpt-cf-oagw-algo-cors-decide` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: `CorsDecision`, the preflight response shape + +### Colocated Tests + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-cors-tests` + +The system **MUST** deliver this feature's unit and integration tests colocated under `gears/system/oagw/oagw/tests/`, covering the three-part preflight detection and its negative cases, the preflight header set including the echoed origin, the echoed method, the echoed requested headers, the constant max age, and the three-member `Vary`, the preflight's independence from resolution and from the permission check, the exact origin matching against the matched and rejected values ADR 0004's Origin Matching section names, the method check and the origin-before-method order, the two 403 bodies with their GTS types, titles, and error-source tags, the decoration of an allowed request including the credentials header and the omitted empty exposure, the wildcard and empty-allowlist configurations, the credentials restriction, the layered fold over `inherit`, `enforce`, and `private` including ADR 0004's parent-and-child example, the absent and disabled family, and the absence of any CORS header on a request with no `Origin`, and **MUST NOT** add any test under `testing/e2e/gears/oagw/`. + +**Implements**: + +- `cpt-cf-oagw-dod-cors-preflight` +- `cpt-cf-oagw-dod-cors-enforcement` +- `cpt-cf-oagw-dod-cors-origin-matching` +- `cpt-cf-oagw-dod-cors-headers` +- `cpt-cf-oagw-dod-cors-hierarchy` +- `cpt-cf-oagw-dod-cors-entities` + +**Constraints**: none from DESIGN §2.2; this is the DECOMPOSITION §1.3(3) placement deviation recorded in §1.5. + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: none — tests only + +## 6. Acceptance Criteria + +- [x] An `OPTIONS` request carrying `Origin` and `Access-Control-Request-Method` is answered 204 with `Access-Control-Allow-Origin` naming the origin the request sent, `Access-Control-Allow-Methods` naming the method the request named, `Access-Control-Allow-Headers` naming the requested headers when any were named, `Access-Control-Max-Age`, `Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers`, and `X-OAGW-Error-Source: gateway`. +- [x] The preflight answer is produced with no upstream resolution, no route match, no tenant context, no plugin execution, and no rate-limit charge, so the same 204 is answered for an alias that does not resolve and for one that does. +- [x] A preflight sent without a bearer token, or with an invalid one, is answered 204 and not 401, because the browser that sends a preflight sends no credentials. +- [x] A preflight is answered 204 while remaining subject to the platform's global and edge rate limiting and WAF/DDoS controls, and the independence this feature claims is only from the per-request counter of `cpt-cf-oagw-feature-rate-limiting`. +- [x] A preflight whose `Access-Control-Request-Method` names a method the effective configuration refuses, and a preflight for an upstream whose `cors.enabled` is false, are both answered the same 204 as any other preflight, and neither answer names an allowed origin or an allowed method. +- [x] An `OPTIONS` request that carries no `Origin` header, or no `Access-Control-Request-Method` header, is answered by nothing in this feature: it is resolved, matched, authenticated, and validated like any other proxy request, and the route's method allowlist judges it, and under the shipped route schema's method enum, which names no `OPTIONS` literal, it matches no route and is answered 404 with the `RouteNotFound` variant rather than forwarded. +- [x] A preflight that omits `Access-Control-Request-Headers` is answered 204 with no `Access-Control-Allow-Headers` header at all, and one that names it receives that value verbatim with no allowlist applied and no name reordered. +- [x] A preflight whose `Access-Control-Request-Headers` value cannot be formed into a response header is answered 204 with `Access-Control-Allow-Headers` omitted and the omission recorded in the request's execution context, and not failed with 4xx or 5xx. +- [x] No preflight answer carries `Access-Control-Allow-Credentials`, and no preflight answer carries `Access-Control-Expose-Headers`, which appear only on an actual-request response. +- [x] An actual cross-origin request whose `Origin` is named by the effective `allowed_origins` and whose method is named by the effective `allowed_methods` is forwarded, and its response carries `Access-Control-Allow-Origin` with the origin the request sent, `Vary: Origin`, and no `Access-Control-Allow-Methods` and no `Access-Control-Max-Age`. +- [x] An actual cross-origin request whose `Origin` is not named by the effective `allowed_origins` is answered 403 with `Content-Type: application/problem+json`, `type` `gts.cf.core.errors.err.v1~cf.oagw.cors.origin_not_allowed.v1`, `title` `CORS Origin Not Allowed`, `status` `403`, a `detail` naming the origin the request sent and no allowed value, `Vary: Origin`, and `X-OAGW-Error-Source: gateway`, and nothing is forwarded to the upstream. +- [x] An actual cross-origin request whose method is not named by the effective `allowed_methods` is answered 403 with `type` `gts.cf.core.errors.err.v1~cf.oagw.cors.method_not_allowed.v1`, `title` `CORS Method Not Allowed`, `status` `403`, a `detail` naming the method the request sent and no allowed value, `Vary: Origin`, and `X-OAGW-Error-Source: gateway`. +- [x] A request whose origin is not allowed and whose method is also not allowed is answered with the origin reason and not the method reason, and its body names no allowed method. +- [x] An upstream configured with `allowed_origins` of `["https://app.example.com"]` admits a request whose `Origin` is `https://app.example.com` and refuses a request whose `Origin` is `https://evil.com`, `https://app.example.com:8080`, or `http://app.example.com`, which is the matched and rejected set ADR 0004's Origin Matching section names. +- [x] An origin value that differs from an allowed one only in the case of a character, only in a trailing slash, or only in an explicit default port is refused, and no pattern, suffix, suffix-of, or case-folding comparison admits `https://evil.com.example.com` against an allowed `https://example.com`. +- [x] An upstream configured with `allowed_origins` of `["*"]` admits an actual cross-origin request from any origin, and its response carries the request's own origin in `Access-Control-Allow-Origin` rather than the literal `*`. +- [x] A configuration with `allow_credentials: true` and a named origin list emits `Access-Control-Allow-Credentials: true` on an admitted response, and the same configuration with `allow_credentials` absent or false emits no such header. +- [x] A configuration with `allow_credentials: true` and `allowed_origins` of `["*"]` is refused when it is written, and a request that reaches the enforcement path against such a configuration is answered with the origin 403 rather than served permissively. +- [x] An upstream configured `cors.enabled: true` with no `allowed_origins` at all, or with an empty list, answers every actual cross-origin request with the origin 403 and forwards nothing, and is not read as a disabled family. +- [x] An upstream or route with `cors.enabled: false`, and a resource that declares no `cors` object at any layer, produces no enforcement and no CORS header of any kind, and a cross-origin request against it is forwarded or refused by the rest of the proxy path exactly as if this feature were absent. +- [x] A request that carries no `Origin` header is forwarded or refused with no CORS header of any kind on its response, including no `Vary: Origin`, and the enforcement flow is not invoked for it. +- [x] Both 403 answers are serialized through the foundation's single RFC 9457 problem-body path and introduce no `DomainError` variant: the foundation catalogue remains 22 variants over 21 identifiers, and neither answer adds a twenty-third. +- [x] An ancestor `cors` marked `inherit` with origins `https://app.example.com` and a descendant route adding `https://admin.example.com` admits a cross-origin request from either origin, and the same ancestor marked `enforce` admits the ancestor's origins alone: the descendant's addition is stored and nothing refuses it at write time, and the effective configuration at enforcement time is the ancestor's origins alone, because `cpt-cf-oagw-algo-field-family-merge` forces the ancestor's whole `cors` object at resolution time. +- [x] An ancestor `cors` marked `private` contributes nothing to a descendant, and a descendant that declares no `cors` object of its own under such an ancestor enforces no CORS at all rather than inheriting the ancestor's list. +- [x] A route-level `cors` object overrides the upstream's for the members it declares, in the upstream, then route order the fold consumes, and a member the route object omits is taken from the upstream object rather than from the shipped default when the upstream declared it. +- [x] An effective configuration whose `allowed_methods` is absent at every layer enforces `GET` and `POST`, which is the shipped schema's declared default, and one whose `expose_headers` is absent emits no `Access-Control-Expose-Headers` header at all. +- [x] The origin check and the method check run after the resolution and the route match have produced the effective configuration, and before the rate-limit check and the composed chain, so a refused cross-origin request charges no counter and executes no plugin. +- [x] Every CORS answer carries `X-OAGW-Error-Source: gateway`, including both 403 answers and the 204 preflight answer's response, and no answer of this feature is tagged `upstream`. +- [x] The feature registers no route of its own: the only request it answers arrives on `OPTIONS /oagw/v1/proxy/{alias}[/{path_suffix}]` or on the proxy path under another method, both of which `cpt-cf-oagw-feature-data-plane-proxy` registers, and the management endpoints that change the configuration it enforces belong to `cpt-cf-oagw-feature-control-plane-config`. +- [x] No plugin item can select CORS: the `gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.cors.v1` identifier is catalog-only and cannot be bound through `plugins.items[].plugin_ref`, and the enforcement flow runs ahead of the chain that would have composed such an item. +- [x] `CorsDecision` and the preflight response shape are declared once in the domain layer, free of transport and persistence types, and `CorsConfig`, `EffectiveCors`, `ResolvedUpstream`, `MatchedRoute`, and `ProxyResponse` are referenced from their owning features rather than redeclared. +- [x] A restart of the gear changes no CORS answer, and no request, configuration, or decision of this feature is persisted, cached, or counted anywhere. +- [x] Every test for this feature lives under `gears/system/oagw/oagw/tests/`, passes there, and no test is added under `testing/e2e/gears/oagw/`. diff --git a/gears/system/oagw/docs/features/data-plane-proxy.md b/gears/system/oagw/docs/features/data-plane-proxy.md new file mode 100644 index 0000000..f460f44 --- /dev/null +++ b/gears/system/oagw/docs/features/data-plane-proxy.md @@ -0,0 +1,1079 @@ +# Feature: Data Plane Proxy + + + + +- [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 Feature-Local Deviations from Shared Baselines](#15-feature-local-deviations-from-shared-baselines) + - [1.6 Explicit Non-Applicability](#16-explicit-non-applicability) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Proxy a Request End to End](#proxy-a-request-end-to-end) + - [Authorize a Proxy Request](#authorize-a-proxy-request) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Consume the Effective Configuration](#consume-the-effective-configuration) + - [Match the Route](#match-the-route) + - [Select the Target Endpoint](#select-the-target-endpoint) + - [Validate the Inbound Request](#validate-the-inbound-request) + - [Validate the Body](#validate-the-body) + - [Transform the Headers](#transform-the-headers) + - [Execute the Plugin Chain](#execute-the-plugin-chain) + - [Enforce the Starlark Sandbox](#enforce-the-starlark-sandbox) + - [Forward the Outbound Request](#forward-the-outbound-request) + - [Classify the Response and Tag the Error Source](#classify-the-response-and-tag-the-error-source) + - [Cache the Resolved Configuration](#cache-the-resolved-configuration) +- [4. States (CDSL)](#4-states-cdsl) +- [5. Definitions of Done](#5-definitions-of-done) + - [Proxy Endpoint Registration and Authorization](#proxy-endpoint-registration-and-authorization) + - [Effective Configuration Consumption](#effective-configuration-consumption) + - [Route Matching](#route-matching) + - [Endpoint Selection](#endpoint-selection) + - [Inbound Validation](#inbound-validation) + - [Body Validation](#body-validation) + - [Header Transformation](#header-transformation) + - [Plugin Chain Execution](#plugin-chain-execution) + - [Starlark Sandbox Enforcement](#starlark-sandbox-enforcement) + - [Outbound Forwarding](#outbound-forwarding) + - [Error Source Tagging](#error-source-tagging) + - [Data Plane Configuration Cache](#data-plane-configuration-cache) + - [Proxy Entities and Layering](#proxy-entities-and-layering) + - [Latency Budget](#latency-budget) + - [Colocated Tests](#colocated-tests) +- [6. Acceptance Criteria](#6-acceptance-criteria) + + + +- [ ] `p1` - **ID**: `cpt-cf-oagw-featstatus-data-plane-proxy-implemented` + + +- [ ] `p2` - `cpt-cf-oagw-feature-data-plane-proxy` + +## 1. Feature Context + +### 1.1 Overview + +This feature is the request path of the `oagw` gear. It serves `{METHOD} /oagw/v1/proxy/{alias}[/{path_suffix}][?{query}]`, resolves the upstream named by the alias against the tenant chain, matches the route, consumes the effective configuration that `cpt-cf-oagw-feature-hierarchical-config` resolved, selects one endpoint of the upstream, executes the plugin chain that `cpt-cf-oagw-feature-plugin-system` composed, rewrites the headers, validates the body, and forwards the call over the shared outbound client. Every answer it produces carries `X-OAGW-Error-Source: gateway|upstream`, and every answer the gateway itself produces is an RFC 9457 problem body. + +### 1.2 Purpose + +DECOMPOSITION §2.5 places this feature at the junction of the two branches of the feature graph: it is where the persisted configuration `cpt-cf-oagw-feature-control-plane-config` writes and `cpt-cf-oagw-feature-hierarchical-config` resolves meets the plugin contracts `cpt-cf-oagw-feature-plugin-system` composes. Everything downstream of it — `cpt-cf-oagw-feature-rate-limiting`, `cpt-cf-oagw-feature-cors`, `cpt-cf-oagw-feature-streaming`, and the proxy-reading slice of `cpt-cf-oagw-feature-observability` — hangs off the resolution and the execution context this feature produces, and none of them can be built until a proxy request resolves, forwards, and answers. + +This feature delivers the DESIGN §3.2 Alias Resolution (its proxy-time consumption), Headers Transformation, Guard Rules, Body Validation Rules, and Transformation Rules subsections, plus the proxy share of the DESIGN §3.2 Security Considerations and Permissions and Access Control subsections. `cpt-cf-oagw-seq-proxy-flow` (DESIGN §3.5) is the only identified sequence in the design and is the reference flow for §2: resolve the upstream by alias against the tenant chain, resolve the route, inject credentials, execute guards, transform the request, call the upstream, transform the response. §2 is the CDSL statement of that sequence, not a second design for it. + +Deliverables: + +- The proxy handler for `{METHOD} /oagw/v1/proxy/{alias}[/{path_suffix}][?{query}]`, registered gear-relative on the mount point `cpt-cf-oagw-feature-gear-foundation` created, and routed to the Data Plane by the path-based routing of `cpt-cf-oagw-adr-request-routing`. +- Bearer-token authorization requiring `gts.cf.core.oagw.proxy.v1~:invoke` and an upstream the calling tenant owns or inherits through the chain. +- Consumption of the effective configuration in the order upstream, then route, then tenant, through `cpt-cf-oagw-flow-resolve-effective-config` of `cpt-cf-oagw-feature-hierarchical-config`, with shadowing and with ancestor `enforce` families still carried. +- Route matching by method allowlist, longest path prefix, and priority, honouring `path_suffix_mode`. +- `X-OAGW-Target-Host` endpoint selection with the full behaviour matrix of ADR 0001, including the required-header case for common-suffix aliases and round-robin otherwise. +- Request-time plugin chain execution in the order Auth, Guards, Transform on the request, the upstream call, then Guards and Transform on the response, and Transform on the error, including credential injection into the outbound request. +- Starlark custom-plugin sandbox enforcement: no network I/O, no file I/O, no imports, a per-invocation timeout and a per-invocation memory limit. +- Header transformation: routing headers consumed, hop-by-hop headers stripped, passthrough rules applied, and `Host` or `:authority` replaced with the upstream value. +- Body validation: `Content-Length` consistency, the 100MB hard limit rejected before buffering, `chunked`-only `Transfer-Encoding`, and rejection of CR/LF injection and of conflicting CL/TE combinations. +- Inbound validation of path, query parameters, and headers against the matched route, answered 400 on failure. +- Outbound forwarding over one shared client with adaptive per-host HTTP version detection, one attempt per client request, and no gateway-level re-issue of that request. +- The Data Plane L1 configuration cache with explicit invalidation, and the error-source tagging of every response. +- Colocated tests under `gears/system/oagw/oagw/tests/`. + +The feature is delivered in the three phases DECOMPOSITION §2.5 names: route matching and effective-config invocation, then outbound proxying and error semantics, then streaming and body handling. The third phase delivers the body-handling half of this feature only; the connection lifecycles of a stream belong to `cpt-cf-oagw-feature-streaming`. + +**Requirements**: + +- [ ] `p1` - `cpt-cf-oagw-fr-request-proxy` +- [ ] `p1` - `cpt-cf-oagw-fr-header-transform` +- [ ] `p1` - `cpt-cf-oagw-fr-auth-injection` +- [ ] `p1` - `cpt-cf-oagw-nfr-ssrf-protection` +- [ ] `p1` - `cpt-cf-oagw-nfr-low-latency` +- [ ] `p1` - `cpt-cf-oagw-nfr-input-validation` +- [ ] `p3` - `cpt-cf-oagw-nfr-starlark-sandbox` +- [ ] `p1` - `cpt-cf-oagw-interface-proxy-api` +- [ ] `p1` - `cpt-cf-oagw-usecase-proxy-request` + +**Principles**: + +- `p1` - `cpt-cf-oagw-principle-no-retry` +- `p1` - `cpt-cf-oagw-principle-no-cache` +- `p1` - `cpt-cf-oagw-principle-error-source` +- `p1` - `cpt-cf-oagw-adr-request-routing` +- `p1` - `cpt-cf-oagw-adr-error-source-distinction` +- `p1` - `cpt-cf-oagw-adr-data-plane-caching` +- `p1` - `cpt-cf-oagw-adr-state-management` + +**Constraints**: + +- `p1` - `cpt-cf-oagw-constraint-body-limit` +- `p1` - `cpt-cf-oagw-constraint-no-direct-internet` +- `p1` - `cpt-cf-oagw-constraint-https-only` +- `p1` - `cpt-cf-oagw-constraint-toolkit-deploy` + +**Design Components**: + +- `p1` - `cpt-cf-oagw-component-model` +- `p1` - `cpt-cf-oagw-design-layers` +- `p1` - `cpt-cf-oagw-tech-dependencies` +- `p1` - `cpt-cf-oagw-interface-api` + +**Sequence**: + +- `p1` - `cpt-cf-oagw-seq-proxy-flow` + +**Domain Model Entities**: + +- `ProxyContext` — the request-side context this feature builds once and passes down: method, normalized alias, path suffix, query, header map, body reference, calling tenant and subject, and the correlation context. +- `ResolvedUpstream` — the upstream the chain resolved, carrying its identifier, its alias, its alias derivation kind, its endpoint set, its protocol, its effective `enabled` state, its header rules, and the per-family sharing modes and ownership the resolution returned. +- `SelectedEndpoint` — one endpoint of the resolved upstream, carrying its scheme, host, and port, and how it was chosen. +- `MatchedRoute` — the route the matcher selected, carrying its identifier, its priority, its effective match keys, the outbound path, and the route-layer configuration. +- `OutboundRequest` — the request as it leaves the gateway: method, target URL, transformed header map, body, selected endpoint, and the protocol version chosen for the host. +- `ProxyResponse` — the answer returned to the caller: status, transformed header map, body, and the error-source tag. + +All six are declared here; DECOMPOSITION §2.5 lists all six under this entry. `EffectiveUpstreamConfig` and `EffectiveRouteConfig` are consumed from `cpt-cf-oagw-feature-hierarchical-config` and are not redeclared, and the four plugin-execution contexts `AuthContext`, `RequestContext`, `ResponseContext`, and `ErrorContext` are consumed from `cpt-cf-oagw-feature-gear-foundation`, which is their single definition point (§1.5). + +**Data**: + +- None. DECOMPOSITION §2.5 declares no table for this feature, and it creates, reads, and writes no table of its own. The one column it writes is `last_used_at` on the plugin row, which `cpt-cf-oagw-feature-plugin-system` persists and owns (§1.5). + +**API**: + +- `{METHOD} /oagw/v1/proxy/{alias}[/{path_suffix}][?{query}]` +- POST /oagw/v1/proxy/api.openai.com/v1/chat/completions (single-endpoint upstream, no target header needed) +- GET /oagw/v1/proxy/my-service/v1/status with `X-OAGW-Target-Host` selecting one endpoint in a pool + +The three lines are the DECOMPOSITION §2.5 API list restated in the gear-relative form of DECOMPOSITION §1.3(1); this feature invents no path, no method, and no response shape that the list does not name. + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-oagw-actor-app-developer` | Sends the proxy request with a bearer token and an alias, and receives either the upstream's response or a gateway answer tagged with its error source. PRD §5.2 names this actor for `cpt-cf-oagw-fr-request-proxy`, and PRD §8 names it the actor of `cpt-cf-oagw-usecase-proxy-request`. | +| `cpt-cf-oagw-actor-upstream-service` | Receives the outbound request the gateway builds and answers it; it is the only actor this feature contacts over a network, and it never sees a routing header, a hop-by-hop header, or the caller's bearer token as a passthrough. The credential it does see is the one the chain injects or a `headers.request` `set` rule configures, never the caller's own token forwarded unmodified (`cpt-cf-oagw-algo-header-transform`). | + +Two actors participate indirectly and are named here so their absence from the table is a record and not a gap: + +- `cpt-cf-oagw-actor-cred-store` answers the resolve call that turns a `cred://` reference into material. That call belongs to `cpt-cf-oagw-algo-credential-resolution` of `cpt-cf-oagw-feature-plugin-system`, which the chain this feature executes invokes; DECOMPOSITION §1.5 lists the credential store under `cpt-cf-oagw-feature-plugin-system` alone, so it is not an actor of this feature. +- `cpt-cf-oagw-actor-types-registry`, `cpt-cf-oagw-actor-platform-operator`, and `cpt-cf-oagw-actor-tenant-admin` issue no call this feature answers. The type catalogue was provisioned once at startup by `cpt-cf-oagw-feature-gear-foundation`, and no request-time path registers or reads a type. The two management actors have no proxy surface; DECOMPOSITION §1.5 lists neither against `cpt-cf-oagw-feature-data-plane-proxy`, and the bearer token a management actor's tenant happens to resolve to is treated exactly like any other caller's. + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) +- **Design**: [DESIGN.md](../DESIGN.md) +- **Dependencies**: `cpt-cf-oagw-feature-hierarchical-config` — the effective configuration this feature consumes, the `EffectiveUpstreamConfig` and `EffectiveRouteConfig` result types, and the alias input this feature normalizes before resolving, by calling `cpt-cf-oagw-algo-alias-normalize` of `cpt-cf-oagw-feature-gear-foundation`, since that feature delivers no alias normalization of its own; and `cpt-cf-oagw-feature-plugin-system` — the three plugin contracts, the three registries, the composed per-phase sub-chains, the credential resolution, and the token cache the chain execution invokes (DECOMPOSITION §3). + +Supporting sources this feature stays consistent with: + +- [ADR/0001-request-routing.md](../ADR/0001-request-routing.md) (`cpt-cf-oagw-adr-request-routing`) — path-based routing that sends `/oagw/v1/proxy/*` to the Data Plane, the proxy API examples, and the `X-OAGW-Target-Host` behaviour matrix this feature implements row for row in §3. +- [ADR/0002-plugin-system.md](../ADR/0002-plugin-system.md) (`cpt-cf-oagw-adr-plugin-system`), [ADR/0008-oauth2-client-credentials-auth-plugin.md](../ADR/0008-oauth2-client-credentials-auth-plugin.md) (`cpt-cf-oagw-adr-oauth2-client-credentials-auth-plugin`), and [ADR/0009-required-headers-guard-plugin.md](../ADR/0009-required-headers-guard-plugin.md) (`cpt-cf-oagw-adr-required-headers-guard-plugin`) — the chain this feature executes. This feature consumes their contracts, their execution order, and their phase-specific rejection statuses, and re-specifies none of them. +- [ADR/0005-data-plane-caching.md](../ADR/0005-data-plane-caching.md) (`cpt-cf-oagw-adr-data-plane-caching`) — the L1 cache layer, its key shapes, its lazy population, and the invalidation ordering on a configuration write. +- [ADR/0006-state-management.md](../ADR/0006-state-management.md) (`cpt-cf-oagw-adr-state-management`) — the Data Plane's statelessness, its small L1 cache with explicit invalidation, the shared outbound client, and the request flow with caching that §3's first routine follows. +- [ADR/0007-error-source-distinction.md](../ADR/0007-error-source-distinction.md) (`cpt-cf-oagw-adr-error-source-distinction`) — the header values, the problem-details rule for gateway errors, the passthrough rule for upstream errors, and the target-host error examples. +- [ADR/0004-cors.md](../ADR/0004-cors.md) — the contrasting precedent behind the two CORS rows of the DESIGN §3.2 Guard Rules table, both of which belong to `cpt-cf-oagw-feature-cors` and are named in §1.6 rather than implemented here. +- [schemas/upstream.v1.schema.json](../schemas/upstream.v1.schema.json) and [schemas/route.v1.schema.json](../schemas/route.v1.schema.json) — the frozen shapes of what this feature consumes: the endpoint `scheme`, `host`, and `port`, the `headers.request` and `headers.response` rule sets with their `passthrough` modes, the `match.http` keys with their `query_allowlist` and `path_suffix_mode`, and the `protocol` enum. Both are frozen inputs this run does not edit. +- [config/e2e-local.yaml](../../../../../config/e2e-local.yaml) — the graded configuration. Its `oagw.config` block sets `proxy_timeout_secs: 2`, `allow_http_upstream: true`, and `ssrf_policy.enabled: false`, and sets neither token-cache key, so both take their ADR 0008 defaults. Its `api-gateway` block sets `defaults.body_limit_bytes: 64000000` and denies the nil-tenant token with a 403. Its `e2e-features.txt` build list names `oagw`, so the gear is compiled into the graded build. No upstream, route, or plugin is declared in it, so every graded proxy request resolves against configuration written through the management API at run time. + +**Run-level assumptions** — premises this feature relies on that come from the platform runtime rather than from PRD, DESIGN, the ADRs, or DECOMPOSITION. Each states what fails if the premise does not hold: + +- Assumption: the platform middleware authenticates the bearer token, resolves the calling tenant and subject, and enforces `gts.cf.core.oagw.proxy.v1~:invoke` before the request reaches this feature's handler. `config/e2e-local.yaml` sets `api-gateway.auth_disabled: false` and `require_auth_by_default: true`, and DESIGN §3.3 names `toolkit-auth` as the inbound mechanism, but no supplied document states that the proxy permission is enforced by the platform rather than by the gear. If it is not, the handler **MUST** enforce it before any resolution runs, and a request that reaches the resolution step with no resolved tenant **MUST** fail closed with the platform RFC 9457 500 problem shape and never be forwarded. +- Assumption: the platform api-gateway applies `defaults.body_limit_bytes` from `config/e2e-local.yaml`, which in the graded configuration is 64,000,000 bytes — below the 100MB hard limit of `cpt-cf-oagw-constraint-body-limit`. If that platform limit is raised above the hard limit, this feature's own check becomes the binding one and the 413 answer of §3 becomes reachable; if it is removed, this feature **MUST** still reject before buffering, because no other layer enforces `cpt-cf-oagw-constraint-body-limit`. +- Assumption: the platform tenant-resolver supplies the calling tenant's ancestor chain, exactly as `cpt-cf-oagw-feature-hierarchical-config` assumes for the same walk. If the chain is unavailable, unordered, or cyclic, the resolution fails closed and this feature answers the platform 500 problem shape; it **MUST NOT** forward a request whose chain it cannot order, because an unordered chain cannot decide who shadows whom. +- Assumption: the external `pingora` dependency of `cpt-cf-oagw-tech-dependencies` supplies the shared outbound client, its connection pooling, and ALPN-based protocol negotiation on the TLS handshake, so the adaptive per-host detection of DESIGN §3.2 Security Considerations is a cache over a capability the connector already has. If it does not, this feature **MUST** fall back to HTTP/1.1 for every host and record the fallback; that is a performance loss against `cpt-cf-oagw-nfr-low-latency` and never a correctness one, so the request still forwards. +- Assumption: the notification of a successful configuration write reaches this feature's flush routine in the same process, because the graded posture is the single-executable branch of `cpt-cf-oagw-constraint-toolkit-deploy` and the write path and this cache share one address space. The two acts have different owners: the notification is the write path's, and the flush is this feature's, executed by `cpt-cf-oagw-algo-dp-cache` before the write's response is produced (§1.5). If the runtime offers no such in-process ordering, that ordering **MUST** still be produced before the write's response is emitted — the same ordering `cpt-cf-oagw-feature-control-plane-config` already applies to its own cache — and this feature **MUST NOT** fall back to a periodic sync, which DECOMPOSITION §1.3(10) dispositions. +- Assumption: the Starlark interpreter the plugin source is stored for is reachable from the Data Plane and exposes per-invocation resource limits it can apply, with no network, file, or import capability. If it cannot enforce a limit, this feature **MUST** refuse to execute the plugin and answer through the `PluginNotFound` variant of the foundation catalogue (§1.5); it **MUST NOT** run untrusted code with a limit it cannot apply. +- Assumption: the correlation context is supplied by the platform, and `cpt-cf-oagw-feature-observability` derives the `trace_id` an error body carries from it. If no context is available, the `trace_id` extension field **MUST** be omitted rather than synthesized, because an invented identifier correlates nothing. + +### 1.5 Feature-Local Deviations from Shared Baselines + +| Deviation | Rationale | Review owner | Validation performed | +|-----------|-----------|--------------|----------------------| +| The proxy path is registered gear-relative at `/oagw/v1/proxy/...` with no `/api` prefix. | DECOMPOSITION §1.3(1) corrects the `/api/oagw/v1/...` tabulation in PRD §7.1 and DESIGN §3.3: `/api` is an operator gateway prefix, not a path this gear serves. Every path in this document is the gear-relative form, and the three API lines above are the restatement of the DECOMPOSITION §2.5 list, not a new design. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The 100MB hard limit of `cpt-cf-oagw-constraint-body-limit` is read as 100,000,000 bytes. | DESIGN §3.2 Body Validation Rules states the row as "Hard limit 100MB" and the constraint states "Body size hard limit: 100MB"; neither states a byte count, and the 413 answer is only testable once one exists. The decimal reading matches the one byte-exact body limit the graded configuration expresses anywhere — `config/e2e-local.yaml`'s `defaults.body_limit_bytes: 64000000` — and is the stricter of the two readings, which is the safe direction for a guard whose purpose is preventing resource exhaustion. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| Route `priority` is compared as an ascending precedence order: the smaller value wins when two candidate routes match the same longest path prefix. | DESIGN §3.1 declares `priority` as an `Int` and DESIGN §3.6 names it in the match-determinism invariant and in the `(upstream_id, method, longest path prefix, priority)` lookup, but no supplied document states which direction wins. The direction is the whole contract, because the value is only ever compared and never interpreted, so it is recorded here rather than left to the implementation. `cpt-cf-oagw-algo-match-uniqueness` of `cpt-cf-oagw-feature-control-plane-config` makes the comparison total by forbidding two enabled routes of one upstream from sharing `(path, priority, method)`. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| Two cache sizes DESIGN and ADR 0006 state are carried as named constants and add no `OagwConfig` key: the Data Plane L1 entry count of 1000, and the per-host protocol cache entry TTL of 1 hour. | ADR 0006 fixes the Data Plane L1 at "1000 entries, no TTL, explicit invalidation" and adds that it is "configurable via environment variable", while the `OagwConfig` surface DECOMPOSITION §2.1 declares closes at five keys — `proxy_timeout_secs`, `allow_http_upstream`, `ssrf_policy`, `token_cache_ttl_secs`, `token_cache_capacity` — and `cpt-cf-oagw-feature-gear-foundation` owns that surface and names no cache key. DESIGN §3.2 Security Considerations fixes the protocol cache entry TTL at 1 hour. Widening the surface here would give one configuration surface two owners, so both values are constants with their sourced values, and the configurability ADR 0006 mentions is not delivered. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The periodic-sync alternative of ADR 0005 and ADR 0006 is dispositioned and not delivered: the Data Plane L1 cache is invalidated explicitly, in process, on the notification the configuration write path issues, and carries no TTL and no background sync. | DECOMPOSITION §1.3(10) assigns this disposition to this feature and DECOMPOSITION §1.3(7) authorizes the single-exec branch that makes it possible. ADR 0005's invalidation step lists two mechanisms for the Data Plane flush — "notified by CP or periodic sync" — and ADR 0006's own mitigation names "explicit cache invalidation from CP on config writes (no TTL; entries persist until invalidated)" as the chosen one. Explicit invalidation keeps the staleness window bounded by the write itself, where a sync would leave an unbounded window and a polling cost on a path whose budget is `cpt-cf-oagw-nfr-low-latency`. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The post-write invalidation of the Data Plane L1 cache is split at a seam this document states: the notification of a successful write is `cpt-cf-oagw-feature-control-plane-config`'s act, and the flush itself is this feature's, executed by `cpt-cf-oagw-algo-dp-cache` before the write's response is produced. | `cpt-cf-oagw-feature-control-plane-config` records in its own reference list that the Data Plane L1 cache and its post-write invalidation belong to `cpt-cf-oagw-feature-data-plane-proxy`, so its write path notifies this feature's flush routine in process rather than flushing the cache itself. ADR 0005's Cache Invalidation step reads "(5) DP flushes its own L1 cache (notified by CP or periodic sync)" and ADR 0006's DP State scopes the cache to the upstream and route configurations, so the act is the Data Plane's and the trigger is the Control Plane's. DECOMPOSITION §1.3(10)'s no-periodic-sync posture is what makes the in-process notification the only mechanism, which is the same call-direction seam `cpt-cf-oagw-feature-plugin-system` records for the binding routines this feature invokes. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| ADR 0005's Cache Keys list is narrowed here to the two shapes the Data Plane L1 actually holds — `upstream:{tenant_id}:{alias}` and `route:{upstream_id}:{method}:{path_prefix}` — and its third shape, `plugin:{plugin_id}`, is not cached by this feature. | ADR 0006's DP State scopes the Data Plane L1 to the upstream and route configurations resolved from `cpt-cf-oagw-feature-control-plane-config`, and no step of §3 builds, reads, or inserts a plugin key: the plugin definitions the chain resolves live in the registry and store `cpt-cf-oagw-feature-plugin-system` owns, and the composed chain is built per resolution and never cached. Carrying a key shape with no population path would state a cache entry no code path produces. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The single `proxy_timeout_secs` deadline is applied to both the connection-establishment phase and the request/response exchange phase of one outbound call, and the two catalogue rows that answer a breach are `ConnectionTimeout` for the first and `RequestTimeout` for the second. | The `OagwConfig` surface closes at five keys and carries exactly one deadline, while DESIGN §3.3 tabulates two 504 rows for the two phases of the one bounded operation. Splitting the deadline into two keys is not available, and answering both phases with one row would leave the other catalogue row unreachable. The third 504 row, `IdleTimeout`, answers a stalled stream and belongs to `cpt-cf-oagw-feature-streaming`, which owns the stream lifecycle. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| This feature writes `last_used_at` on the plugin row, off the request's latency budget, and the write feeds no decision. | The DESIGN §3.1 Plugin class declares the column, and DECOMPOSITION §2.4 places plugin execution on a live request with this feature; `cpt-cf-oagw-feature-plugin-system` records in its own §1.5 that it never writes it, naming this feature as the only writer and objecting that a proxy-path write would put a Control Plane write on the Data Plane hot path. The objection is met by the placement rather than by dropping the write: it is issued after the response is produced, outside the budget `cpt-cf-oagw-nfr-low-latency` sets, and coalesced so concurrent requests to one plugin produce one write. It feeds no garbage-collection decision, because that feature derives eligibility from the reference scan alone and its own DoD forbids depending on this column. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| A plugin execution failure that is not a guard verdict — a sandbox limit breach, a per-invocation timeout, or a raised error — is answered through the `ProtocolError` variant of the foundation catalogue, and a plugin that cannot be executed at all in this deployment is answered through `PluginNotFound`. | DESIGN §3.3 tabulates ten 5xx rows — `SecretNotFound` at 500, `ProtocolError`, `DownstreamError`, and `StreamAborted` at 502, `LinkUnavailable`, `CircuitBreakerOpen`, and `PluginNotFound` at 503, and `ConnectionTimeout`, `RequestTimeout`, and `IdleTimeout` at 504 — and tabulates no row for a plugin that failed to run: each of the other nine describes a state this case is not. `ProtocolError` is the non-retriable 502 row whose description is generic enough for a gateway-side contract violation, and `cpt-cf-oagw-feature-plugin-system` already maps the response-phase guard rejection to it, so the two features answer the same class of failure with the same row. `PluginNotFound` is the 503 row for a plugin the gateway cannot run, which is the answer `cpt-cf-oagw-algo-chain-compose` produces for a reference that resolves to no implementation. Inventing a variant is outside this feature's authority; the catalogue is `cpt-cf-oagw-feature-gear-foundation`'s. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| No credential refresh on a rejected request is delivered by this run: an upstream 401 triggers no refresh, no retry, and no re-send, and is answered under the error-source classification of `cpt-cf-oagw-algo-response-classify`. | DESIGN §3.2 Retry Policy states the intent — "Auth plugins handle token refresh on 401, but do not retry the original request" — while ADR 0008 defers the implementation, because the `AuthPlugin` trait returns no signal a Data Plane could use to decide whether a retry with fresh credentials is meaningful. `cpt-cf-oagw-feature-plugin-system` records the same deferral from the plugin side and implements no retry orchestration, so this feature consumes that posture and builds no refresh branch. The refresh an auth plugin does perform is the token-cache refresh of credential preparation, which happens before the send and never after a rejection. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| A proxy request against an upstream whose effective `enabled` state is false is answered 503 through the `LinkUnavailable` variant. | PRD §8's alternative flow for `cpt-cf-oagw-usecase-proxy-request` states "Upstream disabled: Return 503 with gateway error type" and names no variant. DESIGN §3.3 tabulates three 503 rows, and `LinkUnavailable` is the only one that describes the target rather than the circuit breaker or a plugin. It is marked retriable in the catalogue, which is correct: a disabled upstream is a maintenance state an operator lifts. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| A proxy attempt against a `wt`-scheme upstream is answered 502 through the `ProtocolError` variant with `X-OAGW-Error-Source: gateway`. | DECOMPOSITION §1.3(2) records the scope reduction — no feature carries WebTransport behaviour — and states the answer class without naming a variant. The upstream and the route both resolve, so a not-found answer would be false, and the request names a transport the gateway does not implement, which is the `ProtocolError` row's subject. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| An upstream whose `protocol` is the gRPC value produces no matching route, and the answer is the ordinary 404 `RouteNotFound`. | DESIGN §3.1 and DECOMPOSITION §1.3(4) state that no gRPC proxy code path is implemented or reachable, and ADR 0001 selects the match keys from `upstream.protocol` — gRPC keys for a gRPC upstream. This feature evaluates HTTP match keys only, so it evaluates no match key at all for such an upstream and falls through to the same no-match answer any unmatched HTTP request gets. `cpt-cf-oagw-feature-control-plane-config` records the same posture from the write side: a gRPC-only route is stored and is unreachable at proxy time. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| A non-error response carries `X-OAGW-Error-Source: upstream`. | ADR 0007 requires the header on every response, including successes, and assigns a value only to the two error classes. The value that keeps the header a single statement about who produced the body is `upstream`: a success body is as much an upstream passthrough as an upstream error body is, and the ADR defines its `gateway` value only for the responses whose body the gateway itself produced, which are the problem bodies. DECOMPOSITION §2.5's "on every response" is satisfied without making the header carry a third value no source names. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| Route matching is split across two features and this document states the split: the chain-level selection of which route candidates contribute is `cpt-cf-oagw-feature-hierarchical-config`'s, and the method allowlist, longest path prefix, priority, and `path_suffix_mode` evaluation over that candidate set is this feature's. | DECOMPOSITION §2.5 assigns this feature route matching and simultaneously assigns the hierarchy walk and the effective-config resolution to `cpt-cf-oagw-feature-hierarchical-config`, whose `cpt-cf-oagw-flow-resolve-effective-config` step 4 already resolves the matched route along the chain "with the descendant's route taking priority". Without the split, one route selection would have two owners. The candidate set is the input to `cpt-cf-oagw-algo-route-match` here, and the per-field merge strategies that produce it are `cpt-cf-oagw-algo-field-family-merge`'s and are not restated. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The outbound response header rules of `headers.response` are applied by this feature, and the transfer mode of the body is not. | The shipped upstream schema declares `headers.response` with `set`, `add`, and `remove`, and DECOMPOSITION §2.5 assigns header transformation to this feature, so the rules are this feature's to apply. DESIGN §3.2's Headers Transformation subsection tabulates the inbound direction only — `headers.response` appears nowhere in DESIGN — so the response direction is carried by the schema and the decomposition entry rather than by DESIGN. DECOMPOSITION §3 makes `cpt-cf-oagw-feature-streaming` a consumer of this feature because "it changes how the proxy response body is transferred, not what is resolved", so the body transfer mode is that feature's and the header mutation stays here. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| Scheme handling is split across two features and this document states the split: which scheme literals a configured endpoint may carry is decided at write time by `cpt-cf-oagw-feature-control-plane-config`, and whether a plaintext connection is opened is decided at dial time here. | DECOMPOSITION §1.3(2) separates the two questions and assigns only the second to `allow_http_upstream`'s proxy-time effect; `cpt-cf-oagw-feature-control-plane-config` records the first side in its own §1.5. Both are checks against `cpt-cf-oagw-constraint-https-only`, and neither substitutes for the other: a stored `http` endpoint that the flag forbids at dial time is never dialed, and a dial-time check that read the stored scheme alone would bypass the flag. The graded configuration sets the flag to `true`, so a stored `http` endpoint is dialed in plaintext there. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| This feature's tests are colocated at `gears/system/oagw/oagw/tests/` instead of `testing/e2e/gears/oagw/`. | DECOMPOSITION §1.3(3) reserves `testing/e2e/gears/oagw/` for the acceptance suite; every unit and integration test this decomposition produces lives with the crate. This is the same deviation `cpt-cf-oagw-feature-gear-foundation`, `cpt-cf-oagw-feature-control-plane-config`, `cpt-cf-oagw-feature-hierarchical-config`, and `cpt-cf-oagw-feature-plugin-system` record in their own §1.5 tables, restated here because the tests it governs include this feature's. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The proxy flow invokes the rate-limit check of `cpt-cf-oagw-feature-rate-limiting` ahead of the composed chain and returns the 429 or 503 that check produced, with its error-source tag, without executing the chain or building an outbound request. | ADR 0006's request flow with caching orders the proxy steps as the auth plugin, then "Check rate limiter (DP-owned)", then the guard and transform plugins, then the outbound call; the chain that `cpt-cf-oagw-algo-chain-execute` runs bundles the auth plugin with the guards and the transforms into one call, so the position ADR 0006 fixes is realized ahead of the composed chain. A refused request therefore costs no plugin execution at all, which is the reason the invocation sits where it does and not inside the chain it precedes. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | + +### 1.6 Explicit Non-Applicability + +The areas below apply to the gear as a whole but not to this feature. Each is stated here so the omission is a recorded decision rather than a silent gap, and each names the feature that does own it. + +- **The hierarchy walk, alias shadowing, and the per-field merge strategies.** DECOMPOSITION §2.5 places them in `cpt-cf-oagw-feature-hierarchical-config`, and this feature only consumes their result at proxy time through `cpt-cf-oagw-flow-resolve-effective-config`. `cpt-cf-oagw-algo-tenant-chain-walk`, `cpt-cf-oagw-algo-alias-shadow-resolve`, and `cpt-cf-oagw-algo-field-family-merge` are that feature's routines; §3 of this document calls them and restates no merge strategy row. +- **Token-bucket mechanics and 429 answers.** `cpt-cf-oagw-feature-rate-limiting` owns them, and DECOMPOSITION §3 makes it a consumer of this feature because its check runs inside the resolved proxy context. The ancestor `enforce` rate-limit families the resolution returns are carried on `ResolvedUpstream` for it and applied by it; this feature evaluates no limit and answers no 429. +- **CORS preflight and origin enforcement.** `cpt-cf-oagw-feature-cors` owns both, per ADR 0004. The two CORS rows of the DESIGN §3.2 Guard Rules table are therefore not implemented by `cpt-cf-oagw-algo-inbound-validate`; the preflight `OPTIONS` answer does not require upstream resolution at all, and the origin and method enforcement happens after resolution and before forwarding, on this handler's path but not in this feature. +- **SSE and WebSocket connection lifecycles.** `cpt-cf-oagw-feature-streaming` owns them, including the suspension of the `Upgrade` and `Connection` strip rule for a handshake that ADR 0004's host ADR set and DECOMPOSITION §2.7 describe. The strip rule §3 applies here is the unconditional one for a plain request/response exchange, and the idle-timeout 504 of a stalled stream is that feature's answer, not `RequestTimeout`. +- **Metrics emission and audit log formatting.** `cpt-cf-oagw-feature-observability` owns the correlation identifier, the structured audit record, and the Prometheus surface at `/oagw/v1/metrics`. This feature supplies the request lifecycle those records describe and formats none of them. +- **gRPC proxying and WebTransport.** Both are out of scope per DECOMPOSITION §1.3(4); the answers a caller receives are recorded in §1.5. +- **Response caching and automatic request retries.** `cpt-cf-oagw-principle-no-cache` and `cpt-cf-oagw-principle-no-retry` both forbid them, and PRD §4.2 places both outside the gear. The Data Plane L1 cache of §3 holds configuration and holds no response body, which is the distinction the two principles rest on. +- **DNS and IP-pinning rule implementation details.** PRD §4.2 and DECOMPOSITION §2.5 place them out of scope. What this feature does is decide whether the dial-time evaluation of that policy runs at all, from `ssrf_policy.enabled`, and answer a refused resolution as a gateway error; the rules themselves are not specified here. +- **The plugin contracts, the registries, the chain composition, and credential resolution.** `cpt-cf-oagw-feature-plugin-system` owns all of them, per ADR 0002, ADR 0008, and ADR 0009. `cpt-cf-oagw-algo-chain-execute` below invokes the composed sub-chains that feature delivers and owns nothing about how they were built; the one obligation it adds is the sandbox enforcement of `cpt-cf-oagw-nfr-starlark-sandbox`, which that feature explicitly does not perform. +- **Persistence.** DECOMPOSITION §2.5 declares no table for this feature, and `cpt-cf-oagw-db-schema` is fully claimed by `cpt-cf-oagw-feature-control-plane-config` and `cpt-cf-oagw-feature-plugin-system`. The `last_used_at` write of §1.5 goes through the latter's table and creates none. +- **Events, health, and readiness.** No event is published or consumed here, and no readiness signal is produced. Gear readiness belongs to `cpt-cf-oagw-state-gear-foundation-lifecycle`, and the audit and metric records that describe a proxy request belong to `cpt-cf-oagw-feature-observability`. +- **Rollout, rollback, versioning, localization, accessibility, and compliance.** The gear is one configuration item and one release unit (DECOMPOSITION §1.4), so this feature ships no rollout of its own. Every identifier it reads is fixed at `.v1` and the breaking-change policy of `cpt-cf-oagw-interface-proxy-api` is a PRD-level declaration, not a mechanism here. Problem `title` and `detail` are English protocol strings from the foundation's mapping, and there is no actor-facing rendered surface here to make accessible either: the feature emits protocol bodies and headers and no interface an accessibility requirement could apply to. No credential material is persisted, logged, or echoed, and `cpt-cf-oagw-nfr-credential-isolation` governs what the chain does with the material it resolves. + +## 2. Actor Flows (CDSL) + +The flows below follow the proxy request flow of `cpt-cf-oagw-seq-proxy-flow` (DESIGN §3.5) and the path-based routing of `cpt-cf-oagw-adr-request-routing`, which sends `/oagw/v1/proxy/*` to the Data Plane. The endpoint is gear-relative per DECOMPOSITION §1.3(1), and the `{alias}` path segment is a routing key, not an identifier: it resolves through the tenant chain, which is why a caller addresses an upstream it does not own only when an ancestor shares it. + +**Use cases**: `cpt-cf-oagw-usecase-proxy-request` + +`cpt-cf-oagw-usecase-configure-upstream` and `cpt-cf-oagw-usecase-configure-route` are `cpt-cf-oagw-feature-control-plane-config`'s, `cpt-cf-oagw-usecase-sse-streaming` is `cpt-cf-oagw-feature-streaming`'s, and `cpt-cf-oagw-usecase-rate-limit-exceeded` is `cpt-cf-oagw-feature-rate-limiting`'s; none is restated here. The `sse-streaming` use case is reached through the endpoint this feature registers, which is why DECOMPOSITION §3 makes that feature a consumer of this one. + +```mermaid +sequenceDiagram + participant C as Client + participant API as API Handler + participant DP as Data Plane + participant EC as Effective Config + participant Chain as Plugin Chain + participant US as Upstream Service + + C->>API: {METHOD} /oagw/v1/proxy/{alias}/{path_suffix} + API->>DP: execute_proxy(alias, path_suffix, query, req) + DP->>DP: authorize(invoke, ownership) + DP->>EC: resolve effective config (cache miss) + EC-->>DP: ResolvedUpstream, route candidates + DP->>DP: match route, select endpoint, validate + DP->>DP: rate-limit check (admit / 429 / 503) + DP->>Chain: authenticate and inject credentials + DP->>Chain: execute guards + DP->>Chain: transform request + DP->>US: outbound request + US-->>DP: response + DP->>Chain: guards and transforms on the response + DP-->>API: ProxyResponse with X-OAGW-Error-Source + API-->>C: HTTP response +``` + +### Proxy a Request End to End + +- [x] `p1` - **ID**: `cpt-cf-oagw-flow-proxy-request` + +**Actor**: `cpt-cf-oagw-actor-app-developer` + +**Success Scenarios**: + +- A single-endpoint upstream is reached by alias with no `X-OAGW-Target-Host` header at all; the endpoint is selected without load balancing and the header, when supplied anyway, is validated and ignored (ADR 0001 matrix rows 1 and 2). +- A multi-endpoint upstream with an explicit alias routes to the endpoint the `X-OAGW-Target-Host` header names, bypassing load balancing (ADR 0001 matrix row 4). +- A multi-endpoint upstream with an explicit alias and no header is distributed round-robin across its endpoints (ADR 0001 matrix row 3). +- A multi-endpoint upstream whose alias was derived from a common suffix routes to the endpoint the required `X-OAGW-Target-Host` header names (ADR 0001 matrix row 6). +- A request whose method, path suffix, query parameters, and headers all pass validation against the matched route is forwarded with the caller's method, the caller's allowed query parameters, and a header map that carries no routing header and no hop-by-hop header. +- A second request for the same tenant, alias, method, and path prefix is served from the Data Plane L1 cache without a second resolution call (ADR 0006 request flow with caching). +- A response from the upstream is passed through with its body, status, and content type intact, with `headers.response` rules applied, and with `X-OAGW-Error-Source: upstream` (§1.5). +- A request the rate-limit check of `cpt-cf-oagw-feature-rate-limiting` admits is forwarded exactly as an unconfigured one would be, and the check adds no step the caller observes (§1.5). + +**Error Scenarios**: + +- The bearer token is missing or invalid: 401; it lacks `gts.cf.core.oagw.proxy.v1~:invoke`: 403. +- No candidate upstream exists anywhere in the calling tenant's chain, or no route of the resolved upstream matches the method and path: 404 with the `RouteNotFound` variant (`gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1`). +- The effective `enabled` state of the resolved upstream is false: 503 with the `LinkUnavailable` variant (§1.5). +- The rate-limit check of `cpt-cf-oagw-feature-rate-limiting` refuses the request: 429 with the `RateLimitExceeded` variant and the header set that feature's strategy produces, or 503 with the `CircuitBreakerOpen` variant when its breaker is not admitting; the chain is not executed and no outbound request is built (§1.5). +- A multi-endpoint upstream with a common-suffix alias is addressed without the required header: 400 with the `MissingTargetHost` variant (`gts.cf.core.errors.err.v1~cf.oagw.routing.missing_target_host.v1`). +- The `X-OAGW-Target-Host` value is malformed, or matches no configured endpoint: 400 with the `InvalidTargetHost` or `UnknownTargetHost` variant. +- A request body exceeds the 100MB hard limit, has a `Content-Length` that is not a valid integer or does not match the actual size, declares a `Transfer-Encoding` other than `chunked`, combines `Content-Length` with `Transfer-Encoding`, or injects CR or LF into a header value: 400 with the `ValidationError` variant for every case except the size breach, which is 413 with the `PayloadTooLarge` variant. +- The path suffix is supplied to a route whose `path_suffix_mode` is `disabled`, or a query parameter outside the route's `query_allowlist` is supplied: 400 with the `ValidationError` variant. +- A bound plugin fails to execute, or a guard rejects the request: 502 with the `ProtocolError` variant, or 400 in the request phase of a guard rejection (§1.5, ADR 0009). +- The connection cannot be established within the deadline, or the exchange exceeds it: 504 with the `ConnectionTimeout` or `RequestTimeout` variant. +- The upstream answers with a failure status: that status and body pass through unchanged with `X-OAGW-Error-Source: upstream`, and no gateway error is produced. + +**Steps**: + +1. [x] - `p1` - Actor issues the proxy request carrying the method, the alias, an optional path suffix, an optional query, and any headers including `Authorization` and optionally `X-OAGW-Target-Host` - `inst-px-issue` +2. [x] - `p1` - API: `{METHOD} /oagw/v1/proxy/{alias}[/{path_suffix}][?{query}]` — the platform middleware authenticates the bearer token and resolves the calling tenant and subject, and the handler classifies the request to the Data Plane by path - `inst-px-api` +3. [x] - `p1` - `cpt-cf-oagw-algo-alias-normalize` normalizes the alias, so a proxy resolution can never disagree with a stored alias about shape, case, or a trailing dot - `inst-px-normalize` +4. [x] - `p1` - `cpt-cf-oagw-flow-proxy-authorize` enforces `gts.cf.core.oagw.proxy.v1~:invoke` and the ownership or ancestor-sharing condition before any resolution runs - `inst-px-authorize` +5. [x] - `p1` - `cpt-cf-oagw-algo-resolve-consume` produces the `ResolvedUpstream` and the route candidate set, from the Data Plane L1 cache on a hit and through `cpt-cf-oagw-flow-resolve-effective-config` of `cpt-cf-oagw-feature-hierarchical-config` on a miss - `inst-px-resolve` +6. [x] - `p1` - **IF** the candidate set is empty, or the effective `enabled` state is false, or the resolved upstream's protocol is the gRPC value - `inst-px-resolve-if` + 1. [x] - `p1` - **RETURN** 404 with the `RouteNotFound` variant for an empty candidate set or an unmatched route, or 503 with the `LinkUnavailable` variant for a disabled upstream (§1.5); no outbound request is built - `inst-px-resolve-return` +7. [x] - `p1` - **ELSE** - `inst-px-resolve-else` + 1. [x] - `p1` - `cpt-cf-oagw-algo-route-match` selects the `MatchedRoute` from the candidate set by method allowlist, longest path prefix, priority, and `path_suffix_mode` - `inst-px-match` +8. [x] - `p1` - **IF** no candidate route matches - `inst-px-match-if` + 1. [x] - `p1` - **RETURN** 404 with the `RouteNotFound` variant; the answer names the upstream that resolved, never a candidate that did not - `inst-px-match-return` +9. [x] - `p1` - **ELSE** - `inst-px-match-else` + 1. [x] - `p1` - `cpt-cf-oagw-algo-endpoint-select` produces the `SelectedEndpoint` from the behaviour matrix of ADR 0001, reading and then stripping `X-OAGW-Target-Host` - `inst-px-endpoint` +10. [x] - `p1` - **IF** endpoint selection fails - `inst-px-endpoint-if` + 1. [x] - `p1` - **RETURN** 400 with the `MissingTargetHost`, `InvalidTargetHost`, or `UnknownTargetHost` variant named in `cpt-cf-oagw-algo-endpoint-select`; no upstream call is attempted - `inst-px-endpoint-return` +11. [x] - `p1` - **ELSE** - `inst-px-endpoint-else` + 1. [x] - `p1` - `cpt-cf-oagw-algo-inbound-validate` validates the method, the path suffix, the query parameters, and the headers against the matched route - `inst-px-inbound` + 2. [x] - `p1` - `cpt-cf-oagw-algo-body-validate` validates the body before any of it is buffered - `inst-px-body` + 3. [x] - `p1` - **IF** either validation fails - `inst-px-validate-if` + 1. [x] - `p1` - **RETURN** 400 with the `ValidationError` variant, or 413 with the `PayloadTooLarge` variant for the size breach; nothing is forwarded and no buffer of the body is retained - `inst-px-validate-return` + 4. [x] - `p1` - **ELSE** - `inst-px-validate-else` + 1. [x] - `p1` - `cpt-cf-oagw-flow-rate-limit-check` of `cpt-cf-oagw-feature-rate-limiting` answers admit, an over-limit answer, or a breaker answer for the resolved upstream, the matched route, the calling tenant and subject, the peer address, and the request's `cost` (§1.5) - `inst-px-ratelimit` + 2. [x] - `p1` - **IF** that answer is not an admission - `inst-px-ratelimit-if` + 1. [x] - `p1` - **RETURN** the 429 or 503 the check produced, with its error-source tag; the chain is not executed and no outbound request is built - `inst-px-ratelimit-return` + 3. [x] - `p1` - `cpt-cf-oagw-algo-chain-execute` runs the composed chain in the order Auth, Guards on the request, Transform on the request, injecting the credential material into the outbound request - `inst-px-chain` + 4. [x] - `p1` - **IF** the chain rejects the request or fails to execute - `inst-px-chain-if` + 1. [x] - `p1` - **RETURN** the gateway error `cpt-cf-oagw-algo-chain-execute` names, mapped through `cpt-cf-oagw-algo-error-mapping` of `cpt-cf-oagw-feature-gear-foundation` - `inst-px-chain-return` + 5. [x] - `p1` - **ELSE** - `inst-px-chain-else` + 1. [x] - `p1` - `cpt-cf-oagw-algo-header-transform` builds the `OutboundRequest` header map and `cpt-cf-oagw-algo-outbound-forward` sends it over the shared client - `inst-px-forward` + 2. [x] - `p1` - `cpt-cf-oagw-algo-response-classify` tags the answer, applies `headers.response`, and produces the `ProxyResponse` - `inst-px-classify` +12. [x] - `p1` - **RETURN** the `ProxyResponse` with `X-OAGW-Error-Source` set, and record the plugin use off the request's latency budget (§1.5) - `inst-px-return` + +### Authorize a Proxy Request + +- [x] `p1` - **ID**: `cpt-cf-oagw-flow-proxy-authorize` + +**Actor**: `cpt-cf-oagw-actor-app-developer` + +This flow runs before any resolution, so it answers from the token and the alias alone and leaks nothing about configuration the caller cannot see. It is the CDSL statement of the three authorization checks DESIGN §3.2 Permissions and Access Control states for the proxy API. + +**Success Scenarios**: + +- A token carrying `gts.cf.core.oagw.proxy.v1~:invoke` for a tenant that owns the alias is authorized, and the resolved upstream is its own. +- A token carrying the permission for a tenant whose ancestor owns the alias is authorized, and the resolved upstream is the ancestor's; this is the "shared by ancestor" case, which the chain walk satisfies structurally rather than by a separate grant. +- A token without the permission is answered 403 before any upstream lookup, and the answer is identical whether or not the alias resolves anywhere, so the answer discloses nothing. + +**Error Scenarios**: + +- The token is missing or invalid: 401, answered by the platform middleware. +- The token lacks `gts.cf.core.oagw.proxy.v1~:invoke`: 403. +- The calling tenant resolves no candidate for the alias anywhere in its chain: the resolution step answers 404, not 403, because the alias is not a resource this flow addresses and a not-found answer is what `cpt-cf-oagw-feature-hierarchical-config`'s empty-candidate outcome calls for. +- The token resolves to the nil tenant: 403 from the platform authorization layer, which `config/e2e-local.yaml` records for that token. + +**Steps**: + +1. [x] - `p1` - Read the calling tenant and subject from the resolved SecurityContext; a request with neither fails closed rather than proceeding - `inst-authz-context` +2. [x] - `p1` - **IF** the platform middleware has not already enforced the permission - `inst-authz-delegate-if` + 1. [x] - `p1` - Enforce `gts.cf.core.oagw.proxy.v1~:invoke` in the handler and answer 403 on failure, before any resolution or cache read - `inst-authz-permission` +3. [x] - `p1` - **ELSE** - `inst-authz-delegate-else` + 1. [x] - `p1` - Continue with the platform's decision, which is authoritative - `inst-authz-delegate-continue` +4. [x] - `p1` - Treat the ownership condition as satisfied by the chain resolution itself: the candidate set `cpt-cf-oagw-algo-tenant-chain-walk` produces contains only rows of the calling tenant and its ancestors, so a resolved upstream is always the caller's own or an ancestor's, and there is no third case to check - `inst-authz-ownership` +5. [x] - `p1` - **IF** the resolution later reports an empty candidate set - `inst-authz-empty-if` + 1. [x] - `p1` - Answer 404 with the `RouteNotFound` variant through the resolution step, never 403, so an unauthorized caller learns nothing about which aliases exist outside its chain - `inst-authz-empty-return` +6. [x] - `p1` - **RETURN** the authorized context carrying the tenant, the subject, and the permission verdict, for the resolution step to consume - `inst-authz-return` + +## 3. Processes / Business Logic (CDSL) + +The routines below are called by the flows in §2 and by each other in the order the proxy flow states them. Two of them leave the process: `cpt-cf-oagw-algo-outbound-forward` opens the outbound connection through the shared client, and `cpt-cf-oagw-algo-chain-execute` reaches the credential store through `cpt-cf-oagw-algo-credential-resolution` of `cpt-cf-oagw-feature-plugin-system`. Every failure any of them returns is a `DomainError` from the foundation catalogue, mapped by `cpt-cf-oagw-algo-error-mapping` of that feature into an RFC 9457 body with `X-OAGW-Error-Source: gateway`; a storage failure has no catalogue row and is answered with the platform's RFC 9457 500 problem shape carrying `X-OAGW-Error-Source: gateway`, logged with the correlation identifier, and failed without a forwarded request. + +### Consume the Effective Configuration + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-resolve-consume` + +**Input**: the normalized alias, the method, the request path, the calling tenant and subject, and the Data Plane L1 cache. + +**Output**: a `ResolvedUpstream` carrying the route candidate set, or the not-found, disabled, or failed outcome. + +The order of consumption is upstream, then route, then tenant, which is the layer order DESIGN §2.1 and PRD §5.5 both state as `Upstream (base) < Route < Tenant`: the tenant-level values the hierarchy walk contributed are applied last and therefore prevail. The per-field merge strategies that produce the result are `cpt-cf-oagw-algo-field-family-merge` of `cpt-cf-oagw-feature-hierarchical-config` and are not restated here; what this routine owns is the order the layers are consumed in, the cache that avoids recomputing them, and the decisions the consumption forces. + +**Steps**: + +1. [x] - `p1` - Build the cache key for the resolved configuration in the `upstream:{tenant_id}:{alias}` shape of ADR 0005 and look it up in `cpt-cf-oagw-algo-dp-cache` - `inst-resc-key` +2. [x] - `p1` - **IF** the lookup hits - `inst-resc-hit-if` + 1. [x] - `p1` - Use the cached `ResolvedUpstream` and route candidate set without a resolution call; ADR 0006's flow with caching is the reference for this branch - `inst-resc-hit` +3. [x] - `p1` - **ELSE** - `inst-resc-miss-else` + 1. [x] - `p1` - Call `cpt-cf-oagw-flow-resolve-effective-config` of `cpt-cf-oagw-feature-hierarchical-config`, which walks the tenant chain with shadowing, computes the effective `enabled` state, and returns the effective upstream and route configurations with their per-family sharing modes and ownership - `inst-resc-resolve` + 2. [x] - `p1` - Store the result in `cpt-cf-oagw-algo-dp-cache` under the key of step 1, together with the `route:{upstream_id}:{method}:{path_prefix}` key the matched route is later read under - `inst-resc-store` +4. [x] - `p1` - Consume the result in the layer order upstream, then route, then tenant, carrying the ancestor `enforce` families the resolution marks onto the `ResolvedUpstream` and the `MatchedRoute` for the policy features that consume them - `inst-resc-order` +5. [x] - `p1` - **IF** the effective `enabled` state is false - `inst-resc-disabled-if` + 1. [x] - `p1` - **RETURN** the disabled outcome, which the caller answers 503 with the `LinkUnavailable` variant (§1.5); a disabled upstream is never dialed, whatever the chain contributed - `inst-resc-disabled-return` +6. [x] - `p1` - **ELSE IF** the resolved upstream's protocol is the gRPC value - `inst-resc-grpc-if` + 1. [x] - `p1` - **RETURN** the not-found outcome, which the caller answers 404 with the `RouteNotFound` variant (§1.5); no HTTP match key is evaluated for such an upstream - `inst-resc-grpc-return` +7. [x] - `p1` - **ELSE** - `inst-resc-ok-else` + 1. [x] - `p1` - Produce the `ResolvedUpstream` with its identifier, alias, alias derivation kind, endpoint set, protocol, effective `enabled` state, header rules, and per-family sharing modes, and the ordered route candidate set - `inst-resc-ok` +8. [x] - `p1` - **IF** the resolution failed closed — an unavailable, unordered, or cyclic chain, a storage failure, or a deadline breach - `inst-resc-fail-if` + 1. [x] - `p1` - **RETURN** failure with no partial configuration; the caller answers the platform 500 problem shape and never forwards a request resolved against an incomplete result - `inst-resc-fail-return` +9. [x] - `p1` - **RETURN** the `ResolvedUpstream` and its route candidate set - `inst-resc-return` + +The alias derivation kind is carried because the endpoint-selection matrix keys on it: `cpt-cf-oagw-algo-alias-derive` of `cpt-cf-oagw-feature-control-plane-config` records at write time whether the alias was derived from a common suffix, and that recorded fact, not a re-derivation at proxy time, decides whether `X-OAGW-Target-Host` is required. + +### Match the Route + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-route-match` + +**Input**: the ordered route candidate set from `cpt-cf-oagw-algo-resolve-consume`, the request method, the request path, and the path suffix as supplied. + +**Output**: a `MatchedRoute` carrying the selected route, the outbound path, and the route-layer configuration, or the no-match outcome. + +**Steps**: + +1. [x] - `p1` - Filter the candidate set to the enabled routes whose `match.http.methods` allowlist contains the request method; a route that does not declare the method is never a candidate, whatever its path - `inst-match-method` +2. [x] - `p1` - **IF** no candidate remains - `inst-match-method-if` + 1. [x] - `p1` - **RETURN** the no-match outcome; the caller answers 404 with the `RouteNotFound` variant - `inst-match-method-return` +3. [x] - `p1` - **ELSE** - `inst-match-method-else` + 1. [x] - `p1` - Filter the remaining candidates to those whose `match.http.path` is a prefix of the request path, and select the longest such prefix - `inst-match-prefix` +4. [x] - `p1` - **IF** more than one candidate shares the longest prefix - `inst-match-tie-if` + 1. [x] - `p1` - Select the one with the smallest `priority` value, per the ascending precedence order of §1.5; `cpt-cf-oagw-algo-match-uniqueness` of `cpt-cf-oagw-feature-control-plane-config` makes the comparison total by forbidding two enabled routes of one upstream from sharing `(path, priority, method)` - `inst-match-tie` +5. [x] - `p1` - **ELSE IF** no candidate has a matching prefix - `inst-match-noprefix-if` + 1. [x] - `p1` - **RETURN** the no-match outcome - `inst-match-noprefix-return` +6. [x] - `p1` - Read the selected route's `path_suffix_mode`, whose shipped-schema default is `append` - `inst-match-suffix-read` +7. [x] - `p1` - **IF** the mode is `disabled` and a path suffix was supplied - `inst-match-suffix-disabled-if` + 1. [x] - `p1` - **RETURN** the rejection outcome, which the caller answers 400 with the `ValidationError` variant, per the path-suffix row of the DESIGN §3.2 Guard Rules table - `inst-match-suffix-disabled-return` +8. [x] - `p1` - **ELSE IF** the mode is `append` and a path suffix was supplied - `inst-match-suffix-append-else` + 1. [x] - `p1` - Build the outbound path as the route's `match.http.path` with the suffix appended, per the Transformation Rules row of DESIGN §3.2 - `inst-match-suffix-append` +9. [x] - `p1` - **ELSE** - `inst-match-suffix-none-else` + 1. [x] - `p1` - Build the outbound path as the route's `match.http.path` alone - `inst-match-suffix-none` +10. [x] - `p1` - **RETURN** the `MatchedRoute` with the selected route's identifier, its priority, its effective match keys, the outbound path, and the route-layer configuration - `inst-match-return` + +The gRPC match keys of `schemas/route.v1.schema.json` are never read here: this routine is reached only for an upstream whose protocol is HTTP, and the gRPC case is answered before matching by `cpt-cf-oagw-algo-resolve-consume` (§1.5). + +### Select the Target Endpoint + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-endpoint-select` + +**Input**: the `ResolvedUpstream` with its endpoint set and alias derivation kind, and the inbound `X-OAGW-Target-Host` header value when one was supplied. + +**Output**: a `SelectedEndpoint`, or the reason no endpoint could be selected. + +The behaviour matrix, with the source of each row. ADR 0001's Appendix A is the authority for the matrix, and DESIGN §3.3 Error Response Format is the authority for the three 400 variants it produces: + +| Scenario | Endpoints | Alias kind | Header present | Behaviour | Source | +|---|---|---|---|---|---| +| Single endpoint | 1 | any | no | Route to the endpoint; no load balancing | ADR 0001 matrix row 1 | +| Single endpoint | 1 | any | yes | Validate the value, then route to the endpoint; the header is optional but validated when present | ADR 0001 matrix row 2 | +| Multi-endpoint | 2+ | explicit, no common suffix | no | Round-robin across the endpoints | ADR 0001 matrix row 3 | +| Multi-endpoint | 2+ | explicit, no common suffix | yes | Route to the named endpoint, bypassing load balancing | ADR 0001 matrix row 4 | +| Multi-endpoint | 2+ | common suffix | no | 400, `MissingTargetHost` | ADR 0001 matrix row 5, DESIGN §3.3 | +| Multi-endpoint | 2+ | common suffix | yes | Route to the named endpoint | ADR 0001 matrix row 6 | + +**Steps**: + +1. [x] - `p1` - **IF** the header was supplied - `inst-ep-present-if` + 1. [x] - `p1` - Validate the value as a hostname or an IP address with no port, no path, and no special character; DESIGN §3.3's `InvalidTargetHost` row states both the format rule and the variant, and ADR 0007's Appendix A illustrates the answer - `inst-ep-format` + 2. [x] - `p1` - **IF** the value is malformed - `inst-ep-format-if` + 1. [x] - `p1` - **RETURN** the failure the caller answers 400 with the `InvalidTargetHost` variant (`gts.cf.core.errors.err.v1~cf.oagw.routing.invalid_target_host.v1`) - `inst-ep-format-return` + 3. [x] - `p1` - **ELSE** - `inst-ep-format-else` + 1. [x] - `p1` - Match the value case-insensitively against the endpoint hosts of the resolved upstream - `inst-ep-match` + 4. [x] - `p1` - **IF** no endpoint host matches - `inst-ep-unknown-if` + 1. [x] - `p1` - **RETURN** the failure the caller answers 400 with the `UnknownTargetHost` variant (`gts.cf.core.errors.err.v1~cf.oagw.routing.unknown_target_host.v1`), naming the value and the configured hosts - `inst-ep-unknown-return` + 5. [x] - `p1` - **ELSE** - `inst-ep-unknown-else` + 1. [x] - `p1` - **RETURN** the matching endpoint as the `SelectedEndpoint`, recorded as chosen by the header - `inst-ep-unknown-else-return` +2. [x] - `p1` - **ELSE** - `inst-ep-absent-else` + 1. [x] - `p1` - Continue with the endpoint count and the alias derivation kind - `inst-ep-absent` +3. [x] - `p1` - **IF** the endpoint set holds exactly one endpoint - `inst-ep-single-if` + 1. [x] - `p1` - **RETURN** that endpoint as the `SelectedEndpoint`, recorded as the only candidate; no load balancing runs - `inst-ep-single` +4. [x] - `p1` - **ELSE IF** the alias derivation kind is the common-suffix kind recorded at write time by `cpt-cf-oagw-algo-alias-derive` of `cpt-cf-oagw-feature-control-plane-config` - `inst-ep-suffix-if` + 1. [x] - `p1` - **RETURN** the failure the caller answers 400 with the `MissingTargetHost` variant (`gts.cf.core.errors.err.v1~cf.oagw.routing.missing_target_host.v1`), naming the configured hosts as the valid values, per the DESIGN §3.3 row and the ADR 0007 example of the same answer - `inst-ep-suffix-return` +5. [x] - `p1` - **ELSE** - `inst-ep-rr-else` + 1. [x] - `p1` - Select the next endpoint of the pool from the per-upstream round-robin counter, which is per-instance state of the kind ADR 0006 assigns to the Data Plane, and advance it - `inst-ep-rr` + 2. [x] - `p1` - **RETURN** that endpoint as the `SelectedEndpoint`, recorded as chosen by load balancing - `inst-ep-rr-return` + +Round-robin is the only load-balancing behaviour this feature delivers. DESIGN §3.2 Alias Resolution states "Requests are distributed across endpoints (round-robin)" and adds no weighting, no health-based exclusion, and no stickiness, so none is implemented here; upstream health is a reported state in `cpt-cf-oagw-feature-observability` and a breaker state in `cpt-cf-oagw-feature-rate-limiting`. All endpoints of a pool share the same `protocol`, `scheme`, and `port` by the write-time endpoint-pool homogeneity rule `cpt-cf-oagw-algo-request-validate` of `cpt-cf-oagw-feature-control-plane-config` enforces from DESIGN §3.2 and PRD §5.5, so the selection never has to reconcile a mixed pool. + +### Validate the Inbound Request + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-inbound-validate` + +**Input**: the `ProxyContext` with its method, path suffix, query parameters, and header map, and the `MatchedRoute` with its effective match keys. + +**Output**: the validated context, or the rejection with the property names that failed. + +This routine is the request-validation half of `cpt-cf-oagw-nfr-input-validation`, which requires path, query parameters, headers, and body size to be validated for all inbound requests and invalid requests to be rejected with 400. The body-size half is `cpt-cf-oagw-algo-body-validate`'s. The SSRF clause of the same requirement is met here by the route-scoped path and query validation and by the header stripping of `cpt-cf-oagw-algo-header-transform`, and the DNS and IP-pinning clauses are dispositioned in §1.6. + +**Steps**: + +1. [x] - `p1` - Confirm the method is in the matched route's allowlist; `cpt-cf-oagw-algo-route-match` already filtered on it, so a failure here is a defect, not a caller error - `inst-inv-method` +2. [x] - `p1` - Validate the outbound path against the matched route's `match.http.path`, honouring the `path_suffix_mode` decision `cpt-cf-oagw-algo-route-match` already made - `inst-inv-path` +3. [x] - `p1` - Validate the query parameters against the route's `match.http.query_allowlist`, whose shipped-schema description states "If empty, allow none"; a route that declares no allowlist therefore admits no query parameter at all, and one that declares names admits only those names - `inst-inv-query` +4. [x] - `p1` - **IF** any query parameter is not allowed - `inst-inv-query-if` + 1. [x] - `p1` - **RETURN** the rejection naming the offending parameter; the caller answers 400 with the `ValidationError` variant, per the query-params row of the DESIGN §3.2 Guard Rules table - `inst-inv-query-return` +5. [x] - `p1` - **ELSE** - `inst-inv-query-else` + 1. [x] - `p1` - Validate the header names and values: reject any value carrying CR or LF, reject a header the matched route's rules forbid, and pass the rest to `cpt-cf-oagw-algo-header-transform` - `inst-inv-headers` +6. [x] - `p1` - Validate the well-known headers — `Content-Length` and `Content-Type` among them — as set or adjusted values, per DESIGN §3.2's rule that "invalid headers should result in `400 Bad Request`" - `inst-inv-wellknown` +7. [x] - `p1` - **IF** any check failed - `inst-inv-fail-if` + 1. [x] - `p1` - **RETURN** one rejection naming every failing property, so a caller is not made to retry once per defect - `inst-inv-fail-return` +8. [x] - `p1` - **ELSE** - `inst-inv-fail-else` + 1. [x] - `p1` - **RETURN** the validated context - `inst-inv-return` + +The two CORS rows of the DESIGN §3.2 Guard Rules table are deliberately absent, per §1.6. A preflight request never reaches this routine at all, because it is answered at handler level without resolution, and an actual cross-origin request is enforced by `cpt-cf-oagw-feature-cors` after resolution and before forwarding. + +### Validate the Body + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-body-validate` + +**Input**: the request body as presented, with its `Content-Length` and `Transfer-Encoding` headers and its framing. + +**Output**: the validated body, or the rejection with the check that failed. + +The checks and their answers, per DESIGN §3.2 Body Validation Rules and `cpt-cf-oagw-constraint-body-limit`. None of them requires configuration: + +| Check | Rule | Answer | +|---|---|---| +| `Content-Length` | a valid integer when present, and equal to the actual body size | 400, `ValidationError` | +| Maximum size | the 100MB hard limit of `cpt-cf-oagw-constraint-body-limit`, read as 100,000,000 bytes per §1.5, rejected before any of the body is buffered | 413, `PayloadTooLarge` | +| `Transfer-Encoding` | `chunked` only; every other encoding is unsupported | 400, `ValidationError` | +| Conflicting framing | `Content-Length` together with `Transfer-Encoding` on one request | 400, `ValidationError` | +| Header injection | CR or LF inside any header value, including these two | 400, `ValidationError` | + +The last two rows are not in DESIGN §3.2's table; DECOMPOSITION §2.5 adds them to this feature's scope, and they are recorded here as the sourced statement of that scope. + +**Steps**: + +1. [x] - `p1` - Reject before buffering: evaluate the declared size from the framing headers against the hard limit before any body byte is read into a buffer, which is what `cpt-cf-oagw-constraint-body-limit` requires and what keeps the check off the memory of the process - `inst-body-limit-first` +2. [x] - `p1` - **IF** the declared or actual size exceeds the limit - `inst-body-limit-if` + 1. [x] - `p1` - **RETURN** the rejection the caller answers 413 with the `PayloadTooLarge` variant (`gts.cf.core.errors.err.v1~cf.oagw.payload.too_large.v1`); no buffer of the body is retained - `inst-body-limit-return` +3. [x] - `p1` - **ELSE** - `inst-body-limit-else` + 1. [x] - `p1` - Check the framing: `Content-Length` present and a valid integer, `Transfer-Encoding` present and equal to `chunked`, and never both on one request - `inst-body-framing` +4. [x] - `p1` - **IF** any framing check failed, or any header value carries CR or LF - `inst-body-framing-if` + 1. [x] - `p1` - **RETURN** the rejection the caller answers 400 with the `ValidationError` variant (`gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1`) - `inst-body-framing-return` +5. [x] - `p1` - **ELSE** - `inst-body-framing-else` + 1. [x] - `p1` - Buffer the body up to the limit and compare the buffered size with the declared `Content-Length` when one was declared - `inst-body-size` +6. [x] - `p1` - **IF** the sizes differ - `inst-body-size-if` + 1. [x] - `p1` - **RETURN** the same 400 rejection, per the `Content-Length` row's "must match actual size" - `inst-body-size-return` +7. [x] - `p1` - **ELSE** - `inst-body-size-else` + 1. [x] - `p1` - **RETURN** the validated body for the transformation and forwarding steps - `inst-body-return` + +Additional validation beyond these checks — a JSON Schema over the body, a content-type check, a custom rule — is guard-plugin work, not this routine's: DESIGN §3.2 Body Validation Rules closes with exactly that statement, and ADR 0009 is the one guard this run registers for it. + +### Transform the Headers + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-header-transform` + +**Input**: the validated `ProxyContext` header map, the `ResolvedUpstream`'s `headers` rules, the `SelectedEndpoint`, and the direction being transformed. + +**Output**: the transformed header map for the `OutboundRequest`, or for the `ProxyResponse`. + +The three categories of DESIGN §3.2 Headers Transformation, and what this routine does with each: + +| Category | Members | Disposition | +|---|---|---| +| Routing headers | `X-OAGW-Target-Host` | Read during endpoint selection, then stripped; never forwarded | +| Hop-by-hop headers | `Connection`, `Keep-Alive`, `Proxy-Authenticate`, `Proxy-Authorization`, `TE`, `Trailer`, `Transfer-Encoding`, `Upgrade` | Stripped, per the eight members PRD §5.2 names for `cpt-cf-oagw-fr-header-transform` and the same list DESIGN §3.2 tabulates | +| Replaced | `Host` on HTTP/1.1, `:authority` on HTTP/2 | Replaced with the selected endpoint's host or authority, per DESIGN §3.2's cross-protocol statement | +| Passthrough | every remaining header except the caller's `Authorization` | Forwarded according to the `headers.request.passthrough` mode of the resolved upstream; `Authorization` is never a passthrough candidate in any mode (step 3) | +| Response rules | `headers.response` `set`, `add`, `remove` | Applied to the upstream response before it is returned | + +**Steps**: + +1. [x] - `p1` - Strip the routing header `X-OAGW-Target-Host`, which `cpt-cf-oagw-algo-endpoint-select` has already consumed - `inst-hdr-routing` +2. [x] - `p1` - Strip the eight hop-by-hop headers, on the request direction; the upgrade-handshake exception that suspends two of them belongs to `cpt-cf-oagw-feature-streaming` and is not applied here (§1.6) - `inst-hdr-hop` +3. [x] - `p1` - Apply the `headers.request.passthrough` mode of the resolved upstream: `none`, the shipped-schema default, forwards no inbound header; `allowlist` forwards exactly the names in `passthrough_allowlist`; `all` forwards the remainder. In all three modes the caller's `Authorization` value is excluded from the passthrough set: the gateway consumes it for its own trust domain — the platform middleware authenticates it before this handler runs, and no step of §3 reads it again — and it is therefore never a passthrough candidate - `inst-hdr-passthrough` +4. [x] - `p1` - Apply the `headers.request` `set`, `add`, and `remove` rules of the resolved upstream, in that order, so a `set` overwrites and an `add` appends - `inst-hdr-rules` +5. [x] - `p1` - Replace `Host` with the selected endpoint's host, or `:authority` with its authority when the negotiated protocol version is HTTP/2; the two are the same replacement at the two protocol layers, and neither ever replaces the routing function of `X-OAGW-Target-Host` - `inst-hdr-host` +6. [x] - `p1` - **FOR EACH** header the plugin chain added or mutated during the request phase - `inst-hdr-plugin-loop` + 1. [x] - `p1` - Carry it into the map after the rules above have run, so a plugin sees the transformed request and not the inbound one - `inst-hdr-plugin` +7. [x] - `p1` - On the response direction, apply `headers.response` `set`, `add`, and `remove` to the upstream response, then carry the response-phase plugin mutations (§1.5) - `inst-hdr-response` +8. [x] - `p1` - **IF** the resulting map carries a value with CR or LF, or a well-known header that is invalid for the direction - `inst-hdr-invalid-if` + 1. [x] - `p1` - **RETURN** the rejection the caller answers 400 with the `ValidationError` variant, per DESIGN §3.2's rule for invalid well-known headers - `inst-hdr-invalid-return` +9. [x] - `p1` - **ELSE** - `inst-hdr-invalid-else` + 1. [x] - `p1` - **RETURN** the transformed map for the direction - `inst-hdr-return` + +The transform is a pure function of the resolved configuration and the direction, which is what lets `cpt-cf-oagw-algo-chain-execute` hand a plugin a context that already reflects it, and what keeps the plugin's view of the request identical to what the upstream receives. The caller's `Authorization` value belongs to the gateway's own trust domain and reaches the upstream only through a deliberate act — the auth plugin `cpt-cf-oagw-feature-plugin-system` composes into the chain, or a `headers.request` `set` rule the operator configures, both of which write a credential of their own rather than forward the caller's — never through a passthrough mode, so no mode of the resolved configuration can leak the token the caller authenticated with. + +### Execute the Plugin Chain + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-chain-execute` + +**Input**: the composed per-phase sub-chains and the auth plugin identity that `cpt-cf-oagw-algo-chain-compose` of `cpt-cf-oagw-feature-plugin-system` produced from the effective configuration, the `ProxyContext`, the `SelectedEndpoint`, and the sandbox limits. + +**Output**: the authenticated and transformed `OutboundRequest` inputs, or the gateway error the caller answers with. + +The phase order is Auth, then Guards on the request, then Transform on the request, then the upstream call, then Guards and Transform on the response, and Transform on the error when the call fails. The request leg of that order and the transform-on-response/error leg are the ones DESIGN §3.2 Plugin System states and ADR 0002's execution order repeats, and neither of the two order statements carries a guard on the response; the guard phase on the response is attributed instead to the `guard_response` contract ADR 0002 declares and to the response-phase decision flow of ADR 0009, which exercises it. Within a phase, upstream plugins execute before route plugins. This routine invokes that order and owns two things the composition does not: the sandbox limits of `cpt-cf-oagw-nfr-starlark-sandbox`, which `cpt-cf-oagw-feature-plugin-system` exposes and does not enforce, and the `last_used_at` record of §1.5. + +**Steps**: + +1. [x] - `p1` - **IF** any composed binding resolved to no implementation, or a custom plugin's sandbox limits cannot be enforced in this deployment - `inst-chain-unresolvable-if` + 1. [x] - `p1` - **RETURN** the failure the caller answers 503 with the `PluginNotFound` variant (`gts.cf.core.errors.err.v1~cf.oagw.plugin.not_found.v1`); the composition never silently drops a binding, and this routine never runs untrusted code without its limits (§1.5) - `inst-chain-unresolvable-return` +2. [x] - `p1` - **ELSE** - `inst-chain-unresolvable-else` + 1. [x] - `p1` - Run the auth phase: resolve the credential material through `cpt-cf-oagw-algo-credential-resolution` and the cached token through `cpt-cf-oagw-algo-token-cache` of `cpt-cf-oagw-feature-plugin-system`, and inject it into the outbound request - `inst-chain-auth` + 2. [x] - `p1` - **IF** the credential store refuses the reference, or the upstream rejects the credential - `inst-chain-auth-if` + 1. [x] - `p1` - **RETURN** the failure the caller answers 401 with the `AuthenticationFailed` variant (`gts.cf.core.errors.err.v1~cf.oagw.auth.failed.v1`), or 500 with the `SecretNotFound` variant when the store answers nothing; both mappings are `cpt-cf-oagw-algo-credential-resolution`'s - `inst-chain-auth-return` +3. [x] - `p1` - Run the guard phase on the request, through `cpt-cf-oagw-algo-starlark-sandbox` for a custom guard and directly for a built-in one - `inst-chain-guards` +4. [x] - `p1` - **IF** a guard rejects - `inst-chain-guards-if` + 1. [x] - `p1` - **RETURN** the rejection the caller answers 400 with the `ValidationError` variant, per the phase-specific status ADR 0009 states for the request phase and the mapping `cpt-cf-oagw-feature-plugin-system` records in its own §1.5 - `inst-chain-guards-return` +5. [x] - `p1` - **ELSE** - `inst-chain-guards-else` + 1. [x] - `p1` - Run the transform phase on the request, under the same sandbox discipline, and hand the result to `cpt-cf-oagw-algo-header-transform` and the body forwarder - `inst-chain-transform` +6. [x] - `p1` - After the upstream call returns, run the guard phase on the response, then the transform phase on the response; on a failed call, run the transform phase on the error instead - `inst-chain-response` +7. [x] - `p1` - **IF** a response-phase guard rejects - `inst-chain-response-if` + 1. [x] - `p1` - **RETURN** the rejection the caller answers 502 with the `ProtocolError` variant, per the phase-specific status ADR 0009 states for the response phase (§1.5) - `inst-chain-response-return` +8. [x] - `p1` - **ELSE IF** a custom plugin breached a sandbox limit, exceeded its per-invocation timeout, or raised an error - `inst-chain-sandbox-if` + 1. [x] - `p1` - **RETURN** the failure the caller answers 502 with the `ProtocolError` variant, carrying `X-OAGW-Error-Source: gateway`; no partial mutation the plugin performed survives the answer (§1.5) - `inst-chain-sandbox-return` +9. [x] - `p1` - **ELSE** - `inst-chain-response-else` + 1. [x] - `p1` - **RETURN** the authenticated and transformed request and response inputs - `inst-chain-response-else-return` +10. [x] - `p1` - Record the use of every custom plugin that executed by writing `last_used_at` after the response is produced, coalesced per plugin, outside the latency budget of `cpt-cf-oagw-nfr-low-latency`, and feeding no decision (§1.5) - `inst-chain-lastused` + +Credential material exists only inside the plugin that requested it and for the duration of the request that needed it, per `cpt-cf-oagw-principle-cred-isolation` and `cpt-cf-oagw-nfr-credential-isolation`; it is never logged, never placed in a problem `detail`, and never carried on the `ProxyResponse`. + +### Enforce the Starlark Sandbox + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-starlark-sandbox` + +**Input**: the Starlark source `cpt-cf-oagw-feature-plugin-system` stored verbatim, the plugin configuration, the phase being executed, and the per-invocation limits. + +**Output**: the plugin's verdict or mutation for that phase, or the sandbox failure. + +The four prohibitions and the two limits, from `cpt-cf-oagw-nfr-starlark-sandbox`, whose threshold states "Zero sandbox escapes; plugin execution timeout ≤ 100ms; memory ≤ 10MB per invocation": + +| Constraint | Value | Source | +|---|---|---| +| Network I/O | prohibited | PRD §6.1, DESIGN §3.2 Plugin System | +| File I/O | prohibited | PRD §6.1, DESIGN §3.2 Plugin System | +| Imports | prohibited | PRD §6.1, DESIGN §3.2 Plugin System | +| Per-invocation timeout | at most 100 ms | PRD §6.1 threshold | +| Per-invocation memory | at most 10 MB | PRD §6.1 threshold | + +DESIGN §3.2 Plugin System states the same four prohibitions and the same two limit families for custom plugins, and ADR 0002 defers WASM as the future alternative for untrusted code, which is why the Starlark sandbox is the enforcement point this run has. + +**Steps**: + +1. [x] - `p1` - Confirm before execution that the interpreter instance about to run the source has no network capability, no file capability, and no import capability; a capability that cannot be removed is a limit that cannot be enforced, and the plugin is not run (§1.5) - `inst-sandbox-capabilities` +2. [x] - `p1` - Apply the 100 ms per-invocation timeout and the 10 MB per-invocation memory limit to the invocation, and no other plugin's limits to it, so one plugin's breach never consumes another's budget - `inst-sandbox-limits` +3. [x] - `p1` - **TRY** the invocation - `inst-sandbox-try` + 1. [x] - `p1` - Run the phase's implementation with the phase context and the plugin configuration - `inst-sandbox-run` +4. [x] - `p1` - **CATCH** a timeout, a memory breach, or a raised error - `inst-sandbox-catch` + 1. [x] - `p1` - Terminate the invocation, discard every partial mutation it performed, and report the sandbox failure to `cpt-cf-oagw-algo-chain-execute`, which answers it through the `ProtocolError` variant (§1.5) - `inst-sandbox-catch-handle` +5. [x] - `p1` - **ELSE** - `inst-sandbox-else` + 1. [x] - `p1` - **RETURN** the verdict or mutation, which the chain applies in the composed order - `inst-sandbox-return` + +The 100 ms ceiling also serves `cpt-cf-oagw-nfr-low-latency`, whose MUST that "plugin execution timeouts **MUST** be enforced" this step is the enforcement of. A sandbox breach is never retried and never re-issued, which is `cpt-cf-oagw-principle-no-retry` applied to the gateway's own processing and not only to the upstream call. + +### Forward the Outbound Request + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-outbound-forward` + +**Input**: the transformed `OutboundRequest` inputs, the `SelectedEndpoint`, the negotiated protocol version for the host, and `OagwConfig`. + +**Output**: the upstream response as received, or the gateway error the caller answers with. + +**Steps**: + +1. [x] - `p1` - Check the selected endpoint's scheme against `cpt-cf-oagw-constraint-https-only` at dial time: `https` and `wss` are always legal, `http` is legal exactly when `oagw.config.allow_http_upstream` is `true`, which `config/e2e-local.yaml` sets, and `wt` and `grpc` are never dialed - `inst-fwd-scheme` +2. [x] - `p1` - **IF** the scheme is `wt` - `inst-fwd-scheme-wt-if` + 1. [x] - `p1` - **RETURN** the failure the caller answers 502 with the `ProtocolError` variant and `X-OAGW-Error-Source: gateway`, per the scope reduction of DECOMPOSITION §1.3(2) (§1.5) - `inst-fwd-scheme-wt-return` +3. [x] - `p1` - **ELSE IF** the scheme is `http` and the flag is `false` - `inst-fwd-scheme-http-if` + 1. [x] - `p1` - **RETURN** the same gateway refusal; the write-time acceptance of the `http` literal by `cpt-cf-oagw-feature-control-plane-config` is a separate check against the same constraint and never authorizes the dial (§1.5) - `inst-fwd-scheme-http-return` +4. [x] - `p1` - **ELSE** - `inst-fwd-scheme-else` + 1. [x] - `p1` - Continue with the dial over the shared outbound client, which is constructed once and reused, per ADR 0006's Data Plane state - `inst-fwd-client` +5. [x] - `p1` - Apply the adaptive per-host HTTP version detection of DESIGN §3.2 Security Considerations: on the first request to a host, attempt HTTP/2 through ALPN during the TLS handshake; on success cache "supported" for that host, on failure fall back to HTTP/1.1 and cache that, and on every subsequent request use the cached version; the cache entry lives for the 1 hour DESIGN states (§1.5) - `inst-fwd-version` +6. [x] - `p1` - Apply the deadline `proxy_timeout_secs` carries — 2 in the graded configuration, 30 by the declared default of `cpt-cf-oagw-feature-gear-foundation` — to both the connection-establishment phase and the exchange phase (§1.5) - `inst-fwd-deadline` +7. [x] - `p1` - **IF** the connection cannot be established within the deadline - `inst-fwd-deadline-conn-if` + 1. [x] - `p1` - **RETURN** the failure the caller answers 504 with the `ConnectionTimeout` variant (`gts.cf.core.errors.err.v1~cf.oagw.timeout.connection.v1`), retriable per DESIGN §3.3 - `inst-fwd-deadline-conn-return` +8. [x] - `p1` - **ELSE IF** the exchange exceeds the deadline - `inst-fwd-deadline-req-if` + 1. [x] - `p1` - **RETURN** the failure the caller answers 504 with the `RequestTimeout` variant (`gts.cf.core.errors.err.v1~cf.oagw.timeout.request.v1`), retriable per DESIGN §3.3 - `inst-fwd-deadline-req-return` +9. [x] - `p1` - **ELSE IF** the endpoint host cannot be resolved or reached at all - `inst-fwd-link-if` + 1. [x] - `p1` - **RETURN** the failure the caller answers 503 with the `LinkUnavailable` variant (`gts.cf.core.errors.err.v1~cf.oagw.link.unavailable.v1`), retriable per DESIGN §3.3 - `inst-fwd-link-return` +10. [x] - `p1` - **ELSE** - `inst-fwd-send-else` + 1. [x] - `p1` - Send the request once. Connector-level endpoint or connection attempts stay inside the upstream connector, per `cpt-cf-oagw-principle-no-retry` and the clause of `cpt-cf-oagw-fr-request-proxy` that permits exactly that; the gateway never re-issues the original client request, and no credential refresh on a rejected request is delivered in this run: an upstream 401 triggers no refresh, no retry, and no re-send, and is answered under the error-source classification of `cpt-cf-oagw-algo-response-classify` (§1.5). The refresh an auth plugin does perform is the token-cache refresh of credential preparation before the send, never after a rejection - `inst-fwd-send` +11. [x] - `p1` - **RETURN** the upstream response as received, for `cpt-cf-oagw-algo-response-classify` - `inst-fwd-return` + +The dial-time evaluation of the SSRF policy runs here when `ssrf_policy.enabled` is `true`: the name resolution performed for the selected endpoint's host is validated before the connection is opened, and a resolution the policy rejects is answered as a gateway error rather than dialed. The graded configuration sets the key to `false`, so no such evaluation runs there and the connection is opened to the host the endpoint declares. The rules the evaluation would apply are out of scope per DECOMPOSITION §2.5 and are specified nowhere in this document; what this feature owns is the decision to evaluate them, and the fail-closed direction when it does. + +### Classify the Response and Tag the Error Source + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-response-classify` + +**Input**: the upstream response as received, or the gateway error a routine above returned, plus the `ProxyContext` and the `ResolvedUpstream`. + +**Output**: the `ProxyResponse` with its error-source tag, or the RFC 9457 problem answer. + +The classification, per ADR 0007 and `cpt-cf-oagw-principle-error-source`: + +| Origin | Header value | Body | Source | +|---|---|---|---| +| The gateway produced the answer | `gateway` | `application/problem+json` with the RFC 9457 fields `type`, `title`, `status`, `detail`, `instance` | ADR 0007 Header Values, DESIGN §3.3 | +| The upstream produced the answer, success or failure | `upstream` | the upstream body as received, unmodified | ADR 0007, §1.5 for the success value | + +**Steps**: + +1. [x] - `p1` - **IF** the answer was produced by the gateway — any validation, authorization, resolution, selection, chain, deadline, or scheme failure above - `inst-cls-gateway-if` + 1. [x] - `p1` - Map it through `cpt-cf-oagw-algo-error-mapping` of `cpt-cf-oagw-feature-gear-foundation`, which resolves the variant's HTTP status and GTS `type` identifier, emits the problem body, and sets `X-OAGW-Error-Source: gateway`; this feature adds no second serialization path - `inst-cls-gateway-map` + 2. [x] - `p1` - Attach the `ErrorContext` members that are present — `upstream_id`, `host`, `path`, `retry_after_seconds`, and `trace_id` — as the problem body's extension fields, per DESIGN §3.3's extension list; a member with no value is omitted, never synthesized - `inst-cls-gateway-context` + 3. [x] - `p1` - Emit `Retry-After` only for the catalogue rows DESIGN §3.3 marks retriable and only when `retry_after_seconds` is present, which is the mapping that feature already performs - `inst-cls-gateway-retry` +2. [x] - `p1` - **ELSE** - `inst-cls-upstream-else` + 1. [x] - `p1` - Pass the upstream response through with its status, body, and content type unmodified, set `X-OAGW-Error-Source: upstream`, and apply `headers.response` through `cpt-cf-oagw-algo-header-transform`; no problem-details mapping is applied to it, however unfavourable its status is - `inst-cls-upstream` +3. [x] - `p1` - **IF** the response is a stream whose body is transferred incrementally - `inst-cls-stream-if` + 1. [x] - `p1` - Tag it with the same header and hand the body to `cpt-cf-oagw-feature-streaming`, which owns how it is transferred; the tag is decided here, before any body byte moves - `inst-cls-stream` +4. [x] - `p1` - **ELSE** - `inst-cls-stream-else` + 1. [x] - `p1` - Assemble the `ProxyResponse` and return it - `inst-cls-return` +5. [x] - `p1` - **RETURN** the `ProxyResponse`, and never cache it: `cpt-cf-oagw-principle-no-cache` places the response on the caller and the upstream, so the Data Plane L1 cache of `cpt-cf-oagw-algo-dp-cache` never holds it - `inst-cls-nocache-return` + +A header an intermediary strips is a risk ADR 0007 accepts and records; this feature does not add a second mechanism to compensate for it. The guidance that a client which must be certain combines the header check with an inspection of the response structure is DESIGN §3.3 Error Source Distinction's, and ADR 0007 is cited here only for the recorded stripping risk. + +### Cache the Resolved Configuration + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-dp-cache` + +**Input**: the resolved configuration produced by `cpt-cf-oagw-algo-resolve-consume`, the cache keys of ADR 0005, and the invalidation event of a configuration write. + +**Output**: a cache hit, a populated entry, or an invalidated key set. + +The cache and its boundaries, per ADR 0006 and ADR 0005: + +| Property | Value | Source | +|---|---|---| +| Kind | per-instance LRU | ADR 0006 DP State | +| Entry count | 1000, a named constant (§1.5) | ADR 0006 | +| TTL | none; entries persist until invalidated | ADR 0006 | +| Access time | under 1 microsecond on a hit | ADR 0005 Cache Layers | +| Key shapes | `upstream:{tenant_id}:{alias}` and `route:{upstream_id}:{method}:{path_prefix}` | ADR 0005 Cache Keys, narrowed to the two shapes ADR 0006's DP State assigns the Data Plane (§1.5) | +| Population | lazily, on read; no proactive warming | ADR 0005 Lookup Flow | +| Invalidation | explicit, by the configuration write path, in process | ADR 0006, DECOMPOSITION §1.3(10) | +| Never holds | a response body, credential material, a cached access token, or rate-limit state | `cpt-cf-oagw-principle-no-cache`, `cpt-cf-oagw-principle-cred-isolation` | + +**Steps**: + +1. [x] - `p1` - Look the key up on the read path of `cpt-cf-oagw-algo-resolve-consume` and return the entry when it is present - `inst-cache-lookup` +2. [x] - `p1` - **ELSE** - `inst-cache-miss-else` + 1. [x] - `p1` - Let the resolution run and insert its result under the key, evicting the least-recently-used entry when the 1000-entry ceiling is reached - `inst-cache-insert` +3. [x] - `p1` - **IF** the write path of `cpt-cf-oagw-feature-control-plane-config` notifies this feature's flush routine that a configuration write has succeeded, which it does in process and before the write's response is produced (§1.5) - `inst-cache-invalidate-if` + 1. [x] - `p1` - Execute the flush this feature owns: drop the Data Plane entries the write affects, in the same process and before the write's response is produced, so a read that follows the write's success never sees the previous configuration - `inst-cache-invalidate` + 2. [x] - `p1` - Flush by key prefix — the tenant's upstream keys and the affected upstream's route keys — so one write does not discard unrelated tenants' entries - `inst-cache-flush-prefix` +4. [x] - `p1` - **ELSE** - `inst-cache-invalidate-else` + 1. [x] - `p1` - Leave the cache untouched; a failed write invalidates nothing, because the database it failed against is unchanged - `inst-cache-invalidate-none` +5. [x] - `p1` - Run no periodic sync, no TTL expiry, and no background refresh; the explicit invalidation of step 3 is the only mechanism, and DECOMPOSITION §1.3(10) dispositions the alternative (§1.5) - `inst-cache-nosync` +6. [x] - `p1` - **RETURN** the hit, the inserted entry, or the invalidated key set - `inst-cache-return` + +A cache entry can be stale only between the write and the flush, which the ordering of step 3 closes. The Data Plane L1 caches exactly the upstream and route configurations ADR 0006's DP State scopes it to; ADR 0005's third key shape belongs to the wider configuration surface whose plugin half is resolved by `cpt-cf-oagw-feature-plugin-system`'s own registry and store and is not cached here. The plugin chain itself is composed per resolution by `cpt-cf-oagw-algo-chain-compose` of that feature and is not cached here either, and neither is the Control Plane cache that feature's write path flushes, which DECOMPOSITION §1.3(10) assigns to `cpt-cf-oagw-feature-control-plane-config`. + +## 4. States (CDSL) + +No state machine is defined for this feature, because the Data Plane it implements is stateless by decision. `cpt-cf-oagw-adr-state-management` assigns the Data Plane exactly three pieces of state — the small L1 cache, the shared outbound client, and the per-instance rate limiters — and this feature holds only the first two: `cpt-cf-oagw-algo-dp-cache` is a keyed LRU whose entries have no lifecycle beyond insert, hit, and evict, and the per-host protocol cache of `cpt-cf-oagw-algo-outbound-forward` is a keyed map whose entries carry one bit and expire after the 1 hour DESIGN states. Neither has a valid-versus-invalid state distinction worth a machine, and neither is persisted. The round-robin counter of `cpt-cf-oagw-algo-endpoint-select` is a per-upstream integer. The two state machines that do exist on this path belong to other features: `cpt-cf-oagw-state-plugin-lifecycle` for a plugin row, and the closed, open, and half-open breaker machine of `cpt-cf-oagw-feature-rate-limiting`. DECOMPOSITION §2.5 declares no table for this feature, so no persisted state is created, transitioned, or retained here. + +## 5. Definitions of Done + +### Proxy Endpoint Registration and Authorization + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-proxy-endpoint` + +The system **MUST** register the proxy handler for `{METHOD} /oagw/v1/proxy/{alias}[/{path_suffix}][?{query}]` gear-relative on the mount point `cpt-cf-oagw-feature-gear-foundation` created, classify it to the Data Plane per `cpt-cf-oagw-adr-request-routing`, and **MUST** enforce `gts.cf.core.oagw.proxy.v1~:invoke` for every method the handler accepts, answering 401 for a missing or invalid token and 403 for a token without the permission, before any resolution, validation, or cache read. It **MUST NOT** register any management path, and it **MUST** leave the `OPTIONS` preflight answer to `cpt-cf-oagw-feature-cors`. + +**Implements**: + +- `cpt-cf-oagw-flow-proxy-request` +- `cpt-cf-oagw-flow-proxy-authorize` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: `{METHOD} /oagw/v1/proxy/{alias}[/{path_suffix}][?{query}]` +- DB: none +- DB Table: none +- Entities: `ProxyContext` + +### Effective Configuration Consumption + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-effective-config` + +The system **MUST** consume the effective configuration through `cpt-cf-oagw-flow-resolve-effective-config` of `cpt-cf-oagw-feature-hierarchical-config` in the order upstream, then route, then tenant, **MUST** carry the ancestor `enforce` families the resolution marks onto the `ResolvedUpstream` and the `MatchedRoute`, and **MUST** apply none of the per-field merge strategies itself. It **MUST** answer a false effective `enabled` state with 503 and the `LinkUnavailable` variant, an empty candidate set or an unmatched route with 404 and the `RouteNotFound` variant, and a failed-closed resolution with the platform 500 problem shape, and **MUST NOT** forward a request resolved against an incomplete result. + +**Implements**: + +- `cpt-cf-oagw-algo-resolve-consume` +- `cpt-cf-oagw-flow-resolve-effective-config` of `cpt-cf-oagw-feature-hierarchical-config` + +**Constraints**: none from DESIGN §2.2; the governing elements are the layer order of DESIGN §2.1 and PRD §5.5 and the statelessness of `cpt-cf-oagw-adr-state-management`. + +**Touches**: + +- API: none — the consumption is internal to the proxy handler's own path +- DB: none — every read goes through the resolution the sibling feature performs +- DB Table: none +- Entities: `ResolvedUpstream` + +### Route Matching + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-route-matching` + +The system **MUST** match routes by method allowlist, then longest path prefix, then the ascending `priority` order of §1.5, and **MUST** honour `path_suffix_mode` with its shipped-schema default of `append`, rejecting a supplied suffix with 400 when the mode is `disabled` and appending it to `match.http.path` when the mode is `append`. It **MUST** answer a no-match outcome with 404 and the `RouteNotFound` variant, and it **MUST NOT** evaluate a gRPC match key. + +**Implements**: + +- `cpt-cf-oagw-algo-route-match` + +**Constraints**: none from DESIGN §2.2; the governing element is `cpt-cf-oagw-adr-request-routing`'s request classification. + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: `MatchedRoute` + +### Endpoint Selection + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-endpoint-selection` + +The system **MUST** implement all six rows of the ADR 0001 behaviour matrix: single-endpoint routing with and without the header, multi-endpoint round-robin and header-directed selection for an explicit alias, and the 400 `MissingTargetHost` answer and header-directed selection for a common-suffix alias. It **MUST** validate a supplied `X-OAGW-Target-Host` value even when the header is optional, answering 400 with `InvalidTargetHost` for a malformed value and `UnknownTargetHost` for a value matching no configured endpoint, **MUST** strip the header after reading it, and **MUST** replace `Host` or `:authority` with the selected endpoint's value. + +**Implements**: + +- `cpt-cf-oagw-algo-endpoint-select` + +**Constraints**: none from DESIGN §2.2; the governing elements are the ADR 0001 matrix and `cpt-cf-oagw-principle-error-source` for the answers. + +**Touches**: + +- API: `{METHOD} /oagw/v1/proxy/{alias}[/{path_suffix}][?{query}]` — the `X-OAGW-Target-Host` request header of the endpoint this feature already registers +- DB: none +- DB Table: none +- Entities: `SelectedEndpoint` + +### Inbound Validation + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-inbound-validation` + +The system **MUST** validate the path, the query parameters, and the headers of every inbound proxy request against the matched route before forwarding, **MUST** reject a query parameter outside the route's `match.http.query_allowlist` — including every query parameter when the allowlist is empty, per the shipped schema's "If empty, allow none" — and **MUST** answer every validation failure with 400 and the `ValidationError` variant. It **MUST** reject CR or LF in any header value, and **MUST NOT** implement the two CORS rows of the DESIGN §3.2 Guard Rules table. + +**Implements**: + +- `cpt-cf-oagw-algo-inbound-validate` + +**Constraints**: none from DESIGN §2.2; the governing requirement is `cpt-cf-oagw-nfr-input-validation`. + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: `ProxyContext` + +### Body Validation + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-body-validation` + +The system **MUST** reject a request body exceeding the 100MB hard limit of `cpt-cf-oagw-constraint-body-limit` before any of it is buffered, answering 413 with the `PayloadTooLarge` variant, and **MUST** reject with 400 and the `ValidationError` variant a `Content-Length` that is not a valid integer or that does not match the actual body size, a `Transfer-Encoding` other than `chunked`, a request carrying both `Content-Length` and `Transfer-Encoding`, and a header value containing CR or LF. No configuration input **MUST** be required for any of these checks. + +**Implements**: + +- `cpt-cf-oagw-algo-body-validate` + +**Constraints**: `cpt-cf-oagw-constraint-body-limit` + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: `ProxyContext`, `OutboundRequest` + +### Header Transformation + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-header-transformation` + +The system **MUST** consume the routing header `X-OAGW-Target-Host`, strip the eight hop-by-hop headers PRD §5.2 names for `cpt-cf-oagw-fr-header-transform`, apply the `headers.request` `passthrough` mode with its shipped-schema default of `none` and its `allowlist` and `all` values, apply the `headers.request` and `headers.response` `set`, `add`, and `remove` rules of the resolved upstream, and replace `Host` or `:authority` with the selected endpoint's value across both protocol versions. It **MUST** keep the caller's `Authorization` value out of the outbound request in every `passthrough` mode, so the credential the upstream sees is the one the chain injects or a `headers.request` `set` rule configures, and it **MUST** answer an invalid well-known header with 400. + +**Implements**: + +- `cpt-cf-oagw-algo-header-transform` +- the `headers` rule shapes of `schemas/upstream.v1.schema.json`, validated at write time by `cpt-cf-oagw-algo-request-validate` of `cpt-cf-oagw-feature-control-plane-config` + +**Constraints**: none from DESIGN §2.2; the governing requirement is `cpt-cf-oagw-fr-header-transform`. + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: `OutboundRequest`, `ProxyResponse` + +### Plugin Chain Execution + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-chain-execution` + +The system **MUST** execute the composed plugin chain in the order Auth, Guards on the request, Transform on the request, the upstream call, Guards on the response, Transform on the response, and Transform on the error, **MUST** inject the resolved credential material into the outbound request and into nothing else, and **MUST** answer an unresolvable or unenforceable binding with 503 and the `PluginNotFound` variant, a request-phase guard rejection with 400, a response-phase guard rejection with 502 and the `ProtocolError` variant, and a credential failure with 401 or 500 as `cpt-cf-oagw-algo-credential-resolution` maps it. It **MUST** write `last_used_at` for every custom plugin that executed, off the request's latency budget, and **MUST NOT** let that write feed any decision or appear in any problem body. + +**Implements**: + +- `cpt-cf-oagw-algo-chain-execute` +- `cpt-cf-oagw-algo-chain-compose`, `cpt-cf-oagw-algo-credential-resolution`, and `cpt-cf-oagw-algo-token-cache` of `cpt-cf-oagw-feature-plugin-system` + +**Constraints**: none from DESIGN §2.2; the governing requirements are `cpt-cf-oagw-fr-auth-injection` and `cpt-cf-oagw-nfr-credential-isolation`. + +**Touches**: + +- API: none +- DB: none — the `last_used_at` column of `oagw_plugin` is written through the persistence `cpt-cf-oagw-feature-plugin-system` owns +- DB Table: none +- Entities: `OutboundRequest` + +### Starlark Sandbox Enforcement + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-starlark-sandbox` + +The system **MUST** run every custom Starlark plugin in a sandbox with no network I/O, no file I/O, and no imports, and **MUST** enforce a per-invocation timeout of at most 100 ms and a per-invocation memory ceiling of at most 10 MB, discarding every partial mutation a breached invocation performed and answering the breach through the `ProtocolError` variant with `X-OAGW-Error-Source: gateway`. It **MUST** refuse to execute a plugin whose limits it cannot enforce, and **MUST NOT** accept a sandbox escape. + +**Implements**: + +- `cpt-cf-oagw-algo-starlark-sandbox` + +**Constraints**: none from DESIGN §2.2; the governing requirement is `cpt-cf-oagw-nfr-starlark-sandbox`, with the timeout clause of `cpt-cf-oagw-nfr-low-latency`. + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: none — the sandbox is an execution discipline over the plugin source `cpt-cf-oagw-feature-plugin-system` stores + +### Outbound Forwarding + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-outbound-forwarding` + +The system **MUST** check the selected endpoint's scheme at dial time against `cpt-cf-oagw-constraint-https-only`, opening a plaintext connection exactly when `oagw.config.allow_http_upstream` is `true`, answering a `wt` scheme with 502 and the `ProtocolError` variant, and never dialing a `grpc` scheme. It **MUST** forward over one shared outbound client with adaptive per-host HTTP version detection and its 1 hour entry lifetime, **MUST** apply the `proxy_timeout_secs` deadline to both the connection and the exchange phase with 504 `ConnectionTimeout` and 504 `RequestTimeout` as their answers, **MUST** send the client request once, and **MUST NOT** re-issue it at gateway level or cache the response it receives. + +**Implements**: + +- `cpt-cf-oagw-algo-outbound-forward` + +**Constraints**: `cpt-cf-oagw-constraint-https-only`, `cpt-cf-oagw-constraint-no-direct-internet` + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: `OutboundRequest` + +### Error Source Tagging + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-error-source` + +The system **MUST** set `X-OAGW-Error-Source` on every response it produces, with the value `gateway` for an answer the gateway produced and `upstream` for an answer the upstream produced, including a success, and **MUST** emit an `application/problem+json` body for every gateway error and pass an upstream answer through unmodified. It **MUST** carry the `ErrorContext` members `upstream_id`, `host`, `path`, `retry_after_seconds`, and `trace_id` as problem extension fields when present, **MUST** omit a member with no value rather than synthesizing one, and **MUST** emit `Retry-After` only for the catalogue rows DESIGN §3.3 marks retriable. + +**Implements**: + +- `cpt-cf-oagw-algo-response-classify` +- `cpt-cf-oagw-algo-error-mapping` of `cpt-cf-oagw-feature-gear-foundation` + +**Constraints**: none from DESIGN §2.2; the governing elements are `cpt-cf-oagw-principle-error-source` and `cpt-cf-oagw-adr-error-source-distinction`. + +**Touches**: + +- API: `{METHOD} /oagw/v1/proxy/{alias}[/{path_suffix}][?{query}]` — the response header of the endpoint this feature already registers +- DB: none +- DB Table: none +- Entities: `ProxyResponse` + +### Data Plane Configuration Cache + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-dp-cache` + +The system **MUST** maintain a per-instance LRU of 1000 entries with no TTL for the resolved upstream and route configurations, keyed in the two ADR 0005 shapes ADR 0006's DP State assigns the Data Plane (§1.5), populated lazily on read, and **MUST** flush the entries a configuration write affects, in the same process and before that write's response is produced, when the write path of `cpt-cf-oagw-feature-control-plane-config` notifies it. It **MUST NOT** hold a response body, credential material, a cached access token, or rate-limit state, and **MUST NOT** run a periodic sync, a TTL expiry, or a background refresh. + +**Implements**: + +- `cpt-cf-oagw-algo-dp-cache` + +**Constraints**: none from DESIGN §2.2; the governing elements are `cpt-cf-oagw-adr-data-plane-caching` and `cpt-cf-oagw-adr-state-management`. + +**Touches**: + +- API: none +- DB: none — the cache is in-process and reads nothing the sibling features do not already read +- DB Table: none +- Entities: none — the cache holds resolved sibling-owned configuration, not a new type + +### Proxy Entities and Layering + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-proxy-entities` + +The system **MUST** declare `ProxyContext`, `ResolvedUpstream`, `SelectedEndpoint`, `MatchedRoute`, `OutboundRequest`, and `ProxyResponse` in the domain layer with the members §1.2 assigns each, **MUST** consume `EffectiveUpstreamConfig` and `EffectiveRouteConfig` from `cpt-cf-oagw-feature-hierarchical-config` and the four plugin-execution contexts from `cpt-cf-oagw-feature-gear-foundation` without redeclaring any of them, and **MUST** keep every one of the six free of transport and persistence types, per `cpt-cf-oagw-design-layers`. + +**Implements**: + +- `cpt-cf-oagw-algo-resolve-consume` +- `cpt-cf-oagw-algo-route-match` +- `cpt-cf-oagw-algo-endpoint-select` + +**Constraints**: none from DESIGN §2.2; the governing element is `cpt-cf-oagw-design-layers`. + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: `ProxyContext`, `ResolvedUpstream`, `SelectedEndpoint`, `MatchedRoute`, `OutboundRequest`, `ProxyResponse` + +### Latency Budget + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-low-latency` + +The system **MUST** add less than 10 ms of overhead at p95 to a proxy request, excluding the upstream response time, which is the threshold of `cpt-cf-oagw-nfr-low-latency` and the only latency target any feature of this decomposition sets for this path. It **MUST** spend that budget on the work it owns — resolution on a cache hit, matching, selection, validation, transformation, and one send — **MUST** enforce the plugin execution timeouts that the same requirement names, and **MUST** keep the `last_used_at` write and every cache flush off the measured path. + +**Implements**: + +- `cpt-cf-oagw-algo-dp-cache` +- `cpt-cf-oagw-algo-starlark-sandbox` +- `cpt-cf-oagw-algo-outbound-forward` + +**Constraints**: none from DESIGN §2.2; the governing requirement is `cpt-cf-oagw-nfr-low-latency`. + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: none + +### Colocated Tests + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-proxy-tests` + +The system **MUST** deliver this feature's unit and integration tests colocated under `gears/system/oagw/oagw/tests/`, covering authorization, resolution consumption and its outcomes, route matching including the `path_suffix_mode` branches, the six matrix rows of endpoint selection, inbound and body validation, header transformation in both directions, the absence of the caller's `Authorization` value from the outbound request in every `passthrough` mode, chain execution and its error mappings, sandbox enforcement and its two limits, scheme legality at dial time, the no-re-issue rule, the error-source tagging of both classes of answer, and the cache hit, insert, and explicit invalidation paths, and **MUST NOT** add any test under `testing/e2e/gears/oagw/`. + +**Implements**: + +- `cpt-cf-oagw-flow-proxy-request` +- `cpt-cf-oagw-flow-proxy-authorize` +- `cpt-cf-oagw-algo-resolve-consume` +- `cpt-cf-oagw-algo-route-match` +- `cpt-cf-oagw-algo-endpoint-select` +- `cpt-cf-oagw-algo-inbound-validate` +- `cpt-cf-oagw-algo-body-validate` +- `cpt-cf-oagw-algo-header-transform` +- `cpt-cf-oagw-algo-chain-execute` +- `cpt-cf-oagw-algo-starlark-sandbox` +- `cpt-cf-oagw-algo-outbound-forward` +- `cpt-cf-oagw-algo-response-classify` +- `cpt-cf-oagw-algo-dp-cache` + +**Constraints**: none from DESIGN §2.2; this is the DECOMPOSITION §1.3(3) placement deviation recorded in §1.5. + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: none — tests only + +## 6. Acceptance Criteria + +- [x] A request to `{METHOD} /oagw/v1/proxy/{alias}/{path_suffix}` with a valid token carrying `gts.cf.core.oagw.proxy.v1~:invoke` for a tenant that owns the alias is forwarded to the upstream the alias resolves to, and the response returned to the caller carries `X-OAGW-Error-Source`. +- [x] A token without `gts.cf.core.oagw.proxy.v1~:invoke` is answered 403 before any resolution, cache read, or upstream lookup, and the answer is identical whether or not the alias resolves anywhere. +- [x] A token for a tenant whose ancestor owns the alias reaches the ancestor's upstream, and a token for a tenant whose chain holds no candidate for the alias is answered 404 with `gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1` and never 403. +- [x] The effective configuration is consumed in the order upstream, then route, then tenant, and a tenant-level value the chain contributed prevails over a route-level value for the same family, with no merge strategy restated in this feature. +- [x] A disabled upstream — the target's own flag false, or any matched ancestor's flag false — is answered 503 with `gts.cf.core.errors.err.v1~cf.oagw.link.unavailable.v1` and is never dialed. +- [x] Of two enabled routes of one upstream whose `match.http.path` both prefix the request path and whose methods both allow it, the one with the longer prefix is selected; of two sharing the longest prefix, the one with the smaller `priority` is selected. +- [x] A path suffix supplied to a route with `path_suffix_mode: disabled` is answered 400, and the same suffix supplied to a route with `path_suffix_mode: append` is appended to the route's `match.http.path` and forwarded. +- [x] A query parameter outside the route's `match.http.query_allowlist` is answered 400, and a request carrying any query parameter at all against a route that declares an empty allowlist is answered 400. +- [x] Each of the six rows of the ADR 0001 behaviour matrix produces its stated outcome: a single endpoint is used with and without the header; two endpoints with an explicit alias are rotated without the header and pinned to the named endpoint with it; two endpoints with a common-suffix alias are answered 400 without the header and pinned with it. +- [x] `X-OAGW-Target-Host: us.vendor.com:8443` against a pool of bare hostnames is answered 400 with `gts.cf.core.errors.err.v1~cf.oagw.routing.invalid_target_host.v1`, and `X-OAGW-Target-Host: apac.vendor.com` against a `us.vendor.com`/`eu.vendor.com` pool is answered 400 with `gts.cf.core.errors.err.v1~cf.oagw.routing.unknown_target_host.v1` naming the configured hosts. +- [x] `X-OAGW-Target-Host` is present on the outbound request only as a routing input and never reaches the upstream, and `Host` on an HTTP/1.1 request and `:authority` on an HTTP/2 request carry the selected endpoint's value rather than the caller's. +- [x] The eight hop-by-hop headers `Connection`, `Keep-Alive`, `Proxy-Authenticate`, `Proxy-Authorization`, `TE`, `Trailer`, `Transfer-Encoding`, and `Upgrade` are absent from the outbound request on a plain request/response exchange. +- [x] With `headers.request.passthrough` at its default, no inbound header other than the replaced `Host` reaches the upstream; with `allowlist`, exactly the listed names do; with `all`, every remaining inbound header does — and the caller's `Authorization` value is absent from the outbound request in all three modes, appearing there only when the auth plugin or a `headers.request` `set` rule places a credential of its own there. +- [x] The `headers.response` `set`, `add`, and `remove` rules of the resolved upstream are applied to the response returned to the caller, while the response body's transfer mode is decided by `cpt-cf-oagw-feature-streaming`. +- [x] A body of more than 100,000,000 bytes is answered 413 with `gts.cf.core.errors.err.v1~cf.oagw.payload.too_large.v1` before any body byte is buffered, and the process's retained memory for the request does not grow to the body size. +- [x] A `Content-Length` that is not a valid integer, one that disagrees with the actual body size, a `Transfer-Encoding` other than `chunked`, a request carrying both `Content-Length` and `Transfer-Encoding`, and a header value containing CR or LF are each answered 400 with `gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1`, with no configuration input required for any of the five. +- [x] The plugin chain executes Auth, then guards on the request, then transforms on the request, then the upstream call, then guards and transforms on the response, and transforms on the error, with upstream plugins before route plugins within a phase, and the credential material it resolves appears in the outbound request and in no log, problem body, or response header. +- [x] A bound plugin whose row was deleted, and a custom plugin whose sandbox limits the runtime cannot enforce, are each answered 503 with `gts.cf.core.errors.err.v1~cf.oagw.plugin.not_found.v1`, and no untrusted source is executed in the second case. +- [x] A custom plugin that exceeds 100 ms, exceeds 10 MB, attempts a network or file operation, or raises an error is terminated, its partial mutations are discarded, and the request is answered 502 with `gts.cf.core.errors.err.v1~cf.oagw.protocol.error.v1` and `X-OAGW-Error-Source: gateway`. +- [x] A guard configured through ADR 0009's plugin that finds a required request header missing answers 400, and one that finds a required response header missing answers 502, both with `X-OAGW-Error-Source: gateway`. +- [x] A credential reference the store answers nothing for is answered 500 with `gts.cf.core.errors.err.v1~cf.oagw.secret.not_found.v1`, and one the store declines for the calling tenant is answered 401 with `gts.cf.core.errors.err.v1~cf.oagw.auth.failed.v1`. +- [x] An endpoint with `scheme: https` is dialed over TLS; an endpoint with `scheme: http` is dialed in plaintext exactly when `oagw.config.allow_http_upstream` is `true` and refused with a gateway error when it is `false`; and a `wt`-scheme endpoint is always refused with 502 and `gts.cf.core.errors.err.v1~cf.oagw.protocol.error.v1`. +- [x] A client request is forwarded exactly once: a connector-level attempt inside the upstream connector may repeat the connection or the endpoint, but no gateway-level re-issue of the original request occurs, and no response body is ever written to the Data Plane L1 cache. +- [x] A failure to establish the connection within `proxy_timeout_secs` is answered 504 with `gts.cf.core.errors.err.v1~cf.oagw.timeout.connection.v1`, and an exchange that exceeds it is answered 504 with `gts.cf.core.errors.err.v1~cf.oagw.timeout.request.v1`; neither is retried by the gateway, and an upstream 401 that rejects the injected credential is neither retried nor refreshed. +- [x] The first request to a host negotiates the protocol version through ALPN, the second uses the cached result, and the cached entry is not used after the 1 hour lifetime DESIGN §3.2 states. +- [x] A second request for the same tenant, alias, method, and path prefix is answered without a second resolution call, and a successful configuration write through the management API is flushed by `cpt-cf-oagw-algo-dp-cache` before the write's response is produced, so the next proxy request observes the new configuration with no restart and no periodic sync. +- [x] Every response carries `X-OAGW-Error-Source`, with `gateway` exactly when the gateway produced the body and `upstream` for every response the upstream produced, including a 2xx and an upstream 5xx, which passes through with its status, body, and content type unmodified. +- [x] A gateway error carries `Content-Type: application/problem+json` with the RFC 9457 fields `type`, `title`, `status`, `detail`, and `instance`, the present `ErrorContext` members as extension fields, no synthesized `trace_id`, and no credential material or configuration value in `detail`. +- [x] `Retry-After` is emitted exactly for the catalogue rows DESIGN §3.3 marks retriable and exactly when `retry_after_seconds` is present, and never for `DownstreamError` or for any non-retriable row. +- [x] No `DomainError` variant outside the foundation catalogue is introduced by this feature, and a storage failure anywhere on the path is answered with the platform's RFC 9457 500 problem shape carrying `X-OAGW-Error-Source: gateway`. +- [x] The proxy path adds less than 10 ms of overhead at p95 excluding the upstream response time, the `last_used_at` write and every cache flush are measurably off that path, and a plugin execution timeout is observed to terminate a stalled plugin within its limit. +- [x] `ProxyContext`, `ResolvedUpstream`, `SelectedEndpoint`, `MatchedRoute`, `OutboundRequest`, and `ProxyResponse` are declared once, in the domain layer, free of transport and persistence types, and `EffectiveUpstreamConfig`, `EffectiveRouteConfig`, `AuthContext`, `RequestContext`, `ResponseContext`, and `ErrorContext` are referenced from their owning features rather than redeclared. +- [x] Every test for this feature lives under `gears/system/oagw/oagw/tests/`, passes there, and no test is added under `testing/e2e/gears/oagw/`. 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..68b2e9b --- /dev/null +++ b/gears/system/oagw/docs/features/gear-foundation.md @@ -0,0 +1,493 @@ +# Feature: Gear Foundation + + + + +- [1. Feature Context](#1-feature-context) + - [1.1 Overview](#11-overview) + - [1.2 Purpose](#12-purpose) + - [1.3 Actors](#13-actors) + - [1.4 References](#14-references) + - [1.5 Feature-Local Deviations from Shared Baselines](#15-feature-local-deviations-from-shared-baselines) + - [1.6 Explicit Non-Applicability](#16-explicit-non-applicability) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Gear Registration and Configuration Bootstrap](#gear-registration-and-configuration-bootstrap) + - [GTS Type Catalogue Provisioning Handshake](#gts-type-catalogue-provisioning-handshake) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Configuration Load and Validation](#configuration-load-and-validation) + - [Alias and Hostname Normalization](#alias-and-hostname-normalization) + - [Domain Error to RFC 9457 Response Mapping](#domain-error-to-rfc-9457-response-mapping) + - [GTS Type Catalogue Provisioning](#gts-type-catalogue-provisioning) +- [4. States (CDSL)](#4-states-cdsl) + - [Gear Foundation Provisioning State Machine](#gear-foundation-provisioning-state-machine) +- [5. Definitions of Done](#5-definitions-of-done) + - [Gear Registration and Router Mount Point](#gear-registration-and-router-mount-point) + - [Configuration Surface and Validation](#configuration-surface-and-validation) + - [Domain Model Types and Layering](#domain-model-types-and-layering) + - [GTS Identifier Catalogue and Provisioning](#gts-identifier-catalogue-and-provisioning) + - [Canonical Error Type and Mapping](#canonical-error-type-and-mapping) + - [Colocated Test Placement and Coverage](#colocated-test-placement-and-coverage) +- [6. Acceptance Criteria](#6-acceptance-criteria) + + + +- [ ] `p1` - **ID**: `cpt-cf-oagw-featstatus-gear-foundation-implemented` + + +- [ ] `p2` - `cpt-cf-oagw-feature-gear-foundation` +## 1. Feature Context + +### 1.1 Overview + +This feature turns `gears/system/oagw/oagw` into a registered ToolKit gear and lays down the shared vocabulary the other eight features compile against: the `OagwConfig` surface, the DDD-Light crate skeleton, the `Upstream`/`Route`/`Plugin` domain model, the GTS identifier catalogue with its types-registry provisioning, and one canonical `DomainError` shape mapped to RFC 9457. + +### 1.2 Purpose + +`gear-foundation` is the root of the feature graph in DECOMPOSITION §3. `control-plane-config` and `plugin-system` branch off it directly, and everything downstream of them is written against the types, the error catalogue, and the router mount point delivered here. Without it no other feature can be written or tested, because there is no registered gear to host them, no domain type to persist, and no error shape to answer with. The feature materializes the existing design only: it introduces no requirement, no endpoint, and no architecture decision. + +Deliverables: + +- ToolKit gear registration (`gear.rs`, `lib.rs`) and the empty gear-relative Axum router mount point at `/oagw/v1` that later features populate. +- `OagwConfig` carrying `proxy_timeout_secs`, `allow_http_upstream`, `ssrf_policy`, `token_cache_ttl_secs`, and `token_cache_capacity`. +- DDD-Light crate layering (`api/rest`, `domain`, `infra`) with domain code free of infrastructure types. +- Domain model types `Upstream`, `Route`, and `Plugin`, their `server`, `auth`, `headers`, `rate_limit`, `cors`, and `plugins` sub-configurations, and the alias and hostname value objects. +- GTS identifier constants for the upstream, route, protocol, error, and plugin base types, provisioned into the types registry during the post-init phase. +- `DomainError` covering the full error catalogue, tagged with its gateway-versus-upstream source, and mapped to `application/problem+json` responses carrying GTS `type` identifiers. +- Colocated tests under `gears/system/oagw/oagw/tests/`. + +**Requirements**: + +- [ ] `p1` - `cpt-cf-oagw-fr-error-codes` +- [ ] `p1` - `cpt-cf-oagw-nfr-input-validation` +- [ ] `p1` - `cpt-cf-oagw-contract-types-registry` + +**Principles**: + +- `p1` - `cpt-cf-oagw-principle-rfc9457` + +**Constraints**: + +- `p1` - `cpt-cf-oagw-constraint-toolkit-deploy` +- `p1` - `cpt-cf-oagw-constraint-multi-sql` + +**Design Components**: + +- `p1` - `cpt-cf-oagw-component-model` +- `p1` - `cpt-cf-oagw-design-layers` +- `p1` - `cpt-cf-oagw-design-drivers` +- `p1` - `cpt-cf-oagw-design-overview` +- `p1` - `cpt-cf-oagw-design-domain-model` +- `p1` - `cpt-cf-oagw-design-dependencies` +- `p1` - `cpt-cf-oagw-tech-dependencies` + +**Domain Model Entities**: + +- `Upstream`, `Route`, `Plugin` (aggregate shapes from the design domain model) +- `Endpoint`, `ServerConfig`, `AuthConfig`, `HeadersConfig`, `RateLimitConfig`, `CorsConfig`, `PluginsConfig` +- `Alias`, `Hostname`, and `Scheme` value objects, normalized to ASCII lowercase with trailing dots stripped +- `DomainError`, `ErrorSource`, and the GTS error-type catalogue with gateway/upstream source tags + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-oagw-actor-platform-operator` | Authors the `oagw.config` block that gear init consumes, and receives a fail-fast startup failure when a key is unknown, of the wrong type, or out of range. | +| `cpt-cf-oagw-actor-types-registry` | Receives the OAGW base type schemas and instances during the post-init provisioning handshake and answers per entry with success or a typed failure. | + +`cpt-cf-oagw-actor-platform-operator` reaches this feature only as the author of the `oagw.config` block the init hook consumes, not through a management API — none exists at this stage — and DECOMPOSITION §1.5 records that participation by listing `gear-foundation` among the features that actor is served by. + +`cpt-cf-oagw-actor-cred-store` and `cpt-cf-oagw-actor-upstream-service` do not participate in this feature. No credential material is resolved — `auth.secret_ref` is carried as an opaque `cred://` reference that the value object validates for shape only — and no outbound connection is opened. Both are consumed by `cpt-cf-oagw-feature-plugin-system` and `cpt-cf-oagw-feature-data-plane-proxy` respectively. The tenant administrator and application developer have no surface here either: no management, proxy, or metrics endpoint exists at this stage, so no use case from PRD §8 reaches this feature. + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) +- **Design**: [DESIGN.md](../DESIGN.md) +- **Dependencies**: None — this feature is the root of the feature graph in DECOMPOSITION §3 and depends on no other `oagw` feature. + +Supporting sources this feature stays consistent with: + +- [schemas/upstream.v1.schema.json](../schemas/upstream.v1.schema.json) and [schemas/route.v1.schema.json](../schemas/route.v1.schema.json) — the configuration shapes the domain model types mirror, scoped in §5 to the properties each schema declares. Both files are frozen inputs; where this run overrides them, the override is recorded in §1.5. +- [ADR/0001-request-routing.md](../ADR/0001-request-routing.md) (`cpt-cf-oagw-adr-request-routing`) — the path-based routing table that the router mount point exists to serve. +- [ADR/0006-state-management.md](../ADR/0006-state-management.md) (`cpt-cf-oagw-adr-state-management`) — CP and DP state ownership. This feature creates no cache, no rate limiter, and no other runtime state; the L1 caches of that ADR belong to later features. +- [ADR/0007-error-source-distinction.md](../ADR/0007-error-source-distinction.md) (`cpt-cf-oagw-adr-error-source-distinction`) — the gateway/upstream source tag carried by every `DomainError`. +- [ADR/0008-oauth2-client-credentials-auth-plugin.md](../ADR/0008-oauth2-client-credentials-auth-plugin.md) (`cpt-cf-oagw-adr-oauth2-client-credentials-auth-plugin`) — the token-cache defaults behind `token_cache_ttl_secs` and `token_cache_capacity`. + +**Run-level assumptions** — premises this feature relies on that come from the platform runtime rather than from PRD, DESIGN, the ADRs, or DECOMPOSITION: + +- Assumption: the platform types-registry gear catalogues entries through a two-phase protocol, a staging phase that accepts registrations without validating them and a ready phase that validates every entry. If the runtime does not implement it, the `Configured → TypeCatalogProvisioned` transition never fires and the gear fails closed. +- Assumption: the runtime's topological ordering of post-init hooks puts the types-registry's ready-flip before the OAGW post-init hook. If the runtime orders them the other way, the `Configured → TypeCatalogProvisioned` transition never fires and the gear fails closed. + +### 1.5 Feature-Local Deviations from Shared Baselines + +| Deviation | Rationale | Review owner | Validation performed | +|-----------|-----------|--------------|----------------------| +| Tests are colocated at `gears/system/oagw/oagw/tests/` instead of `testing/e2e/gears/oagw/`. | DECOMPOSITION §1.3(3) reserves `testing/e2e/gears/oagw/` for the acceptance suite; every unit and integration test this decomposition produces lives with the crate, so a feature is done when its tests pass in that tree. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The router mount point is gear-relative `/oagw/v1/...` with no `/api` prefix. | DECOMPOSITION §1.3(1) corrects the `/api/oagw/v1/...` tabulation in PRD §7.1 and DESIGN §3.3: `/api` is an operator gateway prefix, not a path this gear serves. The mount point delivered here is therefore gear-relative, and every later feature registers under it. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| Write-time endpoint `scheme` validation accepts the `http` literal when `oagw.config.allow_http_upstream` is `true`, overriding the `scheme` enum of the frozen `schemas/upstream.v1.schema.json`. | DECOMPOSITION §1.3(2): which literals a configured endpoint may carry and whether a plaintext connection is opened are two questions, and only the second is governed by the flag. `cpt-cf-oagw-constraint-https-only` describes the default posture this flag lifts. The `Scheme` type declared here supplies the single write-time admission predicate; `cpt-cf-oagw-feature-control-plane-config` calls it when validating a write, and `cpt-cf-oagw-feature-data-plane-proxy` re-evaluates the same flag at dial time, keeping the two checks separate against the same constraint. `wt` remains a legal write-time literal even though no feature delivers WebTransport behaviour (DECOMPOSITION §1.3(4)). | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| `DownstreamError`'s `Depends` Retriable cell in DESIGN §3.3 is resolved **non-retriable** for the `Retry-After` gate in this feature. | The six rows DESIGN §3.3 marks `Yes` keep their unconditional retriability, so the write-time catalogue keeps a plain boolean per variant. A 502's retry decision belongs to the caller and to `cpt-cf-oagw-feature-data-plane-proxy`, which owns upstream-failure policy; resolving the cell here keeps that decision out of the type while preserving the PRD's context-dependent intent one layer up. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| `Route.cors` is declared in this feature although the shipped `schemas/route.v1.schema.json` omits the route-level property. | DECOMPOSITION §1.3(5): route-level CORS is configured through the `cors` field exactly as the Route class in DESIGN §3.1 specifies, and the schema is a frozen input this run does not edit. `cpt-cf-oagw-feature-control-plane-config` validates the object against the same shape as the upstream CORS configuration. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| `proxy_timeout_secs` defaults to `30`, a value no supplied document states. | `30` matches the platform REST request deadline applied by the built-in API gateway middleware stack as observed in the workspace, so an omitted key behaves like the platform default rather than like an unbounded request. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| `ssrf_policy.enabled` defaults to `true`, a value no supplied document states. | `true` is the fail-safe posture: SSRF enforcement is on until an operator turns it off, and the graded e2e configuration sets it to `false` explicitly rather than relying on the default. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| Registry-matching rule applied by this feature's provisioning: an identifier already registered with byte-identical content is accepted as success, and an identifier registered with different content that this gear does not own is a per-entry failure. | Idempotent acceptance is what lets a restart re-run provisioning over entries already in the registry without a conflict; refusing content this gear does not own keeps it from reporting readiness against a catalogue entry it did not write. Cited by the §6 criteria on re-registering an entry with byte-identical content and on a per-entry failure preventing readiness. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The `DomainError` catalogue gains two management-conflict variants, `AliasConflict` and `MatchConflict`, which have no row in the DESIGN §3.3 catalogue. | DESIGN §3.3 tabulates exactly one 409 row, `PluginInUse`, which is a plugin-lifecycle answer, so the 409s the management write path answers for an alias conflict and for a match conflict have no variant to map to. The catalogue is this feature's to own, so the two rows are added here and consumed by the feature that answers them: `AliasConflict` answers 409 with `gts.cf.core.errors.err.v1~cf.oagw.alias.conflict.v1`, and `MatchConflict` answers 409 with `gts.cf.core.errors.err.v1~cf.oagw.match.conflict.v1`, both non-retriable and both following the identifier pattern of the DESIGN rows. DECOMPOSITION §1.3(9) records the extension as a run-level decision, and `cpt-cf-oagw-feature-control-plane-config` names both variants in the answers its §2 flows and §3 routines return. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| `Route` additionally carries `priority` and `enabled`, two attributes of the Route class in DESIGN §3.1 that the shipped `schemas/route.v1.schema.json` omits from its property list. | DECOMPOSITION §1.3 records the shipped schemas as frozen inputs this run does not edit, and its item 5 already establishes the same class of override for the route-level `cors` object: a property DESIGN §3.1 declares that the shipped route schema omits is declared by this feature and validated by `cpt-cf-oagw-feature-control-plane-config`. Both the match-uniqueness invariant (DESIGN §3.6) and the enable/disable semantics of `cpt-cf-oagw-fr-enable-disable` are stated over `priority` and `enabled`, so neither can be dropped from the domain type. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The two provisioning criteria closed after the code review round, and `tracing-test` was added as a dev-dependency to assert the log side of one of them. | The criteria on resolvability through the types-registry and on a per-entry refusal are the two this run deferred while the data-plane features landed, and they are closed by `tests/provisioning_tests.rs` against a real in-process types-registry client (`TypesRegistryLocalClient` over `InMemoryGtsRepository`, the shape the `ClientHub` supplies at runtime) rather than against the recording fake the other provisioning tests use: provisioning, the ready commit the types-registry gear drives in its own post-init, a byte-identical read-back of every provisioned entry, the 21 error identifiers, and an identical re-registration. `tracing-test` is already a workspace dependency used by `account-management`, so no new crate enters the dependency graph, and it is dev-only. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | + +### 1.6 Explicit Non-Applicability + +The areas below apply to the gear as a whole but not to this feature. Each is stated here so the omission is a recorded decision rather than a silent gap. + +- **Performance**: no request hot path and no runtime endpoint exist in this feature, so no latency or throughput budget is set here. The only performance-relevant outputs it produces are the `proxy_timeout_secs` deadline and the token-cache ceilings (`token_cache_ttl_secs`, `token_cache_capacity`), and both are consumed by later features that do own a hot path. +- **Compliance and privacy**: no PII, no regulated data, and no retention policy are touched by this feature. `auth.secret_ref` is carried as an opaque, shape-validated `cred://` reference and no credential material is resolved, so there is nothing to protect, log, or retain at this layer. +- **Events**: no event is published or consumed by this feature; the audit and metric surface that reports on the gear belongs to `cpt-cf-oagw-feature-observability`. +- **Rollout and rollback**: this feature is not an independent release unit, so it has no rollout or rollback of its own. It is the foundation slice of the single configuration item described in DECOMPOSITION §1.4, and is baselined and released with the gear. +- **Versioning**: every GTS identifier this feature declares is fixed at `.v1` by DESIGN §3.1, so no version-negotiation, aliasing, or migration surface is introduced here. +- **UX**: no actor-facing surface exists in this feature — the router mount point delivered here carries no routes (§1.1, §1.5) and no management, proxy, or metrics endpoint exists at this stage (§1.3). + +## 2. Actor Flows (CDSL) + +No actor-facing HTTP flow exists in this feature. Every endpoint, handler, and route registration is out of scope (DECOMPOSITION §2.1), so the flows below are limited to the internal actor interactions that do exist: the ToolKit runtime bringing the gear up, and the types-registry provisioning handshake. + +**Use cases**: none. Every `cpt-cf-oagw-usecase-*` identifier in PRD §8 is exercised by a later feature; none of them reaches this one, and none is restated here. + +### Gear Registration and Configuration Bootstrap + +- [x] `p1` - **ID**: `cpt-cf-oagw-flow-gear-init` + +**Actor**: `cpt-cf-oagw-actor-platform-operator` + +**Success Scenarios**: + +- `oagw.config` is present and well-formed: the gear registers under the name `oagw`, holds a validated `OagwConfig`, and exposes an empty router mount point at `/oagw/v1`. +- `oagw.config` is absent entirely: the gear registers with the declared defaults for every key. + +**Error Scenarios**: + +- A key is of the wrong type, out of range, or not part of the configuration surface: init fails, the gear does not register, and startup aborts with the configuration error naming the offending key. + +**Steps**: +1. [x] - `p1` - Operator declares the `oagw.config` block with any of `proxy_timeout_secs`, `allow_http_upstream`, `ssrf_policy`, `token_cache_ttl_secs`, `token_cache_capacity` - `inst-gear-init-declare-config` +2. [x] - `p1` - ToolKit runtime instantiates the `oagw` gear and invokes its init hook with the `GearCtx` scoped to gear name `oagw` - `inst-gear-init-runtime-call` +3. [x] - `p1` - Resolve the `TypesRegistryClient` from the `ClientHub`; the dependency on the types-registry gear is declared so the runtime guarantees its init has completed - `inst-gear-init-resolve-registry` +4. [x] - `p1` - Load `OagwConfig` through `config_or_default`, which yields the declared defaults when the `oagw.config` section is absent - `inst-gear-init-load-config` +5. [x] - `p1` - **IF** `cpt-cf-oagw-algo-config-load-validate` returns an error - `inst-gear-init-validate` + 1. [x] - `p1` - Abort init with that error; the gear does not register and startup fails fast before any later feature can consume a half-configured gear - `inst-gear-init-abort` +6. [x] - `p1` - **ELSE** - `inst-gear-init-else` + 1. [x] - `p1` - Store the validated configuration on the gear instance and construct the empty gear-relative router mount point at `/oagw/v1`; no endpoint, handler, or route is registered - `inst-gear-init-mount` +7. [x] - `p1` - **RETURN** a registered gear whose readiness stays withheld until the type catalogue is provisioned (`cpt-cf-oagw-state-gear-foundation-lifecycle`) - `inst-gear-init-return` + +### GTS Type Catalogue Provisioning Handshake + +- [x] `p1` - **ID**: `cpt-cf-oagw-flow-type-provisioning` + +**Actor**: `cpt-cf-oagw-actor-types-registry` + +**Success Scenarios**: + +- Every OAGW base type schema and instance in the catalogue table is present in the registry when startup completes; an entry already registered with byte-identical content is accepted as success rather than a conflict. +- The registry catalogue is in its ready phase when provisioning runs, so every entry is fully validated before it is accepted (§1.4 run-level assumptions). + +**Error Scenarios**: + +- Any per-entry failure — malformed identifier, schema validation failure, parent type not yet registered, or an identifier already registered with different content that this gear does not own — fails the post-init phase, and the gear never reports readiness. + +**Steps**: +1. [x] - `p1` - Types-registry completes its own init and publishes its client; its catalogue is still in the staging phase, where registrations bypass validation - `inst-type-prov-registry-init` +2. [x] - `p1` - ToolKit runtime runs the post-init phase after every gear's init has returned; system gears run first in topological order, so the registry's own post-init has already flipped its catalogue to the ready phase - `inst-type-prov-post-init-phase` +3. [x] - `p1` - `oagw` enumerates its GTS identifier constants and hands the batch to `cpt-cf-oagw-algo-type-catalog-provisioning` - `inst-type-prov-enumerate` +4. [x] - `p1` - **IF** every entry reports success, including entries already registered with identical content - `inst-type-prov-check` + 1. [x] - `p1` - Mark the type catalogue provisioned and allow readiness to be reported - `inst-type-prov-ready` +5. [x] - `p1` - **ELSE** - `inst-type-prov-else` + 1. [x] - `p1` - Log each failing GTS identifier at ERROR, fail the post-init phase, and leave the gear not ready so startup aborts - `inst-type-prov-fail` +6. [x] - `p1` - **RETURN** a provisioned catalogue and a ready gear, or a failed startup - `inst-type-prov-return` + +## 3. Processes / Business Logic (CDSL) + +Internal routines called by the flows above or by later features. Only the provisioning routine leaves the process, and it does so through the in-process `types_registry` SDK call; nothing here opens an HTTP connection, touches a database, or resolves a secret. + +### Configuration Load and Validation + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-config-load-validate` + +**Input**: the raw `oagw.config` mapping from the deployment configuration, possibly absent, together with the `OagwConfig` defaults. + +**Output**: a validated `OagwConfig`, or a configuration error that fails gear init. + +The configurable surface, its defaults, and its constraints: + +| Key | Type | Default | Constraint | +|-----|------|---------|------------| +| `proxy_timeout_secs` | integer | 30 | At least 1. Outbound request deadline; the graded e2e configuration overrides it to 2. | +| `allow_http_upstream` | boolean | `false` | The only input that admits the `http` endpoint scheme literal at write time. | +| `ssrf_policy.enabled` | boolean | `true` | Fail-safe default; the graded e2e configuration sets it to `false` explicitly. | +| `token_cache_ttl_secs` | integer | 300 | At least 1. Ceiling for a cached access-token TTL (ADR 0008). | +| `token_cache_capacity` | integer | 10000 | At least 1. Maximum token-cache entries (ADR 0008). | + +`ssrf_policy.enabled` gates the SSRF enforcement owned by `cpt-cf-oagw-feature-data-plane-proxy` under `cpt-cf-oagw-nfr-ssrf-protection`; this feature only carries and validates the key and evaluates no SSRF rule of its own. + +**Steps**: +1. [x] - `p1` - Parse the mapping into `OagwConfig`, rejecting keys that are not part of the surface above (`deny_unknown_fields`, the platform configuration idiom) - `inst-config-parse` +2. [x] - `p1` - Apply the declared default for every absent key - `inst-config-defaults` +3. [x] - `p1` - **FOR EACH** integer key in {`proxy_timeout_secs`, `token_cache_ttl_secs`, `token_cache_capacity`} - `inst-config-int-loop` + 1. [x] - `p1` - Reject the configuration if the value is not an integer of at least 1 - `inst-config-int-check` +4. [x] - `p1` - **IF** `allow_http_upstream` is `true` - `inst-config-http-if` + 1. [x] - `p1` - Record the lifted posture on the configuration so the `Scheme` value object admits the `http` literal at write time; recording it does not by itself authorize a plaintext dial - `inst-config-http-record` +5. [x] - `p1` - **IF** any check failed - `inst-config-fail-if` + 1. [x] - `p1` - **RETURN** the configuration error naming the offending key - `inst-config-fail-return` +6. [x] - `p1` - **RETURN** the validated `OagwConfig` - `inst-config-return` + +The 30-second default for `proxy_timeout_secs` matches the platform REST request deadline applied by the built-in API gateway middleware stack, so an omitted key behaves like the platform default rather than like an unbounded request. + +### Alias and Hostname Normalization + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-alias-normalize` + +**Input**: an alias or endpoint host string as supplied by a caller, optionally carrying a `:port` suffix. + +**Output**: a normalized `Alias` or `Hostname` value object, or a validation error. + +**Steps**: +1. [x] - `p1` - Trim surrounding whitespace and strip trailing dots; FQDN notation is tolerated on input and never stored - `inst-alias-trim` +2. [x] - `p1` - Normalize to ASCII lowercase and reject any non-ASCII byte rather than transliterating it - `inst-alias-lower` +3. [x] - `p1` - Validate RFC 1123 hostname syntax: at most 253 characters in total, labels of 1 to 63 characters, labels drawn from ASCII alphanumerics and hyphen, no label starting or ending with a hyphen - `inst-alias-rfc1123` +4. [x] - `p1` - **IF** the string carries a `:port` suffix - `inst-alias-port-if` + 1. [x] - `p1` - Validate the port as an integer from 1 to 65535 and keep it in the normalized value; the port participates in alias identity, so `api.openai.com` and `api.openai.com:8443` are distinct aliases - `inst-alias-port-keep` +5. [x] - `p1` - **IF** the value is empty after trimming - `inst-alias-empty-if` + 1. [x] - `p1` - **RETURN** a validation error - `inst-alias-empty-return` +6. [x] - `p1` - **RETURN** the normalized value object - `inst-alias-return` + +This routine normalizes and validates only. Alias derivation — single hostname, longest common registrable suffix, rejection of a bare public suffix such as `co.uk` — and alias immutability across updates are delivered by `cpt-cf-oagw-feature-control-plane-config`, which calls this routine on every value it stores or resolves. Normalization lives here so that write-time storage and proxy-time resolution cannot disagree about what an alias looks like. + +### Domain Error to RFC 9457 Response Mapping + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-error-mapping` + +**Input**: a `DomainError` carrying its `ErrorSource` tag (`gateway` or `upstream`), its optional `ErrorContext` (`upstream_id`, `host`, `path`, `retry_after_seconds`, `trace_id`), and the request URI to use as the problem `instance`. + +**Output**: an HTTP response. + +`DomainError` declares one variant per error type in the DESIGN §3.3 catalogue (`cpt-cf-oagw-interface-api`) — `RouteError`, `ValidationError`, `MissingTargetHost`, `InvalidTargetHost`, `UnknownTargetHost`, `AuthenticationFailed`, `RouteNotFound`, `PluginInUse`, `PayloadTooLarge`, `RateLimitExceeded`, `SecretNotFound`, `ProtocolError`, `DownstreamError`, `StreamAborted`, `LinkUnavailable`, `CircuitBreakerOpen`, `PluginNotFound`, `ConnectionTimeout`, `RequestTimeout`, `IdleTimeout` — plus the two management-conflict variants added per §1.5, `AliasConflict` and `MatchConflict`. Each variant carries its HTTP status, GTS `type` identifier, and Retriable flag. Every DESIGN row has exactly one variant and every variant has a row: for the twenty DESIGN-named variants that row is the DESIGN §3.3 row, and for the two added variants it is the §1.5 row that records them, because DESIGN §3.3 has no 409 row for a management write conflict. + +The Retriable column of that catalogue is not carried into the type as anything but a boolean. Six rows are marked `Yes` in DESIGN §3.3 and are retriable unconditionally: `RateLimitExceeded`, `LinkUnavailable`, `CircuitBreakerOpen`, `ConnectionTimeout`, `RequestTimeout`, `IdleTimeout`. `DownstreamError` is marked `Depends`, and that cell is resolved non-retriable for the `Retry-After` gate in this feature (§1.5): the retry decision for a 502 belongs to the caller and to `cpt-cf-oagw-feature-data-plane-proxy`, which owns upstream-failure policy. The two §1.5-added variants are marked `No`, since neither an alias conflict nor a match conflict is answered differently by an unchanged retry. Every remaining row is marked `No` and is non-retriable. + +**Steps**: +1. [x] - `p1` - Resolve the GTS `type` identifier for the variant from the catalogue; the identifier is taken verbatim from the DESIGN §3.3 row and is never synthesized from the variant name - `inst-errmap-type` +2. [x] - `p1` - **IF** the source tag is `gateway` - `inst-errmap-gateway-if` + 1. [x] - `p1` - Emit a response with the row's status code and GTS `type`, `Content-Type: application/problem+json`, and the RFC 9457 fields `type`, `title`, `status`, `detail`, and `instance` - `inst-errmap-problem` + 2. [x] - `p1` - Attach every present `ErrorContext` member to the problem body as an extension field - `inst-errmap-extensions` + 3. [x] - `p1` - Set `X-OAGW-Error-Source: gateway` - `inst-errmap-gateway-header` +3. [x] - `p1` - **ELSE** - `inst-errmap-else` + 1. [x] - `p1` - Pass the upstream response through with its body and content type unmodified and set `X-OAGW-Error-Source: upstream`; the problem-details mapping is not applied to an upstream-sourced failure - `inst-errmap-upstream` +4. [x] - `p1` - **IF** the variant is one of the six catalogue rows DESIGN §3.3 marks `Yes` (`RateLimitExceeded`, `LinkUnavailable`, `CircuitBreakerOpen`, `ConnectionTimeout`, `RequestTimeout`, `IdleTimeout`) and carries `retry_after_seconds` - `inst-errmap-retry-if` + 1. [x] - `p1` - Emit `Retry-After` from `retry_after_seconds` on the response - `inst-errmap-retry-emit` +5. [x] - `p1` - **RETURN** the response - `inst-errmap-return` + +`detail` never contains credential material, a `cred://` reference value, or any configuration value (`cpt-cf-oagw-nfr-credential-isolation`). The `trace_id` extension field is populated when a correlation context is available; the correlation identifier itself is supplied by `cpt-cf-oagw-feature-observability`, which owns the audit and metrics surface. + +### GTS Type Catalogue Provisioning + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-type-catalog-provisioning` + +**Input**: the OAGW GTS identifier constants and a resolved `TypesRegistryClient`. + +**Output**: a provisioned catalogue, or the per-entry failures that fail the post-init phase. + +The catalogue this feature owns: + +| GTS identifier | Kind | Declares | +|----------------|------|----------| +| `gts.cf.core.oagw.upstream.v1~` | type schema | Upstream aggregate, mirroring `schemas/upstream.v1.schema.json` | +| `gts.cf.core.oagw.route.v1~` | type schema | Route aggregate, mirroring `schemas/route.v1.schema.json` | +| `gts.cf.core.oagw.protocol.v1~` | type schema | Protocol base type | +| `gts.cf.core.oagw.auth_plugin.v1~` | type schema | Auth plugin base type | +| `gts.cf.core.oagw.guard_plugin.v1~` | type schema | Guard plugin base type | +| `gts.cf.core.oagw.transform_plugin.v1~` | type schema | Transform plugin base type | +| `gts.cf.core.errors.err.v1~` | type schema | Gateway error base type, the namespace of every problem `type` | +| `gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1` | instance | HTTP protocol value | +| `gts.cf.core.oagw.protocol.v1~cf.core.oagw.grpc.v1` | instance | gRPC protocol value | +| `gts.cf.core.errors.err.v1~cf.oagw.{slug}.v1` | instance, 21 distinct identifiers | One per error type in the DESIGN §3.3 catalogue with its slug taken verbatim from that table, plus one each for the two management-conflict variants added per §1.5. There are 21 identifiers for the 22 variants: 19 carry the catalogue's 20 DESIGN rows, because `RouteError` and `ValidationError` share `gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1`, and the other two are `gts.cf.core.errors.err.v1~cf.oagw.alias.conflict.v1` and `gts.cf.core.errors.err.v1~cf.oagw.match.conflict.v1`, one each for `AliasConflict` and `MatchConflict`. | + +Built-in plugin instance identifiers (`gts.cf.core.oagw.{auth,guard,transform}_plugin.v1~cf.core.oagw.{name}.v1`) are deliberately absent: they are registered by `cpt-cf-oagw-feature-plugin-system`, which owns the built-in catalogue and its resolvability rules. + +**Steps**: +1. [x] - `p1` - Collect the base type schemas and the instances they own into one batch, ordered so a parent type never follows its children - `inst-catalog-collect` +2. [x] - `p1` - **TRY** - `inst-catalog-try` + 1. [x] - `p1` - Call the registry's batch register; the registry sorts the batch lexicographically by GTS identifier, which additionally guarantees a base type (suffix `~`) precedes its instances - `inst-catalog-register` +3. [x] - `p1` - **CATCH** a catastrophic SDK failure such as an unavailable backend - `inst-catalog-catch` + 1. [x] - `p1` - Fail the post-init phase; do not retry and do not continue with an unprovisioned catalogue - `inst-catalog-catch-handle` +4. [x] - `p1` - **FOR EACH** per-entry result - `inst-catalog-loop` + 1. [x] - `p1` - **IF** the entry succeeded, or the identifier is already registered with byte-identical content, count it as success - `inst-catalog-idempotent` + 2. [x] - `p1` - **ELSE** record the entry as a failure with its GTS identifier and the typed error - `inst-catalog-collect-failure` +5. [x] - `p1` - **IF** any entry failed - `inst-catalog-fail-if` + 1. [x] - `p1` - Log every failing identifier at ERROR and fail the post-init phase; never skip an entry with a warning - `inst-catalog-fail` +6. [x] - `p1` - **RETURN** the provisioned catalogue - `inst-catalog-return` + +The batch register call is bounded by the platform SDK's default call timeout. A timeout is classified as the same catastrophic case the CATCH above handles: it fails the post-init phase and is neither retried nor partially re-issued. + +Entries registered before a failure remain in the registry. That is the rollback story rather than a defect: registration is idempotent for identical content, so a restart re-runs provisioning and the already-present entries succeed instead of conflicting. No other state is created by this feature, so there is nothing to revert beyond the process itself. + +**Operator recovery for a foreign identifier.** When the conflict case fires — an identifier already registered with content this gear does not own — the ERROR log carries the conflicting GTS identifier, the typed error the registry returned, and the gear that owns the existing entry. The operator inspects the existing registry entry against the catalogue row above: an entry left behind by an earlier run whose content no longer matches what the row declares makes the stale registry entry the thing to remove, while an identifier that does not match what this gear is registering makes the catalogue slug in this gear's configuration the thing to correct. Which of the two remediations applies is decided from that comparison, and it is an operator decision: provisioning never overwrites or deletes an entry it does not own. Already-registered entries are never rolled back; a corrected run re-runs provisioning and the entries already present succeed instead of conflicting. + +## 4. States (CDSL) + +### Gear Foundation Provisioning State Machine + +- [x] `p2` - **ID**: `cpt-cf-oagw-state-gear-foundation-lifecycle` + +**States**: `Unregistered`, `Configured`, `TypeCatalogProvisioned`, `Ready`, `StartupFailed` + +**Initial State**: `Unregistered` + +**Transitions**: +1. [x] - `p1` - **FROM** `Unregistered` **TO** `Configured` **WHEN** the init hook completes with a validated `OagwConfig` - `inst-state-init-ok` +2. [x] - `p1` - **FROM** `Unregistered` **TO** `StartupFailed` **WHEN** configuration loading or validation fails - `inst-state-init-fail` +3. [x] - `p1` - **FROM** `Configured` **TO** `TypeCatalogProvisioned` **WHEN** every catalogue entry is registered and the registry catalogue is in its ready phase (§1.4 run-level assumptions) - `inst-state-provisioned` +4. [x] - `p1` - **FROM** `Configured` **TO** `StartupFailed` **WHEN** any catalogue entry fails to register - `inst-state-provision-fail` +5. [x] - `p1` - **FROM** `TypeCatalogProvisioned` **TO** `Ready` **WHEN** the gear reports readiness; `Ready` serves only the router mount point, which carries no routes in this feature - `inst-state-ready` +6. [x] - `p1` - `StartupFailed` is terminal: the runtime aborts startup, so no later feature ever observes a half-initialized gear - `inst-state-terminal` + +The ordering constraint behind transition 3 is the registry's own two-phase catalogue. A registry catalogue starts in a staging phase that accepts registrations without validating them, and flips to its ready phase in the registry's post-init hook, after every gear's init has returned. OAGW provisioning therefore runs in the post-init phase, never during init: provisioning during init would write into a staging catalogue and bypass the validation that makes the catalogue trustworthy. The declared dependency on the types-registry gear keeps the runtime's topological order such that the registry's ready-flip precedes the OAGW post-init hook. + +## 5. Definitions of Done + +### Gear Registration and Router Mount Point + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-gear-registration` + +The system **MUST** register `oagw` as a ToolKit gear from `gear.rs` with the public surface exported from `lib.rs`, expose the empty gear-relative router mount point at `/oagw/v1` for later features to populate, and register no endpoint, handler, or route of its own (`cpt-cf-oagw-adr-request-routing`). + +**Implements**: + +- `cpt-cf-oagw-flow-gear-init` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: none — the mount point only; no `METHOD /path` is registered by this feature +- DB: none — no persistence; `cpt-cf-oagw-db-schema` is claimed by later features +- DB Table: none +- Entities: `OagwGear` + +### Configuration Surface and Validation + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-config-surface` + +The system **MUST** load `OagwConfig` through the platform configuration provider with exactly the five configurable families tabulated under `cpt-cf-oagw-algo-config-load-validate`, apply the declared defaults when `oagw.config` is absent, reject unknown keys, reject out-of-range values, and fail gear init on any violation. `allow_http_upstream` **MUST** be the only input that lets the `Scheme` value object admit the `http` literal at write time. + +**Implements**: + +- `cpt-cf-oagw-flow-gear-init` +- `cpt-cf-oagw-algo-config-load-validate` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy`, `cpt-cf-oagw-constraint-https-only` + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: `OagwConfig`, `SsrfPolicy`, `Scheme` + +### Domain Model Types and Layering + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-domain-model-types` + +The system **MUST** declare `Upstream`, `Route`, and `Plugin` together with `Endpoint`, `ServerConfig`, `AuthConfig`, `HeadersConfig`, `RateLimitConfig`, `CorsConfig`, and `PluginsConfig` in the domain layer, and **MUST** keep every one of them free of transport and persistence types (`cpt-cf-oagw-design-layers`). The shipped JSON Schemas are mirrored property for property where a schema declares the type: `Upstream` **MUST** carry the properties `schemas/upstream.v1.schema.json` declares, and `Route` **MUST** carry the properties `schemas/route.v1.schema.json` declares plus the properties added per §1.5 — the route-level `cors` object, and the `priority` and `enabled` route attributes of the DESIGN §3.1 Route class. `Plugin` and the sub-configuration types follow the DESIGN §3.1 domain model, for which no schema is shipped. Alias, hostname, and scheme value objects **MUST** be normalized and validated once, here, and reused by every later feature rather than re-derived at each call site. + +**Implements**: + +- `cpt-cf-oagw-algo-alias-normalize` + +**Constraints**: `cpt-cf-oagw-constraint-multi-sql` + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: `Upstream`, `Route`, `Plugin`, `Endpoint`, `ServerConfig`, `AuthConfig`, `HeadersConfig`, `RateLimitConfig`, `CorsConfig`, `PluginsConfig`, `Alias`, `Hostname`, `Scheme` + +Domain purity is a compile-time gate, not a review convention. Domain types are marked so that a field whose type comes from `http`, `axum`, `sea_orm`, or `sqlx` fails macro expansion with a message naming the offending field, and the `DE0301` and `DE0308` lint rules reject infrastructure imports in the domain layer. A domain type that needs a transport shape — an HTTP status code, a header map — forces a mapping type in `api/rest` instead of a dependency. + +### GTS Identifier Catalogue and Provisioning + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-gts-type-catalog` + +The system **MUST** declare the GTS identifier constants for the upstream, route, protocol, error, and plugin base types, and **MUST** provision the catalogue through the `types_registry` SDK during the post-init phase, idempotently for identical content and failing closed on any per-entry error (`cpt-cf-oagw-contract-types-registry`). The provisioned error instance set **MUST** include the two management-conflict instances added per the §1.5 row citing DECOMPOSITION §1.3(9), so a 409 answered anywhere in the gear carries a provisioned GTS `type`. Built-in plugin instance identifiers are **NOT** provisioned here; `cpt-cf-oagw-feature-plugin-system` registers them. + +**Implements**: + +- `cpt-cf-oagw-flow-type-provisioning` +- `cpt-cf-oagw-algo-type-catalog-provisioning` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: the base type schemas and instances tabulated under `cpt-cf-oagw-algo-type-catalog-provisioning` + +### Canonical Error Type and Mapping + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-error-catalogue` + +The system **MUST** declare `DomainError` with one variant per error type in the DESIGN §3.3 catalogue (`cpt-cf-oagw-interface-api`) plus the two management-conflict variants added per §1.5, each carrying its HTTP status, its GTS `type` identifier, its Retriable flag, and its `gateway`/`upstream` source tag, and **MUST** map gateway-sourced errors to `application/problem+json` while leaving upstream-sourced failures as passthrough (`cpt-cf-oagw-principle-rfc9457`, `cpt-cf-oagw-adr-error-source-distinction`). `AliasConflict` and `MatchConflict` **MUST** answer 409 with the GTS `type` identifiers §1.5 records and **MUST NOT** be retriable. Every gateway error answered anywhere in the gear goes through this mapping, so later features add no second error serialization path. + +**Implements**: + +- `cpt-cf-oagw-algo-error-mapping` + +**Constraints**: none from DESIGN §2.2; the governing elements are the principle and the ADR cited above. + +**Touches**: + +- API: none — the mapping is a domain-to-transport function with no endpoint exposing it in this feature +- DB: none +- DB Table: none +- Entities: `DomainError`, `ErrorSource`, `ErrorContext` + +gear-foundation is the single definition point for `Scheme`, `SsrfPolicy`, `OagwGear`, `ErrorSource`, and `ErrorContext` — the five types named in the Entities lines above and listed in DECOMPOSITION §2.1 as the gear's shared vocabulary — and later features consume them and **MUST NOT** redeclare them. + +### Colocated Test Placement and Coverage + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-test-placement` + +The system **MUST** deliver this feature's unit and integration tests colocated under `gears/system/oagw/oagw/tests/`, covering configuration validation, alias and hostname normalization, the error mapping, the type-catalogue provisioning, and the domain layer's freedom from infrastructure types, and **MUST NOT** add any test under `testing/e2e/gears/oagw/`. + +**Implements**: + +- `cpt-cf-oagw-algo-config-load-validate` +- `cpt-cf-oagw-algo-alias-normalize` +- `cpt-cf-oagw-algo-error-mapping` +- `cpt-cf-oagw-algo-type-catalog-provisioning` + +**Constraints**: none from DESIGN §2.2; this is the DECOMPOSITION §1.3(3) placement deviation recorded in §1.5. + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: none — tests only + +## 6. Acceptance Criteria + +- [x] The `oagw` gear registers with the ToolKit runtime, and `lib.rs` exports the gear type, `OagwConfig`, the domain model types, the GTS identifier constants, and `DomainError`. +- [x] A request to any `/oagw/v1/...` path is answered by no OAGW handler: the mount point exists and carries no routes. +- [x] With no `oagw.config` section at all, gear init succeeds and every configuration key takes its declared default from the table under `cpt-cf-oagw-algo-config-load-validate`. +- [x] `proxy_timeout_secs: 0`, a non-positive `token_cache_capacity`, or an unknown key under `oagw.config` fails gear init and aborts startup. +- [x] `allow_http_upstream: false` makes the `http` scheme literal fail the write-time admission check, and `allow_http_upstream: true` makes it pass, with no other input affecting the outcome. +- [x] Alias and hostname inputs are normalized to ASCII lowercase with trailing dots stripped, and an RFC 1123-invalid hostname is rejected. +- [x] `api.openai.com` and `api.openai.com:8443` normalize to distinct alias values. +- [x] Each of the 22 `DomainError` variants — the catalogue's 20 DESIGN §3.3 rows plus the two §1.5-added management-conflict variants — has one mapping that yields that variant's HTTP status and GTS `type` identifier, and the two added variants both yield 409. +- [x] A gateway-sourced error response carries `Content-Type: application/problem+json`, `X-OAGW-Error-Source: gateway`, and no credential material or configuration value in `detail`. +- [x] An upstream-sourced failure is passed through with `X-OAGW-Error-Source: upstream` and is not rewritten into a problem body. +- [x] The six catalogue rows DESIGN §3.3 marks `Yes` (`RateLimitExceeded`, `LinkUnavailable`, `CircuitBreakerOpen`, `ConnectionTimeout`, `RequestTimeout`, `IdleTimeout`) emit `Retry-After` when they carry `retry_after_seconds`, and every other row — including the `DownstreamError` row resolved non-retriable in §1.5 — does not. +- [x] After startup, every base type schema and instance in the provisioning table is resolvable through the types-registry — the 21 distinct error identifiers covering the 22 variants included, the two §1.5-added management-conflict identifiers among them — and re-registering an entry with byte-identical content does not fail startup. +- [x] A per-entry registration failure prevents the gear from ever reporting readiness, and the failing GTS identifiers are logged. +- [x] No domain type references a transport or persistence type; the domain-purity compile gate and the `DE0301`/`DE0308` lint rules pass. +- [x] Every test for this feature lives under `gears/system/oagw/oagw/tests/`, passes there, and no test is added under `testing/e2e/gears/oagw/`. +- [x] Each of `Upstream` and `Route` carries the properties its shipped JSON Schema declares for that type — no missing, extra, or renamed field — verified against that schema's `properties` set, with `Route` additionally carrying the §1.5-added `cors`, `priority`, and `enabled`. diff --git a/gears/system/oagw/docs/features/hierarchical-config.md b/gears/system/oagw/docs/features/hierarchical-config.md new file mode 100644 index 0000000..da1974c --- /dev/null +++ b/gears/system/oagw/docs/features/hierarchical-config.md @@ -0,0 +1,655 @@ +# Feature: Hierarchical 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 Feature-Local Deviations from Shared Baselines](#15-feature-local-deviations-from-shared-baselines) + - [1.6 Explicit Non-Applicability](#16-explicit-non-applicability) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Bind a Descendant Upstream to an Ancestor Upstream](#bind-a-descendant-upstream-to-an-ancestor-upstream) + - [Override an Inherited Configuration Field](#override-an-inherited-configuration-field) + - [Resolve the Effective Configuration for a Proxy Request](#resolve-the-effective-configuration-for-a-proxy-request) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Tenant Chain Walk from Descendant to Root](#tenant-chain-walk-from-descendant-to-root) + - [Ancestor Alias Resolution and Shadowing](#ancestor-alias-resolution-and-shadowing) + - [Per-Field-Family Effective Merge](#per-field-family-effective-merge) + - [Sharing-Mode and Permission Decision](#sharing-mode-and-permission-decision) + - [Binding-Style Creation with Tenant-Local Tags](#binding-style-creation-with-tenant-local-tags) +- [4. States (CDSL)](#4-states-cdsl) +- [5. Definitions of Done](#5-definitions-of-done) + - [Tenant Chain Walk](#tenant-chain-walk) + - [Alias Shadowing and Effective Enabled State](#alias-shadowing-and-effective-enabled-state) + - [Per-Field-Family Effective Merge](#per-field-family-effective-merge-1) + - [Sharing-Mode and Permission Decision](#sharing-mode-and-permission-decision-1) + - [Descendant Override Permissions](#descendant-override-permissions) + - [Binding-Style Creation with Tenant-Local Tags](#binding-style-creation-with-tenant-local-tags-1) + - [Effective Configuration Result Types](#effective-configuration-result-types) + - [Resolution Test Coverage and Placement](#resolution-test-coverage-and-placement) +- [6. Acceptance Criteria](#6-acceptance-criteria) + + + + + +- [ ] `p1` - **ID**: `cpt-cf-oagw-featstatus-hierarchical-config-implemented` + + +- [ ] `p2` - `cpt-cf-oagw-feature-hierarchical-config` + +## 1. Feature Context + +### 1.1 Overview + +This feature turns many tenants' configuration into one answer. It walks the tenant tree from a calling tenant to the platform root using the chain the platform tenant-resolver supplies, resolves an alias against that chain with the closest match winning, applies the three sharing modes `private`, `inherit`, and `enforce` per configuration field, and produces one effective configuration per resolution: an `EffectiveUpstreamConfig` and an `EffectiveRouteConfig` whose five field families (auth, rate limit, plugins, CORS, tags) are already merged. + +### 1.2 Purpose + +DECOMPOSITION §2.3 places this feature third in the feature graph, behind `cpt-cf-oagw-feature-control-plane-config`, which persists the upstream and route rows this feature walks and owns the sharing-mode fields on those rows. Every feature downstream of it consumes the effective configuration: `cpt-cf-oagw-feature-data-plane-proxy` calls the resolution on every proxy request and applies the result in the upstream, then route, then tenant order; `cpt-cf-oagw-feature-rate-limiting` consumes the rate-limit row of the merge; `cpt-cf-oagw-feature-cors` consumes the CORS row. Without it, a partner or customer tenant can neither inherit, tighten, nor be forced by an ancestor's configuration, and every tenant is an island that happens to share a database. + +This feature delivers the DESIGN §3.2 Hierarchical Configuration subsection — the four-row merge table and the tag paragraph — together with the plugin-free share of the DESIGN §3.2 Permissions and Access Control subsection, which is the four descendant override permissions `oagw:upstream:bind`, `oagw:upstream:override_auth`, `oagw:upstream:override_rate`, and `oagw:upstream:add_plugins`. Alias derivation, normalization, and `(tenant_id, alias)` uniqueness are not delivered here: `cpt-cf-oagw-feature-control-plane-config` derives and enforces the alias at write time, and this feature resolves an already-normalized alias against the chain, reusing the foundation's normalization routine so that write-time storage and resolution can never disagree about what an alias looks like. + +Deliverables: + +- The tenant chain walk from descendant to root, and the per-tenant `(tenant_id, alias)` lookup that produces the ordered candidate set. +- Ancestor alias resolution and shadowing, with the enforced fields of a shadowed ancestor carried into the result and the effective `enabled` state computed across the chain. +- The per-field effective merge for all five field families: auth, rate limit, plugins, CORS, and tags. +- The sharing-mode and permission decision for `private`, `inherit`, and `enforce`, applied per configuration field. +- Binding-style upstream creation against an ancestor alias, with request tags kept as tenant-local additions. +- `EffectiveUpstreamConfig`, `EffectiveRouteConfig`, `TenantChain`, `AncestorBinding`, and the per-family merge results. + +**Requirements**: + +- [x] `p2` - `cpt-cf-oagw-fr-config-layering` +- [x] `p2` - `cpt-cf-oagw-fr-hierarchical-config` +- [x] `p2` - `cpt-cf-oagw-fr-alias-resolution` +- [ ] `p1` - `cpt-cf-oagw-fr-enable-disable` +- [ ] `p1` - `cpt-cf-oagw-nfr-multi-tenancy` + +**Principles**: + +- `p1` - `cpt-cf-oagw-principle-tenant-scope` + +**Constraints**: + +- `p1` - `cpt-cf-oagw-constraint-multi-sql` + +**Design Components**: + +- `p2` - `cpt-cf-oagw-component-model` + +**Domain Model Entities**: + +- `SharingMode` (`private` / `inherit` / `enforce`), one value per configuration field family +- `EffectiveUpstreamConfig` and `EffectiveRouteConfig`, one per layer per resolution +- `TenantChain`, the ordered ancestor chain the platform supplies +- `AncestorBinding`, the alias-match link between a descendant's upstream row and a more distant ancestor's upstream row with the same normalized alias +- The per-family merge results `EffectiveAuth`, `EffectiveRateLimit`, `EffectivePluginChain`, `EffectiveCors`, and `EffectiveTagSet` + +`cpt-cf-oagw-nfr-multi-tenancy` is carried as coverage rather than as a delivered requirement: DECOMPOSITION §2.3 lists it unchecked alongside the three checked requirements, and the threshold it states — zero cross-tenant data access — is met here by the fact that every read in the walk is tenant-scoped and no tenant's rows are ever read for a caller that cannot read them. The persisted scoping itself, the predicate that makes that true, belongs to `cpt-cf-oagw-feature-control-plane-config` (`cpt-cf-oagw-dod-tenant-scoping`). + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-oagw-actor-tenant-admin` | Creates an upstream whose alias matches an ancestor's (the bind), overrides the inherited fields its permissions allow, and receives the 400, 403, 404, and 409 answers the sharing modes and the permissions produce. | +| `cpt-cf-oagw-actor-app-developer` | Originates the proxy request whose alias this feature resolves; the request reaches this feature through the resolution routine the Data Plane calls, not through any endpoint of this feature. | + +`cpt-cf-oagw-actor-platform-operator` is named by PRD §5.5 as an actor of both `cpt-cf-oagw-fr-config-layering` and `cpt-cf-oagw-fr-hierarchical-config`, because the ancestor side of every walk is configuration somebody at or above the partner tenant authored. DECOMPOSITION §1.5 maps that actor to `gear-foundation`, `control-plane-config`, `plugin-system`, and `observability`, and does not list this feature against it. Both statements are honoured here: the operator participates as the author of the ancestor configuration the walk consumes, every operation that configuration is written through is a management write `cpt-cf-oagw-feature-control-plane-config` registers, and no flow below names the operator as its triggering actor. This feature adds no surface the operator does not already have. + +The other four actors do not participate: + +- `cpt-cf-oagw-actor-cred-store` is not called. The merge carries `auth.type` and the opaque `auth.config` object; resolving a `secret_ref` into secret material happens at proxy time and belongs to `cpt-cf-oagw-feature-plugin-system` and `cpt-cf-oagw-feature-data-plane-proxy`. DECOMPOSITION §1.5 lists the credential store under `cpt-cf-oagw-feature-plugin-system` alone. +- `cpt-cf-oagw-actor-types-registry` is not called. The GTS catalogue was provisioned once by `cpt-cf-oagw-feature-gear-foundation`; a resolution registers no type. +- `cpt-cf-oagw-actor-upstream-service` is never contacted. No resolution opens a connection; the first outbound dial of a proxied request happens after this feature has returned. +- `cpt-cf-oagw-actor-app-developer` is named above as the originator of a resolution and nothing more: DECOMPOSITION §1.5 maps that actor to `data-plane-proxy`, `rate-limiting`, and `streaming`, the features that own the surface the developer touches, and this feature exposes no endpoint to anyone (DECOMPOSITION §2.3, `API: None`). + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) +- **Design**: [DESIGN.md](../DESIGN.md) +- **Dependencies**: `cpt-cf-oagw-feature-control-plane-config` — the persisted upstream and route rows this feature reads, the repository traits it reads them through, the Control Plane L1 cache those reads go through, the write path that stores the rows a bind-style create produces, and the sharing-mode fields whose values are the input to every decision below (DECOMPOSITION §3). + +Supporting sources this feature stays consistent with: + +- [schemas/upstream.v1.schema.json](../schemas/upstream.v1.schema.json) and [schemas/route.v1.schema.json](../schemas/route.v1.schema.json) — the per-family `sharing` enums (`auth.sharing`, `plugins.sharing`, `rate_limit.sharing`, `cors.sharing`, each defaulting to `private`) whose values are the input to the sharing-mode decision, and the `tags` arrays that carry no sharing field at all. Both files are frozen inputs this run does not edit. +- [ADR/0003-rate-limiting.md](../ADR/0003-rate-limiting.md) (`cpt-cf-oagw-adr-rate-limiting`) — the inheritance table whose `private` row states that an ancestor limit marked `private` contributes nothing, and whose `enforce` row states that the effective limit is `min(parent, child)`. The token bucket that enforces the merged value belongs to `cpt-cf-oagw-feature-rate-limiting`. +- [ADR/0004-cors.md](../ADR/0004-cors.md) (`cpt-cf-oagw-adr-cors`) — the hierarchical CORS example (parent and child origins unioned under `inherit`, child additions refused under `enforce`). +- [ADR/0005-data-plane-caching.md](../ADR/0005-data-plane-caching.md) (`cpt-cf-oagw-adr-data-plane-caching`) — the `upstream:{tenant_id}:{alias}` cache-key shape, which is one entry per chain element of the walk below. +- [ADR/0006-state-management.md](../ADR/0006-state-management.md) (`cpt-cf-oagw-adr-state-management`) — the `CP.resolve_proxy_target(alias, method, path)` entry point whose single tenant hierarchy walk and effective-config merge are this feature's §3 routines, and the `(EffectiveUpstream, MatchedRoute)` pair the Data Plane caches. +- [config/e2e-local.yaml](../../../../../config/e2e-local.yaml) — the graded configuration. Its `gears.oagw.config` block carries only `proxy_timeout_secs`, `allow_http_upstream`, and `ssrf_policy.enabled`, so there is no hierarchy, sharing, or chain key to configure; the tenant tree it grades against is declared under the `static-tr-plugin` gear as a `tenants` list of `id`, `name`, `parent_id`, and `status`, and a `tenant-resolver` gear sits alongside it with a `vendor` key and nothing else. + +**Run-level assumptions** — premises this feature relies on that come from the platform runtime rather than from PRD, DESIGN, the ADRs, or DECOMPOSITION: + +- Assumption: the platform tenant-resolver exposes the calling tenant's ancestor chain to the `oagw` gear, through the same in-process SDK style the `types_registry` and `cred_store` contracts use. PRD §11 states only that "OAGW receives tenant_id from SecurityContext" and that "Tenant hierarchy is resolved by the platform (tenant-resolver gear)"; DESIGN §3.3 Tenant Scoping states that `resolve_alias` "walks the tenant chain"; DECOMPOSITION §2.3 assigns the walk here and puts the tree outside this feature. No supplied document states that the chain itself is handed to a gear. `config/e2e-local.yaml` gives the `tenant-resolver` gear a `vendor` key and nothing else, so nothing in the graded configuration names an OAGW-consumable chain key. If the chain is not exposed, the walk cannot run, and every resolution must fail closed — a not-found outcome, never a guess and never a cross-tenant answer. +- Assumption: the chain arrives as an ordered list from the calling tenant to the platform root, inclusive of both ends, without cycles, and with the calling tenant as its first element. The shadowing order of PRD §5.5 (`subsub-tenant`, then `sub-tenant`, then `root-tenant`) is stated as an order, but no source states the list's shape or which end is first. If the resolver omits the calling tenant, the walk prepends it, because the calling tenant's own rows must be the closest candidates; if the order is absent, the walk cannot order candidates and must fail closed rather than pick one. +- Assumption: the four descendant override permissions `oagw:upstream:bind`, `oagw:upstream:override_auth`, `oagw:upstream:override_rate`, and `oagw:upstream:add_plugins` are denied by default, and this feature evaluates them itself, inside its own flows at the point where the sharing-mode decision runs; the platform middleware that step 2 of each management flow names enforces the management permission family `gts.cf.core.oagw.upstream.v1~:{create;override;read;delete}` only and does not evaluate these four. The 403 a denied override produces is therefore this feature's own answer, returned as a bare 403 problem answer and not as a `DomainError` variant. DESIGN §3.2 says only that the ability to override "depends on permissions granted by ancestors"; no supplied document states who grants them, where they are stored, or how they are evaluated. If the platform does not grant and store them, this feature has nothing to evaluate, and the override path must deny rather than allow, because an unenforceable permission is not a permission. +- Assumption: the chain depth bounds the walk's cost. `config/e2e-local.yaml` declares seven tenants on four active levels — `e2e-root` at the first level, `hierarchy-root` at the second, `hierarchy-l1a` and `hierarchy-l1b` at the third, and `hierarchy-l2b` and `hierarchy-l2c` active at the fourth, alongside a fourth-level `hierarchy-l2a-deleted` whose `status` is `deleted` and which is therefore not an active participant. The static tokens it declares reach only the first three of those levels, so the deepest chain an authenticated graded caller produces is three tenants. If a deployment supplies a chain deeper than the platform bounds it, the walk's cost grows with it and no cache in this feature caps that, because this feature owns no cache. + +### 1.5 Feature-Local Deviations from Shared Baselines + +| Deviation | Rationale | Review owner | Validation performed | +|-----------|-----------|--------------|----------------------| +| The resolution result is named `EffectiveUpstreamConfig` and `EffectiveRouteConfig`, not the `EffectiveUpstream` and `MatchedRoute` names of the ADR 0006 request-flow diagram. | DECOMPOSITION §1.3 states that it prevails over the supplied documents wherever they conflict, and DECOMPOSITION §2.3 names the two entities this feature delivers. The ADR names appear once, inside a code block that sketches a request flow. `cpt-cf-oagw-feature-data-plane-proxy` reads its own DECOMPOSITION §2.5 entity names — `ResolvedUpstream`, `SelectedEndpoint`, and `MatchedRoute` — for the resolution it performs at proxy time, so the two features' result types are distinct and not shared. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The walk collects candidates from descendant to root and the merge is applied from root to child. | DESIGN §3.3 Tenant Scoping states that `resolve_alias` "walks the tenant chain (descendant → root)"; DESIGN §3.6 Common Queries states that resolving the effective configuration means "walk hierarchy, collect bindings, merge from root to child per sharing modes". Both are true at once: the collection order decides who shadows whom, and the application order decides which value is the base and which the override. Applying the merge in the collection order would make the root's value the override of the leaf's, which contradicts every per-field strategy in DESIGN §3.2. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| A bind-style create against an ancestor upstream whose field is `private` proceeds and inherits nothing for that field; `private` blocks the visibility of the ancestor's value, not the bind itself. | PRD §5.5 defines `private` as "not visible to descendants (default)" and DESIGN §3.3 CRUD Semantics lists `private` as one of the sharing-mode constraints a bind respects. Reading `private` as blocking the bind itself would make the default mode block every bind, because all four sharing-bearing families default to `private` in the shipped schema, and would leave PRD §5.5's binding-style sentence with no reachable case. The ancestor's `private` value is therefore never read into a result, never copied onto the descendant's row, and never echoed in an answer. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| A field the ancestor marks `private` needs no descendant override permission, so a descendant's own value for it is its own configuration. | DESIGN §3.2 states that "without appropriate permissions, descendant must use ancestor's configuration as-is (even with `sharing: inherit`)". The qualifier names `inherit`, which is the only mode under which an ancestor value exists to use as-is; with `private` there is no inherited value, so there is nothing to override and no permission to consume. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| An override an ancestor `enforce` field blocks is answered 400 with the validation error, not 403. | DESIGN §3.3's error catalogue (`cpt-cf-oagw-interface-api`) has no 403 row: its client-error rows are 400, 401, 404, and the single 409 `PluginInUse` row; it has no 403 row. The catalogue is `cpt-cf-oagw-feature-gear-foundation`'s to own and this feature must not add a variant to it. An `enforce` sharing mode is a property of the ancestor's stored configuration, so a body that supplies a value for such a field fails the validation of that write against the effective configuration, which is what the 400 row answers. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| An override a descendant lacks the permission for is answered 403 by this feature, at the point where the sharing-mode decision runs, and is not a `DomainError` variant. | The `oagw:upstream:*` descendant override permissions are evaluated inside this feature's flows, not by the platform middleware, which enforces only the management permission family at step 2 of each management flow. One ordering rule governs both management flows: schema validation, then the permission check, then the per-family sharing checks, so the 403 precedes any `enforce` 400, because an unauthorized caller must not learn which families are enforced. This is the answer `cpt-cf-oagw-feature-control-plane-config` gives for a missing management permission, and it keeps one answer for one class of failure: a permission the caller does not hold is an authorization outcome, not a property of the body. The permission family is the `oagw:upstream:*` one, whose evaluation is a run-level assumption recorded in §1.4. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The `min(ancestor, descendant)` rate-limit strategy is applied to an ancestor rate limit only when that ancestor marks it `inherit` or `enforce`; an ancestor `rate_limit` marked `private` contributes nothing and the descendant's own value stands alone. | DESIGN §3.2 states the strategy without restating the mode gate; ADR 0003's inheritance table states it explicitly, with `private` yielding "child's limit only" and `enforce` yielding `min(parent, child)`. The per-family `sharing` field is what carries the mode, so a family marked `private` cannot be a participant in a merge with a descendant's value. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| Comparing two sustained rates — and, under the same rule, two `burst.capacity` values — requires a common scale, and no supplied document states one for the rate. The merge normalizes both rates to requests per second, takes the minimum of the visible rates and the minimum of the visible `burst.capacity` values under the same mode gate, and reports the effective limit in the window of the value that supplied the rate minimum and the effective burst as the capacity that supplied the capacity minimum. | `min` is stated over values that carry a `sustained.window` of `second`, `minute`, `hour`, or `day`, so `100/second` against `5000/minute` is not decidable without a normalization. Per-second normalization is the only unit-free comparison available, and reporting the winner's own window keeps the merged value inside the schema's `window` enum rather than inventing a sub-second unit the schema does not declare. `burst.capacity` is declared as a plain integer with no window of its own, so its common scale is the token count itself and no unit conversion arises for it; it is brought to a common scale exactly as the sustained rate is, and neither minimum is decidable without that common scale. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The sustained rate and `burst.capacity` are each merged as a minimum; `algorithm`, `scope`, `strategy`, and `cost` are carried as they are, and no merge is applied to them. | ADR 0003's Example 1 computes an effective burst as a minimum across the chain (`min(1000, 500, 100)` = `100`) beside the effective sustained rate it computes the same way, so the supplied baseline does state a merge for `burst.capacity`. DESIGN §3.2's row, PRD §5.5's override rule, ADR 0003's inheritance table, and DECOMPOSITION §2.6's canonical form all state a minimum over limits and none of them states a merge for `algorithm`, `scope`, `strategy`, or `cost`. The token-bucket meaning of those members belongs to `cpt-cf-oagw-feature-rate-limiting` (DECOMPOSITION §2.6), which is also where budget modes and overcommit validation live. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The CORS union is confined to `allowed_origins`. Under `enforce` the ancestor's whole `cors` object is the effective one; under `inherit` the origins union and the remaining CORS members come from the routing target's row; under `private` the routing target's own object is the effective one. | DESIGN §3.2's row states "union origins if `inherit`; forced if `enforce`" and ADR 0004's merge example unions `allowed_origins` and nothing else. No supplied document states a merge for `enabled`, `allowed_methods`, `expose_headers`, or `allow_credentials`, and a union over `allow_credentials` is not a meaningful operation. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The `headers` family is not merged across the hierarchy; the header rules of the upstream that owns the resolution apply. | Neither shipped schema declares a `sharing` field on `headers`, and DESIGN §3.2's merge table has no row for it. Header transformation itself belongs to `cpt-cf-oagw-feature-data-plane-proxy` (DECOMPOSITION §2.5), so a merge here would produce a value nothing is specified to consume. The gap is recorded rather than filled by analogy. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The four-permission table of DESIGN §3.2 names no permission for the CORS family, so a descendant's own `cors` under an ancestor's `inherit` is gated by the sharing mode alone, and the union of ADR 0004 happens with no permission check. | The table lists exactly `oagw:upstream:bind`, `oagw:upstream:override_auth`, `oagw:upstream:override_rate`, and `oagw:upstream:add_plugins`, and DECOMPOSITION §2.3 lists the same four as this feature's deliverable. Inventing a fifth permission is outside this feature's authority, and refusing the union would collapse `cors.sharing: inherit` into the same descendant-visible behaviour as `enforce`, which would leave ADR 0004's merge example — parent and child origins unioned — with no reachable case. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The four `oagw:upstream:*` permissions gate the same families on a route row as on an upstream row, and the `upstream` segment of their names is the family namespace, not a restriction to upstream rows. | A route carries three of the four sharing-bearing families (`rate_limit`, `plugins`, `cors`), and of those three `rate_limit` is the only one whose override permission exists in the four-permission table (`oagw:upstream:override_rate`); `plugins` is gated by the plugin arms that `cpt-cf-oagw-feature-plugin-system` enforces; and `cors` has no permission at all, so `cors.sharing` alone decides. DESIGN §3.2's table states the abilities — "specify own rate limits (subject to min())", "append own plugins to inherited chain" — over the configuration, and DECOMPOSITION §2.6 states the route rate as one participant of the same `min` that the permission gates. No supplied document states a second permission family for routes, and `cpt-cf-oagw-interface-management-api` declares none. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The effective `enabled` state is the conjunction across every ancestor row the walk matched on the alias, regardless of that row's per-family sharing modes, including a row whose families are all `private`. | The shipped schema gives `enabled` no `sharing` sibling, so the mode gate that applies to the four sharing-bearing families has nothing to attach to; PRD §5.1 (`cpt-cf-oagw-fr-enable-disable`) states the ancestor disable rule unconditionally ("disabled for all descendants"), and DECOMPOSITION §1.3(11) preserves `enabled` across a replacement that omits it, and DECOMPOSITION §2.3 assigns the walk and the effective `enabled` state to this feature. Reading `private` as blocking the flag would make a disabled-and-private ancestor invisible to the very check that keeps it disabled. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The ancestor binding is not materialized: it is the alias match the walk discovers between a descendant's row and a more distant ancestor's row with the same normalized alias, and no table, column, or join row records it. | DESIGN §3.6 tabulates the gear's tables and none of them is a binding table; the upstream table's only uniqueness key is `(tenant_id, alias)`. DECOMPOSITION §2.3 lists "ancestor binding" among this feature's entities and, in the same entry, puts persisting hierarchy data out of scope. Deriving the binding from the alias match at resolution time is the only reading under which both hold, and it is also what makes `private`-blocks-visibility enforceable: a binding that no row records cannot outlive the configuration that produced it. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| This feature's tests are colocated at `gears/system/oagw/oagw/tests/` instead of `testing/e2e/gears/oagw/`. | DECOMPOSITION §1.3(3) reserves `testing/e2e/gears/oagw/` for the acceptance suite; every unit and integration test this decomposition produces lives with the crate. This is the same deviation `cpt-cf-oagw-feature-gear-foundation` and `cpt-cf-oagw-feature-control-plane-config` record in their own §1.5 tables, restated here because the tests it governs include this feature's. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | + +### 1.6 Explicit Non-Applicability + +The areas below apply to the gear as a whole but not to this feature. Each is stated here so the omission is a recorded decision rather than a silent gap. + +- **Proxy-time consumption**: the effective configuration is this feature's output and nothing more. Applying it, matching the route against a request, executing the plugin chain, and answering the caller belong to `cpt-cf-oagw-feature-data-plane-proxy` (DECOMPOSITION §2.5), which also decides whether an ancestor-owned target may be used at all under the "owned by token's tenant or shared by ancestor" authorization check of DESIGN §3.3. This feature supplies the per-field sharing modes and the resolved ownership that check needs; it renders no verdict of its own. +- **Token-bucket mechanics**: the merge produces one effective sustained rate and one effective `burst.capacity`; counting requests against them, answering 429, and emitting the `X-RateLimit-*` and `Retry-After` headers belong to `cpt-cf-oagw-feature-rate-limiting` (DECOMPOSITION §2.6), together with the budget modes and overcommit validation of ADR 0003. +- **CORS enforcement**: the merged CORS configuration is this feature's output; the permissive preflight answer, the origin check, and the 403 answer for a disallowed origin belong to `cpt-cf-oagw-feature-cors` (DECOMPOSITION §2.7). +- **Persisting hierarchy data**: the tenant tree comes from the platform tenant-resolver, so this feature creates no table, writes no hierarchy row, adds no column to the tables `cpt-cf-oagw-feature-control-plane-config` owns, and claims no share of `cpt-cf-oagw-db-schema`, which DECOMPOSITION §2.3 confirms by listing `Data: None` for this entry. +- **Events**: no event is published or consumed here. A resolution is a read, and the audit log, the metrics, and the configuration-change reporting that describe configuration belong to `cpt-cf-oagw-feature-observability`. +- **Rollout and rollback**: the gear is one configuration item and one release unit (DECOMPOSITION §1.4), so this feature ships no rollout of its own and has no independent rollback path. It persists nothing, so there is no state of its own to roll back. +- **Readiness and health**: this feature contributes no readiness or health signal of its own. Gear readiness belongs to `cpt-cf-oagw-feature-gear-foundation`'s provisioning state machine, and the failures a resolution can produce surface only as the protocol answers §2 and §3 already declare — a not-found outcome, the bare 403 problem answer of §1.5, or the platform 500 problem shape — never as a health probe, a readiness gate, or a status endpoint of this feature's. +- **Versioning**: every GTS identifier this feature reads or produces is fixed at `.v1` by DESIGN §3.1. No version negotiation, aliasing, or migration surface exists here, and the `.v1` of a resolved configuration type is not a versioned contract with any caller. +- **Compliance and privacy**: the families this feature merges are endpoint sets, sharing values, plugin references, rate limits, CORS origin lists, and tag strings; none carries personal or regulated data. The one credential-bearing family, `auth`, is merged as the opaque `auth.type` identifier plus the `auth.config` object, whose content the shipped schema leaves unconstrained; this feature never inspects that content, never resolves it, never logs it, and never echoes it in a problem `detail`. An ancestor value marked `private` is never read into a result, so no `private` value can reach the output at all. Resolving the reference into secret material happens at proxy time and belongs to `cpt-cf-oagw-feature-plugin-system`. +- **Performance**: no latency or throughput target is set here, because `cpt-cf-oagw-nfr-low-latency` is allocated to `cpt-cf-oagw-feature-data-plane-proxy`, which owns the request hot path. What this feature contributes is a walk whose cost is one candidate lookup per chain element, issued through the Control Plane L1 cache `cpt-cf-oagw-feature-control-plane-config` owns in the `upstream:{tenant_id}:{alias}` key shape of ADR 0005, and a merge that allocates one result set per resolution; each per-element read is bounded by the platform request deadline the gear already carries, as §3 states for the walk. It owns no cache and adds none to the hot path. +- **UX (recorded as applicable, not excluded)**: this feature does have actor surface, through the management endpoints the bind and the override ride on, so UX is deliberately absent from this list. It is discharged as protocol answers: every actor-facing outcome below is an HTTP status with an `application/problem+json` body produced by the foundation's error mapping, and this feature adds no rendered surface, no locale negotiation, and no new endpoint to document. + +## 2. Actor Flows (CDSL) + +The flows below reuse the management operation order DESIGN §3.5 states — authenticate, validate the DTO, write, respond — and the management endpoints `cpt-cf-oagw-feature-control-plane-config` registers under `/oagw/v1`. DECOMPOSITION §2.3 states `API: None` for this feature: no path is registered here, and the paths named below are referenced by path and by `cpt-cf-oagw-interface-management-api` only. + +**Use cases**: `cpt-cf-oagw-usecase-configure-upstream` — this feature contributes the alias-match branch of that use case's `POST /oagw/v1/upstreams` main flow, which PRD §8 states only as "Alias conflict: Return 409 Conflict" and DESIGN §3.3 CRUD Semantics states as the bind. `cpt-cf-oagw-feature-control-plane-config` delivers the rest of that use case, and `cpt-cf-oagw-usecase-proxy-request` belongs to `cpt-cf-oagw-feature-data-plane-proxy`, which calls the resolution this feature delivers. + +### Bind a Descendant Upstream to an Ancestor Upstream + +- [x] `p1` - **ID**: `cpt-cf-oagw-flow-bind-ancestor-upstream` + +**Actor**: `cpt-cf-oagw-actor-tenant-admin` + +**Success Scenarios**: + +- A descendant tenant creates an upstream whose normalized alias matches an ancestor's upstream while holding `oagw:upstream:bind`; the operation is a bind, the descendant's own row is persisted, and the answer is 201 with the descendant's identifier, not a 409. +- The ancestor marks a family `private`: the create succeeds with the descendant's own value for that family, and no ancestor value is read, copied, or echoed. +- The ancestor marks a family `enforce` and the body carries no value for it: the create succeeds, and the ancestor's value is forced in every later resolution. +- The request tags are stored on the descendant's row only, and the ancestor's tag rows are byte-identical after the operation. +- The alias matches no upstream anywhere in the chain: the operation is an ordinary create in the calling tenant, exactly as `cpt-cf-oagw-flow-upstream-create` performs it. + +**Error Scenarios**: + +- The bearer token lacks `oagw:upstream:bind` while the alias matches an ancestor upstream: 403, returned by this flow as a bare 403 problem answer after the body has passed schema validation and before any per-family sharing check runs, so the 403 precedes the `enforce` 400 and nothing is disclosed about the ancestor's configuration, including which families it enforces (§1.5). +- The body carries a value for a family the ancestor marks `enforce`: 400 naming that family (§1.5). +- The body fails schema validation, or another upstream of the calling tenant already holds the alias: 400, or 409 with the `AliasConflict` variant — both answered by `cpt-cf-oagw-feature-control-plane-config`'s write path before this flow's walk runs. +- The chain is unavailable: the operation fails closed with the platform 500 problem shape and no row is written. + +**Steps**: + +1. [x] - `p1` - Actor issues the create request carrying the endpoint set, the `protocol`, and any of `alias`, `auth`, `headers`, `rate_limit`, `cors`, `plugins`, `tags`, `enabled` - `inst-bind-issue` +2. [x] - `p1` - API: POST /oagw/v1/upstreams — the platform middleware authenticates the bearer token, resolves the calling tenant, and enforces `gts.cf.core.oagw.upstream.v1~:create` before any validation runs; the path is `cpt-cf-oagw-feature-control-plane-config`'s registration (`cpt-cf-oagw-interface-management-api`) - `inst-bind-authz` +3. [x] - `p1` - `cpt-cf-oagw-algo-request-validate` validates the body against `schemas/upstream.v1.schema.json` and that feature's §1.5 overrides, and `cpt-cf-oagw-algo-alias-derive` derives or validates the normalized alias - `inst-bind-validate` +4. [x] - `p1` - `cpt-cf-oagw-algo-tenant-scope` confirms the calling tenant holds no upstream with this alias; a duplicate answers 409 with the `AliasConflict` variant at that layer, before this flow's walk runs, so an ancestor's alias can never be mistaken for a same-tenant conflict - `inst-bind-own-scope` +5. [x] - `p1` - `cpt-cf-oagw-algo-tenant-chain-walk` walks the chain from the calling tenant to the root looking for the normalized alias - `inst-bind-walk` +6. [x] - `p1` - **IF** the walk returns no candidate at a depth greater than the calling tenant's - `inst-bind-noancestor-if` + 1. [x] - `p1` - The operation is an ordinary create: the write path of `cpt-cf-oagw-flow-upstream-create` persists the descendant's row and answers 201, with no permission beyond `create` consumed - `inst-bind-noancestor` +7. [x] - `p1` - **ELSE** - `inst-bind-ancestor-else` + 1. [x] - `p1` - `cpt-cf-oagw-algo-sharing-mode-decision` evaluates every family the body carries against the ancestor's per-family sharing modes and the calling tenant's `oagw:upstream:*` permission set - `inst-bind-decide` + 2. [x] - `p1` - **IF** the calling tenant does not hold `oagw:upstream:bind` - `inst-bind-perm-if` + 1. [x] - `p1` - **RETURN** 403; the bind is refused, no row is written, and no ancestor value is disclosed in the answer. This flow is already permission-first — the permission check runs after schema validation and before every per-family sharing check, so a caller that lacks the permission never learns which families the ancestor enforces (§1.5) - `inst-bind-perm-return` + 3. [x] - `p1` - **ELSE IF** the body carries a value for a family the ancestor marks `enforce` - `inst-bind-enforce-if` + 1. [x] - `p1` - **RETURN** 400 with the validation error naming that family; no row is written (§1.5) - `inst-bind-enforce-return` + 4. [x] - `p1` - **ELSE** - `inst-bind-write-else` + 1. [x] - `p1` - `cpt-cf-oagw-algo-bind-create-tags` records the ancestor binding and produces the write set for the descendant's own row; the write goes through the same single-transaction path `cpt-cf-oagw-flow-upstream-create` uses, with the request tags on the descendant's row only and every ancestor row untouched - `inst-bind-write` +8. [x] - `p1` - **RETURN** 201 with the descendant's own representation and its identifier as `gts.cf.core.oagw.upstream.v1~{uuid}`; the ancestor's rows are unchanged by the operation - `inst-bind-return` + +A bind is never a conflict with an ancestor. The `(tenant_id, alias)` uniqueness key is per tenant, the ancestor's row is a different tenant's row, and the answer to a matching alias is a binding that requires a permission, not a 409. That is the continuation of the branch `cpt-cf-oagw-flow-upstream-create` explicitly leaves open. A `headers` object the body supplies is stored on the descendant's row and is never merged: the `headers` family takes no part in the hierarchy merge (§1.5), so the header rules of the descendant's own upstream are the ones that apply. + +### Override an Inherited Configuration Field + +- [x] `p1` - **ID**: `cpt-cf-oagw-flow-override-inherited-field` + +**Actor**: `cpt-cf-oagw-actor-tenant-admin` + +The management operation is a replacement of the tenant's own row, which is the row bound to an ancestor's. The same decision applies to a route replacement on the families a route carries (`rate_limit`, `cors`, `plugins`, `tags`); a route carries no `auth` family, so there is no inherited auth configuration for a descendant route to override. + +**Success Scenarios**: + +- The body carries an `auth` value while the ancestor's `auth.sharing` is `inherit` and the tenant holds `oagw:upstream:override_auth`; the override is stored, and the effective auth for the tenant is the tenant's own. +- The body carries a `rate_limit` while the tenant holds `oagw:upstream:override_rate`; a value stricter than the ancestor's wins in the effective configuration, and a looser one changes nothing in it. +- The body carries `plugins` items while the tenant holds `oagw:upstream:add_plugins`; the effective chain is the ancestor's items followed by the tenant's. +- The body adds tags; the effective tag set is the union, and a replacement that omits an inherited tag leaves that tag in the effective set. +- The body mentions no `enforce` family; the ancestor's values stay forced and the replacement succeeds. + +**Error Scenarios**: + +- The body carries a value for an `inherit` family whose override permission the tenant does not hold: 403, returned by this feature's sharing-mode decision as a bare 403 problem answer (§1.5). +- The body carries a value for a family the ancestor marks `enforce`: 400 naming that family. This 400 is answered only after the permission check has passed, because the ordering rule of §1.5 puts the 403 first. +- The `id` in the path names a nonexistent row, or one owned by another tenant including an ancestor: 404, with the two causes indistinguishable. +- The body fails schema validation: 400. The bearer token is missing or invalid: 401. + +**Steps**: + +1. [x] - `p1` - Actor issues the replacement of its own row, carrying the families it wants to set - `inst-ovr-issue` +2. [x] - `p1` - API: PUT /oagw/v1/upstreams/{id} — the platform middleware authenticates the bearer token and enforces `gts.cf.core.oagw.upstream.v1~:override` before any validation runs - `inst-ovr-authz` +3. [x] - `p1` - `cpt-cf-oagw-algo-tenant-scope` resolves the row by identifier and calling tenant; an ancestor's row can never satisfy the predicate - `inst-ovr-scope` +4. [x] - `p1` - **IF** no row matched, because the identifier does not exist or because it belongs to another tenant including an ancestor - `inst-ovr-scope-if` + 1. [x] - `p1` - **RETURN** 404; this is also the reason a descendant can never address an ancestor's row to relax an `enforce` field or to re-enable an ancestor-disabled one - `inst-ovr-scope-return` +5. [x] - `p1` - **ELSE** - `inst-ovr-scope-else` + 1. [x] - `p1` - `cpt-cf-oagw-algo-request-validate` validates the replacement body, and `cpt-cf-oagw-algo-put-replace-diff` builds the write set for the tenant's own row - `inst-ovr-validate` + 2. [x] - `p1` - `cpt-cf-oagw-algo-tenant-chain-walk` resolves the ancestor binding the row participates in, and `cpt-cf-oagw-algo-sharing-mode-decision` evaluates every family the body carries against it - `inst-ovr-decide` + 3. [x] - `p1` - **IF** the body carries a value for an `inherit` family whose override permission the calling tenant does not hold - `inst-ovr-perm-if` + 1. [x] - `p1` - **RETURN** 403; the permission check precedes every per-family sharing check, so the tenant uses the ancestor's value as-is — which is the DESIGN §3.2 rule for a permission-less descendant — and never learns which families the ancestor enforces (§1.5) - `inst-ovr-perm-return` + 4. [x] - `p1` - **ELSE IF** the body carries a value for a family the ancestor marks `enforce` - `inst-ovr-enforce-if` + 1. [x] - `p1` - **RETURN** 400 with the validation error naming that family; the stored row is left unchanged, and this 400 is reached only once the permission check above has passed (§1.5) - `inst-ovr-enforce-return` + 5. [x] - `p1` - **ELSE** - `inst-ovr-write-else` + 1. [x] - `p1` - DB: UPDATE the tenant's own row through `cpt-cf-oagw-algo-put-replace-diff`'s write set in one transaction; the ancestor's rows are not written, and the inherited tags stay in the effective set whatever the body's tag list holds - `inst-ovr-write` +6. [x] - `p1` - **RETURN** 200 with the tenant's own representation; the effective configuration it produces is recomputed at the next resolution, not stored on the row - `inst-ovr-return` + +### Resolve the Effective Configuration for a Proxy Request + +- [x] `p1` - **ID**: `cpt-cf-oagw-flow-resolve-effective-config` + +**Actor**: `cpt-cf-oagw-actor-app-developer` + +The developer's proxy request reaches this feature through the resolution routine the Data Plane calls, which is the `CP.resolve_proxy_target(alias, method, path)` entry of ADR 0006. No endpoint of this feature exists (DECOMPOSITION §2.3, `API: None`), so the steps below name no path and perform no write. + +**Success Scenarios**: + +- The calling tenant owns an upstream with the alias: it is the routing target, its configuration is the base, and no ancestor row contributes a value. +- The calling tenant owns none and an ancestor does: the ancestor's is the target, and its visible families are inherited per their sharing modes. +- Both exist: the descendant's shadows the ancestor's as the routing target, and the ancestor's `enforce` families still apply, including its rate limit. +- A contributing ancestor row is disabled: the effective `enabled` state is disabled for the calling tenant, with no write to any row. +- The chain holds a matching route at more than one level: the descendant's route wins, and the route-level families merge with the same strategies. + +**Error Scenarios**: + +- No candidate exists anywhere in the chain: a not-found outcome, which the consumer answers 404 per `cpt-cf-oagw-fr-error-codes`. +- The chain is unavailable, unordered, or cyclic: the resolution fails closed with the platform 500 problem shape and produces no configuration. +- The storage layer fails: the platform 500 problem shape, with no partial result returned. + +**Steps**: + +1. [x] - `p1` - The Data Plane requests the resolution, carrying the normalized alias and the calling tenant resolved from the SecurityContext - `inst-res-request` +2. [x] - `p1` - `cpt-cf-oagw-algo-alias-normalize` normalizes the alias, so a resolution can never disagree with a stored alias about shape, case, or a trailing dot - `inst-res-normalize` +3. [x] - `p1` - The chain is obtained from the platform tenant-resolver (§1.4) - `inst-res-chain` +4. [x] - `p1` - **IF** the chain is unavailable, unordered, or cyclic - `inst-res-chain-if` + 1. [x] - `p1` - **RETURN** failure; the resolution fails closed and produces no configuration, because an unordered chain cannot decide who shadows whom - `inst-res-chain-return` +5. [x] - `p1` - **ELSE** - `inst-res-chain-else` + 1. [x] - `p1` - `cpt-cf-oagw-algo-tenant-chain-walk` produces the ordered candidate set, one entry per chain element that holds the alias - `inst-res-walk` + 2. [x] - `p1` - `cpt-cf-oagw-algo-alias-shadow-resolve` selects the routing target, collects the ancestor bindings, and computes the effective `enabled` state - `inst-res-shadow` + 3. [x] - `p1` - `cpt-cf-oagw-algo-field-family-merge` produces the `EffectiveUpstreamConfig` from the target and its ancestor bindings - `inst-res-merge-upstream` + 4. [x] - `p1` - The matched route is resolved the same way along the chain, with the descendant's route taking priority, and `cpt-cf-oagw-algo-field-family-merge` produces the `EffectiveRouteConfig` for it - `inst-res-merge-route` +6. [x] - `p1` - **RETURN** the `EffectiveUpstreamConfig`, the `EffectiveRouteConfig`, the effective `enabled` state, and the per-family sharing modes and ownership the consumer needs for its own authorization check - `inst-res-return` + +## 3. Processes / Business Logic (CDSL) + +The routines below are called by the flows in §2 and by each other in the order `cpt-cf-oagw-algo-tenant-chain-walk`, then `cpt-cf-oagw-algo-alias-shadow-resolve`, then `cpt-cf-oagw-algo-field-family-merge`; `cpt-cf-oagw-algo-sharing-mode-decision` is called from both management flows, and `cpt-cf-oagw-algo-bind-create-tags` from the first. Every failure any of them returns is a `DomainError` from the foundation catalogue or the platform 500 problem shape for a storage failure; the 403 a missing `oagw:upstream:*` override permission produces is the one answer outside that catalogue, returned as a bare 403 problem answer and not as a `DomainError` variant (§1.5). No routine here writes to the database. + +The resolution chain the first three routines form, with its fail-closed exits: + +```mermaid +flowchart TD + A["Resolution request: normalized alias and calling tenant"] --> B["Chain from the platform tenant-resolver"] + B --> C{"Chain available, ordered, and acyclic?"} + C -->|no| X["Fail closed: platform 500 problem shape, no configuration"] + C -->|yes| D["Chain walk: one tenant-scoped Control Plane read per chain element"] + D --> E{"Candidate set empty?"} + E -->|yes| Y["Not-found outcome; the consumer answers 404"] + E -->|no| F["Shadow resolve: smallest depth wins as the routing target"] + F --> G["Effective enabled: target flag ANDed with every matched ancestor flag"] + G --> H["Five-family merge, applied root to child"] + H --> I["Per-layer result: EffectiveUpstreamConfig, EffectiveRouteConfig, per-family sharing modes, ownership"] +``` + +### Tenant Chain Walk from Descendant to Root + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-tenant-chain-walk` + +**Input**: the normalized alias, the calling tenant, the ancestor chain supplied by the platform tenant-resolver, and the repository read for upstream and route rows. + +**Output**: the ordered candidate set — one entry per chain element that holds a row with that alias, each carrying its depth, its per-family sharing modes, its `enabled` flag, and its owning tenant — or an empty set. + +**Steps**: + +1. [x] - `p1` - Read the chain; prepend the calling tenant when the resolver omitted it, and treat a cycle or a missing order as an unavailable chain - `inst-walk-read` +2. [x] - `p1` - **IF** the chain is unavailable - `inst-walk-unavailable-if` + 1. [x] - `p1` - **RETURN** failure; the caller fails closed rather than resolving against a chain it cannot order (§1.4) - `inst-walk-unavailable-return` +3. [x] - `p1` - **ELSE** - `inst-walk-else` + 1. [x] - `p1` - **FOR EACH** tenant in the chain, from the calling tenant to the platform root - `inst-walk-loop` + 1. [x] - `p1` - DB: SELECT the upstream rows of that tenant whose alias equals the normalized alias, through the secure ORM with the tenant equality in the same predicate as every other key and with no raw SQL (`cpt-cf-oagw-principle-tenant-scope`), reading through the Control Plane L1 cache `cpt-cf-oagw-feature-control-plane-config` owns in the `upstream:{tenant_id}:{alias}` shape of ADR 0005 - `inst-walk-lookup` + 2. [x] - `p1` - **IF** the tenant holds such a row - `inst-walk-hit-if` + 1. [x] - `p1` - Append one candidate carrying the row's depth, its per-family sharing modes, its `enabled` flag, and its owning tenant identifier - `inst-walk-hit` +4. [x] - `p1` - **RETURN** the candidates ordered by increasing depth, the calling tenant first - `inst-walk-return` + +The walk stops at the root and never descends: a tenant's own descendants are never candidates for its resolution, which is what keeps the answer tenant-scoped. One lookup is issued per chain element, so the chain depth bounds the cost, and no lookup is issued for a tenant whose rows the calling tenant cannot read. Each per-element Control Plane read is bounded by the platform request deadline the gear already carries — `OagwConfig.proxy_timeout_secs`, delivered by `cpt-cf-oagw-feature-gear-foundation` — and a breach of that deadline is a storage failure: it fails the resolution closed with the platform 500 problem shape, exactly as an unavailable chain does, and never yields a partial candidate set. + +### Ancestor Alias Resolution and Shadowing + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-alias-shadow-resolve` + +**Input**: the ordered candidate set from `cpt-cf-oagw-algo-tenant-chain-walk`, each entry with its depth, sharing modes, and `enabled` flag. + +**Output**: the routing target, the ordered ancestor bindings, and the effective `enabled` state. + +**Steps**: + +1. [x] - `p1` - **IF** the candidate set is empty - `inst-shadow-empty-if` + 1. [x] - `p1` - **RETURN** a not-found outcome; the consumer answers it 404 per `cpt-cf-oagw-fr-error-codes`, and this feature returns no configuration - `inst-shadow-empty-return` +2. [x] - `p1` - **ELSE** - `inst-shadow-else` + 1. [x] - `p1` - Select the candidate at the smallest depth as the routing target: the closest match wins, so a descendant's row shadows an ancestor's - `inst-shadow-target` +2. [x] - `p1` - Compare aliases on the normalized form only, case-insensitively, with the port participating in identity, so `api.openai.com` and `api.openai.com:8443` are never the same candidate - `inst-shadow-compare` +3. [x] - `p1` - Collect the remaining candidates, ordered from the most distant to the least, as the ancestor bindings - `inst-shadow-bindings` +4. [x] - `p1` - **FOR EACH** ancestor binding - `inst-shadow-loop` + 1. [x] - `p1` - **IF** the binding marks a family `enforce` - `inst-shadow-enforce-if` + 1. [x] - `p1` - Carry that family into the merge as forced, so shadowing never bypasses it (PRD §5.5, enforced limits across shadowing) - `inst-shadow-enforce` + 2. [x] - `p1` - **ELSE IF** the binding marks a family `inherit` - `inst-shadow-inherit-if` + 1. [x] - `p1` - Carry that family into the merge as the base value - `inst-shadow-inherit` + 3. [x] - `p1` - **ELSE** - `inst-shadow-private-else` + 1. [x] - `p1` - Carry nothing for that family; the binding's value is not read into the result, copied onto any row, or echoed in any answer (§1.5) - `inst-shadow-private` +5. [x] - `p1` - Compute the effective `enabled` state as the conjunction of the target's own flag with the flag of every ancestor row the walk matched on the alias, regardless of that row's per-family sharing modes, because `enabled` is a row-level state and carries no sharing field; so one disabled ancestor disables the resource for every descendant without a write, and no descendant write can raise it - `inst-shadow-enabled` +6. [x] - `p1` - **RETURN** the routing target, the ancestor bindings, and the effective `enabled` state - `inst-shadow-return` + +The target decides where a request goes. The bindings decide what the request is subject to. A shadowing descendant can replace the target and can replace the values the ancestor marked `inherit`, and it can never replace the values the ancestor marked `enforce` or raise the effective `enabled` state. + +### Per-Field-Family Effective Merge + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-field-family-merge` + +**Input**: the routing target's row, the ordered ancestor bindings with the families they contribute, and the layer being resolved (`upstream` or `route`). + +**Output**: one `EffectiveUpstreamConfig` or one `EffectiveRouteConfig`, carrying the five family results. + +The strategies, and the source of each: + +| Family | Ancestor contributes | Descendant contributes | Effective value | +|--------|----------------------|------------------------|-----------------| +| Auth (`auth.sharing`) | its `auth` object when `inherit` or `enforce`; nothing when `private` | its own `auth` object when the ancestor's mode is `private` with no permission consumed, or when the mode is `inherit` and `oagw:upstream:override_auth` is held | the descendant's object when the mode is `inherit` and the permission is held; the ancestor's object when the mode is `enforce`; the descendant's own when the mode is `private`; otherwise the inherited object | +| Rate limit (`rate_limit.sharing`) | its sustained rate and its `burst.capacity` when `inherit` or `enforce`; nothing when `private` | its own sustained rate and its own `burst.capacity` when the ancestor's mode is `private` with no permission consumed, or when `oagw:upstream:override_rate` is held | the minimum of the visible sustained rates, normalized per §1.5 and reported in the winner's window, and the minimum of the visible `burst.capacity` values under the same mode gate and the same common-scale treatment, reported as the capacity that supplied that minimum; the remaining `rate_limit` members (`algorithm`, `scope`, `strategy`, `cost`) are carried unchanged (§1.5) | +| Plugins (`plugins.sharing`) | its plugin items when `inherit` or `enforce`; nothing when `private` | its own plugin items when the ancestor's mode is `private` with no permission consumed, or when `oagw:upstream:add_plugins` is held | the ancestor's items followed by the descendant's, in that order; an `enforce` ancestor's items are never removable by a replacement that omits them | +| CORS (`cors.sharing`) | its origins when `inherit` or `enforce`; nothing when `private` | its own origins when the ancestor's mode is `private`, or when the mode is `inherit`; the four-permission table names no permission for this family, so the sharing mode alone decides (§1.5) | the union of the origins when the mode is `inherit`; the ancestor's whole `cors` object when the mode is `enforce`; the routing target's own object when the mode is `private` (§1.5) | +| Tags (no sharing field) | its tags | its own tags | the union, add-only: descendants add and can never remove an inherited tag | + +`tags` is the only family with no sharing field, which is why it never reaches `cpt-cf-oagw-algo-sharing-mode-decision`. + +**Steps**: + +1. [x] - `p1` - Start from the routing target's row as the base, and hold the ancestor bindings in most-distant-first order so the merge applies from root to child (§1.5) - `inst-merge-base` +2. [x] - `p1` - **FOR EACH** family in {auth, rate limit, plugins, CORS, tags} that the layer carries - `inst-merge-loop` + 1. [x] - `p1` - Apply the strategy row above for that family and record the result and the sharing mode that produced it - `inst-merge-apply` +3. [x] - `p1` - **IF** the layer is `route` - `inst-merge-route-if` + 1. [x] - `p1` - Skip the auth family, which a route does not carry, and produce an `EffectiveRouteConfig` - `inst-merge-route` +4. [x] - `p1` - **ELSE** - `inst-merge-upstream-else` + 1. [x] - `p1` - Produce an `EffectiveUpstreamConfig` with all five families - `inst-merge-upstream` +5. [x] - `p1` - **RETURN** the per-layer result with the per-family sharing modes attached, so the consumer can apply its own authorization check without re-walking the chain - `inst-merge-return` + +The concatenation order within a layer is ancestor then descendant, which is what DESIGN §3.2 states for plugins and what the root-to-child application order produces for every family. The order across layers — upstream chain before route chain — is the execution order of the plugin chain and belongs to `cpt-cf-oagw-feature-data-plane-proxy` (DESIGN §3.2 Plugin System, DECOMPOSITION §2.5); this feature delivers one merged chain per layer and does not interleave them. + +### Sharing-Mode and Permission Decision + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-sharing-mode-decision` + +**Input**: the ancestor's per-family sharing modes, the families the descendant's body carries, and the descendant's `oagw:upstream:*` permission set. + +**Output**: one decision per family — `own`, `inherit-base`, or `forced` — or a refusal. + +The decision table, over the four families that carry a sharing mode. The family's override permission is `oagw:upstream:override_auth` for auth, `oagw:upstream:override_rate` for rate limit, and `oagw:upstream:add_plugins` for plugins; the four-permission table of DESIGN §3.2 names no permission for CORS, so for that family the permission column is always `held` and the sharing mode alone decides (§1.5): + +| Ancestor mode | Family's override permission | Body carries a value | Decision | +|---------------|------------------------------|----------------------|----------| +| `private` | any, or none | yes | `own` — the descendant's value is its own configuration; no override permission is consumed (§1.5) | +| `private` | any, or none | no | `own` — the family takes the schema default; no ancestor value exists to inherit | +| `inherit` | held | yes | `inherit-base` — the ancestor's value is the base and the body's value overrides it | +| `inherit` | held | no | `inherit-base` — nothing to authorize and nothing to override | +| `inherit` | missing | yes | refusal — 403, returned as a bare 403 problem answer and not as a `DomainError` variant (§1.5); the descendant uses the ancestor's value as-is | +| `inherit` | missing | no | `inherit-base` — no permission is needed when nothing is overridden | +| `enforce` | any | yes | refusal — 400 naming the family (§1.5) | +| `enforce` | any | no | `forced` — the ancestor's value is applied in every resolution | + +**Steps**: + +1. [x] - `p1` - **FOR EACH** sharing-bearing family the body carries - `inst-decide-loop` + 1. [x] - `p1` - **IF** no ancestor binding contributes that family - `inst-decide-noancestor-if` + 1. [x] - `p1` - Decide `own`; the family is the descendant's configuration and no permission is consumed - `inst-decide-noancestor` + 2. [x] - `p1` - **ELSE** decide from the table above, taking the ancestor's mode for that family - `inst-decide-row` +2. [x] - `p1` - **IF** any decision is a refusal - `inst-decide-refusal-if` + 1. [x] - `p1` - **RETURN** the first refusal in the order §1.5 fixes — the permission 403 before any `enforce` 400 — naming the family and the reason, so a caller is not made to retry once per blocked family and an unauthorized caller learns nothing about which families are enforced - `inst-decide-refusal-return` +3. [x] - `p1` - **RETURN** the per-family decisions - `inst-decide-return` + +The four permissions map one to one onto the operations that need them: `oagw:upstream:bind` to the bind-style create, `oagw:upstream:override_auth` to the auth override, `oagw:upstream:override_rate` to specifying an own rate limit, and `oagw:upstream:add_plugins` to appending plugin items to an inherited chain. A descendant that holds none of them still resolves, proxies, and inherits; it only cannot change what it inherits, which is the DESIGN §3.2 rule that such a descendant uses the ancestor's configuration as-is. The same four permissions gate the same families on a route row, because a route carries three of the four sharing-bearing families and the permission names the override ability, not a table (§1.5). + +### Binding-Style Creation with Tenant-Local Tags + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-bind-create-tags` + +**Input**: the validated create body, the ancestor binding the walk resolved, and the per-family decisions from `cpt-cf-oagw-algo-sharing-mode-decision`. + +**Output**: the write set for the descendant's own row, or the refusal the decision produced. + +**Steps**: + +1. [x] - `p1` - Confirm the binding: an ancestor row at a greater depth with the same normalized alias; anything else is not a bind and the ordinary create path applies - `inst-bindtags-confirm` +2. [x] - `p1` - Take `id` and `tenant_id` from the calling tenant; the ancestor's identifiers, alias row, and endpoint set are never copied onto the descendant's row - `inst-bindtags-identity` +3. [x] - `p1` - Build the write set from the body for every family whose decision is `own` or `inherit-base`; a family whose decision is `forced` is never written to the descendant's row, because the ancestor's live value is applied by `cpt-cf-oagw-algo-field-family-merge` at resolution time (§1.5) - `inst-bindtags-families` +4. [x] - `p1` - Store the request tags on the descendant's row only, as `cpt-cf-oagw-flow-upstream-create`'s tag write does; no write reaches the ancestor's tag rows, and the effective tag set is the union computed at resolution time - `inst-bindtags-tags` +5. [x] - `p1` - **IF** any decision was a refusal - `inst-bindtags-refusal-if` + 1. [x] - `p1` - **RETURN** that refusal; no row is written and no ancestor value is disclosed - `inst-bindtags-refusal-return` +6. [x] - `p1` - **RETURN** the write set, for the single-transaction write the management flow performs - `inst-bindtags-return` + +The tenant-local rule is what PRD §5.5 states for the binding-style flow: request tags are "treated as tenant-local additions for effective discovery; they do not mutate ancestor tags". The obligation this routine carries is therefore negative — after a bind-style create, the ancestor's tag rows are byte-identical to what they were, and the discovery benefit of the request tags accrues to the descendant's own tenant only. + +## 4. States (CDSL) + +No state machine is defined in this feature. + +This feature is a resolution computation: it reads a chain, merges five families, and returns a result, and nothing it touches changes state as a result of running. The only lifecycle it comes near is the effective `Enabled` / `Disabled` state of an upstream or route, and that machine is already declared — `cpt-cf-oagw-state-config-lifecycle` in `cpt-cf-oagw-feature-control-plane-config`, whose transition 2 ("a contributing ancestor row is disabled") names this feature as the detection half. Declaring a second machine over the same two states would give one state two owners and would leave the stored flag and the effective state described by two documents that can drift apart, so the machine stays where the stored flag lives and this feature supplies the walk that makes its ancestor-driven transition observable. + +The template marks this section optional ("include when entities have explicit lifecycle states"), and the kit's constraint set does not require it; the section is kept, with this reason, so the omission is a recorded decision rather than a gap in the numbering. + +## 5. Definitions of Done + +### Tenant Chain Walk + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-tenant-chain-walk` + +The system **MUST** resolve an alias against the ancestor chain the platform tenant-resolver supplies, walking from the calling tenant to the platform root and issuing one tenant-scoped candidate lookup per chain element on `(tenant_id, alias)`, and **MUST NOT** build, persist, or cache any hierarchy data of its own: no table, no column, and no cache entry is created by this feature, and the walk reads the rows `cpt-cf-oagw-feature-control-plane-config` persists through that feature's repository traits. Every read **MUST** carry the tenant equality in the same predicate as every other key, through the secure ORM and with no raw SQL, so no chain element can contribute a row the calling tenant may not read (`cpt-cf-oagw-principle-tenant-scope`, `cpt-cf-oagw-nfr-multi-tenancy`). An unavailable, unordered, or cyclic chain **MUST** fail the resolution closed. + +**Implements**: + +- `cpt-cf-oagw-flow-resolve-effective-config` +- `cpt-cf-oagw-flow-bind-ancestor-upstream` +- `cpt-cf-oagw-algo-tenant-chain-walk` + +**Constraints**: `cpt-cf-oagw-constraint-multi-sql` + +**Touches**: + +- API: none — DECOMPOSITION §2.3 states `API: None`; the paths named in §2 are `cpt-cf-oagw-feature-control-plane-config`'s registrations, referenced by path and by `cpt-cf-oagw-interface-management-api` only +- DB: none — reads only, through the repository traits and the Control Plane L1 cache of `cpt-cf-oagw-feature-control-plane-config`; no schema object of `cpt-cf-oagw-db-schema` is created, written, or claimed here, because DECOMPOSITION §2.3 lists `Data: None` +- DB Table: none +- Entities: `TenantChain`, `AncestorBinding` + +### Alias Shadowing and Effective Enabled State + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-alias-shadowing` + +The system **MUST** select as the routing target the candidate at the smallest chain depth, so a descendant's row shadows an ancestor's, **MUST** compare aliases on the normalized form only, case-insensitively, with the port participating in identity, **MUST** carry the `enforce` families of every shadowed ancestor into the merge so that shadowing never bypasses them, **MUST** treat an ancestor family marked `private` as contributing nothing at all, and **MUST** return a not-found outcome when no chain element holds the alias, leaving the 404 answer to the consumer. It **MUST** compute the effective `enabled` state as the conjunction of the routing target's own flag with every contributing ancestor row's flag, so an ancestor disable reaches every descendant without a write and no descendant can raise the effective state (`cpt-cf-oagw-fr-alias-resolution`, `cpt-cf-oagw-fr-enable-disable`). + +**Implements**: + +- `cpt-cf-oagw-flow-resolve-effective-config` +- `cpt-cf-oagw-algo-alias-shadow-resolve` + +**Constraints**: none from DESIGN §2.2; the governing elements are the two requirements cited above and the principle cited under `cpt-cf-oagw-dod-tenant-chain-walk`. + +**Touches**: + +- API: none +- DB: none — reads only, as under `cpt-cf-oagw-dod-tenant-chain-walk` +- DB Table: none +- Entities: `AncestorBinding`, `SharingMode`, `EffectiveUpstreamConfig` + +### Per-Field-Family Effective Merge + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-field-family-merge` + +The system **MUST** merge the five field families with the strategies of the table under `cpt-cf-oagw-algo-field-family-merge`: auth overridden when the ancestor marks it `inherit` and forced when it marks it `enforce`; rate limits resolved to the minimum of the visible sustained rates, normalized to a common unit and reported in the winner's window, with `burst.capacity` minimized under the same mode gate and the remaining `rate_limit` members carried unchanged; plugin chains concatenated ancestor-then-descendant with an `enforce` ancestor's items never removable; CORS origins unioned when the ancestor marks the family `inherit` and the ancestor's whole `cors` object forced when it marks it `enforce`; tags always unioned add-only. It **MUST** produce one result per layer, an `EffectiveUpstreamConfig` and an `EffectiveRouteConfig`, so the consumer can apply the upstream, then route, then tenant order of `cpt-cf-oagw-fr-config-layering` without re-walking the chain, and it **MUST** attach the per-family sharing modes and the resolved ownership to the result. + +**Implements**: + +- `cpt-cf-oagw-flow-resolve-effective-config` +- `cpt-cf-oagw-algo-field-family-merge` + +**Constraints**: none from DESIGN §2.2; the governing element is `cpt-cf-oagw-fr-config-layering` and the DESIGN §3.2 merge table. + +**Touches**: + +- API: none +- DB: none — the merge consumes values already read +- DB Table: none +- Entities: `EffectiveUpstreamConfig`, `EffectiveRouteConfig`, `EffectiveAuth`, `EffectiveRateLimit`, `EffectivePluginChain`, `EffectiveCors`, `EffectiveTagSet` + +### Sharing-Mode and Permission Decision + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-sharing-mode-decision` + +The system **MUST** apply the three sharing modes per configuration field family, using the decision table under `cpt-cf-oagw-algo-sharing-mode-decision`: `private` contributes no ancestor value and consumes no override permission, `inherit` makes the ancestor's value the base that a permitted descendant overrides, and `enforce` makes the ancestor's value the effective one that no descendant override can replace. An override an `enforce` family blocks **MUST** be answered 400 naming that family, and an override an `inherit` family admits but the caller lacks the permission for **MUST** be answered 403 by this feature, after schema validation and before any per-family sharing check, so the 403 precedes any `enforce` 400 (§1.5). For the CORS family, which the four-permission table names no permission for, the sharing mode alone **MUST** decide (§1.5). No ancestor value **MUST** be disclosed in any refusal. + +**Implements**: + +- `cpt-cf-oagw-flow-bind-ancestor-upstream` +- `cpt-cf-oagw-flow-override-inherited-field` +- `cpt-cf-oagw-algo-sharing-mode-decision` + +**Constraints**: none from DESIGN §2.2; the governing element is the DESIGN §3.2 sharing-mode table and the DESIGN §3.2 Permissions and Access Control subsection. + +**Touches**: + +- API: none — the two 400 and 403 answers are produced inside the handlers `cpt-cf-oagw-feature-control-plane-config` registers +- DB: none — the decision reads the sharing modes of rows already resolved +- DB Table: none +- Entities: `SharingMode` + +### Descendant Override Permissions + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-descendant-override-permissions` + +The system **MUST** gate the four descendant operations on the four permissions DESIGN §3.2 names — `oagw:upstream:bind` for the bind-style create against an ancestor's alias, `oagw:upstream:override_auth` for the auth override of an `inherit` auth family, `oagw:upstream:override_rate` for specifying an own rate limit, and `oagw:upstream:add_plugins` for appending plugin items to an inherited chain — and **MUST** deny by default, so a descendant that holds none of them resolves, proxies, and inherits without being able to change what it inherits. The same four permissions **MUST** gate the same families on a route row, and **MUST NOT** be extended to a fifth permission for the CORS family, which the sharing mode alone governs (§1.5). A denied operation **MUST** write no row and **MUST NOT** be indistinguishable in effect from a granted one. + +**Implements**: + +- `cpt-cf-oagw-flow-bind-ancestor-upstream` +- `cpt-cf-oagw-flow-override-inherited-field` +- `cpt-cf-oagw-algo-sharing-mode-decision` + +**Constraints**: none from DESIGN §2.2; the governing element is the DESIGN §3.2 Permissions and Access Control subsection. + +**Touches**: + +- API: none — the permissions are enforced on the existing management paths +- DB: none — a denied operation writes nothing +- DB Table: none +- Entities: `SharingMode` + +### Binding-Style Creation with Tenant-Local Tags + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-binding-style-creation` + +The system **MUST** treat a create whose normalized alias matches an ancestor's upstream as a bind requiring `oagw:upstream:bind`, answered 201 with the descendant's own row rather than 409, and **MUST** respect the sharing-mode constraints of that bind: an ancestor family marked `private` blocks the visibility of the ancestor's value without blocking the bind, and an ancestor family marked `enforce` blocks the override (§1.5). The request tags of a bind-style create **MUST** be stored as tenant-local additions on the descendant's row, and the operation **MUST** leave every ancestor row, including every ancestor tag row, byte-identical to what it was. + +**Implements**: + +- `cpt-cf-oagw-flow-bind-ancestor-upstream` +- `cpt-cf-oagw-algo-bind-create-tags` +- `cpt-cf-oagw-algo-sharing-mode-decision` + +**Constraints**: `cpt-cf-oagw-constraint-multi-sql` + +**Touches**: + +- API: `POST /oagw/v1/upstreams` — the existing management path, registered by `cpt-cf-oagw-feature-control-plane-config` and referenced by `cpt-cf-oagw-interface-management-api` +- DB: none — the write set this routine produces is applied by the single-transaction write path of `cpt-cf-oagw-flow-upstream-create`; this feature owns no table and writes none +- DB Table: `oagw_upstream`, `oagw_upstream_tag` — written by that write path, never by this feature +- Entities: `AncestorBinding`, `SharingMode`, `EffectiveTagSet` + +### Effective Configuration Result Types + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-effective-config-result` + +The system **MUST** declare `EffectiveUpstreamConfig`, `EffectiveRouteConfig`, `TenantChain`, `AncestorBinding`, `SharingMode`, and the five per-family merge results in the domain layer, free of transport and persistence types (`cpt-cf-oagw-component-model`, `cpt-cf-oagw-design-layers`), and **MUST** make the result the single thing the downstream features consume: `cpt-cf-oagw-feature-data-plane-proxy` the two per-layer results, `cpt-cf-oagw-feature-rate-limiting` the `EffectiveRateLimit` member, and `cpt-cf-oagw-feature-cors` the `EffectiveCors` member. No downstream feature **MUST** re-walk the chain or re-apply a per-field strategy, and the names **MUST** be the DECOMPOSITION §2.3 names rather than the ADR 0006 diagram names (§1.5). + +**Implements**: + +- `cpt-cf-oagw-flow-resolve-effective-config` +- `cpt-cf-oagw-algo-field-family-merge` +- `cpt-cf-oagw-algo-alias-shadow-resolve` + +**Constraints**: `cpt-cf-oagw-constraint-multi-sql` + +**Touches**: + +- API: none +- DB: none — the result types are domain types with no persistence of their own +- DB Table: none +- Entities: `EffectiveUpstreamConfig`, `EffectiveRouteConfig`, `TenantChain`, `AncestorBinding`, `SharingMode`, `EffectiveAuth`, `EffectiveRateLimit`, `EffectivePluginChain`, `EffectiveCors`, `EffectiveTagSet` + +### Resolution Test Coverage and Placement + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-resolution-tests` + +The system **MUST** deliver this feature's unit and integration tests colocated under `gears/system/oagw/oagw/tests/`, following the placement `cpt-cf-oagw-dod-test-placement` states and `cpt-cf-oagw-dod-colocated-tests` applies to the management feature, covering the chain walk and its fail-closed behaviour, alias shadowing with the enforced fields of a shadowed ancestor, the effective `enabled` state, every strategy row of the merge table for every family, the sharing-mode decision table including both refusals, the four descendant override permissions, and the tenant-local tag rule, and **MUST NOT** add any test under `testing/e2e/gears/oagw/` (DECOMPOSITION §1.3(3)). The coverage **MUST** include a case asserting that after a bind-style create the ancestor's tag rows are byte-identical to what they were, and a case asserting that no answer to a refused operation contains an ancestor value. + +**Implements**: + +- `cpt-cf-oagw-algo-tenant-chain-walk` +- `cpt-cf-oagw-algo-alias-shadow-resolve` +- `cpt-cf-oagw-algo-field-family-merge` +- `cpt-cf-oagw-algo-sharing-mode-decision` +- `cpt-cf-oagw-algo-bind-create-tags` +- `cpt-cf-oagw-flow-resolve-effective-config` +- `cpt-cf-oagw-flow-bind-ancestor-upstream` +- `cpt-cf-oagw-flow-override-inherited-field` + +**Constraints**: `cpt-cf-oagw-constraint-multi-sql` + +**Touches**: + +- API: none — tests exercise the existing management paths and the internal resolution routine +- DB: none — the tests read the tables `cpt-cf-oagw-feature-control-plane-config` owns and write nothing of their own +- DB Table: `oagw_upstream`, `oagw_route`, `oagw_upstream_tag`, `oagw_route_tag` — read by the tests through the persisted model of `cpt-cf-oagw-dod-persisted-model` +- Entities: none — tests only + +## 6. Acceptance Criteria + +- [x] For the deepest authenticated graded caller the graded configuration admits — a chain of the three tenants §1.4 counts — the walk issues one candidate lookup per chain element and returns candidates ordered from the calling tenant to the platform root, and no lookup is issued for a tenant outside that chain. +- [x] An unavailable, unordered, or cyclic chain fails the resolution closed: no configuration is produced, no row is read from a tenant the caller may not read, and the failure is not answered with a guess about the ordering. +- [x] An alias held by the calling tenant and by an ancestor resolves to the calling tenant's row as the routing target, and the ancestor's `enforce` families are still present in the effective configuration (PRD §5.5, enforced limits across shadowing). +- [x] An alias held only by an ancestor resolves to the ancestor's row as the routing target, and the ancestor's `inherit` families are the base values of the merge. +- [x] An alias held by no chain element produces a not-found outcome and no configuration, and the consumer answers it 404 per `cpt-cf-oagw-fr-error-codes`. +- [x] `api.openai.com` and `api.openai.com:8443` never resolve to the same candidate, and a lookup for `API.OpenAI.com.` resolves the upstream stored as `api.openai.com`. +- [x] A contributing ancestor row disabled through the management API makes the effective `enabled` state disabled for every descendant with no write to any row, including when that ancestor marks every family `private`, and a descendant re-enable of its own row leaves the effective state disabled while that ancestor row stays disabled. +- [x] An ancestor `auth` marked `inherit` with a descendant holding `oagw:upstream:override_auth` resolves to the descendant's `auth` object; the same without the permission is answered 403 and resolves to the ancestor's object. +- [x] An ancestor `auth` marked `enforce` resolves to the ancestor's `auth` object regardless of the descendant's permissions, and a descendant body that supplies an `auth` value is answered 400 naming the family. +- [x] An ancestor `auth` marked `private` contributes nothing: the descendant's own `auth` value is the effective one, no permission is consumed, and no answer discloses the ancestor's value. +- [x] An ancestor `rate_limit` marked `enforce` at `10000/minute` with a descendant declaring `100/minute` resolves to `100/minute`, and the same descendant declaring `20000/minute` resolves to `10000/minute` rather than to its own value. +- [x] A sustained rate of `100/second` against `5000/minute` resolves to `5000/minute`, so the normalization of §1.5 decides a comparison the raw windows cannot, and the reported window is the winner's. +- [x] An ancestor `rate_limit` marked `enforce` with a `burst.capacity` of `1000` and a descendant declaring `100` resolves to a `burst.capacity` of `100`, and the same ancestor marked `private` leaves the descendant's `100` as the only capacity; `algorithm`, `scope`, `strategy`, and `cost` are never merged. +- [x] An ancestor `rate_limit` marked `private` contributes nothing: a descendant with no `rate_limit` resolves to no limit rather than to the ancestor's. +- [x] An ancestor plugin chain under `inherit` with a descendant holding `oagw:upstream:add_plugins` resolves to the ancestor's items followed by the descendant's, and a descendant replacement that omits `plugins` leaves an `enforce` ancestor's items in the effective chain. +- [x] An ancestor `cors` marked `inherit` with origins `https://app.example.com` and a descendant adding `https://admin.example.com` resolves to both origins, and the same under `enforce` resolves to the ancestor's origins alone with the descendant's addition refused 400. +- [x] Tags resolve to the union of the ancestor's and the descendant's, a descendant replacement that omits an inherited tag leaves that tag in the effective set, and a bind-style create leaves the ancestor's tag rows byte-identical to what they were. +- [x] A create whose normalized alias matches an ancestor's upstream is answered 201 with the descendant's own identifier when `oagw:upstream:bind` is held and 403 when it is not, and the same alias held only by the calling tenant is still answered 409 with the `AliasConflict` variant. +- [x] A refused bind or override writes no row, and its answer names the blocked family or the missing permission without carrying any ancestor configuration value. +- [x] An override of an inherited family on a route row is decided by the same decision table and the same four `oagw:upstream:*` permissions as an override on an upstream row, with the auth family absent because a route carries none. +- [x] The effective configuration is produced per layer, as one `EffectiveUpstreamConfig` and one `EffectiveRouteConfig`, each carrying its per-family sharing modes, and neither `cpt-cf-oagw-feature-data-plane-proxy` nor `cpt-cf-oagw-feature-rate-limiting` nor `cpt-cf-oagw-feature-cors` re-walks the chain. +- [x] Every test for this feature lives under `gears/system/oagw/oagw/tests/`, passes there, and no test is added under `testing/e2e/gears/oagw/`. diff --git a/gears/system/oagw/docs/features/observability.md b/gears/system/oagw/docs/features/observability.md new file mode 100644 index 0000000..e25483f --- /dev/null +++ b/gears/system/oagw/docs/features/observability.md @@ -0,0 +1,644 @@ +# Feature: Observability + +- [ ] `p1` - **ID**: `cpt-cf-oagw-featstatus-observability-implemented` + + +- [ ] `p2` - `cpt-cf-oagw-feature-observability` + + + +- [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 Feature-Local Deviations from Shared Baselines](#15-feature-local-deviations-from-shared-baselines) + - [1.6 Explicit Non-Applicability](#16-explicit-non-applicability) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Observe a Proxied Request](#observe-a-proxied-request) + - [Record a Configuration Change](#record-a-configuration-change) + - [Scrape the Metrics Endpoint](#scrape-the-metrics-endpoint) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Assign the Correlation Identifier](#assign-the-correlation-identifier) + - [Emit the Audit Record](#emit-the-audit-record) + - [Update the Metric Families](#update-the-metric-families) + - [Render the Prometheus Exposition](#render-the-prometheus-exposition) +- [4. States (CDSL)](#4-states-cdsl) +- [5. Definitions of Done](#5-definitions-of-done) + - [Correlation Identifier Propagation and the trace_id Echo](#correlation-identifier-propagation-and-the-trace_id-echo) + - [Structured Audit Records](#structured-audit-records) + - [Prometheus Metrics Endpoint](#prometheus-metrics-endpoint) + - [Metric Cardinality Control](#metric-cardinality-control) + - [Redaction and Credential Isolation](#redaction-and-credential-isolation) + - [Sampling and Log-Flood Control](#sampling-and-log-flood-control) + - [Colocated Tests](#colocated-tests) +- [6. Acceptance Criteria](#6-acceptance-criteria) + + + +## 1. Feature Context + +### 1.1 Overview + +This feature is the cross-cutting reader of the `oagw` gear and the last entry of the decomposition: it owns no step of the proxy path, answers no policy question, and decides nothing about any request. What it owns is the legibility of the path the other eight features built. Every proxy request the gear serves carries a correlation identifier from the moment it enters the handler to the moment its answer leaves it; every one of those requests is described by at most one structured JSON record written to stdout, exactly one for every request whose record the sampling and the failure-log bound of §1.5 do not drop; every configuration change written through a management route is described by one record of its own; and every traffic, latency, error, and state fact the path produces is exposed as one of the twelve Prometheus metric families DESIGN §4.2 catalogues, scraped at a single endpoint this feature registers. + +The feature attaches to the gear at three points and at no others. The first is the entry of the proxy path `cpt-cf-oagw-feature-data-plane-proxy` implements, where the correlation identifier is assigned before that path authorizes anything, because a request the path refuses is still a request an operator needs to find. The second is the exit of the same path, after the `ProxyResponse` exists or the streamed transfer has ended, where the audit record is emitted and the metric families are observed from the outcome the path recorded in the request's execution context. The third is the completion of a management write in `cpt-cf-oagw-feature-control-plane-config`, where the record for the change is emitted at the seam that feature's write path already uses for its post-write notifications. Everything else — resolution, matching, validation, the rate-limit check, the composed chain, the header rules, the transfer mode, the CORS answers — is another feature's act, and this feature reports it without repeating it. + +The feature registers exactly one endpoint, `GET /oagw/v1/metrics`, on the gear-relative router mount point `cpt-cf-oagw-feature-gear-foundation` created. It emits no series for its own scrape, writes no audit record for its own scrape, and holds no state that outlives the process. + +### 1.2 Purpose + +DECOMPOSITION §2.9 places this feature last and states its purpose as making "the outbound path legible to operators", naming it "the feature an operator uses first when an upstream starts misbehaving". DECOMPOSITION §3 makes it a consumer of three features for the reasons that section states: it requires `cpt-cf-oagw-feature-data-plane-proxy` because "correlation, audit fields, and the proxy-path metrics are all derived from the proxy request and response lifecycle"; it requires `cpt-cf-oagw-feature-rate-limiting` because it "reports rate-limit state and 429 outcomes, which only exist once that feature owns them"; and it requires `cpt-cf-oagw-feature-control-plane-config` because it "logs configuration changes and reads configuration state, so it cannot be completed before that feature's write path exists". This document is the reporting surface those three dependencies exist to feed: the proxy feature produces the lifecycle, the rate-limit feature produces the state and the transitions, and the configuration feature produces the writes, and this feature turns all three into records a scrape can read and a log query can filter. + +PRD §6.1 states both requirements this feature delivers and states the threshold of each. `cpt-cf-oagw-nfr-observability` requires that the system "log all proxy requests with correlation IDs and expose Prometheus metrics for request counts, latencies, error rates, and rate limit state", with the threshold "100% of proxy requests logged with correlation ID; metrics scraped at /metrics endpoint". `cpt-cf-oagw-nfr-credential-isolation` requires that credentials "MUST never appear in logs, error messages, or API responses", with the threshold "Zero credential exposure in any log, error, or API output". The second is the harder of the two here, because this feature is the one place in the gear whose whole output is logs and API responses: the threshold is realized by the redaction rules of §3 and by the Definition of Done that pins them, and it is the reason the header allowlist of DESIGN §4.3 is closed at one name (§1.5). + +This feature delivers the DESIGN §4.2 metrics catalogue and the DESIGN §4.3 audit-log catalogue in full. It has no DESIGN §3.2 subsection, which is the reason DECOMPOSITION §2.9 gives for `cpt-cf-oagw-component-model` staying an umbrella reference here; the catalogues are DESIGN §4 material and are tabulated in §3 of this document rather than derived from any component of §3.2. + +DECOMPOSITION §2.9 names this feature's domain model as "`AuditEvent`, `CorrelationContext`, and the metric label sets". The third of the three is a plural and not a type name, and this document fixes its declared form as `MetricLabelSet`: one declared concept carrying the shared label vocabulary of DESIGN §4.2 and the per-family subsets that section enumerates. That declaration is what makes the label sets checkable rather than a convention the implementation happens to follow. + +Deliverables: + +- The correlation identifier, taken from the header the platform injects when one arrived and generated as a UUID otherwise, recorded on the `CorrelationContext`, propagated to every audit record the request produces, and echoed in every gateway error body the path answers as the `trace_id` extension field. +- The structured JSON audit record of DESIGN §4.3, written to stdout with exactly the fourteen fields that section tabulates, for successful requests, failed requests, configuration changes, authentication failures, and circuit-breaker state transitions. +- The `MetricLabelSet` declaration: the fourteen shared label keys and the per-family subsets of DESIGN §4.2, with the closed value sets each key carries. +- The twelve Prometheus metric families of DESIGN §4.2, observed from the execution context and from the sibling states, and rendered as the Prometheus text exposition format at `GET /oagw/v1/metrics`. +- Cardinality control: no tenant label anywhere in the metrics surface, `http.route` carrying the normalized route match pattern rather than the raw request path, methods normalized to a standard verb or `_OTHER`, and every enumerated label key closed at a bounded value set. +- The redaction rules of DESIGN §4.3: no request body, no response body, no query parameter, and no header value other than the allowlisted one in any record; no API key, no token, no credential material, and no `cred://` reference value in any record, metric label, or error message. +- Sampling of the high-volume success records and a rate-limit bound on the authentication-failure records, both build-time constants with no configuration surface (§1.5). +- Colocated tests under `gears/system/oagw/oagw/tests/`. + +**Requirements**: + +- [ ] `p2` - `cpt-cf-oagw-nfr-observability` +- [ ] `p1` - `cpt-cf-oagw-nfr-credential-isolation` + +Both are listed at the priority DECOMPOSITION §2.9 records. `cpt-cf-oagw-nfr-observability` is the requirement this feature primarily delivers and is the reason the entry is MEDIUM; `cpt-cf-oagw-nfr-credential-isolation` is delivered here in its logging, error, and API-response clauses, which are the clauses this feature's output reaches, and its credential-store and tenant-isolation clauses belong to `cpt-cf-oagw-feature-plugin-system` and `cpt-cf-oagw-feature-control-plane-config`, which own the resolution of the reference and the persistence of the configuration it points into. + +**Principles**: + +- `p1` - `cpt-cf-oagw-principle-cred-isolation` + +`cpt-cf-oagw-principle-cred-isolation` states that the gear "references secrets via `cred_store` URIs (`cred://...`); never stores or logs secret material". The second half of that statement is this feature's to realize: the `cred://` reference value is a credential pointer, and this feature writes it to no record, no label, and no error message, exactly as it writes the material the pointer resolves to. + +**Constraints**: + +- `p1` - `cpt-cf-oagw-constraint-toolkit-deploy` + +This constraint is the one DECOMPOSITION §2.9 records, so no §1.5 row is needed for it, and the reason it is load-bearing here is the same one the sibling features record: the single-executable deployment is what makes the in-process observation seam the only mechanism this feature has, and what makes a scrape of `GET /oagw/v1/metrics` read the same process that served the traffic. + +**Design Components**: + +- `p2` - `cpt-cf-oagw-component-model` +- `p2` - `cpt-cf-oagw-tech-dependencies` + +`cpt-cf-oagw-component-model` stays an umbrella reference for the reason DECOMPOSITION §2.9 gives: this feature has no DESIGN §3.2 subsection to deliver, so the reference names no component surface and carries no subsection row. `cpt-cf-oagw-tech-dependencies` is load-bearing rather than decorative: the Rust and Axum row names the async runtime the emission and the exposition run on, and the metrics-crate row of that table is the registry the twelve families are collected into, which is a dependency the platform supplies and not a mechanism this feature builds. + +**Domain Model Entities**: + +- `AuditEvent` — one structured record of DESIGN §4.3, carrying exactly the fourteen fields that section tabulates, the event name that selects which of them are populated, and the redaction already applied to every value it carries. +- `CorrelationContext` — one per proxy request, carrying the correlation identifier, whether it was taken from the inbound header or generated, and the sampling decision the request's record is subject to. +- `MetricLabelSet` — the shared label vocabulary of DESIGN §4.2 and the per-family subsets that section enumerates, with the closed value set of each enumerated key. + +The first two are named by DECOMPOSITION §2.9 and the third is the declared form of that entry's "the metric label sets" (§1.2). `MetricLabelSet` is a declared concept and not a per-request value: it is the vocabulary the twelve families share, held once, and it is why the cardinality rules of §5 are checkable as a property of the declaration rather than as a property of a call site. Four types are consumed and not redeclared: `ProxyContext` and `ProxyResponse` from `cpt-cf-oagw-feature-data-plane-proxy`, whose members carry the lifecycle the records and series describe, `ResolvedUpstream` from the same feature, and `ErrorContext` from `cpt-cf-oagw-feature-gear-foundation`, which is the single definition point for it and the carrier of the `trace_id` this feature supplies. + +**Data**: + +- None. DECOMPOSITION §2.9 declares no table for this feature, and it creates, reads, and writes none. An `AuditEvent` is written once to stdout and never revisited, a `CorrelationContext` lives exactly as long as the request it describes, and the twelve families are in-process series whose values are recomputed from live state on every scrape. A restart empties the counters, resets the gauges, and loses no record that had not already been written. + +**API**: + +- GET /oagw/v1/metrics + +That line is the whole of the API statement DECOMPOSITION §2.9 makes, and it is the only path this feature registers. It is the gear-relative form of the `/metrics` endpoint DESIGN §4.2 places and the `/metrics` endpoint PRD §6.1 names in its threshold; the divergence is recorded in §1.5 and the two forms name one endpoint, not two. The endpoint is a scrape surface and not a proxy surface: it carries no alias, no path suffix, and no query, and it is answered from the in-process collectors without contacting any upstream service. + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-oagw-actor-platform-operator` | Scrapes `GET /oagw/v1/metrics` and reads the audit stream, and is the actor DECOMPOSITION §2.9's purpose names when it calls this "the feature an operator uses first when an upstream starts misbehaving". PRD §2 names reading the metrics and the logs among this actor's needs, and DECOMPOSITION §1.5 lists this feature against the operator alone among the features that report. | +| `cpt-cf-oagw-actor-app-developer` | Issues the proxy request that carries a correlation identifier, receives the answer that echoes it as `trace_id` when the answer is a gateway error, and never sees a record, a label, or a header value the redaction rules exclude. PRD §6.1's threshold of 100% of proxy requests logged with a correlation ID is stated over this actor's requests. | +| `cpt-cf-oagw-actor-tenant-admin` | Performs the create, the override, and the delete through a management route of `cpt-cf-oagw-feature-control-plane-config` whose completion produces the configuration-change record. The write is that feature's act; the record of it is this one's. | + +Three actors participate indirectly and are named here so their absence from the table is a record and not a gap: + +- `cpt-cf-oagw-actor-upstream-service` is never contacted by this feature. Its answers reach it as the status, the duration, and the byte counts `cpt-cf-oagw-feature-data-plane-proxy` classified and recorded in the execution context, and its failures reach it as the `error_type` of a failed record and the `upstream` value of the `error_type` label. The one thing it supplies directly is nothing: no metric of this feature observes the upstream except through the state another feature already holds. +- `cpt-cf-oagw-actor-cred-store` answers no call this feature makes. The credential material the chain resolved and injected never reaches a record, a label, or an error message, and neither does the `cred://` reference value that points at it, which is the realization of `cpt-cf-oagw-nfr-credential-isolation` this feature owes (§1.5). +- `cpt-cf-oagw-actor-types-registry` issues no call this feature answers. The GTS error-type catalogue was provisioned once at startup by `cpt-cf-oagw-feature-gear-foundation`, and the `error_type` value of a failed record and of the `oagw_errors_total` series is read from that catalogue and never registered by this feature. + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) +- **Design**: [DESIGN.md](../DESIGN.md) +- **Dependencies**: `cpt-cf-oagw-feature-data-plane-proxy` — the proxy path whose entry assigns the correlation identifier and whose exit produces the record and the series, the `ProxyContext` that carries the `CorrelationContext` and the `ResolvedUpstream` and `ProxyResponse` the fields and labels read, the endpoint selection whose `selection_method` the routing families report, and the shared outbound client whose connection pool the `oagw_upstream_connections` gauge reads (DECOMPOSITION §3); `cpt-cf-oagw-feature-rate-limiting` — the breaker state and transitions the `oagw_circuit_breaker_state` and `oagw_circuit_breaker_transitions_total` families observe, the rate-limit state and the 429 outcome the two `rate_limit` families observe, and the admission verdict the in-flight gauge and the success record depend on; and `cpt-cf-oagw-feature-control-plane-config` — the management write path whose completion produces the configuration-change record and the stored upstream and route configuration the `host` and `endpoint` label values are read from. + +Supporting sources this feature stays consistent with: + +- [DESIGN.md](../DESIGN.md) §4.2 — the twelve metric families with their label sets, the cardinality-management paragraph, the histogram buckets, and the statement that the label-key vocabulary matches the inbound API Gateway so both gateways share dashboards. +- [DESIGN.md](../DESIGN.md) §4.3 — the fourteen audit fields, the no-PII and no-secrets rules, the header allowlist, the five logged categories, the four log levels, and the sampling note whose example ratio this feature carries as a constant (§1.5). +- [DESIGN.md](../DESIGN.md) §3.3 — the error catalogue whose GTS `type` identifiers are the closed value set of the `error_type` label and the `error_type` audit field, and the `retry_after_seconds` extension member whose presence decides whether an answer carries `Retry-After`, which is the WARN-level trigger DESIGN §4.3 names as "retry guidance emitted". +- [ADR/0006-state-management.md](../ADR/0006-state-management.md) (`cpt-cf-oagw-adr-state-management`) — the three pieces of state the ADR assigns the Data Plane, of which the shared outbound client is the one whose connection pool the `oagw_upstream_connections` gauge reads, and the request flow with caching that names the proxy-path phases whose durations the `phase` label carries. +- [ADR/0007-error-source-distinction.md](../ADR/0007-error-source-distinction.md) (`cpt-cf-oagw-adr-error-source-distinction`) — the two error classes and the rule that an upstream-sourced answer passes through with its body unmodified, which is the reason an upstream failure carries no `trace_id` echo. +- [config/e2e-local.yaml](../../../../../config/e2e-local.yaml) — the graded configuration. Its `oagw.config` block carries three of the five keys DECOMPOSITION §2.1 declares — `proxy_timeout_secs`, `allow_http_upstream`, and `ssrf_policy` — and names no sampling, logging, or metrics key, leaving the two token-cache keys at their defaults; its `logging` section configures the platform's own console and file targets and no gear-side audit sink; its `opentelemetry.tracing.enabled` and `opentelemetry.metrics.enabled` are both `false`; its `opentelemetry.tracing.http.inject_request_id_header` names `x-request-id`; its `api-gateway` block sets `require_auth_by_default: true`; and its `e2e-token-tenant-a` entry carries `token_scopes: ["*"]`. + +**Run-level assumptions** — premises this feature relies on that come from the platform runtime rather than from PRD, DESIGN, the ADRs, or DECOMPOSITION. Each states what fails if the premise does not hold: + +- Assumption: the route this feature registers is enforced with the permission `gts.cf.core.oagw.metrics.v1~:read`, an identifier formed in the same grammar as the `upstream`, `route`, and `proxy` arms DESIGN §3.2 records and outside the family DECOMPOSITION §2.2 writes, which names no `metrics` arm. No supplied document names a permission for the metrics endpoint, and DESIGN §4.2 qualifies it as admin-only while DECOMPOSITION §1.3(6) drops the qualifier. If the platform does not recognize the identifier, the fail direction **MUST** be the one that denies the scrape, because a metrics surface that answers to an unauthenticated caller discloses the traffic, the route, and the error profile of every upstream the gear serves. `config/e2e-local.yaml`'s `e2e-token-tenant-a` carries `token_scopes: ["*"]` and the `api-gateway` sets `require_auth_by_default: true`, so the graded deployment answers the route to that token (§1.5). +- Assumption: the platform injects a request identifier into the headers of an inbound request, and this feature can read it. `config/e2e-local.yaml`'s `opentelemetry.tracing.http.inject_request_id_header` names `x-request-id` as the header the platform injects, and its `opentelemetry.tracing.enabled` and `opentelemetry.metrics.enabled` are both `false`, so no trace context and no platform metric reach the gear in the graded deployment. If the platform injects nothing, the correlation identifier is generated on every request and is still present on every record, because the generation branch is unconditional when the header is absent (§1.5). +- Assumption: the platform resolves the calling tenant and the authenticated subject before the proxy handler runs, so the `tenant_id` and `principal_id` fields of a record are available to it. `config/e2e-local.yaml` sets `require_auth_by_default: true`, and DESIGN §3.3 names `toolkit-auth` as the inbound mechanism. If neither identifier is available for a request the gear still serves, the record **MUST** be written with both fields omitted rather than synthesized, because an invented tenant identifier attributes traffic to a tenant that sent none. +- Assumption: the process writes to a stdout the platform collects, and the write of one JSON line is atomic enough that concurrent requests do not interleave the bytes of one line inside another. DESIGN §4.3 names stdout as the destination and names a centralized logging system as the reader. If the destination fragments lines, the records remain parseable individually but a consumer cannot rely on line boundaries, and this feature **MUST NOT** add a second sink, a buffer, or a batching layer to compensate, because a buffered sink can lose records a crash would otherwise have delivered. +- Assumption: the shared outbound client exposes its connection-pool occupancy per host in a form a gauge can read, which is the premise the `oagw_upstream_connections{host, state}` family rests on. ADR 0006 names the shared client as the second piece of Data Plane state and specifies nothing about its introspection. If the client exposes no such state, that family **MUST** be omitted from the exposition rather than emitted as a constant, because a constant gauge asserts occupancy the gear cannot see. + +### 1.5 Feature-Local Deviations from Shared Baselines + +| Deviation | Rationale | Review owner | Validation performed | +|-----------|-----------|--------------|----------------------| +| The metrics endpoint is registered gear-relative at `/oagw/v1/metrics`, which DECOMPOSITION §2.9 states, and not at the bare `/metrics` DESIGN §4.2 places or the `/metrics` endpoint PRD §6.1 names in its threshold; the two forms name one endpoint and not a second one. | DECOMPOSITION §1.3(6) makes exactly this correction and gives the reason: the gear's router mounts gear-relative at `/oagw/v1`, which `cpt-cf-oagw-feature-gear-foundation` registers, and the bare `/metrics` of DESIGN §4.2 is the same endpoint under the gear's own prefix rather than a second endpoint at a second path. The same item drops the admin-only qualifier for the reason recorded in the next row. The mount point carries no route outside `/oagw/v1`, so a request to `/metrics` is answered by no OAGW handler at all. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The admin-only gating DESIGN §4.2 states is realized as the permission `gts.cf.core.oagw.metrics.v1~:read`, an identifier formed in the same grammar as the `upstream`, `route`, and `proxy` arms DESIGN §3.2 records and outside the `gts.cf.core.oagw.{upstream,route,*_plugin}.v1~:{create;override;read;delete}` family DECOMPOSITION §2.2 writes. | No supplied document names a permission for the metrics endpoint: DESIGN §4.2 says only "admin-only", and DECOMPOSITION §1.3(6) drops the qualifier because "the graded configuration exposes no admin-gating surface for gear-relative gear routes". A surface that names the traffic, the routes, and the error profile of every upstream is not a surface the gear should answer to an unauthenticated caller, so the endpoint is authenticated and enforced rather than open, and the fail direction when the platform does not recognize the identifier is the one that denies the scrape (§1.4). `config/e2e-local.yaml`'s `e2e-token-tenant-a` carries `token_scopes: ["*"]` and the `api-gateway` sets `require_auth_by_default: true`, so the graded deployment answers the route to that token, which is the consequence recorded in §6. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The correlation identifier is taken from the inbound `X-Request-Id` header when one arrived and is generated as a UUID otherwise, and a caller-supplied value is admitted only when it is a bounded, printable identifier containing no control character; a value that fails that check is discarded and a UUID is generated in its place. | `config/e2e-local.yaml`'s `opentelemetry.tracing.http.inject_request_id_header` names `x-request-id` as the header the platform injects, which is the only header a supplied document names for this purpose, and its `opentelemetry.tracing.enabled` and `opentelemetry.metrics.enabled` are both `false`, so the platform supplies no trace context and no platform metric in the graded deployment and this feature's own identifier is the only correlation the deployment has. The admission check exists because the value is written into every record the request produces and an unbounded or control-bearing value is a log-flooding and log-corruption vector the no-flooding rule of DESIGN §4.3 exists to prevent; a caller cannot use it to escape correlation, because the replacement is itself a correlation identifier. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| This document's status identifier carries the `-implemented` suffix, reading `cpt-cf-oagw-featstatus-observability-implemented` where the FEATURE template fixes the same identifier without that suffix, and its backreference to the DECOMPOSITION entry is left unchecked where that template fixes a checked one. | All eight gated sibling FEATURE documents this run has authored — `cpt-cf-oagw-feature-gear-foundation`, `cpt-cf-oagw-feature-control-plane-config`, `cpt-cf-oagw-feature-hierarchical-config`, `cpt-cf-oagw-feature-plugin-system`, `cpt-cf-oagw-feature-data-plane-proxy`, `cpt-cf-oagw-feature-rate-limiting`, `cpt-cf-oagw-feature-cors`, and `cpt-cf-oagw-feature-streaming` — carry the same two forms, so the departure is a run-wide convention and not a defect of this document alone: the suffix names the status value the identifier reports, and the backreference is a traceability pointer whose state the implementation phase owns. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| This feature's tests are colocated at `gears/system/oagw/oagw/tests/` instead of `testing/e2e/gears/oagw/`. | DECOMPOSITION §1.3(3) reserves `testing/e2e/gears/oagw/` for the acceptance suite; every unit and integration test this decomposition produces lives with the crate. This is the same deviation all eight sibling feature documents record in their own §1.5 tables, restated here because the tests it governs include this feature's. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The 1/100 high-volume-route sampling ratio of DESIGN §4.3's example is a build-time constant of this feature with no configuration surface and no sourced value beyond the example itself. | The `OagwConfig` surface closes at the five keys `proxy_timeout_secs`, `allow_http_upstream`, `ssrf_policy`, `token_cache_ttl_secs`, and `token_cache_capacity`, which `cpt-cf-oagw-feature-gear-foundation` owns and which name no sampling, logging, or metrics key, so a sampling key here would give one configuration surface two owners. DESIGN §4.3 states the ratio as an example — "e.g., sample 1/100 for high-volume routes" — and no other supplied document states a value. The value is recorded in the implementation as a build-time constant, and what §6 pins is that the sampling exists, that it applies to the success records of high-volume routes only, and that no key of `OagwConfig` and no upstream or route configuration reaches it. This is the same class of recorded constant as the two queue bounds `cpt-cf-oagw-feature-rate-limiting` records and the 60-second idle constant `cpt-cf-oagw-feature-streaming` records. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The rate-limit bound on authentication-failure records is a build-time constant of this feature with no configuration surface and no sourced value, of the same class as the sampling ratio above. | DESIGN §4.3 requires that auth failures be "rate limited to prevent log flooding" and states no bound, and no other supplied document states one. What §6 pins is that the bound exists, that it is finite, that a flood of failed authentication attempts produces at most that many records per interval, and that the bound is recorded in the implementation as a build-time constant; the records beyond the bound within one interval are dropped and not queued, because a queue of unsent failure records is the flood the bound exists to prevent. No key of `OagwConfig` reaches it (§1.5). | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The `path` label of `oagw_rate_limit_exceeded_total{host, path}` and `oagw_rate_limit_usage_ratio{host, path}` is read as the normalized route match pattern, the same value the `http.route` label carries, so the label set of both families stays bounded by the number of configured routes. | DESIGN §4.2 enumerates those two families with a `path` label and states, in the same subsection, the cardinality rule that "`http.route` is the normalized route match pattern, not the raw request path" and that there are "no tenant labels". A raw-path reading would put the label set under the control of the callers, which is the unbounded cardinality the subsection's own rule exists to prevent, and would leave the two families inconsistent with the three others that carry `http.route`. Reading `path` as the route match pattern keeps every rule of the subsection true at once and keeps the two families' label sets within the same bound as the rest of the catalogue. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The closed `event` value set the audit routine and §6 point at is: `proxy_request.succeeded` and `proxy_request.failed` for the two request records, `config.upstream.created`, `config.upstream.overridden`, `config.upstream.deleted`, `config.route.created`, `config.route.overridden`, and `config.route.deleted` for the configuration changes over the two kinds `cpt-cf-oagw-feature-control-plane-config` writes, `config.plugin.created` and `config.plugin.deleted` for the two operations `cpt-cf-oagw-feature-plugin-system` admits, `auth.failed` for the authentication failure, and `breaker.transitioned` for the circuit-breaker state transition. | DESIGN §4.3 enumerates the five logged categories and states no literal event name for any of them, and §6's criterion that each record's `event` value is "drawn from the closed set §1.5 records" is untestable until the literals exist. The composition for the configuration change follows that document's own enumeration of "Upstream/route create/update/delete operations" and the `create`/`read`/`delete` arms `cpt-cf-oagw-feature-plugin-system` records, and the two request records follow the success/failed split the same subsection's "What is Logged" table makes. Twelve literals over five categories is the smallest set that keeps every category distinguishable in a record a consumer filters by `event`. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The PRD §6.1 threshold "100% of proxy requests logged with correlation ID" is read as a requirement on the correlation identifier, not on the audit record: every proxy request carries a correlation identifier and every record written carries one in its `request_id` field, while the 1/100 sampling of §1.5 drops some success records of high-volume routes, so not every request produces a record. | PRD §6.1's own sentence ties the threshold to correlation IDs — "log all proxy requests with correlation IDs" — and its threshold clause reads "100% of proxy requests logged with correlation ID", which is a statement about the identifier and not about record survival; the sampling rule is the same subsection's neighbour, DESIGN §4.3's own no-flooding requirement, and the two are in tension only if the threshold is read as a record count. Dropping a sampled success record drops no correlation identifier: the identifier is assigned at the path's entry before the sampling decision runs, and it is carried on every record that is written and in every error body's `trace_id`. A threshold read as a record count would forbid the no-flooding rule DESIGN §4.3 states in the same subsection, so the identifier reading is the only one that keeps both true. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| This feature mutates none of the six series that describe state another feature owns: `oagw_circuit_breaker_state`, `oagw_circuit_breaker_transitions_total`, `oagw_rate_limit_exceeded_total`, and `oagw_rate_limit_usage_ratio` are observations of state `cpt-cf-oagw-feature-rate-limiting` owns; `oagw_routing_target_host_used` and `oagw_routing_endpoint_selected` are observations of the endpoint selection `cpt-cf-oagw-feature-data-plane-proxy` performs per ADR 0001; `oagw_upstream_available` is derived from that breaker state per host and endpoint; and `oagw_upstream_connections` is derived from the shared outbound client's connection pool, one of the three pieces of state ADR 0006 assigns the Data Plane. | DECOMPOSITION §3 makes this feature a consumer of `cpt-cf-oagw-feature-rate-limiting` because it "reports rate-limit state and 429 outcomes, which only exist once that feature owns them", and of `cpt-cf-oagw-feature-data-plane-proxy` because the metrics are "derived from the proxy request and response lifecycle". Every sibling that owns the underlying state records the same posture — `cpt-cf-oagw-feature-rate-limiting` names the four series as "that feature's to emit" and names the `host` label as the upstream alias, which is this feature's labelling decision — so the read-only posture is the recorded seam and not an inference. A writer here would give one state two owners and would let a scrape disagree with the answer the request received. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The configuration-change record is written when the management handler of `cpt-cf-oagw-feature-control-plane-config` completes its write, at the same in-process post-write seam that feature already uses for its cache-flush and cleanup notifications; the event name distinguishes the three operations and the resource kinds upstream, route, and plugin; and the record carries no proxy-path field because no proxy request is involved. | DECOMPOSITION §3 makes this feature a consumer of `cpt-cf-oagw-feature-control-plane-config` because it "logs configuration changes and reads configuration state, so it cannot be completed before that feature's write path exists", and that feature records in its own §1.6 that it "emits no audit record of a successful configuration change" because the surface belongs to this feature. DESIGN §4.3 enumerates "Upstream/route create/update/delete operations", and `cpt-cf-oagw-feature-plugin-system` records in its own §1.6 that the audit log describing a plugin create or delete belongs to this feature too, so the plugin kind is carried rather than dropped. The path of the seam is the one the siblings already use: the write is the caller's act, the notification is issued by it, and the record is the notified feature's. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The log-level mapping of DESIGN §4.3 is applied as: INFO for a successful request, a normal operation, and a circuit-breaker transition whose destination state is not `open`, WARN for a rate-limit refusal, a breaker-open answer, retry guidance emitted, and a circuit-breaker transition whose destination state is `open`, ERROR for an upstream failure, a timeout, and an authentication failure, and DEBUG for detailed plugin execution; DEBUG is the one level this feature emits no record at in the graded deployment. | DESIGN §4.3 tabulates the four levels and their subjects. `config/e2e-local.yaml`'s `logging.default.console_level` is `info`, so a DEBUG record would be filtered by the platform's own console target before a consumer read it, and emitting it would spend the sampling budget of the no-flooding rule on a record no consumer receives. The plugin-execution detail the DEBUG level names belongs to the chain that `cpt-cf-oagw-feature-plugin-system` supplies and `cpt-cf-oagw-feature-data-plane-proxy` executes, and the phase durations the execution context carries are that second feature's (§1.5), so no record of this feature depends on a level the graded deployment does not emit. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The audit stream is the gear's own JSON line stream written to stdout and is distinct from the platform log files `config/e2e-local.yaml` configures; log retention is out of scope per DECOMPOSITION §2.9 and remains the PRD's open question. | DESIGN §4.3 names stdout as the destination and a centralized logging system as the reader, while `config/e2e-local.yaml`'s `logging` section configures console and file targets for the platform and for other gears and carries no `oagw` entry at all. Adding one would be a configuration change to a frozen input, and a file target would put a retention decision inside the gear that the PRD's §13 open question — "What is the retention policy for audit logs?" — places outside it. DECOMPOSITION §2.9 puts log retention out of scope, so this feature writes the stream and owns no policy over what happens to it afterwards. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| `CorrelationContext` is declared by this feature, as DECOMPOSITION §2.9 assigns, and is carried as a member of `ProxyContext`, which `cpt-cf-oagw-feature-data-plane-proxy` declares and owns. | `cpt-cf-oagw-feature-data-plane-proxy` lists the correlation context among the members of `ProxyContext` in its own §1.2, while DECOMPOSITION §2.9 lists the type under this entry, so the type and the slot that carries it would otherwise have two owners. The split is the same one `cpt-cf-oagw-feature-rate-limiting` records for `RateLimitConfig`, which the baseline lists under that entry for the semantics of its members while the type is declared as the foundation's shared vocabulary: the declaration is here, the slot is there, and this feature neither redeclares `ProxyContext` nor reaches inside it for anything but the correlation member the two documents name. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The §1.4 assumption of `cpt-cf-oagw-feature-data-plane-proxy` that the `trace_id` extension field is omitted rather than synthesized is honoured by making a correlation context present on every proxy request, so the omission case is the one outside this feature's assignment. | That feature records that if no correlation context is available, the `trace_id` extension field **MUST** be omitted rather than synthesized, "because an invented identifier correlates nothing", and DECOMPOSITION §2.9 states that correlation identifiers are propagated on every request and echoed in error bodies. The two are reconciled by the assignment: this feature assigns an identifier on every request the proxy path serves, from the inbound header or generated, so a context is always available on that path and the omission branch is reached only by an answer produced before the correlation step or outside it, such as a request the router matched to no handler. A UUID this feature generated is the correlation identifier it owns and not a synthesized one, because it is the same value every record of that request carries. The supplier half of that sibling assumption and the supplier this document names are the same seam read from two sides: the platform supplies only the request-identifier header, `cpt-cf-oagw-algo-correlate` builds the `CorrelationContext` from it at the proxy path's entry, and the sibling's "supplied by the platform" is read as "supplied to that path already assigned", which is the reading its own step 3 records. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| A failed request is recorded with the fourteen fields of DESIGN §4.3 and with no fifteenth member: the human-readable message DESIGN §4.3's "failed requests: all above + error_type, error_message" clause names is not added as a field, and the record carries `error_type` alone. | DECOMPOSITION §2.9 fixes the field set as exactly the fourteen names it lists, and DESIGN §4.3 tabulates the same fourteen as the record's fields while describing the failed-request record in prose as carrying `error_message` as well. Adding a fifteenth field would put this document against the baseline that prevails, and dropping the field loses nothing an operator cannot recover: the message content is the problem `detail` the answer carries, which `cpt-cf-oagw-algo-error-mapping` of `cpt-cf-oagw-feature-gear-foundation` builds and which that feature's own statement bounds to contain no credential material, no `cred://` reference value, and no configuration value. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The `phase` label of `oagw_request_duration_seconds{host, http.route, phase}` is closed at four values — `resolve`, `chain`, `upstream`, and `total` — named for the step of the proxy path whose duration each carries. | DESIGN §4.2 names the `phase` label and enumerates no values, and a label with an open value set is the cardinality risk the same subsection's management paragraph exists to prevent. The four values are the bounded set of phases whose durations the request's execution context carries: `resolve` for `cpt-cf-oagw-algo-resolve-consume` (a Data Plane L1 hit or a hierarchy walk), `chain` for `cpt-cf-oagw-algo-chain-execute`, `upstream` for `cpt-cf-oagw-algo-outbound-forward` (the phase whose two deadline breaches DESIGN §3.3 splits between `ConnectionTimeout` and `RequestTimeout`), and `total` for the whole handler invocation. No phase is added per plugin, per route, or per upstream, so the set cannot grow with traffic or configuration. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The `error_type` label of `oagw_errors_total{host, http.route, error_type}` and the `error_type` field of a failed record are closed at the catalogue slugs of the error variants `cpt-cf-oagw-feature-gear-foundation` provisions plus one further value, `upstream`, for a response the upstream answered with a failure status. | The catalogue is fixed at 22 variants over 21 distinct `gts.cf.core.errors.err.v1~cf.oagw.{slug}.v1` identifiers, which is a bounded set, and DESIGN §4.3's ERROR level names "upstream failures" as a subject the catalogue has no row for, because an upstream failure status is an answer the gateway passes through rather than a `DomainError` it mapped. Naming that case with a closed literal rather than with the upstream's own status text keeps the value set bounded and keeps the two surfaces consistent, since the audit field and the label carry the same value for the same request. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| `http.response.status_code` carries the numeric status of the response the caller received, which is the upstream's status when the upstream answered and the gateway's own status when the gateway answered without contacting it. | DESIGN §4.2 states the label as "the numeric upstream status (OTel HTTP semconv)", and a request the gateway answered at 404, 429, or 503 before any outbound call has no upstream status to carry. Omitting the label on those requests would leave `oagw_requests_total` unable to report the status classes its own catalogue is read by, and status-class queries are expressed at query time by regex on the numeric code, which requires the code to be present. The set of statuses the gateway itself produces is bounded by the error catalogue, so the reading adds no cardinality. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| A scrape of `GET /oagw/v1/metrics` produces no audit record and no observation of `oagw_requests_total`, and a CORS preflight that `cpt-cf-oagw-feature-cors` answers before the proxy flow is reached produces neither; the 403 answers that feature produces after resolution are recorded like any other refused proxy request. | PRD §6.1's threshold is stated over proxy requests, and a scrape is answered from the in-process collectors without entering the proxy path `cpt-cf-oagw-flow-proxy-request` implements, so counting it would make the traffic series report its own observation and would make the record stream describe a request that resolved nothing. The preflight distinction is the one that feature already records: it answers a preflight at handler level before the proxy path authenticates a caller, so the request reaches no step whose outcome this feature reads, while its origin and method refusals happen after resolution and are recorded in the execution context like every other outcome. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The header allowlist DESIGN §4.3 names is closed at one name, the correlation header the platform injects, and its value appears in a record only as the `request_id` field; the `record_headers` list `config/e2e-local.yaml` configures for the platform's trace collector is the platform's list and is not this feature's allowlist. | DESIGN §4.3 states that headers are "never logged except from an allowlist" without naming the list, and the fourteen-field record carries no header field at all, so the allowlist governs the values that reach the fields that do exist. The one header whose value a field reports is the correlation header, whose value is the `request_id` the record carries, and admitting it is what makes the record findable by the identifier the caller already holds. Every other header value — including `Authorization`, whose token is credential material, and including the three names the platform's own `record_headers` list names for its collector — is excluded, and that list is a trace-surface configuration of the platform that this feature reads nothing of. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The authentication failures this feature records are the ones the proxy path observes: the failures `cpt-cf-oagw-algo-chain-execute` reports when an auth plugin cannot resolve or cannot apply a credential, and the 403 answers of `cpt-cf-oagw-flow-proxy-authorize` for a token without the permission it requires. The 401 the platform middleware answers for a missing or invalid token never reaches the gear and is not a record this feature can write, and the threshold of `cpt-cf-oagw-nfr-observability` is read over the requests the proxy path serves. | DESIGN §3.3 names `toolkit-auth` as the inbound mechanism and `cpt-cf-oagw-feature-data-plane-proxy` records in its own error scenarios that a missing or invalid token is answered 401 by the platform middleware, so that answer is produced before any OAGW handler runs and no record of it can originate here. Logging a request the gear never saw would require the gear to observe a surface it does not hold. Every failure the gear does see is recorded, which is what the 100% threshold is testable over, and the rate-limit bound of §1.5 applies to the records that remain. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| A circuit-breaker transition is written as its own record in addition to the one request record the exchange produces, never instead of it, and the fourteen-field record carries no `from_state` or `to_state` member of its own: the two states of a transition are carried on the `oagw_circuit_breaker_transitions_total{host, from_state, to_state}` series the same exchange reports. | DESIGN §4.3 fixes the record at fourteen fields with no fifteenth, so a transition record cannot carry the two states as fields without widening the record the same section closes; DESIGN §4.2 already fixes the states as the labels of `oagw_circuit_breaker_transitions_total`, which the exchange reports at the same observation. Writing the transition record in addition to the request record keeps §1.4's premise that every served request is recorded intact, because the request record of that exchange is still written; without the qualifier the additional record would read as the request record being replaced. The transition record is never sampled and carries the correlation identifier of the request that produced the transition, so a consumer can join the two records of one exchange. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The `auth.failed` literal is selected for the exchanges the proxy path answers with a refusal of the caller's identity or permission: the request that carries no subject, the request the enforcer denies, and the exchange whose error kind is the authentication failure `cpt-cf-oagw-algo-chain-execute` maps. Every such exchange is bounded by the failure-log limit of §1.5, which is what makes the limit reach a served request. | DESIGN §4.3 logs "authentication failures" as a category but names no literal for it, and §1.5 row 177 fixes the exchanges the category covers as the ones the proxy path observes. Selecting the literal on the answer the path produced — the refusal the gateway answered itself or the error kind the chain reported — keeps the category reachable by every branch that answers it, which the fixed event set alone cannot guarantee, and routes the whole category through one bound so a flood of refusals cannot produce unbounded ERROR records. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The `host` label of the three answer families `oagw_requests_total`, `oagw_errors_total`, and `oagw_request_duration_seconds` is the resolved upstream's alias, and a request the gateway answered without resolving one is filed under the one bounded literal `_unresolved`; `oagw_requests_in_flight` keeps the alias the caller addressed, because its raise at the correlate step and its lower at the observation must name the same series. | DESIGN §4.2 fixes `host` as the upstream alias and the same subsection's cardinality-management paragraph exists to keep label value sets out of caller control, while §1.5 row `inst-amo-labels` states the same rule for the `host` field. A request the gateway refused before resolution has no upstream to name, and filing its answer under the alias the caller invented — any RFC 1123 name up to 253 characters — would put a permanent series under caller control, including a permanent `oagw_requests_in_flight` series for an alias no configuration holds. The in-flight gauge is the one exception its own raise/lower pairing forces: filing its raise under the addressed alias and its lower under a sentinel would leak one raised series per invented alias. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| This document applies `cpt-cf-oagw-principle-error-source` and cites `cpt-cf-oagw-adr-error-source-distinction` and `cpt-cf-oagw-adr-state-management` in §1.4 and §5, none of which is on the §1.2 lists DECOMPOSITION §2.9 maps to this entry. | DECOMPOSITION §2.9 maps `cpt-cf-oagw-principle-cred-isolation` and `cpt-cf-oagw-constraint-toolkit-deploy` only, and both are on the §1.2 lists. The additions are applied rather than listed because they govern behaviour this feature cannot opt out of: the error-source distinction decides which answers carry a `trace_id` echo at all, and the state ownership of ADR 0006 decides which of the twelve series this feature reads and which it must never write. Every sibling feature document mirrors its baseline list except where it records the superset. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | + +### 1.6 Explicit Non-Applicability + +The areas below apply to the gear as a whole but not to this feature. Each is stated here so the omission is a recorded decision rather than a silent gap, and each names the feature that does own it. + +- **The proxy path itself: resolution, matching, endpoint selection, validation, the rate-limit check, the chain, forwarding, and classification.** `cpt-cf-oagw-feature-data-plane-proxy` owns all of them, `cpt-cf-oagw-feature-rate-limiting` owns the check inside them, and this feature registers none of their steps. The three attachment points §1.1 names are the whole of this feature's presence on that path, and §2 restates no step of it: the flow below names the steps it reads by reference and adds no decision to any of them. +- **The circuit breaker and the rate-limit algorithms.** `cpt-cf-oagw-feature-rate-limiting` owns both, including the closed, open, and half-open machine `cpt-cf-oagw-state-circuit-breaker` declares. This feature observes that machine as a label value and a transition pair and changes nothing about it: no series it emits can trip, open, probe, or close a breaker, and no record it writes feeds a failure count. +- **The ten management routes and the upstream and route write path.** `cpt-cf-oagw-feature-control-plane-config` owns them, and this feature registers no management path and performs no write. The configuration-change record is the only thing this feature contributes to that path, and it is written after the write completed (§1.5). +- **Distributed tracing backends, spans, and trace propagation.** All three are out of scope per DECOMPOSITION §2.9, and this feature opens no span, exports no trace, and reads no W3C trace context. `config/e2e-local.yaml` sets `opentelemetry.tracing.enabled: false` and `opentelemetry.metrics.enabled: false`, so the graded deployment exports neither signal from the platform either; what this feature delivers is the correlation identifier and the two catalogues, which is what the out-of-scope list leaves in. +- **Dashboard provisioning and metric scraping infrastructure outside the gear.** Both are out of scope per DECOMPOSITION §2.9. What this feature delivers is one endpoint that renders the exposition in the format a scraper reads, with `# HELP` and `# TYPE` lines per family; who scrapes it, how often, and what is drawn from the result are an operator's deployment decisions and no surface of this gear. +- **Log retention.** Out of scope per DECOMPOSITION §2.9 and an open question in PRD §13. The audit stream is written and never read back by this feature, no record is rotated, aged, or deleted here, and no retention key exists in `OagwConfig` to set (§1.5). +- **Persistence.** DECOMPOSITION §2.9 declares no table for this feature, and `cpt-cf-oagw-db-schema` is fully claimed by `cpt-cf-oagw-feature-control-plane-config` and `cpt-cf-oagw-feature-plugin-system`. An `AuditEvent` is written once and never revisited, a `CorrelationContext` is dropped with the request, and the twelve families are in-process series that a restart empties. +- **Health and readiness.** `cpt-cf-oagw-feature-gear-foundation` owns the provisioning state machine and the readiness signal, and no metric of this feature reports gear health. The `oagw_upstream_available` gauge reports whether the breaker of an upstream admits, which is a property of that upstream and not of the gear. +- **Latency targets.** The proxy path's budget is `cpt-cf-oagw-nfr-low-latency`'s, whose Definition of Done `cpt-cf-oagw-feature-data-plane-proxy` carries, and this feature states no target of its own. The emission is issued after the response is produced, in the same post-response position the sibling's `last_used_at` write takes, so the cost it adds to the measured path is the reading of values the path already computed and not a computation on it. +- **gRPC proxying and WebTransport.** Both are out of scope per DECOMPOSITION §1.3(4), and neither produces a proxy request this feature observes: a gRPC upstream produces no matching route, which `cpt-cf-oagw-feature-data-plane-proxy` records, and a `wt`-scheme upstream is refused at dial time before any upstream call, so both appear in the surface only as the refused answers they are. +- **Rollout, rollback, versioning, localization, accessibility, and compliance.** The gear is one configuration item and one release unit (DECOMPOSITION §1.4), so this feature ships no rollout of its own. The exposition is fixed-format text and the records are fixed-field JSON, so there is no version negotiation and no schema to migrate. The `# HELP` strings and the problem `detail` strings this feature's answers pass through are English protocol text with no locale negotiation and no rendered actor-facing surface to make accessible. No credential material, no request body, no query parameter, and no header value other than the allowlisted one reaches anything this feature emits, so there is no personal or regulated datum here to hold a compliance obligation over; the one datum that names a caller is the tenant and subject identifier the platform already resolved and the record already reports as an identifier and not as an attribute. +- **Workarounds, deprecation, and migration.** None applies. The two limitations §1.5 records — the sampling ratio and the failure-log bound, both build-time constants with no configuration surface — have no workaround short of a code change, which is outside this run's authority, and every identifier this feature reads is fixed at `.v1`. +- **Diagnostic reading, troubleshooting, and self-healing.** The surface an operator reaches first is the exposition itself, and the reading it supports is fixed by the catalogue: `oagw_requests_total` and `oagw_request_duration_seconds` answer whether a resolved upstream is reachable at all, `oagw_errors_total` with its `error_type` label names the catalogue row the failures fall under, `oagw_circuit_breaker_state` and `oagw_circuit_breaker_transitions_total` answer whether a refusal came from the breaker rather than from the caller, `oagw_rate_limit_exceeded_total` and `oagw_rate_limit_usage_ratio` answer whether a refusal came from a limit, and `oagw_upstream_available` answers whether the breaker has taken an endpoint out of rotation. A family rendered with its `# TYPE` and `# HELP` lines and no sample lines means the state it reports does not exist yet — no request has touched that upstream, or no transition has occurred — and is not a scrape failure. This feature has no self-healing behaviour of its own, because it mutates no state it reports and no state it reads, and the remediation each of those readings points at is the owning feature's. + +## 2. Actor Flows (CDSL) + +The three flows below are the whole of this feature's presence on the gear's paths. The first runs at two points of the proxy path `cpt-cf-oagw-flow-proxy-request` of `cpt-cf-oagw-feature-data-plane-proxy` implements — its entry, before that path authorizes anything, and its exit, after the answer exists or the streamed transfer has ended — and restates the path's own steps by reference only. The second runs once per management write at the completion point of a handler of `cpt-cf-oagw-feature-control-plane-config`. The third is the one flow this feature owns end to end, because the endpoint it serves is the one path this feature registered. + +**Use cases**: none is restated here. + +DECOMPOSITION §2.9 names no use case for this entry, and every use case the PRD declares belongs to the feature whose path produces it: `cpt-cf-oagw-usecase-proxy-request` is `cpt-cf-oagw-feature-data-plane-proxy`'s, `cpt-cf-oagw-usecase-configure-upstream` and `cpt-cf-oagw-usecase-configure-route` are `cpt-cf-oagw-feature-control-plane-config`'s, and none is restated here. This feature is reached from those paths and adds no second statement of any of them. + +```mermaid +sequenceDiagram + participant C as Caller + participant API as API Handler + participant DP as Data Plane + participant RL as Rate Limiting + participant OB as Observability + participant OP as Operator + + C->>API: {METHOD} /oagw/v1/proxy/{alias}/{path_suffix} + API->>OB: assign the correlation identifier + OB-->>API: CorrelationContext on the request + API->>DP: execute_proxy(alias, path_suffix, query, req) + DP->>DP: authorize, resolve, match, select, validate + DP->>RL: rate-limit check + alt refused + RL-->>DP: 429 or 503 + else admitted + DP->>DP: chain, transform, forward + end + DP-->>API: ProxyResponse with X-OAGW-Error-Source + API->>OB: emit the audit record, observe the metric families + OB->>OB: redact, sample, write one JSON line to stdout + API-->>C: HTTP response with trace_id on a gateway error + OP->>API: GET /oagw/v1/metrics + API->>OB: enforce metrics read permission, render the exposition + OB-->>OP: Prometheus text exposition +``` + +### Observe a Proxied Request + +- [x] `p1` - **ID**: `cpt-cf-oagw-flow-request-observed` + +**Actor**: `cpt-cf-oagw-actor-app-developer` + +This flow runs once per proxy request and is invoked at the two points §1.1 names. It owns the correlation identifier, the record, and the series, and nothing that produced them: by the time its exit point runs, the request has been resolved, matched, authorized, validated, charged, chained, transformed, forwarded, and answered, or refused at whichever step refused it. It adds no decision to the path and no step the caller observes. + +**Success Scenarios**: + +- Every proxy request the path serves carries a correlation identifier from the moment it enters the handler, taken from the inbound `X-Request-Id` header when one arrived and generated as a UUID otherwise (§1.5). +- Exactly one audit record is written for the request, at the exit point, with the fields its outcome populates and the redaction of §3 already applied. +- The twelve metric families are observed from the outcome, and the in-flight gauge is raised for the duration of the exchange and lowered when it ends. +- A gateway error body carries the correlation identifier as the `trace_id` extension field, through `ErrorContext` and `cpt-cf-oagw-algo-error-mapping`, and an upstream-sourced answer carries the upstream's own body and no echo. +- A streamed exchange is recorded once, when the transfer ends, with the duration and the byte counts the transfer produced. + +**Error Scenarios**: + +- The path refuses the request before any outbound call — an authorization, validation, matching, or rate-limit refusal — and the record is still written, with the status the refusal produced and the `error_type` of its catalogue variant. +- The upstream answers with a failure status: the answer passes through under the error-source classification, and the record carries `error_type` as `upstream` and the status the upstream produced. +- The correlation header is absent, or carries a value the admission check of §1.5 refuses: a UUID is generated, and the record still carries a correlation identifier. +- The platform resolves no tenant or subject for the request: the record is written with both fields omitted and neither is synthesized (§1.4). + +**Steps**: + +1. [x] - `p1` - Actor issues the proxy request carrying the method, the alias, an optional path suffix, an optional query, and any headers including `Authorization` - `inst-ro-issue` +2. [x] - `p1` - API: `{METHOD} /oagw/v1/proxy/{alias}[/{path_suffix}][?{query}]` — the proxy path `cpt-cf-oagw-feature-data-plane-proxy` registers, which is the path this flow is reached from and not a second registration of it - `inst-ro-api` +3. [x] - `p1` - At the entry of that path, before any authorization runs, `cpt-cf-oagw-algo-correlate` assigns the correlation identifier, records it on the `CorrelationContext` the `ProxyContext` of that request carries, and raises `oagw_requests_in_flight` for the resolved upstream under the cardinality rules of §1.5, so the gauge is raised at the entry the routine already occupies and lowered at the exit step below - `inst-ro-correlate` +4. [x] - `p1` - `cpt-cf-oagw-flow-proxy-request` runs the path's own steps, restated here by reference and decided by that feature: the alias normalization, the authorization, the resolution, the match, the endpoint selection, the inbound and body validation, the rate-limit check of `cpt-cf-oagw-feature-rate-limiting`, the composed chain, the header transformation, the forward, and the response classification that produces the `ProxyResponse` this flow's exit reads - `inst-ro-path` +5. [x] - `p1` - **IF** the path answered the request from a gateway error it produced - `inst-ro-gateway-if` + 1. [x] - `p1` - The correlation identifier is copied into the `ErrorContext` of that error as its `trace_id` member, and `cpt-cf-oagw-algo-error-mapping` of `cpt-cf-oagw-feature-gear-foundation` attaches it to the problem body as an extension field; no problem body is built here and no second serialization path is added - `inst-ro-echo` +6. [x] - `p1` - **ELSE** - `inst-ro-gateway-else` + 1. [x] - `p1` - The upstream's own answer passes through with its body unmodified under the error-source classification, so it carries no `trace_id`, and no echo is synthesized for it - `inst-ro-no-echo` +7. [x] - `p1` - At the exit of the path, after the `ProxyResponse` exists or the streamed transfer of `cpt-cf-oagw-feature-streaming` has ended, `cpt-cf-oagw-algo-metrics-observe` applies the cardinality rules and updates the twelve metric families from the execution context and the sibling states - `inst-ro-observe` +8. [x] - `p1` - `cpt-cf-oagw-algo-audit-emit` builds the `AuditEvent`, applies the redaction and the header allowlist, applies the sampling decision of the `CorrelationContext`, and writes one JSON line to stdout; for a streamed exchange this step is reached once, when the transfer ends - `inst-ro-emit` +9. [x] - `p1` - **IF** the request was answered by a gateway error, or the upstream answered with a failure status - `inst-ro-failed-if` + 1. [x] - `p1` - The record is a failed record: its level is the one the mapping of §1.5 assigns — ERROR for an upstream failure status, a timeout, and an authentication failure, WARN for a rate-limit refusal and a breaker-open answer, INFO for every other refusal — its `event` literal is `auth.failed` when the failure is the authentication failure §1.5 records and `proxy_request.failed` otherwise, its `error_type` is the catalogue slug of the variant the gateway answered or `upstream` for an upstream failure status, and every field a success record carries is present alongside it (§1.5) - `inst-ro-failed-record` +10. [x] - `p1` - **ELSE** - `inst-ro-failed-else` + 1. [x] - `p1` - The record is a success record at INFO, its `error_type` is omitted, and its sampling decision is the high-volume-route decision §1.5 records - `inst-ro-success-record` +11. [x] - `p1` - **RETURN** the answer unchanged: this flow mutates no header, no status, and no body of any response except the `trace_id` extension field a gateway error body carries, and it adds no latency to the measured path beyond the reading of values the path already computed (§1.6) - `inst-ro-return` + +### Record a Configuration Change + +- [x] `p1` - **ID**: `cpt-cf-oagw-flow-config-change-logged` + +**Actor**: `cpt-cf-oagw-actor-tenant-admin` + +This flow runs once per management write that completed, at the completion point of a handler of `cpt-cf-oagw-feature-control-plane-config`, and it records the change and nothing else. It is not a proxy flow: no upstream is contacted, no route is matched, and no rate limit is charged, which is why the record it produces carries no proxy-path field (§1.5). + +**Success Scenarios**: + +- A create, a replacement, an `enabled` change, or a delete over an upstream, a route, or a plugin completes, and one record is written for it with an event name that carries the resource kind and the operation. +- The record carries the writer's tenant and principal, the management path and method the write was addressed to, and the status the handler answered. +- The record is written before the handler's response is returned to the caller, so a consumer that sees the response has already seen the record's write begin. + +**Error Scenarios**: + +- The write is refused — by validation, by authorization, or by a conflict — and no configuration-change record is written, because the record reports a change and no change happened; the failure is answered by the management path that refused it, which logs its own failure outcomes. +- The write fails at the storage layer: no record is written, because the persisted state is unchanged. +- The completion notification does not reach this feature: no record is written and the write still succeeds, which is a gap in the audit trail and not a failure of the write (§1.5). + +**Steps**: + +1. [x] - `p1` - Actor issues the management request — a create, a replacement, an `enabled` change, or a delete over an upstream, a route, or a plugin — through a route `cpt-cf-oagw-feature-control-plane-config` or `cpt-cf-oagw-feature-plugin-system` registers - `inst-cc-issue` +2. [x] - `p1` - API: one of the ten management paths of `cpt-cf-oagw-feature-control-plane-config` or the plugin paths of `cpt-cf-oagw-feature-plugin-system` — the platform middleware authenticates the bearer token, resolves the calling tenant and subject, and the handler enforces the permission of the resource kind - `inst-cc-api` +3. [x] - `p1` - The handler validates the write, applies it in one transaction, and flushes the Control Plane cache, which is the whole of the write path and is that feature's act and not this one's - `inst-cc-write` +4. [x] - `p1` - **IF** the write completed - `inst-cc-completed-if` + 1. [x] - `p1` - `cpt-cf-oagw-algo-audit-emit` builds the configuration-change `AuditEvent` at the same in-process post-write seam that feature's cache-flush and cleanup notifications use (§1.5): the event name carries the resource kind and the operation, `tenant_id` and `principal_id` are the writer's, `path` and `method` are the management path and method addressed, `status` is the status the handler answered, and `host`, `duration_ms`, `request_size`, and `response_size` are omitted because no proxy exchange happened - `inst-cc-emit` + 2. [x] - `p1` - The record is written to stdout at INFO and is not subject to the high-volume sampling decision, because a configuration change is by definition not a high-volume event - `inst-cc-level` +5. [x] - `p1` - **ELSE** - `inst-cc-completed-else` + 1. [x] - `p1` - No configuration-change record is written; the refusal is answered by the management path that produced it, which reports its own failure outcomes with the correlation identifier and logs no request body and no configuration value - `inst-cc-refused` +6. [x] - `p1` - **RETURN** the handler's response unchanged: this flow mutates no status, no header, and no body of it - `inst-cc-return` + +### Scrape the Metrics Endpoint + +- [x] `p1` - **ID**: `cpt-cf-oagw-flow-metrics-scrape` + +**Actor**: `cpt-cf-oagw-actor-platform-operator` + +This flow is the one flow this feature owns end to end, because `GET /oagw/v1/metrics` is the one path this feature registered. It reads the in-process collectors and renders them, and it contacts no upstream, walks no hierarchy, and reads no persisted configuration. + +**Success Scenarios**: + +- A `GET` with a token carrying the metrics read permission is answered 200 with the Prometheus text exposition of the twelve families, each with its `# HELP` and `# TYPE` lines and its label set as §3 declares. +- A family that has observed nothing since process start is rendered with its type and help and no samples, rather than omitted, so a scraper sees the catalogue whole. +- The exposition is rendered from the collectors at scrape time, so a family describing state another feature owns reports that state as it stands when the scrape is served. + +**Error Scenarios**: + +- The bearer token is missing or invalid: 401, answered by the platform middleware before this feature's handler runs. +- The token lacks `gts.cf.core.oagw.metrics.v1~:read`: 403, and no exposition is rendered (§1.5). +- The method is not `GET`: no handler is registered for it, and the router answers it. +- A family's underlying state is not exposed by the component that owns it: that family is omitted from the exposition rather than emitted as a constant (§1.4). + +**Steps**: + +1. [x] - `p1` - Actor issues `GET /oagw/v1/metrics` with a bearer token - `inst-ms-issue` +2. [x] - `p1` - API: `GET /oagw/v1/metrics` — the one path this feature registers, on the gear-relative router mount point `cpt-cf-oagw-feature-gear-foundation` created, with the platform middleware authenticating the bearer token - `inst-ms-api` +3. [x] - `p1` - The handler enforces `gts.cf.core.oagw.metrics.v1~:read` before any collector is read, answering 403 for a token without it (§1.5) - `inst-ms-authz` +4. [x] - `p1` - **IF** the token carries the permission - `inst-ms-permitted-if` + 1. [x] - `p1` - `cpt-cf-oagw-algo-metrics-render` reads the twelve in-process collectors and renders the Prometheus text exposition format, with a `# HELP` and a `# TYPE` line per family, the histogram rendered as its `_bucket` series with the `le` label plus its `_sum` and `_count` series, and every label key and value the cardinality rules of §3 admit - `inst-ms-render` + 2. [x] - `p1` - **RETURN** 200 with the exposition and the content type the format names, and write no audit record and observe no series for the scrape itself (§1.5) - `inst-ms-return` +5. [x] - `p1` - **ELSE** - `inst-ms-permitted-else` + 1. [x] - `p1` - **RETURN** 403 with no exposition rendered, through `cpt-cf-oagw-algo-error-mapping` of `cpt-cf-oagw-feature-gear-foundation`, so the refusal is an `application/problem+json` body tagged `X-OAGW-Error-Source: gateway` and carrying the `trace_id` of the correlation context the scrape request was assigned - `inst-ms-forbidden` + +## 3. Processes / Business Logic (CDSL) + +The four routines below are called by the three flows in §2 and by each other in the order those flows state. None of them opens a socket, reads a database, or contacts another gear: the values they read are the members of the execution context the proxy path built, the live state of the two features whose state they report, and the configuration the management path wrote. Every record they write goes to stdout and nowhere else, and every series they update is in-process. + +### Assign the Correlation Identifier + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-correlate` + +**Input**: the inbound request headers as the platform middleware delivered them, and the authenticated tenant and subject when the platform resolved them. + +**Output**: a `CorrelationContext` carrying the correlation identifier, its source, and the sampling decision of the request. + +The routine runs at the entry of the proxy path and before any authorization, because a request the path refuses is still a request an operator needs to find, and because the identifier has to exist before the first thing that can fail. It is the reason DECOMPOSITION §2.9 states that correlation identifiers are propagated on every request and not only on the ones that succeed. + +**Steps**: + +1. [x] - `p1` - Read the correlation header the platform injects from the inbound request headers - `inst-ac-read` +2. [x] - `p1` - **IF** the header is present and its value is a bounded, printable identifier containing no control character (§1.5) - `inst-ac-adopt-if` + 1. [x] - `p1` - Adopt the value as the correlation identifier and record on the `CorrelationContext` that it was taken from the inbound header - `inst-ac-adopt` +3. [x] - `p1` - **ELSE** - `inst-ac-adopt-else` + 1. [x] - `p1` - Generate a UUID as the correlation identifier and record on the `CorrelationContext` that it was generated, which is the branch every request takes when the platform injects nothing and the branch a value that fails the admission check takes (§1.4) - `inst-ac-generate` +4. [x] - `p1` - Record the tenant and subject identifiers on the `CorrelationContext` when the platform resolved them, and record their absence as an absence rather than as a synthesized value (§1.4) - `inst-ac-ident` +5. [x] - `p1` - Record the sampling decision on the `CorrelationContext`: the high-volume-route ratio for the success record of a route §1.5 classifies as high-volume, and no sampling for every other record the request can produce - `inst-ac-sampling` +6. [x] - `p1` - **RETURN** the `CorrelationContext`, for the `ProxyContext` of the request to carry and for every routine of §3 that writes on the request's behalf to read - `inst-ac-return` + +**Error handling**: the routine has no failure mode that refuses a request. A header that carries no value, a value with a control character, and a value beyond the bound all take the generation branch, so the identifier is always present and never unbounded. The routine writes nothing and emits no record; the record for the request is written at its exit by `cpt-cf-oagw-algo-audit-emit`, and an error the path produces before that point is still a request this routine gave an identifier to. + +### Emit the Audit Record + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-audit-emit` + +**Input**: the outcome recorded in the request's execution context, the `CorrelationContext`, and, for a configuration change, the resource kind, the operation, the management path and method, and the status the handler answered. + +**Output**: one JSON line written to stdout, or no line when the sampling decision or the failure-log bound drops it. + +The record carries exactly the fourteen fields DESIGN §4.3 tabulates — `timestamp`, `level`, `event`, `request_id`, `tenant_id`, `principal_id`, `host`, `path`, `method`, `status`, `duration_ms`, `request_size`, `response_size`, `error_type` — and no fifteenth member (§1.5). A field with no value for the event is omitted rather than written null or written empty, which is why the fourteen names are the record's field set and not a guarantee that every record carries fourteen values. + +**Steps**: + +1. [x] - `p1` - Build the `AuditEvent` from the event name the outcome selects, which is one of the closed set §1.5 records: the success request, the failed request, the authentication failure, the circuit-breaker transition, or the configuration change whose event name carries the resource kind and the operation - `inst-ae-event` +2. [x] - `p1` - Populate the fields the event name calls for from the execution context, the `CorrelationContext`, and the sibling states: `timestamp` from the instant the write is issued, read once per record so every field of one record describes one moment, `request_id` from the correlation identifier, `tenant_id` and `principal_id` from the platform-resolved identity, `host` from the resolved upstream's alias, `path` from the matched route pattern or from the management path, `method` from the request, `status` from the answer, `duration_ms` from the measured duration, `request_size` and `response_size` from the byte counts as transferred, and `error_type` from the catalogue slug or the `upstream` literal (§1.5) - `inst-ae-populate` +3. [x] - `p1` - Apply the redaction before any field is serialized: no request body, no response body, no query parameter, and no header value other than the one name the allowlist of §1.5 admits reaches any field, and no API key, no token, no credential material, and no `cred://` reference value reaches any field, any value derived into a field, or any message a field carries - `inst-ae-redact` +4. [x] - `p1` - Assign the level from the mapping of §1.5: INFO for a success record, a normal operation, and a transition whose destination state is not `open`, WARN for a rate-limit refusal, a breaker-open answer, retry guidance emitted, and a transition whose destination state is `open`, ERROR for an upstream failure, a timeout, and an authentication failure, and no record at DEBUG in the graded deployment - `inst-ae-level` +5. [x] - `p1` - **IF** the event is a success record on a route the sampling decision samples - `inst-ae-sample-if` + 1. [x] - `p1` - Apply the 1/100 decision of the `CorrelationContext` and drop the record when the decision is not to sample, keeping the sampling constant per request rather than per route so a route cannot be sampled into silence or out of it by a second decision (§1.5) - `inst-ae-sample` +6. [x] - `p1` - **ELSE IF** the event is an authentication-failure record - `inst-ae-flood-if` + 1. [x] - `p1` - Apply the failure-log bound and drop the record when the interval's allowance is spent, so a flood of failed authentication attempts produces at most that many records per interval and the surplus is dropped rather than queued (§1.5) - `inst-ae-bound` +7. [x] - `p1` - **ELSE** - `inst-ae-else` + 1. [x] - `p1` - Apply neither decision: a failed request, a circuit-breaker transition, and a configuration change are never sampled and never bound, because each is either an event an operator must see or an event that is by definition not high-volume - `inst-ae-unbound` +8. [x] - `p1` - Serialize the record as one JSON object with the fourteen field names in the order DESIGN §4.3 lists them and write one line to stdout, which is the destination DESIGN §4.3 names and not a target of the platform's `logging` section (§1.5) - `inst-ae-write` +9. [x] - `p1` - **RETURN** nothing: the routine produces no value the caller uses, holds nothing after the write, and never revisits a record it wrote - `inst-ae-return` + +**Error handling**: a failure to write the line is not a failure of the request that produced it, and the routine raises nothing into the path that called it, because an observability failure must not turn a served request into an unserved one. The routine retries no write, which is `cpt-cf-oagw-principle-no-retry` applied to its own output, and it holds no buffer that could lose a record silently: a dropped record is dropped by a stated rule, either the sampling decision or the failure-log bound, and never by an error path. + +### Update the Metric Families + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-metrics-observe` + +**Input**: the outcome recorded in the request's execution context, the `CorrelationContext`, the `ResolvedUpstream` and the `SelectedEndpoint` of the request, the breaker state and transitions of `cpt-cf-oagw-feature-rate-limiting`, the rate-limit state and the 429 outcome of the same feature, and the shared outbound client's connection-pool occupancy. + +**Output**: updated values for the twelve metric families of DESIGN §4.2. + +The routine applies the cardinality rules before it writes any series, because a label value that the rules exclude must never reach a collector: the rules are applied at the observation and not at the render, so a scrape can never expose a value the rules would have refused. + +**Steps**: + +1. [x] - `p1` - Derive the label values under the rules of §1.5: `host` from the resolved upstream's alias, `http.route` from the matched route's normalized match pattern and never from the raw request path, `http.request.method` as the standard verb or `_OTHER` for a method outside the five literals the shipped route schema declares, and `http.response.status_code` as the numeric status of the response the caller received (§1.5) - `inst-amo-labels` +2. [x] - `p1` - Increment `oagw_requests_total` once per proxy request the path served, with the four labels of its set, and carry the gateway's own status on a request the gateway answered without contacting the upstream (§1.5) - `inst-amo-requests` +3. [x] - `p1` - Record the four `phase` durations of the request into `oagw_request_duration_seconds` against the buckets DESIGN §4.2 states, and no other phase (§1.5) - `inst-amo-duration` +4. [x] - `p1` - Lower `oagw_requests_in_flight` for the resolved upstream now that the exchange has ended, which is the lower half of the raise `cpt-cf-oagw-algo-correlate` performed at the path's entry, so the gauge is raised once at admission and lowered once at the exit and holds its value for the whole of a streamed transfer (§1.5) - `inst-amo-inflight` +5. [x] - `p1` - **IF** the request was answered by a gateway error or the upstream answered with a failure status - `inst-amo-error-if` + 1. [x] - `p1` - Increment `oagw_errors_total` with `host`, `http.route`, and the `error_type` of §1.5, which is the catalogue slug of the variant the gateway answered or `upstream` for an upstream failure status - `inst-amo-error` +6. [x] - `p1` - **ELSE** - `inst-amo-error-else` + 1. [x] - `p1` - Increment nothing in that family, because a successful request is not an error and no series of this feature reports success as one - `inst-amo-no-error` +7. [x] - `p1` - Observe the rate-limit state `cpt-cf-oagw-feature-rate-limiting` owns without mutating it: read the breaker state of the resolved upstream into `oagw_circuit_breaker_state`, increment `oagw_circuit_breaker_transitions_total` with the `from_state` and `to_state` of each transition that machine reported, increment `oagw_rate_limit_exceeded_total` on the 429 outcomes it produced, and read the allowance ratio into `oagw_rate_limit_usage_ratio`, with the `path` label of both `rate_limit` families carrying the normalized route match pattern (§1.5) - `inst-amo-ratelimit` +8. [x] - `p1` - Observe the endpoint selection `cpt-cf-oagw-feature-data-plane-proxy` performed: increment `oagw_routing_endpoint_selected` with the `selection_method` its routine recorded, which is `explicit_header` for a header-selected endpoint, `round_robin` for a load-balanced one, and `default` for the only candidate of a single-endpoint pool, and increment `oagw_routing_target_host_used` when the request named its target through the routing header - `inst-amo-routing` +9. [x] - `p1` - Derive `oagw_upstream_available` per host and endpoint from the breaker state of §1.5, as 1 when the machine admits and 0 when it does not, and read `oagw_upstream_connections` from the shared outbound client's connection-pool occupancy with `state` carrying `idle`, `active`, or `max` as DESIGN §4.2 enumerates - `inst-amo-health` +10. [x] - `p1` - Write no tenant value into any label of any family, which is the one rule of DESIGN §4.2's management paragraph that has no exception and the reason the tenant identifier is an audit field and never a label - `inst-amo-no-tenant` +11. [x] - `p1` - **RETURN** nothing: the routine updates the collectors and produces no value the caller uses - `inst-amo-return` + +**Error handling**: the routine reads the state of two other features and mutates neither, which is the posture §1.5 records; a state it cannot read is a family it does not update, and a family whose underlying state is not exposed at all is omitted from the exposition rather than emitted as a constant (§1.4). No credential material, no `cred://` reference value, and no header value reaches a label, because the label vocabulary is closed and none of its keys can carry one. + +### Render the Prometheus Exposition + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-metrics-render` + +**Input**: the twelve in-process collectors and the permission decision of the handler that invoked it. + +**Output**: the Prometheus text exposition of the families, or the 403 the permission decision produced. + +The render's cost is bounded by the catalogue itself: twelve families over closed label sets, each read from live state and aggregated nowhere at render time, so a scrape is linear in the number of series and no route rate limit is applied to it, because the bounded render and the `gts.cf.core.oagw.metrics.v1~:read` permission of §1.5 are the whole abuse surface of the path this feature owns. + +**Steps**: + +1. [x] - `p1` - Read each of the twelve collectors at the moment the scrape is served, so a family describing state another feature owns reports that state as it stands and not as it stood at the last observation - `inst-amr-read` +2. [x] - `p1` - Emit a `# HELP` line and a `# TYPE` line for each family, declaring counter, gauge, or histogram as DESIGN §4.2 assigns, and emit a family that has observed nothing with its type and help and no samples rather than omitting it - `inst-amr-headers` +3. [x] - `p1` - Render the histogram family as its `_bucket` series with the `le` label over the twelve buckets DESIGN §4.2 states, in seconds, plus its `_sum` and its `_count` series, and render every other family as one series per label-value combination its set admits - `inst-amr-histogram` +4. [x] - `p1` - Render every label value under the closed sets §1.5 records, so no value reaches the exposition that the cardinality rules would exclude, including the absence of any tenant value in any label - `inst-amr-values` +5. [x] - `p1` - **RETURN** the rendered exposition with the content type the text exposition format names, and write no audit record for the scrape - `inst-amr-return` + +**Error handling**: the routine renders what the collectors hold and invents nothing: a family whose underlying state is not exposed is omitted (§1.4), a counter that has observed nothing is rendered empty and not as zero samples with labels it never carried, and the routine performs no aggregation, no rate computation, and no status-class folding, because DESIGN §4.2 states that status-class queries are expressed at query time by regex on the numeric code and this feature renders the code, not the class. + +## 4. States (CDSL) + +No state machine is defined for this feature, and the absence is a property of what the feature is rather than a gap in this document. DECOMPOSITION §2.9 assigns this feature no state, and two things in its scope could be mistaken for one, so both are disposed of here. + +The first is the circuit breaker. The only lifecycle this feature reads is the closed, open, and half-open machine that `cpt-cf-oagw-state-circuit-breaker` of `cpt-cf-oagw-feature-rate-limiting` declares, and it reads it as a label value on `oagw_circuit_breaker_state`, as a `from_state` and `to_state` pair on `oagw_circuit_breaker_transitions_total`, and as the source of the derived `oagw_upstream_available` gauge. It observes that machine and does not own it: no routine of §3 causes a transition, counts a failure, admits a probe, or closes a circuit, and §1.5 records the read-only posture. A machine this feature cannot move is not a machine this feature declares. + +The second is the `AuditEvent`. An audit record is written once, to stdout, and never revisited: it has no states, no transitions, and no lifecycle beyond the write, and the fourteen fields it carries are populated before it is serialized rather than amended afterwards. A record that had to be corrected, retracted, or completed would be a second record, because the stream is append-only by construction and no consumer of it is promised anything but the order the writes arrived in. The same is true of the `CorrelationContext`, which lives exactly as long as the request it describes and is dropped with it, and of `MetricLabelSet`, which is a declared vocabulary held once and not a value with a lifetime. + +No persisted state is created, transitioned, or retained here: DECOMPOSITION §2.9 declares no table for this feature, `cpt-cf-oagw-db-schema` is fully claimed elsewhere, and a restart empties the counters, resets the gauges, and loses no record that had not already been written. + +## 5. Definitions of Done + +### Correlation Identifier Propagation and the trace_id Echo + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-obs-correlation` + +The system **MUST** assign a correlation identifier to every proxy request the proxy path of `cpt-cf-oagw-feature-data-plane-proxy` serves, at that path's entry and before any authorization runs, taking the value of the inbound `X-Request-Id` header when one arrived and generating a UUID otherwise (§1.5). It **MUST** admit a caller-supplied value only when it is a bounded, printable identifier containing no control character, and **MUST** generate a UUID in place of a value that fails that check, so the identifier is always present and never unbounded. It **MUST** record the identifier on the `CorrelationContext` the `ProxyContext` of the request carries, **MUST** propagate it to every audit record the request produces, and **MUST** copy it into the `ErrorContext` of every gateway error the path produces so that `cpt-cf-oagw-algo-error-mapping` of `cpt-cf-oagw-feature-gear-foundation` attaches it to the problem body as the `trace_id` extension field. It **MUST NOT** synthesize a `trace_id` for an upstream-sourced answer, which passes through with its body unmodified under the error-source distinction, and **MUST NOT** add a second serialization path for the problem body. + +**Implements**: + +- `cpt-cf-oagw-flow-request-observed` +- `cpt-cf-oagw-algo-correlate` + +**Constraints**: none from DESIGN §2.2; the governing elements are `cpt-cf-oagw-adr-error-source-distinction` and the `trace_id` member of `ErrorContext`, which `cpt-cf-oagw-algo-error-mapping` of `cpt-cf-oagw-feature-gear-foundation` carries into the problem body — that routine is that feature's, and this one supplies only the identifier it reads. + +**Touches**: + +- API: none — the echo is carried on the answers of `{METHOD} /oagw/v1/proxy/{alias}[/{path_suffix}]`, the path `cpt-cf-oagw-feature-data-plane-proxy` registers +- DB: none +- DB Table: none +- Entities: `CorrelationContext`, `ErrorContext` + +### Structured Audit Records + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-obs-audit` + +The system **MUST** write exactly one JSON line to stdout for every proxy request the proxy path serves whose record §1.5's sampling and failure-log bound do not drop, carrying exactly the fourteen fields DESIGN §4.3 tabulates and no fifteenth member (§1.5), and **MUST** omit a field that has no value for the event rather than writing it null or empty. It **MUST** write the five logged categories DESIGN §4.3 names — successful requests, failed requests, configuration changes, authentication failures, and circuit-breaker state transitions — with an `event` value drawn from the closed set §1.5 records and with the level the mapping of §1.5 assigns, and **MUST** emit no record at DEBUG in the graded deployment. It **MUST** write the configuration-change record when the management handler of `cpt-cf-oagw-feature-control-plane-config` completes its write, with an event name that carries the resource kind and the operation and with no proxy-path field, and **MUST NOT** write one for a write that was refused or that failed at the storage layer. It **MUST** write the audit stream to stdout and **MUST NOT** write it to a target of the platform's `logging` section, and **MUST NOT** revisit, amend, or retract a record it wrote. + +**Implements**: + +- `cpt-cf-oagw-flow-request-observed` +- `cpt-cf-oagw-flow-config-change-logged` +- `cpt-cf-oagw-algo-audit-emit` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: none — the records describe requests served on paths other features register +- DB: none +- DB Table: none +- Entities: `AuditEvent` + +### Prometheus Metrics Endpoint + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-obs-metrics` + +The system **MUST** register exactly one handler for `GET /oagw/v1/metrics` on the gear-relative router mount point `cpt-cf-oagw-feature-gear-foundation` created, and **MUST** register no other method, path, or query parameter for it. It **MUST** require bearer-token authentication on the endpoint, **MUST** enforce `gts.cf.core.oagw.metrics.v1~:read` before any collector is read, answering 401 for a missing or invalid token and 403 for a token without the permission, and **MUST** answer an authorized scrape with 200 and the Prometheus text exposition of the twelve families of DESIGN §4.2, each with its `# HELP` and `# TYPE` lines, its label set as §3 declares, and the histogram rendered as its `_bucket` series over the twelve buckets DESIGN §4.2 states plus its `_sum` and `_count` series. It **MUST** render a family that has observed nothing with its type and help and no samples, **MUST** omit a family whose underlying state is not exposed rather than emit it as a constant, and **MUST** write no audit record and observe no series for the scrape itself. + +**Implements**: + +- `cpt-cf-oagw-flow-metrics-scrape` +- `cpt-cf-oagw-algo-metrics-render` +- `cpt-cf-oagw-algo-metrics-observe` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: `GET /oagw/v1/metrics` +- DB: none +- DB Table: none +- Entities: `MetricLabelSet` + +### Metric Cardinality Control + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-obs-cardinality` + +The system **MUST** apply the cardinality rules of DESIGN §4.2 at the observation and before any value reaches a collector: **MUST** place no tenant value in any label of any family, **MUST** carry in `http.route` the normalized route match pattern and never the raw request path, **MUST** normalize `http.request.method` to a standard verb or `_OTHER` for a method outside the five literals the shipped route schema declares, and **MUST** carry in `http.response.status_code` the numeric status of the response the caller received, including the gateway's own status on a request the gateway answered without contacting the upstream (§1.5). It **MUST** declare the label sets once as `MetricLabelSet`, with the fourteen shared keys of DESIGN §4.2 and the per-family subsets that section enumerates, **MUST** close every enumerated key at the bounded value set §1.5 records — `phase` at four, `error_type` at the catalogue slugs plus `upstream`, `state` at the three values DESIGN §4.2 enumerates, `selection_method` at the three values DESIGN §4.2 enumerates, and the breaker states at the three `cpt-cf-oagw-state-circuit-breaker` declares — and **MUST NOT** add a label key, a label value, or a metric family that no supplied document names. It **MUST** mutate none of the six series that describe state another feature owns (§1.5). + +**Implements**: + +- `cpt-cf-oagw-algo-metrics-observe` +- `cpt-cf-oagw-algo-metrics-render` +- `cpt-cf-oagw-flow-request-observed` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: `GET /oagw/v1/metrics` — the cardinality rules are visible only in the exposition that path renders +- DB: none +- DB Table: none +- Entities: `MetricLabelSet` + +### Redaction and Credential Isolation + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-obs-redaction` + +The system **MUST** apply the no-PII rule of DESIGN §4.3 to every record it writes: **MUST NOT** log a request body, a response body, or a query parameter, and **MUST NOT** log a header value other than the value of the one name the allowlist of §1.5 admits, that name being the correlation header the platform injects and its value reaching the record only as the `request_id` field. It **MUST** apply the no-secrets rule to every record it writes, every label it places, and every message a field carries: **MUST NOT** log an API key, a token, or any credential material, **MUST NOT** log a `cred://` reference value, and **MUST NOT** place any of them in a metric label or an error message. It **MUST** apply the redaction before any field is serialized, so a redacted value never exists in a serialized form, and **MUST** realize the threshold of `cpt-cf-oagw-nfr-credential-isolation` — zero credential exposure in any log, error, or API output — over the whole of its own output, which is the one place in the gear whose entire output is logs and API responses. + +**Implements**: + +- `cpt-cf-oagw-algo-audit-emit` +- `cpt-cf-oagw-algo-metrics-observe` +- `cpt-cf-oagw-algo-correlate` +- `cpt-cf-oagw-flow-request-observed` + +**Constraints**: none from DESIGN §2.2; the governing elements are `cpt-cf-oagw-principle-cred-isolation` and `cpt-cf-oagw-nfr-credential-isolation`. + +**Touches**: + +- API: none — the redaction governs the records and the exposition, and the one API path this feature registers carries no credential-bearing field +- DB: none +- DB Table: none +- Entities: `AuditEvent`, `MetricLabelSet` + +### Sampling and Log-Flood Control + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-obs-sampling` + +The system **MUST** sample the success records of high-volume routes at the 1/100 ratio DESIGN §4.3's example states, carried as a build-time constant of this feature with no configuration surface and no sourced value beyond that example (§1.5), and **MUST** apply the decision per request so a route is neither sampled into silence nor out of it by a second decision. It **MUST** bound the authentication-failure records with a build-time constant per interval of the same class (§1.5), so a flood of failed authentication attempts produces at most that many records per interval, and **MUST** drop the records beyond the bound rather than queue them. It **MUST NOT** sample, bound, or drop a failed request, a circuit-breaker transition, or a configuration-change record, and **MUST NOT** widen the `OagwConfig` surface to make either constant configurable. + +**Implements**: + +- `cpt-cf-oagw-algo-audit-emit` +- `cpt-cf-oagw-algo-correlate` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: `CorrelationContext` + +### Colocated Tests + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-obs-tests` + +The system **MUST** deliver this feature's unit and integration tests colocated under `gears/system/oagw/oagw/tests/`, covering the correlation assignment from the header and from the generator and each negative of the admission check, the propagation of the identifier to a record and to a gateway error body's `trace_id` and the absence of the echo on an upstream-sourced answer, the presence and the label set of each of the twelve metric families, the twelve histogram buckets and the `_sum` and `_count` series, every cardinality rule including the absence of any tenant label and the `_OTHER` method normalization, the fourteen audit fields and the omission of an unpopulated one, the five logged categories and their four levels, both redaction rules and the single-name allowlist, both build-time constants and the absence of any configuration surface for either, the permission enforcement on the metrics path and its 200 and 403 answers, the configuration-change record at the write-completion seam and its absence for a refused write, the registration statement, and the entity declarations and the consumed types, and **MUST NOT** add any test under `testing/e2e/gears/oagw/`. The upstream, the sibling states, and the stdout sink are the mock boundary of those tests, and nothing below the proxy path's own exit is substituted by any of them; the test data is the correlation header present and absent, the refusal statuses, the failure statuses, and a sampled and an unsampled route; and each test owns its collectors and its sink, so no test observes another's series. + +**Implements**: + +- `cpt-cf-oagw-flow-request-observed` +- `cpt-cf-oagw-flow-config-change-logged` +- `cpt-cf-oagw-flow-metrics-scrape` +- `cpt-cf-oagw-algo-correlate` +- `cpt-cf-oagw-algo-audit-emit` +- `cpt-cf-oagw-algo-metrics-observe` +- `cpt-cf-oagw-algo-metrics-render` + +**Constraints**: none from DESIGN §2.2; this is the DECOMPOSITION §1.3(3) placement deviation recorded in §1.5. + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: none — tests only + +## 6. Acceptance Criteria + +- [x] Every proxy request the proxy path serves carries a correlation identifier from the moment it enters the handler, and 100% of the audit records written for proxy requests carry one in their `request_id` field. +- [x] A request carrying `X-Request-Id` with a bounded, printable value is recorded with that value as its `request_id`, and a request carrying none is recorded with a generated UUID. +- [x] A caller-supplied correlation value containing a control character, or one beyond the bounded length, is discarded and replaced by a generated UUID, and the request is still correlated and still recorded. +- [x] The correlation identifier of a request answered by a gateway error appears in that answer's `application/problem+json` body as the `trace_id` extension field, attached by `cpt-cf-oagw-algo-error-mapping` and not by a second serialization path in this feature. +- [x] An answer the upstream produced passes through with its body unmodified and carries no `trace_id`, and no echo is synthesized for it. +- [x] The `CorrelationContext` is declared once by this feature and is carried as a member of `ProxyContext`, and `ProxyContext`, `ResolvedUpstream`, `ProxyResponse`, and `ErrorContext` are consumed from their owning features and not redeclared. +- [x] Exactly one JSON line is written to stdout for every proxy request the proxy path serves, and every line parses as one JSON object with no interleaved bytes from a concurrent request. +- [x] Every audit record carries only field names drawn from the fourteen DESIGN §4.3 tabulates — `timestamp`, `level`, `event`, `request_id`, `tenant_id`, `principal_id`, `host`, `path`, `method`, `status`, `duration_ms`, `request_size`, `response_size`, `error_type` — and no record carries a fifteenth field. +- [x] A field with no value for the event is omitted from the record and is never written as null or as an empty string, and a successful record omits `error_type`. +- [x] A successful request is recorded at INFO with `request_id`, `tenant_id`, `host`, `path`, `method`, `status`, `duration_ms`, `request_size`, and `response_size`. +- [x] A failed request is recorded with every field a success record carries plus `error_type`, at the level the mapping assigns, and carries no `error_message` field (§1.5). +- [x] A configuration change written through a management route of `cpt-cf-oagw-feature-control-plane-config` produces exactly one record, written when the handler completes its write, with an event name that carries the resource kind and the operation, the writer's `tenant_id` and `principal_id`, the management path and method, and the answered status. +- [x] The configuration-change record omits `host`, `duration_ms`, `request_size`, and `response_size`, and a write refused by validation, authorization, or conflict produces no configuration-change record at all. +- [x] The five logged categories DESIGN §4.3 names all produce records, and the `event` value of each is drawn from the closed set §1.5 records, with the circuit-breaker transition record written in addition to the one request record the exchange produces and the `from_state` and `to_state` of the transition it reports carried on the `oagw_circuit_breaker_transitions_total{host, from_state, to_state}` series §1.5 records for the same exchange; the set is the twelve literals §1.5 enumerates, and every record's `timestamp` is the instant its write was issued, read once per record. +- [x] The levels of the records follow the mapping of §1.5: INFO for a success, a configuration change, and a circuit-breaker transition whose destination state is not `open`, WARN for a rate-limit refusal, a breaker-open answer, retry guidance emitted, and a circuit-breaker transition whose destination state is `open`, ERROR for an upstream failure, a timeout, and an authentication failure, and no record is emitted at DEBUG with `logging.default.console_level` set to `info`. +- [x] No record contains a request body, a response body, a query parameter, or any header value other than the correlation header's, and the allowlist of §1.5 admits exactly one name. +- [x] No record, no metric label, and no error message contains an API key, a token, credential material, or a `cred://` reference value, including a request whose `Authorization` header carries a valid bearer token and whose route matches a `cred://`-referenced upstream. +- [x] `GET /oagw/v1/metrics` is registered exactly once, gear-relative, and no other method, path, or query parameter is registered for it; a request to the bare `/metrics` path is answered by no OAGW handler. +- [x] A scrape without a bearer token is answered 401, a scrape with a token lacking `gts.cf.core.oagw.metrics.v1~:read` is answered 403 with an `application/problem+json` body tagged `X-OAGW-Error-Source: gateway` and no exposition, and a scrape with `config/e2e-local.yaml`'s `e2e-token-tenant-a` is answered 200. +- [x] The exposition declares all twelve families of DESIGN §4.2 with their `# HELP` and `# TYPE` lines, and a family that has observed nothing is rendered with its type and help and no samples. +- [x] The twelve families and their label sets are exactly the ones DESIGN §4.2 enumerates, including `oagw_requests_total{host, http.request.method, http.route, http.response.status_code}`, `oagw_request_duration_seconds{host, http.route, phase}`, `oagw_requests_in_flight{host}`, `oagw_errors_total{host, http.route, error_type}`, `oagw_circuit_breaker_state{host}`, `oagw_rate_limit_exceeded_total{host, path}`, `oagw_circuit_breaker_transitions_total{host, from_state, to_state}`, `oagw_rate_limit_usage_ratio{host, path}`, `oagw_routing_target_host_used{upstream_id, endpoint_host}`, `oagw_routing_endpoint_selected{upstream_id, endpoint_host, selection_method}`, `oagw_upstream_available{host, endpoint}`, and `oagw_upstream_connections{host, state}`. +- [x] The histogram family is rendered as its `_bucket` series over the twelve buckets `[0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]` in seconds, plus its `_sum` and its `_count` series, and no bucket outside that set appears. +- [x] No metric label of any family carries a tenant value, on a request from any tenant and on a scrape by any caller. +- [x] `http.route` carries the matched route's normalized match pattern and never the raw request path, including for a request whose path suffix contains characters no route declares. +- [x] `http.request.method` is normalized to the standard verb for a request whose method is one of the five literals the shipped route schema declares and to `_OTHER` for a method outside them. +- [x] `http.response.status_code` carries the upstream's numeric status on a proxied answer and the gateway's own numeric status on a request the gateway answered without contacting the upstream, and status-class totals are computed at query time by regex on the numeric code and are not pre-aggregated into a label. +- [x] `phase` carries only `resolve`, `chain`, `upstream`, or `total`, and no per-plugin, per-route, or per-upstream phase value ever appears. +- [x] `error_type` carries only a catalogue slug of the variants `cpt-cf-oagw-feature-gear-foundation` provisions or the literal `upstream`, and a request the upstream answered with a failure status increments `oagw_errors_total` with that literal. +- [x] `oagw_circuit_breaker_state`, `oagw_circuit_breaker_transitions_total`, `oagw_rate_limit_exceeded_total`, `oagw_rate_limit_usage_ratio`, `oagw_routing_target_host_used`, and `oagw_routing_endpoint_selected` report state that `cpt-cf-oagw-feature-rate-limiting` and `cpt-cf-oagw-feature-data-plane-proxy` own, and no routine of this feature trips a breaker, counts a failure, admits a probe, closes a circuit, selects an endpoint, or refuses a request. +- [x] The `path` label of `oagw_rate_limit_exceeded_total` and `oagw_rate_limit_usage_ratio` carries the normalized route match pattern, the same value `http.route` carries, and the label set of both families stays bounded by the number of configured routes. +- [x] `oagw_requests_in_flight` is raised by `cpt-cf-oagw-algo-correlate` when a request is admitted and lowered by `cpt-cf-oagw-algo-metrics-observe` when its exchange ends, stays raised for the whole of a streamed transfer, and returns to its prior value after every completed exchange. +- [x] Success records on a high-volume route are emitted at the 1/100 ratio, the ratio is a build-time constant changed by no key of `OagwConfig` and by no upstream or route configuration, and no failed request, breaker transition, or configuration-change record is ever sampled. +- [x] A flood of failed authentication attempts produces at most the bounded number of records per interval, the surplus is dropped and not queued, and the bound is a build-time constant changed by no key of `OagwConfig`. +- [x] The audit stream is written to stdout and no record is written to any file target of the platform's `logging` section, and no retention, rotation, or ageing decision is made by this feature. +- [x] A scrape produces no audit record and increments no series, including `oagw_requests_total` and `oagw_requests_in_flight`, and a CORS preflight answered before the proxy flow is reached produces neither. +- [x] Every test for this feature lives under `gears/system/oagw/oagw/tests/`, passes there, and no test is added under `testing/e2e/gears/oagw/`. diff --git a/gears/system/oagw/docs/features/plugin-system.md b/gears/system/oagw/docs/features/plugin-system.md new file mode 100644 index 0000000..d158d64 --- /dev/null +++ b/gears/system/oagw/docs/features/plugin-system.md @@ -0,0 +1,852 @@ +# Feature: Plugin System + + + + +- [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 Feature-Local Deviations from Shared Baselines](#15-feature-local-deviations-from-shared-baselines) + - [1.6 Explicit Non-Applicability](#16-explicit-non-applicability) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Provision a Custom Plugin](#provision-a-custom-plugin) + - [Read a Plugin and Fetch Its Starlark Source](#read-a-plugin-and-fetch-its-starlark-source) + - [Delete an Unlinked Custom Plugin](#delete-an-unlinked-custom-plugin) + - [Bind Plugins to an Upstream or a Route](#bind-plugins-to-an-upstream-or-a-route) + - [Resolve Credentials for an OAuth2 Client-Credentials Auth Plugin](#resolve-credentials-for-an-oauth2-client-credentials-auth-plugin) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Plugin Contracts and Registry Resolution](#plugin-contracts-and-registry-resolution) + - [Plugin Chain Composition and Execution Order](#plugin-chain-composition-and-execution-order) + - [Plugin Reference Resolution Across the Store and the Registry](#plugin-reference-resolution-across-the-store-and-the-registry) + - [Binding Validation and Write](#binding-validation-and-write) + - [Immutability, In-Use Protection, and Garbage-Collection Eligibility](#immutability-in-use-protection-and-garbage-collection-eligibility) + - [Credential Reference Resolution](#credential-reference-resolution) + - [Token-Cache Lookup, Insert, and Eviction](#token-cache-lookup-insert-and-eviction) +- [4. States (CDSL)](#4-states-cdsl) + - [Plugin Row Lifecycle](#plugin-row-lifecycle) +- [5. Definitions of Done](#5-definitions-of-done) + - [Plugin Contracts and Separate Registries](#plugin-contracts-and-separate-registries) + - [Built-In and Catalog-Only Catalogue](#built-in-and-catalog-only-catalogue) + - [Plugin Management API and Permissions](#plugin-management-api-and-permissions) + - [Binding Model and Validation](#binding-model-and-validation) + - [Plugin and Plugin-Binding Persistence](#plugin-and-plugin-binding-persistence) + - [Credential Isolation](#credential-isolation) + - [OAuth2 Token Cache](#oauth2-token-cache) + - [Immutability, In-Use Protection, and Garbage Collection](#immutability-in-use-protection-and-garbage-collection) + - [Colocated Tests](#colocated-tests) +- [6. Acceptance Criteria](#6-acceptance-criteria) + + + + + +- [ ] `p1` - **ID**: `cpt-cf-oagw-featstatus-plugin-system-implemented` + + +- [ ] `p2` - `cpt-cf-oagw-feature-plugin-system` + +## 1. Feature Context + +### 1.1 Overview + +This feature supplies the extensibility model of the `oagw` gear: the `AuthPlugin`, `GuardPlugin`, and `TransformPlugin` contracts with their three separate registries, the built-in and catalog-only plugin catalogue, the five plugin management endpoints, the `plugin_ref`/`plugin_uuid` binding model, the plugin and plugin-binding tables, and the credential resolution that turns an opaque `cred://` reference into secret material without ever storing, returning, or logging it. + +### 1.2 Purpose + +DECOMPOSITION §2.4 places this feature on its own branch off the root, parallel to `cpt-cf-oagw-feature-control-plane-config`: it needs the plugin base-type identifiers, the domain contracts, and the types-registry provisioning that `cpt-cf-oagw-feature-gear-foundation` delivers, and it needs neither upstream nor route persistence. Everything downstream of the junction consumes it — `cpt-cf-oagw-feature-data-plane-proxy` executes the chains this feature composes and resolves the secret material this feature's routines produce, and `cpt-cf-oagw-feature-hierarchical-config` concatenates the binding sets this feature validates and writes. Without it there is no plugin anywhere in the gear: no auth plugin to inject a credential with, no guard to reject a request with, no transform to mutate a request or response with, and no way for an operator to ship a custom Starlark plugin at all. + +This feature delivers the DESIGN §3.2 Plugin System and Plugin Lifecycle Management subsections and the plugin share of the DESIGN §3.2 Permissions and Access Control subsection. Authentication injection is a p1 capability (`cpt-cf-oagw-fr-auth-injection`) and lives here as the auth plugin contract, the built-in auth catalogue, and the credential-resolution routine; its execution on a live request does not, and belongs to `cpt-cf-oagw-feature-data-plane-proxy`. + +Deliverables: + +- The `AuthPlugin`, `GuardPlugin`, and `TransformPlugin` contracts with one registry per contract, the deterministic execution order Auth, then Guards, then Transform on the request, then the upstream call, then Transform on the response or the error, and the upstream-before-route chain composition. +- The built-in catalogue: six resolvable plugin identifiers and six catalog-only identifiers registered in the types-registry only. +- The five plugin management endpoints of DECOMPOSITION §2.4, registered gear-relative under `/oagw/v1/plugins` with the `*_plugin.v1~` permission arms enforced by the same mechanism as the upstream and route endpoints. +- The `plugin_ref`/`plugin_uuid` binding model, its contiguous-from-0 chain positions, the matching rule between `plugin_uuid` and `plugin_ref`, and the resolution of `plugin_ref` across the persisted plugin store and the in-process named registry. +- Immutability after creation, in-use protection answering 409 `PluginInUse`, and garbage-collection eligibility for unlinked custom plugins. +- The plugin and plugin-binding tables, this feature's share of `cpt-cf-oagw-db-schema`, and the scalar auth-plugin identity columns that keep the in-use check off JSON scanning. +- Credential resolution through `cred://` references, and the internal token cache the two OAuth2 Client Credentials variants use. + +The feature is delivered in the three phases DECOMPOSITION §2.4 names: plugin contracts and registries, then the built-in catalogue and the management API, then bindings and lifecycle. Nothing in the phase order changes the contract of any endpoint. + +**Requirements**: + +- [ ] `p2` - `cpt-cf-oagw-fr-plugin-system` +- [ ] `p2` - `cpt-cf-oagw-fr-builtin-plugins` +- [ ] `p1` - `cpt-cf-oagw-fr-auth-injection` +- [ ] `p1` - `cpt-cf-oagw-nfr-credential-isolation` +- [ ] `p1` - `cpt-cf-oagw-contract-cred-store` +- [ ] `p1` - `cpt-cf-oagw-contract-types-registry` +- [ ] `p1` - `cpt-cf-oagw-interface-management-api` + +**Principles**: + +- `p1` - `cpt-cf-oagw-principle-cred-isolation` +- `p2` - `cpt-cf-oagw-principle-plugin-immutable` +- `p1` - `cpt-cf-oagw-adr-plugin-system` +- `p1` - `cpt-cf-oagw-adr-oauth2-client-credentials-auth-plugin` +- `p1` - `cpt-cf-oagw-adr-required-headers-guard-plugin` + +**Constraints**: + +- `p1` - `cpt-cf-oagw-constraint-toolkit-deploy` +- `p1` - `cpt-cf-oagw-constraint-no-direct-internet` + +**Design Components**: + +- `p1` - `cpt-cf-oagw-component-model` +- `p1` - `cpt-cf-oagw-design-layers` +- `p1` - `cpt-cf-oagw-interface-api` + +**Domain Model Entities**: + +- `Plugin` (the UUID-backed custom plugin row), the plugin binding rows of `oagw_upstream_plugin` and `oagw_route_plugin`, and the named-plugin registry entry +- `AuthContext`, `RequestContext`, `ResponseContext`, and `ErrorContext`, consumed from `cpt-cf-oagw-feature-gear-foundation` and not redeclared here (§1.5) +- `GuardDecision`, the allow-or-reject verdict a guard plugin returns +- The token-cache entry of the two OAuth2 Client Credentials variants, carrying the original cache key and the secret material + +**Data**: + +- `p1` - `cpt-cf-oagw-db-schema` + +`cpt-cf-oagw-interface-management-api` is the PRD §7.1 declaration and `cpt-cf-oagw-interface-api` is the DESIGN §3.3 contract section whose table carries the five plugin rows this feature implements beside the ten upstream and route rows that belong to `cpt-cf-oagw-feature-control-plane-config`: this feature restates that upstream contract in §2 and §5 and does not redesign a single endpoint. The `cpt-cf-oagw-db-schema` claim is shared — DECOMPOSITION §1.6 assigns this feature the plugin and plugin-binding tables and leaves the upstream, route, tag, and match tables to `cpt-cf-oagw-feature-control-plane-config`. + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-oagw-actor-platform-operator` | Creates custom plugins, binds plugins to the upstreams and routes it owns, and is the actor PRD §5.3 names for `cpt-cf-oagw-fr-builtin-plugins`; it consumes the built-in catalogue and receives the 400 answers of the catalog-only rejection path. | +| `cpt-cf-oagw-actor-tenant-admin` | Creates, reads, fetches the source of, and deletes its tenant's custom plugins, and binds plugins to its own upstreams and routes; it receives the 400, 403, 404, and 409 answers below. | +| `cpt-cf-oagw-actor-cred-store` | Resolves a `cred://` reference into secret material on the credential-resolution miss path, and answers with the secret or with a refusal the routine maps to `SecretNotFound` or `AuthenticationFailed`. | +| `cpt-cf-oagw-actor-types-registry` | Holds the twelve plugin instance identifiers of the built-in and catalog-only catalogue, registered once during the post-init phase this feature performs, and answers each with success or a typed failure. | + +The four actors reach this feature through the five plugin management endpoints, through the binding write that rides on the upstream and route write paths, and through the in-process `cred_store` and `types_registry` SDK calls. The difference between the two human actors is which tenant the bearer token resolves to, not which handler runs; PRD §5.3 names both as the actors of `cpt-cf-oagw-fr-plugin-system` and names the platform operator alone for `cpt-cf-oagw-fr-builtin-plugins`, and DECOMPOSITION §1.5 lists this feature against both human actors and both system actors. + +The other two actors do not participate: + +- `cpt-cf-oagw-actor-app-developer` issues no call this feature answers. Its endpoint is the proxy, delivered by `cpt-cf-oagw-feature-data-plane-proxy`; the plugin chain that request runs through is composed by this feature and executed by that one, and the developer observes the chain only through the response it produces. +- `cpt-cf-oagw-actor-upstream-service` is never contacted. No routine in this feature opens a socket, resolves a host, or dials an upstream; the credential injection that reaches an upstream request happens at proxy time, and the IdP exchange the OAuth2 plugin performs is part of that same proxy-time execution, not of anything this feature runs on its own behalf. + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) +- **Design**: [DESIGN.md](../DESIGN.md) +- **Dependencies**: `cpt-cf-oagw-feature-gear-foundation` — the plugin base-type identifiers this feature registers instances of, the `DomainError` catalogue every 400, 401, 403, 404, 409, and 500 answer below reuses, the `OagwConfig` ceilings the token cache is constructed with, the shared router mount point the five plugin paths are registered on, and the four plugin-execution context types this feature consumes and does not redeclare (DECOMPOSITION §3). + +Supporting sources this feature stays consistent with: + +- [ADR/0002-plugin-system.md](../ADR/0002-plugin-system.md) (`cpt-cf-oagw-adr-plugin-system`) — the three plugin types, the trait shape, the execution order, the built-in and catalog-only catalogue, and the immutability rule. The one place that ADR contradicts itself is recorded in §1.5. +- [ADR/0008-oauth2-client-credentials-auth-plugin.md](../ADR/0008-oauth2-client-credentials-auth-plugin.md) (`cpt-cf-oagw-adr-oauth2-client-credentials-auth-plugin`) — the two registered variants, the `fetch_token` choice over a background watcher, the cache key design, the `CachedToken` key verification, the TTL rule, and the known residual plaintext. +- [ADR/0009-required-headers-guard-plugin.md](../ADR/0009-required-headers-guard-plugin.md) (`cpt-cf-oagw-adr-required-headers-guard-plugin`) — the one resolvable guard identifier, its two configuration keys, the fail-open posture on absent or blank configuration, and the phase-specific rejection statuses. +- [ADR/0004-cors.md](../ADR/0004-cors.md) — the contrasting precedent behind the catalog-only `cors` guard identifier: CORS is a dedicated `cors` field on `Upstream` and `Route`, enforced by `cpt-cf-oagw-feature-cors`, and is not a `GuardPlugin` implementation. +- [schemas/upstream.v1.schema.json](../schemas/upstream.v1.schema.json) — the `auth` and `plugins` sub-configurations whose references this feature resolves and validates. It is a frozen input this run does not edit. +- [config/e2e-local.yaml](../../../../../config/e2e-local.yaml) — the graded configuration. Its `oagw.config` block sets no token-cache key, so both ceilings take their ADR 0008 defaults; its `credstore` gear selects its value-store backend plugin by vendor and its `static-credstore-plugin` provisions the test secrets keyed by tenant and owner that the credential-resolution routine resolves by `cred://` reference. + +**Run-level assumptions** — premises this feature relies on that come from the platform runtime rather than from PRD, DESIGN, the ADRs, or DECOMPOSITION: + +- Assumption: the `cred_store` SDK exposes an in-process resolve call that takes a `cred://` reference together with the calling tenant and subject and returns the secret material, per `cpt-cf-oagw-contract-cred-store` ("in-process Rust trait call via the `cred_store` SDK"). `config/e2e-local.yaml` provisions a `credstore` gear with a value-store backend plugin selected by vendor and a `static-credstore-plugin` that declares its secrets with a `tenant_id`, an `owner_id`, a `key`, and a `value`, which is consistent with such a call but states none. If the SDK is absent, or answers nothing for a reference, the credential-resolution routine returns the typed failure the caller maps to `SecretNotFound` — never an empty credential and never a guessed value. +- Assumption: the types-registry SDK accepts the twelve plugin instance identifiers during the post-init phase, after `cpt-cf-oagw-feature-gear-foundation` has registered the three plugin base types. The catalog-only identifiers are registrable there exactly like the backed ones, because DESIGN §3.1 and ADR 0002 both describe the reserved identifiers as "cataloged in the types-registry", and PRD §5.3 states of each that it is a "catalog identifier only" with no backing implementation. If the registry declines one, the post-init phase fails and the gear never reports readiness, exactly as the foundation's provisioning behaves. +- Assumption: the platform middleware resolves the calling tenant and subject from the SecurityContext and enforces the brace-notation permission families as distinct literal permissions, so `gts.cf.core.oagw.auth_plugin.v1~:{create;read;delete}` is three permissions and not one string. If the middleware treats the brace form as a single literal, authorization either always fails or always passes, and the fail direction **MUST** be the one that denies the operation. +- Assumption: the `oagw` gear receives a database handle. The plugin and plugin-binding tables cannot exist without one, and `config/e2e-local.yaml` declares a `database:` block for the `credstore` and `users-info` gears and none under the `oagw` gear. If the runtime provisions no handle for a gear that declares none, the plugin management endpoints answer the platform 500 problem shape and no custom plugin can be stored, while the six backed built-in identifiers remain resolvable from the registry. +- Assumption: the runtime offers the gear a periodic-job facility for the garbage-collection job DESIGN §3.2 Plugin Lifecycle Management describes — the job that marks unlinked plugins by setting `gc_eligible_at` and deletes the rows whose `gc_eligible_at` is in the past. PRD §13 leaves the garbage-collection policy an open question and DESIGN answers it as time-based after a TTL, but no supplied document states that the runtime schedules gear-level jobs. If it does not, the marking and the deletion cannot run, so this feature **MUST** keep `gc_eligible_at` authoritative and derive the explicit-delete decision from the reference scan alone, which never depends on the job having run; an unlinked plugin then persists until its owning tenant deletes it, and is never reported as garbage-collected when it was not. +- Assumption: the `read` permission check on the plugin list path is evaluated against the arm the request selects — the `plugin_type` a `$filter` on `type` names when one is supplied, and any one of the three `*_plugin.v1~:read` permissions otherwise. DESIGN §3.2 tabulates the three plugin permission arms separately and states no rule for a list that is not type-filtered. If the platform middleware cannot express an any-of check, the list **MUST** require all three `read` permissions rather than none, because the fail direction that denies the read is the safe one. + +### 1.5 Feature-Local Deviations from Shared Baselines + +| Deviation | Rationale | Review owner | Validation performed | +|-----------|-----------|--------------|----------------------| +| The five plugin paths are registered gear-relative at `/oagw/v1/plugins...` with no `/api` prefix. | DECOMPOSITION §1.3(1) corrects the `/api/oagw/v1/...` tabulation in PRD §7.1 and DESIGN §3.3: `/api` is an operator gateway prefix, not a path this gear serves. Every path in this document is the gear-relative form, and the five paths are the restatement of the upstream contract, not a new design. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| `basic` and `bearer` have no backing `AuthPlugin` implementation, so the `AuthPluginRegistry` registration sketch in ADR 0002 Plugin Loading, which inserts a `BasicAuthPlugin`, is not implemented. | ADR 0002's own Built-in Plugins section states that `cf.core.oagw.basic.v1` and `cf.core.oagw.bearer.v1` "are reserved GTS identifiers cataloged in the types-registry with no backing `AuthPlugin` implementation in `infra/plugin/`", and PRD §5.3 and DESIGN §3.1 state the same. The sketch is a code illustration inside the ADR that contradicts the ADR's normative statement one section earlier; this feature follows the normative statement. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| A catalog-only identifier used as a bindable `plugin_ref` or as the upstream `auth` sub-configuration's plugin-type member is answered 400 with the validation error for all three plugin families. | DESIGN §3.1 states the answer only for the auth family ("using either as `auth.plugin_type` fails with `unknown auth plugin`", that section's prose name for the member, recorded as a divergence below) and states the guard and transform cases as "cannot be bound" and "not resolvable" without naming an answer. One answer for one class of failure keeps the catalogue closed: the identifier is reserved in the types-registry and absent from the plugin registry, which is a property of the body, and a property of the body is what the 400 row answers. The problem `detail` distinguishes an identifier the catalogue reserves from one it does not know, so an operator can tell a reserved-but-unimplemented identifier from a typo. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| A duplicate plugin `name` within the calling tenant is answered 400 naming `name`, not a 409. | `oagw_plugin` is unique on `(tenant_id, name)` per DESIGN §3.6, and the duplicate is named in the body, but the catalogue has no variant for it: DESIGN §3.3 tabulates exactly one 409 row, `PluginInUse`, which is a plugin-lifecycle answer, and DECOMPOSITION §1.3(9) closed the catalogue at two management-conflict variants, `AliasConflict` and `MatchConflict`, both scoped to upstream and route writes. Inventing a third 409 variant is outside this feature's authority, and the precedent `cpt-cf-oagw-feature-control-plane-config` sets — a resource named in the body is a validation failure, not a disclosure about another tenant — is followed here. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The garbage-collection TTL is carried as a named constant of 30 days and adds no `OagwConfig` key. | DESIGN §3.2 Plugin System inventories the custom-plugin behaviour as GC for unlinked plugins after a configurable TTL, and DESIGN §3.2 Plugin Lifecycle Management gives that TTL its default of 30 days; no single sentence of that section reads as a quotation, so the two halves are cited separately. The `OagwConfig` surface DECOMPOSITION §2.1 declares closes at five keys — `proxy_timeout_secs`, `allow_http_upstream`, `ssrf_policy`, `token_cache_ttl_secs`, `token_cache_capacity` — and names no garbage-collection key, and `cpt-cf-oagw-feature-gear-foundation` owns that surface. Widening it here would give one configuration surface two owners, so the TTL is a constant with the sourced value and the configurability DESIGN mentions is not delivered. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| `last_used_at` is created on the plugin row and never written by this feature. | The DESIGN §3.1 Plugin class declares the column, and the only event that sets it is a use on a live request, which is `cpt-cf-oagw-feature-data-plane-proxy`'s execution obligation and is out of this feature's scope (DECOMPOSITION §2.4). Writing it from a management read would be a lie about use, and writing it from the proxy path would put a Control Plane write on the Data Plane hot path. Garbage-collection eligibility is therefore derived from the reference scan alone and never from `last_used_at`. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The five plugin management paths address persisted custom plugins only, and a named plugin's GTS identifier addressed through any of them answers 404. | DESIGN §3.1 states that named plugins are "not stored in `oagw_plugin` and not subject to GC", so the list returns custom rows only, and a single read, a source read, or a deletion of a named identifier has no row to address. The built-in catalogue is observable through the types-registry, where all twelve identifiers are registered, and not through this API. The 404 is the same indistinguishable answer the upstream and route paths give for a foreign identifier. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The plugin permission arms are enforced with the `{create;read;delete}` members only, and the `override` member DECOMPOSITION §2.2 writes into the brace family gates no plugin endpoint. | DECOMPOSITION §2.2 writes the family as `gts.cf.core.oagw.{upstream,route,*_plugin}.v1~:{create;override;read;delete}`, while DESIGN §3.2 grants the plugin arms `{create;read;delete}` only. There is no plugin endpoint an `override` permission could gate, because plugins are immutable after creation and DESIGN §3.3 states "Plugins are immutable (no PUT)". The narrowing is recorded on the management feature's side as well; this row records the same fact from the plugin side. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The Starlark `source_code` is stored verbatim and is not parsed, compiled, sandbox-checked, or executed at create time. | DECOMPOSITION §2.4 assigns this feature the exposure of the sandbox limits and their enforcement to execution time, which belongs to `cpt-cf-oagw-feature-data-plane-proxy` together with `cpt-cf-oagw-nfr-starlark-sandbox`. No supplied document states a create-time source gate, so adding one would invent a validation no caller is told about and no catalogue row declares. Create-time validation therefore covers the declared fields only: the type, the name, the configuration schema being an object, the declared phases being a subset of the phases the type supports, and the source being non-empty. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The binding validation and the binding-row write are delivered as routines this feature owns, invoked from the upstream and route write paths `cpt-cf-oagw-feature-control-plane-config` registers, and no separate binding endpoint exists. | DECOMPOSITION §2.4 assigns this feature the `plugin_ref`/`plugin_uuid` binding model and the plugin-binding tables, and lists only the five plugin paths as its API; `cpt-cf-oagw-feature-control-plane-config` records in its own §1.5 that it validates the `plugins` sub-object and writes no binding row. The two records meet here: the validation and the write are this feature's, and they run inside the single transaction the parent write already opens. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The four plugin-execution contexts are treated as `cpt-cf-oagw-feature-gear-foundation`'s shared vocabulary, although that feature's own entity list names only `ErrorContext` among the four. | DECOMPOSITION §2.4 lists `AuthContext`, `RequestContext`, `ResponseContext`, and `ErrorContext` with the parenthetical "consumed from `gear-foundation`", and its §2.1 entity list for the foundation names `ErrorContext` but none of the other three. DECOMPOSITION prevails, so the definition point for all four is the foundation and this feature redeclares none of them. The gap is recorded here rather than closed by declaring the three types in this feature, which would give one type two owners and would let the plugin contracts and the proxy path disagree about what a request context carries. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The plugin-execution contexts are four distinct types, not the single `RequestContext` the ADR 0002 trait sketch passes to `authenticate` and to `guard_request`. | ADR 0002's Plugin Traits sketch declares `authenticate(&self, ctx: &mut RequestContext)` and `guard_request(&self, ctx: &RequestContext)`, while ADR 0008 names `AuthContext` as the `authenticate` parameter and DECOMPOSITION §2.4 lists all four context types as this feature's entities. DECOMPOSITION prevails, and the four-type reading is the one that keeps a credential-injection context from carrying the response surface a response transform needs. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The stored `plugin_type` carries one of the three literals `auth`, `guard`, or `transform`, which selects the base type of the plugin's anonymous GTS identifier. | DESIGN §3.1 declares `plugin_type` as a `String` and gives the API identifier as `gts.cf.core.oagw.{type}_plugin.v1~{uuid}`, PRD §5.3 declares exactly three plugin types, and ADR 0002's Appendix A uses the literal `guard`. No supplied document states the full literal set, so it is recorded here: three values, each mapping to one of the three plugin base types `cpt-cf-oagw-feature-gear-foundation` provisioned. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| A guard rejection in the response phase is mapped to the `ProtocolError` variant of the foundation catalogue, and a guard rejection in the request phase to the `ValidationError` variant. | ADR 0009 states the phase-specific statuses (400 in the request phase, 502 in the response phase) and the `REQUIRED_HEADER_MISSING` error code, and states no `DomainError` variant. The two rows of DESIGN §3.3 that carry those statuses are `ValidationError` (400, non-retriable) and `ProtocolError` (502, non-retriable, "Protocol-level error"); the response-phase verdict is a statement that the upstream's response violates the configured contract rather than a passthrough of an upstream failure the guard did not evaluate, so the non-retriable 502 row is the one it maps to. Both rejections are gateway-sourced and carry `X-OAGW-Error-Source: gateway`. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| This feature's tests are colocated at `gears/system/oagw/oagw/tests/` instead of `testing/e2e/gears/oagw/`. | DECOMPOSITION §1.3(3) reserves `testing/e2e/gears/oagw/` for the acceptance suite; every unit and integration test this decomposition produces lives with the crate. This is the same deviation `cpt-cf-oagw-feature-gear-foundation`, `cpt-cf-oagw-feature-control-plane-config`, and `cpt-cf-oagw-feature-hierarchical-config` record in their own §1.5 tables, restated here because the tests it governs include this feature's. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The binding item this feature validates and writes is the object that carries `position`, `plugin_ref`, `plugin_uuid`, and `config`, and the parent's schema validation is applied with a feature-local override for `plugins.items`, which the shipped `upstream.v1` and `route.v1` schemas declare as an array of bare identifier strings. | DESIGN §3.1 Bindings stores `(position, plugin_ref, plugin_uuid, config)` in `oagw_upstream_plugin` and `oagw_route_plugin`, PRD §5.3 names `plugins.items[].plugin_ref` as the only guard identifier bindable through it, ADR 0009's Upstream Configuration Example carries the items as objects with a `plugin_ref` and a `config`, and DECOMPOSITION §2.4 states each binding carries its chain position, the plugin reference, the optional plugin UUID, and its plugin configuration; the shipped schemas admit none of those members, and neither names a `position` to order the array by. The `plugins` envelope and its `sharing` enum stay under the shipped schema and `cpt-cf-oagw-algo-request-validate`, and the item shape is validated by this feature's own `cpt-cf-oagw-algo-binding-validate`, which applies the parent's schema validation with a feature-local override for `plugins.items` — the same device by which `cpt-cf-oagw-feature-control-plane-config` records the route `priority` and `enabled` root extras its own shipped schema omits. The divergence is recorded here rather than closed by editing a frozen schema. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The wire member of the upstream `auth` sub-configuration that names the auth plugin is read and validated as `auth.type`, which the shipped `upstream.v1` schema declares, and not as `auth.plugin_type`. | DESIGN §3.1's prose calls the member `auth.plugin_type` when it states that using either reserved auth identifier as `auth.plugin_type` fails with `unknown auth plugin`, and PRD never uses that name; the shipped schema declares the member as `type` with the `gts-identifier` format and no `plugin_type` member at all. The wire form is what a caller submits and what this feature validates, so the catalog-only rejection of §1.5 and the credential-reference shape check of `cpt-cf-oagw-algo-binding-validate` both read `auth.type`, and the DESIGN §3.1 prose name is recorded here as the divergence rather than adopted as a second member. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| This feature writes the two `auth_plugin_ref` and `auth_plugin_uuid` columns of the upstream row, although it creates and writes no row of the upstream, route, tag, or match tables. | DESIGN §3.1 declares the two scalar columns on the upstream row and DESIGN §3.6's Track Plugin Usage operation reads them, while DECOMPOSITION §1.6 assigns the upstream table itself to `cpt-cf-oagw-feature-control-plane-config` and its §3 declares the two features independent of each other. The columns are written inside the parent's transaction and are reached through that parent write path's invocation of this feature's `cpt-cf-oagw-algo-binding-validate` and `cpt-cf-oagw-algo-plugin-inuse-gc` routines, which makes them the one coupled point between the two features; the routines and their tests remain deliverable against the foundation contracts alone, and every other column of the upstream row stays the parent feature's. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | + +### 1.6 Explicit Non-Applicability + +The areas below apply to the gear as a whole but not to this feature. Each is stated here so the omission is a recorded decision rather than a silent gap. + +- **Plugin execution on a live request**: the contracts, the registries, the composed chain, and the credential material a chain needs are this feature's outputs; invoking them on a request is `cpt-cf-oagw-feature-data-plane-proxy`'s obligation (DECOMPOSITION §2.4 and §2.5). The OAuth2 token cache of §3 is a plugin-internal concern whose lookup and insert run inside `authenticate()`, and every step that touches it is reached through that invocation rather than through a request path this feature owns. +- **Circuit breaking**: core gateway policy, not a plugin — PRD §5.3 states it after `cpt-cf-oagw-fr-plugin-system` and ADR 0002 closes with the same sentence. No `CircuitBreakerPlugin` identifier exists in the catalogue, none is registered, and none is bindable. +- **gRPC proxying**: no gRPC proxy code path is currently implemented or reachable (DESIGN §3.1, DECOMPOSITION §1.3(4)). No plugin contract in this feature is protocol-specific, and none is extended for gRPC. +- **Plugin versioning and lifecycle management as a separate concern**: PRD §4.2 and DESIGN §4.5 both put it out of scope, and DECOMPOSITION §2.4 repeats the exclusion. What this feature delivers is the whole lifecycle the baseline states: immutability after creation, in-use protection, and garbage-collection eligibility. A new version of a plugin is a new plugin row, and rebinding is the caller's operation. +- **Retry on an upstream 401**: ADR 0008 defers it as an area of active design, because the `AuthPlugin` trait returns no signal a Data Plane could use to decide whether a retry with fresh credentials is meaningful. This feature implements no retry orchestration, adds no return metadata to the trait, and does not refresh a credential on a rejected request. +- **Authorization Code Grant**: out of scope by ADR 0008, which requires user consent, redirect callbacks, and refresh-token storage across data centres. Only the two Client Credentials variants are registered. +- **Events**: no event is published or consumed here. The audit log, the metrics, and the configuration-change reporting that describe a plugin create or delete belong to `cpt-cf-oagw-feature-observability`, which DECOMPOSITION §3 makes dependent on features this one does not wait for. +- **Health and diagnostics**: this feature contributes no readiness or health signal of its own. Gear readiness belongs to `cpt-cf-oagw-feature-gear-foundation`'s provisioning state machine, and this feature's contribution to it is the post-init registration of the twelve plugin instance identifiers, whose per-entry failure fails readiness exactly as any other catalogue entry's failure does. Every failure this feature produces surfaces as a protocol answer through the foundation's mapping — a 400, a 401, a 403, a 404, a 409, or the platform 500 problem shape for a storage failure — and never as a status endpoint, a readiness gate, or a diagnostic surface of its own. +- **Rollout and rollback**: the gear is one configuration item and one release unit (DECOMPOSITION §1.4), so this feature ships no rollout of its own and no independent rollback path. Deleting a plugin row is an operator decision answered 409 while the plugin is in use, so no rollback of a bound plugin is ever needed. +- **Versioning**: every GTS identifier this feature registers or resolves is fixed at `.v1` by DESIGN §3.1, and the breaking-change policy of `cpt-cf-oagw-interface-management-api` (major version bump from v1 to v2) is a PRD-level declaration about the interface, not a mechanism this feature implements. No version negotiation, aliasing, or migration surface exists here, and the `plugin_ref`/`plugin_uuid` binding model is not a versioning mechanism. +- **Localization and accessibility**: the `title` and `detail` of every problem body this feature answers are English protocol strings produced by the foundation's error mapping, and there is no locale negotiation, no translated surface, and no actor-facing rendered UI to make accessible. +- **Compliance and privacy**: the only sensitive material this feature handles is credential material, and `cpt-cf-oagw-nfr-credential-isolation` governs all of it. No personal data and no regulated data are persisted — the plugin row holds a type, a name, a description, a configuration schema, and Starlark source, and the binding rows hold a position, a reference, an optional UUID, and a configuration object. Secret material is held in the zeroizing secret type ADR 0008 names, is never persisted, never returned in a management response, and never written to a log or to a problem `detail`; the two known residual plaintexts that ADR 0008 records — the constructed bearer header value and the in-flight token inside the one-shot fetch — are that ADR's recorded residual and are not eliminated here. +- **Performance**: no latency or throughput target is set here, because `cpt-cf-oagw-nfr-low-latency` is allocated to `cpt-cf-oagw-feature-data-plane-proxy`, which owns the request hot path. The two performance ceilings this feature's output feeds are the token-cache ones: `token_cache_ttl_secs` defaults to 300 and `token_cache_capacity` to 10000 per ADR 0008's gear-level table, both keys are carried and range-checked by the foundation's `cpt-cf-oagw-algo-config-load-validate`, and both are consumed at execution time by the OAuth2 plugin through `AuthPluginRegistry::with_builtins` — this feature constructs the cache with them and evaluates neither at management time. The chain composition of §3 allocates one composed chain per resolution and reads only the binding rows already resolved, so its cost is linear in the number of bindings the effective configuration carries, and no composed chain is cached here: the Control Plane L1 cache belongs to `cpt-cf-oagw-feature-control-plane-config` and the Data Plane L1 cache to `cpt-cf-oagw-feature-data-plane-proxy`. +- **Credential material in problem detail**: no problem `detail` this feature answers ever echoes request body content, a `cred://` reference value, or a resolved secret. A `detail` names the failing property, the addressed plugin, the colliding reference, or the rejected identifier, and nothing copied from the body. The reference itself is not secret material, but it is never echoed either, because a reference that appears in a log is a pointer an operator can follow and a detail that appears in a third-party ticket is not the place for it. + +## 2. Actor Flows (CDSL) + +The flows below follow the management operation order DESIGN §3.5 states — authenticate, validate the body, write, respond — and the path-based routing of `cpt-cf-oagw-adr-plugin-system`'s host ADR set, which sends `/oagw/v1/plugins/*` to the Control Plane (DESIGN §3.2 Request Routing). Path parameters carry anonymous GTS identifiers of the form `gts.cf.core.oagw.{type}_plugin.v1~{uuid}`, and the five paths are the gear-relative restatement of the DESIGN §3.3 contract. The binding flow does not register a path of its own: it rides on the upstream and route write paths `cpt-cf-oagw-feature-control-plane-config` registers, with the validation and the write performed by the routines this feature delivers (§1.5). + +**Use cases**: none. Every `cpt-cf-oagw-usecase-*` identifier in PRD §8 is exercised by another feature — `cpt-cf-oagw-usecase-configure-upstream` and `cpt-cf-oagw-usecase-configure-route` by `cpt-cf-oagw-feature-control-plane-config`, `cpt-cf-oagw-usecase-proxy-request` and `cpt-cf-oagw-usecase-sse-streaming` by `cpt-cf-oagw-feature-data-plane-proxy`, and `cpt-cf-oagw-usecase-rate-limit-exceeded` by `cpt-cf-oagw-feature-rate-limiting` — and PRD §8 declares no plugin use case for this feature to claim. The binding flow below contributes the plugin-reference branch of the two configure use cases without restating either. + +### Provision a Custom Plugin + +- [x] `p1` - **ID**: `cpt-cf-oagw-flow-plugin-create` + +**Actor**: `cpt-cf-oagw-actor-tenant-admin` + +**Success Scenarios**: + +- A plugin of type `guard` with a name unique within the calling tenant, an object configuration schema, and non-empty Starlark source is created; the row is persisted with a server-generated identifier and the response carries the identifier as `gts.cf.core.oagw.guard_plugin.v1~{uuid}`. +- A plugin whose declared phases are a subset of the phases its type supports is created, including a plugin that declares exactly one phase. +- A plugin created without an explicit description or configuration schema is created with those members left absent, both being optional. +- A platform operator performs the same operation for its own tenant and receives the same answer; the two human actors reach this flow through the same endpoint. + +**Error Scenarios**: + +- The body fails validation — no `plugin_type`, a `plugin_type` outside the three literals of §1.5, no `name`, an empty `source_code`, a `config_schema` that is not an object, or a declared phase the plugin type does not support — and is answered 400 naming every failing property. +- A catalog-only identifier is submitted where a backed plugin type is required: 400, with a `detail` that distinguishes a reserved identifier from an unknown one (§1.5). +- Another plugin of the calling tenant already holds the `name`: 400 naming `name` (§1.5). +- The bearer token is missing or invalid: 401; it lacks the `create` permission of the plugin type's arm: 403. +- The storage layer fails: the platform 500 problem shape, and no row is written. + +**Steps**: +1. [x] - `p1` - Actor issues the create request carrying the plugin definition: `plugin_type`, `name`, and any of `description`, `config_schema`, `source_code`, and the declared phases - `inst-pl-create-issue` +2. [x] - `p1` - API: POST /oagw/v1/plugins — the platform middleware authenticates the bearer token and the handler enforces the `create` permission of the arm the body's `plugin_type` selects, before any validation runs - `inst-pl-create-authz` +3. [x] - `p1` - `cpt-cf-oagw-algo-plugin-contract-registry` confirms the requested `plugin_type` names one of the three plugin base types and that the declared phases are a subset of the phases that type supports - `inst-pl-create-type` +4. [x] - `p1` - **IF** the `plugin_type` does not name a backed plugin base type, or the source is empty, the configuration schema is not an object, or a declared phase is outside the type's supported set - `inst-pl-create-validate-if` + 1. [x] - `p1` - **RETURN** 400 naming every failing property; no row is written, and the source is never parsed or executed at create time (§1.5) - `inst-pl-create-validate-return` +5. [x] - `p1` - **ELSE** - `inst-pl-create-validate-else` + 1. [x] - `p1` - Continue with the validated definition and the verbatim source - `inst-pl-create-validate-continue` +6. [x] - `p1` - DB: SELECT the plugin rows of the calling tenant whose `name` equals the submitted one, through the secure ORM with the tenant equality in the same predicate as every other key - `inst-pl-create-dup` +7. [x] - `p1` - **IF** a row matched - `inst-pl-create-dup-if` + 1. [x] - `p1` - **RETURN** 400 naming `name` as taken within the calling tenant; no 409 variant exists for this conflict (§1.5) - `inst-pl-create-dup-return` +8. [x] - `p1` - **ELSE** - `inst-pl-create-dup-else` + 1. [x] - `p1` - DB: INSERT into `oagw_plugin` the row with the server-generated `id`, the calling tenant, the `plugin_type`, the `name`, the configuration schema, the verbatim source, and `gc_eligible_at` unset, in one transaction; no binding row is written and `last_used_at` stays unset (§1.5) - `inst-pl-create-insert` +9. [x] - `p1` - **RETURN** 201 with the created representation and the `id` as `gts.cf.core.oagw.{type}_plugin.v1~{uuid}` - `inst-pl-create-return` + +### Read a Plugin and Fetch Its Starlark Source + +- [x] `p1` - **ID**: `cpt-cf-oagw-flow-plugin-read-source` + +**Actor**: `cpt-cf-oagw-actor-tenant-admin` + +This flow covers the three read paths — `GET /oagw/v1/plugins`, `GET /oagw/v1/plugins/{id}`, and `GET /oagw/v1/plugins/{id}/source` — and it writes nothing: no cache is flushed, no row is touched, and `last_used_at` is not updated, because a read is not a use (§1.5). + +**Success Scenarios**: + +- A plugin addressed by identifier and owned by the calling tenant is returned with its representation and its `id` as the anonymous GTS identifier of its type. +- The source path returns the stored Starlark source of the addressed plugin and nothing else: no configuration value, no credential reference, and no secret material. +- A list request returns the calling tenant's custom plugin rows, bounded by the OData parameters DESIGN §3.3 tabulates for the plugin list (`$filter`, `$select`, `$top`, `$skip`), and the list holds no named plugin because none is persisted (§1.5). + +**Error Scenarios**: + +- The identifier in the path names a nonexistent plugin, one owned by another tenant including an ancestor, or a named plugin that has no row: 404, with the three causes indistinguishable. +- A query parameter is malformed or an expression names a field the plugin row does not expose: 400. +- The bearer token is missing or invalid: 401; it lacks the `read` permission of the arm the request selects: 403 (§1.4). + +**Steps**: +1. [x] - `p1` - Actor issues a `GET` against one of the three read paths, with an `{id}` path parameter for a single read or for a source read, and OData query parameters for a list - `inst-pl-read-issue` +2. [x] - `p1` - API: GET /oagw/v1/plugins, GET /oagw/v1/plugins/{id}, or GET /oagw/v1/plugins/{id}/source — the platform middleware authenticates the bearer token and the handler enforces the `read` permission of the arm the request selects before any query is built - `inst-pl-read-authz` +3. [x] - `p1` - DB: SELECT the plugin row by `id` and calling tenant, or the tenant-scoped page for a list, through the secure ORM with the tenant equality in the same predicate as every other key and with no raw SQL (`cpt-cf-oagw-principle-tenant-scope`) - `inst-pl-read-scope` +4. [x] - `p1` - **IF** no row matched, because the identifier does not exist, because it belongs to another tenant including an ancestor, or because it names a named plugin that has no row - `inst-pl-read-404-if` + 1. [x] - `p1` - **RETURN** 404; the three causes are deliberately indistinguishable so the endpoint discloses nothing about other tenants' plugins or about the registry's contents - `inst-pl-read-404-return` +5. [x] - `p1` - **ELSE** - `inst-pl-read-404-else` + 1. [x] - `p1` - Assemble the representation, or the stored source alone for the source path, with the configuration schema and the source carried as stored and never re-rendered - `inst-pl-read-assemble` +6. [x] - `p1` - **RETURN** 200 with the representation, the bounded page, or the source; the source path returns the Starlark source of the addressed plugin and no other member of the row - `inst-pl-read-return` + +### Delete an Unlinked Custom Plugin + +- [x] `p1` - **ID**: `cpt-cf-oagw-flow-plugin-delete` + +**Actor**: `cpt-cf-oagw-actor-tenant-admin` + +**Success Scenarios**: + +- A custom plugin of the calling tenant that no upstream or route references is deleted with `204 No Content` and no body, and the row disappears in one transaction. +- Deleting a plugin never touches a binding row: no foreign key runs from the binding tables to `oagw_plugin` (DESIGN §3.1), so the deletion cannot orphan or rewrite a binding. +- A plugin that is still linked is not deleted, and nothing about it changes. + +**Error Scenarios**: + +- The identifier names a nonexistent plugin, one owned by another tenant including an ancestor, or a named plugin: 404, with the three causes indistinguishable. +- The plugin is referenced by a binding row in `oagw_upstream_plugin` or `oagw_route_plugin`, or by an upstream's `auth_plugin_uuid` column: 409 with the `PluginInUse` variant, and the answer names no referencing resource beyond the fact of the reference. +- The bearer token is missing or invalid: 401; it lacks the `delete` permission of the plugin type's arm: 403. +- The storage layer fails: the platform 500 problem shape, and no row is removed. + +**Steps**: +1. [x] - `p1` - Actor issues `DELETE /oagw/v1/plugins/{id}` with no body - `inst-pl-del-issue` +2. [x] - `p1` - API: DELETE /oagw/v1/plugins/{id} — the platform middleware authenticates the bearer token and the handler enforces the `delete` permission of the plugin type's arm before any query is built - `inst-pl-del-authz` +3. [x] - `p1` - DB: SELECT the plugin row by `id` and calling tenant through the secure ORM - `inst-pl-del-scope` +4. [x] - `p1` - **IF** no row matched - `inst-pl-del-404-if` + 1. [x] - `p1` - **RETURN** 404, indistinguishable between a missing identifier, a foreign one, and a named plugin - `inst-pl-del-404-return` +5. [x] - `p1` - **ELSE** - `inst-pl-del-404-else` + 1. [x] - `p1` - `cpt-cf-oagw-algo-plugin-inuse-gc` scans the reference set for the resolved row - `inst-pl-del-inuse` +6. [x] - `p1` - **IF** any binding row in `oagw_upstream_plugin` or `oagw_route_plugin` carries the plugin, or any upstream row carries it in `auth_plugin_uuid` - `inst-pl-del-inuse-if` + 1. [x] - `p1` - **RETURN** 409 with the `PluginInUse` variant (`gts.cf.core.errors.err.v1~cf.oagw.plugin.in_use.v1`); the row is left untouched and its `gc_eligible_at` is left as the reference scan found it - `inst-pl-del-inuse-return` +7. [x] - `p1` - **ELSE** - `inst-pl-del-inuse-else` + 1. [x] - `p1` - DB: DELETE the `oagw_plugin` row by identifier in one transaction; the plugin becomes `Deleted` in `cpt-cf-oagw-state-plugin-lifecycle` and no other row changes - `inst-pl-del-write` +8. [x] - `p1` - **RETURN** `204 No Content` with no body; a deletion has no representation to return - `inst-pl-del-return` + +### Bind Plugins to an Upstream or a Route + +- [x] `p1` - **ID**: `cpt-cf-oagw-flow-bind-plugins` + +**Actor**: `cpt-cf-oagw-actor-tenant-admin` + +A platform operator reaches the same operation for its own tenant and receives the same answers; the two human actors issue the same parent request, exactly as the create, read, and delete flows record. The management operation is the create or replacement of an upstream or a route, which `cpt-cf-oagw-feature-control-plane-config` registers and validates against the shipped schemas. This flow is the plugin-reference branch of that operation: the `plugins` sub-object the body carries, and for an upstream the `auth` sub-configuration's plugin identity, are resolved, validated, and written by the routines this feature delivers, inside the single transaction the parent write already opens (§1.5). No path is registered here. + +**Success Scenarios**: + +- An upstream whose `plugins` items carry contiguous positions from 0, backed `plugin_ref` values of the guard or transform type, and no `plugin_uuid` is created, and the binding rows are written in the parent's transaction. +- A binding that names a custom plugin by `plugin_ref` and by the matching `plugin_uuid` is accepted, and the stored row carries the reference always and the UUID only because the plugin is UUID-backed. +- An upstream that binds one auth plugin through the `auth` sub-configuration is created, and the identity lands in the scalar `auth_plugin_ref` and `auth_plugin_uuid` columns rather than in a binding row. +- A route whose `plugins` items are validated the same way is created, with the route's own positions starting at 0 independently of any upstream positions. +- A replacement that drops a binding item is accepted, and the row it unlinks becomes garbage-collection-eligible when it was the last reference. + +**Error Scenarios**: + +- A `plugin_ref` names a catalog-only identifier, an identifier no registry or store resolves, or an identifier whose plugin type does not match the family the binding slot carries: 400, with the `detail` distinguishing a reserved identifier from an unknown one (§1.5). +- The submitted positions are not the contiguous set from 0, or two items carry the same position: 400. +- A binding carries a `plugin_uuid` that does not match the UUID embedded in its `plugin_ref`, or a `plugin_uuid` on a named plugin: 400. +- An upstream body binds a second auth plugin, or a route body carries an `auth` sub-configuration at all: 400. +- The parent write fails for a reason that feature answers — 400 for a schema failure, 404 for a foreign parent, 409 for an alias or match conflict — and no binding row is written, because the parent's transaction is the only writer. +- The bearer token is missing or invalid: 401; it lacks the management permission of the parent resource: 403. + +**Steps**: +1. [x] - `p1` - Actor issues the create or replacement carrying the `plugins` sub-object with its ordered items, and for an upstream the `auth` sub-configuration naming one auth plugin - `inst-bind-issue` +2. [x] - `p1` - API: POST /oagw/v1/upstreams, PUT /oagw/v1/upstreams/{id}, POST /oagw/v1/routes, or PUT /oagw/v1/routes/{id} — the platform middleware authenticates the bearer token and the handler enforces the parent resource's management permission; the path is `cpt-cf-oagw-feature-control-plane-config`'s registration (`cpt-cf-oagw-interface-management-api`) - `inst-bind-authz` +3. [x] - `p1` - `cpt-cf-oagw-algo-request-validate` validates the body against the shipped schema for the parent resource, which confirms the `plugins` sub-object's envelope and the `sharing` enum on it; the shape of the items that envelope carries is **NOT** confirmed by that schema, which declares them as bare identifier strings, and is validated instead by this feature's `cpt-cf-oagw-algo-binding-validate` (§1.5) - `inst-bind-parent-validate` +4. [x] - `p1` - `cpt-cf-oagw-algo-binding-validate` resolves every `plugin_ref` through `cpt-cf-oagw-algo-plugin-ref-resolve`, validates the positions, the `plugin_uuid` match, and the type match, and validates the auth plugin identity and its credential references for `cred://` shape - `inst-bind-validate` +5. [x] - `p1` - **IF** any resolution or validation fails - `inst-bind-fail-if` + 1. [x] - `p1` - **RETURN** 400 naming the failing item, its position, and the reason; no binding row and no parent row is written, and the failure is indistinguishable from any other validation failure of the parent write - `inst-bind-fail-return` +6. [x] - `p1` - **ELSE** - `inst-bind-fail-else` + 1. [x] - `p1` - DB: INSERT or REPLACE the `oagw_upstream_plugin` or `oagw_route_plugin` rows for the parent, and for an upstream set the scalar `auth_plugin_ref` and `auth_plugin_uuid` columns on the upstream row, all inside the parent's single transaction - `inst-bind-write` +7. [x] - `p1` - `cpt-cf-oagw-algo-plugin-inuse-gc` recomputes the reference set of every plugin whose linkage the write changed, so a plugin that lost its last reference becomes garbage-collection-eligible and one that gained one loses that eligibility - `inst-bind-gc` +8. [x] - `p1` - **RETURN** the parent write's own answer — 201 for a create, the replaced representation for a replacement — carrying no plugin source and no credential material - `inst-bind-return` + +### Resolve Credentials for an OAuth2 Client-Credentials Auth Plugin + +- [x] `p1` - **ID**: `cpt-cf-oagw-flow-oauth2-token-cache` + +**Actor**: `cpt-cf-oagw-actor-cred-store` + +This feature delivers the auth plugin contract, the two registered OAuth2 variants, the cache, and the credential-resolution routine. The invocation of `authenticate()` on a live request is `cpt-cf-oagw-feature-data-plane-proxy`'s execution obligation and is out of this feature's scope (§1.6), so the steps below are the credential-resolution contract the plugin exposes, reached through that invocation and not through any endpoint or request path of this feature. + +**Success Scenarios**: + +- First use for a given tenant, subject, auth method, and configuration: the cache holds no entry, the two credential references are resolved through the credential store, the IdP exchange returns a bearer value and its `expires_in`, the entry is stored with the TTL of §3, and the bearer value is injected into the request context's headers. +- Every subsequent use inside the TTL: the cache returns an entry whose stored key equals the lookup key, and the bearer value is injected with no credential-store call and no IdP call. +- A cache hit whose stored key does not equal the lookup key is treated as a miss, so a hash collision can never hand one tenant's token to another. +- A token the IdP issues with an `expires_in` at or below the 30-second safety margin is injected but not stored, so no entry is served that is already at expiry. + +**Error Scenarios**: + +- A credential reference fails the `cred://` shape check: the plugin returns its typed failure before any credential-store call, and no reference value is echoed. +- The credential store cannot resolve a reference, or declines it for the calling tenant: the routine returns the typed failure the caller maps to `SecretNotFound` or `AuthenticationFailed`, and nothing is cached. +- The IdP exchange fails: the plugin returns its typed failure and nothing is cached, so the next request for the same key retries the IdP. +- The cache is at its `token_cache_capacity` ceiling: the eviction policy of the cache decides which entry is dropped, and the new entry is stored; the ceiling never causes a request to fail. + +**Steps**: +1. [x] - `p1` - The Data Plane invokes the plugin's `authenticate()` with the `AuthContext` it built; this step names the boundary, not an obligation of this feature - `inst-tc-invoke` +2. [x] - `p1` - `cpt-cf-oagw-algo-credential-resolution` validates every credential reference in the plugin configuration for `cred://` shape, and the plugin returns its typed failure on the first one that fails - `inst-tc-shape` +3. [x] - `p1` - The plugin builds the cache key from the four identity components of §3 — the subject tenant, the subject, the auth method tag of the variant, and the deterministic hash of the plugin configuration - `inst-tc-key` +4. [x] - `p1` - **IF** the cache returns an entry whose stored key equals the lookup key - `inst-tc-hit-if` + 1. [x] - `p1` - Inject the entry's bearer value into the request context's authorization header and return success; no credential-store call and no IdP call is made, and the entry's secret material never leaves the cache in any form but the injected header - `inst-tc-hit` +5. [x] - `p1` - **ELSE** - `inst-tc-miss-else` + 1. [x] - `p1` - `cpt-cf-oagw-algo-credential-resolution` resolves the client identifier reference and the client secret reference through the credential store, in that order, and returns the typed failure if either is unresolvable or declined - `inst-tc-resolve` + 2. [x] - `p1` - **TRY** - `inst-tc-fetch-try` + 1. [x] - `p1` - Perform the one-shot token exchange for the variant's client auth method, which returns the bearer value and its `expires_in` and spawns no background task (ADR 0008) - `inst-tc-fetch` + 3. [x] - `p1` - **CATCH** the exchange failing - `inst-tc-fetch-catch` + 1. [x] - `p1` - **RETURN** the plugin's typed failure without caching anything, so the next request for the same key retries the IdP - `inst-tc-fetch-catch-handle` + 4. [x] - `p1` - Compute the entry TTL as the minimum of the configured ceiling and the reported lifetime less the 30-second safety margin - `inst-tc-ttl` + 5. [x] - `p1` - **IF** the reported lifetime is above the safety margin - `inst-tc-store-if` + 1. [x] - `p1` - Store the entry keyed by the full key and carrying that key alongside the secret material, so a later hit can be verified against the key it was stored under - `inst-tc-store` +6. [x] - `p1` - **RETURN** success with the bearer value injected, or the plugin's typed failure with nothing cached - `inst-tc-return` + +## 3. Processes / Business Logic (CDSL) + +The routines below are called by the flows in §2, by the upstream and route write paths `cpt-cf-oagw-feature-control-plane-config` registers, and by each other in the order the flows state. Only `cpt-cf-oagw-algo-credential-resolution` and `cpt-cf-oagw-algo-token-cache` leave the process, and they do so through the in-process `cred_store` SDK call and the IdP exchange the OAuth2 plugin performs at proxy time; nothing here opens a connection to an upstream service. Every failure any of them returns is a `DomainError` from the foundation catalogue, except the storage failure, which has no catalogue row and is answered with the platform's RFC 9457 500 problem shape carrying `X-OAGW-Error-Source: gateway`, logged with the correlation identifier, and failed without partial writes. + +### Plugin Contracts and Registry Resolution + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-plugin-contract-registry` + +**Input**: a plugin GTS identifier or a `plugin_type` literal, the phase being resolved, and the three registries. + +**Output**: the registered implementation for that identifier and phase, or the reason it is not resolvable. + +The three contracts, their registries, and the phases each exposes, per ADR 0002: + +| Contract | Registry | Phases | Cardinality per parent | +|----------|----------|--------|------------------------| +| `AuthPlugin` | `AuthPluginRegistry` | credential injection before guards | one per upstream, never on a route | +| `GuardPlugin` | `GuardPluginRegistry` | `guard_request` before the upstream call, `guard_response` after it | many per upstream and per route | +| `TransformPlugin` | `TransformPluginRegistry` | `on_request`, `on_response`, `on_error`, each declared by the plugin | many per upstream and per route | + +The sandbox limits the contracts expose — no network I/O, no file I/O, no imports, a per-invocation timeout of at most 100 ms, and at most 10 MB of memory per invocation, from `cpt-cf-oagw-nfr-starlark-sandbox` — are carried as part of the contract surface this feature publishes. Their enforcement is execution-time work and belongs to `cpt-cf-oagw-feature-data-plane-proxy`; this feature exposes the limits and enforces none of them (§1.6). + +**Steps**: +1. [x] - `p1` - Parse the plugin GTS identifier into its base type and its instance part, the substring after the `~` separator - `inst-reg-parse` +2. [x] - `p1` - Map the base type to its registry, so an auth identifier is never looked up in the guard or transform registry - `inst-reg-map` +3. [x] - `p1` - **IF** the identifier names a catalog-only plugin of the catalogue table under `cpt-cf-oagw-dod-builtin-catalogue` - `inst-reg-catalog-if` + 1. [x] - `p1` - **RETURN** not resolvable, with the distinction between a reserved identifier and an unknown one carried in the reason - `inst-reg-catalog-return` +4. [x] - `p1` - **ELSE** - `inst-reg-else` + 1. [x] - `p1` - Look the identifier up in its own registry, which holds the six backed built-in implementations registered at initialization and no others - `inst-reg-lookup` +5. [x] - `p1` - **IF** the registry holds no entry for the identifier - `inst-reg-empty-if` + 1. [x] - `p1` - **RETURN** not resolvable - `inst-reg-empty-return` +6. [x] - `p1` - **ELSE** - `inst-reg-empty-else` + 1. [x] - `p1` - **RETURN** the entry together with the phases it declares, so the caller can skip a phase the implementation does not declare - `inst-reg-return` + +### Plugin Chain Composition and Execution Order + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-chain-compose` + +**Input**: the effective plugin binding set for the upstream layer and for the route layer, in stored `position` order, plus the auth plugin identity of the upstream. + +**Output**: one composed chain per phase, ordered deterministically. + +```mermaid +flowchart TD + A["Binding sets in stored position order: the upstream layer, then the route layer"] --> B["Compose: upstream positions first, route positions after"] + B --> C{"Every composed binding resolves to an implementation?"} + C -->|no| X["Report the unresolved reference; the caller answers it through the PluginNotFound variant of the foundation catalogue"] + C -->|yes| D["1. Auth — the upstream's single auth plugin, resolved from the scalar identity columns"] + D --> E["2. Guards on the request"] + E --> F["3. Transforms on the request"] + F --> G["4. The upstream call"] + G -->|response| H["5. Guards then transforms on the response"] + G -->|error| I["Transform on the error, then the failure answer"] +``` + +The order DESIGN §3.2 Plugin System states, and the composition that produces it: + +- The phase order is Auth, then Guards, then Transform on the request, then the upstream call, then Transform on the response, and Transform on the error when the call fails. +- Within a phase, upstream plugins execute before route plugins, so `[U1, U2] + [R1, R2]` composes to `[U1, U2, R1, R2]`. +- Within a layer, the stored `position` decides the order, and the positions are contiguous from 0 by the validation of `cpt-cf-oagw-algo-binding-validate`. +- Positions are scoped to one parent row and one layer. The cross-layer concatenation of an ancestor's and a descendant's binding set is the merge `cpt-cf-oagw-feature-hierarchical-config` performs, and this feature composes only the binding sets it is given, never re-deriving an inherited one. + +**Steps**: +1. [x] - `p1` - Resolve the upstream's single auth plugin through `cpt-cf-oagw-algo-plugin-ref-resolve` from the scalar identity columns; an upstream with none resolves to the no-op behaviour, and a route contributes no auth phase at all - `inst-compose-auth` +2. [x] - `p1` - Order the upstream layer's guard and transform bindings by `position`, then the route layer's, and concatenate them in that order - `inst-compose-order` +3. [x] - `p1` - **FOR EACH** composed binding, in the composed order - `inst-compose-loop` + 1. [x] - `p1` - Resolve its implementation through `cpt-cf-oagw-algo-plugin-ref-resolve` and record the phases that implementation declares - `inst-compose-resolve` +4. [x] - `p1` - **FOR EACH** phase in {guards on request, transforms on request, guards on response, transforms on response, transforms on error} - `inst-compose-phase-loop` + 1. [x] - `p1` - Emit the sub-chain of composed bindings whose implementation declares that phase, preserving the composed order within it - `inst-compose-phase` +5. [x] - `p1` - **IF** a composed binding resolves to no implementation, because its plugin row was deleted after the binding was written or its identifier is no longer registered - `inst-compose-missing-if` + 1. [x] - `p1` - Report the unresolved reference to the caller, which answers it through the `PluginNotFound` variant of the foundation catalogue; the composition never silently drops a binding it was given - `inst-compose-missing` +6. [x] - `p1` - **RETURN** the per-phase sub-chains with the auth plugin identity attached - `inst-compose-return` + +### Plugin Reference Resolution Across the Store and the Registry + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-plugin-ref-resolve` + +**Input**: a `plugin_ref` value, the binding's `plugin_uuid` when the caller carries one, and the layer the binding belongs to. + +**Output**: the resolved plugin — a persisted custom row or a named registry entry — or the reason it is not resolvable. + +The algorithm DESIGN §3.1 Plugin Identification Model states, applied in its order: + +**Steps**: +1. [x] - `p1` - Parse the GTS identifier to extract the instance part after the `~` separator - `inst-ref-parse` +2. [x] - `p1` - **IF** the instance part parses as a UUID - `inst-ref-uuid-if` + 1. [x] - `p1` - DB: SELECT the `oagw_plugin` row by that identifier through the secure ORM, scoped to the tenant the binding's parent row belongs to - `inst-ref-store` + 2. [x] - `p1` - **IF** no row matched, or the row's `plugin_type` does not match the base type the identifier's prefix names - `inst-ref-store-fail-if` + 1. [x] - `p1` - **RETURN** not resolvable, naming whether the identifier was absent or of the wrong type - `inst-ref-store-fail-return` +3. [x] - `p1` - **ELSE** - `inst-ref-named-else` + 1. [x] - `p1` - Resolve the identifier through `cpt-cf-oagw-algo-plugin-contract-registry`, whose lookup fails for a catalog-only identifier and for an unknown one alike - `inst-ref-named` + 2. [x] - `p1` - **IF** the resolution fails - `inst-ref-named-fail-if` + 1. [x] - `p1` - **RETURN** not resolvable, with the reserved-versus-unknown distinction of §1.5 carried in the reason - `inst-ref-named-fail-return` +4. [x] - `p1` - **IF** the caller carries a `plugin_uuid` - `inst-ref-uuidcheck-if` + 1. [x] - `p1` - **IF** the resolved plugin is UUID-backed and its identifier differs from the carried `plugin_uuid`, or the resolved plugin is a named one and a `plugin_uuid` was carried at all - `inst-ref-uuidcheck-fail-if` + 1. [x] - `p1` - **RETURN** not resolvable; the application validates that `plugin_uuid` matches `plugin_ref` when present (DESIGN §3.1) - `inst-ref-uuidcheck-fail-return` +5. [x] - `p1` - **RETURN** the resolved plugin, carrying whether it is UUID-backed so the caller stores the UUID only when it is - `inst-ref-return` + +A binding is persisted with its `plugin_ref` always and its `plugin_uuid` only when the resolved plugin is UUID-backed, which leaves every named-plugin binding row with a null `plugin_uuid` (DESIGN §3.1). + +### Binding Validation and Write + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-binding-validate` + +**Input**: the validated parent body, the `plugins` sub-object with its ordered items, the auth plugin identity for an upstream, and the calling tenant. + +**Output**: the binding write set and the auth plugin column values, or the validation error that fails the parent write. + +The checks this routine performs, and the source of each: + +| Check | Rule | Source | +|-------|------|--------| +| Contiguous positions | the submitted `position` values are exactly the integers from 0 to one less than the item count, in the submitted order, with no duplicate and no gap | DESIGN §3.6 key invariants | +| Reference resolution | every `plugin_ref` resolves through `cpt-cf-oagw-algo-plugin-ref-resolve` | DESIGN §3.1 Resolution Algorithm | +| Catalog-only rejection | an identifier the catalogue reserves and no registry resolves is rejected rather than bound | DECOMPOSITION §2.4, §1.5 | +| Type match | an auth slot carries an auth identifier, a guard slot a guard identifier, and a transform slot a transform identifier | PRD §5.3, DESIGN §3.1 | +| UUID and reference match | a carried `plugin_uuid` equals the UUID embedded in the same item's `plugin_ref`, and no named item carries a `plugin_uuid` at all | DESIGN §3.1, §3.6 | +| Single auth plugin | at most one auth plugin per upstream, and none on a route | DESIGN §3.1, §3.2 | +| Credential reference shape | every credential reference in the auth plugin configuration and in the `auth` sub-configuration matches the `cred://` shape, and none is resolved here | `cpt-cf-oagw-nfr-credential-isolation`, DESIGN §3.2 Secret Access Control | +| Custom plugin tenancy | a `plugin_ref` that resolves to a custom row resolves only within the parent row's tenant | `cpt-cf-oagw-nfr-multi-tenancy`, DESIGN §3.3 Tenant Scoping | + +**Steps**: +1. [x] - `p1` - Read the submitted items in the order the body carries them and confirm the `position` values form the contiguous set from 0; the submitted order is the stored order - `inst-bindv-positions` +2. [x] - `p1` - **FOR EACH** item, in `position` order - `inst-bindv-loop` + 1. [x] - `p1` - Resolve its `plugin_ref` through `cpt-cf-oagw-algo-plugin-ref-resolve` and check the resolved type against the family the slot carries - `inst-bindv-resolve` + 2. [x] - `p1` - **IF** the resolution failed, the type does not match, or the item carries a `plugin_uuid` that does not match its reference - `inst-bindv-item-fail-if` + 1. [x] - `p1` - Collect the failure with the item's position and the reason - `inst-bindv-item-fail` +3. [x] - `p1` - **IF** the parent is an upstream - `inst-bindv-upstream-if` + 1. [x] - `p1` - Resolve the `auth` sub-configuration's plugin identity and confirm at most one is present; a route body that carries an `auth` sub-configuration is a schema failure the parent validation already answered - `inst-bindv-auth` + 2. [x] - `p1` - Validate every credential reference the auth plugin configuration carries for `cred://` shape, and resolve none of them - `inst-bindv-credshape` +4. [x] - `p1` - **IF** any check failed - `inst-bindv-fail-if` + 1. [x] - `p1` - **RETURN** one validation error naming every failing item with its position and reason, so a caller is not made to retry once per defect - `inst-bindv-fail-return` +5. [x] - `p1` - **ELSE** - `inst-bindv-fail-else` + 1. [x] - `p1` - Build the write set as the full replacement of the parent's binding rows, carrying the reference on every row and the UUID only on a UUID-backed one, and the auth plugin column values for an upstream - `inst-bindv-write-set` +6. [x] - `p1` - **RETURN** the write set and the column values, for the parent's single-transaction write - `inst-bindv-return` + +A replacement that omits the `plugins` sub-object clears the parent's binding rows, which is the full-replacement rule DESIGN §3.3 states for the parent write and which `cpt-cf-oagw-algo-put-replace-diff` of that feature applies. An ancestor's `enforce` items are never written to the descendant's rows, because the effective chain is composed at resolution time from the merge that feature performs. + +### Immutability, In-Use Protection, and Garbage-Collection Eligibility + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-plugin-inuse-gc` + +**Input**: a `Plugin` row identifier, the binding rows of the two binding tables, the upstream rows' `auth_plugin_uuid` values, and whether the caller is deleting or writing a binding. + +**Output**: the in-use verdict, the garbage-collection eligibility of the row, and the reference set that produced both. + +**Steps**: +1. [x] - `p1` - Scan `oagw_upstream_plugin` and `oagw_route_plugin` for rows whose `plugin_uuid` equals the identifier, and the upstream rows whose `auth_plugin_uuid` equals it; the scalar column is what keeps this check off JSON scanning (DESIGN §3.1) - `inst-inuse-scan` +2. [x] - `p1` - **IF** the reference set is non-empty and the caller is deleting - `inst-inuse-delete-if` + 1. [x] - `p1` - **RETURN** in use, which the caller answers 409 with the `PluginInUse` variant - `inst-inuse-delete-return` +3. [x] - `p1` - **ELSE** - `inst-inuse-delete-else` + 1. [x] - `p1` - Continue with the reference set as the input to the eligibility decision - `inst-inuse-continue` +4. [x] - `p1` - **IF** the caller is writing a binding set for a parent row and the write removes this plugin's last reference - `inst-inuse-unlink-if` + 1. [x] - `p1` - Mark the row garbage-collection-eligible by setting `gc_eligible_at`, and leave the row in place - `inst-inuse-unlink` +5. [x] - `p1` - **ELSE IF** the write adds a reference to a row whose `gc_eligible_at` is set - `inst-inuse-relink-if` + 1. [x] - `p1` - Clear `gc_eligible_at`, so a plugin that is rebound before the TTL elapses never disappears under a live binding - `inst-inuse-relink` +6. [x] - `p1` - **IF** the row is a named plugin, which has no row in `oagw_plugin` at all - `inst-inuse-named-if` + 1. [x] - `p1` - **RETURN** not applicable; named plugins are never stored, never garbage-collected, and never deleteable (DESIGN §3.1, §3.2) - `inst-inuse-named-return` +7. [x] - `p1` - **RETURN** the in-use verdict, the eligibility state, and the reference set - `inst-inuse-return` + +Immutability is not a step in this routine because it is not a transition: no plugin endpoint accepts a replacement, there is no PUT on any of the five paths (DESIGN §3.3), and `cpt-cf-oagw-principle-plugin-immutable` is therefore enforced by the absence of an operation rather than by a check inside one. A new version of a plugin is a new row, and rebinding the references to it is the caller's operation. + +The garbage-collection job this routine's marking feeds is the periodic job of §1.4: it marks unlinked rows by setting `gc_eligible_at`, deletes the rows whose `gc_eligible_at` is in the past, and leaves everything else alone. Its marking is the same reference scan that drives transition 1 of `cpt-cf-oagw-state-plugin-lifecycle`, so a row whose reference set the job finds empty is marked on that run whether it lost its last reference to a binding write or never gained one at all, and a custom plugin that is created and never bound is marked at the first run of the job after its creation. The 30-day TTL is the constant of §1.5. The job never deletes a row whose reference set is non-empty at the moment it runs, so a plugin rebound between the marking and the deletion is never removed. + +### Credential Reference Resolution + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-credential-resolution` + +**Input**: a credential reference string, the calling tenant and subject resolved from the SecurityContext, and the resolved `cred_store` SDK client. + +**Output**: the secret material, or the typed failure the caller maps to `SecretNotFound` or `AuthenticationFailed`. + +**Steps**: +1. [x] - `p1` - Validate the reference for `cred://` shape only: the `cred://` scheme, a non-empty remainder, no surrounding whitespace, and no fragment; a reference that fails the shape check fails here before any credential-store call - `inst-cred-shape` +2. [x] - `p1` - **TRY** - `inst-cred-try` + 1. [x] - `p1` - Call the credential store's in-process resolve with the reference and the calling tenant and subject, so the store can apply its own sharing policy, including the ancestor sharing DESIGN §3.2 Secret Access Control describes - `inst-cred-call` +3. [x] - `p1` - **CATCH** the SDK being unreachable or failing - `inst-cred-catch` + 1. [x] - `p1` - **RETURN** the typed internal failure; no credential material is returned, nothing is cached, and no reference value is echoed - `inst-cred-catch-handle` +4. [x] - `p1` - **IF** the store answers no material for the reference - `inst-cred-missing-if` + 1. [x] - `p1` - **RETURN** the typed failure the caller maps to `SecretNotFound` (500, `gts.cf.core.errors.err.v1~cf.oagw.secret.not_found.v1`) - `inst-cred-missing-return` +5. [x] - `p1` - **ELSE IF** the store declines the reference for the calling tenant or subject - `inst-cred-declined-if` + 1. [x] - `p1` - **RETURN** the typed failure the caller maps to `AuthenticationFailed` (401, `gts.cf.core.errors.err.v1~cf.oagw.auth.failed.v1`), which is the answer DESIGN §3.2 states for an inaccessible secret - `inst-cred-declined-return` +6. [x] - `p1` - **ELSE** - `inst-cred-ok-else` + 1. [x] - `p1` - **RETURN** the material wrapped in the zeroizing secret type ADR 0008 names, so eviction zeroes it rather than leaking it to a deallocated buffer - `inst-cred-ok` +7. [x] - `p1` - **RETURN** the material or the typed failure - `inst-cred-return` + +This routine is the only thing in the gear that turns a `cred://` reference into material (`cpt-cf-oagw-principle-cred-isolation`). It is never called at management time: a create, a replacement, a read, or a delete resolves no reference, because resolving one at management time would put a credential-store dependency on a write path that has no use for the material and would let a management answer depend on a secret's availability. The reference is carried opaque through the store and the binding tables, and the material exists only inside the plugin that requested it and for the duration of the request that needed it. + +### Token-Cache Lookup, Insert, and Eviction + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-token-cache` + +**Input**: the `AuthContext` the Data Plane built, the variant's client auth method, the plugin configuration, and the `TokenCacheConfig` the registry was constructed with. + +**Output**: the cached bearer value, or a miss that sends the caller through the fetch path of `cpt-cf-oagw-flow-oauth2-token-cache`. + +The cache key's four components, each present because its absence would break an isolation boundary (ADR 0008 Cache Key Design): + +| Component | Isolates | +|-----------|----------| +| the subject tenant | one tenant's cached token is never served to another tenant | +| the subject | one subject's cached token is never served to another subject of the same tenant, which is what the credential store's `private` sharing mode requires | +| the auth method tag | the `Form` and `Basic` variants sharing one configuration never collide with each other | +| the deterministic hash of the plugin configuration | two upstreams whose configurations differ in any key, including the scopes, get different entries | + +**Steps**: +1. [x] - `p1` - Build the key by concatenating the four components with a separator that cannot appear in any of them, and hash the plugin configuration over its keys in sorted order so the hash is deterministic across processes and restarts - `inst-cache-key` +2. [x] - `p1` - **TRY** - `inst-cache-get-try` + 1. [x] - `p1` - Read the cache at that key; the cache is the in-memory cache ADR 0008 names, sized to `token_cache_capacity` and bounded by `token_cache_ttl_secs` - `inst-cache-get` +3. [x] - `p1` - **CATCH** the cache being unavailable - `inst-cache-catch` + 1. [x] - `p1` - Treat it as a miss and continue; a cache failure is never a request failure while the IdP is reachable, and never a reason to skip the isolation check - `inst-cache-catch-handle` +4. [x] - `p1` - **IF** an entry was returned and its stored key equals the lookup key - `inst-cache-verify-if` + 1. [x] - `p1` - **RETURN** the entry's bearer value, which the caller injects; the stored key is the defence against the hash-collision risk ADR 0008 records, and a mismatch is a miss and never another tenant's token - `inst-cache-verify` +5. [x] - `p1` - **ELSE** - `inst-cache-miss-else` + 1. [x] - `p1` - **RETURN** a miss, with no material read and no reference resolved - `inst-cache-miss` +6. [x] - `p1` - **IF** the caller is inserting, the fetch succeeded, and the reported lifetime is above the 30-second safety margin - `inst-cache-put-if` + 1. [x] - `p1` - Store the entry with the TTL of the minimum of the configured ceiling and the reported lifetime less the margin, carrying the key alongside the material so step 4 can verify it - `inst-cache-put` +7. [x] - `p1` - **ELSE** - `inst-cache-noput-else` + 1. [x] - `p1` - Store nothing; a failed fetch is never cached, and a token at or inside the safety margin is injected once and never cached - `inst-cache-noput` +8. [x] - `p1` - **RETURN** the cached value or the miss - `inst-cache-return` + +The ceilings and their source: `token_cache_ttl_secs` defaults to 300 and `token_cache_capacity` to 10000, both declared in ADR 0008's gear-level configuration table, both carried and range-checked by the foundation's `cpt-cf-oagw-algo-config-load-validate`, and both threaded to the plugin constructors through `AuthPluginRegistry::with_builtins`. The TTL ceiling is kept short because there is no cache-invalidation mechanism: a revoked or rotated token stays served until its entry expires, which is the staleness window ADR 0008 accepts. Eviction is the cache's own policy at the capacity ceiling; this feature sets the ceiling and does not choose the victim. No background task is created for the cache — the one-shot exchange returns and ends, which is why ADR 0008 chose `fetch_token` over the long-lived token handle that would have spawned a watcher per cache miss. + +## 4. States (CDSL) + +### Plugin Row Lifecycle + +- [x] `p2` - **ID**: `cpt-cf-oagw-state-plugin-lifecycle` + +**States**: `Linked`, `Unlinked`, `Deleted` + +**Initial State**: `Linked` + +The machine is justified by the baseline rather than invented for it: DESIGN §3.2 Plugin Lifecycle Management states that a periodic job marks plugins eligible by setting `gc_eligible_at` when they become unlinked and deletes the rows whose `gc_eligible_at` is in the past, and the DESIGN §3.1 Plugin class declares `gc_eligible_at` as a stored column. A stored column whose value the system sets, clears, and acts on is a lifecycle, and two states plus the terminal one are the smallest machine that describes it. There is no `Created` state separate from `Linked`: a newly created row has no reference yet, and DESIGN's own wording makes eligibility a function of being unlinked rather than of being referenced, so a row is born `Linked` in the sense that it is not yet eligible and becomes `Unlinked` at the first reference scan that finds it with no reference — the scan that follows a binding write which unlinked it, or, for a row that was created and never bound, the first scan the periodic job runs after its creation. Immutability is not a state — it is the absence of an update operation, as §3 records. + +**Transitions**: +1. [x] - `p1` - **FROM** `Linked` **TO** `Unlinked` **WHEN** the reference scan of `cpt-cf-oagw-algo-plugin-inuse-gc` finds no binding row in either binding table and no upstream `auth_plugin_uuid` carrying the row — whether that scan is the transactional one the routine runs after a binding write that removed the last reference, or the periodic job's own scan of a row that never gained one - `inst-state-unlink` +2. [x] - `p1` - **FROM** `Unlinked` **TO** `Linked` **WHEN** a binding write adds a reference to the row, which clears `gc_eligible_at` and restores the row to full use - `inst-state-relink` +3. [x] - `p1` - **FROM** `Unlinked` **TO** `Deleted` **WHEN** the garbage-collection TTL elapses and the periodic job deletes the row, or when the owning tenant deletes it explicitly through `cpt-cf-oagw-flow-plugin-delete`, which the in-use scan permits exactly because no reference exists - `inst-state-gc` +4. [x] - `p1` - **FROM** `Linked` **TO** `Deleted` is refused, and the row stays `Linked`, **WHEN** the owning tenant attempts the deletion while any reference exists: this is the 409 `PluginInUse` answer of `cpt-cf-oagw-flow-plugin-delete`, and no path reaches `Deleted` from `Linked` - `inst-state-inuse-guard` +5. [x] - `p1` - `Deleted` is terminal: the row is gone, no binding row referenced it, and no transition returns it - `inst-state-terminal` + +The machine is per row and per tenant. It is not the lifecycle of a named plugin, which has no row, no `gc_eligible_at`, and no deletion, and it is not the lifecycle of a plugin version, which this feature does not model: a new version is a new row in `Linked`, and the old row follows this machine on its own. Because the state is stored rather than computed, transitions 1 and 2 are writes inside the transaction of the binding change that caused them, so a binding write and the eligibility it produces commit together or not at all. + +## 5. Definitions of Done + +### Plugin Contracts and Separate Registries + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-plugin-contracts-registries` + +The system **MUST** declare the `AuthPlugin`, `GuardPlugin`, and `TransformPlugin` contracts in the domain layer with one registry per contract — `AuthPluginRegistry`, `GuardPluginRegistry`, and `TransformPluginRegistry` — and **MUST** keep the three registries separate, so an auth identifier is never looked up in the guard or transform registry and a guard identifier never in the transform one. The `GuardPlugin` contract **MUST** expose both `guard_request` and `guard_response`, the `TransformPlugin` contract all three of `transform_request`, `transform_response`, and `transform_error`, and the `AuthPlugin` contract the single credential-injection phase, with the `AuthContext`, `RequestContext`, `ResponseContext`, and `ErrorContext` parameters consumed from `cpt-cf-oagw-feature-gear-foundation` and not redeclared (§1.5). The contract surface **MUST** expose the sandbox limits of `cpt-cf-oagw-nfr-starlark-sandbox` — no network I/O, no file I/O, no imports, at most 100 ms per invocation, and at most 10 MB of memory per invocation — and **MUST NOT** enforce any of them, which is execution-time work that belongs to `cpt-cf-oagw-feature-data-plane-proxy` (`cpt-cf-oagw-adr-plugin-system`, `cpt-cf-oagw-design-layers`). + +**Implements**: + +- `cpt-cf-oagw-algo-plugin-contract-registry` +- `cpt-cf-oagw-algo-chain-compose` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy`, `cpt-cf-oagw-constraint-no-direct-internet` + +**Touches**: + +- API: none — the contracts are domain types with no endpoint of their own +- DB: none — the contracts are not persisted; the rows are claimed by `cpt-cf-oagw-dod-plugin-persistence` +- DB Table: none +- Entities: `AuthPlugin`, `GuardPlugin`, `TransformPlugin`, `AuthContext`, `RequestContext`, `ResponseContext`, `ErrorContext`, `GuardDecision` + +### Built-In and Catalog-Only Catalogue + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-builtin-catalogue` + +The system **MUST** register the six backed plugin identifiers in their own registries at initialization — the four auth identifiers `gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.noop.v1`, `...apikey.v1`, `...oauth2_client_cred.v1`, and `...oauth2_client_cred_basic.v1`, the one guard identifier `gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1`, and the one transform identifier `gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1` — and **MUST** register all twelve plugin identifiers of PRD §5.3 in the types-registry during the post-init phase, which adds the six catalog-only identifiers `...auth_plugin.v1~cf.core.oagw.basic.v1`, `...auth_plugin.v1~cf.core.oagw.bearer.v1`, `...guard_plugin.v1~cf.core.oagw.timeout.v1`, `...guard_plugin.v1~cf.core.oagw.cors.v1`, `...transform_plugin.v1~cf.core.oagw.logging.v1`, and `...transform_plugin.v1~cf.core.oagw.metrics.v1` (`cpt-cf-oagw-contract-types-registry`, `cpt-cf-oagw-fr-builtin-plugins`). The six catalog-only identifiers **MUST** be resolvable in the types-registry and **MUST NOT** be resolvable in any plugin registry, and a `plugin_ref` or an `auth.type` that names one **MUST** be rejected with 400 rather than bound (§1.5). `basic` and `bearer` **MUST** have no backing `AuthPlugin` implementation, including the one the ADR 0002 Plugin Loading sketch shows (§1.5); `timeout` and `cors` **MUST** remain core Data Plane behaviour rather than guard implementations (`cpt-cf-oagw-adr-required-headers-guard-plugin`); and `logging` and `metrics` **MUST** remain core Data Plane instrumentation rather than transform implementations (`cpt-cf-oagw-adr-plugin-system`, DESIGN §3.1). + +**Implements**: + +- `cpt-cf-oagw-algo-plugin-contract-registry` +- `cpt-cf-oagw-algo-plugin-ref-resolve` +- `cpt-cf-oagw-flow-oauth2-token-cache` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy`, `cpt-cf-oagw-constraint-no-direct-internet` + +**Touches**: + +- API: none — the catalogue is registered at initialization and in the post-init phase, not through an endpoint +- DB: none +- DB Table: none +- Entities: the twelve plugin instance identifiers of PRD §5.3, the six backed registry entries, and the `TokenCacheConfig` the two OAuth2 entries are constructed with + +### Plugin Management API and Permissions + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-plugin-management-api` + +The system **MUST** register exactly the five plugin endpoints of DECOMPOSITION §2.4 — `POST /oagw/v1/plugins`, `GET /oagw/v1/plugins`, `GET /oagw/v1/plugins/{id}`, `GET /oagw/v1/plugins/{id}/source`, and `DELETE /oagw/v1/plugins/{id}` — on the gear-relative router mount point the foundation created, with `{id}` accepted as `gts.cf.core.oagw.{type}_plugin.v1~{uuid}`, and **MUST** register no plugin replacement endpoint, because plugins are immutable after creation and DESIGN §3.3 states "Plugins are immutable (no PUT)". Every endpoint **MUST** require bearer-token authentication through `toolkit-auth` and **MUST** enforce the `{create;read;delete}` members of the arm the operation addresses — `gts.cf.core.oagw.auth_plugin.v1~`, `gts.cf.core.oagw.guard_plugin.v1~`, or `gts.cf.core.oagw.transform_plugin.v1~` — by the same mechanism as the upstream and route endpoints, with 401 for a missing or invalid token and 403 for a token without the required permission, in both cases before any validation or database access. The `source` endpoint **MUST** return the stored Starlark source of the addressed plugin and nothing else from the row, and a successful deletion **MUST** be answered `204 No Content` with no body. The list endpoint **MUST** support `$filter`, `$select`, `$top`, and `$skip` as DESIGN §3.3 tabulates them for the plugin list (`cpt-cf-oagw-interface-management-api`, `cpt-cf-oagw-interface-api`). + +**Implements**: + +- `cpt-cf-oagw-flow-plugin-create` +- `cpt-cf-oagw-flow-plugin-read-source` +- `cpt-cf-oagw-flow-plugin-delete` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: `POST /oagw/v1/plugins`, `GET /oagw/v1/plugins`, `GET /oagw/v1/plugins/{id}`, `GET /oagw/v1/plugins/{id}/source`, `DELETE /oagw/v1/plugins/{id}` +- DB: none — registration only; the tables are claimed by `cpt-cf-oagw-dod-plugin-persistence` +- DB Table: none +- Entities: none — the domain types were declared by `cpt-cf-oagw-feature-gear-foundation` + +### Binding Model and Validation + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-binding-model` + +The system **MUST** implement the `plugin_ref`/`plugin_uuid` binding model DESIGN §3.1 declares and DECOMPOSITION §2.4 restates — the binding row is the object form that carries a chain `position`, a `plugin_ref`, an optional `plugin_uuid`, and a plugin configuration, which the shipped parent schemas do not themselves declare (§1.5): every binding row carries its chain `position`, its `plugin_ref`, its optional `plugin_uuid`, and its plugin configuration; the submitted positions **MUST** be the contiguous set from 0 in the submitted order, with no duplicate and no gap; a carried `plugin_uuid` **MUST** equal the UUID embedded in the same item's `plugin_ref`, and no named-plugin item **MUST** carry a `plugin_uuid` at all; and the resolution of a `plugin_ref` **MUST** follow the DESIGN §3.1 algorithm, preferring the persisted store for a UUID instance and the in-process registry for a named one, with the resolved plugin's base type required to match the identifier's prefix. An auth plugin **MUST** be bound through the upstream's scalar `auth_plugin_ref` and `auth_plugin_uuid` columns rather than through a binding row, at most one auth plugin **MUST** be bound to an upstream, and no route **MUST** carry an auth plugin. A catalog-only identifier and an identifier no store or registry resolves **MUST** each be rejected with 400 before any row is written, and the whole binding write **MUST** land in the same transaction as the parent upstream or route write so a failed parent leaves no binding behind. Every credential reference the binding carries **MUST** be validated for `cred://` shape and **MUST NOT** be resolved (`cpt-cf-oagw-fr-plugin-system`). + +**Implements**: + +- `cpt-cf-oagw-algo-binding-validate` +- `cpt-cf-oagw-algo-plugin-ref-resolve` +- `cpt-cf-oagw-flow-bind-plugins` +- `cpt-cf-oagw-algo-chain-compose` + +**Constraints**: `cpt-cf-oagw-constraint-multi-sql`, `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: `POST /oagw/v1/upstreams`, `PUT /oagw/v1/upstreams/{id}`, `POST /oagw/v1/routes`, `PUT /oagw/v1/routes/{id}` — the parent paths `cpt-cf-oagw-feature-control-plane-config` registers, referenced by `cpt-cf-oagw-interface-management-api` +- DB: `cpt-cf-oagw-db-schema` — the binding rows and the auth plugin identity columns +- DB Table: `oagw_upstream_plugin`, `oagw_route_plugin`, `oagw_upstream` +- Entities: `Plugin`, the plugin binding rows, the named-plugin registry entry + +### Plugin and Plugin-Binding Persistence + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-plugin-persistence` + +The system **MUST** persist plugins in the table set DECOMPOSITION §1.6 assigns to this feature: `oagw_plugin` keyed on `id` and unique on `(tenant_id, name)`, carrying the `plugin_type`, the `name`, the configuration schema, the verbatim Starlark source, the `last_used_at` column the DESIGN §3.1 class declares, and the `gc_eligible_at` column the lifecycle sets; and `oagw_upstream_plugin` with `oagw_route_plugin` keyed on `(parent_id, position)` and carrying the reference, the optional UUID, and the plugin configuration. There **MUST** be no foreign key from either binding table to `oagw_plugin`, because named plugins have no rows (DESIGN §3.1), and a plugin deletion **MUST** therefore remove no binding row. The upstream's auth plugin identity **MUST** be stored in the scalar `auth_plugin_ref` and `auth_plugin_uuid` columns so the in-use check does not depend on JSON scanning. Every multi-table write **MUST** land in a single transaction, **MUST** carry the tenant equality in the same predicate as every other key through the secure ORM and with no raw SQL (`cpt-cf-oagw-principle-tenant-scope`, `cpt-cf-oagw-nfr-multi-tenancy`), **MUST** stay portable across the PostgreSQL, MySQL, and SQLite backends of `cpt-cf-oagw-constraint-multi-sql` by avoiding backend-specific features, and **MUST NOT** create or write rows of the upstream, route, tag, or match tables, which DECOMPOSITION §1.6 assigns to `cpt-cf-oagw-feature-control-plane-config` — the two `auth_plugin_ref` and `auth_plugin_uuid` columns of the upstream row excepted, which this feature **MUST** write inside the parent's transaction as DESIGN §3.1 requires of it, without creating, altering, or writing any other column of that row. + +A persistence-layer failure is **NOT** a `DomainError` catalogue variant. It **MUST** be answered by the platform's RFC 9457 500 problem shape carrying `X-OAGW-Error-Source: gateway`, **MUST** be logged with the correlation identifier, and **MUST** fail the request without partial writes. + +**Implements**: + +- `cpt-cf-oagw-flow-plugin-create` +- `cpt-cf-oagw-flow-plugin-delete` +- `cpt-cf-oagw-flow-bind-plugins` +- `cpt-cf-oagw-algo-binding-validate` +- `cpt-cf-oagw-algo-plugin-inuse-gc` + +**Constraints**: `cpt-cf-oagw-constraint-multi-sql`, `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: `POST /oagw/v1/plugins`, `DELETE /oagw/v1/plugins/{id}`, and the four parent paths listed under `cpt-cf-oagw-dod-binding-model` +- DB: `cpt-cf-oagw-db-schema` — this feature's share of the shared schema +- DB Table: `oagw_plugin`, `oagw_upstream_plugin`, `oagw_route_plugin`, `oagw_upstream` — the last only for its two auth plugin identity columns, which this feature writes inside the parent's transaction and whose rows remain `cpt-cf-oagw-feature-control-plane-config`'s +- Entities: `Plugin`, the plugin binding rows, the `(tenant_id, name)` uniqueness key + +### Credential Isolation + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-credential-isolation` + +The system **MUST** carry every credential as an opaque `cred://` reference — the `auth.secret_ref` member of the upstream `auth` sub-configuration and the credential-reference keys of the auth plugin configuration — **MUST** validate every such reference for shape only, and **MUST** resolve it through nothing but the credential store, at request time, never at management time (`cpt-cf-oagw-principle-cred-isolation`, `cpt-cf-oagw-nfr-credential-isolation`, `cpt-cf-oagw-contract-cred-store`). No credential material **MUST** be persisted in any table this feature owns, returned in any management response, written to any log, or carried in any problem `detail`. An unresolvable reference **MUST** map to `SecretNotFound` and a reference the store declines for the calling tenant or subject **MUST** map to `AuthenticationFailed`, both through the foundation's mapping and both without echoing the reference value or the material. The material the OAuth2 path holds **MUST** be wrapped in the zeroizing secret type ADR 0008 names, so eviction zeroes it. The material **MUST** be tenant-isolated, which the cache key's tenant component and the credential store's own sharing policy together guarantee. + +**Implements**: + +- `cpt-cf-oagw-algo-credential-resolution` +- `cpt-cf-oagw-algo-token-cache` +- `cpt-cf-oagw-flow-oauth2-token-cache` +- `cpt-cf-oagw-flow-bind-plugins` + +**Constraints**: `cpt-cf-oagw-constraint-no-direct-internet`, `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: none — no endpoint of this feature returns or accepts credential material +- DB: `cpt-cf-oagw-db-schema` — references only, never material +- DB Table: `oagw_plugin`, `oagw_upstream_plugin`, `oagw_route_plugin`, `oagw_upstream` +- Entities: `AuthContext`, the token-cache entry + +### OAuth2 Token Cache + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-token-cache` + +The system **MUST** give the two OAuth2 Client Credentials variants an internal token cache with the cache key's four components — the subject tenant, the subject, the auth method tag, and the deterministic hash of the plugin configuration over its keys in sorted order — and **MUST** store each entry wrapped so that the key it was stored under is verified on every hit, treating a mismatch as a miss rather than as a hit on another tenant's or subject's token (ADR 0008). The entry **MUST** be stored with the TTL of the minimum of the configured ceiling and the reported lifetime less the 30-second safety margin, **MUST NOT** be stored when the reported lifetime is at or below that margin, and **MUST NOT** be stored after a failed fetch, so the next request for the same key retries the IdP. The cache **MUST** be constructed with the `token_cache_ttl_secs` and `token_cache_capacity` values the registry receives, whose defaults of 300 and 10000 come from ADR 0008's gear-level table and whose range checks come from the foundation's configuration validation. The cache **MUST NOT** create a background refresh task, and the injected header value **MUST NOT** be written to any log or error message. + +**Implements**: + +- `cpt-cf-oagw-algo-token-cache` +- `cpt-cf-oagw-flow-oauth2-token-cache` +- `cpt-cf-oagw-dod-credential-isolation` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy`, `cpt-cf-oagw-constraint-no-direct-internet` + +**Touches**: + +- API: none — the cache is plugin-internal and is reached only through the invocation the Data Plane performs +- DB: none — the cache holds no persisted state +- DB Table: none +- Entities: the token-cache entry of the two OAuth2 Client Credentials variants, `TokenCacheConfig` + +### Immutability, In-Use Protection, and Garbage Collection + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-plugin-inuse-gc` + +The system **MUST** keep a custom plugin immutable after creation, which the absence of any replacement endpoint on the five plugin paths enforces, and **MUST** refuse the deletion of a plugin that any binding row in `oagw_upstream_plugin` or `oagw_route_plugin` references, or that any upstream row carries in `auth_plugin_uuid`, with 409 and the `PluginInUse` variant (`gts.cf.core.errors.err.v1~cf.oagw.plugin.in_use.v1`). The reference scan **MUST** be the scalar-column scan DESIGN §3.6 names and **MUST NOT** scan a JSON configuration column. A plugin that loses its last reference **MUST** become garbage-collection-eligible by having `gc_eligible_at` set, one that gains a reference **MUST** have it cleared, and the periodic job **MUST** delete only the rows whose `gc_eligible_at` is in the past and whose reference set is empty at the moment it runs, after the 30-day TTL of §1.5. Named plugins **MUST** be exempt from all of it: no row, no `gc_eligible_at`, no deletion, and no garbage collection. `last_used_at` **MUST** be left unset by this feature, and no garbage-collection decision **MUST** depend on it (`cpt-cf-oagw-principle-plugin-immutable`, `cpt-cf-oagw-fr-plugin-system`). + +**Implements**: + +- `cpt-cf-oagw-algo-plugin-inuse-gc` +- `cpt-cf-oagw-flow-plugin-delete` +- `cpt-cf-oagw-flow-bind-plugins` +- `cpt-cf-oagw-state-plugin-lifecycle` + +**Constraints**: `cpt-cf-oagw-constraint-multi-sql`, `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: `DELETE /oagw/v1/plugins/{id}`, and the four parent paths listed under `cpt-cf-oagw-dod-binding-model` +- DB: `cpt-cf-oagw-db-schema` — the reference scan, the `gc_eligible_at` writes, and the deletion +- DB Table: `oagw_plugin`, `oagw_upstream_plugin`, `oagw_route_plugin`, `oagw_upstream` +- Entities: `Plugin`, the plugin binding rows, `gc_eligible_at` + +### Colocated Tests + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-plugin-tests` + +The system **MUST** deliver this feature's unit and integration tests colocated under `gears/system/oagw/oagw/tests/`, covering the three contracts and their separate registries, the built-in and catalog-only catalogue with its resolvability rules, the five management endpoints and their permission arms, the binding model with its contiguity, match, and type rules, the reference resolution across the store and the registry, the in-use protection and the garbage-collection eligibility, the credential resolution with its shape check and its two failure mappings, and the token cache with its key, its verification, and its ceilings, and **MUST NOT** add any test under `testing/e2e/gears/oagw/` (DECOMPOSITION §1.3(3)). The coverage **MUST** include a case asserting that no management answer, no log line, and no problem `detail` produced by this feature contains credential material or a `cred://` reference value, and a case asserting that a cached token for one tenant is never returned for a lookup keyed by another tenant's subject, including under a simulated key collision. + +**Implements**: + +- `cpt-cf-oagw-algo-plugin-contract-registry` +- `cpt-cf-oagw-algo-chain-compose` +- `cpt-cf-oagw-algo-plugin-ref-resolve` +- `cpt-cf-oagw-algo-binding-validate` +- `cpt-cf-oagw-algo-plugin-inuse-gc` +- `cpt-cf-oagw-algo-credential-resolution` +- `cpt-cf-oagw-algo-token-cache` +- `cpt-cf-oagw-state-plugin-lifecycle` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: all five paths listed under `cpt-cf-oagw-dod-plugin-management-api`, and the four parent paths listed under `cpt-cf-oagw-dod-binding-model` +- DB: `cpt-cf-oagw-db-schema` — the tables the integration tests exercise +- DB Table: `oagw_plugin`, `oagw_upstream_plugin`, `oagw_route_plugin`, `oagw_upstream` +- Entities: none — tests only + +## 6. Acceptance Criteria + +- [x] Exactly the five plugin paths of DECOMPOSITION §2.4 are registered, all gear-relative, a request to the `/api/oagw/v1/plugins` form is answered by no OAGW handler, and `GET /oagw/v1/plugins/{id}` resolves a plugin addressed as `gts.cf.core.oagw.guard_plugin.v1~{uuid}` while a path parameter that is not the anonymous GTS identifier of one of the caller's plugins resolves to no resource. +- [x] No replacement operation exists on any plugin path: a `PUT` or `PATCH` against `/oagw/v1/plugins/{id}` is answered by no OAGW handler, and the stored source, configuration schema, and name of an existing plugin are byte-identical after any sequence of management calls that creates, reads, lists, and deletes plugins. +- [x] A plugin management request without a bearer token is answered 401 and reaches no handler and no database access; a valid token without the `create` permission of a plugin arm is answered 403 on `POST /oagw/v1/plugins` and writes no row; and a token holding only `read` succeeds on `GET /oagw/v1/plugins` while failing on the create and on the delete of a plugin it owns. +- [x] A create body with no `plugin_type`, a `plugin_type` outside the three literals, no `name`, empty `source_code`, or a `config_schema` that is not an object is answered 400 with a validation error naming every failing property, and no row is written. +- [x] A create body declaring a phase its plugin type does not support is answered 400, and a body declaring a subset of the supported phases is accepted. +- [x] A second plugin with the same `name` in the same tenant is answered 400 naming `name`, and the same `name` in a different tenant is not a conflict. +- [x] All twelve plugin identifiers of PRD §5.3 are resolvable through the types-registry after startup — the six backed identifiers and the six catalog-only ones included, those six being the auth identifiers `basic` and `bearer`, the guard identifiers `timeout` and `cors`, and the transform identifiers `logging` and `metrics` — and re-registering any of them with byte-identical content does not fail startup. +- [x] The six backed identifiers resolve in their own registries and the six catalog-only identifiers resolve in none of them; a lookup of `basic` or `bearer` in `AuthPluginRegistry`, of `timeout` or `cors` in `GuardPluginRegistry`, and of `logging` or `metrics` in `TransformPluginRegistry` fails, and no implementation exists for any of the six. +- [x] A `plugin_ref` naming `timeout`, `cors`, `logging`, or `metrics` in a `plugins` item, and an `auth.type` naming `basic` or `bearer`, are each answered 400 before any row is written, with a `detail` that distinguishes the reserved identifier from an unknown one. +- [x] A `plugin_ref` naming an identifier no registry holds and no catalogue row reserves is answered 400, and the answer does not disclose the contents of the registry or the catalogue beyond the fact of the failure. +- [x] A binding set whose positions are 0, 1, 2 is accepted; one whose positions are 0 and 2, one with a duplicate position, and one whose first position is 1 are each answered 400; and the stored order of the accepted set is the submitted order. +- [x] A binding whose `plugin_uuid` equals the UUID embedded in its `plugin_ref` is stored with both values, a binding whose `plugin_uuid` differs from it is answered 400, and a binding whose `plugin_ref` names a built-in plugin and that nevertheless carries a `plugin_uuid` is answered 400. +- [x] A binding that names a custom plugin of the calling tenant resolves through the persisted store, and the same `plugin_ref` submitted by a different tenant resolves to nothing; a binding that names a built-in plugin resolves through the registry and stores a null `plugin_uuid`. +- [x] A guard or transform identifier submitted in a slot of the other family is answered 400, an upstream body binding two auth plugins is answered 400, a route body carrying an `auth` sub-configuration is answered 400, and an upstream that binds one auth plugin stores the identity in its scalar `auth_plugin_ref` and `auth_plugin_uuid` columns and in no binding row. +- [x] A `cred://` reference that is well-formed is accepted at write time and not resolved; one that is empty, carries surrounding whitespace, or carries a fragment is answered 400; and no create, replacement, read, list, or delete of a plugin or of a binding triggers a credential-store call. +- [x] `GET /oagw/v1/plugins/{id}/source` returns the stored Starlark source of the addressed plugin and no other member of the row, and the response contains no credential material, no `cred://` reference value, and no configuration value. +- [x] A descendant tenant's `GET`, source read, and `DELETE` of a plugin owned by an ancestor tenant are each answered 404, indistinguishably from a request for a nonexistent identifier, and a named plugin's GTS identifier addressed through any of the three read-and-delete paths is answered 404 as well. +- [x] A list request with no `$top` returns a bounded page, a `$filter` on the plugin type narrows it, a malformed `$top` or `$skip` or a `$filter` naming a field the plugin row does not expose is answered 400 rather than interpreted as an absent parameter, and no page contains another tenant's plugin row. +- [x] `DELETE /oagw/v1/plugins/{id}` of a plugin referenced by a row in `oagw_upstream_plugin`, by a row in `oagw_route_plugin`, or by an upstream's `auth_plugin_uuid` column is answered 409 with the `PluginInUse` variant and its GTS type identifier, the row is left in place, and the answer names no referencing resource. +- [x] `DELETE /oagw/v1/plugins/{id}` of an unlinked plugin is answered `204 No Content` with no body, removes the row in one transaction on each of the PostgreSQL, MySQL, and SQLite backends, leaves every binding row untouched, and leaves no partial row behind when the write fails partway. +- [x] A binding write that removes a plugin's last reference sets `gc_eligible_at` in the same transaction, a later binding write that references it again clears `gc_eligible_at`, and the periodic job deletes only the rows whose `gc_eligible_at` is in the past and whose reference set is empty when it runs; no row is deleted while a reference exists, and `last_used_at` is never written by any of these operations. +- [x] A custom plugin that is created and never bound becomes garbage-collection-eligible at the first run of the periodic job after its creation — its `gc_eligible_at` is set by that job's own reference scan and not by any binding write — and the job deletes it once the 30-day TTL of §1.5 has passed; no row that never gained a reference is left unmarked for want of an unbinding write. +- [x] The credential-resolution routine resolves a well-formed reference through the credential store with the calling tenant and subject, maps an absent reference to `SecretNotFound` with its GTS type, maps a declined reference to `AuthenticationFailed` with its GTS type, and returns the material only inside the zeroizing wrapper, so no log line, no management response, and no problem `detail` produced by the routine contains credential material or a reference value. +- [x] For the same tenant, subject, auth method, and configuration, the first request resolves both credential references and performs the IdP exchange, and every subsequent request inside the TTL performs neither; the stored TTL is the minimum of the configured ceiling and the reported lifetime less the 30-second margin, and a token whose reported lifetime is at or below that margin is injected once and never stored. +- [x] A cache lookup keyed by one tenant's subject never returns an entry stored for another tenant's or another subject's token, including when the lookup key hashes to the same slot; a failed fetch stores nothing and the next request retries the IdP; and the cache is sized to `token_cache_capacity` with entries bounded by `token_cache_ttl_secs`, with no background refresh task created. +- [x] Every test for this feature lives under `gears/system/oagw/oagw/tests/`, passes there, and no test is added under `testing/e2e/gears/oagw/`. diff --git a/gears/system/oagw/docs/features/rate-limiting.md b/gears/system/oagw/docs/features/rate-limiting.md new file mode 100644 index 0000000..c1c2d68 --- /dev/null +++ b/gears/system/oagw/docs/features/rate-limiting.md @@ -0,0 +1,795 @@ +# Feature: Rate Limiting + + + +- [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 Feature-Local Deviations from Shared Baselines](#15-feature-local-deviations-from-shared-baselines) + - [1.6 Explicit Non-Applicability](#16-explicit-non-applicability) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Enforce the Rate Limit on a Proxy Request](#enforce-the-rate-limit-on-a-proxy-request) + - [Apply the Configured Over-Limit Strategy](#apply-the-configured-over-limit-strategy) + - [Release Rate-Limit State on a Configuration Deletion](#release-rate-limit-state-on-a-configuration-deletion) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Fold the Effective Limit](#fold-the-effective-limit) + - [Refill and Acquire from the Token Bucket](#refill-and-acquire-from-the-token-bucket) + - [Count the Sliding Window](#count-the-sliding-window) + - [Allocate and Validate the Budget](#allocate-and-validate-the-budget) + - [Emit the Rate-Limit Response Headers](#emit-the-rate-limit-response-headers) + - [Count Upstream Failures and Trip the Breaker](#count-upstream-failures-and-trip-the-breaker) +- [4. States (CDSL)](#4-states-cdsl) + - [Circuit Breaker State Machine](#circuit-breaker-state-machine) +- [5. Definitions of Done](#5-definitions-of-done) + - [Rate-Limit Check on the Proxy Path](#rate-limit-check-on-the-proxy-path) + - [Token Bucket and Sliding Window Algorithms](#token-bucket-and-sliding-window-algorithms) + - [Hierarchical Enforcement and Budget Allocation](#hierarchical-enforcement-and-budget-allocation) + - [Rate-Limit Response Headers](#rate-limit-response-headers) + - [Over-Limit Strategies](#over-limit-strategies) + - [Circuit Breaker](#circuit-breaker) + - [Per-Instance State, Cleanup, and Distributed Posture](#per-instance-state-cleanup-and-distributed-posture) + - [Rate-Limit Entities and Layering](#rate-limit-entities-and-layering) + - [Latency Budget](#latency-budget) + - [Colocated Tests](#colocated-tests) +- [6. Acceptance Criteria](#6-acceptance-criteria) + + + +- [ ] `p1` - **ID**: `cpt-cf-oagw-featstatus-rate-limiting-implemented` + + +- [ ] `p2` - `cpt-cf-oagw-feature-rate-limiting` + +## 1. Feature Context + +### 1.1 Overview + +This feature is the rate-limit and circuit-breaker policy of the `oagw` gear. It attaches to the proxy path `cpt-cf-oagw-feature-data-plane-proxy` owns and evaluates one question per request: may this call go out, and if not, what does the caller see. It folds the upstream, route, and tenant layers of the resolved configuration into one effective limit, counts the request against a token bucket or a sliding window under the configured counter scope, and answers an over-limit caller with 429 and the standard rate-limit headers so it can retry correctly. It also owns the circuit breaker that takes an unhealthy upstream out of rotation with 503 before the gateway dials it again. + +The feature registers no endpoint and no sequence of its own. A rejection is an answer on the proxy path the caller already called, produced before the outbound request is built and tagged `X-OAGW-Error-Source: gateway` like every other gateway answer. + +### 1.2 Purpose + +DECOMPOSITION §2.6 places this feature as the first of the three policy tails that hang off the proxy spine: `cpt-cf-oagw-feature-data-plane-proxy` resolves, matches, and forwards, and this feature decides whether a resolved request is admitted. DECOMPOSITION §3 makes it a consumer of the proxy feature because "the check runs inside the resolved proxy context, and 429 and 503 answers replace the response the proxy would have produced", and makes `cpt-cf-oagw-feature-observability` a consumer of this one because that feature "reports rate-limit state and 429 outcomes, which only exist once that feature owns them". + +This feature delivers the enforcement half of ADR 0003 (`cpt-cf-oagw-adr-rate-limiting`) — the token bucket, the dual-rate configuration, the counter scopes, the over-limit strategies, and the hierarchical fold of the canonical merge formula — and the breaker half of `cpt-cf-oagw-nfr-high-availability`. The configuration side of the same ADR is split three ways and this document names the split: the `rate_limit` object's schema validation at write time is `cpt-cf-oagw-feature-control-plane-config`'s, the per-field merge and the sharing modes are `cpt-cf-oagw-feature-hierarchical-config`'s, and what the merged members mean at enforcement time is this feature's. + +Deliverables: + +- The rate-limit check on the proxy path, invoked ahead of the composed chain at the position the request flow with caching of `cpt-cf-oagw-adr-state-management` fixes. +- The token bucket as the default algorithm and the sliding window as the optional alternative, selected by the `algorithm` member the resolved configuration carries. +- Dual-rate enforcement of `sustained.rate` with `sustained.window`, `burst.capacity`, and the per-request `cost`. +- Counter scoping over `global`, `tenant`, `user`, `ip`, and `route`. +- The three over-limit strategies `reject`, `queue`, and `degrade`, with the `queue` strategy delivered as its bounded behaviour and no further backpressure machinery. +- Hierarchical enforcement through `effective = min(selected_rate, route_rate, all_ancestor_enforced_rates)`, holding across alias shadowing, with budget allocation and overcommit validation. +- The 429 answer with the `X-RateLimit-*` header set and `Retry-After`, gated by the `response_headers` member. +- The circuit breaker state machine over `closed`, `open`, and `half_open`, tripping within the configured failure window and answering 503 `CircuitBreakerOpen`; the breaker's configuration parameters are deferred per DESIGN §4.7(1) and are not delivered here. +- The per-instance in-memory bucket registry of `cpt-cf-oagw-adr-state-management`, with prefix-based cleanup when an upstream or route is deleted. +- Colocated tests under `gears/system/oagw/oagw/tests/`. + +**Requirements**: + +- [ ] `p1` - `cpt-cf-oagw-fr-rate-limiting` +- [ ] `p1` - `cpt-cf-oagw-nfr-high-availability` +- [ ] `p1` - `cpt-cf-oagw-nfr-low-latency` +- [x] `p2` - `cpt-cf-oagw-fr-hierarchical-config` +- [ ] `p2` - `cpt-cf-oagw-usecase-rate-limit-exceeded` + +`cpt-cf-oagw-fr-hierarchical-config` carries the checked state DECOMPOSITION §2.6 records: its merge behaviour is delivered by `cpt-cf-oagw-feature-hierarchical-config`, and what this feature consumes of it is the merged `EffectiveRateLimit` result rather than a second merge. + +**Principles**: + +- `p1` - `cpt-cf-oagw-principle-error-source` +- `p1` - `cpt-cf-oagw-adr-rate-limiting` +- `p1` - `cpt-cf-oagw-adr-state-management` +- `p1` - `cpt-cf-oagw-adr-error-source-distinction` + +**Constraints**: + +- `p1` - `cpt-cf-oagw-constraint-toolkit-deploy` + +**Design Components**: + +- `p1` - `cpt-cf-oagw-component-model` +- `p1` - `cpt-cf-oagw-tech-dependencies` + +This feature delivers the rate-limit merge row of the DESIGN §3.2 Hierarchical Configuration subsection — the row whose strategy is `min(ancestor, descendant)` — as its enforcement-time consumption, together with the Shadowing Behavior paragraph that states the canonical formula. The merge table itself, the hierarchy walk, and the sharing-mode decision are `cpt-cf-oagw-feature-hierarchical-config`'s and are not restated here. + +**Domain Model Entities**: + +- `TokenBucket` — one bucket per counter key, carrying its tokens, its last update instant, its capacity, and its refill rate (ADR 0003's implementation notes name the type). +- `RateLimiterRegistry` — the per-instance registry of buckets and breaker machines the Data Plane holds (`cpt-cf-oagw-adr-state-management` names `rate_limiters` as the third piece of Data Plane state). +- `BudgetAllocation` — the budget mode, the parent total, the overcommit ratio, and the child allocations validated against them; DECOMPOSITION §2.6 names the concept "budget allocation" and this document fixes the type name. +- `CircuitBreakerState` — one machine per resolved upstream, carrying its state, its rolling failure window, and the instant its open interval began. + +`TokenBucket`, `BudgetAllocation`, and `CircuitBreakerState` are declared here and DECOMPOSITION §2.6 lists all three under this entry; `RateLimiterRegistry` is declared here because `cpt-cf-oagw-adr-state-management` names `rate_limiters` as the third piece of Data Plane state, which the baseline's entity list does not carry, and `RateLimitConfig` is listed by DECOMPOSITION §2.6 for the enforcement semantics of its members and is consumed from `cpt-cf-oagw-feature-gear-foundation` rather than declared (§1.5). `RateLimitConfig` is consumed from `cpt-cf-oagw-feature-gear-foundation`, which declares it as shared vocabulary, and `EffectiveRateLimit` is consumed from `cpt-cf-oagw-feature-hierarchical-config`, which produces it; neither is redeclared (§1.5). `ResolvedUpstream`, `MatchedRoute`, `ProxyContext`, and `ProxyResponse` are consumed from `cpt-cf-oagw-feature-data-plane-proxy`, and `ErrorContext` from `cpt-cf-oagw-feature-gear-foundation`. + +**Data**: + +- None. DECOMPOSITION §2.6 declares no table for this feature, and it creates, reads, or writes none. The buckets, the counters, the budget allocations, and the breaker machines are in-process and persisted nowhere; a restart loses them and a restart is the only thing that resets them (§1.5). + +**API**: + +- None. Rejections are returned on the existing `{METHOD} /oagw/v1/proxy/{alias}[/{path_suffix}]` path that `cpt-cf-oagw-feature-data-plane-proxy` registers, which is the whole of the API statement DECOMPOSITION §2.6 makes. + +This feature invents no path, no method, no query parameter, and no response shape. The management write path that changes the configuration it enforces is `cpt-cf-oagw-feature-control-plane-config`'s, and this feature is a callee of that path for two purposes only: the overcommit validation of a child allocation, and the cleanup notification of a deletion (§1.5). + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-oagw-actor-app-developer` | Sends the proxy request whose rate limit is evaluated and receives either the upstream's response or the 429 answer with the rate-limit headers. PRD §5.2 names this actor for the proxy requirements and PRD §8 names it the actor of `cpt-cf-oagw-usecase-rate-limit-exceeded`, whose three strategy outcomes are this feature's §2 answers. | +| `cpt-cf-oagw-actor-platform-operator` | Deletes an upstream or a route through the management API and thereby triggers the prefix-based cleanup of the buckets and breaker entries that configuration owned. The deletion itself is `cpt-cf-oagw-feature-control-plane-config`'s act; what this feature does on the notification of it is §2's third flow. | +| `cpt-cf-oagw-actor-upstream-service` | Answers the outbound attempt whose outcome the breaker counts. It is contacted by `cpt-cf-oagw-feature-data-plane-proxy` and never by this feature; what reaches this feature is the classification of that attempt, which is the only signal that opens or closes the breaker. | + +Two actors participate indirectly and are named here so their absence from the table is a record and not a gap: + +- `cpt-cf-oagw-actor-tenant-admin` configures the rate limits of its own tenant hierarchy — a stricter rate, a tighter scope, an own `cost` — through the management API of `cpt-cf-oagw-feature-control-plane-config` under the `oagw:upstream:override_rate` permission `cpt-cf-oagw-feature-hierarchical-config` evaluates. PRD §2 names setting stricter rate limits as that actor's need, and the configuring is a write-time act this feature performs nothing of. +- `cpt-cf-oagw-actor-types-registry` and `cpt-cf-oagw-actor-cred-store` issue no call this feature answers. The error-type catalogue was provisioned at startup by `cpt-cf-oagw-feature-gear-foundation`, and the credential material a forwarded request carries is resolved by `cpt-cf-oagw-feature-plugin-system` before the check this feature runs. + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) +- **Design**: [DESIGN.md](../DESIGN.md) +- **Dependencies**: `cpt-cf-oagw-feature-data-plane-proxy` — the proxy path this feature runs inside, the `ProxyContext`, `ResolvedUpstream`, `MatchedRoute`, and `ProxyResponse` types it consumes, the resolution and route matching that precede the check, the outbound attempt whose outcome the breaker counts, and the error-source classification that tags every answer it produces (DECOMPOSITION §3); and `cpt-cf-oagw-feature-hierarchical-config` — the `EffectiveRateLimit` result and the per-family sharing modes the check consumes, delivered by `cpt-cf-oagw-algo-field-family-merge` of that feature. + +Supporting sources this feature stays consistent with: + +- [ADR/0003-rate-limiting.md](../ADR/0003-rate-limiting.md) (`cpt-cf-oagw-adr-rate-limiting`) — the token bucket over the sliding window, the dual-rate field table with its defaults, the counter scopes, the three strategies, the inheritance table, the budget modes with the overcommit arithmetic, the worked Example 1 that min-merges the burst capacity beside the sustained rate, the `response_headers` gate, the response header set, and the prefix-based cleanup of a deleted resource. +- [ADR/0006-state-management.md](../ADR/0006-state-management.md) (`cpt-cf-oagw-adr-state-management`) — the per-instance token buckets owned by the Data Plane because it "has full request context (tenant, upstream, route)", the request flow with caching that fixes where the check sits, and the `RateLimiterRegistry` the Data Plane state holds. +- [ADR/0007-error-source-distinction.md](../ADR/0007-error-source-distinction.md) (`cpt-cf-oagw-adr-error-source-distinction`) — `X-OAGW-Error-Source: gateway` for the 429 and the 503 this feature answers, the `application/problem+json` body both carry, and the `retry_after_seconds` extension member beside the `Retry-After` header. +- [schemas/upstream.v1.schema.json](../schemas/upstream.v1.schema.json) and [schemas/route.v1.schema.json](../schemas/route.v1.schema.json) — the frozen `definitions.rate_limit` shape this feature enforces against: `sharing`, `algorithm`, `sustained.rate` with `sustained.window`, `burst.capacity`, `scope`, `strategy`, and `cost`, with `sustained` required and every numeric member at least 1. Both files are frozen inputs this run does not edit, and neither declares the `response_headers` or `budget` member ADR 0003 names (§1.5). +- [config/e2e-local.yaml](../../../../../config/e2e-local.yaml) — the graded configuration. Its only `rate_limit` key sits under the `api-gateway` gear's `defaults` block and carries `rps: 1000`, `burst: 200`, and `in_flight: 64`: the inbound platform gateway's own limiter, whose members (`rps`, `in_flight`) are not members of the OAGW `rate_limit` object at all. No tenant, upstream, or route declared in it carries a `rate_limit` block, and its `oagw.config` block carries no rate-limit key, so every rate limit the graded gear enforces is one written through the management API at run time, and with none written the gear enforces no limit at all. + +**Run-level assumptions** — premises this feature relies on that come from the platform runtime rather than from PRD, DESIGN, the ADRs, or DECOMPOSITION. Each states what fails if the premise does not hold: + +- Assumption: the Data Plane process has a monotonic clock for bucket refill, sliding-window accounting, and the breaker's rolling failure window, and a wall clock for the absolute epoch second the `X-RateLimit-Reset` header reports. ADR 0003's implementation notes read an instant-of-day monotonic source for refill, and its header example reports `X-RateLimit-Reset` as an absolute epoch value, which no monotonic clock can produce. If only a wall clock is available, the refill arithmetic **MUST** be computed from differences of successive wall readings so a clock adjustment cannot add or remove tokens, and if no wall clock is available the `X-RateLimit-Reset` header **MUST** be omitted rather than synthesized, because an invented absolute time correlates nothing. +- Assumption: the graded deployment runs one process, which is the single-executable branch of `cpt-cf-oagw-constraint-toolkit-deploy` that DECOMPOSITION §1.4 records, so the per-instance registry sees every proxied request. If a deployment runs more than one instance serving the same upstream, each instance holds its own buckets, the aggregate admission can exceed the configured rate by up to the instance count, and this feature **MUST NOT** report global accuracy, **MUST NOT** add a sync path, and **MUST NOT** present the per-instance counters as the configured limit (§1.5). +- Assumption: the platform supplies the identifiers the counter scopes key on — the calling tenant and subject from the authenticated context, and the peer address of the inbound connection. A `scope` of `user` on a request with no authenticated subject, or of `ip` on a connection whose peer address cannot be resolved, is a counter the gateway cannot key, and skipping enforcement would turn a configured limit into no limit. Either case **MUST** therefore fall back to the `tenant` scope rather than skip the check, and the fallback **MUST** be the same for every request that lacks the identifier, so a caller cannot move between scopes to escape a limit. The peer address is the one the platform's inbound handler exposes for the connection that reached the gear; this feature **MUST NOT** parse a proxying header such as `X-Forwarded-For` to recover a client address, because no supplied document assigns the gear that duty and a self-derived address is a key a caller can forge. Where the platform does not forward the client identity, the `ip` scope degenerates to one bucket shared by every caller behind that hop, which is a recorded consequence and not a failure. +- Assumption: the classification of one outbound attempt reaches this feature as a per-attempt outcome, produced by `cpt-cf-oagw-algo-response-classify` of `cpt-cf-oagw-feature-data-plane-proxy`, which is the feature that owns the classification. No supplied document states who reports a failure to the breaker, and the breaker has no other source of evidence. If no outcome is delivered, the breaker **MUST** stay closed and **MUST NOT** open on the absence of information, because taking an upstream out of rotation without evidence is a worse failure than the one the breaker prevents. +- Assumption: the notification of a successful upstream or route deletion reaches this feature's registry in the same process and before the delete's response is produced, which is the same in-process ordering `cpt-cf-oagw-feature-data-plane-proxy` already relies on for its cache flush and which the single-executable posture makes the only mechanism (§1.5). If the notification does not arrive, the buckets and breaker entries of the deleted configuration remain resident and are never consulted again, because no resolution can reach a deleted row; that is a memory leak and not a correctness one, and this feature **MUST NOT** re-resolve a deleted resource to discover that it is gone. `cpt-cf-oagw-flow-route-delete` and the deletion branch of the upstream write flow of `cpt-cf-oagw-feature-control-plane-config` produce that notification, which is the producer this feature's cleanup flow receives it from. +- Assumption: the platform's inbound handler can hold a proxy request open while the `queue` strategy holds it, because a queued request is one that has been read and not yet answered. The outbound deadline `oagw.config.proxy_timeout_secs` bounds the upstream exchange and not the inbound wait, so it does not bound the queue. If the runtime cannot suspend a handler, the `queue` strategy **MUST** degrade to the `reject` answer rather than hold a worker indefinitely, and the degradation **MUST** be the whole strategy and not a per-request choice, so a deployment either queues or it does not. + +### 1.5 Feature-Local Deviations from Shared Baselines + +| Deviation | Rationale | Review owner | Validation performed | +|-----------|-----------|--------------|----------------------| +| The two circuit-breaker parameters DESIGN §4.7(1) defers are carried as the constants PRD §6.1 states — the breaker trips within 5 failed requests in a 30-second window — and add no `OagwConfig` key. | PRD §6.1 states both numbers as the threshold of `cpt-cf-oagw-nfr-high-availability`, and DESIGN §4.7(1) defers the breaker's "config and fallback strategies", which is a deferral of their configurability and not of the machine this feature is assigned. The `OagwConfig` surface closes at the five keys DECOMPOSITION §2.1 declares and `cpt-cf-oagw-feature-gear-foundation` owns, and names no breaker key, so a breaker key here would give one configuration surface two owners. A deployment that needs different values changes a build-time constant, not a configuration file. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The open-to-half-open interval is a named constant of this feature with no configuration surface and no sourced value. | PRD §6.1 states a trip threshold and states no reopen delay; DESIGN §3.3 tabulates the `CircuitBreakerOpen` row and states no interval; ADR 0003 does not cover the breaker at all; and DESIGN §4.7(1) defers exactly this configuration. A machine that can open must also be able to try again, so the interval cannot be left undefined, and stating a number here would be the invention the constants rule forbids. The value is recorded in the implementation as a build-time constant, and the behaviour this document pins is that the interval exists, that it is finite, and that no request is forwarded while it runs. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| What counts as a breaker failure is fixed to three rows of the DESIGN §3.3 catalogue — `ConnectionTimeout` and `RequestTimeout` at 504, and `LinkUnavailable` at 503 — and to nothing else. | No supplied document enumerates the failures a breaker counts, so the enumeration is a recorded decision. The three rows are the ones DESIGN §3.3 marks unconditionally retriable whose descriptions say the gateway obtained no answer from the target: a connection that never established, an exchange that never completed, and a link that was unavailable. `DownstreamError` at 502 is marked retriable "Depends" and describes an upstream that answered, so it is evidence about the upstream's mood and not about its reachability; a 4xx answer is the upstream's own verdict on the request; and a 429 this feature produced is evidence about nothing but the caller's own rate. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| A successful outbound attempt clears the upstream's failure count. | PRD §6.1 states the trip condition as a count within a window and states no recovery from partial accumulation. Without a clearing rule, a host that alternates one failure with many successes accumulates five failures across a long period and opens, which contradicts the breaker's purpose of isolating a target that is unhealthy now. Clearing on success ties the count to the target's recent behaviour, which is what the "30-second window" of PRD §6.1 describes. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| While `half_open`, the machine admits one probe; every further concurrent request for the same upstream is answered 503 `CircuitBreakerOpen` until that probe resolves. | DESIGN §4.7(2) defers concurrency control, so no in-flight limit exists for the machine to borrow, and an unbounded half-open state would forward an arbitrary number of requests to a target that just failed five times. One probe is the smallest bound that still tests the target, and the 503 answer is the catalogue row for a breaker that is not admitting, which is what the machine is during the probe. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| `Retry-After` is the whole-second delay until the effective bucket holds the request's `cost`, rounded up to at least 1, and `X-RateLimit-Reset` is the epoch second at which the bucket reaches capacity. | ADR 0003 cites RFC 6585 and the IETF rate-limit headers draft, shows the four headers beside one another in its More Information section, and states no derivation for either value; DESIGN §3.3 names the `retry_after_seconds` extension member as "Retry guidance" and states no derivation; and ADR 0007's worked example shows `Retry-After` and `retry_after_seconds` carrying the same value. The derivation above is the only one the dual-rate configuration determines, and the two members carry the same number because the one example that shows both shows them equal. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The `response_headers` and `budget` members of ADR 0003 are carried as that ADR's declared defaults and are not configurable in this deployment, because the shipped `definitions.rate_limit` of both frozen schemas declares `additionalProperties: false` and lists neither member. | `cpt-cf-oagw-feature-control-plane-config` rejects an unknown member of the `rate_limit` object at write time, so a configuration carrying either member is refused and cannot reach this feature. DECOMPOSITION §2.6 carries both into this feature's scope, so neither is dropped: the header set is emitted under ADR 0003's declared default `response_headers: true`, and the budget mode is its declared default `unlimited`. The `allocated` and `shared` modes and the overcommit validation of ADR 0003 are delivered and tested at the domain layer and become reachable from a written configuration only when a schema revision admits the member, which is a change this run does not make to a frozen input. Neither member adds an `OagwConfig` key. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The overcommit validation of ADR 0003 is a routine this feature owns and the management write path of `cpt-cf-oagw-feature-control-plane-config` invokes when a child upstream or route that declares a budget is written, because this feature registers no management endpoint. | ADR 0003 frames the validation as happening on child creation, which is a management-write act, while DECOMPOSITION §2.6 assigns the validation to this feature and declares its API as none. The resolution is the same call-direction seam `cpt-cf-oagw-feature-data-plane-proxy` records for its cache flush and `cpt-cf-oagw-feature-plugin-system` records for the routines the proxy invokes: the act is this feature's and the trigger is another feature's. A rejected allocation is answered 400 through the foundation's `ValidationError` variant, which is the catalogue row for a failed request validation and needs no new variant. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The prefix-based cleanup of a deleted upstream or route is split at a named seam: the notification of a successful deletion is `cpt-cf-oagw-feature-control-plane-config`'s act, and the cleanup is this feature's, executed in process before the delete's response is produced. | ADR 0003's Redis key structure paragraph gives the `{resource_type}:{resource_id}` prefix for "efficient prefix-based cleanup … when a resource is deleted", and names two mechanisms for it, the in-memory `retain` and the Redis `SCAN`; the Redis half is out of scope per DECOMPOSITION §2.6, so only the in-memory half is delivered. The write path already issues one in-process notification per interested owner — to `cpt-cf-oagw-feature-data-plane-proxy`'s flush routine, per that feature's own §1.5 — and this feature is a second interested owner of the same event. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The rate-limit check sits ahead of the composed chain on the proxy path. | ADR 0006's request flow with caching orders the proxy steps as the auth plugin, then "Check rate limiter (DP-owned)", then the guard and transform plugins, then the outbound call, and no other supplied document places the check; DESIGN §3.5's sequence diagram names no rate-limit step at all. The chain that `cpt-cf-oagw-algo-chain-execute` runs bundles the auth plugin with the guards and the transforms into one call, so the position ADR 0006 fixes for the check is realized ahead of that chain, and the credential injection it performs happens after the check — which the check never needs, because it keys on the tenant, the subject, and the peer address the middleware resolved. The position is load-bearing: it is after resolution, so the check has the effective configuration; it is before the guard chain, so a rejected request costs no plugin execution; and it is before the send, so an over-limit request never reaches the upstream. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The canonical formula is applied here as a layer minimum over the three layer values the resolution produced, and its `all_ancestor_enforced_rates` term arrives already folded into those layer values by `cpt-cf-oagw-algo-field-family-merge` of `cpt-cf-oagw-feature-hierarchical-config`. | DESIGN §3.2's Shadowing Behavior states `effective_rate = min(selected_rate, route_rate, all_ancestor_enforced_rates)` and its merge table states the rate-limit row as `min(ancestor, descendant)`; `cpt-cf-oagw-feature-hierarchical-config` applies that minimum across the ancestor chain, normalizes the compared values to one scale, and reports one result per layer, and its Definition of Done forbids a downstream feature from re-walking the chain or re-applying a per-field strategy. Re-folding the ancestor term here would walk the chain a second time for a value it already carries. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The four `rate_limit` members that carry no merge — `algorithm`, `scope`, `strategy`, and `cost` — are taken from the last layer that declares them in the upstream, then route, then tenant order. | ADR 0003's inheritance table and DESIGN §3.2's merge row state a strategy for the limits and for none of the four; `cpt-cf-oagw-fr-config-layering` states the layer order as upstream base, then route, then tenant highest, and `cpt-cf-oagw-feature-data-plane-proxy` records that the tenant layer is applied last and therefore prevails. The same order decides a member no source assigns a merge to, which is why the route's `cost` of ADR 0003's Example 3 wins over an upstream `cost` and a tenant `cost` wins over both. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| A counter scope the platform cannot key falls back to the `tenant` scope, and a request whose resolved configuration names no `rate_limit` at all is enforced by nothing. | The first half is the fail-consequence of the scope-identifier assumption in §1.4. The second half is the shipped schema's own shape: `rate_limit` is an optional member of both the upstream and the route, so its absence is a legal configuration and not an error, and PRD §5.2 conditions enforcement on limits being configured at those levels. A missing limit is a configuration choice an operator made, not a failure to report. A missing limit at *every* layer is that configuration choice; a limit at any one layer is enforced, which is the fold's outcome and not the two-layer look the check's guard once suggested. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| A request whose `cost` exceeds the effective `burst.capacity` is never admittable and is refused on every attempt for as long as that configuration stands. | `sustained.rate` and `cost` are both at least 1 and neither is bounded against the other by the shipped schema, so the configuration is legal and the refusal is its consequence: the bucket's ceiling is the capacity, and a cost above the ceiling can never be covered. ADR 0003's Example 3 shows costs of 1 and 10 against a tenant budget of 1000 per minute, so the case is not the ADR's intent, and the answer is a deterministic 429 rather than an intermittent one, which is the honest rendering of a limit that can never be met. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The `queue` strategy is delivered as its bounded behaviour only, and its two bounds are named constants of this feature with no configuration surface and no sourced value: the queue holds at most a fixed number of requests and a queued request waits at most a fixed interval before it is answered 429 exactly as a full queue answers. | PRD §8 states the outcome as "Request queued for later execution within bounded capacity" and states no value for either bound; DESIGN §4.7(3) defers backpressure queueing as a future development; and DECOMPOSITION §2.6 puts "backpressure queueing strategies beyond the `queue` strategy's bounded behaviour" out of scope. What this document pins is the existence of both bounds and their consequence — a full queue answers 429 with the header set the `reject` strategy produces, the queue never grows past its count bound, and a queued request that outwaits the wait bound is answered the same 429 and charged nothing — and both values are recorded in the implementation as build-time constants. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The `degrade` strategy withholds the burst reserve: under the `token_bucket` algorithm a degraded request is evaluated against a bucket whose capacity is reduced to the sustained rate, and under the `sliding_window` algorithm there is no burst reserve to withhold, ADR 0003's own comparison table giving that algorithm "No boundary burst", so the degraded request is evaluated against the unchanged window and the strategy's only observable effect is the absence of a 429 for a request the window admits. | PRD §8 states the outcome as "Request processed with reduced functionality" and names no reduction, and the reduction this feature applies is expressed in the currency each algorithm has; DESIGN and ADR 0003 name `degrade` only as an enum value. The response body belongs to the upstream and its transfer mode to `cpt-cf-oagw-feature-streaming`, so the only reduction this feature can apply without touching a surface another feature owns is the allowance it computes itself. A degraded request that the reduced capacity cannot cover is still refused, because a strategy that admitted everything would make the strategy indistinguishable from no limit. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| `RateLimitConfig` is consumed and not redeclared, and the ownership of the rate-limit types is split three ways and named here. | `cpt-cf-oagw-feature-gear-foundation` declares `RateLimitConfig` with the other sub-configuration types as the gear's shared vocabulary (DECOMPOSITION §2.1); `cpt-cf-oagw-feature-hierarchical-config` declares `EffectiveRateLimit` as its merge result and states in its own §1.5 that the token-bucket meaning of the members, the budget modes, and the overcommit validation belong to this feature; and this feature declares the four enforcement types listed in §1.2. DECOMPOSITION §2.6 lists `RateLimitConfig` under this entry because the enforcement semantics of its members are this feature's, not because the type is declared twice. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| Rate-limit state is not persisted across a restart, and the brief burst window a restart opens is accepted. | PRD §12 offers two mitigations for the restart risk: persist the rate limit counters, or accept a brief burst on cold start. DECOMPOSITION §1.4 records that this run accepts the burst window "rather than introducing a Redis dependency", which selects the second mitigation and declines the first, and DECOMPOSITION §2.6 puts Redis-backed counters out of scope altogether. A cold bucket starts full, so the window is bounded by the configured `burst.capacity` and closes as the bucket refills. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The per-instance counters are a recorded limitation for multi-instance deployments and are stated as one wherever the limit is described. | DECOMPOSITION §1.4 records that there is no cross-instance state and no distributed rate-limit sync, and ADR 0003's Option A — the only fully local option it considers — states the consequence itself: "Effective limit = `configured_limit / node_count`", with the further caveat that the division is accurate only when traffic is evenly distributed. The hybrid option ADR 0003 recommends and the centralized option it rejects both require the Redis dependency DECOMPOSITION §2.6 excludes, so the limitation is the price of the posture the graded deployment chooses, and this feature reports it rather than papering over it. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| This feature's tests are colocated at `gears/system/oagw/oagw/tests/` instead of `testing/e2e/gears/oagw/`. | DECOMPOSITION §1.3(3) reserves `testing/e2e/gears/oagw/` for the acceptance suite; every unit and integration test this decomposition produces lives with the crate. This is the same deviation `cpt-cf-oagw-feature-gear-foundation`, `cpt-cf-oagw-feature-control-plane-config`, `cpt-cf-oagw-feature-hierarchical-config`, `cpt-cf-oagw-feature-data-plane-proxy`, and `cpt-cf-oagw-feature-plugin-system` record in their own §1.5 tables, restated here because the tests it governs include this feature's. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| This feature's §1.4 declares `cpt-cf-oagw-feature-hierarchical-config` as a second dependency, which DECOMPOSITION §3 withholds from it while granting the same wait to `cpt-cf-oagw-feature-cors` in the same sentence. | DECOMPOSITION §2.6 itself assigns the per-field merge table to `cpt-cf-oagw-feature-hierarchical-config` and carries the canonical formula into this feature's scope, and `cpt-cf-oagw-algo-effective-limit-fold` consumes the `EffectiveRateLimit` result that feature produces, so the dependency is a real consumption and the §3 sentence records build parallelism rather than consumption. Recording it here keeps the dependency graph the implementation phase follows consistent with the baseline's. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The `ip` scope keys on the peer address the platform's inbound handler exposes for the connection that reached the gear, and this feature parses no proxying header such as `X-Forwarded-For` to recover a client address; where the platform does not forward the client identity the scope holds one bucket shared by every caller behind that hop. | No supplied document assigns the gear the duty of recovering a client address from a proxying header, and a self-derived address is a key a caller can forge, so the peer address the §1.4 assumption names is the only source the scope reads. An address the platform does expose is a resolvable one, so the §1.4 `tenant` fallback does not fire for it, and the shared bucket that results is a recorded consequence and not a failure. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The §1.2 Principles list carries `cpt-cf-oagw-adr-state-management` and `cpt-cf-oagw-adr-error-source-distinction` beyond the two entries DECOMPOSITION §2.6 records, both of which this feature implements and cites in §1.4. | Both added ADRs are load-bearing here — the per-instance registry of ADR 0006 is the state this feature holds, and the error-source distinction of ADR 0007 tags every answer it produces — and every sibling feature document mirrors its baseline list exactly, so the superset is recorded rather than silently carried. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | + +### 1.6 Explicit Non-Applicability + +The areas below apply to the gear as a whole but not to this feature. Each is stated here so the omission is a recorded decision rather than a silent gap, and each names the feature that does own it. + +- **The hierarchy walk, alias shadowing, the sharing-mode decision, and the per-field merge table.** DECOMPOSITION §2.3 places all four in `cpt-cf-oagw-feature-hierarchical-config`, and this feature consumes their result through `EffectiveRateLimit`. `cpt-cf-oagw-algo-tenant-chain-walk`, `cpt-cf-oagw-algo-alias-shadow-resolve`, `cpt-cf-oagw-algo-sharing-mode-decision`, and `cpt-cf-oagw-algo-field-family-merge` are that feature's routines; §3 of this document calls the last of them indirectly, through the resolution, and restates no merge row. +- **The `rate_limit` configuration schema and its write-time validation.** `cpt-cf-oagw-feature-control-plane-config` validates the `rate_limit` sub-object of both frozen schemas, and this feature enforces against a configuration that already passed that validation. The one routine of §3 that runs at write time is the overcommit validation, which that feature's write path calls and which is not a schema check (§1.5). +- **The proxy path itself: resolution, matching, endpoint selection, inbound and body validation, header transformation, forwarding, and error-source classification.** `cpt-cf-oagw-feature-data-plane-proxy` owns all of them, and this feature produces neither the `ProxyResponse` nor the `X-OAGW-Error-Source` tag — it produces the `DomainError` the tag attaches to, and the classification is that feature's. +- **Redis-backed distributed counters, the hybrid sync of ADR 0003's Option C, and the centralized store of its Option B.** DECOMPOSITION §2.6 and DECOMPOSITION §1.4 both exclude them, and the graded posture of `cpt-cf-oagw-constraint-toolkit-deploy` uses L1 state only. ADR 0003's own consequences list records the trade: the Redis dependency is gone, and the accuracy across instances is gone with it (§1.5). +- **Backpressure queueing strategies beyond the `queue` strategy's bounded behaviour, and concurrency control.** DESIGN §4.7(3) defers the former and §4.7(2) defers the latter. What §3 delivers is the bound and its 429 answer, and no in-flight limit, no admission control, and no graceful-degradation machinery. +- **Circuit-breaker configuration parameters and fallback strategies.** DESIGN §4.7(1) defers both. What §4 delivers is the state machine and the 503 answer DECOMPOSITION §2.6 assigns, and no configurable threshold, no fallback response, and no per-route breaker policy. +- **Metrics emission and audit log formatting.** `cpt-cf-oagw-feature-observability` owns the Prometheus surface and the structured record, and DECOMPOSITION §3 makes it a consumer of this feature because it "reports rate-limit state and 429 outcomes". The `oagw_rate_limit_usage_ratio`, `oagw_rate_limit_exceeded_total`, `oagw_circuit_breaker_state`, and `oagw_circuit_breaker_transitions_total` series DESIGN §4.2 names are that feature's to emit, and the `host` label under which the breaker state is reported is the upstream alias, which is that feature's labelling decision. What this feature supplies is the state and the transitions those series describe. +- **Persistence.** DECOMPOSITION §2.6 declares no table for this feature, and `cpt-cf-oagw-db-schema` is fully claimed by `cpt-cf-oagw-feature-control-plane-config` and `cpt-cf-oagw-feature-plugin-system`. Nothing this feature computes outlives the process. +- **gRPC proxying and WebTransport.** Both are out of scope per DECOMPOSITION §1.3(4), and `cpt-cf-oagw-feature-data-plane-proxy` records that a gRPC upstream produces no matching route and a `wt` upstream is refused at dial time. A request that never resolves to a forwardable target never reaches the check, so this feature enforces no limit and holds no breaker for either. +- **The plugin contracts, the registries, the chain composition, and credential resolution.** `cpt-cf-oagw-feature-plugin-system` owns them, and the check this feature runs sits ahead of the chain that composes them, executing neither the auth plugin nor any guard. A guard that rejects a request after the check has charged it does not refund the charge, which is the behaviour of a counter that records an admitted request rather than a forwarded one. +- **CORS preflight and streaming connection lifecycles.** `cpt-cf-oagw-feature-cors` owns the preflight answer, which requires no upstream resolution and therefore no rate-limit check, and `cpt-cf-oagw-feature-streaming` owns the stream lifecycles. A request that upgrades to a stream is charged once, at the check, and the stream's own duration consumes no further tokens. +- **Rollout, rollback, versioning, localization, accessibility, and compliance.** The gear is one configuration item and one release unit (DECOMPOSITION §1.4), so this feature ships no rollout of its own. Every identifier it reads is fixed at `.v1`. The 429 and 503 bodies are English protocol strings from the foundation's mapping, and the rate-limit headers are protocol values an accessibility requirement does not reach. No credential material, no request body, and no caller identifier other than the scope key enters a bucket, and the scope key is never echoed in a problem `detail`. + +## 2. Actor Flows (CDSL) + +The flows below run inside the proxy request flow of `cpt-cf-oagw-seq-proxy-flow` (DESIGN §3.5) at the position ADR 0006 fixes, and they answer the three outcomes of `cpt-cf-oagw-usecase-rate-limit-exceeded` (PRD §8): handled per strategy, whether that strategy rejected, queued, or degraded the request. None of them registers a path; each is reached through the proxy handler `cpt-cf-oagw-feature-data-plane-proxy` registered. + +**Use cases**: `cpt-cf-oagw-usecase-rate-limit-exceeded` + +`cpt-cf-oagw-usecase-proxy-request` is `cpt-cf-oagw-feature-data-plane-proxy`'s and is not restated here; this feature is reached from it and adds no second statement of it. + +```mermaid +sequenceDiagram + participant C as Client + participant API as API Handler + participant DP as Data Plane + participant RL as Rate Limiting + participant US as Upstream Service + + C->>API: {METHOD} /oagw/v1/proxy/{alias}/{path_suffix} + API->>DP: execute_proxy(alias, path_suffix, query, req) + DP->>DP: resolve, match, authenticate + DP->>RL: check(resolved config, request context) + RL->>RL: fold effective limit, read breaker + RL->>RL: acquire cost tokens for the scope key + alt within the effective limit + RL-->>DP: admitted + DP->>US: outbound request + US-->>DP: response + DP->>RL: record attempt outcome + else over the limit + RL-->>DP: 429 RateLimitExceeded with X-RateLimit-* and Retry-After + else breaker open + RL-->>DP: 503 CircuitBreakerOpen + end + DP-->>API: ProxyResponse with X-OAGW-Error-Source + API-->>C: HTTP response +``` + +### Enforce the Rate Limit on a Proxy Request + +- [x] `p1` - **ID**: `cpt-cf-oagw-flow-rate-limit-check` + +**Actor**: `cpt-cf-oagw-actor-app-developer` + +This flow is invoked once per proxy request by `cpt-cf-oagw-flow-proxy-request` of `cpt-cf-oagw-feature-data-plane-proxy`, ahead of the composed chain at the position §1.5 records. It answers with an admission, with a gateway error, or with nothing at all when no limit is configured; it never answers with a passthrough, because it produces no upstream response. + +**Success Scenarios**: + +- A request whose counter can cover its `cost` is charged and the proxy path continues with no answer from this flow, which is the ordinary case and produces no header of its own. +- A burst is admitted up to `burst.capacity`: a caller that sends more than the sustained rate for a short period is admitted from stored tokens, which is the behaviour ADR 0003's Confirmation item 1 names as the first thing the tests verify. +- The same upstream under a `route` scope charges the matched route's counter and under a `tenant` scope charges the calling tenant's, so the scope selects a counter and never a limit. +- A request whose counter key falls back to the `tenant` scope under §1.4 is enforced against that tenant's counter, and the fallback is the same for every request that lacks the identifier. +- A configuration change that tightens the effective limit takes effect on the next request with no restart, no flush, and no action from this feature, because the limit is read from the resolution the proxy already performed and the bucket is keyed by the resolved identity. +- A request whose resolved configuration carries no `rate_limit` at any layer is enforced by nothing and charged to nothing (§1.5). + +**Error Scenarios**: + +- The counter cannot cover the `cost` and the configured strategy is `reject`: 429 with the `RateLimitExceeded` variant (`gts.cf.core.errors.err.v1~cf.oagw.rate_limit.exceeded.v1`), the header set of §3, and `X-OAGW-Error-Source: gateway`; nothing is forwarded. +- The counter cannot cover the `cost` and the strategy is `queue`: the request is held under `cpt-cf-oagw-flow-rate-limit-strategy` and no answer is produced yet; when the queue is at its bound, the same 429 answer is produced instead. +- The counter cannot cover the `cost` and the strategy is `degrade`: the request is admitted with the burst reserve withheld and forwarded, and no 429 is produced for it. +- The breaker for the resolved upstream is `open` or is in its `half_open` probe window with the probe already in flight: 503 with the `CircuitBreakerOpen` variant (`gts.cf.core.errors.err.v1~cf.oagw.circuit_breaker.open.v1`), produced before any charge is made and before the outbound attempt. +- The `cost` exceeds the effective `burst.capacity`: 429 on every attempt for as long as that configuration stands (§1.5). + +**Steps**: + +1. [x] - `p1` - Receive the check request from the proxy path carrying the resolved upstream and route, the matched route's identity, the calling tenant and subject, the peer address, and the request's `cost` - `inst-rlc-issue` +2. [x] - `p1` - **IF** `cpt-cf-oagw-algo-effective-limit-fold` returns the no-limit outcome over every layer the resolution produced, upstream, route, tenant, and the ancestor `enforce` families it carried - `inst-rlc-none-if` + 1. [x] - `p1` - **RETURN** admission with no charge, no counter, and no rate-limit header, so an unconfigured upstream is not silently limited by a default it never declared (§1.5), and a limit declared at any one layer is enforced rather than bypassed by a guard that looked at two - `inst-rlc-none-return` +3. [x] - `p1` - **ELSE** - `inst-rlc-none-else` + 1. [x] - `p1` - `cpt-cf-oagw-algo-effective-limit-fold` produces the effective limit and the effective `algorithm`, `scope`, `strategy`, and `cost` from the resolved layers - `inst-rlc-fold` + 2. [x] - `p1` - **IF** the breaker machine for the resolved upstream is not admitting - `inst-rlc-breaker-if` + 1. [x] - `p1` - **RETURN** 503 with the `CircuitBreakerOpen` variant and `X-OAGW-Error-Source: gateway`, before any charge and before the outbound attempt, carrying `retry_after_seconds` set to the seconds remaining of the open interval, which the foundation's error mapping emits as `Retry-After` for one of its six retriable rows - `inst-rlc-breaker-return` + 3. [x] - `p1` - **ELSE** - `inst-rlc-breaker-else` + 1. [x] - `p1` - Form the counter key under the structure ADR 0003's Redis key structure gives: the `{resource_type}:{resource_id}` prefix of the resource whose `rate_limit` the effective limit came from — the matched route when the effective limit is the route layer's, and the resolved upstream for every other layer — followed by the effective `scope`, its identifier, and the effective `sustained.window` (§1.5) - `inst-rlc-key` + 2. [x] - `p2` - **IF** the effective scope is `user` with no authenticated subject, or `ip` with no resolvable peer address - `inst-rlc-key-fallback-if` + 1. [x] - `p2` - Fall back to the `tenant` scope and its key rather than skip enforcement, so a counter the gateway cannot key never becomes a limit it does not apply (§1.4) - `inst-rlc-key-fallback` + 3. [x] - `p1` - Attempt the acquisition against the bucket or window the effective `algorithm` selects: `cpt-cf-oagw-algo-token-bucket` for the default `token_bucket`, `cpt-cf-oagw-algo-sliding-window` for `sliding_window` - `inst-rlc-acquire` + 4. [x] - `p1` - **IF** the acquisition is admitted - `inst-rlc-allow-if` + 1. [x] - `p1` - Charge the `cost` to the counter and hand the request back to the proxy path to be forwarded - `inst-rlc-allow` + 5. [x] - `p1` - **ELSE** - `inst-rlc-allow-else` + 1. [x] - `p1` - Hand the refusal to `cpt-cf-oagw-flow-rate-limit-strategy` with the counter state and the request's `cost` - `inst-rlc-over` +4. [x] - `p1` - **RETURN** the admission, the over-limit answer, or the breaker answer, and record the outcome for `cpt-cf-oagw-feature-observability` to report without emitting a metric of its own — the outcome being the admission verdict, the over-limit refusal, or the breaker answer, recorded in the request's execution context, which is the record that feature reads; this feature registers no sink and emits no metric of its own - `inst-rlc-return` + +### Apply the Configured Over-Limit Strategy + +- [x] `p1` - **ID**: `cpt-cf-oagw-flow-rate-limit-strategy` + +**Actor**: `cpt-cf-oagw-actor-app-developer` + +This flow runs only when `cpt-cf-oagw-flow-rate-limit-check` refused the acquisition. It is the CDSL statement of the three strategy outcomes PRD §8 lists for `cpt-cf-oagw-usecase-rate-limit-exceeded`, and it produces the only 429 answer in the gear. + +**Success Scenarios**: + +- The `reject` strategy answers 429 with the `RateLimitExceeded` variant, the header set of `cpt-cf-oagw-algo-rate-limit-headers`, and `X-OAGW-Error-Source: gateway`, and the request is never forwarded and never queued. +- The `queue` strategy holds the request within its bound and re-runs `cpt-cf-oagw-flow-rate-limit-check` when it is released; a released and admitted request proceeds exactly as an immediately admitted one would, with no marker that it waited. +- The `degrade` strategy admits the request against the allowance the burst reserve's withholding leaves — a capacity reduced to the sustained rate under `token_bucket`, and the unchanged window under `sliding_window` — charges its `cost`, and forwards it with no 429, no queueing, and no response transformation (§1.5). + +**Error Scenarios**: + +- The `queue` strategy is selected and its queue is already at its bound: the 429 answer of the `reject` strategy, with the same variant, the same header set, and the same error source, so a caller cannot tell a full queue from an exhausted bucket (§1.5). +- The `degrade` strategy is selected and the allowance the degraded posture leaves cannot cover the `cost`: the same 429 answer, because a strategy that admitted everything would be indistinguishable from no limit (§1.5). +- A request released from the queue is refused again: it is answered 429 and is not queued a second time, so the queue cannot become a retry loop and no request is held indefinitely. +- A queued request that passes the wait bound is answered 429 and charged nothing, which is the second bound §1.5 records. +- A client that disconnects while queued leaves the queue with no charge and no answer, and its slot returns to the bound. +- The runtime cannot suspend the inbound handler: the whole `queue` strategy degrades to the `reject` answer, and not per request (§1.4). + +**Steps**: + +1. [x] - `p1` - Read the effective `strategy` from the folded limit, which is `reject` when no layer declared one, that being the default ADR 0003's field table declares - `inst-rst-read` +2. [x] - `p1` - **IF** the strategy is `reject` - `inst-rst-reject-if` + 1. [x] - `p1` - `cpt-cf-oagw-algo-rate-limit-headers` builds the header set and the `retry_after_seconds` value from the counter state - `inst-rst-headers` + 2. [x] - `p1` - **RETURN** 429 with the `RateLimitExceeded` variant mapped through `cpt-cf-oagw-algo-error-mapping` of `cpt-cf-oagw-feature-gear-foundation`, carrying the header set and `X-OAGW-Error-Source: gateway` - `inst-rst-reject` +3. [x] - `p1` - **ELSE IF** the strategy is `queue` - `inst-rst-queue-if` + 1. [x] - `p1` - **IF** the queue for the counter key holds its bound - `inst-rst-queue-full-if` + 1. [x] - `p1` - Produce the 429 answer of step 2 with no enqueueing, so the bound is a property of the strategy and not a condition the caller can wait out - `inst-rst-queue-full` + 2. [x] - `p1` - **ELSE** - `inst-rst-queue-full-else` + 1. [x] - `p1` - Enqueue the request, release the queued requests in the order they arrived, and re-run `cpt-cf-oagw-flow-rate-limit-check` for each release - `inst-rst-queue-hold` + 2. [x] - `p1` - **IF** a queued request has waited past the wait bound - `inst-rst-queue-expire-if` + 1. [x] - `p1` - Answer it the 429 of step 2, dequeue it, and charge it nothing, so a request the queue cannot admit in time is refused rather than held - `inst-rst-queue-expire` + 3. [x] - `p1` - **IF** the client of a queued request disconnects before it is released - `inst-rst-queue-gone-if` + 1. [x] - `p1` - Dequeue it silently, charge it nothing, and produce no answer, so a queue slot is not spent on a caller that is no longer there - `inst-rst-queue-gone` + 4. [x] - `p1` - **IF** a released request is refused again - `inst-rst-queue-recheck-if` + 1. [x] - `p1` - Answer it 429 through step 2 and enqueue it no second time - `inst-rst-queue-recheck` +4. [x] - `p1` - **ELSE** - the strategy is `degrade`, which withholds the burst reserve the effective `algorithm` has (§1.5) - `inst-rst-degrade-if` + 1. [x] - `p1` - **IF** the allowance the degraded posture leaves covers the `cost` - the reduced capacity under `token_bucket`, and the unchanged window under `sliding_window` - `inst-rst-degrade-cover-if` + 1. [x] - `p1` - Charge the `cost` against that reduced capacity and hand the request back to the proxy path to be forwarded, producing no 429 and no response transformation (§1.5) - `inst-rst-degrade-admit` + 2. [x] - `p1` - **ELSE** - `inst-rst-degrade-cover-else` + 1. [x] - `p1` - Produce the 429 answer of step 2 - `inst-rst-degrade-refuse` +5. [x] - `p1` - **RETURN** the strategy's outcome - `inst-rst-return` + +### Release Rate-Limit State on a Configuration Deletion + +- [x] `p2` - **ID**: `cpt-cf-oagw-flow-rate-limit-cleanup` + +**Actor**: `cpt-cf-oagw-actor-platform-operator` + +This flow runs on the management write path of `cpt-cf-oagw-feature-control-plane-config` and not on the proxy path, so it is the one flow in this feature that no proxy request triggers. It delivers the in-memory half of the prefix-based cleanup ADR 0003 states for a deleted resource (§1.5). The prefixes the two drop steps below key on are the `{resource_type}:{resource_id}` prefixes ADR 0003's key structure puts at the head of every key, which is what makes a prefix drop well defined over the keys the check of `cpt-cf-oagw-flow-rate-limit-check` forms. + +**Success Scenarios**: + +- Deleting an upstream drops every bucket keyed under that upstream's prefix and the breaker machine held for it, so no state outlives the configuration that gave it meaning. +- Deleting a route drops every bucket keyed under that route's prefix and leaves the upstream's own buckets and the breaker machine untouched, because the route's counters are not the upstream's. +- The cleanup completes before the delete's response is produced, so no request can be charged against a bucket whose owner is already gone. + +**Error Scenarios**: + +- The notification does not reach the registry: the buckets and breaker entries of the deleted configuration remain resident and are never consulted again, which is a memory leak and not a correctness one (§1.4). +- A deletion of a configuration that holds no bucket drops nothing and reports no failure, because the cleanup is idempotent over an absent key set. +- A failed deletion notifies nothing: the database it failed against is unchanged, and so is the registry. + +**Steps**: + +1. [x] - `p2` - Receive the notification from the write path of `cpt-cf-oagw-feature-control-plane-config` that an upstream or a route deletion succeeded, in process and before the delete's response is produced - `inst-rcu-notify` +2. [x] - `p2` - **IF** the notification names an upstream - `inst-rcu-upstream-if` + 1. [x] - `p2` - Drop every entry whose key begins with that upstream's prefix, including the breaker machine held for it, and retain nothing - `inst-rcu-upstream-drop` +3. [x] - `p2` - **ELSE IF** the notification names a route - `inst-rcu-route-if` + 1. [x] - `p2` - Drop every entry whose key begins with that route's prefix and retain the upstream's own buckets and breaker machine - `inst-rcu-route-drop` +4. [x] - `p2` - **ELSE** - `inst-rcu-else` + 1. [x] - `p2` - Drop nothing, because a notification that names no resource is not a cleanup instruction - `inst-rcu-none` +5. [x] - `p2` - **RETURN** the number of entries dropped, which is a diagnostic value and not a condition any caller branches on - `inst-rcu-return` + +## 3. Processes / Business Logic (CDSL) + +The routines below are called by the flows in §2 and by the management write path named in §1.5. Only one of them leaves the process: `cpt-cf-oagw-algo-budget-allocate` is invoked by `cpt-cf-oagw-feature-control-plane-config`'s write path, which is the same in-process seam that path uses for its own cache flush. Every failure any of them returns is a `DomainError` from the foundation catalogue, mapped by `cpt-cf-oagw-algo-error-mapping` of that feature into an RFC 9457 body carrying `X-OAGW-Error-Source: gateway`; a failure of the registry itself has no catalogue row and is answered with the platform's RFC 9457 500 problem shape carrying `X-OAGW-Error-Source: gateway`, and **MUST** fail closed — a registry this feature cannot read is not a limit it can claim to enforce, and forwarding on an unreadable counter would enforce nothing. + +### Fold the Effective Limit + +- [x] `p1` - **ID**: `cpt-cf-oagw-algo-effective-limit-fold` + +**Input**: the resolved configuration `cpt-cf-oagw-algo-resolve-consume` of `cpt-cf-oagw-feature-data-plane-proxy` produced — the upstream-layer and route-layer `EffectiveRateLimit` values of `cpt-cf-oagw-feature-hierarchical-config`, the tenant-layer contributions, the ancestor `enforce` families the resolution carried, and the matched route's identity. + +**Output**: one effective sustained rate reported in one window, one effective `burst.capacity`, and one effective `algorithm`, `scope`, `strategy`, and `cost`; or the outcome that no limit is configured. + +This routine is the enforcement-time application of the rate-limit merge row of DESIGN §3.2 and of the canonical formula in its Shadowing Behavior paragraph. It applies a minimum and nothing else; the merge strategies, the sharing modes, and the ancestor walk are `cpt-cf-oagw-feature-hierarchical-config`'s (§1.5). + +**Steps**: + +1. [x] - `p1` - Collect every layer value the resolution produced that a `private` ancestor did not withhold, in the order upstream, then route, then tenant - `inst-fold-collect` +2. [x] - `p1` - Take the minimum of the visible sustained rates, which `cpt-cf-oagw-algo-field-family-merge` has already normalized to one scale and reported in the winning layer's window - `inst-fold-rate` +3. [x] - `p1` - Take the minimum of the visible `burst.capacity` values under the same mode gate, which is the merge ADR 0003's Example 1 performs beside the sustained one - `inst-fold-burst` +4. [x] - `p1` - Carry `algorithm`, `scope`, `strategy`, and `cost` from the last layer that declares each, in the upstream, then route, then tenant order of §1.5, and apply the declared default of ADR 0003's field table for any of the four that no layer declares - `inst-fold-members` +5. [x] - `p1` - **IF** no layer carries a `rate_limit` - `inst-fold-none-if` + 1. [x] - `p1` - **RETURN** the no-limit outcome, and let `cpt-cf-oagw-flow-rate-limit-check` enforce nothing - `inst-fold-none` +6. [x] - `p1` - **RETURN** the effective limit and its four carried members - `inst-fold-return` + +**Error handling**: a sustained rate or a capacity below 1 cannot occur, because the shipped schema sets a minimum of 1 on both and `cpt-cf-oagw-feature-control-plane-config` rejected anything else at write time; an `algorithm`, `scope`, or `strategy` outside its enum cannot occur for the same reason. A layer value that arrives unnormalized is a defect in the resolution and **MUST** fail closed as a registry failure rather than be compared on mixed scales, because a minimum over mixed units is not a limit. + +### Refill and Acquire from the Token Bucket + +- [x] `p1` - **ID**: `cpt-cf-oagw-algo-token-bucket` + +**Input**: the bucket held for the counter key under the effective scope, the effective sustained rate and its window, the effective `burst.capacity`, the request's `cost`, and a reading of the monotonic clock. + +**Output**: an admission verdict, the tokens remaining after it, and the delay until the `cost` becomes affordable. + +The refill rate is the sustained rate converted to tokens per second, and the capacity is the `burst.capacity`, which defaults to the `sustained.rate` when no layer declares one, that being the default ADR 0003's field table declares. + +**Steps**: + +1. [x] - `p1` - **IF** no bucket exists for the counter key - `inst-tb-init-if` + 1. [x] - `p1` - Initialize one at full capacity, so a first burst is admitted up to `burst.capacity`, which is the behaviour ADR 0003's Confirmation item 1 names - `inst-tb-init` +2. [x] - `p1` - Refill: add to the stored tokens the elapsed time since the bucket's last update multiplied by the refill rate, capped at the capacity, and stamp the update instant - `inst-tb-refill` +3. [x] - `p1` - Compare the refilled tokens against the request's `cost` - `inst-tb-compare` +4. [x] - `p1` - **IF** the tokens cover the `cost` - `inst-tb-allow-if` + 1. [x] - `p1` - Subtract the `cost`, report the admission and the tokens remaining - `inst-tb-allow` +5. [x] - `p1` - **ELSE** - `inst-tb-allow-else` + 1. [x] - `p1` - Report the refusal, the tokens remaining, and the delay as the shortfall against the `cost` divided by the refill rate, rounded up to a whole second - `inst-tb-refuse` + +**Error handling**: a refill rate of zero cannot occur, because `sustained.rate` is at least 1. A `cost` above the capacity can never be covered, and the refusal is permanent for that configuration, which §1.5 records and which `cpt-cf-oagw-flow-rate-limit-strategy` answers as any other refusal. A clock that moves backwards between two readings of the same bucket **MUST** be treated as no elapsed time at all rather than as a negative refill, so a clock adjustment cannot add tokens. + +### Count the Sliding Window + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-sliding-window` + +**Input**: the window held for the counter key under the effective scope, the effective sustained rate, the effective `sustained.window` converted to a length, the request's `cost`, and a reading of the monotonic clock. + +**Output**: an admission verdict, the charged total in the current window, and the delay until the total drops enough to admit the `cost`. + +This is the alternative `algorithm` value the shipped schema admits and the one ADR 0003 prefers where strict rate enforcement matters more than burst tolerance, its comparison table giving the sliding window "No boundary burst, accurate" against the token bucket's "Burst at window boundary". + +**Steps**: + +1. [x] - `p1` - Drop every charge recorded for the counter key whose instant falls outside the window length, which is the conversion of the `second`, `minute`, `hour`, and `day` literals the shipped schema enumerates - `inst-sw-expire` +2. [x] - `p1` - Sum the charges that remain - `inst-sw-sum` +3. [x] - `p1` - **IF** the sum plus the request's `cost` does not exceed the effective sustained rate - `inst-sw-allow-if` + 1. [x] - `p1` - Record the `cost` against the counter at the current instant and report the admission and the new total - `inst-sw-allow` +4. [x] - `p1` - **ELSE** - `inst-sw-allow-else` + 1. [x] - `p1` - Report the refusal, the current total, and the delay as the time until the oldest recorded charge ages out of the window enough to admit the `cost` - `inst-sw-refuse` + +**Error handling**: a refused request records no charge and therefore does not extend the window, so a caller that retries faster only ever sees the same answer and never a worse one. A `cost` above the sustained rate can never be admitted within one window, and the refusal is permanent for that configuration, which is the sliding-window counterpart of the deviation §1.5 records for the token bucket. + +### Allocate and Validate the Budget + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-budget-allocate` + +**Input**: a parent configuration's `budget` object — its mode, its `total`, and its `overcommit_ratio` — and the allocations the children declare, including the one being validated or charged. + +**Output**: an acceptance or a rejection of an `allocated` child, the remaining amount of a `shared` pool, or the no-tracking outcome of `unlimited`. + +The three modes and the arithmetic are ADR 0003's: `unlimited` tracks nothing, `allocated` gives each child a fixed slice of the parent's budget, and `shared` lets the children draw on the parent's total first-come-first-served with no individual guarantee. This routine is invoked at write time by the management write path (§1.5); its `shared`-mode charge is exercised through this routine's own contract and its colocated tests, and `cpt-cf-oagw-flow-rate-limit-check` does not invoke it, because the `shared` mode is unreachable from a written configuration (§1.5). + +**Steps**: + +1. [x] - `p1` - **IF** the mode is `unlimited` - `inst-bud-unlimited-if` + 1. [x] - `p1` - **RETURN** the no-tracking outcome, no validation performed, which is the mode ADR 0003 declares as the default for a leaf tenant - `inst-bud-unlimited` +2. [x] - `p1` - **ELSE IF** the mode is `allocated` - `inst-bud-allocated-if` + 1. [x] - `p1` - Sum the children's declared allocations together with the one under consideration - `inst-bud-sum` + 2. [x] - `p1` - **IF** the sum exceeds the parent's `total` multiplied by the `overcommit_ratio` - `inst-bud-over-if` + 1. [x] - `p1` - **RETURN** the rejection, which the write path answers 400 through the foundation's `ValidationError` variant (§1.5) - `inst-bud-over` + 3. [x] - `p1` - **ELSE** - `inst-bud-over-else` + 1. [x] - `p1` - **RETURN** the acceptance, with a warning recorded when the sum exceeds the parent's `total` but not the ratio's ceiling, which is the outcome ADR 0003's worked arithmetic shows for a ratio above 1.0 - `inst-bud-accept` +3. [x] - `p1` - **ELSE** - `inst-bud-shared-if` + 1. [x] - `p1` - Charge the request's `cost` against the parent's pool counter and report the amount remaining, with no per-child allocation validated, which is the first-come-first-served behaviour ADR 0003 states for the mode - `inst-bud-shared` + +**Error handling**: an `overcommit_ratio` below 1.0 cannot occur in a written configuration, because no written configuration can carry a `budget` member at all (§1.5); at the domain layer, where ADR 0003's field table sets `overcommit_ratio` a minimum of 1.0 and `total` a minimum of 1, a caller that passes a lower value is a caller error and **MUST** be answered with a rejection of its own rather than with the 400 validation answer a written configuration earns. A parent whose budget is absent while a child declares an allocation is a configuration the merged resolution would not have produced, and **MUST** be treated as `unlimited` rather than as a rejection, because a mode that tracks nothing cannot be exceeded. + +### Emit the Rate-Limit Response Headers + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-rate-limit-headers` + +**Input**: the effective limit, the counter state at the refusal, the request's `cost`, the gate the `response_headers` member sets, and a reading of the wall clock. + +**Output**: the header set of the 429 answer and the `retry_after_seconds` value the problem body carries, or an empty set. + +The four headers are the set ADR 0003's More Information section lists under RFC 6585 and the IETF rate-limit headers draft, and the extension member is the one DESIGN §3.3 names. + +**Steps**: + +1. [x] - `p2` - **IF** the `response_headers` gate is closed, its declared default being open (§1.5) - `inst-hdr-gate-if` + 1. [x] - `p2` - **RETURN** the empty set, and with it no `retry_after_seconds` member, so a deployment that withholds the headers withholds the guidance with them - `inst-hdr-gate` +2. [x] - `p2` - Set `X-RateLimit-Limit` to the effective sustained rate expressed per its window - `inst-hdr-limit` +3. [x] - `p2` - Set `X-RateLimit-Remaining` to the amount the counter still holds — the tokens left in the bucket under `token_bucket`, and the effective sustained rate minus the charged total in the current window under `sliding_window` - `inst-hdr-remaining` +4. [x] - `p2` - Set `X-RateLimit-Reset` to the epoch second at which the counter reaches its capacity, read from the wall clock — the instant the bucket is full again under `token_bucket`, and the instant the oldest charge ages out of the window under `sliding_window` - `inst-hdr-reset` +5. [x] - `p2` - Set `Retry-After` to the whole-second delay until the counter holds the request's `cost`, rounded up to at least 1, and set the problem body's `retry_after_seconds` member to the same number (§1.5) - `inst-hdr-retry` +6. [x] - `p2` - **RETURN** the header set and the value - `inst-hdr-return` + +**Error handling**: the header set is produced for a refusal and for nothing else; an admitted request, a degraded request, and a queued request that has not yet been answered produce no rate-limit header, because ADR 0003's Confirmation item 3 ties the set to the 429 response. A wall clock that is unavailable leaves `X-RateLimit-Reset` unset and the other three headers intact (§1.4), and `Retry-After` is unaffected by it because it is a relative value. Both per-algorithm readings are the two currencies the effective `algorithm` selects, and a header is computed once in the currency of the algorithm that produced the refusal. + +### Count Upstream Failures and Trip the Breaker + +- [x] `p1` - **ID**: `cpt-cf-oagw-algo-breaker-count` + +**Input**: the breaker machine held for the resolved upstream, the classification of one outbound attempt produced by `cpt-cf-oagw-algo-response-classify` of `cpt-cf-oagw-feature-data-plane-proxy`, and a reading of the monotonic clock. + +**Output**: the machine's next state, or no change. + +The breaker is one machine per resolved upstream, which is the granularity the `oagw_circuit_breaker_state` gauge of DESIGN §4.2 reports against the upstream alias. It counts only the three catalogue rows §1.5 enumerates and it clears on success (§1.5). + +**Steps**: + +1. [x] - `p1` - **IF** no classification is delivered for an attempt - `inst-brc-absent-if` + 1. [x] - `p1` - Change nothing and leave the machine in the state it already holds, so the breaker never opens on the absence of evidence (§1.5); a `half_open` machine that never receives its probe's outcome returns to `open` when the open interval elapses again, counted from the same stamp `inst-cb-probe` read, so one lost classification cannot hold the upstream at 503 until a restart - `inst-brc-absent` +2. [x] - `p1` - **ELSE IF** the attempt succeeded - `inst-brc-success-if` + 1. [x] - `p1` - Clear the upstream's failure count (§1.5) and, if the machine is `half_open`, return it to `closed` - `inst-brc-success` +3. [x] - `p1` - **ELSE IF** the attempt failed with one of the three rows §1.5 enumerates - `inst-brc-fail-if` + 1. [x] - `p1` - Append the failure to the upstream's rolling window and drop the entries older than the 30 seconds PRD §6.1 states - `inst-brc-record` + 2. [x] - `p1` - **IF** the machine is `closed` and the window holds 5 or more failures - `inst-brc-trip-if` + 1. [x] - `p1` - Move the machine to `open` and stamp the instant the open interval began, which is the trip of PRD §6.1's threshold - `inst-brc-trip` +4. [x] - `p1` - **ELSE** - `inst-brc-else` + 1. [x] - `p1` - Change nothing, because a 4xx answer, an upstream error status passed through as `DownstreamError`, and a 429 this feature produced are not evidence about the target's reachability (§1.5) - `inst-brc-ignore` +5. [x] - `p1` - **RETURN** the resulting state - `inst-brc-return` + +**Error handling**: an attempt whose machine was dropped by the cleanup of `cpt-cf-oagw-flow-rate-limit-cleanup` re-initializes at `closed`, which is the correct posture for a target whose configuration was rewritten. A failure recorded for an upstream that resolves to a different upstream after a re-resolution is recorded against the machine the request actually resolved to, so no count crosses an upstream boundary. + +## 4. States (CDSL) + +### Circuit Breaker State Machine + +- [x] `p1` - **ID**: `cpt-cf-oagw-state-circuit-breaker` + +This is the one state machine this feature owns and the one DECOMPOSITION §2.6 assigns it: the closed, open, and half-open machine that keeps an unhealthy upstream from cascading. Its two thresholds are the constants §1.5 records, and its configuration parameters are deferred per DESIGN §4.7(1), so nothing in this machine is configurable. `cpt-cf-oagw-feature-data-plane-proxy` records in its own §4 that this machine is the one on its path that belongs elsewhere. + +**States**: `closed`, `open`, `half_open` + +**Initial State**: `closed` + +The diagram renders the six transitions below; the prose remains the normative statement of each. + +```mermaid +stateDiagram-v2 + closed --> open : fifth failure in 30s + open --> open : further failure + open --> half_open : interval elapsed + half_open --> closed : probe succeeded + half_open --> open : probe failed + half_open --> open : probe outcome never delivered +``` + +**Transitions**: + +1. [x] - `p1` - **FROM** `closed` **TO** `open` **WHEN** the rolling 30-second window holds the fifth failed outbound attempt for the upstream, which is the trip threshold of `cpt-cf-oagw-nfr-high-availability` (§1.5) - `inst-cb-trip` +2. [x] - `p1` - **FROM** `open` **TO** `open` **WHEN** a further failure is recorded while the machine is open, so an accumulating failure count neither re-trips the machine nor extends the interval it is already serving - `inst-cb-stay-open` +3. [x] - `p1` - **FROM** `open` **TO** `half_open` **WHEN** the open interval elapses, that interval being the constant §1.5 records, and the machine then admits one probe and no more - `inst-cb-probe` +4. [x] - `p1` - **FROM** `half_open` **TO** `closed` **WHEN** the probe attempt succeeds, which also clears the failure count under `cpt-cf-oagw-algo-breaker-count` - `inst-cb-recover` +5. [x] - `p1` - **FROM** `half_open` **TO** `open` **WHEN** the probe attempt fails, which restarts the open interval from its beginning - `inst-cb-reopen` +6. [x] - `p1` - **FROM** `half_open` **TO** `open` **WHEN** the open interval elapses again from the same stamp with no probe outcome delivered, which is the fail-safe `cpt-cf-oagw-algo-breaker-count` applies and the reason a lost classification is a re-probe and not a permanent outage - `inst-cb-stall` + +**Invalid transitions**: + +- `closed` to `half_open` is invalid, because a probe exists to test a target the machine has already taken out of rotation, and a target that has never been tripped needs no probe. +- `open` to `closed` is invalid, because a machine must earn its way back through a successful probe and not through the passage of time alone; the transition that bypasses the probe would reopen a target on no evidence. +- `half_open` to `half_open` on a second concurrent request is invalid, and that request is answered 503 `CircuitBreakerOpen` rather than forwarded, which is the bound §1.5 records (§1.4). +- Any transition out of `closed` on a delivered outcome other than the three rows §1.5 enumerates is invalid, and the machine ignores that outcome rather than counting it. + +**What is stored**: for each resolved upstream, the current state, the failure outcomes of the rolling window each with the instant it was recorded at, the instant the current open interval began, and the identity of the in-flight probe while the machine is `half_open`, the probe itself being an outbound attempt bounded by the `proxy_timeout_secs` deadline the Data Plane applies to every upstream exchange, so it cannot run indefinitely. All of it is in-process, keyed under the upstream's prefix in the same registry as the buckets, dropped by the cleanup of `cpt-cf-oagw-flow-rate-limit-cleanup` when the upstream is deleted, and lost on a restart exactly as the buckets are (§1.5). + +## 5. Definitions of Done + +### Rate-Limit Check on the Proxy Path + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-rate-limit-check` + +The system **MUST** run `cpt-cf-oagw-flow-rate-limit-check` once per proxy request per admission attempt, the queue's releases being the re-runs §2 records, at the position §1.5 records ahead of the composed chain, and **MUST** enforce nothing only when the fold returns the no-limit outcome over every layer the resolution produced, and **MUST** enforce a limit declared at any single layer including the tenant layer and an ancestor `enforce` family. It **MUST** answer a breaker that is not admitting with 503 and the `CircuitBreakerOpen` variant before any charge and before the outbound attempt, **MUST** answer an over-limit request through `cpt-cf-oagw-flow-rate-limit-strategy`, and **MUST** fall back to the `tenant` scope when the configured scope's key cannot be formed (§1.4). It **MUST NOT** forward an over-limit request under the `reject` strategy, **MUST NOT** register any endpoint, and **MUST NOT** produce a rate-limit header for an admitted, degraded, or queued request. + +**Implements**: + +- `cpt-cf-oagw-flow-rate-limit-check` +- `cpt-cf-oagw-fr-rate-limiting` +- `cpt-cf-oagw-usecase-rate-limit-exceeded` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: none — rejections are returned on `{METHOD} /oagw/v1/proxy/{alias}[/{path_suffix}]`, the path `cpt-cf-oagw-feature-data-plane-proxy` registers +- DB: none +- DB Table: none +- Entities: `RateLimiterRegistry` + +### Token Bucket and Sliding Window Algorithms + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-rate-limit-algorithms` + +The system **MUST** enforce the token bucket of `cpt-cf-oagw-algo-token-bucket` as the default algorithm, initializing an absent bucket at full capacity, refilling it at the sustained rate converted to tokens per second, capping it at `burst.capacity`, and subtracting the request's `cost` on an admission, and **MUST** enforce the sliding window of `cpt-cf-oagw-algo-sliding-window` when the resolved configuration selects it. It **MUST** treat a clock that moves backwards as no elapsed time, **MUST** refuse a request whose `cost` exceeds the effective capacity on every attempt, and **MUST NOT** record a charge for a refused request. Both algorithms **MUST** take their rate, window, capacity, cost, and scope from the resolved configuration and from nothing else. + +**Implements**: + +- `cpt-cf-oagw-algo-token-bucket` +- `cpt-cf-oagw-algo-sliding-window` + +**Constraints**: none from DESIGN §2.2; the governing element is `cpt-cf-oagw-adr-rate-limiting`, whose algorithm comparison and dual-rate field table both algorithms implement. + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: `TokenBucket` + +### Hierarchical Enforcement and Budget Allocation + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-rate-limit-hierarchy` + +The system **MUST** fold the resolved layers into one effective limit through `cpt-cf-oagw-algo-effective-limit-fold`, taking the minimum of the visible sustained rates and the minimum of the visible `burst.capacity` values under the mode gate `cpt-cf-oagw-feature-hierarchical-config` applied, and **MUST** take `algorithm`, `scope`, `strategy`, and `cost` from the last layer that declares each in the upstream, then route, then tenant order (§1.5). It **MUST** apply the canonical formula of DESIGN §3.2's Shadowing Behavior with limits inherited across alias shadowing, **MUST** re-walk no chain and re-apply no per-field merge strategy, and **MUST** validate a child allocation against its parent's budget through `cpt-cf-oagw-algo-budget-allocate` under the three modes ADR 0003 declares, at the domain layer and in its colocated tests, since no written configuration can carry a `budget` member (§1.5), rejecting a sum above the parent's `total` multiplied by the `overcommit_ratio` and warning on a sum above the `total` alone. + +**Implements**: + +- `cpt-cf-oagw-algo-effective-limit-fold` +- `cpt-cf-oagw-algo-budget-allocate` +- `cpt-cf-oagw-fr-hierarchical-config` + +**Constraints**: none from DESIGN §2.2; the governing elements are the merge row of DESIGN §3.2 Hierarchical Configuration and the layer order of `cpt-cf-oagw-fr-config-layering`. + +**Touches**: + +- API: none +- DB: none — the validation is a routine the write path of `cpt-cf-oagw-feature-control-plane-config` calls +- DB Table: none +- Entities: `BudgetAllocation` + +### Rate-Limit Response Headers + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-rate-limit-headers` + +The system **MUST** emit `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, and `Retry-After` on a 429 answer under `cpt-cf-oagw-algo-rate-limit-headers`, with the values §1.5 records, and **MUST** set the problem body's `retry_after_seconds` extension member to the same number as `Retry-After`. It **MUST** gate the whole set on the `response_headers` member, whose declared default is open, and **MUST** omit `X-RateLimit-Reset` rather than synthesize it when no wall clock is available (§1.4). It **MUST NOT** emit the set on any answer other than a 429, and **MUST NOT** emit a `Retry-After` on any non-retriable catalogue row, that emission rule being `cpt-cf-oagw-feature-data-plane-proxy`'s. + +**Implements**: + +- `cpt-cf-oagw-algo-rate-limit-headers` +- `cpt-cf-oagw-flow-rate-limit-strategy` + +**Constraints**: none from DESIGN §2.2; the governing elements are the header set of `cpt-cf-oagw-adr-rate-limiting` and the gateway tagging of `cpt-cf-oagw-adr-error-source-distinction`. + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: none — the headers are produced from the counter state alone + +### Over-Limit Strategies + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-rate-limit-strategies` + +The system **MUST** deliver all three strategies the shipped schema enumerates: `reject` answering 429 with the `RateLimitExceeded` variant and `X-OAGW-Error-Source: gateway`, `queue` holding a request within a bounded in-process queue and re-running the check on release, and `degrade` admitting the request against the allowance the burst reserve's withholding leaves, which is the reduced capacity under `token_bucket` and the unchanged window under `sliding_window` (§1.5). It **MUST** default the strategy to `reject` when no layer declares one, **MUST** answer a request the bound queue cannot hold with the same 429 answer the `reject` strategy produces, **MUST** bound the queue in count and in wait so a request past either bound is answered 429 and charged nothing, **MUST** dequeue a disconnected client's queued request without a charge, **MUST** refuse to queue a released request a second time, and **MUST NOT** implement any backpressure strategy beyond the bound (§1.5). + +**Implements**: + +- `cpt-cf-oagw-flow-rate-limit-strategy` +- `cpt-cf-oagw-usecase-rate-limit-exceeded` + +**Constraints**: none from DESIGN §2.2; the governing element is `cpt-cf-oagw-adr-rate-limiting`'s strategy enum and the bounded-queue outcome PRD §8 states. + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: none — the queue is in-process state of the registry + +### Circuit Breaker + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-circuit-breaker` + +The system **MUST** run one `CircuitBreakerState` machine per resolved upstream through `cpt-cf-oagw-state-circuit-breaker` and `cpt-cf-oagw-algo-breaker-count`, starting at `closed`, tripping to `open` when the rolling 30-second window holds 5 failed attempts, moving to `half_open` when the open interval elapses, and returning to `closed` on a successful probe (§1.5). It **MUST** count only the three catalogue rows §1.5 enumerates, **MUST** clear the failure count on a successful attempt, **MUST** answer 503 with the `CircuitBreakerOpen` variant and `X-OAGW-Error-Source: gateway` while it is not admitting, **MUST** change nothing when no attempt outcome is delivered (§1.4), and **MUST NOT** expose a configurable threshold, an open-interval setting, or a fallback response, which DESIGN §4.7(1) defers. + +**Implements**: + +- `cpt-cf-oagw-state-circuit-breaker` +- `cpt-cf-oagw-algo-breaker-count` +- `cpt-cf-oagw-nfr-high-availability` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: none — the 503 is answered on the proxy path +- DB: none +- DB Table: none +- Entities: `CircuitBreakerState` + +### Per-Instance State, Cleanup, and Distributed Posture + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-rate-limit-state` + +The system **MUST** hold every bucket, counter, budget pool, and breaker machine in the per-instance in-process `RateLimiterRegistry` ADR 0006 assigns to the Data Plane, keyed under the upstream and route prefixes ADR 0003 gives for cleanup, and **MUST** persist none of it. Every key the registry holds **MUST** carry the `{resource_type}:{resource_id}` prefix ADR 0003's key structure gives, so two upstreams limited at the same `scope` never share a counter and a prefix drop has exactly one owner. It **MUST** run `cpt-cf-oagw-flow-rate-limit-cleanup` on the notification the write path of `cpt-cf-oagw-feature-control-plane-config` issues for a successful upstream or route deletion, dropping the deleted configuration's prefix and leaving every other tenant's and every sibling route's entries in place (§1.5). It **MUST** accept the burst window a restart opens and the per-instance accuracy a multi-instance deployment gets, **MUST** report the limitation rather than hide it, and **MUST NOT** add a Redis dependency, a sync path, or a periodic refresh. + +**Implements**: + +- `cpt-cf-oagw-flow-rate-limit-cleanup` +- `cpt-cf-oagw-nfr-high-availability` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: none +- DB: none — no counter is persisted, and the restart loss of PRD §12 is accepted (§1.5) +- DB Table: none +- Entities: `RateLimiterRegistry`, `TokenBucket`, `CircuitBreakerState` + +### Rate-Limit Entities and Layering + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-rate-limit-entities` + +The system **MUST** declare `TokenBucket`, `RateLimiterRegistry`, `BudgetAllocation`, and `CircuitBreakerState` once, in the domain layer, free of transport and persistence types (`cpt-cf-oagw-component-model`, `cpt-cf-oagw-design-layers`), and **MUST** reference `RateLimitConfig` from `cpt-cf-oagw-feature-gear-foundation`, `EffectiveRateLimit` from `cpt-cf-oagw-feature-hierarchical-config`, and `ProxyContext`, `ResolvedUpstream`, `MatchedRoute`, and `ProxyResponse` from `cpt-cf-oagw-feature-data-plane-proxy` rather than redeclare any of them. It **MUST** consume the foundation's `DomainError` catalogue for both of its answers and **MUST NOT** introduce a variant outside it, and a failure of the registry itself **MUST** be answered with the platform's RFC 9457 500 problem shape carrying `X-OAGW-Error-Source: gateway` and never with a `DomainError`. + +**Implements**: + +- `cpt-cf-oagw-algo-effective-limit-fold` +- `cpt-cf-oagw-state-circuit-breaker` + +**Constraints**: none from DESIGN §2.2; the governing element is `cpt-cf-oagw-design-domain-model`, whose `RateLimitConfig` members this feature gives their enforcement meaning. + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: `TokenBucket`, `RateLimiterRegistry`, `BudgetAllocation`, `CircuitBreakerState` + +### Latency Budget + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-rate-limit-latency` + +The system **MUST** keep the check inside the budget `cpt-cf-oagw-nfr-low-latency` allocates to the proxy path — less than 10 ms of overhead at p95 excluding the upstream response time — which is the only latency target any feature of this decomposition sets for this path and the statement `cpt-cf-oagw-feature-data-plane-proxy` already records. It **MUST** realize the driver DESIGN §1.2 names for the requirement, in-memory rate limiters, so the check is an in-process read and no counter, no budget pool, and no breaker state is reached over a network or through a lock the outbound path holds. ADR 0003's decision driver states the design intent for the check's own share as a sub-millisecond rate check, and this document records it as the ADR's intent and **MUST NOT** restate it as a second requirement threshold on the same path. + +**Implements**: + +- `cpt-cf-oagw-flow-rate-limit-check` +- `cpt-cf-oagw-nfr-low-latency` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: `RateLimiterRegistry` + +### Colocated Tests + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-rate-limit-tests` + +The system **MUST** deliver this feature's unit and integration tests colocated under `gears/system/oagw/oagw/tests/`, covering the token bucket's burst up to capacity and its refill, the sliding window's boundary behaviour, the five counter scopes and the `tenant` fallback, the hierarchical fold including the min-merged `burst.capacity` of ADR 0003's Example 1 and the inheritance across alias shadowing, the budget modes and the overcommit arithmetic of ADR 0003's worked example, the three strategies including the bound queue and the withheld burst reserve, the header set and the `Retry-After` derivation, the 10 ms p95 overhead target of `cpt-cf-oagw-dod-rate-limit-latency`, the breaker's trip, probe, recovery, and its refusal to open on absent evidence, the prefix cleanup of a deleted upstream and of a deleted route, the no-limit configuration, and the `cost` above capacity refusal, and **MUST NOT** add any test under `testing/e2e/gears/oagw/`. + +**Implements**: + +- `cpt-cf-oagw-dod-rate-limit-check` +- `cpt-cf-oagw-dod-rate-limit-algorithms` +- `cpt-cf-oagw-dod-rate-limit-hierarchy` +- `cpt-cf-oagw-dod-rate-limit-headers` +- `cpt-cf-oagw-dod-rate-limit-strategies` +- `cpt-cf-oagw-dod-circuit-breaker` +- `cpt-cf-oagw-dod-rate-limit-state` + +**Constraints**: none from DESIGN §2.2; this is the DECOMPOSITION §1.3(3) placement deviation recorded in §1.5. + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: none — tests only + +## 6. Acceptance Criteria + +- [x] A request within the effective limit is charged its `cost` and forwarded with no rate-limit header of any kind, and the same request over the limit under the `reject` strategy is answered 429 with `gts.cf.core.errors.err.v1~cf.oagw.rate_limit.exceeded.v1` and `X-OAGW-Error-Source: gateway`. +- [x] Neither the resolved upstream nor the matched route carrying a `rate_limit` means the request is enforced by nothing: no counter is charged, no bucket is created, and no rate-limit header is produced. +- [x] No path, method, or route is registered by this feature: the only request it answers arrives on `{METHOD} /oagw/v1/proxy/{alias}[/{path_suffix}]`, and the management endpoints that change the configuration it enforces belong to `cpt-cf-oagw-feature-control-plane-config`. +- [x] A caller that exceeds the sustained rate for a short period is admitted up to `burst.capacity` from stored tokens, and a bucket that has been idle refills to its capacity and no further. +- [x] The same upstream with `sustained.rate` of `10` per `second` and `burst.capacity` of `50` admits 50 immediate requests and then refuses, and admits again once the refill has restored the tokens the burst consumed. +- [x] With `algorithm: sliding_window`, a caller that exceeds the sustained rate is refused across the window boundary rather than admitted by it, and a refused request records no charge and so never extends the window against itself. +- [x] Both algorithms take their rate, window, capacity, cost, and scope from the resolved configuration alone, no `OagwConfig` key changes either algorithm's behaviour, and the `oagw.config` block of the graded configuration names no rate-limit key at all. +- [x] A clock that moves backwards between two readings of the same bucket adds no tokens and removes none, and a request evaluated after the adjustment sees the tokens the bucket already held. +- [x] A `cost` of `10` charged to a bucket of capacity `50` admits 5 requests and refuses the sixth, and a `cost` above the effective capacity is refused on every attempt for as long as that configuration stands. +- [x] A `scope` of `global` charges one counter for the whole gear, `tenant` one per calling tenant, `user` one per authenticated subject, `ip` one per peer address, and `route` one per matched route; and a request whose `user` or `ip` key cannot be formed is charged to the calling tenant's counter instead of going unenforced. +- [x] An ancestor `rate_limit` marked `enforce` at `10000` per `minute` with a descendant declaring `1000` per `minute` enforces `1000` per `minute`, and the same descendant declaring `20000` enforces `10000`; and the ancestor's `burst.capacity` of `1000` against a descendant's `100` enforces a capacity of `100`, which is the min-merge ADR 0003's Example 1 performs beside the sustained rate. +- [x] A descendant upstream that shadows an ancestor's alias is still limited by the ancestor's `enforce` rate, and a descendant whose ancestor marks the family `private` is limited by its own value alone. +- [x] The four members that carry no merge are taken from the last layer that declares them in the upstream, then route, then tenant order: a route `cost` of `10` overrides an upstream `cost` of `1`, a tenant `cost` overrides both, and a strategy declared only at the tenant layer is the strategy enforced. +- [x] The check runs ahead of the composed chain, so a rejected request executes no plugin at all and a forwarded request has already been charged, and the check re-reads the effective limit from the resolution on every request so a configuration change takes effect with no restart. +- [x] Under the `reject` strategy the 429 answer carries `X-RateLimit-Limit` with the effective sustained rate per its window, `X-RateLimit-Remaining` with the amount the counter holds, `X-RateLimit-Reset` with the epoch second the counter reaches capacity, and `Retry-After` with a whole-second value of at least 1, and the problem body's `retry_after_seconds` member carries the same number as `Retry-After`. +- [x] `Retry-After` on a 429 names the delay until the counter holds the request's `cost`: a bucket short by 5 tokens refilling at 10 per second answers `Retry-After` of 1, and one short by 5 refilling at 1 per second answers `Retry-After` of 5. +- [x] A configuration whose `response_headers` gate is closed produces a 429 with no `X-RateLimit-*` header and no `Retry-After`, and the `RateLimitExceeded` variant and the error-source tag are unchanged by the gate. +- [x] No response other than a 429 carries an `X-RateLimit-*` header: an admitted request, a degraded request, a queued request that is later admitted, and a 503 from the breaker all carry none. +- [x] When no wall clock is available the `X-RateLimit-Reset` header is omitted rather than synthesized, and the other three headers and the `Retry-After` value are unaffected by the omission. +- [x] A configuration that declares no `strategy` answers an over-limit request 429, which is the default ADR 0003's field table declares. +- [x] Under the `queue` strategy a request over the limit is held and re-checked rather than answered, a released request that is admitted is forwarded exactly as an immediately admitted one would be, and a released request that is refused again is answered 429 and never queued a second time. +- [x] A queue that has reached its bound answers the next over-limit request with the same 429 answer, the same header set, and the same error source that the `reject` strategy produces, and holds no request beyond its bound. +- [x] Under the `degrade` strategy a request over the limit is charged against the allowance the burst reserve's withholding leaves — the reduced capacity under `token_bucket` and the unchanged window under `sliding_window` — and is forwarded with no 429 and no change to the response body or its transfer mode, and a degraded request that allowance cannot cover is answered 429. +- [x] The breaker starts `closed`, opens when the fifth failed outbound attempt for one upstream falls inside a rolling 30-second window, and answers every request for that upstream 503 with `gts.cf.core.errors.err.v1~cf.oagw.circuit_breaker.open.v1` and `X-OAGW-Error-Source: gateway` while it is open. +- [x] Four failures inside the window followed by a successful attempt do not open the breaker, because the success clears the count; and a failure recorded outside the window does not contribute to a later trip. +- [x] A breaker failure is counted for a connection that never established within the deadline, an exchange that exceeded it, and an unavailable link, and is not counted for an upstream 4xx, an upstream error status passed through as `DownstreamError`, or a 429 this feature produced. +- [x] After the open interval elapses the breaker admits one probe and answers every concurrent request for the same upstream 503 `CircuitBreakerOpen` until the probe resolves; a successful probe closes the breaker and a failed one reopens it with the interval restarted; a probe whose outcome is never delivered returns the machine to `open` when the interval elapses again, and the upstream is re-probed rather than bricked. +- [x] A breaker that receives no outcome for an attempt changes nothing and stays closed, and a breaker whose upstream is deleted is dropped with that upstream's prefix and re-initializes at `closed` if the same alias is recreated. +- [x] The 503 `CircuitBreakerOpen` answer carries `Retry-After` naming the seconds remaining of the open interval, because `CircuitBreakerOpen` is one of the six retriable rows of the foundation catalogue and emits the header when it carries `retry_after_seconds`, and it carries no `X-RateLimit-*` header, because the breaker is not a rate limit. +- [x] No configuration key, no route-level setting, and no per-upstream setting changes the breaker's trip threshold, its open interval, or its probe bound, and a breaker that is closed produces no fallback response of its own: the caller receives the upstream's own answer or the gateway error the proxy path classifies. +- [x] Deleting an upstream through the management API drops every bucket and the breaker machine keyed under its prefix before the delete's response is produced, and deleting a route drops that route's buckets and leaves the upstream's own buckets and its breaker machine in place; every key dropped carries the `{resource_type}:{resource_id}` prefix ADR 0003's key structure gives, so two upstreams limited at the same `scope` never share a counter and a prefix drop has exactly one owner. +- [x] Exercised at the domain layer through `cpt-cf-oagw-algo-budget-allocate`'s colocated tests and not through a written configuration, which cannot carry a `budget` member (§1.5): a child allocation whose sum with its siblings exceeds the parent's `total` at an `overcommit_ratio` of 1.0 is rejected with a 400 validation answer, and the same sum at a ratio of 1.5 is accepted with a warning, which is the arithmetic ADR 0003's budget validation works through. +- [x] Exercised at the domain layer through `cpt-cf-oagw-algo-budget-allocate`'s colocated tests and not through a written configuration, which cannot carry a `budget` member (§1.5): a `budget` mode of `unlimited` performs no tracking and no validation, a mode of `shared` charges each request to the parent's pool with no per-child guarantee, and a parent with no `budget` at all is treated as `unlimited` rather than as a rejection. +- [x] A restart of the gear loses every bucket, counter, and breaker machine, and a cold bucket starts full, so the burst window the restart opens is bounded by the configured `burst.capacity` and closes as the bucket refills. +- [x] Two instances serving the same upstream each hold their own buckets, the aggregate admission across them can exceed the configured rate, and neither the documentation nor any response claims global accuracy for the counters. +- [x] The registry adds no Redis dependency, no periodic refresh, and no cross-instance sync, and a configuration write that is not a deletion invalidates no bucket, no budget pool, and no breaker machine. +- [x] Every rate-limit and breaker answer is a `DomainError` variant of the foundation catalogue, a failure of the registry itself is answered with the platform's RFC 9457 500 problem shape carrying `X-OAGW-Error-Source: gateway`, and no variant outside that catalogue is introduced. +- [x] The rate-limit check adds less than 10 ms of overhead at p95 to a proxy request excluding the upstream response time, every counter and breaker read is an in-process access, and no counter is reached over a network. +- [x] `TokenBucket`, `RateLimiterRegistry`, `BudgetAllocation`, and `CircuitBreakerState` are declared once in the domain layer, free of transport and persistence types, and `RateLimitConfig`, `EffectiveRateLimit`, `ProxyContext`, `ResolvedUpstream`, `MatchedRoute`, and `ProxyResponse` are referenced from their owning features rather than redeclared. +- [x] Every test for this feature lives under `gears/system/oagw/oagw/tests/`, passes there, and no test is added under `testing/e2e/gears/oagw/`. diff --git a/gears/system/oagw/docs/features/streaming.md b/gears/system/oagw/docs/features/streaming.md new file mode 100644 index 0000000..2333cf3 --- /dev/null +++ b/gears/system/oagw/docs/features/streaming.md @@ -0,0 +1,657 @@ +# Feature: Streaming + +- [ ] `p1` - **ID**: `cpt-cf-oagw-featstatus-streaming-implemented` + + +- [ ] `p2` - `cpt-cf-oagw-feature-streaming` + + + +- [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 Feature-Local Deviations from Shared Baselines](#15-feature-local-deviations-from-shared-baselines) + - [1.6 Explicit Non-Applicability](#16-explicit-non-applicability) +- [2. Actor Flows (CDSL)](#2-actor-flows-cdsl) + - [Transfer a Streaming Response Body](#transfer-a-streaming-response-body) + - [Proxy a WebSocket Upgrade and Tunnel](#proxy-a-websocket-upgrade-and-tunnel) +- [3. Processes / Business Logic (CDSL)](#3-processes--business-logic-cdsl) + - [Select the Transfer Mode](#select-the-transfer-mode) + - [Build the Upgrade Handshake and Judge its Answer](#build-the-upgrade-handshake-and-judge-its-answer) + - [Pump the Stream](#pump-the-stream) +- [4. States (CDSL)](#4-states-cdsl) + - [Stream Session Lifecycle State Machine](#stream-session-lifecycle-state-machine) +- [5. Definitions of Done](#5-definitions-of-done) + - [Server-Sent Event Forwarding](#server-sent-event-forwarding) + - [Stream Lifecycle and Teardown](#stream-lifecycle-and-teardown) + - [WebSocket Upgrade Proxying](#websocket-upgrade-proxying) + - [Stream Timeouts](#stream-timeouts) + - [Stream Error Mapping](#stream-error-mapping) + - [Stream Entities and Layering](#stream-entities-and-layering) + - [Colocated Tests](#colocated-tests) +- [6. Acceptance Criteria](#6-acceptance-criteria) + + + +## 1. Feature Context + +### 1.1 Overview + +This feature is the third policy tail of the `oagw` gear and the one that owns how a response body moves rather than what a request resolves to. It attaches to the proxy path `cpt-cf-oagw-feature-data-plane-proxy` owns and is invoked from `cpt-cf-oagw-flow-proxy-request` at exactly two points. The first is the upgrade detection at that path's header-transformation step: before `cpt-cf-oagw-algo-header-transform` builds the outbound header map, this feature decides whether the request is an upgrade request, and if it is, the strip of `Upgrade` and `Connection` is suspended so the handshake reaches the upstream. The second is the body transfer after `cpt-cf-oagw-algo-outbound-forward` has received the upstream's response headers: at that point this feature selects the transfer mode of the body and owns the transfer itself, reading one connection half and writing the other for as long as either carries bytes. + +Everything that precedes the body is not this feature's and is not relaxed for a streaming request. Resolution, route matching, the permission check, inbound and body validation, the rate-limit check, the composed plugin chain, and the configured header rules all run exactly as they run for a non-streaming request. An upgrade request bypasses nothing, which is the deliberate contrast with `cpt-cf-oagw-feature-cors`: that feature answers a preflight at handler level before the proxy path authenticates a caller, because the browser that sends a preflight sends no credentials, while an upgrade request is a full proxy request that carries a bearer token, consumes a rate-limit allowance, executes the chain, and is validated like any other. The feature registers no path of its own and no second registration of the proxy path; the only state it holds is the `StreamSession`, which lives exactly as long as the two connection halves it describes and is gone when they are. + +### 1.2 Purpose + +DECOMPOSITION §2.8 places this feature as the last of the three policy tails that hang off the proxy spine, and DECOMPOSITION §3 makes it a consumer of `cpt-cf-oagw-feature-data-plane-proxy` for the reason that entry states: it "changes how the proxy response body is transferred, not what is resolved". `cpt-cf-oagw-feature-data-plane-proxy` resolves, matches, forwards, and classifies, and its own §1.5 records that the transfer mode of the body is not its to decide; this feature is where that decision lives. Streaming APIs such as chat completions answer with server-sent events that must reach the caller as the upstream emits them, and bidirectional clients upgrade to WebSocket, which the proxy path cannot serve by stripping the handshake headers and buffering the answer. PRD §5.4 states the requirement, PRD §8 states the use case, and PRD §9 states the acceptance criterion — "SSE streaming proxies events with correct lifecycle handling". + +This feature delivers the DESIGN §3.2 Headers Transformation upgrade exception, which is the suspension of the `Upgrade` and `Connection` strip rule for a handshake, and the body-passthrough row of the §3.2 Transformation Rules subsection, whose reading this document fixes in §1.5. The stream lifecycle itself has no DESIGN §3.2 subsection, so the machine in §4 is stated here and nowhere else. DECOMPOSITION §2.8 records the same assignment: it lists `cpt-cf-oagw-component-model` as an umbrella reference precisely because no §3.2 subsection carries the lifecycle. + +Deliverables: + +- The transfer-mode selection for a response, taken from the request and the upstream's response headers, with `tunnel` and `incremental` as the only two modes and no third one that buffers. +- Server-sent event forwarding as events arrive, with no whole-body buffering, no frame parsing, no event rewriting, and no injected keepalive. +- The WebSocket upgrade handshake: the three-part detection, the suspension of two of the eight hop-by-hop headers, the 101 judgement, and the non-101 passthrough. +- The bidirectional byte tunnel that follows a 101, which frames nothing, interprets nothing, and applies the idle timer to the absence of traffic in either direction. +- The stream lifecycle machine and the two teardown directions: a client disconnect closes the upstream connection, and an upstream close closes the client connection. +- The idle timeout for a stalled stream and the error answers for a mid-flight termination, both mapped through the foundation's single RFC 9457 problem-body path. +- Colocated tests under `gears/system/oagw/oagw/tests/`. + +**Requirements**: + +- [ ] `p1` - `cpt-cf-oagw-fr-streaming` +- [ ] `p1` - `cpt-cf-oagw-usecase-sse-streaming` + +`cpt-cf-oagw-fr-streaming` is delivered in part by this feature, exactly as DECOMPOSITION §2.8 states: the HTTP request/response, SSE, and WebSocket clauses are in scope, and its WebTransport clause is not (DECOMPOSITION §1.3, item 4). The HTTP request/response clause is delivered by the incremental transfer mode, which every response body takes; the SSE clause by the flush-as-they-arrive discipline over the same mode; and the WebSocket clause by the handshake and the tunnel. The WebTransport clause is recorded as not delivered in §1.6 with the answer a caller receives. + +**Principles**: + +- `p1` - `cpt-cf-oagw-principle-error-source` +- `p1` - `cpt-cf-oagw-principle-no-cache` + +`cpt-cf-oagw-principle-no-cache` has a specific implementation here and not merely an absence: the gateway never buffers a complete response body before forwarding, which is why no cache surface exists on this path at all, and why the Data Plane L1 configuration cache that `cpt-cf-oagw-algo-dp-cache` owns, as a routine of `cpt-cf-oagw-feature-data-plane-proxy`, is forbidden from holding a response body by that feature's own Definition of Done. + +**Constraints**: + +- `p1` - `cpt-cf-oagw-constraint-toolkit-deploy` + +This constraint is a §1.5 superset beyond the empty constraint list DECOMPOSITION §2.8 records, added for the same reason the sibling policy tails add it: the single-executable deployment is what makes the in-process invocation seam and the byte tunnel the only mechanisms this feature has. + +**Design Components**: + +- `p1` - `cpt-cf-oagw-component-model` +- `p1` - `cpt-cf-oagw-tech-dependencies` + +`cpt-cf-oagw-component-model` stays an umbrella reference for the reason DECOMPOSITION §2.8 gives. `cpt-cf-oagw-tech-dependencies` is load-bearing here rather than decorative: the Rust and Axum row of that table names the async runtime, and the `pingora` row names the shared outbound client, and both are what the pump and the tunnel consume. The pump is a task over two connection halves on that runtime, and the incremental transfer mode is a body type the crate's ToolKit integration provides; neither is a mechanism this feature builds. + +**Domain Model Entities**: + +- `StreamSession` — one streaming exchange, carrying its two connection halves (the caller's and the upstream's), the transfer mode selected for it, the response `Content-Type` recorded for it, the lifecycle state it is in, the deadlines in force over it, and the outcome recorded when it ended. +- `UpgradeHandshake` — one upgrade exchange, carrying the outbound handshake request's suspended headers and the upstream's answer. + +Both are declared here and DECOMPOSITION §2.8 lists both under this entry: it names "`StreamSession`, stream lifecycle state, and the upgrade handshake result", and the third of those three, "the upgrade handshake result", is `UpgradeHandshake`. The lifecycle state is `cpt-cf-oagw-state-stream-lifecycle` and not a third type, so a session carries a state of the machine in §4 rather than a duplicated enum. Five types are consumed and not redeclared: `ResolvedUpstream`, `SelectedEndpoint`, `MatchedRoute`, and `ProxyResponse` from `cpt-cf-oagw-feature-data-plane-proxy`, and `ErrorContext` from `cpt-cf-oagw-feature-gear-foundation`, which is the single definition point for it. + +**Data**: + +- None. DECOMPOSITION §2.8 declares no table for this feature, and it creates, reads, and writes none. A `StreamSession` is an in-process description of two live connections and cannot outlive them; nothing about a stream is persisted, so a restart changes no answer this feature gives and ends every session it held. + +**API**: + +- `{METHOD} /oagw/v1/proxy/{alias}[/{path_suffix}]` with server-sent event responses forwarded as received +- GET /oagw/v1/proxy/{alias}[/{path_suffix}] with `Upgrade: websocket` for upgrade proxying + +Both lines are the two statements DECOMPOSITION §2.8 makes, and both are the proxy path `cpt-cf-oagw-feature-data-plane-proxy` registers taken under the conditions each line names. They are not second registrations: that feature's Definition of Done registers the handler for `{METHOD} /oagw/v1/proxy/{alias}[/{path_suffix}][?{query}]`, and this feature adds no handler, no route, and no path segment to it. A server-sent event response is reached by any method the matched route's allowlist admits, and the upgrade statement names the one method the three-part detection requires. + +### 1.3 Actors + +| Actor | Role in Feature | +|-------|-----------------| +| `cpt-cf-oagw-actor-app-developer` | Issues the proxy request that produces a streaming response, and the `GET` request that carries `Upgrade: websocket`, and receives either the bytes as the upstream emits them or the answer an upgrade that was not taken up produces. PRD §5.4 names this actor for `cpt-cf-oagw-fr-streaming` and PRD §8 names it the actor of `cpt-cf-oagw-usecase-sse-streaming`. | +| `cpt-cf-oagw-actor-upstream-service` | Emits the response headers whose content type selects the transfer mode, the event bytes that follow them, the 101 that completes a handshake, and the close that ends either half. It is the only actor this feature exchanges bytes with, and it sees the handshake headers it needs and no routing header, no hop-by-hop header other than the two the suspension preserves, and no credential other than the one the chain injected before the body moved. | + +Three actors participate indirectly and are named here so their absence from the table is a record and not a gap: + +- `cpt-cf-oagw-actor-cred-store` answers no call this feature makes. The credential material an upgrade request's outbound handshake carries was resolved and injected by the chain that `cpt-cf-oagw-algo-chain-execute` ran before the send, which is the same order a non-streaming request takes; by the time the pump holds the two halves, the material has been written into the request and the store is no longer in the path. DECOMPOSITION §1.5 lists the credential store under `cpt-cf-oagw-feature-plugin-system` alone. +- `cpt-cf-oagw-actor-types-registry` issues no call this feature answers. No request-time path registers or reads a type, and the error `type` identifiers this feature's answers carry were provisioned once at startup by `cpt-cf-oagw-feature-gear-foundation`. +- `cpt-cf-oagw-actor-platform-operator` and `cpt-cf-oagw-actor-tenant-admin` have no surface here: there is no streaming configuration key to write, because the idle timeout is a build-time constant of this feature and the transfer mode is selected from the request and the response rather than from configuration (§1.5). An operator tunes how long a stream may stall by rebuilding nothing and configuring nothing, because no supplied document provides the key and none is invented here. + +### 1.4 References + +- **PRD**: [PRD.md](../PRD.md) +- **Design**: [DESIGN.md](../DESIGN.md) +- **Dependencies**: `cpt-cf-oagw-feature-data-plane-proxy` — the proxy path both flows run on, the handler registration that receives both, the header transformation whose strip this feature suspends, the outbound client that opens the upstream half, the response classification that hands the body over, the `ResolvedUpstream`, `SelectedEndpoint`, `MatchedRoute`, and `ProxyResponse` types both consume, and the `proxy_timeout_secs` deadline whose reach this feature narrows (DECOMPOSITION §3). + +Supporting sources this feature stays consistent with: + +- [DESIGN.md](../DESIGN.md) §3.2 — the Headers Transformation subsection whose hop-by-hop table is tabulated `Inbound Header | Rule` and whose `Upgrade` and `Connection` rows are the two this feature suspends; the Guard Rules subsection whose method, query, and path rows are evaluated for an upgrade request exactly as for any other; the Transformation Rules subsection whose body row is the passthrough this feature implements; and the Security Considerations subsection whose HTTP version negotiation and HTTP/3 note bound what a tunnel can be carried over. +- [DESIGN.md](../DESIGN.md) §3.3 — the `StreamAborted`, `IdleTimeout`, `ConnectionTimeout`, `RequestTimeout`, `DownstreamError`, and `ProtocolError` rows of the error catalogue, with their statuses, GTS `type` identifiers, and Retriable cells, and the `retry_after_seconds` extension member this feature deliberately leaves unset. +- [ADR/0006-state-management.md](../ADR/0006-state-management.md) (`cpt-cf-oagw-adr-state-management`) — the three pieces of state ADR 0006 assigns the Data Plane, none of which is a stream session, and its rejection of the fully stateless option, and the shared outbound client that opens the upstream half of one. +- [ADR/0007-error-source-distinction.md](../ADR/0007-error-source-distinction.md) (`cpt-cf-oagw-adr-error-source-distinction`) — the `X-OAGW-Error-Source` header values, the problem-details rule for gateway errors, the passthrough rule for upstream answers, and the ADR's own confirmation item that the header works with streaming protocols. +- [schemas/upstream.v1.schema.json](../schemas/upstream.v1.schema.json) — the endpoint `scheme` enum of `https`, `wss`, `wt`, and `grpc`, of which the dial-time check admits `https` always and `http` exactly when `oagw.config.allow_http_upstream` is `true`, and never `wt` or `grpc`, and the `headers.request` and `headers.response` rule sets whose `passthrough` default of `none` interacts with the handshake headers (§1.5). +- [schemas/route.v1.schema.json](../schemas/route.v1.schema.json) — the `match.http.methods` enum of `GET`, `POST`, `PUT`, `DELETE`, and `PATCH`, which admits `GET` and therefore lets an upgrade request match a route. +- [config/e2e-local.yaml](../../../../../config/e2e-local.yaml) — the graded configuration. Its `oagw.config` block sets `proxy_timeout_secs: 2` and `allow_http_upstream: true` and sets neither token-cache key, and no streaming key exists in it to set. The graded consequence of the 2-second value is stated in §1.5 and in §6. + +**Run-level assumptions** — premises this feature relies on that come from the platform runtime rather than from PRD, DESIGN, the ADRs, or DECOMPOSITION. Each states what fails if the premise does not hold: + +- Assumption: the platform serves long-lived responses and upgrade tunnels, and exposes a response body type the handler can stream through, without an intermediary between the caller and this feature's handler buffering either. DESIGN §4.6 names "streaming support" among the HTTP client abstractions the platform must supply, and the `pingora` row of `cpt-cf-oagw-tech-dependencies` names the reverse-proxy engine that provides it. If the platform buffers a streamed body into a whole response before the handler sees it, or buffers an upgraded connection's frames, server-sent events arrive in one block at the end and the upgrade cannot complete at all; in the first case the stream is silently degraded and nothing in this feature can compensate, because the buffering happens below every routine of §3, and in the second case the three-part detection never reaches a 101 and the caller receives a plain error answer. +- Assumption: the upstream's response headers arrive before its body, so the transfer mode is selectable from the headers alone. This is the HTTP response framing contract and the reason `cpt-cf-oagw-algo-response-classify` can tag an answer and hand its body over before any body byte moves, which that feature's own step records as the order. If a transport delivered body bytes before the headers were complete, no mode selection could run ahead of them, and this feature **MUST** treat the exchange as failed and answer 502 with the `StreamAborted` variant rather than guess a mode, because a guessed mode is the difference between a tunnel and a body transfer and the two tear down differently. +- Assumption: the shipped route schema's `match.http.methods` enum admits `GET`, so an upgrade request can match a route and reach the header-transformation step at all. The enum admits five literals and `GET` is one of them, which is what distinguishes the upgrade request from the ordinary `OPTIONS` request `cpt-cf-oagw-feature-cors` records: that method is admitted by no route in the shipped schema, so an ordinary `OPTIONS` proxy request matches nothing and is answered 404, while a `GET` upgrade request matches a route that lists it and proceeds. If a deployment's route configuration omits `GET` from every allowlist, no upgrade request can be proxied by that deployment, and the answer is the ordinary 404 `RouteNotFound` rather than a streaming-specific one, because the failure is a match failure and not a transfer one. +- Assumption: the `OagwConfig` surface is closed at the five keys DECOMPOSITION §2.1 declares and `cpt-cf-oagw-feature-gear-foundation` owns, and names no streaming key. If the surface were widened by another feature, this feature would owe a second owner an answer about the idle deadline; since it is not, the idle timeout is a build-time constant of this feature and the behaviour §6 pins is that the deadline exists, that it is finite, and that no configuration input reaches it. +- Assumption: the platform delivers `Connection` and `Upgrade` to this feature's detection point intact on an upgrade request, with their values unmodified and un-normalized, because the detection reads the token list of the one and the protocol name of the other and because the suspension forwards them as the caller sent them. If the platform stripped or rewrote either header before the handler ran, the handshake could not be reconstructed, and this feature **MUST** answer the request as a plain request/response exchange under `cpt-cf-oagw-flow-stream-transfer` rather than emit a handshake request the upstream would answer 400, because a handshake this feature cannot complete honestly is not one it should begin. + +### 1.5 Feature-Local Deviations from Shared Baselines + +| Deviation | Rationale | Review owner | Validation performed | +|-----------|-----------|--------------|----------------------| +| This feature is invoked from `cpt-cf-oagw-flow-proxy-request` at two points — the upgrade detection at that flow's header-transformation step, and the body transfer after `cpt-cf-oagw-algo-outbound-forward` has received the upstream's response headers — and that flow records no step for either invocation. | `cpt-cf-oagw-algo-header-transform` states at its own strip step that "the upgrade-handshake exception that suspends two of them belongs to `cpt-cf-oagw-feature-streaming` and is not applied here", and `cpt-cf-oagw-algo-response-classify` states at its own stream step that the body is "hand[ed] to `cpt-cf-oagw-feature-streaming`, which owns how it is transferred", so both invocation points are named by the sibling's routines without its flow carrying a step for them. The sibling is a frozen input this run does not edit, and the invocation is the same in-process seam that path already uses for the rate-limit check and for the CORS enforcement, so the position is recorded here rather than added there. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The upgrade detection is three-part: the request method is `GET`, the `Upgrade` header names `websocket`, and the `Connection` header names the `upgrade` token, compared case-insensitively over a comma-separated token list; all three must hold for a request to be an upgrade request. | No supplied document states a detection rule. The three parts are the three things the handshake requires and the strip rule would otherwise destroy: DECOMPOSITION §2.8 names only the WebSocket upgrade as the upgrade this feature proxies, the WebSocket handshake's own request-side requirements fix the method and the two headers, and DESIGN §3.2's hop-by-hop table is the rule the suspension exists to escape. A request that carries `Upgrade: websocket` on a `POST` is not a WebSocket handshake, and one that names another protocol on a `GET` is not the upgrade DECOMPOSITION §2.8 delivers, so each negative is a request this feature hands back stripped rather than one it half-serves. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The strip suspension is one-directional: only the request direction suspends the strip of `Upgrade` and `Connection`, and no response-direction strip is suspended, because no response-direction strip exists. | DESIGN §3.2's hop-by-hop table is tabulated with the column headings `Inbound Header` and `Rule`, so every row it states is a rule over the request direction. The response direction carries only the configured `headers.response` `set`, `add`, and `remove` rules that `cpt-cf-oagw-feature-data-plane-proxy` applies, which appears nowhere in DESIGN and is that feature's to apply (its own §1.5). A 101 answer therefore needs nothing suspended on its way back: the headers it carries reach the caller through that configured rule set alone, and an operator who configures a removal of a handshake answer's header has configured it. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The six hop-by-hop headers of DESIGN §3.2's table other than `Upgrade` and `Connection` — `Keep-Alive`, `Proxy-Authenticate`, `Proxy-Authorization`, `TE`, `Trailer`, and `Transfer-Encoding` — stay stripped on an upgrade request. | DECOMPOSITION §2.8 suspends "the `Upgrade` and `Connection` strip rule", which is one row-pair of the eight the table states and the eight PRD §5.2 names for `cpt-cf-oagw-fr-header-transform`, and names no general suspension. `cpt-cf-oagw-algo-header-transform` strips all eight on a plain exchange and its own step defers only the exception this feature owns, so a suspension wider than the two would change a surface the sibling owns and would forward headers whose hop-by-hop meaning is unchanged by an upgrade. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| On a detected upgrade request the WebSocket handshake's own request headers — `Sec-WebSocket-Key`, `Sec-WebSocket-Version`, and any `Sec-WebSocket-Extensions` and `Sec-WebSocket-Protocol` the caller offered — are forwarded to the upstream regardless of the resolved `headers.request.passthrough` mode, including at that mode's shipped default of `none`. | DECOMPOSITION §2.8 states the suspension's purpose as "so the handshake headers reach the upstream and the 101 response can complete", and the shipped schema's `passthrough` default forwards no inbound header at all, so suspending the two hop-by-hop headers alone would deliver an outbound request carrying `Upgrade` and `Connection` but none of the fields a handshake is judged by. The upstream would refuse it, and the refusal would look like an unsupported upstream rather than like a configuration default. The four headers are the handshake's own fields and no other inbound header is admitted by this reading; the `headers.request` `set`, `add`, and `remove` rules and the `Host` or `:authority` replacement still apply to the handshake request exactly as to any other. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The single `proxy_timeout_secs` deadline is read here as bounding the wait for the upstream's response headers and nothing more once the transfer mode is streaming, and the two 504 catalogue rows split accordingly: `RequestTimeout` answers the header-arrival wait, `IdleTimeout` answers the body. | `cpt-cf-oagw-feature-data-plane-proxy` records in its own §1.5 that the one configured deadline is applied to both the connection-establishment phase and the request/response exchange phase of one outbound call, with `ConnectionTimeout` and `RequestTimeout` as their answers, and records in its own §1.6 that "the idle-timeout 504 of a stalled stream is that feature's answer, not `RequestTimeout`". Reading the exchange phase as covering the whole body would kill every long-lived response at `proxy_timeout_secs`, which is the opposite of what DECOMPOSITION §2.8 exists to prevent, and reading it as covering nothing would leave the header-arrival wait unbounded. The split above is the only one that keeps both sibling statements true, and the third 504 row of the catalogue is exactly the one that answers the body. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The idle timeout is a build-time constant of 60 seconds with no configuration surface and no sourced value. | The `OagwConfig` surface closes at the five keys `proxy_timeout_secs`, `allow_http_upstream`, `ssrf_policy`, `token_cache_ttl_secs`, and `token_cache_capacity`, which `cpt-cf-oagw-feature-gear-foundation` owns and which name no idle deadline; no supplied document states an idle value; and DESIGN §3.3 tabulates the `IdleTimeout` row without a threshold. A stalled stream must be answered rather than held open indefinitely, so the value cannot be left undefined, and stating a number here is the same class of recorded constant as the ones the sibling policy tails record for their own unsourced values. 60 seconds is long enough to keep a healthy event stream open across the gap an upstream is expected to emit into, and short enough to answer a genuinely stalled one before a caller gives up on its own. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The graded consequence of the split above is stated: with `proxy_timeout_secs: 2`, the wait for the upstream's response headers is bounded at 2 seconds, and a stream that emits its first bytes within it outlives that deadline for as long as it keeps emitting. | `config/e2e-local.yaml` sets `proxy_timeout_secs: 2` in its `oagw.config` block, which is the value `cpt-cf-oagw-algo-outbound-forward` applies to both phases it bounds. The 2-second value is a configuration choice the graded deployment made for the request/response path, and under the §1.5 split it does not bound a stream's body, which is why a long-lived event stream is servable in that deployment at all. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| The body row of DESIGN §3.2's Transformation Rules table — inbound `Body` to outbound `Body`, rule "Passthrough by default; plugin mutable" — is read as "every response body is forwarded as received", so the incremental transfer mode is the answer for every response body `cpt-cf-oagw-algo-response-classify` hands over, and no such body is ever buffered in full before its first byte is forwarded. | DESIGN §3.2 states the row as a passthrough and states no buffering step, and `cpt-cf-oagw-principle-no-cache` forbids holding a response. A buffered mode would make the gateway a cache of one response per request and would make every server-sent event arrive after the upstream finished, which is the failure the feature exists to prevent. The row's "plugin mutable" clause stays with the request direction, where `cpt-cf-oagw-algo-chain-execute` runs the transform phase before the send; no response-phase body mutation is delivered here, because a body that is being forwarded as it arrives cannot be transformed whole. The boundary of that reading is the sibling's own step: a response that routine does not tag as a stream it assembles into its `ProxyResponse` and returns itself, so the reading claims the bodies handed over and not the answers the sibling keeps. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| A mid-flight termination is answered 502 with the `StreamAborted` variant whichever side caused it, and the `DownstreamError` variant is not used by this feature at all. | DECOMPOSITION §2.8 fixes the case and the row: "502 `StreamAborted` with `X-OAGW-Error-Source: gateway` when a stream is terminated mid-flight". The catalogue describes `DownstreamError` as an "Upstream service error", which is an answer about a response the gateway received and not about a transfer it was performing, and the distinction matters because the two rows carry different Retriable cells and different GTS `type` identifiers. Attributing the termination to one side would also require the gateway to decide something it cannot observe, namely which peer was at fault for a socket that closed under it. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| Neither of the two answers this feature produces carries `Retry-After`, so `IdleTimeout` is retriable in the catalogue and is nevertheless answered without the header, and `StreamAborted` is non-retriable and is answered without it because the mapping emits the header only for retriable rows. | The convention `cpt-cf-oagw-algo-error-mapping` of `cpt-cf-oagw-feature-gear-foundation` performs is to emit `Retry-After` only for the catalogue rows DESIGN §3.3 marks retriable and only when the variant carries `retry_after_seconds`. `IdleTimeout` is in the retriable-and-carrying set, so its missing header is a decision of this feature and not a property of the mapping, because the variant carries no `retry_after_seconds` for the gateway to emit; `StreamAborted` is outside that set, so `cpt-cf-oagw-algo-error-mapping` would omit the header for it in any case, and the absence there is a property of the mapping rather than a decision. For the row the decision does cover, the gateway has no interval to state for a stream that died: it does not know when the upstream will emit again and it does not re-issue requests per `cpt-cf-oagw-principle-no-retry`. A caller that retries a stalled stream does so on its own schedule, which is what that principle assigns to it. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| DECOMPOSITION §2.8's "upstream close closes the client connection and logs the event" is satisfied through the outcome recorded in the request's execution context, which `cpt-cf-oagw-feature-observability` reports, and this feature emits no log line, no metric, and no span of its own. | The audit record and the metric surface are that feature's per DESIGN §4.2 and §4.3, and DECOMPOSITION §3 makes it a consumer of this path precisely to read the request lifecycle those records describe. An outcome written into the execution context is the same record every sibling policy tail contributes — `cpt-cf-oagw-feature-cors` records the same posture for its two 403 answers — and a second emission path would report one exchange twice with two owners of its fields. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| This feature's tests are colocated at `gears/system/oagw/oagw/tests/` instead of `testing/e2e/gears/oagw/`. | DECOMPOSITION §1.3(3) reserves `testing/e2e/gears/oagw/` for the acceptance suite; every unit and integration test this decomposition produces lives with the crate. This is the same deviation `cpt-cf-oagw-feature-gear-foundation`, `cpt-cf-oagw-feature-control-plane-config`, `cpt-cf-oagw-feature-hierarchical-config`, `cpt-cf-oagw-feature-plugin-system`, `cpt-cf-oagw-feature-data-plane-proxy`, `cpt-cf-oagw-feature-rate-limiting`, and `cpt-cf-oagw-feature-cors` record in their own §1.5 tables, restated here because the tests it governs include this feature's. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| This feature's §1.2 Constraints list carries `cpt-cf-oagw-constraint-toolkit-deploy` beyond the empty constraint list DECOMPOSITION §2.8 records. | The single-executable deployment of DECOMPOSITION §1.4 is what makes the in-process invocation seam and the byte tunnel the only mechanisms this feature has: both invocation points are function calls in one address space, and no queue, no broker, and no second process sits between the proxy path and the pump. The sibling policy tails add the same constraint for the same reason, and every sibling feature document mirrors its baseline list except where it records the superset, so the addition is recorded rather than silently carried. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| This document applies `cpt-cf-oagw-principle-no-retry` in its prose and cites `cpt-cf-oagw-adr-error-source-distinction` in §5, neither of which is on the §1.2 lists DECOMPOSITION §2.8 maps to this entry. | DECOMPOSITION §2.8 maps `cpt-cf-oagw-principle-error-source` and `cpt-cf-oagw-principle-no-cache` only, and both are on the §1.2 list; the two additions are applied rather than listed because they govern behaviour this feature cannot opt out of — a stream that has been consumed cannot be consumed again, and the error-source distinction decides which of its two answers carries the gateway tag. The owner of the retry posture on the proxy path is `cpt-cf-oagw-feature-data-plane-proxy`, whose own flow performs the send, so the application here records where the principle bites rather than a second owner of it. Every sibling feature document mirrors its baseline list except where it records the superset. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | +| This document's status identifier carries the `-implemented` suffix, reading `cpt-cf-oagw-featstatus-streaming-implemented` where the FEATURE template fixes the same identifier without that suffix, and its backreference to the DECOMPOSITION entry is left unchecked where that template fixes a checked one. | All eight FEATURE documents this run has authored so far, this one included, carry the same two forms, so the departure is a run-wide convention and not a defect of this document alone: the suffix names the status value the identifier reports rather than a second identifier, and the backreference is a traceability pointer whose state the implementation phase owns. The departure is therefore a stated convention rather than a silent one. | The controller of this run. | Accepted by the semantic review of this run; `cfs validate --artifact` clean. | + +### 1.6 Explicit Non-Applicability + +The areas below apply to the gear as a whole but not to this feature. Each is stated here so the omission is a recorded decision rather than a silent gap, and each names the feature that does own it. + +- **WebTransport, the `wt` clause of `cpt-cf-oagw-fr-streaming`.** It is the one clause of that requirement this feature does not deliver, per the scope reduction DECOMPOSITION §1.3(4) records and DECOMPOSITION §2.8 repeats. `cpt-cf-oagw-feature-data-plane-proxy` owns the answer a caller receives: it records in its own §1.5 that a proxy attempt against a `wt`-scheme upstream is answered 502 through the `ProtocolError` variant with `X-OAGW-Error-Source: gateway`, and its dial-time scheme check returns that refusal inside the send `cpt-cf-oagw-flow-upgrade-proxy` performs, so the handshake `cpt-cf-oagw-algo-upgrade-handshake` built is never taken up, the session that routine opened in `Opening` moves to `Closed`, and no tunnel is carried. The `wt` literal remains a legal write-time admission in the shipped schema, which is the same divergence that feature and `cpt-cf-oagw-feature-gear-foundation` already record. +- **gRPC streaming and HTTP/3.** Both are out of scope per DECOMPOSITION §2.8. A gRPC upstream produces no matching route and is answered with the ordinary 404 `RouteNotFound`, which `cpt-cf-oagw-feature-data-plane-proxy` records, and no `match.http.methods` allowlist can produce a gRPC match because the shipped route schema's gRPC match keys are never evaluated here. HTTP/3 (QUIC) is future work per DESIGN §3.2 Security Considerations and §4.5, so no tunnel is carried over it and no event stream is negotiated onto it. +- **Response header transformation and the `headers.response` rules.** `cpt-cf-oagw-feature-data-plane-proxy` owns both, and it applies them before the body is handed over: its own §1.5 records that the outbound response header rules are its act and the transfer mode of the body is this feature's. `cpt-cf-oagw-algo-response-classify` applies the `set`, `add`, and `remove` rules and tags the answer before `cpt-cf-oagw-algo-stream-mode-select` runs, so the response the caller receives during a streamed transfer already carries the configured mutation and the error-source tag, and this feature mutates neither. +- **Body validation, the query allowlist, and the guard rules.** `cpt-cf-oagw-feature-data-plane-proxy` owns all three, and an upgrade request is subject to all three like any other: it carries no body, so `cpt-cf-oagw-algo-body-validate` has nothing to reject, which is the only case in which that routine is reached with no body at all, and its query parameters are judged against the matched route's `match.http.query_allowlist` and its method against the route's allowlist before the handshake is built. A guard that rejects an upgrade request answers 400 in the request phase exactly as ADR 0009's request-phase status states, and the handshake is never built for a request the chain refused. +- **Resolution, matching, the permission check, the rate-limit check, and the composed chain.** `cpt-cf-oagw-feature-data-plane-proxy` owns the first four and `cpt-cf-oagw-feature-rate-limiting` owns the check inside them; both flows in §2 are reached from `cpt-cf-oagw-flow-proxy-request` and restate no step of it. An upgrade request consumes exactly one rate-limit allowance at that feature's check, and the stream's own duration consumes no further tokens and refunds none, which is the posture `cpt-cf-oagw-feature-rate-limiting` records for a request that upgrades to a stream. +- **Metrics, audit logs, correlation, tracing, health, and diagnostics.** `cpt-cf-oagw-feature-observability` owns the Prometheus surface, the structured audit record, and the correlation identifier, and `cpt-cf-oagw-feature-gear-foundation` owns the health surface and the `trace_id` an error body carries. This feature emits no series, writes no audit line, opens no span, and assigns no correlation identifier; what it supplies is the outcome recorded in the request's execution context, which is the record those surfaces report (§1.5). +- **Persistence.** DECOMPOSITION §2.8 declares no table for this feature, and `cpt-cf-oagw-db-schema` is fully claimed by `cpt-cf-oagw-feature-control-plane-config` and `cpt-cf-oagw-feature-plugin-system`. Nothing this feature computes outlives the two connections it describes, and the lifecycle state of §4 is held in the `StreamSession` and nowhere else. +- **Latency targets.** The proxy path's budget is `cpt-cf-oagw-nfr-low-latency`'s, whose threshold is less than 10 ms of added latency at p95 excluding the upstream response time, and `cpt-cf-oagw-feature-data-plane-proxy` carries the Definition of Done that consumes it. This feature states no target of its own, and the cost it adds to the path is per-byte forwarding rather than a per-request computation: the work it does is proportional to the number of bytes the upstream emits and not to the number of decisions the path makes, so the omission is recorded here rather than left silent. +- **Data protection.** No personal-adjacent datum reaches this feature beyond the request the proxy path already carries, and nothing is persisted, logged, or echoed here. The pump moves bytes and inspects none of them, so it cannot disclose a body it does not read; the handshake carries the caller's handshake headers and no credential beyond the one the chain injected; and neither problem `detail` this feature produces names a caller, a path it was not given, or a body it moved. +- **Rollout, rollback, versioning, localization, accessibility, and compliance.** The gear is one configuration item and one release unit (DECOMPOSITION §1.4), so this feature ships no rollout or rollback of its own and there is one configuration item to roll back. Every identifier it reads and every `type` it writes is fixed at `.v1`, so there is no version negotiation and no predecessor to migrate from. The two problem bodies it produces are English protocol strings from the foundation's mapping, the headers it forwards and suspends are protocol values an accessibility requirement does not reach, and there is no rendered actor-facing surface here to make accessible. Compliance has no surface here to assess, because the feature persists nothing, emits no record of its own, and produces only the two problem bodies the foundation's mapping owns. +- **Workarounds, deprecation, and migration.** None applies. The two limitations §1.5 records — the 60-second constant with no configuration surface, and the three-part detection with no configurable widening — have no workaround short of a code change or a schema revision, both of which are outside this run's authority, and every identifier this feature reads is fixed at `.v1`. + +## 2. Actor Flows (CDSL) + +The two flows below run on the proxy path `cpt-cf-oagw-flow-proxy-request` of `cpt-cf-oagw-feature-data-plane-proxy` implements, and neither registers a path of its own. The first is reached after that flow's outbound call has received the upstream's response headers, at the position `cpt-cf-oagw-algo-response-classify` names when it hands a stream body over. The second is reached at that flow's header-transformation step, at the position `cpt-cf-oagw-algo-header-transform` names when it defers the upgrade-handshake exception. Both are in-process invocations from that flow, and §1.5 records that its steps name neither. + +**Use cases**: `cpt-cf-oagw-usecase-sse-streaming` + +`cpt-cf-oagw-usecase-proxy-request` is `cpt-cf-oagw-feature-data-plane-proxy`'s and is not restated here; this feature is reached from it and adds no second statement of it. The SSE use case is reached through the endpoint that feature registers, which is why DECOMPOSITION §3 makes this feature a consumer of that one rather than the reverse. + +```mermaid +sequenceDiagram + participant C as Caller + participant API as API Handler + participant DP as Data Plane + participant ST as Streaming + participant US as Upstream Service + + C->>API: GET /oagw/v1/proxy/{alias}/{path_suffix} with Upgrade: websocket + API->>DP: execute_proxy(alias, path_suffix, query, req) + DP->>DP: authorize, resolve, match, validate, rate-limit, chain + DP->>ST: upgrade detection before the header map is built + ST->>ST: suspend the strip of Upgrade and Connection + DP->>US: outbound handshake request + alt upstream answers 101 + US-->>ST: 101 Switching Protocols + ST->>ST: lifecycle Opening to Open + loop both directions + C->>ST: bytes as received + ST->>US: bytes as received + US->>ST: bytes as received + ST->>C: bytes as received + end + else upstream answers anything other than 101 + US-->>DP: that answer + DP-->>API: passthrough under X-OAGW-Error-Source + end + C->>API: {METHOD} /oagw/v1/proxy/{alias}/{path_suffix} on an SSE endpoint + API->>DP: execute_proxy(alias, path_suffix, query, req) + DP->>DP: authorize, resolve, match, validate, rate-limit, chain + DP->>US: outbound request + US-->>DP: response headers with Content-Type: text/event-stream + DP->>ST: mode selection after the headers arrive + ST-->>DP: mode incremental + loop as events arrive + US->>ST: event bytes + ST->>ST: flush on arrival, reset the idle timer + ST->>C: event bytes + end + alt caller disconnects + ST->>US: close the upstream half + else upstream closes + ST->>C: close the caller half + end + DP-->>API: outcome recorded in the execution context + API-->>C: HTTP response +``` + +### Transfer a Streaming Response Body + +- [x] `p1` - **ID**: `cpt-cf-oagw-flow-stream-transfer` + +**Actor**: `cpt-cf-oagw-actor-app-developer` + +This flow is invoked once per proxy response whose body is not a completed tunnel, by `cpt-cf-oagw-flow-proxy-request` of `cpt-cf-oagw-feature-data-plane-proxy`, after `cpt-cf-oagw-algo-outbound-forward` has received the upstream's response headers and `cpt-cf-oagw-algo-response-classify` has tagged the answer. It owns the transfer of the body and nothing that produced it: by the time it runs, the request has been resolved, matched, authorized, validated, charged, chained, and transformed, and the answer has been classified and tagged. + +**Success Scenarios**: + +- A response whose `Content-Type` is `text/event-stream` is forwarded as events arrive, and each event's bytes reach the caller in the order and at the cadence the upstream emitted them, with no whole-body buffering and no frame parsing. +- Every other response body that `cpt-cf-oagw-algo-response-classify` hands over is also forwarded as received, without buffering the whole body, which is the reading of the body-passthrough row §1.5 records. +- The caller disconnects: the upstream half is closed, the outcome is recorded in the request's execution context, and the lifecycle reaches `Closed` through `Closing`. +- The upstream closes its half: the bytes already read are written to the caller, the caller's half is closed, and the same recording and lifecycle apply. +- The upstream's response headers arrive within `proxy_timeout_secs` and the first body bytes follow: the stream outlives that deadline, which in the graded configuration is 2 seconds, for as long as it keeps emitting. + +**Error Scenarios**: + +- The upstream's response headers do not arrive within `proxy_timeout_secs`: 504 with the `RequestTimeout` variant, answered by `cpt-cf-oagw-algo-outbound-forward` before this flow is invoked, and no `StreamSession` is opened. +- No byte arrives in either direction for 60 seconds: 504 with the `IdleTimeout` variant (`gts.cf.core.errors.err.v1~cf.oagw.timeout.idle.v1`), `X-OAGW-Error-Source: gateway`, and no `Retry-After` (§1.5). +- The transfer terminates mid-flight on either side while bytes are still expected: 502 with the `StreamAborted` variant (`gts.cf.core.errors.err.v1~cf.oagw.stream.aborted.v1`) and `X-OAGW-Error-Source: gateway`, whichever side caused it (§1.5). +- The response is an upgrade that the upstream took up: this flow is not invoked for it, because the tunnel is `cpt-cf-oagw-flow-upgrade-proxy`'s and the body transfer is not a body transfer at all. + +**Steps**: + +1. [x] - `p1` - Actor issues the proxy request carrying the method, the alias, an optional path suffix, and the headers the matched route admits, expecting a response whose body arrives over time - `inst-st-issue` +2. [x] - `p1` - API: `{METHOD} /oagw/v1/proxy/{alias}[/{path_suffix}][?{query}]` — resolved, matched, authorized, validated, charged, chained, and forwarded by `cpt-cf-oagw-flow-proxy-request` exactly as for a non-streaming request, with no streaming-specific relaxation anywhere before the send - `inst-st-api` +3. [x] - `p1` - `cpt-cf-oagw-algo-outbound-forward` receives the upstream's response headers, which is the boundary the `RequestTimeout` deadline bounds and the last moment at which the exchange can still be answered as a whole - `inst-st-headers` +4. [x] - `p1` - `cpt-cf-oagw-algo-response-classify` tags the answer, applies `headers.response`, and hands the body over, and `cpt-cf-oagw-algo-stream-mode-select` selects the transfer mode from the request and those headers - `inst-st-mode` +5. [x] - `p1` - **IF** the selected mode is `tunnel` - `inst-st-tunnel-if` + 1. [x] - `p1` - **RETURN** nothing for this flow to transfer, because the exchange is `cpt-cf-oagw-flow-upgrade-proxy`'s and its two halves are already held there - `inst-st-tunnel-return` +6. [x] - `p1` - **ELSE** - `inst-st-tunnel-else` + 1. [x] - `p1` - Open the `StreamSession` in the `Open` state, carrying the caller's half, the upstream half `cpt-cf-oagw-algo-outbound-forward` opened, the `incremental` mode `cpt-cf-oagw-algo-stream-mode-select` returned, the response `Content-Type` it attached, the idle deadline in force, and no outcome - `inst-st-session` + 2. [x] - `p1` - `cpt-cf-oagw-algo-stream-pump` transfers the body: it reads from the upstream half, writes and flushes to the caller's half, and resets the idle timer on every byte in either direction - `inst-st-pump` + 3. [x] - `p1` - **IF** the caller disconnects - `inst-st-client-if` + 1. [x] - `p1` - Close the upstream half, record the client-disconnect outcome in the request's execution context, and take the lifecycle through `Closing` to `Closed`, which is the first of the two teardown directions DECOMPOSITION §2.8 states - `inst-st-client-close` + 4. [x] - `p1` - **ELSE IF** the upstream closes its half - `inst-st-upstream-if` + 1. [x] - `p1` - Write the bytes already read to the caller, close the caller's half, record the upstream-close outcome in the same execution context, and take the lifecycle through `Closing` to `Closed`, which is the second of the two teardown directions - `inst-st-upstream-close` + 5. [x] - `p1` - **ELSE IF** the idle timer expires at 60 seconds with no byte in either direction - `inst-st-idle-if` + 1. [x] - `p1` - Tear both halves down and answer 504 with the `IdleTimeout` variant and `X-OAGW-Error-Source: gateway`, carrying no `Retry-After` (§1.5) - `inst-st-idle-return` + 6. [x] - `p1` - **ELSE IF** either half fails while bytes are still expected - `inst-st-abort-if` + 1. [x] - `p1` - Tear both halves down and answer 502 with the `StreamAborted` variant and `X-OAGW-Error-Source: gateway`, whichever side failed (§1.5) - `inst-st-abort-return` +7. [x] - `p1` - **RETURN** the outcome recorded in the request's execution context, for `cpt-cf-oagw-feature-observability` to report; this flow emits no log line, no metric, and no span of its own (§1.5) - `inst-st-return` + +### Proxy a WebSocket Upgrade and Tunnel + +- [x] `p1` - **ID**: `cpt-cf-oagw-flow-upgrade-proxy` + +**Actor**: `cpt-cf-oagw-actor-app-developer` + +This flow is invoked once per proxy request, at the header-transformation step of `cpt-cf-oagw-flow-proxy-request` of `cpt-cf-oagw-feature-data-plane-proxy`, before `cpt-cf-oagw-algo-header-transform` builds the outbound header map. It answers one question there — is this an upgrade request — and, when the answer is yes, it owns the handshake and the tunnel that follows it. Unlike the CORS preflight of `cpt-cf-oagw-feature-cors`, which is answered before the proxy path authenticates a caller, this flow bypasses nothing: the permission check, the resolution, the match, the validations, the rate-limit charge, and the composed chain all ran before the handshake is built, and a request any of them refused never reaches it. + +**Success Scenarios**: + +- A request whose method is `GET`, whose `Upgrade` header names `websocket`, and whose `Connection` header names the `upgrade` token is detected as an upgrade request, and the strip of `Upgrade` and `Connection` is suspended for it while the other six hop-by-hop headers stay stripped. +- The outbound handshake request carries the suspended two, the handshake's own request headers, the configured `headers.request` mutations, and the replaced `Host` or `:authority`; the upstream answers 101; the lifecycle moves from `Opening` to `Open`. +- After the 101 the gateway is a byte tunnel in both directions: it frames nothing, interprets nothing, injects nothing, and applies the idle timer to the absence of traffic in either direction. +- The caller disconnects and the upstream half is closed; the upstream closes and the caller's half is closed after the bytes already read are written. + +**Error Scenarios**: + +- The three-part detection does not recognize an upgrade request: no suspension is applied, all eight hop-by-hop headers are stripped, and the exchange proceeds as a plain request/response transfer under `cpt-cf-oagw-flow-stream-transfer`. +- The upstream answers anything other than 101: that answer passes through unchanged under the error-source classification, the session opened at the send moves to `Closed` without any half being read, and the connection stays a plain request/response exchange. +- The handshake fails before any data moves — a refused connection, a deadline breach, or an unavailable link — and the answer is the one `cpt-cf-oagw-algo-outbound-forward` produced, with the lifecycle moving from `Opening` to `Closed`. +- No traffic moves in either direction for 60 seconds: 504 with the `IdleTimeout` variant and no `Retry-After` (§1.5). +- The tunnel terminates mid-flight: 502 with the `StreamAborted` variant and `X-OAGW-Error-Source: gateway`, whichever side caused it (§1.5). +- The selected endpoint's scheme is `wt`: 502 with the `ProtocolError` variant, answered by `cpt-cf-oagw-algo-outbound-forward` at this flow's send step, which is the disposition §1.6 records rather than restates. + +**Steps**: + +1. [x] - `p1` - Actor issues the upgrade request carrying the `GET` method, the `Upgrade: websocket` header, the `Connection` header naming the `upgrade` token, the handshake's own request headers, and a bearer token for `gts.cf.core.oagw.proxy.v1~:invoke` - `inst-up-issue` +2. [x] - `p1` - API: `GET /oagw/v1/proxy/{alias}[/{path_suffix}][?{query}]` — the proxy path `cpt-cf-oagw-feature-data-plane-proxy` registers taken under those conditions, with the permission check, the resolution, the match, the validations, the rate-limit charge, and the composed chain all executed before this flow is reached, so the upgrade request bypasses nothing - `inst-up-api` +3. [x] - `p1` - Before `cpt-cf-oagw-algo-header-transform` builds the outbound header map, `cpt-cf-oagw-algo-stream-mode-select` applies its three-part detection to the request as the proxy path holds it - `inst-up-detect` +4. [x] - `p1` - **IF** the method is not `GET`, or `Upgrade` does not name `websocket`, or `Connection` does not name the `upgrade` token - `inst-up-detect-if` + 1. [x] - `p1` - **RETURN** no suspension and no `UpgradeHandshake`, so `cpt-cf-oagw-algo-header-transform` strips all eight hop-by-hop headers and the exchange stays under `cpt-cf-oagw-flow-stream-transfer`; a request that fails any one of the three parts is an ordinary proxy request and not a handshake - `inst-up-detect-return` +5. [x] - `p1` - **ELSE** - `inst-up-detect-else` + 1. [x] - `p1` - `cpt-cf-oagw-algo-upgrade-handshake` builds the `UpgradeHandshake`: it suspends the strip of `Upgrade` and `Connection`, keeps the other six stripped, forwards the handshake's own request headers regardless of the `headers.request.passthrough` mode (§1.5), and applies the configured `headers.request` rules and the `Host` or `:authority` replacement - `inst-up-build` + 2. [x] - `p1` - Send the handshake request through `cpt-cf-oagw-algo-outbound-forward`, which applies the dial-time scheme check and bounds the wait for the answer with the `RequestTimeout` deadline (§1.5) - `inst-up-send` + 3. [x] - `p1` - **IF** the upstream answers 101 - `inst-up-101-if` + 1. [x] - `p1` - Judge the handshake complete, carry the answer on the `UpgradeHandshake`, and move the lifecycle from `Opening` to `Open`, so the two halves become a tunnel - `inst-up-101-open` + 4. [x] - `p1` - **ELSE** - `inst-up-not-101-else` + 1. [x] - `p1` - Judge the handshake not taken up, and let `cpt-cf-oagw-algo-upgrade-handshake` carry that answer through unchanged, so the caller receives the upstream's own response rather than a gateway variant of it - `inst-up-not-101` +6. [x] - `p1` - For the session `cpt-cf-oagw-algo-upgrade-handshake` opened in `Opening` and the 101 branch moved to `Open`, run `cpt-cf-oagw-algo-stream-pump` over both halves in the `tunnel` mode: it reads from either half, writes to the other, frames nothing, interprets nothing, and resets the idle timer on every byte in either direction - `inst-up-tunnel` +7. [x] - `p1` - **IF** the caller disconnects - `inst-up-teardown-client-if` + 1. [x] - `p1` - Close the upstream half, record the client-disconnect outcome in the request's execution context, and take the lifecycle through `Closing` to `Closed` - `inst-up-teardown-client` +8. [x] - `p1` - **ELSE IF** the upstream closes its half - `inst-up-teardown-upstream-if` + 1. [x] - `p1` - Close the caller's half, record the upstream-close outcome in the same execution context, and take the lifecycle through `Closing` to `Closed` - `inst-up-teardown-upstream` +9. [x] - `p1` - **ELSE IF** no traffic moves in either direction for 60 seconds - `inst-up-idle-if` + 1. [x] - `p1` - Tear both halves down and answer 504 with the `IdleTimeout` variant and `X-OAGW-Error-Source: gateway`, carrying no `Retry-After` (§1.5) - `inst-up-idle-return` +10. [x] - `p1` - **ELSE IF** the tunnel terminates mid-flight - `inst-up-abort-if` + 1. [x] - `p1` - Tear both halves down and answer 502 with the `StreamAborted` variant and `X-OAGW-Error-Source: gateway`, whichever side caused it (§1.5) - `inst-up-abort-return` +11. [x] - `p1` - **IF** the handshake failed before any data moved - `inst-up-fail-if` + 1. [x] - `p1` - Move the lifecycle from `Opening` to `Closed` and **RETURN** the answer `cpt-cf-oagw-algo-outbound-forward` produced, which is 504 with the `ConnectionTimeout` or `RequestTimeout` variant, 503 with the `LinkUnavailable` variant, or the gateway refusal for a scheme the dial-time check rejects - `inst-up-fail-return` +12. [x] - `p1` - **RETURN** the outcome recorded in the request's execution context, for `cpt-cf-oagw-feature-observability` to report; this flow emits no log line, no metric, and no span of its own (§1.5) - `inst-up-return` + +## 3. Processes / Business Logic (CDSL) + +The three routines below are called by the two flows in §2 and by each other in the order those flows state them. None of them opens a socket: the caller's half is held by the platform's inbound handler and the upstream half was opened by `cpt-cf-oagw-algo-outbound-forward` of `cpt-cf-oagw-feature-data-plane-proxy`, which is why `cpt-cf-oagw-constraint-no-direct-internet` is that feature's constraint to enforce and not this one's. The handshake send leaves the process, and it does so through that feature's routine rather than through a client of its own. Every failure any of them returns is mapped through `cpt-cf-oagw-algo-error-mapping` of `cpt-cf-oagw-feature-gear-foundation` into an RFC 9457 body with `X-OAGW-Error-Source: gateway`, and this feature adds no second serialization path. + +### Select the Transfer Mode + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-stream-mode-select` + +**Input**: the request as the proxy path holds it — its method, its `Upgrade` header, and its `Connection` header — and, after the send, the upstream's response headers as `cpt-cf-oagw-algo-outbound-forward` received them, with the `SelectedEndpoint` and the `MatchedRoute` for context. + +**Output**: the upgrade detection for the header-transformation step, and, after the send, the transfer mode with the 101 answer or the response `Content-Type` attached, for the flow or routine that opens the session to carry. + +The routine runs in two halves at the two invocation points §1.5 records, which is why its input names both the request and the response headers and why each half is total on the inputs it has at that point. The first half runs before `cpt-cf-oagw-algo-header-transform` builds the outbound header map and answers only the detection question, because the response does not exist yet. The second half runs after the response headers arrive and answers only the mode question, because the request has already been sent. The transfer mode has exactly two values, `tunnel` and `incremental`; there is no third mode that buffers a complete response body, and the reason is recorded in §1.5. + +| Input condition | Outcome | Source | +|---|---|---| +| Method `GET`, `Upgrade` naming `websocket`, `Connection` naming the `upgrade` token | the upgrade detection, and after the send `tunnel` when the answer is 101 | DECOMPOSITION §2.8's WebSocket upgrade clause | +| Response `Content-Type` of `text/event-stream` | `incremental`, flushed as events arrive | DECOMPOSITION §2.8's SSE clause | +| Every other response body | `incremental`, forwarded as received | DESIGN §3.2 Transformation Rules, body row | + +**Steps**: + +1. [x] - `p1` - Read the request's method, `Upgrade` header, and `Connection` header as the proxy path holds them, before `cpt-cf-oagw-algo-header-transform` builds the outbound header map and before any strip runs - `inst-sms-request` +2. [x] - `p1` - **IF** the method is `GET`, `Upgrade` names `websocket`, and `Connection` names the `upgrade` token, each compared case-insensitively and the last over a comma-separated token list (§1.5) - `inst-sms-upgrade-if` + 1. [x] - `p1` - Return the upgrade detection, so `cpt-cf-oagw-algo-upgrade-handshake` builds the handshake and applies the suspension, and hold the detection on the request for the second half to consume - `inst-sms-upgrade-return` +3. [x] - `p1` - **ELSE** - `inst-sms-upgrade-else` + 1. [x] - `p1` - Return no upgrade detection, so the strip runs over all eight hop-by-hop headers and the exchange proceeds as a plain request/response transfer - `inst-sms-not-upgrade` +4. [x] - `p1` - After `cpt-cf-oagw-algo-outbound-forward` receives the upstream's response headers, read the response status and the `Content-Type` header, and take the detection the first half recorded - `inst-sms-response` +5. [x] - `p1` - **IF** the request was an upgrade request and the response status is 101 - `inst-sms-tunnel-if` + 1. [x] - `p1` - Select `tunnel`, and return it with the 101 answer, so `cpt-cf-oagw-algo-upgrade-handshake` opens the session it already began in `Opening` and carries both halves - `inst-sms-tunnel` +6. [x] - `p1` - **ELSE** - `inst-sms-tunnel-else` + 1. [x] - `p1` - Select `incremental`, and return it with the response `Content-Type` recorded for the session the flow that called this routine is to open; the mode is the same value for `text/event-stream` and for every other body, and neither buffers a complete response body (§1.5) - `inst-sms-incremental` +7. [x] - `p1` - **RETURN** the detection for the first half and the mode, with the 101 answer or the response `Content-Type` attached, for the flow or routine that opens the session to carry - `inst-sms-return` + +**Error handling**: the routine reads headers and compares literals, so it has no failure mode of its own and cannot fail on one. A method outside the shipped route schema's five literals cannot reach it, because the route's allowlist refused it earlier and the answer is 404 with the `RouteNotFound` variant; a request that reached it through a route that admits `GET` is the only request whose method can satisfy the first part. The routine neither normalizes nor repairs a header value: an `Upgrade` value carrying whitespace or a list of protocols is matched against the `websocket` literal and against nothing else, and a value that does not name it is a request this feature does not upgrade. + +### Build the Upgrade Handshake and Judge its Answer + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-upgrade-handshake` + +**Input**: the request as the proxy path holds it, the upgrade detection `cpt-cf-oagw-algo-stream-mode-select` returned, the `SelectedEndpoint`, and the `ResolvedUpstream`'s `headers` rules. + +**Output**: an `UpgradeHandshake` carrying the outbound handshake request's suspended headers and the upstream's answer, or the recorded fact that the handshake failed before data. + +The suspension this routine applies is the DESIGN §3.2 Headers Transformation upgrade exception, which DECOMPOSITION §2.8 assigns to this feature and which `cpt-cf-oagw-algo-header-transform` explicitly does not apply. It is scoped to two of the eight hop-by-hop headers and to the request direction (§1.5), and it changes nothing else about the request the proxy path would have sent. + +**Steps**: + +1. [x] - `p1` - Suspend the strip of `Upgrade` and `Connection` for the outbound request, so both reach the upstream with the values the caller sent and the handshake can be taken up - `inst-uh-suspend` +2. [x] - `p1` - Strip the other six hop-by-hop headers DESIGN §3.2's table names — `Keep-Alive`, `Proxy-Authenticate`, `Proxy-Authorization`, `TE`, `Trailer`, and `Transfer-Encoding` — exactly as the unconditional rule strips them, because an upgrade changes the meaning of neither (§1.5) - `inst-uh-six` +3. [x] - `p1` - Forward the WebSocket handshake's own request headers — `Sec-WebSocket-Key`, `Sec-WebSocket-Version`, and any `Sec-WebSocket-Extensions` and `Sec-WebSocket-Protocol` the caller offered — regardless of the resolved `headers.request.passthrough` mode, including at that mode's shipped default of `none` (§1.5) - `inst-uh-sec` +4. [x] - `p1` - Apply the `headers.request` `set`, `add`, and `remove` rules of the resolved upstream and the `Host` or `:authority` replacement, so the handshake request is transformed exactly as a non-upgrade request would be apart from the suspension - `inst-uh-rules` +5. [x] - `p1` - **TRY** the send through `cpt-cf-oagw-algo-outbound-forward`, which applies the dial-time scheme check and bounds the wait for the answer with the `RequestTimeout` deadline - `inst-uh-try` +6. [x] - `p1` - Open the `StreamSession` in the `Opening` state as the send begins, carrying the caller's half, the upstream half `cpt-cf-oagw-algo-outbound-forward` is opening, the `tunnel` mode, the idle deadline in force, and no outcome, so the session exists for as long as the handshake is in flight - `inst-uh-session` +7. [x] - `p1` - **CATCH** the failure that send reports - `inst-uh-catch` + 1. [x] - `p1` - Record on the `UpgradeHandshake` that the handshake failed before data and return that failure, which the caller answers through the variants `cpt-cf-oagw-algo-outbound-forward` names, and move the lifecycle of the session this routine opened in `Opening` to `Closed`, and no half survives it with an open connection - `inst-uh-catch-handle` +8. [x] - `p1` - **IF** the upstream's answer is 101 - `inst-uh-101-if` + 1. [x] - `p1` - Judge the handshake complete, carry the answer on the `UpgradeHandshake`, and move the lifecycle of the session this routine opened in `Opening` to `Open`, so the session carries the two halves and they become a tunnel - `inst-uh-101` +9. [x] - `p1` - **ELSE** - `inst-uh-101-else` + 1. [x] - `p1` - Judge the handshake not taken up: the upstream's answer passes through unchanged under the error-source classification, the session this routine opened in `Opening` moves to `Closed` and no tunnel is carried, and the connection stays a plain request/response exchange, with no variant of the catalogue substituted for the answer the upstream itself produced - `inst-uh-not-101` +10. [x] - `p1` - **RETURN** the `UpgradeHandshake` - `inst-uh-return` + +**Error handling**: the routine never re-issues a handshake the upstream refused, which is `cpt-cf-oagw-principle-no-retry` applied to the one request type that cannot be repeated idempotently, because the caller's handshake key is spent and a second one would be a different handshake. A `Connection` header naming the `upgrade` token plus a protocol other than `websocket` reaches the upstream stripped and unupgraded rather than refused, because the detection in §1.5 fixed which upgrade this feature delivers and the request that fails it is an ordinary one. The response direction applies only the configured `headers.response` rules, so a handshake answer reaches the caller complete unless an operator configured a removal of one of its headers (§1.5). + +### Pump the Stream + +- [x] `p2` - **ID**: `cpt-cf-oagw-algo-stream-pump` + +**Input**: a `StreamSession` in the `Open` state, its two connection halves, its transfer mode, and the idle deadline in force. + +**Output**: the bytes transferred, the outcome of the transfer recorded on the session, and the teardown of both halves. + +The pump is one routine for both transfer modes and for both directions, and the mode changes only which halves it reads and writes: the `tunnel` mode reads and writes both, and the `incremental` mode reads the upstream half and writes the caller's. This is the routine that implements `cpt-cf-oagw-principle-no-cache` on this path, because it never holds a complete response body, and it is also the routine that keeps the SSE contract, because it parses nothing: no SSE frame is parsed, no event is rewritten, no `id:` or `retry:` field is interpreted, and no keepalive or heartbeat is injected (§1.5). + +**Steps**: + +1. [x] - `p1` - Read from whichever half has the next byte and write to the other, in the direction the mode fixes: both directions for `tunnel`, upstream to caller for `incremental` - `inst-sp-direction` +2. [x] - `p1` - Write and flush each chunk as soon as it is read, never accumulating a complete response body, which is the implementation of `cpt-cf-oagw-principle-no-cache` this feature delivers (§1.5) - `inst-sp-flush` +3. [x] - `p1` - Hold at most one chunk between the halves at any moment: a read from a half is suspended until the chunk it last read has been written and flushed to the other half, so a caller that stops accepting bytes stops the reads that would fill the buffer rather than growing it - `inst-sp-bounded` +4. [x] - `p1` - Count a byte as moved only once it has been read from one half and written and flushed to the other, which is the event the idle timer measures and the reason a caller that accepts nothing is indistinguishable from an upstream that emits nothing - `inst-sp-moved` +5. [x] - `p1` - Reset the idle timer every time a byte moves in either direction under the definition of the step above, so a healthy stream is never answered for being quiet between events and a stalled one is - `inst-sp-idle-reset` +6. [x] - `p1` - **IF** the idle timer expires at 60 seconds with no byte moved in either direction under the definition above - `inst-sp-idle-if` + 1. [x] - `p1` - Tear both halves down, record the stalled outcome on the session, and answer 504 with the `IdleTimeout` variant (`gts.cf.core.errors.err.v1~cf.oagw.timeout.idle.v1`) and `X-OAGW-Error-Source: gateway`, carrying no `Retry-After` (§1.5) - `inst-sp-idle-return` +7. [x] - `p1` - **ELSE IF** the caller's half ends - `inst-sp-client-if` + 1. [x] - `p1` - Close the upstream half, record the client-disconnect outcome on the session, and take the lifecycle through `Closing` to `Closed`; the upstream is not told why, because the gateway conveys only the close - `inst-sp-client-return` +8. [x] - `p1` - **ELSE IF** the upstream's half ends - `inst-sp-upstream-if` + 1. [x] - `p1` - Write the bytes already read to the caller, close the caller's half, record the upstream-close outcome on the session, and take the lifecycle through `Closing` to `Closed` - `inst-sp-upstream-return` +9. [x] - `p1` - **ELSE IF** either half fails while bytes are still expected - `inst-sp-abort-if` + 1. [x] - `p1` - Tear both halves down, record the aborted outcome on the session, and answer 502 with the `StreamAborted` variant (`gts.cf.core.errors.err.v1~cf.oagw.stream.aborted.v1`) and `X-OAGW-Error-Source: gateway`, whichever side failed (§1.5) - `inst-sp-abort-return` +10. [x] - `p1` - **RETURN** the outcome recorded on the session, which the flow above carries into the request's execution context - `inst-sp-return` + +**Error handling**: the pump never re-reads, re-orders, or re-requests a byte, because a stream that has been consumed cannot be consumed again and `cpt-cf-oagw-principle-no-retry` forbids the gateway from re-issuing the request that produced it. A half that ends cleanly is a close and not an abort, whichever half it is, so an upstream that finishes a response and closes is answered with a completed transfer rather than a 502; the abort answer is for a half that failed while bytes were still expected, which is the only case in which the caller received an incomplete body and needs to know it. The pump inspects no byte, so it cannot transform, filter, or drop one, and the only thing it ever withholds is a chunk it has not yet read. + +## 4. States (CDSL) + +### Stream Session Lifecycle State Machine + +- [x] `p1` - **ID**: `cpt-cf-oagw-state-stream-lifecycle` + +This is the one state machine this feature owns and the one DECOMPOSITION §2.8 assigns it as "stream lifecycle state". It is a machine over a `StreamSession` and over nothing else, which is why it is not a third domain type beside `StreamSession` and `UpgradeHandshake`: the session carries the state, and the machine describes how that member moves. It is the only state on the proxy path that `cpt-cf-oagw-feature-data-plane-proxy` does not own and that is not a keyed entry in a registry — it exists only while two connections are open, and it is gone when they close. + +**States**: `Opening`, `Open`, `Closing`, `Closed` + +**Initial State**: `Opening` + +The diagram renders the five transitions below; the prose remains the normative statement of each. + +```mermaid +stateDiagram-v2 + [*] --> Opening + Opening --> Open : upstream accepted, or handshake completed + Opening --> Closed : upstream refused, or handshake failed before data + Open --> Closing : one side signalled the end + Closing --> Closed : other half torn down and outcome recorded + Open --> Closed : mid-flight failure, abort +``` + +**Transitions**: + +1. [x] - `p1` - **FROM** `Opening` **TO** `Open` **WHEN** the upstream takes the handshake up: a 101 arrives for a `tunnel` and the session `cpt-cf-oagw-algo-upgrade-handshake` opened at the send moves to `Open`; the guard is that the answer is one the gateway can forward, so a 4xx or 5xx answer is not this transition - `inst-state-open` +2. [x] - `p1` - **FROM** `Opening` **TO** `Closed` **WHEN** the handshake fails or is refused after the send began: a non-101 answer to a handshake, a breach of the `RequestTimeout` deadline, a `LinkUnavailable` answer, or the dial-time scheme refusal; the guard is that the session `cpt-cf-oagw-algo-upgrade-handshake` opened at the send is the one that closes, and no `StreamSession` survives this transition with an open half - `inst-state-refused` +3. [x] - `p1` - **FROM** `Open` **TO** `Closing` **WHEN** one side signals the end: the caller disconnects, or the upstream closes its half; the guard is that at least one byte has moved or the half has ended cleanly, so a half that failed while bytes were still expected takes the abort transition instead - `inst-state-closing` +4. [x] - `p1` - **FROM** `Closing` **TO** `Closed` **WHEN** the other half is torn down and the outcome is recorded in the request's execution context; the guard is that no byte is in flight in either direction when the state changes, so a caller never observes a `Closed` session that still has a half to drain - `inst-state-closed` +5. [x] - `p1` - **FROM** `Open` **TO** `Closed` **WHEN** a mid-flight failure aborts the transfer on either side, which is the only transition that bypasses `Closing`, because a failed half has nothing to drain and both halves are torn down together - `inst-state-abort` + +**Invalid transitions**: + +- `Closed` is terminal: no transition leaves it, because the two connections the session described no longer exist and there is nothing left to move. +- `Closing` to `Open` is invalid, because a session that has begun to tear down has already lost the half that signalled the end and cannot be reopened. +- `Opening` to `Closing` is invalid, because a session that never opened has no bytes in flight to drain and takes the refusal transition instead. +- `Opening` to `Open` on a non-101 answer to a handshake is invalid: the upstream did not take the handshake up, so there is no tunnel to open and the exchange stays a plain request/response transfer under `cpt-cf-oagw-flow-upgrade-proxy`'s passthrough branch. +- An `incremental` session is never in `Opening`, because `cpt-cf-oagw-flow-stream-transfer` opens it in `Open` only after the upstream's response headers have arrived and this feature is first reached at that point; there is no in-flight window for it to describe. + +**Persistence answer**: none. A stream session lives only as long as its connections, DECOMPOSITION §2.8 declares no table for this feature, and no state of this machine is written to any store. `cpt-cf-oagw-adr-state-management` assigns the Data Plane three pieces of state — the small L1 cache, the shared outbound client, and the per-instance rate limiters — and a stream session is none of them, so it is held on the request's execution context and dropped with it. A restart changes nothing about any answer this feature gives and ends every session it held, which is the consequence of the state being inseparable from the connections it describes. + +## 5. Definitions of Done + +### Server-Sent Event Forwarding + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-stream-sse-forwarding` + +The system **MUST** transfer every response body whose mode `cpt-cf-oagw-algo-stream-mode-select` selects as `incremental` by reading the upstream half and writing the caller's half as bytes arrive, and **MUST** flush each chunk as it is written, so a response whose `Content-Type` is `text/event-stream` reaches the caller as events arrive and in the order the upstream emitted them. It **MUST NOT** buffer a complete response body before its first byte is forwarded, **MUST NOT** parse an SSE frame, rewrite an event, or interpret an `id:` or `retry:` field, and **MUST NOT** inject a keepalive or a heartbeat of its own, because the only SSE-specific behaviour this feature delivers is that bytes are flushed as they arrive rather than accumulated. It **MUST** apply the same forwarding to a response body `cpt-cf-oagw-algo-response-classify` hands over whose content type is not `text/event-stream`, and **MUST** leave the `headers.response` rules and the `X-OAGW-Error-Source` tag to `cpt-cf-oagw-algo-response-classify`, which applied them before the body was handed over. + +**Implements**: + +- `cpt-cf-oagw-flow-stream-transfer` +- `cpt-cf-oagw-algo-stream-mode-select` +- `cpt-cf-oagw-algo-stream-pump` +- `cpt-cf-oagw-usecase-sse-streaming` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: `{METHOD} /oagw/v1/proxy/{alias}[/{path_suffix}]` — answered on the proxy path `cpt-cf-oagw-feature-data-plane-proxy` registers, which is the first API statement DECOMPOSITION §2.8 declares for this feature and not a second registration of it +- DB: none +- DB Table: none +- Entities: `StreamSession` + +### Stream Lifecycle and Teardown + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-stream-lifecycle` + +The system **MUST** carry every streaming exchange on a `StreamSession` whose lifecycle state is a state of `cpt-cf-oagw-state-stream-lifecycle`, **MUST** open a `tunnel` session in `Opening` when the outbound handshake request is sent and move it to `Open` when the 101 arrives, and open an `incremental` session in `Open` when the upstream's response headers have arrived, because this feature is first reached at that point and no in-flight window precedes it, and **MUST** close it through `Closing` to `Closed` when the other half is torn down and the outcome is recorded. It **MUST** close the upstream connection when the caller disconnects and the caller's connection when the upstream closes, which are the two teardown directions DECOMPOSITION §2.8 states, and **MUST** record the outcome in the request's execution context rather than emitting a log line, a metric, or a span of its own (§1.5). It **MUST NOT** persist any state of the machine, and **MUST** drop the session with the connections it describes. + +**Implements**: + +- `cpt-cf-oagw-flow-stream-transfer` +- `cpt-cf-oagw-flow-upgrade-proxy` +- `cpt-cf-oagw-algo-stream-pump` +- `cpt-cf-oagw-state-stream-lifecycle` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: none — the lifecycle is held on the sessions of exchanges the proxy path already serves +- DB: none +- DB Table: none +- Entities: `StreamSession`, `cpt-cf-oagw-state-stream-lifecycle` + +### WebSocket Upgrade Proxying + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-stream-upgrade` + +The system **MUST** detect an upgrade request by the three parts of §1.5 — the method is `GET`, `Upgrade` names `websocket`, and `Connection` names the `upgrade` token — at the header-transformation step of `cpt-cf-oagw-flow-proxy-request` and before `cpt-cf-oagw-algo-header-transform` builds the outbound header map, and **MUST** suspend the strip of `Upgrade` and `Connection` for a request all three parts identify while keeping the other six hop-by-hop headers stripped. It **MUST** forward the WebSocket handshake's own request headers regardless of the resolved `headers.request.passthrough` mode (§1.5), and **MUST** apply the configured `headers.request` rules and the `Host` or `:authority` replacement to the handshake request exactly as to any other. It **MUST** judge the handshake complete only on a 101 answer, **MUST** pass any other answer through unchanged under the error-source classification, with the session `cpt-cf-oagw-algo-upgrade-handshake` opened in `Opening` moved to `Closed` and no tunnel carried, and **MUST** run the tunnel that follows a 101 as a byte tunnel in both directions that frames nothing, interprets nothing, and injects nothing. It **MUST** run detection, the permission check, resolution, matching, validation, the rate-limit check, and the composed chain for an upgrade request exactly as for a non-streaming request, and **MUST NOT** bypass any of them. + +**Implements**: + +- `cpt-cf-oagw-flow-upgrade-proxy` +- `cpt-cf-oagw-algo-upgrade-handshake` +- `cpt-cf-oagw-algo-stream-mode-select` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: `GET /oagw/v1/proxy/{alias}[/{path_suffix}]` with `Upgrade: websocket` — answered on the proxy path `cpt-cf-oagw-feature-data-plane-proxy` registers, which is the second API statement DECOMPOSITION §2.8 declares for this feature and not a second registration of it +- DB: none +- DB Table: none +- Entities: `UpgradeHandshake` + +### Stream Timeouts + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-stream-timeouts` + +The system **MUST** apply an idle timeout of 60 seconds to every streaming exchange, as a build-time constant of this feature with no configuration surface and no sourced value (§1.5), and **MUST** reset it on every byte that moves in either direction, so it measures the absence of traffic and not the duration of the exchange. It **MUST** answer a breach with 504 and the `IdleTimeout` variant (`gts.cf.core.errors.err.v1~cf.oagw.timeout.idle.v1`) carrying `X-OAGW-Error-Source: gateway`. It **MUST** leave the wait for the upstream's response headers to the `proxy_timeout_secs` deadline `cpt-cf-oagw-algo-outbound-forward` applies, which is the phase whose breach that feature answers with the `RequestTimeout` variant, and **MUST NOT** answer a mid-body stall with `RequestTimeout`, because once the headers have arrived and the mode is streaming the only deadline on the body is the idle timeout (§1.5). It **MUST NOT** widen the `OagwConfig` surface to make the constant configurable. + +**Implements**: + +- `cpt-cf-oagw-algo-stream-pump` +- `cpt-cf-oagw-algo-stream-mode-select` +- `cpt-cf-oagw-flow-stream-transfer` +- `cpt-cf-oagw-flow-upgrade-proxy` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: `StreamSession` + +### Stream Error Mapping + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-stream-errors` + +The system **MUST** answer a mid-flight termination with 502 and the `StreamAborted` variant (`gts.cf.core.errors.err.v1~cf.oagw.stream.aborted.v1`) carrying `X-OAGW-Error-Source: gateway`, whichever side caused it, and **MUST NOT** answer that case with the `DownstreamError` variant (§1.5). It **MUST** answer a stalled stream with 504 and the `IdleTimeout` variant carrying the same tag. It **MUST** serialize both answers through `cpt-cf-oagw-algo-error-mapping` of `cpt-cf-oagw-feature-gear-foundation` as `application/problem+json` bodies carrying the variant's GTS `type` identifier and the present `ErrorContext` members as extension fields, and **MUST NOT** add a second serialization path. It **MUST NOT** set `retry_after_seconds` on either answer, so neither carries `Retry-After`, including `IdleTimeout`, which the catalogue marks retriable and which is answered without the header for the reason §1.5 records. It **MUST NOT** substitute a gateway error for an upstream answer that the handshake received, which passes through unchanged under the error-source classification. + +**Implements**: + +- `cpt-cf-oagw-algo-stream-pump` +- `cpt-cf-oagw-algo-upgrade-handshake` +- `cpt-cf-oagw-algo-error-mapping` of `cpt-cf-oagw-feature-gear-foundation` +- `cpt-cf-oagw-algo-response-classify` of `cpt-cf-oagw-feature-data-plane-proxy` + +**Constraints**: none from DESIGN §2.2; the governing elements are `cpt-cf-oagw-principle-error-source`, `cpt-cf-oagw-adr-error-source-distinction`, and the `StreamAborted` and `IdleTimeout` rows of the DESIGN §3.3 catalogue. + +**Touches**: + +- API: none — both answers are returned on `{METHOD} /oagw/v1/proxy/{alias}[/{path_suffix}]`, the path `cpt-cf-oagw-feature-data-plane-proxy` registers +- DB: none +- DB Table: none +- Entities: `StreamSession`, `ErrorContext` + +### Stream Entities and Layering + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-stream-entities` + +The system **MUST** declare `StreamSession` and `UpgradeHandshake` once, in the domain layer, free of transport and persistence types, with the members §1.2 assigns each, and **MUST** carry the lifecycle state of a session as a state of `cpt-cf-oagw-state-stream-lifecycle` rather than as a third type. It **MUST** consume `ResolvedUpstream`, `SelectedEndpoint`, `MatchedRoute`, and `ProxyResponse` from `cpt-cf-oagw-feature-data-plane-proxy` and `ErrorContext` from `cpt-cf-oagw-feature-gear-foundation` rather than redeclaring any of them, and **MUST NOT** declare a second `ProxyResponse`, a second `ErrorContext`, or a cache that holds a response body. + +**Implements**: + +- `cpt-cf-oagw-algo-stream-mode-select` +- `cpt-cf-oagw-algo-upgrade-handshake` +- `cpt-cf-oagw-algo-stream-pump` +- `cpt-cf-oagw-state-stream-lifecycle` + +**Constraints**: `cpt-cf-oagw-constraint-toolkit-deploy` + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: `StreamSession`, `UpgradeHandshake` + +### Colocated Tests + +- [x] `p1` - **ID**: `cpt-cf-oagw-dod-stream-tests` + +The system **MUST** deliver this feature's unit and integration tests colocated under `gears/system/oagw/oagw/tests/`, covering the three-part upgrade detection and each of its negatives, the suspension of the two headers and the retention of the other six, the forwarding of the handshake's own request headers at the shipped `passthrough` default, the 101 judgement and the non-101 passthrough, the tunnel's byte transparency in both directions, the incremental transfer of a `text/event-stream` body and of a body that is not one, the absence of whole-body buffering and of frame parsing, both teardown directions and their recorded outcomes, every transition and every invalid transition of the lifecycle machine, the 60-second idle timeout and its absence of a configuration surface, the boundary of `RequestTimeout` at the response headers and the 2-second graded value of the header-arrival wait, the two error answers with their GTS types, their tags, and their missing `Retry-After`, the entity declarations and the consumed types, and the registration statement, and **MUST NOT** add any test under `testing/e2e/gears/oagw/`. The upstream and the caller's half are the mock boundary of those tests, and nothing below `cpt-cf-oagw-algo-outbound-forward` is substituted by any of them; the test data is the detection-positive and detection-negative header sets plus a `text/event-stream` body and a body that is not one; and each test owns its session and its two halves, so no test observes another's. + +**Implements**: + +- `cpt-cf-oagw-flow-stream-transfer` +- `cpt-cf-oagw-flow-upgrade-proxy` +- `cpt-cf-oagw-algo-stream-mode-select` +- `cpt-cf-oagw-algo-upgrade-handshake` +- `cpt-cf-oagw-algo-stream-pump` +- `cpt-cf-oagw-state-stream-lifecycle` + +**Constraints**: none from DESIGN §2.2; this is the DECOMPOSITION §1.3(3) placement deviation recorded in §1.5. + +**Touches**: + +- API: none +- DB: none +- DB Table: none +- Entities: none — tests only + +## 6. Acceptance Criteria + +- [x] A request whose method is `GET`, whose `Upgrade` header names `websocket`, and whose `Connection` header names the `upgrade` token is detected as an upgrade request at the header-transformation step of `cpt-cf-oagw-flow-proxy-request`, and the strip of `Upgrade` and `Connection` is suspended for the outbound request. +- [x] A `POST` carrying `Upgrade: websocket` and `Connection: upgrade` is not detected as an upgrade request, `Upgrade` and `Connection` are stripped from its outbound request, and the exchange is transferred as a plain request/response body. +- [x] A `GET` carrying `Upgrade: h2c` is not detected as an upgrade request and the strip runs over all eight hop-by-hop headers, because the only upgrade DECOMPOSITION §2.8 delivers names `websocket`. +- [x] A `GET` carrying `Upgrade: websocket` with no `Connection` header naming the `upgrade` token is not detected as an upgrade request, and the same is true of a `GET` carrying `Connection: upgrade` with no `Upgrade` header. +- [x] On a detected upgrade request the six hop-by-hop headers `Keep-Alive`, `Proxy-Authenticate`, `Proxy-Authorization`, `TE`, `Trailer`, and `Transfer-Encoding` are absent from the outbound request, exactly as they are on a plain request/response exchange. +- [x] On a detected upgrade request `Sec-WebSocket-Key`, `Sec-WebSocket-Version`, and any offered `Sec-WebSocket-Extensions` and `Sec-WebSocket-Protocol` reach the upstream even when the resolved `headers.request.passthrough` mode is its shipped default of `none`, and the configured `headers.request` `set`, `add`, and `remove` rules and the `Host` replacement still apply to the handshake request. +- [x] A handshake the upstream answers with 101 completes, the lifecycle moves from `Opening` to `Open`, and the response direction applies only the configured `headers.response` rules with no strip suspended. +- [x] A handshake the upstream answers with anything other than 101 is returned to the caller with that upstream response unchanged, with `X-OAGW-Error-Source` set by the error-source classification, with the session opened in `Opening` moved to `Closed`, and with the connection left a plain request/response exchange. +- [x] After a 101, bytes sent by the caller reach the upstream and bytes sent by the upstream reach the caller, with no frame added, no frame interpreted, no keepalive injected, and no byte withheld, dropped, or reordered. +- [x] The idle timer of a tunnel is reset by traffic in either direction, so a tunnel that is quiet because both peers are quiet is answered for the quiet and not for its age. +- [x] A response whose `Content-Type` is `text/event-stream` is forwarded to the caller as the upstream's events arrive, and an event the upstream emits is visible to the caller before the next one is emitted. +- [x] No SSE frame is parsed, no event is rewritten, no `id:` or `retry:` field is interpreted, and no keepalive or heartbeat is injected into a streamed response, verified by a byte-for-byte comparison of the upstream's bytes against the caller's. +- [x] A response body whose content type is not `text/event-stream` is also forwarded as received, and no response body of any transfer is buffered in full before its first byte is forwarded. +- [x] When the caller disconnects, the upstream connection is closed by the gateway and the outcome is recorded in the request's execution context. +- [x] When the upstream closes its half, the bytes already read are written to the caller, the caller's connection is closed, and the same outcome is recorded. +- [x] The lifecycle moves from `Opening` to `Open` when the upstream's response headers arrive for an incremental transfer or a 101 arrives for a tunnel, and from `Opening` to `Closed` when the upstream refuses, the handshake fails before data, the deadline is breached, or the scheme is refused at dial time. +- [x] The lifecycle moves from `Open` to `Closing` when one side signals the end and from `Closing` to `Closed` when the other half is torn down and the outcome is recorded, and `Closed` is terminal. +- [x] A mid-flight failure on either side moves the lifecycle from `Open` directly to `Closed`, bypassing `Closing`, and no `Closing` session ever moves back to `Open` or from `Opening` to `Closing`. +- [x] No state of the lifecycle machine is persisted: a restart changes no answer this feature gives and ends every session it held, and no table of `cpt-cf-oagw-db-schema` is written by any routine of §3. +- [x] A stream that receives no byte in either direction for 60 seconds is answered 504 with `gts.cf.core.errors.err.v1~cf.oagw.timeout.idle.v1` and `X-OAGW-Error-Source: gateway`, and the 60-second value is changed by no key of `OagwConfig` and by no upstream or route configuration. +- [x] A stream whose upstream emits a byte at least once every 60 seconds is never answered for stalling, however long it runs, because the idle timer measures the gap and not the duration, and whose caller keeps accepting them, because a caller that accepts nothing stops the movement the timer measures. +- [x] The wait for the upstream's response headers is bounded by `proxy_timeout_secs` and answered 504 with `gts.cf.core.errors.err.v1~cf.oagw.timeout.request.v1` on a breach, and once those headers have arrived and the mode is streaming, no mid-body stall is answered with that variant. +- [x] With `config/e2e-local.yaml`'s `proxy_timeout_secs: 2`, a stream whose response headers arrive within 2 seconds and whose body continues past it outlives the deadline, and a request whose headers do not arrive within it is answered 504 `RequestTimeout`, with no `incremental` `StreamSession` opened and any `tunnel` session opened at the send closed in `Opening`. +- [x] A transfer that terminates mid-flight on either side is answered 502 with `gts.cf.core.errors.err.v1~cf.oagw.stream.aborted.v1` and `X-OAGW-Error-Source: gateway`, whichever side terminated it, and the `DownstreamError` variant is never used for that case. +- [x] The 502 `StreamAborted` answer carries `Content-Type: application/problem+json`, the RFC 9457 fields, and no `Retry-After` header. +- [x] The 504 `IdleTimeout` answer carries the same body shape, the same tag, and no `Retry-After` header, although the catalogue marks that row retriable, because the value `retry_after_seconds` is not set on either answer this feature produces. +- [x] `StreamSession` and `UpgradeHandshake` are declared once in the domain layer and free of transport and persistence types, the lifecycle state of a session is a state of `cpt-cf-oagw-state-stream-lifecycle` and not a third type, and `ResolvedUpstream`, `SelectedEndpoint`, `MatchedRoute`, `ProxyResponse`, and `ErrorContext` are consumed from their owning features and not redeclared. +- [x] Every test for this feature lives under `gears/system/oagw/oagw/tests/`, passes there, and no test is added under `testing/e2e/gears/oagw/`. +- [x] Both API statements of §1.2 are served by the handler `cpt-cf-oagw-feature-data-plane-proxy` registers for `{METHOD} /oagw/v1/proxy/{alias}[/{path_suffix}][?{query}]`, no second handler or route is registered for either, and no streaming-specific path, method, or query parameter exists anywhere in the gear. +- [x] An upgrade request consumes exactly one rate-limit allowance at the check `cpt-cf-oagw-feature-rate-limiting` runs, and the stream's own duration consumes no further tokens and refunds none. +- [x] A proxy request whose matched route's method allowlist omits `GET` is answered 404 with the `RouteNotFound` variant and never reaches the upgrade detection, and the answer names the upstream that resolved rather than a streaming-specific reason. +- [x] A `wt`-scheme endpoint is answered 502 with `gts.cf.core.errors.err.v1~cf.oagw.protocol.error.v1` and `X-OAGW-Error-Source: gateway` by `cpt-cf-oagw-algo-outbound-forward` at the send step of `cpt-cf-oagw-flow-upgrade-proxy`, so the `UpgradeHandshake` built for it is never taken up and the session opened in `Opening` moves to `Closed` without a tunnel. diff --git a/gears/system/oagw/oagw/Cargo.toml b/gears/system/oagw/oagw/Cargo.toml index a18b934..297ed14 100644 --- a/gears/system/oagw/oagw/Cargo.toml +++ b/gears/system/oagw/oagw/Cargo.toml @@ -66,6 +66,7 @@ credstore = { workspace = true } opentelemetry = { workspace = true } heck = { workspace = true } # CP deps +jsonschema = { workspace = true } dashmap = { workspace = true } parking_lot = { workspace = true } psl = { workspace = true } @@ -75,10 +76,10 @@ 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", "macros"] } tokio-retry = { workspace = true } hyper = { workspace = true } -hyper-util = { workspace = true } +hyper-util = { workspace = true, features = ["tokio"] } # Pingora proxy engine pingora-proxy = { version = "0.8", features = ["rustls"] } pingora-core = { version = "0.8", features = ["rustls"] } @@ -109,3 +110,4 @@ httpmock = { workspace = true } tokio-rustls = { workspace = true } rustls = { workspace = true } futures-util = { workspace = true } +tracing-test = { workspace = true, features = ["no-env-filter"] } 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..5a96bff --- /dev/null +++ b/gears/system/oagw/oagw/src/api/mod.rs @@ -0,0 +1,7 @@ +//! Transport layer of the `oagw` gear. +//! +//! The only module in the crate allowed to touch `axum` and `http`. Domain +//! failures arrive here as [`crate::domain::DomainError`] and leave as either +//! an RFC 9457 problem document or a preserved upstream response. + +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..abbaca4 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/dto.rs @@ -0,0 +1,161 @@ +//! Wire representations of the configuration rows — `cpt-cf-oagw-dod-management-routes`. +//! +//! The representation of one row is the resource kind's own schema shape: the +//! domain type serialized as it is declared, with `id` carried as the resource's +//! anonymous GTS instance. A list page carries the platform page envelope plus +//! the projection the caller asked for, and the projection is applied to every +//! item on the wire. + +use serde_json::{Map, Value}; +use uuid::Uuid; + +use crate::control_plane::odata::Page; +use crate::domain::plugin_contract::PluginFamily; +use crate::gts; +use crate::store::{PluginRow, RouteRow, UpstreamRow}; + +/// The platform page envelope's row set. +const ITEMS: &str = "items"; +/// The platform page envelope's paging metadata. +const PAGE_INFO: &str = "page_info"; +/// The projection the page was built with, present only when one was asked for. +const PROJECTION: &str = "projection"; + +/// The anonymous GTS instance identifier of one upstream row. +#[must_use] +pub fn upstream_id(id: Uuid) -> String { + gts::gts_instance(gts::UPSTREAM_TYPE, id) +} + +/// The anonymous GTS instance identifier of one route row. +#[must_use] +pub fn route_id(id: Uuid) -> String { + gts::gts_instance(gts::ROUTE_TYPE, id) +} + +/// The representation of one upstream row. +#[must_use] +pub fn upstream(row: &UpstreamRow) -> Value { + let representation = serde_json::to_value(&row.upstream).unwrap_or_default(); + identified(representation, upstream_id(row.upstream.id)) +} + +/// The representation of one route row. +#[must_use] +pub fn route(row: &RouteRow) -> Value { + let representation = serde_json::to_value(&row.route).unwrap_or_default(); + identified(representation, route_id(row.route.id)) +} + +/// The wire page of an upstream list. +#[must_use] +pub fn upstream_page(page: &Page) -> Value { + let items: Vec = page + .items + .iter() + .map(|row| projected(&upstream(row), &page.projection)) + .collect(); + envelope(items, &page.projection, page.top) +} + +/// The wire page of a route list. +#[must_use] +pub fn route_page(page: &Page) -> Value { + let items: Vec = page + .items + .iter() + .map(|row| projected(&route(row), &page.projection)) + .collect(); + envelope(items, &page.projection, page.top) +} + +/// The anonymous GTS instance identifier of one plugin row, derived from the +/// family its `plugin_type` names. +#[must_use] +pub fn plugin_id(family: PluginFamily, id: Uuid) -> String { + gts::gts_instance(family.base_type(), id) +} + +/// The representation of one plugin row. +/// +/// The configuration schema and the source are carried exactly as stored: no +/// member is re-rendered, defaulted, or dropped. +#[must_use] +pub fn plugin(row: &PluginRow) -> Value { + let representation = serde_json::to_value(&row.plugin).unwrap_or_default(); + identified(representation, plugin_row_id(row)) +} + +/// The stored Starlark source of one plugin, as the source path answers it. +#[must_use] +pub fn plugin_source(source: &str) -> Value { + Value::from(source) +} + +/// The wire page of a plugin list. +#[must_use] +pub fn plugin_page(page: &Page) -> Value { + let items: Vec = page + .items + .iter() + .map(|row| projected(&plugin(row), &page.projection)) + .collect(); + envelope(items, &page.projection, page.top) +} + +/// The anonymous GTS instance identifier one stored plugin row answers to, +/// derived from the family literal its `plugin_type` carries. +fn plugin_row_id(row: &PluginRow) -> String { + match PluginFamily::from_type_literal(&row.plugin.plugin_type) { + Some(family) => plugin_id(family, row.plugin.id), + // The store's invariant check holds every stored literal to one of the + // three families, so a row that named no family is never stored; it + // would answer to its bare identifier. + None => row.plugin.id.to_string(), + } +} + +/// Serializes one domain row and states its identifier as the anonymous GTS +/// instance the resource kind is addressed by. +fn identified(mut representation: Value, id: String) -> Value { + if let Some(object) = representation.as_object_mut() { + object.insert(String::from("id"), Value::from(id)); + } + representation +} + +/// Narrows one representation to the properties the caller projected. +/// +/// An empty projection leaves the representation whole. +fn projected(representation: &Value, projection: &[String]) -> Value { + if projection.is_empty() { + return representation.clone(); + } + let Some(fields) = representation.as_object() else { + return representation.clone(); + }; + let mut narrowed = Map::new(); + for name in projection { + if let Some(value) = fields.get(name.as_str()) { + narrowed.insert(name.clone(), value.clone()); + } + } + Value::Object(narrowed) +} + +/// The platform page envelope: the row set, the paging metadata, and — only +/// when the caller projected — the projection the items were narrowed to. +fn envelope(items: Vec, projection: &[String], top: u64) -> Value { + let mut page_info = Map::new(); + page_info.insert(String::from("limit"), Value::from(top)); + page_info.insert(String::from("next_cursor"), Value::Null); + page_info.insert(String::from("prev_cursor"), Value::Null); + + let mut body = Map::new(); + body.insert(String::from(ITEMS), Value::from(items)); + body.insert(String::from(PAGE_INFO), Value::Object(page_info)); + if !projection.is_empty() { + body.insert(String::from(PROJECTION), Value::from(projection.to_vec())); + } + Value::Object(body) +} diff --git a/gears/system/oagw/oagw/src/api/rest/handlers/metrics.rs b/gears/system/oagw/oagw/src/api/rest/handlers/metrics.rs new file mode 100644 index 0000000..444ceef --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers/metrics.rs @@ -0,0 +1,112 @@ +//! The metrics surface — the one path `cpt-cf-oagw-feature-observability` +//! registers. +//! +//! `GET /oagw/v1/metrics` is gear-relative on the mount point the foundation +//! created, is registered for that method alone, and is enforced with the +//! `gts.cf.core.oagw.metrics.v1~:read` permission before any collector is +//! read. The handler reads nothing but the seam's exposition and answers no +//! audit record of its own: the scrape observes nothing and is observed by +//! nothing (§1.5). + +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::Response; +use axum::Extension; +use toolkit_security::SecurityContext; + +use super::{SharedState, READ, SUPPORTED_PROPERTIES}; +use crate::api::rest::problem; + +/// The content type the Prometheus text exposition format names. +const EXPOSITION_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8"; + +/// The enforcer's descriptor of the metrics resource. +#[must_use] +fn metrics_resource_type() -> authz_resolver_sdk::pep::ResourceType { + authz_resolver_sdk::pep::ResourceType::from_static( + crate::gts::METRICS_TYPE, + SUPPORTED_PROPERTIES, + ) +} + +/// The 403 the metrics permission is answered with. +fn forbidden(instance: &str) -> Response { + problem::forbidden_response(crate::gts::METRICS_TYPE, instance) +} + +/// Answers `GET /oagw/v1/metrics` with the text exposition of the twelve +/// families DESIGN §4.2 declares. +pub async fn scrape( + State(state): State, + context: Option>, +) -> Response { + let instance = String::from("/oagw/v1/metrics"); + + // @cpt-begin:cpt-cf-oagw-flow-metrics-scrape:p1:inst-ms-issue + // The actor issues the scrape with a bearer token; the handler answers it + // with the exposition or with a refusal, and decides nothing else. + // @cpt-end:cpt-cf-oagw-flow-metrics-scrape:p1:inst-ms-issue + // @cpt-begin:cpt-cf-oagw-flow-metrics-scrape:p1:inst-ms-api + // The API is the one path this feature registers, on the gear-relative + // mount point the foundation created, and the platform middleware that + // authenticates the bearer token has run before this handler did. + // @cpt-end:cpt-cf-oagw-flow-metrics-scrape:p1:inst-ms-api + + // @cpt-begin:cpt-cf-oagw-flow-metrics-scrape:p1:inst-ms-authz + // The permission is enforced before any collector is read: a token without + // it is answered 403 and renders nothing, and a request with no + // authenticated subject is answered 401, because the platform middleware + // that would have resolved one did not run for it. + let Some(context) = context.as_ref().map(|extension| &extension.0) else { + return problem::problem_response( + &crate::domain::error::DomainError::gateway( + crate::domain::error::ErrorKind::AuthenticationFailed, + "the request carries no authenticated subject", + ), + &instance, + ); + }; + let Some(enforcer) = state.enforcer() else { + tracing::warn!(instance, "no AuthZ client resolved; the metrics surface fails closed"); + return forbidden(&instance); + }; + if let Err(error) = enforcer + .access_scope(context, &metrics_resource_type(), READ, None) + .await + { + tracing::warn!(instance, error = %error, "the metrics permission was refused"); + // @cpt-begin:cpt-cf-oagw-flow-metrics-scrape:p1:inst-ms-permitted-else + // @cpt-begin:cpt-cf-oagw-flow-metrics-scrape:p1:inst-ms-forbidden + // RETURN 403 with no exposition rendered, through the foundation's + // error mapping: an `application/problem+json` body tagged + // `X-OAGW-Error-Source: gateway` that carries the `trace_id` of the + // correlation context this scrape request was assigned. + // @cpt-end:cpt-cf-oagw-flow-metrics-scrape:p1:inst-ms-forbidden + // @cpt-end:cpt-cf-oagw-flow-metrics-scrape:p1:inst-ms-permitted-else + return forbidden(&instance); + } + // @cpt-end:cpt-cf-oagw-flow-metrics-scrape:p1:inst-ms-authz + + // @cpt-begin:cpt-cf-oagw-flow-metrics-scrape:p1:inst-ms-permitted-if + // @cpt-begin:cpt-cf-oagw-flow-metrics-scrape:p1:inst-ms-render + // `cpt-cf-oagw-algo-metrics-render` reads the twelve collectors at the + // moment the scrape is served and renders the text exposition format, with + // a `# HELP` and a `# TYPE` line per family and the histogram as its + // `_bucket` series plus its `_sum` and `_count` series. + let exposition = state.observability().render(); + // @cpt-end:cpt-cf-oagw-flow-metrics-scrape:p1:inst-ms-render + // @cpt-begin:cpt-cf-oagw-flow-metrics-scrape:p1:inst-ms-return + // RETURN 200 with the exposition and the content type the format names; + // no audit record is written and no series is observed for the scrape + // itself. + Response::builder() + .status(StatusCode::OK) + .header( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static(EXPOSITION_TYPE), + ) + .body(axum::body::Body::from(exposition)) + .unwrap_or_else(|_| Response::new(axum::body::Body::empty())) + // @cpt-end:cpt-cf-oagw-flow-metrics-scrape:p1:inst-ms-return + // @cpt-end:cpt-cf-oagw-flow-metrics-scrape:p1:inst-ms-permitted-if +} diff --git a/gears/system/oagw/oagw/src/api/rest/handlers/mod.rs b/gears/system/oagw/oagw/src/api/rest/handlers/mod.rs new file mode 100644 index 0000000..22184cb --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers/mod.rs @@ -0,0 +1,420 @@ +//! Management handlers — the ten endpoints of DECOMPOSITION §2.2. +//! +//! Every handler runs the same order: authenticate, enforce the operation's +//! permission, resolve the calling tenant, then — and only then — read the body +//! and reach the management service. The one address a path carries is +//! resolved between the permission and the tenant, and a selector that parses +//! to no identifier is answered only once both checks have answered, so a +//! request that cannot state who it is or what it may do writes nothing, reads +//! nothing, and is told nothing about the path it named. +//! +//! A handler never formats a problem body of its own: [`refused`] maps the +//! service outcome onto the module's problem builders, and a persistence +//! failure is logged with the reason it carries and answered with the platform's +//! 500 shape. + +pub mod metrics; +pub mod plugins; +pub mod proxy; +pub mod routes; +pub mod upstreams; + +use std::sync::Arc; + +use authz_resolver_sdk::pep::ResourceType; +use axum::body::Bytes; +use axum::http::{StatusCode, Uri}; +use axum::response::Response; +use serde_json::Value; +use toolkit_security::pep_properties; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::api::rest::problem; +use crate::api::rest::state::OagwState; +use crate::control_plane::scoping; +use crate::control_plane::service::ServiceError; +use crate::control_plane::validation::ResourceKind; +use crate::domain::error::{DomainError, ErrorKind}; +use crate::domain::plugin_contract::PluginFamily; + +/// The `create` permission the family declares. +pub(crate) const CREATE: &str = "create"; +/// The `read` permission the family declares. +pub(crate) const READ: &str = "read"; +/// The `override` permission the family declares; a replacement and any +/// `enabled` change need it, because the ten paths hold no dedicated +/// enable/disable operation. +pub(crate) const OVERRIDE: &str = "override"; +/// The `delete` permission the family declares. +pub(crate) const DELETE: &str = "delete"; +/// The `invoke` permission the proxy API declares, which the Data Plane +/// enforces before any resolution or cache read runs. +pub(crate) const INVOKE: &str = "invoke"; + +/// The PEP properties the two resource types declare to the enforcer. +const SUPPORTED_PROPERTIES: &[&str] = &[pep_properties::OWNER_TENANT_ID, pep_properties::RESOURCE_ID]; + +/// The enforcer's descriptor of one resource kind. +#[must_use] +pub fn resource_type(kind: ResourceKind) -> ResourceType { + match kind { + ResourceKind::Upstream => { + ResourceType::from_static(crate::gts::UPSTREAM_TYPE, SUPPORTED_PROPERTIES) + } + ResourceKind::Route => { + ResourceType::from_static(crate::gts::ROUTE_TYPE, SUPPORTED_PROPERTIES) + } + } +} + +/// Authenticates the request: the 401 a subjectless request answers with. +/// +/// A request that carries no `SecurityContext` extension was never +/// authenticated, so it is answered 401 and nothing else runs — no path +/// resolution, no validation, no store access. +/// +/// # Errors +/// +/// Returns the response the refusal answers with; the `Ok` arm carries the +/// authenticated context the caller enforces a permission against. +pub async fn authenticate<'a>( + context: Option<&'a SecurityContext>, + instance: &str, +) -> Result<&'a SecurityContext, Response> { + // @cpt-begin:cpt-cf-oagw-dod-authz-permissions:p1:inst-authz-401 + if let Some(context) = context { + return Ok(context); + } + tracing::warn!(instance, "management request reached no authenticated subject"); + Err(problem::problem_response(&unauthenticated(), instance)) + // @cpt-end:cpt-cf-oagw-dod-authz-permissions:p1:inst-authz-401 +} + +/// Enforces one operation's permission before any validation or store access. +/// +/// An authenticated request whose token lacks the permission is answered 403. A +/// state resolved with no enforcer at all fails closed, because a permission +/// this process cannot check is a permission it must not grant. +/// +/// # Errors +/// +/// Returns the response the refusal answers with; the `Ok` arm answers nothing +/// and the caller proceeds. +pub async fn enforce( + state: &OagwState, + context: &SecurityContext, + kind: ResourceKind, + action: &str, + id: Option, + instance: &str, +) -> Result<(), Response> { + // @cpt-begin:cpt-cf-oagw-dod-authz-permissions:p1:inst-authz-403 + let Some(enforcer) = state.enforcer() else { + tracing::warn!(instance, "no AuthZ client resolved; the management surface fails closed"); + return Err(problem::forbidden_response(gts_type(kind), instance)); + }; + if let Err(error) = enforcer + .access_scope(context, &resource_type(kind), action, id) + .await + { + tracing::warn!(instance, action, error = %error, "management permission refused"); + return Err(problem::forbidden_response(gts_type(kind), instance)); + } + // @cpt-end:cpt-cf-oagw-dod-authz-permissions:p1:inst-authz-403 + Ok(()) +} + +/// Authenticates the request and enforces one operation's permission, in that +/// order and before any validation or store access. +/// +/// # Errors +/// +/// Returns the response the refusal answers with; the `Ok` arm carries the +/// authenticated context the caller resolves the tenant from. +pub async fn authorize<'a>( + state: &'a OagwState, + context: Option<&'a SecurityContext>, + kind: ResourceKind, + action: &str, + id: Option, + instance: &str, +) -> Result<&'a SecurityContext, Response> { + let context = authenticate(context, instance).await?; + enforce(state, context, kind, action, id, instance).await?; + Ok(context) +} + +/// Resolves the calling tenant the request carries, answering 401 when the +/// authenticated subject carries none. +#[allow(clippy::result_large_err)] +pub fn tenant_of(context: &SecurityContext, instance: &str) -> Result { + scoping::calling_tenant(context).map_err(|error| problem::problem_response(&error, instance)) +} + +/// Enforces one operation's permission against the plugin arm one family +/// selects, before any validation or store access. +/// +/// The three plugin families are three resource types to the enforcer, so the +/// family the request selects — from the body's `plugin_type` for a create and +/// from the path identifier for an addressed operation — is what names the arm. +/// +/// # Errors +/// +/// Returns the response the refusal answers with; the `Ok` arm answers nothing +/// and the caller proceeds. +pub async fn enforce_plugin( + state: &OagwState, + context: &SecurityContext, + family: PluginFamily, + action: &str, + id: Option, + instance: &str, +) -> Result<(), Response> { + let Some(enforcer) = state.enforcer() else { + tracing::warn!(instance, "no AuthZ client resolved; the management surface fails closed"); + return Err(problem::forbidden_response(family.base_type(), instance)); + }; + if let Err(error) = enforcer + .access_scope(context, &plugin_resource_type(family), action, id) + .await + { + tracing::warn!(instance, action, error = %error, "management permission refused"); + return Err(problem::forbidden_response(family.base_type(), instance)); + } + Ok(()) +} + +/// Authenticates the request and enforces the permission of the plugin arm one +/// family selects, in that order and before any validation or store access. +/// +/// # Errors +/// +/// Returns the response the refusal answers with; the `Ok` arm carries the +/// authenticated context the caller resolves the tenant from. +pub async fn authorize_plugin<'a>( + state: &'a OagwState, + context: Option<&'a SecurityContext>, + family: PluginFamily, + action: &str, + id: Option, + instance: &str, +) -> Result<&'a SecurityContext, Response> { + let context = authenticate(context, instance).await?; + enforce_plugin(state, context, family, action, id, instance).await?; + Ok(context) +} + +/// Authenticates the request and enforces the permission of the arm one +/// plugin path selector names, in that order and before any validation or +/// store access. +/// +/// A selector whose prefix names a family is enforced against that arm with +/// the identifier its full form carries; a selector that names no family at +/// all names no arm, and is admitted only when the token holds the permission +/// on every one — the same posture a plugin list takes. +/// +/// # Errors +/// +/// Returns the response the refusal answers with; the `Ok` arm carries the +/// authenticated context the caller resolves the tenant from. +pub async fn authorize_selector<'a>( + state: &'a OagwState, + context: Option<&'a SecurityContext>, + selector: &crate::api::rest::params::PluginSelector, + action: &str, + instance: &str, +) -> Result<&'a SecurityContext, Response> { + let Some(family) = selector.family else { + return authorize_plugin_all(state, context, action, instance).await; + }; + authorize_plugin(state, context, family, action, selector.id, instance).await +} + +/// Enforces one operation's permission on every plugin arm, in family order. +/// +/// A list carries no path identifier and no body, so no single arm names the +/// resource it reads: the operation reads the catalogue of all three families, +/// and is admitted only when the token holds the permission on each. One +/// refusal is one refusal, answered before any query is built. +/// +/// # Errors +/// +/// Returns the response the first refusal answers with; the `Ok` arm answers +/// nothing and the caller proceeds. +pub async fn authorize_plugin_all<'a>( + state: &'a OagwState, + context: Option<&'a SecurityContext>, + action: &str, + instance: &str, +) -> Result<&'a SecurityContext, Response> { + const FAMILIES: [PluginFamily; 3] = [ + PluginFamily::Auth, + PluginFamily::Guard, + PluginFamily::Transform, + ]; + let context = authenticate(context, instance).await?; + for family in FAMILIES { + enforce_plugin(state, context, family, action, None, instance).await?; + } + Ok(context) +} + +/// Reads the request body as one JSON object. +/// +/// A body that is not JSON at all is answered before the validators run; the +/// detail names the failure and copies nothing from the body. +#[allow(clippy::result_large_err)] +pub fn parse_body(body: &Bytes, instance: &str) -> Result { + match serde_json::from_slice::(body) { + Ok(value) => Ok(value), + Err(_) => { + let error = DomainError::gateway( + ErrorKind::ValidationError, + "the request body is not a well-formed JSON document", + ); + Err(problem::problem_response(&error, instance)) + } + } +} + +/// The JSON response one representation answers with. +#[must_use] +pub fn json_response(status: StatusCode, representation: &Value) -> Response { + Response::builder() + .status(status) + .header( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("application/json"), + ) + .body(axum::body::Body::from(representation.to_string())) + .unwrap_or_else(|_| Response::new(axum::body::Body::empty())) +} + +/// The response one refused or failed operation answers with. +/// +/// A domain failure is logged with the failing property names and the +/// colliding identifier its detail carries; a persistence failure is logged +/// with the reason the store produced and answered with the platform's 500 +/// problem shape, which carries that reason nowhere. +#[must_use] +pub fn refused(error: &ServiceError, instance: &str) -> Response { + match error { + ServiceError::Domain(failure) => { + tracing::info!( + instance, + kind = failure.kind.title(), + detail = %failure.detail, + "management operation refused" + ); + problem::problem_response(failure, instance) + } + ServiceError::Forbidden { resource, permission } => { + tracing::warn!( + instance, + permission, + "a descendant override permission was refused" + ); + problem::forbidden_permission_response(gts_type(*resource), instance) + } + ServiceError::Storage { reason } => { + tracing::error!( + instance, + reason, + "the configuration store could not apply the management operation" + ); + problem::storage_problem_response(instance) + } + } +} + +/// The configuration-change record `cpt-cf-oagw-flow-config-change-logged` +/// writes when a management write completed. +/// +/// The record is written at the in-process post-write seam the cache flush and +/// the cleanup notifications occupy, so it is produced when the write is +/// durable and before the response is returned. `host`, `duration_ms`, +/// `request_size`, and `response_size` are omitted, because no proxy exchange +/// happened, and the record is never sampled, because a configuration change +/// is by definition not a high-volume event. A write that was refused writes +/// no record at all. +pub fn record_config_change( + state: &OagwState, + event: &'static str, + context: &SecurityContext, + method: &str, + instance: &str, + status: u16, + tenant: Uuid, +) { + // @cpt-begin:cpt-cf-oagw-flow-config-change-logged:p1:inst-cc-emit + // `cpt-cf-oagw-algo-audit-emit` builds the configuration-change + // `AuditEvent` at the same in-process post-write seam the cache flush and + // the cleanup notifications occupy: the event name carries the resource + // kind and the operation, `tenant_id` and `principal_id` are the writer's, + // `path` and `method` are the management path and method addressed, and + // `status` is the status the handler answered. + // @cpt-begin:cpt-cf-oagw-flow-config-change-logged:p1:inst-cc-level + // The record is written to stdout at INFO and is not subject to the + // high-volume sampling decision, because a configuration change is by + // definition not a high-volume event. + state.observability().config_change( + event, + Some(tenant), + Some(context.subject_id().to_string()), + method, + instance, + status, + ); + // @cpt-end:cpt-cf-oagw-flow-config-change-logged:p1:inst-cc-level + // @cpt-end:cpt-cf-oagw-flow-config-change-logged:p1:inst-cc-emit +} + +/// The `204 No Content` response a successful deletion answers with. +#[must_use] +pub fn no_content() -> Response { + Response::builder() + .status(StatusCode::NO_CONTENT) + .body(axum::body::Body::empty()) + .unwrap_or_else(|_| Response::new(axum::body::Body::empty())) +} + +/// The `instance` a problem document names: the request path the operation was +/// issued against. +#[must_use] +pub fn instance_of(uri: &Uri) -> String { + String::from(uri.path()) +} + +/// The 404 response a path-addressed identifier that addresses nothing answers +/// with. +#[must_use] +pub(crate) fn unaddressed(instance: &str) -> Response { + problem::problem_response(&scoping::path_miss(), instance) +} + +/// The GTS type of one resource kind. +fn gts_type(kind: ResourceKind) -> &'static str { + match kind { + ResourceKind::Upstream => crate::gts::UPSTREAM_TYPE, + ResourceKind::Route => crate::gts::ROUTE_TYPE, + } +} + +/// The enforcer's descriptor of one plugin family's base type. +#[must_use] +pub(crate) fn plugin_resource_type(family: PluginFamily) -> ResourceType { + ResourceType::from_static(family.base_type(), SUPPORTED_PROPERTIES) +} + +/// The 401 row a request with no authenticated subject answers with. +#[allow(clippy::result_large_err)] +fn unauthenticated() -> DomainError { + DomainError::gateway( + ErrorKind::AuthenticationFailed, + "the request carries no authenticated subject", + ) +} + +/// The `Arc` state extractor alias the handlers share. +pub type SharedState = Arc; diff --git a/gears/system/oagw/oagw/src/api/rest/handlers/plugins.rs b/gears/system/oagw/oagw/src/api/rest/handlers/plugins.rs new file mode 100644 index 0000000..ac14855 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers/plugins.rs @@ -0,0 +1,247 @@ +//! Plugin handlers — the five custom-plugin endpoints of DECOMPOSITION §2.4. +//! +//! The three plugin families are three resource types to the enforcer, so the +//! one thing every handler resolves first is the family the request selects: +//! the body's `plugin_type` for a create and the path identifier's prefix for +//! an addressed read or deletion. A list names no family at all, so it is +//! admitted only when the token holds `read` on every arm. + +use axum::body::Bytes; +use axum::extract::{OriginalUri, Path, State}; +use axum::http::StatusCode; +use axum::response::Response; +use axum::Extension; +use toolkit_security::SecurityContext; + +use super::{ + authenticate, authorize_plugin_all, authorize_selector, enforce_plugin, instance_of, + json_response, no_content, parse_body, record_config_change, refused, tenant_of, unaddressed, + CREATE, DELETE, READ, SharedState, +}; +use crate::api::rest::dto; +use crate::api::rest::params; +use crate::api::rest::problem; +use crate::control_plane::plugin_def; + +/// Creates one custom plugin: `POST /oagw/v1/plugins`. +/// +/// The body is read before the permission, because it is what selects the arm +/// the permission is enforced against; validation itself still runs after the +/// permission, in the service. +pub async fn create( + State(state): State, + original: OriginalUri, + context: Option>, + body: Bytes, +) -> Response { + let instance = instance_of(&original.0); + // @cpt-begin:cpt-cf-oagw-flow-plugin-create:p1:inst-pl-create-issue + // The actor's create request carries the plugin definition; the handler + // authenticates before it reads the body, because the body selects the arm + // and the arm is meaningless without a subject. + // @cpt-end:cpt-cf-oagw-flow-plugin-create:p1:inst-pl-create-issue + let context = match authenticate(bearer_of(&context), &instance).await { + Ok(context) => context, + Err(answer) => return answer, + }; + let body = match parse_body(&body, &instance) { + Ok(body) => body, + Err(answer) => return answer, + }; + let Some(family) = plugin_def::declared_family(&body) else { + // No arm is named by a body whose `plugin_type` selects none, so no + // single arm may speak for the request: every arm is enforced, and a + // token that holds none of them is refused before the validation the + // flow answers with. + if let Err(answer) = authorize_plugin_all(&state, Some(context), CREATE, &instance).await { + return answer; + } + let tenant = match tenant_of(context, &instance) { + Ok(tenant) => tenant, + Err(answer) => return answer, + }; + return match state.service().create_plugin(tenant, &body) { + Ok(row) => json_response(StatusCode::CREATED, &dto::plugin(&row)), + Err(error) => refused(&error, &instance), + }; + }; + // @cpt-begin:cpt-cf-oagw-flow-plugin-create:p1:inst-pl-create-authz + let enforced = enforce_plugin( + &state, + context, + family, + CREATE, + None, + &instance, + ) + .await; + // @cpt-end:cpt-cf-oagw-flow-plugin-create:p1:inst-pl-create-authz + if let Err(answer) = enforced { + return answer; + } + let tenant = match tenant_of(context, &instance) { + Ok(tenant) => tenant, + Err(answer) => return answer, + }; + match state.service().create_plugin(tenant, &body) { + Ok(row) => { + record_config_change( + &state, + crate::domain::observability::EVENT_PLUGIN_CREATED, + context, + "POST", + &instance, + u16::from(StatusCode::CREATED), + tenant, + ); + json_response(StatusCode::CREATED, &dto::plugin(&row)) + } + Err(error) => refused(&error, &instance), + } +} + +/// Lists the custom plugins of the calling tenant: `GET /oagw/v1/plugins`. +pub async fn list( + State(state): State, + original: OriginalUri, + context: Option>, +) -> Response { + let instance = instance_of(&original.0); + // @cpt-begin:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-issue + // The actor's read request: the list path, or one of the two addressed + // read paths below. + // @cpt-end:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-issue + // @cpt-begin:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-authz + // A list names no family, so every arm is enforced in turn. + let checked = authorize_plugin_all(&state, bearer_of(&context), READ, &instance).await; + // @cpt-end:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-authz + let context = match checked { + Ok(context) => context, + Err(answer) => return answer, + }; + let tenant = match tenant_of(context, &instance) { + Ok(tenant) => tenant, + Err(answer) => return answer, + }; + match state + .service() + .list_plugins(tenant, params::raw_query(&original.0)) + { + Ok(page) => json_response(StatusCode::OK, &dto::plugin_page(&page)), + Err(error) => refused(&error, &instance), + } +} + +/// Reads one custom plugin: `GET /oagw/v1/plugins/{id}`. +pub async fn read( + State(state): State, + original: OriginalUri, + context: Option>, + Path(id): Path, +) -> Response { + let instance = instance_of(&original.0); + // The path identifier is parsed, never answered, before the two gates. + let selector = params::PluginSelector::parse(&id); + let checked = authorize_selector(&state, bearer_of(&context), &selector, READ, &instance).await; + let context = match checked { + Ok(context) => context, + Err(answer) => return answer, + }; + let (_, id) = match selector.addressed() { + Ok(selector) => selector, + Err(error) => return problem::problem_response(&error, &instance), + }; + let tenant = match tenant_of(context, &instance) { + Ok(tenant) => tenant, + Err(answer) => return answer, + }; + match state.service().read_plugin(tenant, id) { + Ok(row) => json_response(StatusCode::OK, &dto::plugin(&row)), + Err(error) => refused(&error, &instance), + } +} + +/// Reads the Starlark source of one custom plugin: +/// `GET /oagw/v1/plugins/{id}/source`. +pub async fn source( + State(state): State, + original: OriginalUri, + context: Option>, + Path(id): Path, +) -> Response { + let instance = instance_of(&original.0); + let selector = params::PluginSelector::parse(&id); + let checked = authorize_selector(&state, bearer_of(&context), &selector, READ, &instance).await; + let context = match checked { + Ok(context) => context, + Err(answer) => return answer, + }; + let (_, id) = match selector.addressed() { + Ok(selector) => selector, + Err(error) => return problem::problem_response(&error, &instance), + }; + let tenant = match tenant_of(context, &instance) { + Ok(tenant) => tenant, + Err(answer) => return answer, + }; + match state.service().read_plugin_source(tenant, id) { + Ok(source) => json_response(StatusCode::OK, &dto::plugin_source(&source)), + Err(error) => refused(&error, &instance), + } +} + +/// Deletes one custom plugin: `DELETE /oagw/v1/plugins/{id}`. +pub async fn delete( + State(state): State, + original: OriginalUri, + context: Option>, + Path(id): Path, +) -> Response { + let instance = instance_of(&original.0); + // @cpt-begin:cpt-cf-oagw-flow-plugin-delete:p1:inst-pl-del-issue + // The actor's deletion request carries no body: the path identifier is + // the only address the operation holds. + // @cpt-end:cpt-cf-oagw-flow-plugin-delete:p1:inst-pl-del-issue + let selector = params::PluginSelector::parse(&id); + // @cpt-begin:cpt-cf-oagw-flow-plugin-delete:p1:inst-pl-del-authz + let checked = + authorize_selector(&state, bearer_of(&context), &selector, DELETE, &instance).await; + // @cpt-end:cpt-cf-oagw-flow-plugin-delete:p1:inst-pl-del-authz + let context = match checked { + Ok(context) => context, + Err(answer) => return answer, + }; + let (_, id) = match selector.addressed() { + Ok(selector) => selector, + Err(error) => return problem::problem_response(&error, &instance), + }; + let tenant = match tenant_of(context, &instance) { + Ok(tenant) => tenant, + Err(answer) => return answer, + }; + match state.service().delete_plugin(tenant, id) { + Ok(true) => { + record_config_change( + &state, + crate::domain::observability::EVENT_PLUGIN_DELETED, + context, + "DELETE", + &instance, + u16::from(StatusCode::NO_CONTENT), + tenant, + ); + no_content() + } + // The service resolved the row before the deletion, so a `false` + // answer means the row left between the resolution and the write; the + // answer is the same 404 the resolution would have produced. + Ok(false) => unaddressed(&instance), + Err(error) => refused(&error, &instance), + } +} + +/// The authenticated context the extractor carried, or `None` when it carried +/// none. +fn bearer_of(context: &Option>) -> Option<&SecurityContext> { + context.as_deref() +} diff --git a/gears/system/oagw/oagw/src/api/rest/handlers/proxy.rs b/gears/system/oagw/oagw/src/api/rest/handlers/proxy.rs new file mode 100644 index 0000000..f5595eb --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers/proxy.rs @@ -0,0 +1,1548 @@ +//! Proxy handlers — the Data Plane on the wire. +//! +//! The two routes of `cpt-cf-oagw-flow-proxy-request`: `{METHOD} +//! /oagw/v1/proxy/{alias}` and `{METHOD} /oagw/v1/proxy/{alias}/{path_suffix}`. +//! Both realize the same flow, which the shared [`serve`] owns in the order the +//! FEATURE states it: authorize, resolve, match, select, validate, the +//! rate-limit seam, the chain, the header transform, the forward, and the +//! classification. +//! +//! The handler is the only place a transport type meets the Data Plane: it +//! assembles the [`ProxyContext`] from the extractor set, hands it to the +//! routines, and maps every failure through the foundation's problem mapping, +//! which sets `X-OAGW-Error-Source: gateway`; an upstream answer is passed +//! through with `upstream` instead. + +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::{ConnectInfo, FromRequestParts, OriginalUri, Path, State}; +use axum::http::{HeaderMap, Method, Uri}; +use axum::response::Response; +use axum::Extension; +use parking_lot::Mutex; +use toolkit_security::SecurityContext; + +use super::SharedState; +use crate::api::rest::problem; +use crate::control_plane::chain; +use crate::data_plane::classify::classify_upstream_head; +use crate::data_plane::endpoint::select_endpoint; +use crate::data_plane::execute::run_request_phase; +use crate::data_plane::headers::transform_request; +use crate::data_plane::match_route::{failure_of, match_route}; +use crate::data_plane::observability::{ + DeferredObservation, EndpointObservation, Exchange, RateLimitObservation, +}; +use crate::data_plane::validate::{read_body, validate_inbound}; +use crate::data_plane::{consume, Resolution}; +use crate::domain::effective::RouteSelector; +use crate::domain::error::{DomainError, ErrorKind}; +use crate::domain::observability::CorrelationContext; +use crate::domain::plugin_contract::SANDBOX_LIMITS; +use crate::domain::proxy::ProxyContext; +use crate::domain::stream::{StreamSession, TransferMode}; + +/// The upgrade handle the HTTP layer placed in the request extensions, taken +/// so the tunnel `cpt-cf-oagw-feature-streaming` carries can ride the +/// connection the caller arrived on. A request the platform delivered with no +/// such handle leaves `None`, and no tunnel is carried over it. +#[derive(Debug)] +pub struct UpgradeHandle(Option); + +impl FromRequestParts for UpgradeHandle { + type Rejection = std::convert::Infallible; + + async fn from_request_parts( + parts: &mut http::request::Parts, + _state: &S, + ) -> Result { + Ok(Self(parts.extensions.remove::())) + } +} + +/// Answers `GET /oagw/v1/proxy/{alias}` and every other method on it. +#[allow(clippy::too_many_arguments)] +pub async fn root( + State(state): State, + original: OriginalUri, + method: Method, + uri: Uri, + headers: HeaderMap, + context: Option>, + peer: Option>>, + Path(alias): Path, + UpgradeHandle(upgrade): UpgradeHandle, + body: Body, +) -> Response { + serve( + &state, + method, + alias, + None, + uri.query(), + headers, + body, + context.map(|value| value.0), + peer.map(|value| (value.0).0), + upgrade, + instance_of(&original.0, &uri), + ) + .await +} + +/// Answers `{METHOD} /oagw/v1/proxy/{alias}/{path_suffix}`. +#[allow(clippy::too_many_arguments)] +pub async fn suffix( + State(state): State, + original: OriginalUri, + method: Method, + uri: Uri, + headers: HeaderMap, + context: Option>, + peer: Option>>, + Path((alias, path_suffix)): Path<(String, String)>, + UpgradeHandle(upgrade): UpgradeHandle, + body: Body, +) -> Response { + serve( + &state, + method, + alias, + Some(path_suffix), + uri.query(), + headers, + body, + context.map(|value| value.0), + peer.map(|value| (value.0).0), + upgrade, + instance_of(&original.0, &uri), + ) + .await +} + +/// The observation the proxy path's exit performs, held by the request's own +/// task so every return the path takes answers through it. +/// +/// The guard is armed at the path's entry, where the correlate step assigns +/// the correlation identifier and raises the in-flight gauge, and the exchange +/// it holds is the skeleton the path's steps fill as they produce values. Its +/// drop is the exit step of the observed flow, so a request that left the path +/// at any branch is still observed once and only once; the streamed exchanges +/// take the exchange out and defer the observation to the transfer's end. +struct PathExit { + observability: Arc, + exchange: Option, + correlation: Option, + timings: crate::data_plane::observability::PhaseTimings, +} + +impl PathExit { + /// Arms the guard at the path's entry: the correlate step of the observed + /// flow assigns the identifier, the gauge is raised for the alias the + /// request addressed, and the exchange is the skeleton to fill. + fn arm( + observability: Arc, + method: &Method, + alias: &str, + inbound: Option<&str>, + ) -> Self { + // @cpt-begin:cpt-cf-oagw-flow-request-observed:p1:inst-ro-correlate + // `cpt-cf-oagw-algo-correlate` runs before any authorization step: the + // identifier is assigned from the header the platform injected or + // generated, the in-flight gauge is raised for the alias the request + // addressed, and the context is recorded on the exchange the exit + // reads and on the `ProxyContext` the path's steps read. + let correlation = CorrelationContext::assign(inbound, None, None); + observability.raise_in_flight(alias); + // @cpt-end:cpt-cf-oagw-flow-request-observed:p1:inst-ro-correlate + Self { + exchange: Some(Exchange { + host: Some(String::from(alias)), + method: method.as_str().to_ascii_uppercase(), + ..Exchange::default() + }), + correlation: Some(correlation), + observability, + timings: crate::data_plane::observability::PhaseTimings::started(), + } + } + + /// The correlation context the request carries, for the `ProxyContext` the + /// path's steps read. + fn correlation(&self) -> Option { + self.correlation.clone() + } + + /// The exchange the path's steps fill in. + fn exchange(&mut self) -> &mut Exchange { + self.exchange + .as_mut() + .expect("the exit is armed for the whole of the path") + } + + /// Records the identity the platform resolved onto the correlation + /// context. + fn identify(&mut self, tenant_id: uuid::Uuid, principal_id: &str) { + if let Some(correlation) = self.correlation.as_mut() { + correlation.tenant_id = Some(tenant_id); + correlation.principal_id = Some(String::from(principal_id)); + } + } + + /// Stamps the moment the resolution completed. + fn resolved(&mut self) { + self.timings.resolved(); + } + + /// Stamps the moment the composed chain completed. + fn chained(&mut self) { + self.timings.chained(); + } + + /// Stamps the moment the outbound forward completed. + fn forwarded(&mut self) { + self.timings.forwarded(); + } + + /// Records the answer a gateway failure produced and maps it to the + /// response the caller is answered with. + /// + /// The correlation identifier is copied into the `ErrorContext` of the + /// error as its `trace_id` member, which + /// `cpt-cf-oagw-algo-error-mapping` attaches to the problem body as the + /// extension field it already reads: no problem body is built here and no + /// second serialization path is added. + fn gateway(&mut self, failure: &DomainError, instance: &str) -> Response { + // @cpt-begin:cpt-cf-oagw-flow-request-observed:p1:inst-ro-gateway-if + // The path answered the request from a gateway error it produced, so + // the identifier is echoed and the answer is a problem body. + let exchange = self.exchange(); + exchange.error = Some(failure.kind); + exchange.gateway_answer = true; + exchange.status = Some(failure.http_status()); + tracing::info!( + instance, + kind = failure.kind.title(), + detail = %failure.detail, + "proxy request refused by the gateway" + ); + // @cpt-begin:cpt-cf-oagw-flow-request-observed:p1:inst-ro-echo + let mut echoed = failure.clone(); + echoed.context.trace_id = self + .correlation + .as_ref() + .map(|correlation| correlation.request_id.clone()); + // @cpt-end:cpt-cf-oagw-flow-request-observed:p1:inst-ro-echo + // @cpt-end:cpt-cf-oagw-flow-request-observed:p1:inst-ro-gateway-if + problem::problem_response(&echoed, instance) + } + + /// Records the answer a bare problem response carries, which is the CORS + /// refusal: the answer is outside the catalogue and names no error type. + fn bare(&mut self, status: u16) { + let exchange = self.exchange(); + exchange.gateway_answer = true; + exchange.status = Some(status); + } + + /// Records the answer the authorize step gave a caller whose identity or + /// whose permission it could not establish. + /// + /// The answer is the platform's permission-denied problem body and not a + /// catalogue row of this feature, so it is recorded as bare; §1.5 row 177 + /// names it as an authentication failure this feature records, so the + /// exchange is marked for the `auth.failed` event the closed set holds. + fn authentication_failure(&mut self, status: u16) { + let exchange = self.exchange(); + exchange.gateway_answer = true; + exchange.authentication_failure = true; + exchange.status = Some(status); + } + + /// Records that the path resolved a configured upstream before it went on + /// to answer, which is what decides the `host` label of the answer + /// families: a request that never resolved names no upstream and is filed + /// under the one bounded literal instead of the alias the caller + /// addressed. + fn upstream_resolved(&mut self) { + self.exchange().upstream_resolved = true; + } + + /// Records the endpoint selection the path performed. + fn endpoint( + &mut self, + upstream_id: uuid::Uuid, + endpoint_host: String, + choice: crate::domain::proxy::EndpointChoice, + ) { + self.exchange().endpoint = Some(EndpointObservation { + upstream_id, + endpoint_host, + method: crate::domain::observability::selection_method_label(choice), + used_header: choice == crate::domain::proxy::EndpointChoice::Header, + }); + } + + /// Records the rate-limit outcome the check produced. + fn rate_limit(&mut self, observation: RateLimitObservation) { + self.exchange().rate_limit = Some(observation); + } + + /// Records the breaker state and the transitions the machine reported. + fn breaker( + &mut self, + phase: Option, + transitions: Vec, + ) { + self.exchange().breaker = Some(crate::data_plane::observability::BreakerObservation { + phase, + transitions, + }); + } + + /// Takes the exchange and the correlation out and returns the observation + /// the transfer's end runs. + /// + /// The streamed exchanges are the ones that take it: the transfer outlives + /// the response head, so the observation runs when the body's session ends + /// and the in-flight gauge stays raised for the whole of it. + #[must_use] + fn defer_to_transfer( + &mut self, + session: Arc>, + ) -> DeferredObservation { + let (mut exchange, correlation, observability) = self.take(); + exchange.session = Some(session); + observability.defer(exchange, correlation) + } + + /// Takes the exchange and the correlation out, disarming the guard. + fn take( + &mut self, + ) -> ( + Exchange, + Option, + Arc, + ) { + // The timings travel with the exchange, so a deferred observation of a + // streamed transfer carries the same four phases a finished one does. + let mut exchange = self + .exchange + .take() + .expect("the exit is armed for the whole of the path"); + exchange.timings = Some(self.timings); + ( + exchange, + self.correlation.take(), + Arc::clone(&self.observability), + ) + } +} + +impl Drop for PathExit { + // @cpt-begin:cpt-cf-oagw-flow-request-observed:p1:inst-ro-observe + // The exit of the path, taken on every return the path can make after the + // `ProxyResponse` exists or the streamed transfer has ended: + // `cpt-cf-oagw-algo-metrics-observe` applies the cardinality rules and + // updates the twelve families from the execution context and the sibling + // states. + fn drop(&mut self) { + let Some(mut exchange) = self.exchange.take() else { + return; + }; + exchange.timings = Some(self.timings); + self.observability.observe(&exchange, self.correlation.as_ref()); + } + // @cpt-end:cpt-cf-oagw-flow-request-observed:p1:inst-ro-observe +} + +/// The `instance` a proxy problem document names: the request path the +/// operation was issued against, query excluded. +#[must_use] +fn instance_of(_original: &Uri, uri: &Uri) -> String { + String::from(uri.path()) +} + +/// Runs one proxy exchange through the Data Plane. +#[allow(clippy::too_many_arguments)] +#[allow(clippy::too_many_lines)] +async fn serve( + state: &SharedState, + method: Method, + alias: String, + path_suffix: Option, + query: Option<&str>, + headers: HeaderMap, + body: Body, + security: Option, + peer: Option, + upgrade: Option, + instance: String, +) -> Response { + // @cpt-begin:cpt-cf-oagw-flow-request-observed:p1:inst-ro-issue + // The actor's request is the one this path is serving, and its answer is + // either the upstream's or a problem document: nothing about it is + // decided here, and the flow only observes what the path produces. + // @cpt-begin:cpt-cf-oagw-flow-request-observed:p1:inst-ro-api + // The API is the proxy path `cpt-cf-oagw-feature-data-plane-proxy` + // registers, which is the path this flow is reached from and not a second + // registration of it. + // @cpt-end:cpt-cf-oagw-flow-request-observed:p1:inst-ro-api + // @cpt-end:cpt-cf-oagw-flow-request-observed:p1:inst-ro-issue + + // @cpt-begin:cpt-cf-oagw-flow-cors-preflight:p1:inst-cpf-receive + // The request the proxy handler holds arrives here first, before the + // permission check and the resolution step of + // `cpt-cf-oagw-flow-proxy-request` run: the three-part detection reads the + // method and two headers and nothing else, so the answer below is produced + // whatever the alias names and whether it resolves at all (§1.5). + let preflight = preflight_of(&method, &headers); + // @cpt-end:cpt-cf-oagw-flow-cors-preflight:p1:inst-cpf-receive + + // @cpt-begin:cpt-cf-oagw-flow-cors-preflight:p1:inst-cpf-preflight-if + // The three-part detection ADR 0004 states: method `OPTIONS`, an `Origin` + // header, and an `Access-Control-Request-Method` header. Nothing behind + // the answer is resolved, read, or charged. + if let Some(answer) = preflight { + // @cpt-begin:cpt-cf-oagw-flow-cors-preflight:p1:inst-cpf-headers + // `cpt-cf-oagw-algo-cors-preflight-headers` builds the header set from + // the request's own three header values and from the constant max age, + // reading no configuration and resolving no upstream. + let answer = crate::domain::cors::preflight_answer( + answer.origin.as_deref(), + answer.request_method.as_deref(), + answer.request_headers.as_deref(), + ); + // @cpt-end:cpt-cf-oagw-flow-cors-preflight:p1:inst-cpf-headers + + // @cpt-begin:cpt-cf-oagw-flow-cors-preflight:p1:inst-cpf-return + // RETURN 204 with that header set and no body: no upstream resolution, + // no tenant context, no route match, no endpoint selection, no plugin + // execution, no rate-limit charge, and no permission check, so the + // answer discloses nothing but the permissiveness ADR 0004 fixes. + return preflight_response(&answer, &instance); + // @cpt-end:cpt-cf-oagw-flow-cors-preflight:p1:inst-cpf-return + } + // @cpt-end:cpt-cf-oagw-flow-cors-preflight:p1:inst-cpf-preflight-if + + // @cpt-begin:cpt-cf-oagw-flow-cors-preflight:p1:inst-cpf-preflight-else + // The ELSE of the detection: the request failed the three-part test, so it + // is an ordinary proxy request and not a CORS preflight. + // @cpt-begin:cpt-cf-oagw-flow-cors-preflight:p1:inst-cpf-else-return + // RETURN nothing: the proxy flow below resolves, matches, authenticates, + // validates, and charges it like any other request. + // @cpt-end:cpt-cf-oagw-flow-cors-preflight:p1:inst-cpf-else-return + // @cpt-end:cpt-cf-oagw-flow-cors-preflight:p1:inst-cpf-preflight-else + + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-issue + // The actor's request carries the method, the alias, an optional path + // suffix, an optional query, and any headers, and the answer is the + // upstream's or a problem document. + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-api + // The route classified the request to the Data Plane by path; the platform + // middleware has already authenticated the bearer token and resolved the + // calling tenant and subject into the context this handler reads. + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-api + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-issue + + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-issue + // The actor's request that carries a body arriving over time is the same + // request any proxy exchange carries, and nothing about it is known here + // until the upstream's answer names the transfer mode. + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-api + // The API is the proxy path `cpt-cf-oagw-feature-data-plane-proxy` + // registers, taken through the permission check, the resolution, the match, + // the validations, the rate-limit charge, and the composed chain exactly as + // for a non-streaming request: no streaming-specific relaxation applies + // anywhere before the send, and the transfer begins only at the response + // head below. + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-api + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-issue + + // @cpt-begin:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-issue + // The actor's upgrade request carries the `GET` method, the `Upgrade` + // header naming `websocket`, the `Connection` header naming the `upgrade` + // token, the handshake's own request headers, and the same bearer token + // every proxy request carries. + // @cpt-begin:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-api + // The API is the same proxy path, taken under those conditions with the + // permission check, the resolution, the match, the validations, the + // rate-limit charge, and the composed chain all executed before this flow + // is reached, so the upgrade request bypasses nothing of it. + // @cpt-end:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-api + // @cpt-end:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-issue + + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-normalize + // The same normalization routine the write path uses, so a proxy resolution + // can never disagree with a stored alias about shape, case, or a trailing + // dot. + let normalized = match crate::domain::Alias::parse(&alias) { + Ok(normalized) => normalized, + Err(_) => { + return gateway( + &DomainError::gateway( + ErrorKind::RouteNotFound, + "the request addressed no alias the gateway can normalize", + ), + &instance, + ); + } + }; + let alias = normalized.to_string(); + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-normalize + + let mut exit = PathExit::arm( + Arc::clone(state.observability()), + &method, + &alias, + crate::data_plane::observability::correlation_header(&header_pairs(&headers)), + ); + + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-authorize + // `cpt-cf-oagw-flow-proxy-authorize` runs before any resolution or cache + // read: the platform resolved the caller and this handler enforces the + // `:invoke` permission of the proxy API on it. + // @cpt-begin:cpt-cf-oagw-flow-proxy-authorize:p1:inst-authz-context + // The calling tenant and the subject come from the resolved + // SecurityContext; a request with neither fails closed rather than + // proceeding, and answers before any resolution or cache read. + let Some(context) = security.as_ref() else { + return exit.gateway( + &DomainError::gateway( + ErrorKind::AuthenticationFailed, + "the request carries no authenticated subject", + ), + &instance, + ); + }; + let tenant = match crate::control_plane::scoping::calling_tenant(context) { + Ok(tenant) => tenant, + Err(error) => return exit.gateway(&error, &instance), + }; + let subject = context.subject_id(); + exit.identify(tenant, &subject.to_string()); + // @cpt-end:cpt-cf-oagw-flow-proxy-authorize:p1:inst-authz-context + + // @cpt-begin:cpt-cf-oagw-flow-proxy-authorize:p1:inst-authz-ownership + // The ownership condition is satisfied by the chain resolution itself: the + // candidate set the walk produces contains only rows of the calling tenant + // and its ancestors, so a request that reaches a route at all reached a + // route of its own chain, and no separate ownership check runs here. + // @cpt-end:cpt-cf-oagw-flow-proxy-authorize:p1:inst-authz-ownership + + // @cpt-begin:cpt-cf-oagw-flow-proxy-authorize:p1:inst-authz-empty-if + // An alias outside the caller's chain is never a candidate, so the + // resolution's empty answer is the 404 an unauthorized caller is answered + // with, and never 403: the answer names no alias that exists outside its + // chain. + // @cpt-begin:cpt-cf-oagw-flow-proxy-authorize:p1:inst-authz-empty-return + // RETURN the empty candidate set, which the resolution step maps to the 404 + // the flow answers with. + // @cpt-end:cpt-cf-oagw-flow-proxy-authorize:p1:inst-authz-empty-return + // @cpt-end:cpt-cf-oagw-flow-proxy-authorize:p1:inst-authz-empty-if + + // @cpt-begin:cpt-cf-oagw-flow-proxy-authorize:p1:inst-authz-return + // RETURN the authorized context carrying the tenant, the subject, and the + // permission verdict, for the resolution step to consume. + // @cpt-end:cpt-cf-oagw-flow-proxy-authorize:p1:inst-authz-return + + // @cpt-begin:cpt-cf-oagw-flow-proxy-authorize:p1:inst-authz-delegate-if + // @cpt-begin:cpt-cf-oagw-flow-proxy-authorize:p1:inst-authz-permission + // The platform middleware authenticates; the permission of the proxy API is + // this handler's to enforce, before any resolution or cache read. An + // enforcer the state does not hold fails closed, exactly as the management + // surface does. + let Some(enforcer) = state.enforcer() else { + tracing::warn!(instance, "no AuthZ client resolved; the proxy surface fails closed"); + exit.authentication_failure(403); + return problem::forbidden_response(crate::gts::PROXY_TYPE, &instance); + }; + if let Err(error) = enforcer + .access_scope( + context, + &proxy_resource_type(), + super::INVOKE, + None, + ) + .await + { + tracing::warn!(instance, error = %error, "proxy permission refused"); + exit.authentication_failure(403); + return problem::forbidden_response(crate::gts::PROXY_TYPE, &instance); + } + // @cpt-end:cpt-cf-oagw-flow-proxy-authorize:p1:inst-authz-permission + // @cpt-begin:cpt-cf-oagw-flow-proxy-authorize:p1:inst-authz-delegate-else + // The ELSE of the delegation check: this deployment's middleware enforces + // no permission of its own, so the handler's enforcement is the only one + // and its decision is authoritative. + // @cpt-begin:cpt-cf-oagw-flow-proxy-authorize:p1:inst-authz-delegate-continue + // CONTINUE with the handler's own verdict, which is the enforcement this + // deployment has. + // @cpt-end:cpt-cf-oagw-flow-proxy-authorize:p1:inst-authz-delegate-continue + // @cpt-end:cpt-cf-oagw-flow-proxy-authorize:p1:inst-authz-delegate-else + // @cpt-end:cpt-cf-oagw-flow-proxy-authorize:p1:inst-authz-delegate-if + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-authorize + + // The request context the resolution, the validation, and the plugins + // read: the identity the platform resolved, the alias as normalized, and + // the request as it arrived. + let context = ProxyContext { + method: method.as_str().to_ascii_uppercase(), + alias: alias.clone(), + path_suffix, + query: query.map(String::from), + headers: header_pairs(&headers), + target_host: headers + .get("x-oagw-target-host") + .and_then(|value| value.to_str().ok()) + .map(String::from), + tenant_id: tenant, + subject_id: Some(subject), + correlation: exit.correlation(), + }; + + // @cpt-begin:cpt-cf-oagw-flow-request-observed:p1:inst-ro-path + // The path's own steps run here, restated by reference and decided by + // their own feature: the alias normalization, the authorization, the + // resolution, the match, the endpoint selection, the inbound and body + // validation, the rate-limit check, the composed chain, the header + // transformation, the forward, and the response classification that + // produces the `ProxyResponse` the exit of this flow reads. + // @cpt-end:cpt-cf-oagw-flow-request-observed:p1:inst-ro-path + + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-resolve + // The candidate set comes from the L1 cache on a hit and from the + // hierarchical feature's resolution on a miss; the ancestor chain is the + // platform tenant-resolver's, and its absence is an empty chain, which is + // the calling tenant alone. + let ancestors = chain::chain_of(state.resolver(), context_security(security.as_ref()), tenant) + .await + .map(|chain| chain.tenants().to_vec()) + .unwrap_or_default(); + let selector = RouteSelector::Http { + method: context.method.clone(), + path: context.request_path(), + }; + let resolution = consume( + state.store(), + state.dp_cache(), + tenant, + &ancestors, + &alias, + &selector, + ); + exit.resolved(); + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-resolve + + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-resolve-if + // An empty candidate set, a disabled upstream, and a gRPC upstream are the + // three outcomes the resolution answers before any route is matched, and + // none of them builds an outbound request. The failed-closed outcome is the + // platform 500 problem shape, not a catalogue row. + let Resolution::Resolved(resolved) = resolution else { + if let Resolution::Failed = resolution { + tracing::error!( + instance, + "the effective configuration could not be resolved; the request failed closed" + ); + exit.bare(500); + return problem::storage_problem_response(&instance); + } + let failure = resolution + .failure_of() + .unwrap_or_else(|| DomainError::gateway(ErrorKind::RouteNotFound, "no route matched")); + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-resolve-return + // 404 with the `RouteNotFound` variant for an empty candidate set or an + // unmatched route, and 503 with the `LinkUnavailable` variant for a + // disabled upstream; no outbound request is built. + return exit.gateway(&failure, &instance); + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-resolve-return + }; + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-resolve-if + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-resolve-else + // The resolved configuration carries the candidate set the match selects + // from. + exit.upstream_resolved(); + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-resolve-else + + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-match + // The match selects by method allowlist, longest path prefix, priority, and + // `path_suffix_mode`, over the candidate set the resolution produced, in + // the path space the route paths address. + let outcome = match_route( + &resolved, + None, + &context.method, + &context.request_path(), + context.path_suffix.as_deref(), + ); + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-match + + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-match-if + // The answer names the upstream that resolved, never a candidate that did + // not: a rejected suffix and an unmatched request are the two failures the + // match produces, answered 404 and 400. + let Some(matched) = (match &outcome { + crate::data_plane::MatchOutcome::Matched(matched) => Some(matched), + _ => None, + }) else { + let failure = failure_of(&outcome).unwrap_or_else(|| { + DomainError::gateway(ErrorKind::RouteNotFound, "no route matched the request") + }); + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-match-return + // The answer names the upstream that resolved, never a candidate that + // did not. + return exit.gateway(&failure, &instance); + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-match-return + }; + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-match-if + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-match-else + // The match selected a route, and the selection is what the steps below + // consume; no branch of its own is taken here. + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-match-else + + exit.exchange().route = Some(matched.match_pattern.clone()); + + // @cpt-begin:cpt-cf-oagw-flow-cors-enforce:p1:inst-cfe-receive + // The check request arrives from the proxy path after its resolution and + // route match have produced the effective configuration and the matched + // route, and before its rate-limit check and its composed chain run: the + // two layer results the resolution attached are the flow's input, and the + // origin is the value the platform delivered byte-exact. + let origin = text_header(&headers, "origin"); + let mut decoration: Option = None; + // @cpt-end:cpt-cf-oagw-flow-cors-enforce:p1:inst-cfe-receive + + // @cpt-begin:cpt-cf-oagw-flow-cors-enforce:p1:inst-cfe-no-origin-if + // A request that carries no `Origin` header is admitted by nothing here: + // DESIGN §3.2 qualifies both CORS guard rows "actual cross-origin requests + // only", so the flow is not invoked for it and the forwarded answer carries + // no CORS header of any kind. + // @cpt-begin:cpt-cf-oagw-flow-cors-enforce:p1:inst-cfe-no-origin + // RETURN the not-cross-origin outcome with no decoration and no CORS + // header of any kind, which is what `decoration` holds when the branch + // below never runs. + // @cpt-end:cpt-cf-oagw-flow-cors-enforce:p1:inst-cfe-no-origin + if let Some(origin) = origin { + // @cpt-end:cpt-cf-oagw-flow-cors-enforce:p1:inst-cfe-no-origin-if + // @cpt-begin:cpt-cf-oagw-flow-cors-enforce:p1:inst-cfe-no-origin-else + // The ELSE of the origin check: the request is cross-origin, so the + // effective configuration is folded and the request is decided. + // @cpt-end:cpt-cf-oagw-flow-cors-enforce:p1:inst-cfe-no-origin-else + + // @cpt-begin:cpt-cf-oagw-flow-cors-enforce:p1:inst-cfe-fold + // `cpt-cf-oagw-algo-cors-fold` applies its per-member overlay across + // the two layer results in the upstream, then route order, and reports + // the absent family when no layer carries one or the prevailing + // `enabled` is false. + let policy = crate::domain::cors::fold(resolved.cors.as_ref(), matched.cors.as_ref()); + // @cpt-end:cpt-cf-oagw-flow-cors-enforce:p1:inst-cfe-fold + + // @cpt-begin:cpt-cf-oagw-flow-cors-enforce:p1:inst-cfe-decide + // `cpt-cf-oagw-algo-cors-decide` evaluates that configuration against + // the request's `Origin` and its method; an absent family decides + // nothing and decorates nothing. + let decision = policy + .map(|policy| crate::domain::cors::decide(&policy, Some(origin.as_str()), context.method.as_str())); + // @cpt-end:cpt-cf-oagw-flow-cors-enforce:p1:inst-cfe-decide + + // @cpt-begin:cpt-cf-oagw-flow-cors-enforce:p1:inst-cfe-allow-if + match decision { + Some(crate::domain::cors::CorsDecision::Allowed(admitted)) => { + // @cpt-begin:cpt-cf-oagw-flow-cors-enforce:p1:inst-cfe-allow + // RETURN the admission with the decoration the decision + // computed, carried on the response the proxy path assembles + // below, and let the proxy path forward the request. + decoration = Some(admitted); + // @cpt-end:cpt-cf-oagw-flow-cors-enforce:p1:inst-cfe-allow + } + // @cpt-end:cpt-cf-oagw-flow-cors-enforce:p1:inst-cfe-allow-if + // @cpt-begin:cpt-cf-oagw-flow-cors-enforce:p1:inst-cfe-allow-else + // The ELSE of the decision: the request is refused, and the answer + // is produced before anything is forwarded. + // @cpt-end:cpt-cf-oagw-flow-cors-enforce:p1:inst-cfe-allow-else + Some(crate::domain::cors::CorsDecision::Refused(reason)) => { + // @cpt-begin:cpt-cf-oagw-flow-cors-enforce:p1:inst-cfe-refuse + // RETURN 403 with the problem body the decision names — the + // origin type for a disallowed origin and the method type for a + // disallowed method — carrying `Vary: Origin` and + // `X-OAGW-Error-Source: gateway`, answered before anything is + // forwarded, so no counter is charged and no plugin runs. + exit.bare(403_u16); + return cors_refusal_response(&reason, origin.as_str(), context.method.as_str(), &instance); + // @cpt-end:cpt-cf-oagw-flow-cors-enforce:p1:inst-cfe-refuse + } + None => {} + } + } + // @cpt-begin:cpt-cf-oagw-flow-cors-enforce:p1:inst-cfe-return + // RETURN the admission, the decoration, or the 403: the outcome the + // enforcement produced is recorded in the request's execution context for + // `cpt-cf-oagw-feature-observability` to report, and this feature + // registers no sink and emits no metric of its own. + // @cpt-end:cpt-cf-oagw-flow-cors-enforce:p1:inst-cfe-return + + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-endpoint + // The selection reads the routing header the context already carried and + // never forwards it, and advances the per-upstream round-robin counter when + // no header named the endpoint. + let selected = match select_endpoint(&resolved, context.target_host.as_deref(), state.round_robin()) { + Ok(selected) => { + exit.endpoint( + resolved.upstream_id, + selected.endpoint.host.as_str().to_owned(), + selected.choice, + ); + selected + } + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-endpoint-if + Err(failure) => { + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-endpoint-return + // 400 with the `MissingTargetHost`, `InvalidTargetHost`, or + // `UnknownTargetHost` variant the selection named; no upstream call + // is attempted. + return exit.gateway(&failure, &instance); + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-endpoint-return + } + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-endpoint-if + }; + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-endpoint + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-endpoint-else + // The endpoint was selected, and the steps below send to it; no branch of + // its own is taken here. + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-endpoint-else + + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-inbound + // The method is the match's own filter, so the checks that remain are the + // query parameters against the matched route's allowlist and the header + // values against the injection check. + let validated = validate_inbound(&context, matched); + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-inbound + + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-body + // The body is read and validated together: the framing headers and the + // declared size are answered before any byte is buffered, and the read + // stops at the first byte past the hard limit. + let body_read = read_body(&context, body).await; + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-body + + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-validate-if + // Either validation answers the request before anything is forwarded, and + // no buffer of the body outlives the answer. + let body = match (validated, body_read) { + (Ok(()), Ok(body)) => { + exit.exchange().request_size = u64::try_from(body.len()).unwrap_or(u64::MAX); + body + } + (_, Err(failure)) | (Err(failure), _) => { + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-validate-return + return exit.gateway(&failure, &instance); + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-validate-return + } + }; + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-validate-if + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-validate-else + // Both validations passed, and the read body is what the outbound request + // carries. + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-validate-else + + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-ratelimit + // The rate-limit check of `cpt-cf-oagw-feature-rate-limiting` runs here, + // after the body validation and ahead of the composed chain, keyed on the + // identity the security context and the inbound connection carry. + let identity = crate::data_plane::ratelimit::LimitIdentity::new( + tenant, + Some(String::from(subject)), + peer, + ); + let verdict = crate::data_plane::ratelimit::check( + state.rate_limits(), + &resolved, + matched, + &identity, + std::time::Instant::now(), + ) + .await; + exit.rate_limit(RateLimitObservation { + exceeded: matches!( + verdict, + crate::data_plane::ratelimit::LimitVerdict::Rejected(_) + ), + usage_ratio: None, + }); + exit.breaker( + Some(state.rate_limits().lock().breaker(&crate::data_plane::ratelimit::upstream_prefix( + resolved.upstream_id, + )).phase), + Vec::new(), + ); + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-ratelimit + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-ratelimit-if + // The verdict is mapped to the answer the check's own flow produced: 429 + // with the header set for a refusal, 503 for a breaker that is not + // admitting, and the forward for an admission, a release, and a degraded + // admission. + match verdict { + crate::data_plane::ratelimit::LimitVerdict::Admitted + | crate::data_plane::ratelimit::LimitVerdict::Degraded => {} + crate::data_plane::ratelimit::LimitVerdict::Rejected(headers) => { + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-ratelimit-return + let failure = DomainError::with_retry_after( + ErrorKind::RateLimitExceeded, + "the request exceeded the effective rate limit", + headers.retry_after_seconds, + ); + let response = exit.gateway(&failure, &instance); + return with_rate_limit_headers(response, &headers); + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-ratelimit-return + } + crate::data_plane::ratelimit::LimitVerdict::Open { + retry_after_seconds, + } => { + let failure = DomainError::with_retry_after( + ErrorKind::CircuitBreakerOpen, + "the resolved upstream is not admitting attempts", + Some(retry_after_seconds), + ); + return exit.gateway(&failure, &instance); + } + } + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-ratelimit-if + + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-chain + // The composed chain of the upstream and route layers runs its request leg: + // auth, then the guards, then the transforms, whose mutations the header + // transform carries into the outbound map. + let chain_composed = match crate::plugins::chain::compose( + state.store(), + tenant, + &crate::domain::plugin_contract::NamedPluginRegistry::with_builtins(), + state.registries(), + None, + &state.store().upstream_plugin_rows(tenant, resolved.upstream_id), + &state.store().route_plugin_rows(tenant, matched.route_id), + ) { + Ok(composed) => composed, + Err(failure) => return exit.gateway(&failure, &instance), + }; + let mutations = match run_request_phase(&chain_composed, &context, &SANDBOX_LIMITS).await { + Ok(mutations) => mutations, + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-chain-if + Err(failure) => { + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-chain-return + // The gateway error `cpt-cf-oagw-algo-chain-execute` named, mapped + // through `cpt-cf-oagw-algo-error-mapping`. + return exit.gateway(&failure, &instance); + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-chain-return + } + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-chain-if + }; + exit.chained(); + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-chain + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-chain-else + // The chain executed and its mutations are what the transform and the + // forward below carry; no branch of its own is taken here. + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-chain-else + + // @cpt-begin:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-detect + // The three-part detection `cpt-cf-oagw-algo-stream-mode-select` applies to + // the request as the proxy path holds it, before the header transform builds + // the outbound map and before any strip runs: the method, the `Upgrade` + // header, and every value the `Connection` header carried, so a token list + // naming `upgrade` is read as the list it is. + let connections = context.header_values("connection").join(", "); + let detection = crate::domain::stream::upgrade_detection( + &context.method, + context.header("upgrade"), + if connections.is_empty() { + None + } else { + Some(connections.as_str()) + }, + ); + // @cpt-end:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-detect + // @cpt-begin:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-detect-if + // The negative branch of the detection: no suspension is recorded, all + // eight hop-by-hop headers are stripped, and no handshake is built, so the + // exchange stays a plain request/response transfer. + // @cpt-begin:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-detect-return + // RETURN no suspension and no `UpgradeHandshake`: `detection` is `None` + // here, so the transform strips all eight hop-by-hop headers and the + // exchange stays under `cpt-cf-oagw-flow-stream-transfer`. A request that + // fails any one of the three parts is an ordinary proxy request and not a + // handshake. + // @cpt-end:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-detect-return + // @cpt-end:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-detect-if + // @cpt-begin:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-detect-else + // The ELSE of the detection: the request failed none of the three parts, so + // the handshake is built and the send that follows it is the handshake's. + // @cpt-end:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-detect-else + + // @cpt-begin:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-build + // The handshake is built from the detection and the request, which records + // the suspended headers the transform forwards and leaves the answer + // unjudged until the upstream's own answer arrives. + let mut handshake = + detection.map(|detected| crate::domain::stream::UpgradeHandshake::build(detected, &context)); + // @cpt-end:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-build + + // @cpt-begin:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-return + // RETURN the `UpgradeHandshake`: it carries the suspended two and the + // forwarded handshake headers out of the build phase to the transform that + // applies them and to the judgement below, where the answer the send + // receives decides whether the session it opened reaches `Open`. The + // routine has no answer of its own to substitute at this point, so the + // value returned is the handshake as built and nothing else. + // @cpt-end:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-return + + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-forward + // The outbound map is built from the validated context, the resolved + // upstream's request rules, and the plugin mutations, and is sent once over + // the shared client. + let outbound_headers = + match transform_request(&context, &resolved.headers, &selected, &mutations, detection) { + Ok(headers) => headers, + Err(failure) => return exit.gateway(&failure, &instance), + }; + let request = crate::domain::proxy::OutboundRequest { + method: context.method.clone(), + scheme: selected.endpoint.scheme, + host: selected.endpoint.host.to_string(), + port: selected.endpoint.port, + path: outbound_path(context.query.as_deref(), &matched.outbound_path), + headers: outbound_headers, + body, + }; + // @cpt-begin:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-session + // The session of a detected upgrade is opened in `Opening` as the send + // begins, so it exists for as long as the handshake is in flight and + // carries the `tunnel` mode the detection fixed. + let opened = detection.is_some().then(|| { + Arc::new(Mutex::new(StreamSession::open_for_handshake( + tenant, + resolved.upstream_id, + ))) + }); + // @cpt-end:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-session + // @cpt-begin:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-try + // The send through `cpt-cf-oagw-algo-outbound-forward`, which applies the + // dial-time scheme check and bounds the wait for the answer with the + // `RequestTimeout` deadline. The response header is the boundary that + // deadline bounds, and the last moment at which the exchange can still be + // answered as a whole. + // @cpt-begin:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-send + // The handshake request is sent once and never re-issued, which is + // `cpt-cf-oagw-principle-no-retry` applied to the one request type that + // cannot be repeated idempotently: the caller's handshake key is spent and + // a second send would be a different handshake. + let attempt = match state.outbound().begin(&request, state.config()).await { + Ok(mut live) => live.head().await.map(|head| (live, head)), + Err(failure) => Err(failure), + }; + // @cpt-end:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-send + // @cpt-end:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-try + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-forward + + // @cpt-begin:cpt-cf-oagw-algo-breaker-count:p1:inst-brc-return + // The classification of one outbound attempt is delivered to the breaker + // machine the check consulted, which is the flow's input; a failed + // exchange is the classification of the failure the forward produced, and + // the machine counts only the three rows §1.5 enumerates. The count reads + // nothing back, so it stays off the request's latency budget. + let (succeeded, counted) = match &attempt { + Ok(_) => (true, false), + Err(failure) => ( + false, + matches!( + failure.kind, + ErrorKind::ConnectionTimeout | ErrorKind::RequestTimeout | ErrorKind::LinkUnavailable + ), + ), + }; + state + .rate_limits() + .lock() + .breaker(&crate::data_plane::ratelimit::upstream_prefix(resolved.upstream_id)) + .count(succeeded, counted, std::time::Instant::now()); + { + let mut limits = state.rate_limits().lock(); + let breaker = limits.breaker(&crate::data_plane::ratelimit::upstream_prefix( + resolved.upstream_id, + )); + exit.breaker(Some(breaker.phase), breaker.drain_reported()); + } + // @cpt-end:cpt-cf-oagw-algo-breaker-count:p1:inst-brc-return + + // @cpt-begin:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-catch + // @cpt-begin:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-fail-if + // A handshake that failed before any data moved is recorded on the + // handshake, closes the session it opened in `Opening`, and is answered + // with the failure the forward produced. + let (live, head) = match attempt { + Ok(pair) => { + exit.forwarded(); + pair + } + Err(failure) => { + if let Some(record) = handshake.as_mut() { + record.failed_before_data(); + } + if let Some(opened) = &opened { + // @cpt-begin:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-catch-handle + opened.lock().refuse(); + // @cpt-end:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-catch-handle + } + // @cpt-begin:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-fail-return + run_error_phase_of(&chain_composed, &failure); + return exit.gateway(&failure, &instance); + // @cpt-end:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-fail-return + } + }; + // @cpt-end:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-fail-if + // @cpt-end:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-catch + + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-headers + // The response header is what this flow is invoked after, and it is the + // boundary `proxy_timeout_secs` bounds. + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-headers + + // @cpt-begin:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-101-if + // The upstream's answer is judged on the handshake it was sent for: a 101 + // is the handshake taken up, anything else is an answer that passes + // through unchanged, and the session opened at the send moves with the + // judgement. + // @cpt-begin:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-101-if + if let Some(record) = handshake.as_mut() { + record.judge(head.status); + } + let tunnelling = opened.as_ref().and_then(|session| { + let mut guard = session.lock(); + if head.status == 101 { + // @cpt-begin:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-101-open + if let Ok(open) = guard.lifecycle.opened() { + guard.lifecycle = open; + } + // @cpt-end:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-101-open + Some(Arc::clone(session)) + } else { + // @cpt-begin:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-not-101-else + // @cpt-begin:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-not-101 + // The handshake was not taken up: the session closes without any + // half being read and no variant of the catalogue is substituted + // for the answer the upstream itself produced, so the caller + // receives the upstream's own response rather than a gateway + // variant of it. + guard.refuse(); + // @cpt-end:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-not-101 + // @cpt-end:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-not-101-else + None + } + }); + // @cpt-end:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-101-if + // @cpt-end:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-101-if + // @cpt-begin:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-101-else + // The ELSE of the 101 branch: no tunnel is carried and the connection + // stays a plain request/response exchange. + // @cpt-end:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-101-else + + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-classify + // A failed exchange is a gateway failure mapped by the foundation's + // problem mapping; the response leg of the chain runs on the upstream + // answer, whose headers the `headers.response` rules then transform, and + // the answer is tagged with its error source before any body byte moves. + let response_mutations = match crate::data_plane::execute::run_response_phase( + &chain_composed, + head.status, + &head.headers, + &SANDBOX_LIMITS, + ) { + Ok(mutations) => mutations, + Err(failure) => return exit.gateway(&failure, &instance), + }; + let mut classified = + classify_upstream_head(head.status, head.headers.clone(), &resolved.headers, &response_mutations); + // The status the caller was answered with is the upstream's own numeric + // code, which is what the `http.response.status_code` label and the + // record's `status` field carry on a streamed exchange. + exit.exchange().status = Some(classified.status); + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-classify + + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-mode + // The transfer mode is selected from the request the first half of + // `cpt-cf-oagw-algo-stream-mode-select` judged and from the response + // headers the send received, and it is the body transfer that owns + // whatever the classification hands over. + let (mode, _carry) = crate::domain::stream::select_mode( + detection, + head.status, + head.header("content-type"), + ); + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-mode + + // @cpt-begin:cpt-cf-oagw-dod-cors-headers:p1:inst-cors-attach + // The decoration an allowed decision computed rides the response the proxy + // path assembled: this feature attaches it and assembles, tags, and + // classifies nothing of the response itself. + if let Some(admitted) = &decoration { + classified.headers.extend(admitted.headers()); + } + // @cpt-end:cpt-cf-oagw-dod-cors-headers:p1:inst-cors-attach + + // @cpt-begin:cpt-cf-oagw-flow-proxy-request:p1:inst-px-return + // The record of the plugin use is issued after the response is produced and + // reads nothing back, so it is off the request's latency budget. + crate::data_plane::execute::record_last_used( + state.store(), + &chain_composed, + crate::store::unix_now(), + ); + // @cpt-end:cpt-cf-oagw-flow-proxy-request:p1:inst-px-return + + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-tunnel-if + if mode == TransferMode::Tunnel { + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-tunnel-return + // Nothing is returned for this flow to transfer: the exchange is + // `cpt-cf-oagw-flow-upgrade-proxy`'s and its two halves are already + // held there. + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-tunnel-return + // @cpt-begin:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-tunnel + // The 101 is answered with the headers the transform left, and the two + // halves become a byte tunnel in a task of its own, because the + // response this handler returns ends the handler's part in it and the + // tunnel outlives it. + let Some(handle) = upgrade else { + // The connection the caller arrived on carries no upgrade to + // fulfil, so no tunnel is carried over it and the session the send + // opened closes with it. The answer is the exchange's end, and the + // armed guard observes it as the path returns. + if let Some(session) = &tunnelling { + session.lock().refuse(); + } + return passthrough(&classified, Body::empty(), &instance); + }; + let session = tunnelling.expect("the tunnel is carried by the session the send opened"); + // @cpt-begin:cpt-cf-oagw-flow-request-observed:p1:inst-ro-observe + // The tunnel outlives the response head, so the observation is taken + // out of the guard and travels into the task the tunnel runs in: the + // in-flight gauge stays raised for the whole of the transfer and the + // record carries the byte counts as transferred. + let deferred = exit.defer_to_transfer(Arc::clone(&session)); + tokio::spawn(async move { + match handle.await { + Ok(caller) => crate::data_plane::stream::tunnel(live, session, caller).await, + Err(_upgrade_failed) => {} + } + drop(deferred); + }); + // @cpt-end:cpt-cf-oagw-flow-request-observed:p1:inst-ro-observe + return passthrough(&classified, Body::empty(), &instance); + // @cpt-end:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-tunnel + // @cpt-begin:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-return + // RETURN the outcome the tunnel records in the session the send opened, + // which is the record the request's execution context carries, for + // `cpt-cf-oagw-feature-observability` to report; this flow emits no log + // line, no metric, and no span of its own. The head below is the answer + // the handshake was judged with and the body it carries is empty, + // because the two halves are the tunnel's and no body is transferred + // past the 101. + // @cpt-end:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-return + } + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-tunnel-if + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-tunnel-else + // The ELSE of the tunnel branch: the body is transferred as it arrives. + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-tunnel-else + + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-session + // The session is opened in the `Open` state, carrying the caller's half, + // the upstream half the forward opened, the `incremental` mode the + // selection returned, the response `Content-Type` it attached, the idle + // deadline in force, and no outcome. + let session = Arc::new(Mutex::new(StreamSession::open_for_incremental( + tenant, + resolved.upstream_id, + crate::data_plane::classify::content_type_of(&classified).map(String::from), + ))); + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-session + + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-pump + // The pump transfers the body one chunk at a time, and the first chunk is + // awaited here so a stall and an abort can still be answered as a whole. + let body = match crate::data_plane::stream::incremental(live, Arc::clone(&session)).await { + Ok(body) => body, + Err(failure) => return exit.gateway(&failure, &instance), + }; + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-pump + + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-return + // The body is answered with its head, and the outcome of the transfer is + // recorded on the session the pump holds, which is the record the request's + // execution context carries; this flow emits no log line, no metric, and no + // span of its own. The observation is taken out of the guard and rides the + // body, so it runs when the transfer ends and not when the head is + // answered. + let deferred = exit.defer_to_transfer(Arc::clone(&session)); + // @cpt-begin:cpt-cf-oagw-flow-request-observed:p1:inst-ro-return + // RETURN the answer unchanged: this flow mutates no header, no status, and + // no body of any response except the `trace_id` extension field a gateway + // error body carries, and it adds no latency to the measured path beyond + // the reading of values the path already computed. + passthrough( + &classified, + Body::from_stream(ObservedBody { + inner: body, + deferred: Some(deferred), + }), + &instance, + ) + // @cpt-end:cpt-cf-oagw-flow-request-observed:p1:inst-ro-return + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-return +} + +/// The body of an incremental transfer, which carries the observation the +/// exchange's exit performs and lets it run when the transfer ends. +/// +/// The transport drops the body when the transfer ends or when the caller stops +/// accepting it, and the drop is what runs the deferred observation the guard +/// handed over. +struct ObservedBody { + inner: futures_util::stream::BoxStream<'static, Result>, + /// The exchange's deferred exit, read by the destructor the transport + /// runs when the transfer ends. + #[allow(dead_code)] + deferred: Option, +} + +impl Drop for ObservedBody { + // The body's own drop is the moment the transfer ended, and the field it + // holds is the observation that ends the exchange: the destructor runs it + // before the pump's half is closed under it. + fn drop(&mut self) {} +} + +impl futures_util::Stream for ObservedBody { + type Item = Result; + + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.inner.as_mut().poll_next(cx) + } +} + +/// The preflight the three-part detection produced: the method and two header +/// values the answer echoes, each absent when the platform delivered a value +/// that cannot be formed into a response header. +struct Preflight { + origin: Option, + request_method: Option, + request_headers: Option, +} + +/// Detects a CORS preflight from the method and two headers alone. +/// +/// The detection is presence-based, which is the three-part test ADR 0004 +/// states: a value the platform delivered that cannot be formed into a +/// response header leaves the request a preflight and only the header that +/// would echo it is omitted (§1.5 of the FEATURE). +#[must_use] +fn preflight_of(method: &Method, headers: &HeaderMap) -> Option { + if method != Method::OPTIONS { + return None; + } + if !headers.contains_key("origin") || !headers.contains_key("access-control-request-method") { + return None; + } + Some(Preflight { + origin: text_header(headers, "origin"), + request_method: text_header(headers, "access-control-request-method"), + request_headers: text_header(headers, "access-control-request-headers"), + }) +} + +/// One header value as the platform delivered it, byte-exact. +/// +/// A value the platform cannot form into a response header is reported absent +/// rather than guessed at, which is the refuse-rather-than-guess rule §1.4 +/// states for a value that cannot be compared byte-exactly. +#[must_use] +fn text_header(headers: &HeaderMap, name: &str) -> Option { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(String::from) +} + +/// The answer a preflight is mapped to: the 204 status, the header set, and no +/// body. +/// +/// A member the platform cannot form into a response header is omitted and the +/// omission is recorded in the request's execution context, which is the +/// record the correlation identifier and the trace surfaces read. +fn preflight_response(answer: &crate::domain::cors::PreflightAnswer, instance: &str) -> Response { + let status = axum::http::StatusCode::from_u16(answer.status) + .unwrap_or(axum::http::StatusCode::NO_CONTENT); + let mut response = Response::builder() + .status(status) + .header( + crate::api::rest::problem::ERROR_SOURCE_HEADER, + axum::http::HeaderValue::from_static("gateway"), + ) + .body(Body::empty()) + .unwrap_or_else(|_| Response::new(Body::empty())); + for (name, value) in &answer.headers { + match ( + axum::http::HeaderName::try_from(name.as_str()), + axum::http::HeaderValue::from_str(value), + ) { + (Ok(name), Ok(value)) => { + response.headers_mut().insert(name, value); + } + _ => tracing::info!( + instance, + header = %name, + "a preflight echo the platform cannot form is omitted" + ), + } + } + response +} + +/// The answer a CORS refusal is mapped to: the bare 403 problem document ADR +/// 0004 spells, with `Vary: Origin` and the gateway error-source tag, answered +/// before anything is forwarded. +fn cors_refusal_response( + refusal: &crate::domain::cors::CorsRefusal, + origin: &str, + method: &str, + instance: &str, +) -> Response { + let detail = crate::domain::cors::refusal_detail(*refusal, origin, method); + let mut response = problem::bare_forbidden_response( + refusal.gts_type(), + refusal.title(), + &detail, + instance, + ); + if let (Ok(name), Ok(value)) = ( + axum::http::HeaderName::try_from("vary"), + axum::http::HeaderValue::from_str(crate::domain::cors::VARY_ORIGIN), + ) { + response.headers_mut().insert(name, value); + } + tracing::info!( + instance, + reason = refusal.title(), + "cross-origin request refused before forwarding" + ); + response +} + +/// Runs the error leg of the chain on the failure the exchange produced. +fn run_error_phase_of( + chain: &crate::plugins::chain::ComposedChain, + failure: &DomainError, +) { + let mut failed = failure.clone(); + crate::data_plane::execute::run_error_phase(chain, &mut failed); +} + +/// The outbound path the request is dialed with: the matched route's path with +/// the suffix appended, and the query the inbound validation admitted, which +/// passed the same allowlist the route declares. +fn outbound_path(query: Option<&str>, matched_path: &str) -> String { + match query { + Some(query) if !query.is_empty() => format!("{matched_path}?{query}"), + _ => String::from(matched_path), + } +} + +/// The header pairs the HTTP layer held, in arrival order. +fn header_pairs(headers: &HeaderMap) -> Vec<(String, String)> { + headers + .iter() + .map(|(name, value)| { + ( + name.as_str().to_string(), + String::from_utf8_lossy(value.as_bytes()).into_owned(), + ) + }) + .collect() +} + +/// The enforcer's descriptor of the proxy resource. +#[must_use] +fn proxy_resource_type() -> authz_resolver_sdk::pep::ResourceType { + authz_resolver_sdk::pep::ResourceType::from_static( + crate::gts::PROXY_TYPE, + super::SUPPORTED_PROPERTIES, + ) +} + +/// The security context the chain walk reads, borrowed for the call. +fn context_security(context: Option<&SecurityContext>) -> &SecurityContext { + // The authorize step answered before this point, so the context is present. + context.expect("the authorized request carries its security context") +} + +/// The answer a gateway failure is mapped to. +fn gateway(failure: &DomainError, instance: &str) -> Response { + // @cpt-begin:cpt-cf-oagw-algo-response-classify:p1:inst-cls-gateway-if + // Every failure this helper receives was produced by the gateway — a + // validation, authorization, resolution, selection, chain, deadline, or + // scheme failure above — so the classification sends it to the + // foundation's problem mapping. + // @cpt-end:cpt-cf-oagw-algo-response-classify:p1:inst-cls-gateway-if + tracing::info!( + instance, + kind = failure.kind.title(), + detail = %failure.detail, + "proxy request refused by the gateway" + ); + problem::problem_response(failure, instance) +} + +/// Appends the rate-limit header set of a 429 answer to a built response. +fn with_rate_limit_headers( + mut response: Response, + headers: &crate::data_plane::ratelimit::RateLimitHeaders, +) -> Response { + for (name, value) in headers.pairs() { + if let (Ok(name), Ok(value)) = ( + axum::http::HeaderName::try_from(name.as_str()), + axum::http::HeaderValue::from_str(&value), + ) { + response.headers_mut().insert(name, value); + } + } + response +} + +/// The answer an upstream-sourced classification is mapped to: the status and +/// the headers the transform left, with the body the pump carries under them. +fn passthrough( + classified: &crate::domain::proxy::ProxyResponse, + body: Body, + _instance: &str, +) -> Response { + // @cpt-begin:cpt-cf-oagw-flow-request-observed:p1:inst-ro-gateway-else + // The path did not answer the request from a gateway error: the upstream's + // own answer passes through under the error-source classification. + // @cpt-begin:cpt-cf-oagw-flow-request-observed:p1:inst-ro-no-echo + // The body passes through unmodified and carries no `trace_id`, and no + // echo is synthesized for it. + // @cpt-end:cpt-cf-oagw-flow-request-observed:p1:inst-ro-no-echo + // @cpt-end:cpt-cf-oagw-flow-request-observed:p1:inst-ro-gateway-else + let content_type = crate::data_plane::classify::content_type_of(classified); + let mut response = problem::passthrough_response(classified.status, body, content_type); + for (name, value) in &classified.headers { + if let (Ok(name), Ok(value)) = ( + axum::http::HeaderName::try_from(name.as_str()), + axum::http::HeaderValue::from_str(value), + ) { + response.headers_mut().append(name, value); + } + } + response +} diff --git a/gears/system/oagw/oagw/src/api/rest/handlers/routes.rs b/gears/system/oagw/oagw/src/api/rest/handlers/routes.rs new file mode 100644 index 0000000..ee9a3c1 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers/routes.rs @@ -0,0 +1,321 @@ +//! Route handlers — the five `route` endpoints of DECOMPOSITION §2.2. +//! +//! The order is the upstream half's: the `route` permission of the operation is +//! enforced first, the calling tenant is resolved from the authenticated +//! subject, the body is read last, and the service outcome becomes a +//! representation, a `204`, or a problem document. A route replacement carries +//! no `upstream_id`, because the addressed row is where the reference comes +//! from. + +use axum::body::Bytes; +use axum::extract::{OriginalUri, Path, State}; +use axum::http::StatusCode; +use axum::response::Response; +use axum::Extension; +use toolkit_security::SecurityContext; + +use super::{ + authorize, instance_of, json_response, no_content, parse_body, record_config_change, refused, + tenant_of, + unaddressed, CREATE, DELETE, OVERRIDE, READ, SharedState, +}; +use crate::api::rest::dto; +use crate::api::rest::params; +use crate::api::rest::problem; +use crate::control_plane::chain; +use crate::control_plane::sharing::OverridePermissions; +use crate::control_plane::validation::ResourceKind; + +/// The ancestor chain the calling tenant resolves to, for the hierarchical +/// write decisions. +/// +/// A tenant the platform tenant-resolver cannot answer for resolves to no +/// ancestor at all, which is the fail-closed posture of the walk: with no +/// chain, no ancestor row is ever read, copied, or echoed. +async fn ancestors_of( + state: &SharedState, + context: &SecurityContext, + tenant: uuid::Uuid, +) -> Vec { + chain::chain_of(state.resolver(), context, tenant) + .await + .map(|chain| chain.tenants().to_vec()) + .unwrap_or_default() +} + +/// Creates one route: `POST /oagw/v1/routes`. +pub async fn create( + State(state): State, + original: OriginalUri, + context: Option>, + body: Bytes, +) -> Response { + let instance = instance_of(&original.0); + // @cpt-begin:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-issue + // The actor's create request carries the route DTO: `upstream_id`, + // `match`, `priority`, and the optional families. + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-issue + // The route's own `plugins` items ride on the same request; a route binds + // no auth plugin, so its body carries no `auth` sub-configuration. + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-issue + // @cpt-begin:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-authz + let checked = authorize( + &state, + bearer_of(&context), + ResourceKind::Route, + CREATE, + None, + &instance, + ) + .await; + // @cpt-end:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-authz + let context = match checked { + Ok(context) => context, + Err(answer) => return answer, + }; + let tenant = match tenant_of(context, &instance) { + Ok(tenant) => tenant, + Err(answer) => return answer, + }; + let body = match parse_body(&body, &instance) { + Ok(body) => body, + Err(answer) => return answer, + }; + // @cpt-end:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-issue + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-authz + // The route create permission is the one this operation consumes. + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-authz + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-return + match state.service().create_route(tenant, &body) { + Ok(row) => { + record_config_change( + &state, + crate::domain::observability::EVENT_ROUTE_CREATED, + context, + "POST", + &instance, + u16::from(StatusCode::CREATED), + tenant, + ); + json_response(StatusCode::CREATED, &dto::route(&row)) + } + Err(error) => refused(&error, &instance), + } + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-return +} + +/// Lists the routes of the calling tenant: `GET /oagw/v1/routes`. +pub async fn list( + State(state): State, + original: OriginalUri, + context: Option>, +) -> Response { + let instance = instance_of(&original.0); + let checked = authorize( + &state, + bearer_of(&context), + ResourceKind::Route, + READ, + None, + &instance, + ) + .await; + let context = match checked { + Ok(context) => context, + Err(answer) => return answer, + }; + let tenant = match tenant_of(context, &instance) { + Ok(tenant) => tenant, + Err(answer) => return answer, + }; + // @cpt-begin:cpt-cf-oagw-dod-list-query-parameters:p1:inst-list-bind-query + match state + .service() + .list_routes(tenant, params::raw_query(&original.0)) + // @cpt-end:cpt-cf-oagw-dod-list-query-parameters:p1:inst-list-bind-query + { + Ok(page) => json_response(StatusCode::OK, &dto::route_page(&page)), + Err(error) => refused(&error, &instance), + } +} + +/// Reads one route: `GET /oagw/v1/routes/{id}`. +pub async fn read( + State(state): State, + original: OriginalUri, + context: Option>, + Path(id): Path, +) -> Response { + let instance = instance_of(&original.0); + // The path identifier is parsed, never answered, before the two gates: a + // request that cannot state who it is or what it may do is told nothing + // about the path it named. + let selector = params::path_id(ResourceKind::Route, &id); + let checked = authorize( + &state, + bearer_of(&context), + ResourceKind::Route, + READ, + selector.as_ref().ok().copied(), + &instance, + ) + .await; + let context = match checked { + Ok(context) => context, + Err(answer) => return answer, + }; + let id = match selector { + Ok(id) => id, + Err(error) => return problem::problem_response(&error, &instance), + }; + let tenant = match tenant_of(context, &instance) { + Ok(tenant) => tenant, + Err(answer) => return answer, + }; + match state.service().read_route(tenant, id) { + Ok(row) => json_response(StatusCode::OK, &dto::route(&row)), + Err(error) => refused(&error, &instance), + } +} + +/// Replaces one route: `PUT /oagw/v1/routes/{id}`. +/// +/// The `override` permission covers the `enabled` flag, because the ten +/// management paths hold no dedicated enable or disable operation. +pub async fn replace( + State(state): State, + original: OriginalUri, + context: Option>, + Path(id): Path, + body: Bytes, +) -> Response { + let instance = instance_of(&original.0); + // The path identifier is parsed, never answered, before the two gates: a + // request that cannot state who it is or what it may do is told nothing + // about the path it named. + let selector = params::path_id(ResourceKind::Route, &id); + let checked = authorize( + &state, + bearer_of(&context), + ResourceKind::Route, + OVERRIDE, + selector.as_ref().ok().copied(), + &instance, + ) + .await; + let context = match checked { + Ok(context) => context, + Err(answer) => return answer, + }; + let id = match selector { + Ok(id) => id, + Err(error) => return problem::problem_response(&error, &instance), + }; + let tenant = match tenant_of(context, &instance) { + Ok(tenant) => tenant, + Err(answer) => return answer, + }; + let body = match parse_body(&body, &instance) { + Ok(body) => body, + Err(answer) => return answer, + }; + // @cpt-begin:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-issue + // @cpt-begin:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-authz + // The chain and the permission set the override decision consults. + let ancestors = ancestors_of(&state, context, tenant).await; + let permissions = OverridePermissions::of(context); + // @cpt-end:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-authz + // @cpt-end:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-issue + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-issue + // A route replacement carries the full replacement of its binding rows. + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-issue + // The binding decision consumes the permission the override flow already + // checked, so the plugin flow registers no gate of its own here. + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-return + match state + .service() + .replace_route_in_chain(tenant, id, &ancestors, &permissions, &body) + { + Ok(row) => { + record_config_change( + &state, + crate::domain::observability::EVENT_ROUTE_OVERRIDDEN, + context, + "PUT", + &instance, + u16::from(StatusCode::OK), + tenant, + ); + json_response(StatusCode::OK, &dto::route(&row)) + } + Err(error) => refused(&error, &instance), + } + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-return +} + +/// Deletes one route and its dependent rows: `DELETE /oagw/v1/routes/{id}`. +/// +/// The upstream the route was created under is untouched by the deletion. +pub async fn delete( + State(state): State, + original: OriginalUri, + context: Option>, + Path(id): Path, +) -> Response { + let instance = instance_of(&original.0); + // The path identifier is parsed, never answered, before the two gates: a + // request that cannot state who it is or what it may do is told nothing + // about the path it named. + let selector = params::path_id(ResourceKind::Route, &id); + // @cpt-begin:cpt-cf-oagw-flow-route-delete:p1:inst-rt-del-issue + // The actor's deletion request carries no body. + // @cpt-begin:cpt-cf-oagw-flow-route-delete:p1:inst-rt-del-authz + let checked = authorize( + &state, + bearer_of(&context), + ResourceKind::Route, + DELETE, + selector.as_ref().ok().copied(), + &instance, + ) + .await; + // @cpt-end:cpt-cf-oagw-flow-route-delete:p1:inst-rt-del-authz + let context = match checked { + Ok(context) => context, + Err(answer) => return answer, + }; + let id = match selector { + Ok(id) => id, + Err(error) => return problem::problem_response(&error, &instance), + }; + let tenant = match tenant_of(context, &instance) { + Ok(tenant) => tenant, + Err(answer) => return answer, + }; + // @cpt-end:cpt-cf-oagw-flow-route-delete:p1:inst-rt-del-issue + match state.service().delete_route(tenant, id) { + Ok(true) => { + record_config_change( + &state, + crate::domain::observability::EVENT_ROUTE_DELETED, + context, + "DELETE", + &instance, + u16::from(StatusCode::NO_CONTENT), + tenant, + ); + no_content() + } + // The service resolved the row before the deletion, so a `false` answer + // means the row left between the resolution and the write; the answer is + // the same 404 the resolution would have produced. + Ok(false) => unaddressed(&instance), + Err(error) => refused(&error, &instance), + } +} + +/// The authenticated context the extractor carried, or `None` when it carried +/// none. +fn bearer_of(context: &Option>) -> Option<&SecurityContext> { + context.as_deref() +} diff --git a/gears/system/oagw/oagw/src/api/rest/handlers/upstreams.rs b/gears/system/oagw/oagw/src/api/rest/handlers/upstreams.rs new file mode 100644 index 0000000..26c98f3 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers/upstreams.rs @@ -0,0 +1,374 @@ +//! Upstream handlers — the five `upstream` endpoints of DECOMPOSITION §2.2. +//! +//! Each handler realizes one step of the FEATURE's flows on the wire and hands +//! the rest to the management service: the permission of the operation is +//! enforced first, the calling tenant is resolved from the authenticated +//! subject, the body is read last, and the service outcome becomes a +//! representation, a `204`, or a problem document. + +use axum::body::Bytes; +use axum::extract::{OriginalUri, Path, State}; +use axum::http::StatusCode; +use axum::response::Response; +use axum::Extension; +use toolkit_security::SecurityContext; + +use super::{ + authorize, instance_of, json_response, no_content, parse_body, record_config_change, refused, + tenant_of, + unaddressed, CREATE, DELETE, OVERRIDE, READ, SharedState, +}; +use crate::api::rest::dto; +use crate::api::rest::params; +use crate::api::rest::problem; +use crate::control_plane::chain; +use crate::control_plane::sharing::OverridePermissions; +use crate::control_plane::validation::ResourceKind; + +/// The ancestor chain the calling tenant resolves to, for the hierarchical +/// write decisions. +/// +/// A tenant the platform tenant-resolver cannot answer for — a missing client, +/// a failed call, an unordered answer — resolves to no ancestor at all, which +/// is the fail-closed posture of the walk: with no chain, no ancestor row is +/// ever read, copied, or echoed, and every family the body carries is decided +/// `own`. The chain arrives ordered, calling tenant first. +async fn ancestors_of( + state: &SharedState, + context: &SecurityContext, + tenant: uuid::Uuid, +) -> Vec { + chain::chain_of(state.resolver(), context, tenant) + .await + .map(|chain| chain.tenants().to_vec()) + .unwrap_or_default() +} + +/// Creates one upstream: `POST /oagw/v1/upstreams`. +pub async fn create( + State(state): State, + original: OriginalUri, + context: Option>, + body: Bytes, +) -> Response { + let instance = instance_of(&original.0); + // @cpt-begin:cpt-cf-oagw-flow-config-change-logged:p1:inst-cc-issue + // The actor issues the management write — a create here, and the same + // shape a replacement, an `enabled` change, or a delete takes on the + // upstream, route, and plugin paths. + // @cpt-end:cpt-cf-oagw-flow-config-change-logged:p1:inst-cc-issue + // @cpt-begin:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-issue + // The actor's create request carries the upstream DTO; the handler answers + // it in the order: permission, tenant, body, service. + // @cpt-begin:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-issue + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-issue + // The same request carries the `plugins` sub-object and, for an upstream, + // the `auth` sub-configuration the plugin flow resolves and writes; no + // plugin path of its own is issued. + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-issue + // The same request reaches the hierarchical flow: whether it is an + // ordinary create or a bind against an ancestor's alias is decided after + // the alias is derived, by the service. + // @cpt-end:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-issue + // @cpt-begin:cpt-cf-oagw-flow-config-change-logged:p1:inst-cc-api + // The API is the management path `cpt-cf-oagw-feature-control-plane-config` + // registers: the platform middleware authenticates the bearer token and + // resolves the calling tenant and subject, and the handler below enforces + // the resource kind's permission. + // @cpt-end:cpt-cf-oagw-flow-config-change-logged:p1:inst-cc-api + // @cpt-begin:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-authz + let checked = authorize( + &state, + bearer_of(&context), + ResourceKind::Upstream, + CREATE, + None, + &instance, + ) + .await; + // @cpt-end:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-authz + let context = match checked { + Ok(context) => context, + Err(answer) => return answer, + }; + let tenant = match tenant_of(context, &instance) { + Ok(tenant) => tenant, + Err(answer) => return answer, + }; + let body = match parse_body(&body, &instance) { + Ok(body) => body, + Err(answer) => return answer, + }; + // @cpt-end:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-issue + // @cpt-begin:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-authz + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-authz + // The parent resource's management permission is the one this operation + // consumes: the plugin flow registers no path and consumes no permission + // of its own. + let ancestors = ancestors_of(&state, context, tenant).await; + let permissions = OverridePermissions::of(context); + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-authz + // @cpt-end:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-authz + // @cpt-begin:cpt-cf-oagw-flow-config-change-logged:p1:inst-cc-write + // The write itself — the validation, the one transaction, and the Control + // Plane cache flush — is `cpt-cf-oagw-feature-control-plane-config`'s act + // and is issued here through the management service. + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-return + match state + .service() + .create_upstream_in_chain(tenant, &ancestors, &permissions, &body) + { + // @cpt-begin:cpt-cf-oagw-flow-config-change-logged:p1:inst-cc-completed-if + Ok(row) => { + record_config_change( + &state, + crate::domain::observability::EVENT_UPSTREAM_CREATED, + context, + "POST", + &instance, + u16::from(StatusCode::CREATED), + tenant, + ); + json_response(StatusCode::CREATED, &dto::upstream(&row)) + } + // @cpt-begin:cpt-cf-oagw-flow-config-change-logged:p1:inst-cc-completed-else + // @cpt-begin:cpt-cf-oagw-flow-config-change-logged:p1:inst-cc-refused + // No configuration-change record is written for a refused write: the + // management path answers the refusal itself, with the correlation + // identifier, and logs neither a request body nor a configuration + // value. + Err(error) => refused(&error, &instance), + // @cpt-end:cpt-cf-oagw-flow-config-change-logged:p1:inst-cc-refused + // @cpt-end:cpt-cf-oagw-flow-config-change-logged:p1:inst-cc-completed-else + // @cpt-end:cpt-cf-oagw-flow-config-change-logged:p1:inst-cc-completed-if + // @cpt-begin:cpt-cf-oagw-flow-config-change-logged:p1:inst-cc-return + // RETURN the handler's response unchanged: this flow mutates no + // status, no header, and no body of it. + // @cpt-end:cpt-cf-oagw-flow-config-change-logged:p1:inst-cc-return + } + // @cpt-end:cpt-cf-oagw-flow-config-change-logged:p1:inst-cc-write + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-return +} + +/// Lists the upstreams of the calling tenant: `GET /oagw/v1/upstreams`. +pub async fn list( + State(state): State, + original: OriginalUri, + context: Option>, +) -> Response { + let instance = instance_of(&original.0); + // @cpt-begin:cpt-cf-oagw-flow-config-read-list:p1:inst-read-issue + // The actor's read request: one of the four read paths, with an `{id}` + // path parameter for a single read and OData query parameters for a list. + // @cpt-begin:cpt-cf-oagw-flow-config-read-list:p1:inst-read-authz + let checked = authorize( + &state, + bearer_of(&context), + ResourceKind::Upstream, + READ, + None, + &instance, + ) + .await; + // @cpt-end:cpt-cf-oagw-flow-config-read-list:p1:inst-read-authz + let context = match checked { + Ok(context) => context, + Err(answer) => return answer, + }; + // @cpt-end:cpt-cf-oagw-flow-config-read-list:p1:inst-read-issue + let tenant = match tenant_of(context, &instance) { + Ok(tenant) => tenant, + Err(answer) => return answer, + }; + // @cpt-dod:cpt-cf-oagw-dod-list-query-parameters:p1 + match state + .service() + .list_upstreams(tenant, params::raw_query(&original.0)) + { + Ok(page) => json_response(StatusCode::OK, &dto::upstream_page(&page)), + Err(error) => refused(&error, &instance), + } +} + +/// Reads one upstream: `GET /oagw/v1/upstreams/{id}`. +pub async fn read( + State(state): State, + original: OriginalUri, + context: Option>, + Path(id): Path, +) -> Response { + let instance = instance_of(&original.0); + // The path identifier is parsed, never answered, before the two gates: a + // request that cannot state who it is or what it may do is told nothing + // about the path it named. + let selector = params::path_id(ResourceKind::Upstream, &id); + let checked = authorize( + &state, + bearer_of(&context), + ResourceKind::Upstream, + READ, + selector.as_ref().ok().copied(), + &instance, + ) + .await; + let context = match checked { + Ok(context) => context, + Err(answer) => return answer, + }; + let id = match selector { + Ok(id) => id, + Err(error) => return problem::problem_response(&error, &instance), + }; + let tenant = match tenant_of(context, &instance) { + Ok(tenant) => tenant, + Err(answer) => return answer, + }; + match state.service().read_upstream(tenant, id) { + Ok(row) => json_response(StatusCode::OK, &dto::upstream(&row)), + Err(error) => refused(&error, &instance), + } +} + +/// Replaces one upstream: `PUT /oagw/v1/upstreams/{id}`. +/// +/// The `override` permission covers the `enabled` flag, because the ten +/// management paths hold no dedicated enable or disable operation. +pub async fn replace( + State(state): State, + original: OriginalUri, + context: Option>, + Path(id): Path, + body: Bytes, +) -> Response { + let instance = instance_of(&original.0); + // The path identifier is parsed, never answered, before the two gates: a + // request that cannot state who it is or what it may do is told nothing + // about the path it named. + let selector = params::path_id(ResourceKind::Upstream, &id); + // @cpt-begin:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-issue + // The actor's operation against `/oagw/v1/upstreams/{id}`: a replacement + // when the request carries a body, a deletion when it carries none. + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-issue + // A replacement carries the full replacement of the parent's binding rows, + // so a body that omits the `plugins` sub-object unlinks every plugin. + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-issue + // @cpt-begin:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-authz + // @cpt-begin:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-authz + let checked = authorize( + &state, + bearer_of(&context), + ResourceKind::Upstream, + OVERRIDE, + selector.as_ref().ok().copied(), + &instance, + ) + .await; + // @cpt-end:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-authz + // @cpt-end:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-authz + let context = match checked { + Ok(context) => context, + Err(answer) => return answer, + }; + let id = match selector { + Ok(id) => id, + Err(error) => return problem::problem_response(&error, &instance), + }; + let tenant = match tenant_of(context, &instance) { + Ok(tenant) => tenant, + Err(answer) => return answer, + }; + let body = match parse_body(&body, &instance) { + Ok(body) => body, + Err(answer) => return answer, + }; + // @cpt-end:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-issue + // @cpt-begin:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-issue + // @cpt-begin:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-authz + // The chain and the permission set the override decision consults. + let ancestors = ancestors_of(&state, context, tenant).await; + let permissions = OverridePermissions::of(context); + // @cpt-end:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-authz + // @cpt-end:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-issue + // The binding decision consumes the permission the ancestor flow already + // checked, so the plugin flow registers no gate of its own here. + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-return + match state + .service() + .replace_upstream_in_chain(tenant, id, &ancestors, &permissions, &body) + { + Ok(row) => { + record_config_change( + &state, + crate::domain::observability::EVENT_UPSTREAM_OVERRIDDEN, + context, + "PUT", + &instance, + u16::from(StatusCode::OK), + tenant, + ); + json_response(StatusCode::OK, &dto::upstream(&row)) + } + Err(error) => refused(&error, &instance), + } + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-return +} + +/// Deletes one upstream and its cascaded rows: `DELETE /oagw/v1/upstreams/{id}`. +pub async fn delete( + State(state): State, + original: OriginalUri, + context: Option>, + Path(id): Path, +) -> Response { + let instance = instance_of(&original.0); + // The path identifier is parsed, never answered, before the two gates: a + // request that cannot state who it is or what it may do is told nothing + // about the path it named. + let selector = params::path_id(ResourceKind::Upstream, &id); + let checked = authorize( + &state, + bearer_of(&context), + ResourceKind::Upstream, + DELETE, + selector.as_ref().ok().copied(), + &instance, + ) + .await; + let context = match checked { + Ok(context) => context, + Err(answer) => return answer, + }; + let id = match selector { + Ok(id) => id, + Err(error) => return problem::problem_response(&error, &instance), + }; + let tenant = match tenant_of(context, &instance) { + Ok(tenant) => tenant, + Err(answer) => return answer, + }; + match state.service().delete_upstream(tenant, id) { + Ok(true) => { + record_config_change( + &state, + crate::domain::observability::EVENT_UPSTREAM_DELETED, + context, + "DELETE", + &instance, + u16::from(StatusCode::NO_CONTENT), + tenant, + ); + no_content() + } + // The service resolved the row before the deletion, so a `false` answer + // means the row left between the resolution and the write; the answer is + // the same 404 the resolution would have produced. + Ok(false) => unaddressed(&instance), + Err(error) => refused(&error, &instance), + } +} + +/// The authenticated context the extractor carried, or `None` when it carried +/// none. +fn bearer_of(context: &Option>) -> Option<&SecurityContext> { + context.as_deref() +} 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..2501ef7 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/mod.rs @@ -0,0 +1,98 @@ +//! REST surface of the `oagw` gear. + +pub mod dto; +pub mod handlers; +pub mod params; +pub mod problem; +pub mod state; + +use std::sync::Arc; + +use axum::Router; + +use crate::api::rest::state::OagwState; + +/// The mount point every OAGW REST route hangs under. +pub const MOUNT_POINT: &str = "/oagw/v1"; + +/// Nests the OAGW mount point under the runtime router. +/// +/// The foundation feature adds no route to it: every `/oagw/v1/**` path +/// answers 404 until a feature registers handlers. +#[must_use = "the mounted router must be returned to the runtime"] +pub fn nest_mount_point(parent: Router) -> Router { + parent.nest(MOUNT_POINT, Router::new()) +} + +/// Registers the ten management endpoints on the mount point. +/// +/// Exactly the ten paths DECOMPOSITION §2.2 assigns to the management feature +/// are registered, and the five plugin paths of DESIGN §3.3 the plugin system +/// feature adds to the same mount point. +/// +/// The `{id}` path parameter is the resource's anonymous GTS instance, and the +/// list endpoints read their five OData parameters from the query string. +#[must_use = "the mounted router must be returned to the runtime"] +pub fn register_management_routes(parent: Router, state: Arc) -> Router { + // @cpt-begin:cpt-cf-oagw-dod-management-routes:p1:inst-mgmt-routes + let management = Router::new() + .route( + "/upstreams", + axum::routing::post(handlers::upstreams::create).get(handlers::upstreams::list), + ) + .route( + "/upstreams/{id}", + axum::routing::get(handlers::upstreams::read) + .put(handlers::upstreams::replace) + .delete(handlers::upstreams::delete), + ) + .route( + "/routes", + axum::routing::post(handlers::routes::create).get(handlers::routes::list), + ) + .route( + "/routes/{id}", + axum::routing::get(handlers::routes::read) + .put(handlers::routes::replace) + .delete(handlers::routes::delete), + ) + // @cpt-begin:cpt-cf-oagw-dod-plugin-management-api:p1:inst-pl-routes + // The five plugin endpoints of DESIGN §3.3: the two management features + // share one mount point, so the plugin routes join the same router + // rather than a second one, and no replacement route is registered — + // plugins are immutable after creation. + .route( + "/plugins", + axum::routing::post(handlers::plugins::create).get(handlers::plugins::list), + ) + .route( + "/plugins/{id}", + axum::routing::get(handlers::plugins::read).delete(handlers::plugins::delete), + ) + .route( + "/plugins/{id}/source", + axum::routing::get(handlers::plugins::source), + ) + // @cpt-end:cpt-cf-oagw-dod-plugin-management-api:p1:inst-pl-routes + // @cpt-begin:cpt-cf-oagw-dod-proxy-endpoint:p1:inst-px-routes + // The two proxy paths: every method is admitted to the handler, whose + // match step answers the methods no route declares, so no method + // routing sits between the platform middleware and the authorize step. + .route( + "/proxy/{alias}", + axum::routing::any(handlers::proxy::root), + ) + // @cpt-begin:cpt-cf-oagw-dod-obs-metrics:p1:inst-ms-routes + // The one path this feature registers, for that method alone. + .route("/metrics", axum::routing::get(handlers::metrics::scrape)) + // @cpt-end:cpt-cf-oagw-dod-obs-metrics:p1:inst-ms-routes + .route( + "/proxy/{alias}/{*path_suffix}", + axum::routing::any(handlers::proxy::suffix), + ) + // @cpt-end:cpt-cf-oagw-dod-proxy-endpoint:p1:inst-px-routes + .with_state(state); + // @cpt-end:cpt-cf-oagw-dod-management-routes:p1:inst-mgmt-routes + + parent.nest(MOUNT_POINT, management) +} diff --git a/gears/system/oagw/oagw/src/api/rest/params.rs b/gears/system/oagw/oagw/src/api/rest/params.rs new file mode 100644 index 0000000..d3eb8e8 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/params.rs @@ -0,0 +1,97 @@ +//! Request parameter binding — `cpt-cf-oagw-dod-list-query-parameters`. +//! +//! The `{id}` path parameter is accepted as the resource kind's anonymous GTS +//! instance (`gts.cf.core.oagw.upstream.v1~{uuid}`) or as a bare UUID, and the +//! list query string is handed to the list algorithm untouched: the five OData +//! parameters are the list algorithm's concern, the raw string this module's. + +use axum::http::Uri; +use uuid::Uuid; + +use crate::control_plane::scoping; +use crate::control_plane::validation::ResourceKind; +use crate::domain::error::DomainError; +use crate::domain::plugin_contract::PluginFamily; + +/// Parses the `{id}` path parameter of one addressed operation. +/// +/// # Errors +/// +/// Returns the same 404 row a path-addressed miss answers with when the value +/// is neither the resource kind's anonymous GTS instance nor a bare UUID: an +/// identifier that cannot be parsed addresses nothing, and the answer discloses +/// no more than a miss does. +#[allow(clippy::result_large_err)] +pub fn path_id(kind: ResourceKind, value: &str) -> Result { + let prefix = match kind { + ResourceKind::Upstream => crate::gts::UPSTREAM_TYPE, + ResourceKind::Route => crate::gts::ROUTE_TYPE, + }; + let parsed = crate::gts::parse_gts_instance(prefix, value); + parsed.ok_or_else(scoping::path_miss) +} + +/// The plugin selector one `{id}` path parameter carries. +/// +/// Only a family's full anonymous GTS instance is accepted +/// (`gts.cf.core.oagw.{type}_plugin.v1~{uuid}`): a bare `Uuid` names no +/// permission arm, so it addresses nothing exactly as an unparseable value +/// does. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PluginSelector { + /// The family the identifier's prefix names, when it names one at all. + pub family: Option, + /// The identifier the full form names, when the whole value parsed. + pub id: Option, +} + +impl PluginSelector { + /// Parses one `{id}` path parameter into the family its prefix names and + /// the identifier its full form names, independently of each other: an + /// unparseable identifier still names the arm its prefix selected, and a + /// value that names no family at all names no arm. + #[must_use] + pub fn parse(value: &str) -> Self { + const FAMILIES: [PluginFamily; 3] = [ + PluginFamily::Auth, + PluginFamily::Guard, + PluginFamily::Transform, + ]; + for family in FAMILIES { + if let Some(tail) = value.strip_prefix(family.base_type()) { + return Self { + family: Some(family), + id: Uuid::parse_str(tail).ok(), + }; + } + } + Self { + family: None, + id: None, + } + } + + /// The family and identifier the selector addresses, or the 404 row a + /// path-addressed miss answers with when either is absent. + /// + /// # Errors + /// + /// Returns the same 404 row every path-addressed miss answers with. + #[allow(clippy::result_large_err)] + pub fn addressed(self) -> Result<(PluginFamily, Uuid), DomainError> { + match (self.family, self.id) { + (Some(family), Some(id)) => Ok((family, id)), + _ => Err(scoping::path_miss()), + } + } +} + +/// The raw query string of the request, without its leading `?`. +/// +/// The string is bound to the list operation verbatim and never echoed into a +/// problem document: the `instance` a list refusal names is the request path +/// only, so nothing the query carried is reflected back to the caller. +#[must_use] +pub fn raw_query(uri: &Uri) -> &str { + uri.query().unwrap_or_default() +} diff --git a/gears/system/oagw/oagw/src/api/rest/problem.rs b/gears/system/oagw/oagw/src/api/rest/problem.rs new file mode 100644 index 0000000..085fe8c --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/problem.rs @@ -0,0 +1,311 @@ +//! RFC 9457 mapping of a [`DomainError`] onto an HTTP response. +//! +//! Realizes `cpt-cf-oagw-algo-error-mapping`. A gateway-sourced failure +//! becomes `application/problem+json` with the catalogue row's `type`, `title` +//! and `status`, the caller's `detail`, the request URI as `instance`, and every +//! present [`ErrorContext`] member as an extension field. An upstream-sourced +//! failure is passed through untouched — never rewritten into a problem body. + +use axum::body::Body; +use axum::http::{HeaderValue, StatusCode}; +use axum::response::Response; +use serde_json::{Map, Value}; +use toolkit_canonical_errors::CanonicalError; + +use crate::domain::error::{DomainError, ErrorContext}; + +/// `X-OAGW-Error-Source` — distinguishes a gateway failure from an upstream one. +pub const ERROR_SOURCE_HEADER: &str = "x-oagw-error-source"; + +/// Content type of a gateway-sourced problem document. +const PROBLEM_JSON: &str = "application/problem+json"; + +/// The reason the permission check states when it refuses a request. +const DENY_REASON: &str = "INSUFFICIENT_PERMISSION"; + +/// The detail a refused permission answers with. +/// +/// The property name of the permission and the resource type are named in the +/// document's own fields; no request-body value appears anywhere in it. +const DENY_DETAIL: &str = "the bearer token lacks the permission the operation requires"; + +/// The reason a refused descendant override permission states. +const OVERRIDE_DENY_REASON: &str = "OVERRIDE_PERMISSION_DENIED"; + +/// The detail a refused descendant override permission answers with. +/// +/// It names neither the family the body carried nor the mode any ancestor +/// declared: a refused caller learns nothing about which families its ancestor +/// enforces, and nothing it did not already state itself about which families +/// it tried to set. +const OVERRIDE_DENY_DETAIL: &str = + "the operation requires a descendant permission the bearer token does not hold"; + +/// The detail a persistence failure answers with. +/// +/// The reason is a server-side concern: it is logged with the correlation +/// identifier and never placed on the wire. +const STORAGE_DETAIL: &str = "the configuration store could not apply the operation"; + +/// Builds the RFC 9457 problem document for a gateway-sourced failure. +/// +/// `type` is the catalogue row's GTS identifier, `title` and `status` come from +/// the row, `detail` is the caller's text verbatim, `instance` is the request +/// URI the caller passes in, and every present [`ErrorContext`] member becomes +/// an extension field. +#[must_use] +pub fn problem_document(error: &DomainError, instance: &str) -> Value { + // @cpt-begin:cpt-cf-oagw-algo-error-mapping:p1:inst-errmap-problem + let mut body = Map::new(); + body.insert(String::from("type"), Value::from(error.gts_type())); + body.insert(String::from("title"), Value::from(error.kind.title())); + body.insert( + String::from("status"), + Value::from(u64::from(error.http_status())), + ); + body.insert(String::from("detail"), Value::from(error.detail.as_str())); + body.insert(String::from("instance"), Value::from(instance)); + // @cpt-begin:cpt-cf-oagw-algo-error-mapping:p1:inst-errmap-extensions + extend_with_context(&mut body, &error.context); + // @cpt-end:cpt-cf-oagw-algo-error-mapping:p1:inst-errmap-extensions + Value::Object(body) + // @cpt-end:cpt-cf-oagw-algo-error-mapping:p1:inst-errmap-problem +} + +/// Builds a gateway-sourced RFC 9457 problem response. +/// +/// `Retry-After` is emitted only for the six retriable catalogue rows and only +/// when the context carries a delay; no delay is ever invented. +#[must_use] +pub fn problem_response(error: &DomainError, instance: &str) -> Response { + // @cpt-begin:cpt-cf-oagw-algo-error-mapping:p1:inst-errmap-gateway-if + // @cpt-begin:cpt-cf-oagw-algo-response-classify:p1:inst-cls-gateway-map + // The gateway-sourced row of the classification: the answer is mapped + // through `cpt-cf-oagw-algo-error-mapping`, which resolves the variant's + // HTTP status and GTS `type` identifier, emits the problem body, and sets + // `X-OAGW-Error-Source: gateway`. + let status = status_code(error.http_status()); + let mut response = Response::builder() + .status(status) + .header( + axum::http::header::CONTENT_TYPE, + HeaderValue::from_static(PROBLEM_JSON), + ) + .body(Body::from(problem_document(error, instance).to_string())) + .unwrap_or_else(|_| Response::new(Body::empty())); + // @cpt-end:cpt-cf-oagw-algo-response-classify:p1:inst-cls-gateway-map + + // @cpt-begin:cpt-cf-oagw-algo-response-classify:p1:inst-cls-gateway-context + // The `ErrorContext` members that are present ride the problem body's + // extension fields, per DESIGN §3.3's extension list, and a member with no + // value is omitted rather than emitted empty. + // @cpt-end:cpt-cf-oagw-algo-response-classify:p1:inst-cls-gateway-context + + // @cpt-begin:cpt-cf-oagw-algo-error-mapping:p1:inst-errmap-gateway-header + response.headers_mut().insert( + ERROR_SOURCE_HEADER, + HeaderValue::from_static("gateway"), + ); + // @cpt-end:cpt-cf-oagw-algo-error-mapping:p1:inst-errmap-gateway-header + + // @cpt-begin:cpt-cf-oagw-algo-response-classify:p1:inst-cls-gateway-retry + // `Retry-After` is emitted only for the catalogue rows DESIGN §3.3 marks + // retriable and only when `retry_after_seconds` is present, which is the + // mapping `cpt-cf-oagw-algo-error-mapping` already performs. + // @cpt-end:cpt-cf-oagw-algo-response-classify:p1:inst-cls-gateway-retry + + // @cpt-begin:cpt-cf-oagw-algo-error-mapping:p1:inst-errmap-retry-if + if let Some(seconds) = error.retry_after_seconds() { + // @cpt-begin:cpt-cf-oagw-algo-error-mapping:p1:inst-errmap-retry-emit + apply_retry_after(&mut response, seconds); + // @cpt-end:cpt-cf-oagw-algo-error-mapping:p1:inst-errmap-retry-emit + } + // @cpt-end:cpt-cf-oagw-algo-error-mapping:p1:inst-errmap-retry-if + + // @cpt-begin:cpt-cf-oagw-algo-error-mapping:p1:inst-errmap-return + response + // @cpt-end:cpt-cf-oagw-algo-error-mapping:p1:inst-errmap-return + // @cpt-end:cpt-cf-oagw-algo-error-mapping:p1:inst-errmap-gateway-if +} + +/// Builds an upstream-sourced response: the status, body and content type the +/// upstream produced, tagged with `X-OAGW-Error-Source: upstream`. +/// +/// The invariant is that an upstream failure is never rewritten into +/// `application/problem+json` and never gains a `Retry-After`. +#[must_use] +pub fn passthrough_response(status: u16, body: Body, content_type: Option<&str>) -> Response { + // @cpt-begin:cpt-cf-oagw-algo-error-mapping:p1:inst-errmap-else + // @cpt-begin:cpt-cf-oagw-algo-error-mapping:p1:inst-errmap-upstream + let mut builder = Response::builder() + .status(status_code(status)) + .header(ERROR_SOURCE_HEADER, HeaderValue::from_static("upstream")); + + if let Some(content_type) = content_type { + builder = builder.header(axum::http::header::CONTENT_TYPE, content_type); + } + + builder + .body(body) + .unwrap_or_else(|_| Response::new(Body::empty())) + // @cpt-end:cpt-cf-oagw-algo-error-mapping:p1:inst-errmap-upstream + // @cpt-end:cpt-cf-oagw-algo-error-mapping:p1:inst-errmap-else +} + +/// Builds the 403 response a refused permission answers with. +/// +/// The permission family this feature enforces is not a row of the OAGW error +/// catalogue, so `type`, `title` and `status` come from the platform's +/// canonical catalogue and the enforced resource type is named in the context. +/// The detail is a constant: no request-body value, no tenant, no identifier. +#[must_use] +pub fn forbidden_response(resource_type: &'static str, instance: &str) -> Response { + let document = permission_denied(resource_type); + gateway_response(StatusCode::FORBIDDEN, &document, instance) +} + +/// Builds the 403 response a refused descendant override permission answers +/// with. +/// +/// The four `oagw:upstream:*` permissions are not rows of the OAGW error +/// catalogue either, so `type`, `title` and `status` come from the platform's +/// canonical catalogue and the enforced resource type is named in the context. +/// The detail is a constant that names no family and no sharing mode. +#[must_use] +pub fn forbidden_permission_response(resource_type: &'static str, instance: &str) -> Response { + let error = toolkit_canonical_errors::ResourceErrorBuilder::__permission_denied( + resource_type, + OVERRIDE_DENY_DETAIL, + ) + .with_reason(OVERRIDE_DENY_REASON) + .create(); + let document = canonical_document(&error); + gateway_response(StatusCode::FORBIDDEN, &document, instance) +} + +/// Builds the 500 response a persistence failure answers with. +/// +/// A storage failure is never a [`DomainError`] catalogue variant of this +/// feature: the document carries the platform's internal-error row, the +/// `X-OAGW-Error-Source: gateway` header, and a constant detail. The reason the +/// store produced is logged by the handler that observed it, not placed here. +#[must_use] +pub fn storage_problem_response(instance: &str) -> Response { + let document = internal_error(); + gateway_response(StatusCode::INTERNAL_SERVER_ERROR, &document, instance) +} + +/// Builds the bare 403 problem response a CORS refusal answers with. +/// +/// A CORS refusal is not a [`DomainError`] catalogue variant either: DESIGN +/// §3.3's catalogue is closed at 22 variants over 21 identifiers and carries no +/// 403 row, so `type`, `title`, and `status` come from the two identifiers ADR +/// 0004 spells and the detail is the caller's own rendering of the offending +/// value. The document is written on the same problem-body path every gateway +/// answer takes and carries the `X-OAGW-Error-Source: gateway` tag of it. +#[must_use] +pub fn bare_forbidden_response( + gts_type: &'static str, + title: &'static str, + detail: &str, + instance: &str, +) -> Response { + let mut body = Map::new(); + body.insert(String::from("type"), Value::from(gts_type)); + body.insert(String::from("title"), Value::from(title)); + body.insert(String::from("status"), Value::from(403u64)); + body.insert(String::from("detail"), Value::from(detail)); + gateway_response(StatusCode::FORBIDDEN, &Value::Object(body), instance) +} + +/// The canonical permission-denied row, with the enforced resource type named. +fn permission_denied(resource_type: &'static str) -> Value { + let error = toolkit_canonical_errors::ResourceErrorBuilder::__permission_denied( + resource_type, + DENY_DETAIL, + ) + .with_reason(DENY_REASON) + .create(); + canonical_document(&error) +} + +/// The canonical internal-error row. +fn internal_error() -> Value { + let error = CanonicalError::internal(STORAGE_DETAIL).create(); + canonical_document(&error) +} + +/// The problem document of a canonical catalogue row: the row's `type`, `title` +/// and `status`, its `detail`, and its context members as extension fields. +fn canonical_document(error: &CanonicalError) -> Value { + let mut body = Map::new(); + body.insert(String::from("type"), Value::from(error.gts_type())); + body.insert(String::from("title"), Value::from(error.title())); + body.insert( + String::from("status"), + Value::from(u64::from(error.status_code())), + ); + body.insert(String::from("detail"), Value::from(error.detail())); + body.insert( + String::from("resource_type"), + Value::from(error.resource_type().unwrap_or_default()), + ); + Value::Object(body) +} + +/// The response shape every gateway-sourced problem document shares. +fn gateway_response(status: StatusCode, document: &Value, instance: &str) -> Response { + let mut body = match document { + Value::Object(fields) => fields.clone(), + _ => Map::new(), + }; + body.insert(String::from("instance"), Value::from(instance)); + + Response::builder() + .status(status) + .header( + axum::http::header::CONTENT_TYPE, + HeaderValue::from_static(PROBLEM_JSON), + ) + .header(ERROR_SOURCE_HEADER, HeaderValue::from_static("gateway")) + .body(Body::from(Value::Object(body).to_string())) + .unwrap_or_else(|_| Response::new(Body::empty())) +} + +/// The `StatusCode` of a catalogue row, falling back to `500` for a value the +/// HTTP grammar cannot express. +fn status_code(status: u16) -> StatusCode { + StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR) +} + +/// Adds every present [`ErrorContext`] member as an extension field; absent +/// members add nothing. +fn extend_with_context(body: &mut Map, context: &ErrorContext) { + if let Some(upstream_id) = context.upstream_id { + body.insert( + String::from("upstream_id"), + Value::from(upstream_id.to_string()), + ); + } + if let Some(host) = &context.host { + body.insert(String::from("host"), Value::from(host.as_str())); + } + if let Some(path) = &context.path { + body.insert(String::from("path"), Value::from(path.as_str())); + } + if let Some(seconds) = context.retry_after_seconds { + body.insert(String::from("retry_after_seconds"), Value::from(seconds)); + } + if let Some(trace_id) = &context.trace_id { + body.insert(String::from("trace_id"), Value::from(trace_id.as_str())); + } +} + +/// Sets `Retry-After` on a response. +fn apply_retry_after(response: &mut Response, seconds: u64) { + if let Ok(value) = HeaderValue::from_str(&seconds.to_string()) { + response + .headers_mut() + .insert(axum::http::header::RETRY_AFTER, value); + } +} diff --git a/gears/system/oagw/oagw/src/api/rest/state.rs b/gears/system/oagw/oagw/src/api/rest/state.rs new file mode 100644 index 0000000..31447d2 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/state.rs @@ -0,0 +1,276 @@ +//! Shared handler state — the gear's management half on the wire. +//! +//! [`OagwState`] is the one value every management handler extracts: the +//! compiled configuration, the store, the management service, the cache the +//! write path advances, and the permission enforcer the platform resolved at +//! startup. It carries no `axum` extractor and no request-scoped value, so it +//! is built once per gear and shared through `Router::with_state`. + +use std::sync::Arc; + +use authz_resolver_sdk::api::AuthZResolverClient; +use authz_resolver_sdk::pep::PolicyEnforcer; +use tenant_resolver_sdk::TenantResolverClient; + +use crate::config::OagwConfig; +use crate::control_plane::cache::{ControlPlaneCache, RateLimitCleanup}; +use crate::data_plane::forward::OutboundClient; +use crate::control_plane::service::ManagementService; +use crate::store::OagwStore; + +/// The management surface's shared state. +#[derive(Clone)] +pub struct OagwState { + config: Arc, + store: Arc, + service: Arc, + enforcer: Option>, + resolver: Option>, + cache: Arc, + /// The Data Plane L1 cache of `cpt-cf-oagw-algo-dp-cache`, one per process. + dp_cache: crate::data_plane::DpCache, + /// The shared outbound client of `cpt-cf-oagw-adr-state-management`, one + /// connector per process. + outbound: OutboundClient, + /// The per-upstream round-robin counters of `cpt-cf-oagw-algo-endpoint-select`. + round_robin: crate::data_plane::RoundRobin, + /// The three plugin registries of `cpt-cf-oagw-feature-plugin-system`, + /// built once at initialization and shared by every chain composition. + registries: crate::plugins::PluginRegistries, + /// The per-instance rate-limit registry + /// `cpt-cf-oagw-feature-rate-limiting` charges every proxy request on, one + /// per process, held for the data plane ADR 0006 assigns. + rate_limits: crate::data_plane::SharedLimits, + /// The in-process observation seam + /// `cpt-cf-oagw-feature-observability` collects the twelve families and the + /// audit stream into, one per process, holding no persisted state. + observability: Arc, +} + +impl OagwState { + /// Assembles the state over a built service. + #[must_use] + pub fn new( + config: Arc, + store: Arc, + service: Arc, + enforcer: Option>, + resolver: Option>, + cache: Arc, + ) -> Self { + let registries = Self::registries_for(None, &config); + let dp_cache = crate::data_plane::DpCache::new(); + // The Data Plane flush registers with the cache the write path + // advances, so one successful write invalidates in the same process + // and before the write's response is produced. + cache.register_dp_flush(Arc::new(dp_cache.clone())); + let state = Self { + config, + store, + service, + enforcer, + resolver, + cache, + dp_cache, + outbound: OutboundClient::new(), + round_robin: crate::data_plane::RoundRobin::new(), + registries, + rate_limits: crate::data_plane::SharedLimits::new(), + observability: Arc::new( + crate::data_plane::observability::Observability::new(), + ), + }; + // The rate-limit cleanup registers with the same deletion seam the + // data-plane flush does, so one successful upstream or route deletion + // drops its counters before the delete's response is produced. + let observer = Arc::new(crate::data_plane::RegistryCleanup::new( + state.rate_limits.clone(), + )); + state.register_deletion_observer(observer); + state + } + + /// The registries a deployment serves, over the credential store the hub + /// resolved or over the unavailable one. + fn registries_for( + cred_store: Option>, + config: &OagwConfig, + ) -> crate::plugins::PluginRegistries { + crate::plugins::PluginRegistries::for_deployment( + cred_store, + crate::plugins::TokenCacheConfig::new( + std::time::Duration::from_secs(config.token_cache_ttl_secs), + config.token_cache_capacity as usize, + ), + ) + } + + /// The configuration the gear loaded at init. + #[must_use] + pub fn config(&self) -> &OagwConfig { + &self.config + } + + /// The store the management service owns. + #[must_use] + pub fn store(&self) -> &OagwStore { + &self.store + } + + /// The management service every operation is issued against. + #[must_use] + pub fn service(&self) -> &ManagementService { + &self.service + } + + /// The permission enforcer, or `None` when the platform resolved no + /// `AuthZ` client at startup. + /// + /// `None` fails closed: every management request is answered 403, because + /// a permission this feature cannot check is a permission it must not + /// grant. + #[must_use] + pub fn enforcer(&self) -> Option<&PolicyEnforcer> { + self.enforcer.as_deref() + } + + /// The cache the write path advances and the data plane observes. + #[must_use] + pub fn cache(&self) -> &ControlPlaneCache { + &self.cache + } + + /// Registers the rate-limit cleanup the deletion seam notifies. + /// + /// `cpt-cf-oagw-feature-rate-limiting` owns the observer; this feature only + /// forwards the registration to the service that issues the notification. + pub fn register_deletion_observer(&self, observer: Arc) { + self.service.register_deletion_observer(observer); + } + + /// Assembles the management surface the gear mounts. + /// + /// One store, one Control Plane cache and one management service are built + /// over the compiled configuration; the enforcer is the platform's + /// `AuthZ` client when the hub resolved one and `None` when it did not, so + /// a gear that starts without an `AuthZ` client serves a surface that + /// answers 403 to every request rather than one that answers 200 to any. + /// The tenant-resolver client is the same kind of opportunistic + /// resolution, and a gear that starts without one serves a surface whose + /// every bind and every resolution fails closed, because no ancestor chain + /// can be obtained. + /// + /// # Errors + /// + /// Returns the refusal of the configuration compilation, which a + /// configuration that loaded and validated cannot produce. + #[allow(clippy::result_large_err)] + pub fn assemble( + config: &OagwConfig, + authz: Option>, + resolver: Option>, + cred_store: Option>, + ) -> Result { + // @cpt-begin:cpt-cf-oagw-dod-authz-permissions:p1:inst-authz-resolve + let enforcer = authz.map(|client| Arc::new(PolicyEnforcer::new(client))); + let cache = Arc::new(ControlPlaneCache::new()); + // @cpt-end:cpt-cf-oagw-dod-authz-permissions:p1:inst-authz-resolve + + let store = Arc::new(OagwStore::new()); + let service = Arc::new(ManagementService::new( + Arc::clone(&store), + config, + Arc::clone(&cache), + )?); + let dp_cache = crate::data_plane::DpCache::new(); + // The Data Plane flush registers with the cache the write path + // advances, so one successful write invalidates in the same process + // and before the write's response is produced. + cache.register_dp_flush(Arc::new(dp_cache.clone())); + let state = Self { + config: Arc::new(*config), + store, + service, + enforcer, + resolver, + cache, + dp_cache, + outbound: OutboundClient::new(), + round_robin: crate::data_plane::RoundRobin::new(), + registries: Self::registries_for(cred_store, config), + rate_limits: crate::data_plane::SharedLimits::new(), + observability: Arc::new( + crate::data_plane::observability::Observability::new(), + ), + }; + // The rate-limit cleanup registers with the same deletion seam the + // data-plane flush does, so one successful upstream or route deletion + // drops its counters before the delete's response is produced. + let observer = std::sync::Arc::new(crate::data_plane::RegistryCleanup::new( + state.rate_limits.clone(), + )); + state.register_deletion_observer(observer); + Ok(state) + } + + /// The Data Plane L1 cache the proxy resolution reads and populates. + #[must_use] + pub fn dp_cache(&self) -> &crate::data_plane::DpCache { + &self.dp_cache + } + + /// The shared outbound client the proxy forward dial through. + #[must_use] + pub fn outbound(&self) -> &OutboundClient { + &self.outbound + } + + /// The per-upstream round-robin counters the endpoint selection advances. + #[must_use] + pub fn round_robin(&self) -> &crate::data_plane::RoundRobin { + &self.round_robin + } + + /// The platform tenant-resolver client the ancestor chain comes from. + /// + /// `None` answers a hub that resolved nothing, and every caller of it + /// fails closed: no chain means no bind and no resolution. + #[must_use] + pub fn resolver(&self) -> Option<&Arc> { + self.resolver.as_ref() + } + + /// The per-instance rate-limit registry the proxy check charges and the + /// deletion observer drops prefixes from. + #[must_use] + pub fn rate_limits(&self) -> &crate::data_plane::SharedLimits { + &self.rate_limits + } + + /// The plugin registries the chain composition resolves built-ins through. + #[must_use] + pub fn registries(&self) -> &crate::plugins::PluginRegistries { + &self.registries + } + + /// The in-process observation seam the proxy path's entry and exit read + /// and the metrics path renders from. + #[must_use] + pub fn observability( + &self, + ) -> &Arc { + &self.observability + } + + /// Replaces the audit sink the seam writes its records to. + /// + /// A deployment that leaves the stdout sink in place writes the stream to + /// the stdout the single executable owns; a test installs a collecting + /// sink, which is the mock boundary `cpt-cf-oagw-dod-obs-tests` names. + pub fn swap_audit_sink( + &self, + sink: Arc, + ) { + self.observability.swap_sink(sink); + } +} diff --git a/gears/system/oagw/oagw/src/config.rs b/gears/system/oagw/oagw/src/config.rs new file mode 100644 index 0000000..36e404d --- /dev/null +++ b/gears/system/oagw/oagw/src/config.rs @@ -0,0 +1,315 @@ +//! `OagwConfig` — the configuration surface the `oagw` gear loads at init. +//! +//! Realizes `cpt-cf-oagw-algo-config-load-validate` and +//! `cpt-cf-oagw-dod-config-surface`: exactly the five configurable families +//! tabulated in the feature, the declared defaults applied when +//! `oagw.config` is absent, unknown keys rejected, and out-of-range integers +//! rejected with the offending key named. + +use serde::{Deserialize, Serialize}; + +use crate::domain::scheme::Scheme; + +/// `proxy_timeout_secs` — at least 1. +pub const MIN_PROXY_TIMEOUT_SECS: u64 = 1; +/// `token_cache_ttl_secs` — at least 1. +pub const MIN_TOKEN_CACHE_TTL_SECS: u64 = 1; +/// `token_cache_capacity` — at least 1. +pub const MIN_TOKEN_CACHE_CAPACITY: u64 = 1; + +/// Declared default for `proxy_timeout_secs`: the platform REST request +/// deadline applied by the built-in API gateway middleware stack, so an +/// omitted key behaves like the platform default rather than like an +/// unbounded request. +pub const DEFAULT_PROXY_TIMEOUT_SECS: u64 = 30; +/// Declared default for `allow_http_upstream`: HTTPS-only posture. +pub const DEFAULT_ALLOW_HTTP_UPSTREAM: bool = false; +/// Declared default for `token_cache_ttl_secs` (ADR 0008). +pub const DEFAULT_TOKEN_CACHE_TTL_SECS: u64 = 300; +/// Declared default for `token_cache_capacity` (ADR 0008). +pub const DEFAULT_TOKEN_CACHE_CAPACITY: u64 = 10_000; + +/// `proxy_timeout_secs` configuration key. +pub const KEY_PROXY_TIMEOUT_SECS: &str = "proxy_timeout_secs"; +/// `allow_http_upstream` configuration key. +pub const KEY_ALLOW_HTTP_UPSTREAM: &str = "allow_http_upstream"; +/// `ssrf_policy` configuration key. +pub const KEY_SSRF_POLICY: &str = "ssrf_policy"; +/// `token_cache_ttl_secs` configuration key. +pub const KEY_TOKEN_CACHE_TTL_SECS: &str = "token_cache_ttl_secs"; +/// `token_cache_capacity` configuration key. +pub const KEY_TOKEN_CACHE_CAPACITY: &str = "token_cache_capacity"; +/// `ssrf_policy.enabled` nested configuration key. +pub const KEY_SSRF_POLICY_ENABLED: &str = "ssrf_policy.enabled"; + +/// Every top-level key of the `oagw.config` surface. +pub const KNOWN_KEYS: [&str; 5] = [ + KEY_PROXY_TIMEOUT_SECS, + KEY_ALLOW_HTTP_UPSTREAM, + KEY_SSRF_POLICY, + KEY_TOKEN_CACHE_TTL_SECS, + KEY_TOKEN_CACHE_CAPACITY, +]; + +/// Configuration error naming the offending configuration key. +/// +/// Realizes the error half of `cpt-cf-oagw-algo-config-load-validate`: init +/// aborts with this error, the gear does not register, and startup fails fast +/// before any later feature can consume a half-configured gear. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ConfigError { + /// A key that is not part of the configuration surface was supplied. + #[error("unknown configuration key '{key}' under oagw.config")] + UnknownKey { + /// The offending key, as written in the configuration. + key: String, + }, + /// An integer key fell below its declared minimum. + #[error("configuration key '{key}' must be an integer of at least {minimum}, got {actual}")] + OutOfRange { + /// The offending integer key. + key: String, + /// The smallest accepted value. + minimum: u64, + /// The supplied value. + actual: u64, + }, + /// The section could not be parsed into the configuration surface. + #[error("invalid oagw.config section: {message}")] + Deserialize { + /// Human-readable parse failure, including the offending key. + message: String, + }, +} + +impl ConfigError { + /// The offending configuration key, when the error is attributable to one. + #[must_use] + pub fn offending_key(&self) -> Option<&str> { + match self { + Self::UnknownKey { key } | Self::OutOfRange { key, .. } => Some(key), + Self::Deserialize { .. } => None, + } + } +} + +/// SSRF enforcement posture. +/// +/// `ssrf_policy.enabled` gates the SSRF enforcement owned by the data-plane +/// proxy feature; this feature only carries and validates the key and +/// evaluates no SSRF rule of its own. The fail-safe default is `true`: +/// enforcement is on until an operator turns it off. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct SsrfPolicy { + /// Whether SSRF enforcement is active. + pub enabled: bool, +} + +impl Default for SsrfPolicy { + fn default() -> Self { + // @cpt-begin:cpt-cf-oagw-algo-config-load-validate:p1:inst-config-defaults + Self { + enabled: DEFAULT_SSRF_POLICY_ENABLED, + } + // @cpt-end:cpt-cf-oagw-algo-config-load-validate:p1:inst-config-defaults + } +} + +/// Fail-safe default for `ssrf_policy.enabled`. +pub const DEFAULT_SSRF_POLICY_ENABLED: bool = true; + +// @cpt-dod:cpt-cf-oagw-dod-config-surface:p1 +/// Configuration surface of the `oagw` gear. +/// +/// Loaded through the platform configuration provider +/// (`ctx.config_or_default::()`), which yields +/// [`OagwConfig::default`] when the `oagw.config` section is absent. Every +/// integer key is validated against its declared minimum by +/// [`OagwConfig::validate`]. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct OagwConfig { + /// Outbound request deadline in seconds. At least 1. + pub proxy_timeout_secs: u64, + /// The only input that admits the `http` endpoint scheme literal at write + /// time. Recording the lifted posture does not authorize a plaintext dial. + pub allow_http_upstream: bool, + /// SSRF enforcement posture; see [`SsrfPolicy`]. + pub ssrf_policy: SsrfPolicy, + /// Ceiling for a cached access-token TTL in seconds (ADR 0008). At least 1. + pub token_cache_ttl_secs: u64, + /// Maximum token-cache entries (ADR 0008). At least 1. + pub token_cache_capacity: u64, +} + +impl Default for OagwConfig { + fn default() -> Self { + // @cpt-begin:cpt-cf-oagw-algo-config-load-validate:p1:inst-config-defaults + Self { + proxy_timeout_secs: DEFAULT_PROXY_TIMEOUT_SECS, + allow_http_upstream: DEFAULT_ALLOW_HTTP_UPSTREAM, + ssrf_policy: SsrfPolicy::default(), + token_cache_ttl_secs: DEFAULT_TOKEN_CACHE_TTL_SECS, + token_cache_capacity: DEFAULT_TOKEN_CACHE_CAPACITY, + } + // @cpt-end:cpt-cf-oagw-algo-config-load-validate:p1:inst-config-defaults + } +} + +impl OagwConfig { + /// Parses and validates the raw `oagw.config` mapping. + /// + /// `None` (the section is absent entirely) yields the declared defaults + /// for every key. This is the single entry point of + /// `cpt-cf-oagw-algo-config-load-validate`. + /// + /// # Errors + /// + /// Returns [`ConfigError::UnknownKey`] for a key outside the surface, + /// [`ConfigError::OutOfRange`] for an integer below its declared minimum, + /// and [`ConfigError::Deserialize`] for a value of the wrong type. + pub fn load(raw: Option<&serde_json::Value>) -> Result { + let config = match raw { + None => Self::default(), + Some(value) => Self::from_value(value)?, + }; + // @cpt-begin:cpt-cf-oagw-algo-config-load-validate:p1:inst-config-fail-if + config.validate()?; + // @cpt-end:cpt-cf-oagw-algo-config-load-validate:p1:inst-config-fail-if + + // @cpt-begin:cpt-cf-oagw-algo-config-load-validate:p1:inst-config-return + Ok(config) + // @cpt-end:cpt-cf-oagw-algo-config-load-validate:p1:inst-config-return + } + + /// Parses the raw `oagw.config` mapping, applying the declared default + /// for every absent key. + /// + /// # Errors + /// + /// Returns [`ConfigError::UnknownKey`] for a key outside the surface and + /// [`ConfigError::Deserialize`] for a value of the wrong type. Range + /// checking is left to [`OagwConfig::validate`]. + pub fn from_value(raw: &serde_json::Value) -> Result { + reject_unknown_keys(raw)?; + check_raw_integer(raw, KEY_PROXY_TIMEOUT_SECS, MIN_PROXY_TIMEOUT_SECS)?; + check_raw_integer(raw, KEY_TOKEN_CACHE_TTL_SECS, MIN_TOKEN_CACHE_TTL_SECS)?; + check_raw_integer(raw, KEY_TOKEN_CACHE_CAPACITY, MIN_TOKEN_CACHE_CAPACITY)?; + + serde_json::from_value(raw.clone()).map_err(|e| ConfigError::Deserialize { + message: e.to_string(), + }) + } + + /// Validates the integer keys against their declared minimums. + /// + /// # Errors + /// + /// Returns [`ConfigError::OutOfRange`] naming the offending key for the + /// first integer key below its declared minimum. + pub fn validate(&self) -> Result<(), ConfigError> { + // @cpt-begin:cpt-cf-oagw-algo-config-load-validate:p1:inst-config-int-loop + // @cpt-begin:cpt-cf-oagw-algo-config-load-validate:p1:inst-config-int-check + check_min( + KEY_PROXY_TIMEOUT_SECS, + self.proxy_timeout_secs, + MIN_PROXY_TIMEOUT_SECS, + )?; + check_min( + KEY_TOKEN_CACHE_TTL_SECS, + self.token_cache_ttl_secs, + MIN_TOKEN_CACHE_TTL_SECS, + )?; + check_min( + KEY_TOKEN_CACHE_CAPACITY, + self.token_cache_capacity, + MIN_TOKEN_CACHE_CAPACITY, + )?; + Ok(()) + // @cpt-end:cpt-cf-oagw-algo-config-load-validate:p1:inst-config-int-check + // @cpt-end:cpt-cf-oagw-algo-config-load-validate:p1:inst-config-int-loop + } + + /// Write-time scheme admission delegated to the [`Scheme`] value object. + /// + /// `allow_http_upstream` is the only input that can change the outcome for + /// `Scheme::Http`; no other configuration key affects any scheme. + #[must_use] + pub const fn admits_scheme(&self, scheme: Scheme) -> bool { + // @cpt-begin:cpt-cf-oagw-algo-config-load-validate:p1:inst-config-http-record + scheme.is_write_admitted(self.allow_http_upstream) + // @cpt-end:cpt-cf-oagw-algo-config-load-validate:p1:inst-config-http-record + } +} + +/// Rejects keys that are not part of the configuration surface, at the top +/// level and inside `ssrf_policy`. +fn reject_unknown_keys(raw: &serde_json::Value) -> Result<(), ConfigError> { + // @cpt-begin:cpt-cf-oagw-algo-config-load-validate:p1:inst-config-parse + let Some(fields) = raw.as_object() else { + return Ok(()); + }; + + for key in fields.keys() { + if !KNOWN_KEYS.contains(&key.as_str()) { + return Err(ConfigError::UnknownKey { key: key.clone() }); + } + } + + if let Some(ssrf) = fields.get(KEY_SSRF_POLICY) + && let Some(ssrf_fields) = ssrf.as_object() + { + for key in ssrf_fields.keys() { + if key != "enabled" { + return Err(ConfigError::UnknownKey { + key: format!("{KEY_SSRF_POLICY}.{key}"), + }); + } + } + } + + Ok(()) + // @cpt-end:cpt-cf-oagw-algo-config-load-validate:p1:inst-config-parse +} + +/// Range-checks a raw integer key before serde ever sees it, so the error +/// names the key even when the supplied value is not a `u64` at all. +fn check_raw_integer(raw: &serde_json::Value, key: &str, minimum: u64) -> Result<(), ConfigError> { + let Some(found) = raw.get(key) else { + return Ok(()); + }; + + let Some(actual) = found.as_u64() else { + return Err(ConfigError::Deserialize { + message: format!("{key}: expected an integer of at least {minimum}, got {found}"), + }); + }; + + if actual < minimum { + return Err(ConfigError::OutOfRange { + key: key.to_owned(), + minimum, + actual, + }); + } + + Ok(()) +} + +/// Range-checks one validated integer key. +fn check_min(key: &str, actual: u64, minimum: u64) -> Result<(), ConfigError> { + // @cpt-begin:cpt-cf-oagw-algo-config-load-validate:p1:inst-config-fail-return + if actual < minimum { + return Err(ConfigError::OutOfRange { + key: key.to_owned(), + minimum, + actual, + }); + } + // @cpt-end:cpt-cf-oagw-algo-config-load-validate:p1:inst-config-fail-return + Ok(()) +} diff --git a/gears/system/oagw/oagw/src/control_plane/alias_derive.rs b/gears/system/oagw/oagw/src/control_plane/alias_derive.rs new file mode 100644 index 0000000..b04eeca --- /dev/null +++ b/gears/system/oagw/oagw/src/control_plane/alias_derive.rs @@ -0,0 +1,302 @@ +//! Alias derivation — `cpt-cf-oagw-algo-alias-derive`. +//! +//! Derives the routing alias an endpoint set resolves to, reconciles it with a +//! caller-supplied one, and confirms alias immutability across a replacement. +//! Normalization itself is `cpt-cf-oagw-algo-alias-normalize`, which lives on +//! the [`Hostname`], [`Alias`], and [`EndpointHost`] value objects; this module +//! only ever holds a value produced by one of those constructors, so +//! derivation and later resolution cannot disagree about the shape of a value. +//! +//! No detail produced here carries a host, an alias value, or any other +//! configuration value. + +// @cpt-dod:cpt-cf-oagw-dod-alias-derivation:p1 + +use std::net::IpAddr; + +use crate::domain::alias::{Alias, Hostname}; +use crate::domain::error::{DomainError, ErrorKind}; +use crate::domain::scheme::Scheme; +use crate::domain::upstream::Endpoint; + +/// Declared standard port for an `http` endpoint. +const STANDARD_HTTP_PORT: u16 = 80; +/// Declared standard port for an `https`, `wss`, `wt`, or `grpc` endpoint. +const STANDARD_TLS_PORT: u16 = 443; +/// The smallest number of labels a derived multi-host suffix may carry. +const MIN_SUFFIX_LABELS: usize = 2; + +/// Why an endpoint set admits no derived alias. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeriveError { + /// The set holds an IP literal, the hostnames share no suffix of at least + /// two labels, or the common suffix is itself a bare public suffix. + NonDerivable, +} + +impl DeriveError { + /// The detail a validation error carries for this outcome; it names the + /// endpoint set and echoes no value. + #[must_use] + pub const fn detail(self) -> &'static str { + match self { + Self::NonDerivable => "server.endpoints is not derivable", + } + } +} + +/// The declared standard port of an endpoint scheme. +#[must_use] +pub const fn standard_port(scheme: Scheme) -> u16 { + match scheme { + Scheme::Http => STANDARD_HTTP_PORT, + Scheme::Https | Scheme::Wss | Scheme::Wt | Scheme::Grpc => STANDARD_TLS_PORT, + } +} + +/// Resolves the alias an upstream write stores. +/// +/// `stored` is `Some` on a replacement, where the alias is immutable. +/// +/// # Errors +/// +/// Returns a gateway validation error when the endpoint set derives no alias +/// and the caller supplied none, when the supplied alias differs from the +/// derived one, or when the supplied alias is not a valid alias. Returns the +/// `AliasConflict` catalogue row — which answers 409 — when a replacement +/// would change the stored alias. +#[allow(clippy::result_large_err)] +pub fn resolve( + endpoints: &[Endpoint], + supplied: Option<&str>, + stored: Option<&Alias>, +) -> Result { + // @cpt-begin:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-return + let alias = match derive(endpoints) { + Ok(derived) => match supplied { + Some(supplied) => reconcile(supplied, &derived)?, + None => derived, + }, + Err(error) => match supplied { + // A non-derivable set requires, and accepts, an explicit alias. + Some(supplied) => Alias::parse(supplied) + .map_err(|_| require_explicit(error))?, + None => return Err(require_explicit(error)), + }, + }; + // @cpt-end:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-return + + // @cpt-begin:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-put-if + if let Some(stored) = stored { + // @cpt-begin:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-put + confirm_immutable(&alias, stored)?; + // @cpt-end:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-put + } + // @cpt-end:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-put-if + + Ok(alias) +} + +/// Derives the alias an endpoint set resolves to. +/// +/// A single hostname derives itself on a standard port and `hostname:port` +/// otherwise. Several hostnames derive the longest common suffix of at least +/// two labels, or that suffix with the port appended when the port is +/// non-standard, and a bare public suffix is never an alias. An IP literal in +/// the set makes the whole set non-derivable. +/// +/// # Errors +/// +/// Returns [`DeriveError::NonDerivable`] when the set admits no alias; the +/// caller then requires an explicit one. +pub fn derive(endpoints: &[Endpoint]) -> Result { + let mut hostnames = Vec::with_capacity(endpoints.len()); + let mut ip_literal = false; + for endpoint in endpoints { + // @cpt-begin:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-normalize + // @cpt-begin:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-classify + match endpoint.host.as_str().parse::() { + Ok(_) => ip_literal = true, + Err(_) => match Hostname::parse(endpoint.host.as_str()) { + Ok(name) => hostnames.push(name), + Err(_) => return Err(DeriveError::NonDerivable), + }, + } + // @cpt-end:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-classify + // @cpt-end:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-normalize + } + + // @cpt-begin:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-nd-if + if ip_literal || hostnames.is_empty() { + // @cpt-begin:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-nd + return Err(DeriveError::NonDerivable); + // @cpt-end:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-nd + } + // @cpt-end:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-nd-if + + let Some(first) = endpoints.first() else { + return Err(DeriveError::NonDerivable); + }; + let port = endpoint_port(first); + let standard = port == standard_port(first.scheme); + + // @cpt-begin:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-single-if + if hostnames.len() == 1 { + // @cpt-begin:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-single + return single(&hostnames[0], port, standard); + // @cpt-end:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-single + } + // @cpt-end:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-single-if + + // @cpt-begin:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-multi-if + if hostnames.len() > 1 { + return multi(&hostnames, port, standard); + } + // @cpt-end:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-multi-if + + Err(DeriveError::NonDerivable) +} + +/// The effective port of an endpoint, with the declared default applied. +fn endpoint_port(endpoint: &Endpoint) -> u16 { + endpoint + .port + .unwrap_or_else(|| standard_port(endpoint.scheme)) +} + +/// Derives the alias of a single-hostname endpoint set. +fn single(host: &Hostname, port: u16, standard: bool) -> Result { + if standard { + Alias::parse(host.as_str()).map_err(|_| DeriveError::NonDerivable) + } else { + Alias::parse(&format!("{}:{port}", host.as_str())) + .map_err(|_| DeriveError::NonDerivable) + } +} + +/// Derives the alias of a multi-hostname endpoint set. +fn multi(hostnames: &[Hostname], port: u16, standard: bool) -> Result { + // @cpt-begin:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-suffix + let Some(suffix) = common_suffix(hostnames) else { + return Err(DeriveError::NonDerivable); + }; + // @cpt-end:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-suffix + + // @cpt-begin:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-suffix-if + if bare_public_suffix(&suffix) { + // @cpt-begin:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-suffix-reject + return Err(DeriveError::NonDerivable); + // @cpt-end:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-suffix-reject + } + // @cpt-end:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-suffix-if + + if standard { + Alias::parse(&suffix).map_err(|_| DeriveError::NonDerivable) + } else { + Alias::parse(&format!("{suffix}:{port}")).map_err(|_| DeriveError::NonDerivable) + } +} + +/// The longest common suffix of at least two labels, comparing the reversed +/// label sequences. +fn common_suffix(hostnames: &[Hostname]) -> Option { + let mut sequences: Vec> = hostnames + .iter() + .map(|host| host.as_str().split('.').rev().collect()) + .collect(); + let first = sequences.pop()?; + let first: &[&str] = first.as_slice(); + let rest: &[Vec<&str>] = sequences.as_slice(); + + let mut shared: Vec = Vec::new(); + for (position, label) in first.iter().enumerate() { + if !rest + .iter() + .all(|labels| labels.get(position) == Some(label)) + { + break; + } + shared.push((*label).to_owned()); + } + + if shared.len() < MIN_SUFFIX_LABELS { + return None; + } + + Some( + shared + .iter() + .rev() + .fold(String::new(), |accumulated, label| { + if accumulated.is_empty() { + label.clone() + } else { + format!("{accumulated}.{label}") + } + }), + ) +} + +/// Whether a candidate suffix is itself a bare public suffix, which is never +/// an alias. +fn bare_public_suffix(candidate: &str) -> bool { + psl::suffix_str(candidate) == Some(candidate) +} + +/// Reconciles a caller-supplied alias with the derived one. +/// +/// A derivable endpoint set does not leave the alias free: an equal supplied +/// value is accepted as an idempotent no-op, a different one is a validation +/// error. +/// +/// # Errors +/// +/// Returns a gateway validation error when the supplied value is not a valid +/// alias or differs from the derived one. +#[allow(clippy::result_large_err)] +fn reconcile(supplied: &str, derived: &Alias) -> Result { + // @cpt-begin:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-supplied-if + let parsed = Alias::parse(supplied).map_err(|_| alias_mismatch())?; + // @cpt-begin:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-supplied-eq + if parsed == *derived { + return Ok(parsed); + } + // @cpt-end:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-supplied-eq + // @cpt-begin:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-supplied-ne + Err(alias_mismatch()) + // @cpt-end:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-supplied-ne + // @cpt-end:cpt-cf-oagw-algo-alias-derive:p1:inst-alias-derive-supplied-if +} + +/// The validation error for a supplied alias the endpoint set does not derive. +fn alias_mismatch() -> DomainError { + DomainError::gateway( + ErrorKind::ValidationError, + "alias does not match the endpoint set", + ) +} + +/// The validation error the FEATURE's non-derivable step states, naming the +/// endpoint set and echoing no value. +fn require_explicit(error: DeriveError) -> DomainError { + DomainError::gateway(ErrorKind::ValidationError, error.detail()) +} + +/// Confirms the alias is immutable across a replacement. +/// +/// # Errors +/// +/// Returns the `AliasConflict` catalogue row, which answers 409, when the +/// replacement endpoints derive a different alias; the stored alias is left +/// unchanged. +#[allow(clippy::result_large_err)] +pub fn confirm_immutable(derived: &Alias, stored: &Alias) -> Result<(), DomainError> { + if derived == stored { + return Ok(()); + } + Err(DomainError::gateway( + ErrorKind::AliasConflict, + "alias is immutable across updates", + )) +} diff --git a/gears/system/oagw/oagw/src/control_plane/bind.rs b/gears/system/oagw/oagw/src/control_plane/bind.rs new file mode 100644 index 0000000..d73e658 --- /dev/null +++ b/gears/system/oagw/oagw/src/control_plane/bind.rs @@ -0,0 +1,117 @@ +//! Binding-style creation with tenant-local tags — +//! `cpt-cf-oagw-algo-bind-create-tags`. +//! +//! A create whose normalized alias matches an ancestor's upstream is not an +//! ordinary create: it binds the descendant's row to the ancestor's, and the +//! write it produces is the descendant's own row and nothing else. This module +//! turns the validated create body and the sharing-mode decision into that +//! write set, or returns the refusal the decision produced, so the management +//! flow persists one row and touches no ancestor row at all — including no +//! ancestor tag row. + +// @cpt-dod:cpt-cf-oagw-dod-binding-style-creation:p1 + +use uuid::Uuid; + +use crate::control_plane::sharing::{Decisions, Refusal}; +use crate::domain::effective::Family; +use crate::domain::upstream::Upstream; + +/// The write set a bind-style create persists: the descendant's own row. +#[derive(Debug, Clone, PartialEq)] +pub struct BindWriteSet { + /// The calling tenant, which owns the row the write persists. + pub tenant_id: Uuid, + /// The row to persist, carrying the body's own families and tags. + pub value: Upstream, + /// Whether the create bound the descendant's row to an ancestor's. + pub bound: bool, +} + +/// Produces the write set one create persists, binding or ordinary. +/// +/// `ancestors` is the number of ancestor bindings the chain walk resolved for +/// the normalized alias at a depth greater than the calling tenant's; zero of +/// them means no ancestor holds the alias and the operation is an ordinary +/// create. +/// +/// # Errors +/// +/// Returns the refusal the sharing-mode decision produced, which the caller +/// answers instead of writing any row. +pub fn write_set( + calling_tenant: Uuid, + ancestors: usize, + value: Upstream, + decided: &Result, +) -> Result { + // @cpt-begin:cpt-cf-oagw-algo-bind-create-tags:p1:inst-bindtags-confirm + // Confirm the binding: an ancestor row at a greater depth with the same + // normalized alias. Anything else is not a bind, and the ordinary create + // path applies. + let bound = ancestors > 0; + // @cpt-end:cpt-cf-oagw-algo-bind-create-tags:p1:inst-bindtags-confirm + + // @cpt-begin:cpt-cf-oagw-algo-bind-create-tags:p1:inst-bindtags-identity + // The row's identity is the calling tenant's own: `tenant_id` is the + // calling tenant and `id` is the identifier the create path already + // derived from nothing but the request, so the ancestor's identifiers, its + // alias row, and its endpoint set are never copied onto the descendant's + // row. + let tenant_id = calling_tenant; + let mut row = value; + // @cpt-end:cpt-cf-oagw-algo-bind-create-tags:p1:inst-bindtags-identity + + // @cpt-begin:cpt-cf-oagw-algo-bind-create-tags:p1:inst-bindtags-families + // A family whose decision is `own` or `inherit-base` is written as the + // body carries it; a family whose decision is `forced` is never written to + // the descendant's row, because the ancestor's live value is applied by + // `cpt-cf-oagw-algo-field-family-merge` at resolution time. A `forced` + // decision only arises for a family the body omitted, so the clearing + // below restates that rule on the row rather than discarding a value the + // body carried — the decision refuses such a body before this routine runs. + if let Ok(decisions) = decided { + for family in [ + Family::Auth, + Family::RateLimit, + Family::Plugins, + Family::Cors, + ] { + if !decisions.forced(family) { + continue; + } + match family { + Family::Auth => row.auth = None, + Family::RateLimit => row.rate_limit = None, + Family::Plugins => row.plugins = None, + Family::Cors => row.cors = None, + } + } + } + // @cpt-end:cpt-cf-oagw-algo-bind-create-tags:p1:inst-bindtags-families + + // @cpt-begin:cpt-cf-oagw-algo-bind-create-tags:p1:inst-bindtags-tags + // The request tags travel on the descendant's row only, exactly as the + // ordinary create's tag write stores them: `row.tags` is the body's own + // list, the write that persists it reaches no ancestor's tag rows, and the + // effective tag set is the add-only union the merge computes at resolution + // time. + // @cpt-end:cpt-cf-oagw-algo-bind-create-tags:p1:inst-bindtags-tags + + // @cpt-begin:cpt-cf-oagw-algo-bind-create-tags:p1:inst-bindtags-refusal-if + if let Err(refusal) = decided { + // @cpt-begin:cpt-cf-oagw-algo-bind-create-tags:p1:inst-bindtags-refusal-return + // No row is written and no ancestor value is disclosed. + return Err(*refusal); + // @cpt-end:cpt-cf-oagw-algo-bind-create-tags:p1:inst-bindtags-refusal-return + } + // @cpt-end:cpt-cf-oagw-algo-bind-create-tags:p1:inst-bindtags-refusal-if + + // @cpt-begin:cpt-cf-oagw-algo-bind-create-tags:p1:inst-bindtags-return + Ok(BindWriteSet { + tenant_id, + value: row, + bound, + }) + // @cpt-end:cpt-cf-oagw-algo-bind-create-tags:p1:inst-bindtags-return +} diff --git a/gears/system/oagw/oagw/src/control_plane/binding.rs b/gears/system/oagw/oagw/src/control_plane/binding.rs new file mode 100644 index 0000000..486c753 --- /dev/null +++ b/gears/system/oagw/oagw/src/control_plane/binding.rs @@ -0,0 +1,578 @@ +//! Plugin reference resolution and binding validation — the two algorithms +//! DECOMPOSITION §2.4 names `cpt-cf-oagw-algo-plugin-ref-resolve` and +//! `cpt-cf-oagw-algo-binding-validate`. +//! +//! A parent write that carries a `plugins` sub-object, and an upstream write +//! that carries an `auth` sub-configuration, resolves every reference it names +//! and validates every binding rule before any row is written, and produces +//! the write set the parent's single transaction persists. Resolution follows +//! the DESIGN §3.1 algorithm in its order: the identifier's instance part +//! selects the persisted store for a UUID and the named registry for a name, +//! the resolved plugin's base type must match the identifier's prefix, and a +//! carried `plugin_uuid` must agree with the plugin the reference resolved to. +//! +//! Nothing here resolves a credential: the references a binding carries are +//! checked for their `cred://` shape and carried opaque from there on, which +//! is what keeps a management write free of credential-store calls. + +// @cpt-dod:cpt-cf-oagw-dod-binding-model:p1 + +use serde_json::Value; +use uuid::Uuid; + +use crate::domain::error::{DomainError, ErrorKind}; +use crate::domain::plugin_contract::{NamedPluginRegistry, PluginFamily, PluginResolveError}; +use crate::plugins::credential; +pub use crate::store::{AuthIdentity, BindingWrite}; +use crate::store::{OagwStore, PluginBinding}; + +/// The upper bound one upstream or route chain holds: one auth plugin, bound +/// through the upstream's scalar columns, and never through a binding row. +const MAX_AUTH_PLUGINS: usize = 1; + +/// The plugin one reference resolved to. +#[derive(Debug, Clone, PartialEq)] +pub enum ResolvedPlugin { + /// A persisted custom row: UUID-backed, resolved from `oagw_plugin`. + Custom { + /// The family the row's `plugin_type` names. + family: PluginFamily, + /// The row's identifier, which is the UUID the instance part named. + id: Uuid, + }, + /// A named registry entry: never stored, never garbage-collected. + Named { + /// The family the identifier's base type names. + family: PluginFamily, + /// The identifier, as submitted. + identifier: String, + }, +} + +impl ResolvedPlugin { + /// The family the resolved plugin belongs to. + #[must_use] + pub const fn family(&self) -> PluginFamily { + match self { + Self::Custom { family, .. } | Self::Named { family, .. } => *family, + } + } + + /// Whether the resolved plugin has a row in `oagw_plugin`. + #[must_use] + pub const fn uuid_backed(&self) -> bool { + matches!(self, Self::Custom { .. }) + } + + /// The canonical identifier the resolved plugin is persisted with. + #[must_use] + pub fn canonical_ref(&self) -> String { + match self { + Self::Custom { family, id } => crate::gts::gts_instance(family.base_type(), *id), + Self::Named { identifier, .. } => identifier.clone(), + } + } +} + +/// Why one reference is not resolvable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolveFailure { + /// No persisted row matched the identifier, or the row's `plugin_type` + /// does not match the base type the identifier's prefix names. + Store { + /// Whether no row matched at all, as distinct from a row of the wrong + /// type. + absent: bool, + }, + /// The named registry refused the identifier: it is one of the six + /// catalog-only identifiers, or it is unknown to the registry. + Registry(PluginResolveError), + /// The carried `plugin_uuid` does not agree with the plugin the reference + /// resolved to. + UuidMismatch, + /// The reference is not a plugin identifier at all: no plugin base type + /// prefix names a family for it, so no plugin of any kind answers it. + Malformed, +} + +impl ResolveFailure { + /// The reason one failed reference is named with, in the validation error + /// the parent write answers with. + #[must_use] + pub fn reason(&self) -> String { + match self { + Self::Store { absent: true } => { + String::from("no plugin row of the calling tenant answers the identifier") + } + Self::Store { absent: false } => String::from( + "the plugin row the identifier answers carries a plugin_type the identifier's base type does not name", + ), + Self::Registry(PluginResolveError::Reserved { .. }) => String::from( + "plugin_type names a catalogue identifier no plugin family backs", + ), + Self::Registry(_) => String::from("plugin_type names no resolvable plugin"), + Self::UuidMismatch => String::from("plugin_uuid does not match the plugin the reference names"), + Self::Malformed => String::from( + "the reference is not a plugin identifier, so no plugin base type names a family for it", + ), + } + } +} + +/// The parsed parts of one `plugin_ref`. +struct ParsedRef<'a> { + /// The family the identifier's base type prefix names. + family: PluginFamily, + /// The part after the `~` separator. + instance: &'a str, +} + +/// Parses one reference into its base type and its instance part. +/// +/// A reference that names no plugin base type is refused: the type match the +/// algorithm requires compares the resolved plugin's family against the base +/// type the identifier declares, and a bare UUID declares none. +fn parse_ref(reference: &str) -> Option> { + let (family, instance) = PluginFamily::parse_identifier(reference)?; + let family = family?; + Some(ParsedRef { family, instance }) +} + +/// Resolves one reference through the persisted store and the named registry. +/// +/// # Errors +/// +/// Returns the reason the reference is not resolvable, never echoing the +/// reference value itself. +#[allow(clippy::result_large_err)] +pub fn resolve( + store: &OagwStore, + tenant_id: Uuid, + registry: &NamedPluginRegistry, + reference: &str, + carried_uuid: Option, +) -> Result { + // @cpt-begin:cpt-cf-oagw-algo-plugin-ref-resolve:p1:inst-ref-parse + // Parse the GTS identifier to extract the instance part after the `~` + // separator. + let parsed = parse_ref(reference).ok_or(ResolveFailure::Malformed)?; + // @cpt-end:cpt-cf-oagw-algo-plugin-ref-resolve:p1:inst-ref-parse + + // @cpt-begin:cpt-cf-oagw-algo-plugin-ref-resolve:p1:inst-ref-uuid-if + let resolved = if Uuid::parse_str(parsed.instance).is_ok() { + // @cpt-begin:cpt-cf-oagw-algo-plugin-ref-resolve:p1:inst-ref-store + // The instance part parses as a UUID, so the plugin is resolved from + // the persisted store, scoped to the tenant the binding's parent row + // belongs to. + let row = store.get_plugin(tenant_id, parsed.instance.parse::().unwrap_or_default()); + // @cpt-end:cpt-cf-oagw-algo-plugin-ref-resolve:p1:inst-ref-store + // @cpt-begin:cpt-cf-oagw-algo-plugin-ref-resolve:p1:inst-ref-store-fail-if + let row = match row { + None => { + // @cpt-begin:cpt-cf-oagw-algo-plugin-ref-resolve:p1:inst-ref-store-fail-return + // No row matched: the reference names nothing the tenant owns. + return Err(ResolveFailure::Store { absent: true }); + // @cpt-end:cpt-cf-oagw-algo-plugin-ref-resolve:p1:inst-ref-store-fail-return + } + // @cpt-end:cpt-cf-oagw-algo-plugin-ref-resolve:p1:inst-ref-store-fail-if + Some(row) => row, + }; + if PluginFamily::from_type_literal(&row.plugin.plugin_type) != Some(parsed.family) { + // The row's `plugin_type` does not match the base type the + // identifier's prefix names, so the identifier answers no plugin + // of the kind it declares. + return Err(ResolveFailure::Store { absent: false }); + } + ResolvedPlugin::Custom { + family: parsed.family, + id: row.plugin.id, + } + } else { + // @cpt-begin:cpt-cf-oagw-algo-plugin-ref-resolve:p1:inst-ref-named-else + // @cpt-begin:cpt-cf-oagw-algo-plugin-ref-resolve:p1:inst-ref-named + // The instance part is a name, so the identifier is resolved through + // the named registry, whose lookup fails for a catalog-only identifier + // and for an unknown one alike. + // @cpt-begin:cpt-cf-oagw-algo-plugin-ref-resolve:p1:inst-ref-named-fail-if + // @cpt-begin:cpt-cf-oagw-algo-plugin-ref-resolve:p1:inst-ref-named-fail-return + registry.resolve(reference).map_err(ResolveFailure::Registry)?; + // @cpt-end:cpt-cf-oagw-algo-plugin-ref-resolve:p1:inst-ref-named-fail-return + // @cpt-end:cpt-cf-oagw-algo-plugin-ref-resolve:p1:inst-ref-named-fail-if + // @cpt-end:cpt-cf-oagw-algo-plugin-ref-resolve:p1:inst-ref-named + ResolvedPlugin::Named { + family: parsed.family, + identifier: String::from(reference), + } + // @cpt-end:cpt-cf-oagw-algo-plugin-ref-resolve:p1:inst-ref-named-else + }; + // @cpt-end:cpt-cf-oagw-algo-plugin-ref-resolve:p1:inst-ref-uuid-if + + // @cpt-begin:cpt-cf-oagw-algo-plugin-ref-resolve:p1:inst-ref-uuidcheck-if + // @cpt-begin:cpt-cf-oagw-algo-plugin-ref-resolve:p1:inst-ref-uuidcheck-fail-if + if let Some(carried) = carried_uuid { + // A carried `plugin_uuid` must name the same plugin the reference + // resolved to, and a named plugin carries none at all. + let agrees = match &resolved { + ResolvedPlugin::Custom { id, .. } => *id == carried, + ResolvedPlugin::Named { .. } => false, + }; + if !agrees { + // @cpt-begin:cpt-cf-oagw-algo-plugin-ref-resolve:p1:inst-ref-uuidcheck-fail-return + return Err(ResolveFailure::UuidMismatch); + // @cpt-end:cpt-cf-oagw-algo-plugin-ref-resolve:p1:inst-ref-uuidcheck-fail-return + } + } + // @cpt-end:cpt-cf-oagw-algo-plugin-ref-resolve:p1:inst-ref-uuidcheck-fail-if + // @cpt-end:cpt-cf-oagw-algo-plugin-ref-resolve:p1:inst-ref-uuidcheck-if + + // @cpt-begin:cpt-cf-oagw-algo-plugin-ref-resolve:p1:inst-ref-return + // The resolved plugin carries whether it is UUID-backed, so the caller + // stores the UUID only when the plugin has a row. + Ok(resolved) + // @cpt-end:cpt-cf-oagw-algo-plugin-ref-resolve:p1:inst-ref-return +} + + +/// The auth plugin identity one upstream body's `auth` sub-configuration +/// carries, as the body wrote it. +struct SubmittedAuth { + /// The `auth.type` member. + plugin_type: String, +} + +/// The submitted `plugins` items of one parent body, as the body carried them. +struct SubmittedBinding { + /// The position the item carried, when it carried one. + position: Option, + /// The `plugin_ref` the item named. + plugin_ref: String, + /// The `plugin_uuid` the item carried, when it carried one. + plugin_uuid: Option, + /// The configuration the item carried. + config: Value, +} + +/// Validates one parent body's bindings and builds the write set. +/// +/// The checks are the DESIGN §3.6 key invariants and the DESIGN §3.1 +/// resolution algorithm: contiguous positions from 0, every reference +/// resolved, the type match, the `plugin_uuid` match, at most one auth plugin +/// bound through the scalar columns and none through a binding row, the +/// `cred://` shape of every credential reference, and the tenancy of every +/// custom reference. Every failure is accumulated into one validation error, +/// so a caller is not made to retry once per defect. +/// +/// # Errors +/// +/// Returns one gateway validation error naming every failing item with its +/// position and the reason. +#[allow(clippy::result_large_err)] +pub fn validate( + store: &OagwStore, + tenant_id: Uuid, + registry: &NamedPluginRegistry, + body: &Value, + is_upstream: bool, + marked_at: u64, +) -> Result { + let mut defects = Defects::default(); + + let submitted = submitted_items(body, is_upstream, &mut defects); + + // @cpt-begin:cpt-cf-oagw-algo-binding-validate:p1:inst-bindv-positions + // The submitted positions are read in the order the body carries them, and + // an omitted `position` defaults to the item's index, so the ADR example — + // which carries none — binds at position 0. The contiguous set from 0 in + // the submitted order is then one check: the effective position of every + // item is its index. + for (index, item) in submitted.iter().enumerate() { + // The effective position of every item is its index, so an item that + // carries a position other than its own breaks the contiguous set. + let carried = item.position.map_or(index, |position| { + usize::try_from(position).unwrap_or(usize::MAX) + }); + if carried != index { + let carried = item.position.map_or_else(String::new, |position| format!("{position} ")); + defects.add( + index, + &format!("position {}is not the submitted order", carried), + ); + } + } + // @cpt-end:cpt-cf-oagw-algo-binding-validate:p1:inst-bindv-positions + + // @cpt-begin:cpt-cf-oagw-algo-binding-validate:p1:inst-bindv-loop + let mut bindings = Vec::with_capacity(submitted.len()); + for (index, item) in submitted.iter().enumerate() { + // @cpt-begin:cpt-cf-oagw-algo-binding-validate:p1:inst-bindv-resolve + // Every reference is resolved, and the resolved type is checked + // against the family the slot carries: a binding row is a guard or a + // transform slot, and the auth slot is the upstream's scalar columns. + let resolved = resolve( + store, + tenant_id, + registry, + &item.plugin_ref, + item.plugin_uuid, + ); + // @cpt-end:cpt-cf-oagw-algo-binding-validate:p1:inst-bindv-resolve + // @cpt-begin:cpt-cf-oagw-algo-binding-validate:p1:inst-bindv-item-fail-if + match resolved { + Ok(resolved) if resolved.family() != PluginFamily::Auth => { + // @cpt-begin:cpt-cf-oagw-algo-binding-validate:p1:inst-bindv-item-fail + // The stored row carries the reference always and the UUID + // only because the plugin is UUID-backed. + bindings.push(PluginBinding { + position: index as u32, + plugin_ref: resolved.canonical_ref(), + plugin_uuid: resolved.uuid_backed().then(|| match resolved { + ResolvedPlugin::Custom { id, .. } => id, + ResolvedPlugin::Named { .. } => Uuid::nil(), + }), + config: item.config.clone(), + }); + // @cpt-end:cpt-cf-oagw-algo-binding-validate:p1:inst-bindv-item-fail + } + Ok(_) => defects.add( + index, + "the item names an auth plugin, which is bound through the upstream's auth sub-configuration and never through a binding row", + ), + Err(failure) => defects.add(index, &failure.reason()), + } + // @cpt-end:cpt-cf-oagw-algo-binding-validate:p1:inst-bindv-item-fail-if + } + // @cpt-end:cpt-cf-oagw-algo-binding-validate:p1:inst-bindv-loop + + let mut auth = None; + // @cpt-begin:cpt-cf-oagw-algo-binding-validate:p1:inst-bindv-upstream-if + if is_upstream { + // @cpt-begin:cpt-cf-oagw-algo-binding-validate:p1:inst-bindv-auth + // The `auth` sub-configuration names the one auth plugin the upstream + // binds, through its scalar columns; a route body that carries one is + // a schema failure the parent validation already answered. + auth = validate_auth(store, tenant_id, registry, body, &mut defects); + // @cpt-end:cpt-cf-oagw-algo-binding-validate:p1:inst-bindv-auth + // @cpt-begin:cpt-cf-oagw-algo-binding-validate:p1:inst-bindv-credshape + // Every credential reference the auth plugin configuration carries is + // checked for its `cred://` shape and resolved by nobody here. + check_credential_references(body, &mut defects); + // @cpt-end:cpt-cf-oagw-algo-binding-validate:p1:inst-bindv-credshape + } + // @cpt-end:cpt-cf-oagw-algo-binding-validate:p1:inst-bindv-upstream-if + + // @cpt-begin:cpt-cf-oagw-algo-binding-validate:p1:inst-bindv-fail-if + if defects.is_empty() { + // @cpt-begin:cpt-cf-oagw-algo-binding-validate:p1:inst-bindv-fail-else + // @cpt-begin:cpt-cf-oagw-algo-binding-validate:p1:inst-bindv-write-set + // The write set is the full replacement of the parent's binding rows, + // which is what makes a body that omits the `plugins` sub-object clear + // them. + // @cpt-begin:cpt-cf-oagw-algo-binding-validate:p1:inst-bindv-return + return Ok(BindingWrite { + bindings, + auth, + marked_at, + }); + // @cpt-end:cpt-cf-oagw-algo-binding-validate:p1:inst-bindv-return + // @cpt-end:cpt-cf-oagw-algo-binding-validate:p1:inst-bindv-write-set + } + // @cpt-end:cpt-cf-oagw-algo-binding-validate:p1:inst-bindv-fail-else + // @cpt-begin:cpt-cf-oagw-algo-binding-validate:p1:inst-bindv-fail-return + Err(defects.into_error()) + // @cpt-end:cpt-cf-oagw-algo-binding-validate:p1:inst-bindv-fail-return + // @cpt-end:cpt-cf-oagw-algo-binding-validate:p1:inst-bindv-fail-if +} + +/// Reads the submitted `plugins` items and the auth identity off the body. +fn submitted_items(body: &Value, is_upstream: bool, defects: &mut Defects) -> Vec { + let mut submitted = Vec::new(); + let Some(items) = body + .get("plugins") + .and_then(|plugins| plugins.get("items")) + .and_then(Value::as_array) + else { + return submitted; + }; + for (index, item) in items.iter().enumerate() { + match binding_item(item) { + Ok(item) => submitted.push(item), + Err(reason) => defects.add(index, &reason), + } + } + if !is_upstream && body.get("auth").is_some() { + // A route carries no `auth` sub-configuration at all; the schema root + // refuses the member, and the check here is the record of that rule. + defects.take_auth(); + } + submitted +} + +/// Reads one submitted item into the binding it validates to. +fn binding_item(item: &Value) -> Result { + let (reference, carried_uuid, config, position) = match item { + Value::String(identifier) => (identifier.clone(), None, Value::Object(Default::default()), None), + Value::Object(fields) => { + let reference = fields + .get("plugin_ref") + .and_then(Value::as_str) + .ok_or_else(|| String::from("the item carries no plugin_ref to identify the plugin by"))?; + let carried_uuid = match fields.get("plugin_uuid") { + None => None, + Some(value) => match value.as_str().map(Uuid::parse_str) { + Some(Ok(uuid)) => Some(uuid), + _ => { + return Err(String::from("plugin_uuid")); + } + }, + }; + let config = fields.get("config").cloned().unwrap_or_else(|| { + Value::Object(serde_json::Map::new()) + }); + if !config.is_object() { + return Err(String::from("config")); + } + let position = match fields.get("position") { + None => None, + Some(value) => match value.as_u64() { + Some(position) => Some(u32::try_from(position).map_err(|_| String::from("position"))?), + None => return Err(String::from("position")), + }, + }; + (String::from(reference), carried_uuid, config, position) + } + other => { + return Err(format!( + "the item is neither an identifier nor a plugin binding object: {other}" + )) + } + }; + Ok(SubmittedBinding { + position, + plugin_ref: reference, + plugin_uuid: carried_uuid, + config, + }) +} + +/// Validates the `auth` sub-configuration of one upstream body. +fn validate_auth( + store: &OagwStore, + tenant_id: Uuid, + registry: &NamedPluginRegistry, + body: &Value, + defects: &mut Defects, +) -> Option { + let auth = body.get("auth")?; + // A sub-configuration that names no plugin binds no auth plugin, which the + // shipped schema admits by declaring the member optional: the sharing mode + // alone is a legal body, and it writes no identity column. + let plugin_type = auth.get("type").and_then(Value::as_str)?; + let submitted = SubmittedAuth { + plugin_type: String::from(plugin_type), + }; + // The auth slot is one per upstream and carries an auth identifier only. + let resolved = resolve( + store, + tenant_id, + registry, + &submitted.plugin_type, + None, + ); + match resolved { + Ok(resolved) if resolved.family() == PluginFamily::Auth => Some(AuthIdentity { + plugin_ref: resolved.canonical_ref(), + plugin_uuid: resolved.uuid_backed().then(|| match resolved { + ResolvedPlugin::Custom { id, .. } => id, + ResolvedPlugin::Named { .. } => Uuid::nil(), + }), + }), + Ok(resolved) => { + defects.add( + MAX_AUTH_PLUGINS, + &format!( + "auth.type names a {} plugin, which is not an auth plugin", + resolved.family().as_str() + ), + ); + None + } + Err(failure) => { + defects.add(MAX_AUTH_PLUGINS, &failure.reason()); + None + } + } +} + +/// Checks every credential reference the `auth` sub-configuration carries for +/// its `cred://` shape. +/// +/// The reference members are the `auth.secret_ref` the shipped schema names +/// and every `*_ref` member of the plugin configuration, which is the shape +/// the built-in auth plugins read their references through. None is resolved, +/// and no reference value is echoed into the answer. +fn check_credential_references(body: &Value, defects: &mut Defects) { + let Some(auth) = body.get("auth") else { + return; + }; + if let Some(reference) = auth.get("secret_ref").and_then(Value::as_str) { + check_shape(reference, "auth.secret_ref", defects); + } + let Some(config) = auth.get("config").and_then(Value::as_object) else { + return; + }; + for (key, value) in config { + if !key.ends_with("_ref") { + continue; + } + let Some(reference) = value.as_str() else { + continue; + }; + check_shape(reference, &format!("auth.config.{key}"), defects); + } +} + +/// Names the member when the reference does not carry the `cred://` shape. +fn check_shape(reference: &str, property: &str, defects: &mut Defects) { + if !credential::is_credential_reference(reference) { + defects.add(MAX_AUTH_PLUGINS, property); + } +} + +/// Accumulated failing items of one binding validation. +#[derive(Debug, Default)] +struct Defects { + /// One entry per failing item: the position and the reason. + items: Vec, + /// Whether the body carried an `auth` sub-configuration where none is + /// admitted. + auth: bool, +} + +impl Defects { + /// Adds one failing item, dropping a repeat. + fn add(&mut self, position: usize, reason: &str) { + let detail = format!("plugins.items[{position}]: {reason}"); + if !self.items.contains(&detail) { + self.items.push(detail); + } + } + + /// Records that the body carried an `auth` sub-configuration it may not. + fn take_auth(&mut self) { + self.auth = true; + } + + /// Whether every check passed. + fn is_empty(&self) -> bool { + self.items.is_empty() && !self.auth + } + + /// The single validation error naming every failing item. + fn into_error(self) -> DomainError { + let mut details = self.items; + if self.auth { + details.push(String::from("auth")); + } + DomainError::gateway(ErrorKind::ValidationError, details.join(", ")) + } +} diff --git a/gears/system/oagw/oagw/src/control_plane/cache.rs b/gears/system/oagw/oagw/src/control_plane/cache.rs new file mode 100644 index 0000000..5147a42 --- /dev/null +++ b/gears/system/oagw/oagw/src/control_plane/cache.rs @@ -0,0 +1,191 @@ +//! Control Plane cache and deletion seam — ADR 0006. +//! +//! [`ControlPlaneCache`] is the L1 configuration cache ADR 0006 assigns to this +//! feature: a generation counter the write path advances after a successful +//! write and before the response is produced. The data-plane proxy feature +//! consumes the generation to decide when its in-memory configuration snapshot +//! is stale; this feature only ever moves it forward. +//! +//! [`RateLimitCleanup`] is the in-process seam `cpt-cf-oagw-feature-rate-limiting` +//! registers its cleanup with: a successful upstream or route deletion notifies +//! the registered observer after the transaction commits and before the +//! response is produced, and a failed deletion notifies nothing. +//! +//! [`DataPlaneFlush`] is the same kind of seam for +//! `cpt-cf-oagw-algo-dp-cache`: a successful write of any kind notifies the +//! registered flush in the same process and before the write's response is +//! produced, so a read that follows the write never sees the stale entry. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use parking_lot::RwLock; +use uuid::Uuid; + +/// The L1 configuration cache of the management half. +/// +/// A generation counter is enough for this feature: the data plane only needs +/// to know that *something* changed, never what. +#[derive(Debug, Clone)] +pub struct ControlPlaneCache { + generation: Arc, + /// The configuration-write seam the Data Plane flush registers with. + writes: Arc, +} + +/// The flush `cpt-cf-oagw-algo-dp-cache` registers for configuration writes. +pub trait DataPlaneFlush: Send + Sync { + /// Drops the Data Plane entries the write of one tenant affects, in the + /// same process and before the write's response is produced. + fn configuration_written(&self, tenant_id: Uuid); +} + +/// The registration slot of the configuration-write seam. +#[derive(Default)] +pub struct WriteObservers { + registered: RwLock>>, +} + +impl std::fmt::Debug for WriteObservers { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let filled = self.registered.read().is_some(); + formatter + .debug_struct("WriteObservers") + .field("registered", &filled) + .finish() + } +} + +impl WriteObservers { + /// Creates the empty slot. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Registers the flush the data-plane proxy feature owns. + pub fn register(&self, observer: Arc) { + *self.registered.write() = Some(observer); + } + + /// Notifies the registered flush that one tenant's configuration changed. + /// + /// With no flush registered the notification is logged and nothing else + /// happens, which is the posture of a deployment whose data plane holds no + /// cache to invalidate. + pub fn configuration_written(&self, tenant_id: Uuid) { + match self.registered.read().clone() { + Some(observer) => observer.configuration_written(tenant_id), + None => tracing::debug!( + "no data-plane flush registered; the configuration write is not notified" + ), + } + } +} + +impl Default for ControlPlaneCache { + fn default() -> Self { + Self::new() + } +} + +impl ControlPlaneCache { + /// Creates the cache with the generation at zero. + #[must_use] + pub fn new() -> Self { + Self { + generation: Arc::new(AtomicU64::new(0)), + writes: Arc::new(WriteObservers::new()), + } + } + + /// Advances the generation after a successful write and before the + /// response is produced. + pub fn flush(&self) { + self.generation.fetch_add(1, Ordering::Release); + } + + /// Registers the Data Plane flush a successful write notifies. + pub fn register_dp_flush(&self, observer: Arc) { + self.writes.register(observer); + } + + /// Advances the generation and notifies the Data Plane flush that the + /// configuration of one tenant changed, in that order: a read that races + /// the write either misses the cache and resolves the new chain, or hits + /// the entry the flush dropped and re-resolves it. + pub fn flush_for(&self, tenant_id: Uuid) { + self.flush(); + self.writes.configuration_written(tenant_id); + } + + /// Reads the current generation. + #[must_use] + pub fn generation(&self) -> u64 { + self.generation.load(Ordering::Acquire) + } +} + +/// The cleanup the rate-limiting feature registers for configuration +/// deletions. +pub trait RateLimitCleanup: Send + Sync { + /// Notifies the cleanup that one upstream row was deleted. + fn upstream_deleted(&self, tenant_id: Uuid, upstream_id: Uuid); + /// Notifies the cleanup that one route row was deleted. + fn route_deleted(&self, tenant_id: Uuid, route_id: Uuid); +} + +/// The registration slot of the deletion seam. +#[derive(Default)] +pub struct DeletionObservers { + registered: RwLock>>, +} + +impl std::fmt::Debug for DeletionObservers { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let filled = self.registered.read().is_some(); + formatter + .debug_struct("DeletionObservers") + .field("registered", &filled) + .finish() + } +} + +impl DeletionObservers { + /// Creates the empty slot. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Registers the observer the rate-limiting feature owns. + pub fn register(&self, observer: Arc) { + *self.registered.write() = Some(observer); + } + + /// Notifies the registered observer of a successful upstream deletion. + /// + /// With no observer registered the notification is logged and nothing else + /// happens. + pub fn upstream_deleted(&self, tenant_id: Uuid, upstream_id: Uuid) { + match self.registered.read().clone() { + Some(observer) => observer.upstream_deleted(tenant_id, upstream_id), + None => tracing::debug!( + "no rate-limit cleanup registered; the upstream deletion is not notified" + ), + } + } + + /// Notifies the registered observer of a successful route deletion. + /// + /// With no observer registered the notification is logged and nothing else + /// happens. + pub fn route_deleted(&self, tenant_id: Uuid, route_id: Uuid) { + match self.registered.read().clone() { + Some(observer) => observer.route_deleted(tenant_id, route_id), + None => tracing::debug!( + "no rate-limit cleanup registered; the route deletion is not notified" + ), + } + } +} diff --git a/gears/system/oagw/oagw/src/control_plane/chain.rs b/gears/system/oagw/oagw/src/control_plane/chain.rs new file mode 100644 index 0000000..785bee2 --- /dev/null +++ b/gears/system/oagw/oagw/src/control_plane/chain.rs @@ -0,0 +1,186 @@ +//! The tenant chain walk of the hierarchical configuration feature. +//! +//! [`walk_candidates`] is `cpt-cf-oagw-algo-tenant-chain-walk`: it walks the +//! ordered ancestor chain from the calling tenant to the platform root and +//! issues one tenant-scoped read per element for the normalized alias. A chain +//! the platform tenant-resolver cannot answer in order is an unavailable chain +//! and the walk fails closed, rather than ordering candidates against a chain +//! it cannot order. +//! +//! [`chain_of`] is the adapter the API layer calls: it asks the +//! `tenant-resolver` gear for the ancestor chain and drops the tenants it +//! retired — `status: deleted` — before ordering it, so a retired tenant is +//! never an active participant of a resolution. It answers `None` when the +//! gear is absent, when the call fails, or when the answer cannot be ordered; +//! every caller fails closed on `None`. + +// @cpt-dod:cpt-cf-oagw-dod-tenant-chain-walk:p1 + +use std::sync::Arc; + +use toolkit_security::SecurityContext; +use tenant_resolver_sdk::{GetAncestorsOptions, TenantResolverClient, TenantStatus}; +use toolkit_macros::domain_model; +use uuid::Uuid; + +use crate::domain::alias::Alias; +use crate::domain::effective::{FamilyModes, TenantChain}; +use crate::domain::upstream::Upstream; +use crate::store::{OagwStore, UpstreamRow}; + +/// Why the walk could not be run. +#[domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum UnavailableChain { + /// The platform tenant-resolver supplied no usable chain. + #[error("the platform tenant-resolver supplied no ordered ancestor chain")] + Unordered, +} + +/// One upstream row the walk matched, with everything the shadow resolve and +/// the merge need from it. +/// +/// The candidate carries the whole row, not only the visible families: which +/// families are visible is decided later, by the sharing-mode decision, from +/// the modes recorded here. An `enforce` mode is recorded as a mode and never +/// pre-applied, so the walk stays a read and the decision stays in one place. +#[domain_model] +#[derive(Debug, Clone, PartialEq)] +pub struct ChainCandidate { + /// The depth of the owning tenant in the chain: `0` for the calling + /// tenant and growing towards the root. + pub depth: usize, + /// The tenant that owns the row. + pub tenant_id: Uuid, + /// The identifier of the matched upstream row. + pub upstream_id: Uuid, + /// The row's `enabled` flag, which participates in the effective enabled + /// state regardless of every sharing mode. + pub enabled: bool, + /// The per-family sharing modes the row declares. + pub modes: FamilyModes, + /// The matched row itself. + pub row: UpstreamRow, +} + +impl ChainCandidate { + /// Builds the candidate the walk appends for one matched row. + #[must_use] + pub fn of(depth: usize, row: &UpstreamRow) -> Self { + Self { + depth, + tenant_id: row.tenant_id, + upstream_id: row.upstream.id, + enabled: row.upstream.enabled, + modes: modes_of(&row.upstream), + row: row.clone(), + } + } +} + +/// The per-family sharing modes one upstream row declares. +/// +/// A family the row omits, and a family whose `sharing` member the row omits, +/// both take the schema default `private`; [`FamilyModes::new`] takes the +/// default for whatever it is not told about. +#[must_use] +pub fn modes_of(upstream: &Upstream) -> FamilyModes { + FamilyModes::new( + upstream.auth.as_ref().and_then(|auth| auth.sharing), + upstream.rate_limit.as_ref().and_then(|limit| limit.sharing), + upstream.plugins.as_ref().and_then(|plugins| plugins.sharing), + upstream.cors.as_ref().and_then(|cors| cors.sharing), + ) +} + +/// The `upstream:{tenant_id}:{alias}` Control Plane L1 cache key of ADR 0005 +/// one per-element read is addressed by. +#[must_use] +pub fn cache_key(tenant_id: Uuid, alias: &Alias) -> String { + format!("upstream:{tenant_id}:{alias}") +} + +/// Walks the ancestor chain from the calling tenant to the platform root, +/// looking for the normalized alias. +/// +/// # Errors +/// +/// Returns [`UnavailableChain::Unordered`] when the resolver's answer cannot +/// be ordered — a cycle, a repeated element, or a calling tenant that is not +/// its first element — and the caller fails closed rather than resolving +/// against it. +pub fn walk_candidates( + store: &OagwStore, + calling_tenant: Uuid, + ancestors: &[Uuid], + alias: &Alias, +) -> Result, UnavailableChain> { + // @cpt-begin:cpt-cf-oagw-algo-tenant-chain-walk:p1:inst-walk-read + let chain = TenantChain::from_resolver(calling_tenant, ancestors); + // @cpt-end:cpt-cf-oagw-algo-tenant-chain-walk:p1:inst-walk-read + + // @cpt-begin:cpt-cf-oagw-algo-tenant-chain-walk:p1:inst-walk-unavailable-if + let Some(chain) = chain else { + // @cpt-begin:cpt-cf-oagw-algo-tenant-chain-walk:p1:inst-walk-unavailable-return + return Err(UnavailableChain::Unordered); + // @cpt-end:cpt-cf-oagw-algo-tenant-chain-walk:p1:inst-walk-unavailable-return + }; + // @cpt-end:cpt-cf-oagw-algo-tenant-chain-walk:p1:inst-walk-unavailable-if + + // @cpt-begin:cpt-cf-oagw-algo-tenant-chain-walk:p1:inst-walk-else + let mut candidates = Vec::new(); + // @cpt-end:cpt-cf-oagw-algo-tenant-chain-walk:p1:inst-walk-else + + // @cpt-begin:cpt-cf-oagw-algo-tenant-chain-walk:p1:inst-walk-loop + for (depth, tenant) in chain.tenants().iter().enumerate() { + // @cpt-begin:cpt-cf-oagw-algo-tenant-chain-walk:p1:inst-walk-hit-if + if let Some(row) = store.upstream_by_alias(*tenant, alias) { + // @cpt-begin:cpt-cf-oagw-algo-tenant-chain-walk:p1:inst-walk-hit + candidates.push(ChainCandidate::of(depth, &row)); + // @cpt-end:cpt-cf-oagw-algo-tenant-chain-walk:p1:inst-walk-hit + } + // @cpt-end:cpt-cf-oagw-algo-tenant-chain-walk:p1:inst-walk-hit-if + } + // @cpt-end:cpt-cf-oagw-algo-tenant-chain-walk:p1:inst-walk-loop + + // @cpt-begin:cpt-cf-oagw-algo-tenant-chain-walk:p1:inst-walk-return + Ok(candidates) + // @cpt-end:cpt-cf-oagw-algo-tenant-chain-walk:p1:inst-walk-return +} + +/// Asks the platform tenant-resolver for the ancestor chain of one tenant and +/// orders it, calling tenant first. +/// +/// A tenant the resolver retired — `status: deleted` — is dropped before the +/// chain is ordered, because a retired tenant is not an active participant of +/// any resolution. `None` answers a failed call, a missing client, and an +/// answer that cannot be ordered; the caller fails closed on all three. +#[must_use] +pub async fn chain_of( + client: Option<&Arc>, + context: &SecurityContext, + tenant: Uuid, +) -> Option { + let Some(client) = client else { + tracing::debug!("no tenant-resolver client registered; the chain is unavailable"); + return None; + }; + let answer = client + .get_ancestors( + context, + tenant_resolver_sdk::TenantId(tenant), + &GetAncestorsOptions::default(), + ) + .await + .inspect_err(|error| { + tracing::debug!(%error, "the tenant-resolver refused the ancestor chain"); + }) + .ok()?; + let ancestors: Vec = answer + .ancestors + .iter() + .filter(|reference| reference.status != TenantStatus::Deleted) + .map(|reference| reference.id.0) + .collect(); + TenantChain::from_resolver(tenant, &ancestors) +} diff --git a/gears/system/oagw/oagw/src/control_plane/effective.rs b/gears/system/oagw/oagw/src/control_plane/effective.rs new file mode 100644 index 0000000..200a08e --- /dev/null +++ b/gears/system/oagw/oagw/src/control_plane/effective.rs @@ -0,0 +1,732 @@ +//! The per-field-family effective merge and the resolution entry point. +//! +//! [`merge_upstream_layer`] and [`merge_route_layer`] realize +//! `cpt-cf-oagw-algo-field-family-merge`: they take the routing target's row, +//! the ordered ancestor bindings with the families they contribute, and the +//! layer being resolved, and answer one [`EffectiveUpstreamConfig`] or one +//! [`EffectiveRouteConfig`]. +//! +//! [`resolve_effective`] is the entry `cpt-cf-oagw-flow-resolve-effective-config` +//! names. It normalizes the alias, orders the chain the platform tenant +//! resolver supplies, walks it, shadow-resolves the candidates, and merges +//! both layers. It answers [`ResolveError::UnavailableChain`] when the chain +//! cannot be ordered — the caller fails closed with the platform 500 problem +//! shape — and `None` when no chain element holds the alias, which the +//! consumer answers 404. +//! +//! The merge applies from root to child: the layers arrive most distant first +//! with the routing target last, so a closer layer's own value replaces a more +//! distant one, and a value an ancestor marked `enforce` is decided by that +//! ancestor and no closer layer can replace it. [`merge_upstream_layer`] +//! builds the upstream chain's layers and [`merge_route_layer`] the route +//! chain's; both feed the same five per-family merges, which is why a route +//! row and an upstream row are decided by one table. + +// @cpt-dod:cpt-cf-oagw-dod-field-family-merge:p1 + +use toolkit_macros::domain_model; +use uuid::Uuid; + +use crate::control_plane::chain::{ChainCandidate, walk_candidates}; +use crate::control_plane::shadow::{ShadowResolution, route_contributed, shadow_resolve}; +use crate::domain::alias::Alias; +use crate::domain::effective::{ + AncestorBinding, ContributedFamilies, EffectiveAuth, EffectiveCors, EffectivePluginChain, + EffectiveRateLimit, EffectiveRouteConfig, EffectiveTagSet, EffectiveUpstreamConfig, Family, + FamilyModes, RouteSelector, TenantChain, +}; +use crate::domain::route::{GrpcMatch, HttpMatch, Route}; +use crate::domain::upstream::{ + AuthConfig, Burst, CorsConfig, PluginsConfig, RateLimitConfig, SharingMode, Sustained, Window, +}; +use crate::store::OagwStore; + +/// Why a resolution produced no configuration. +#[domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum ResolveError { + /// The platform tenant-resolver supplied no ordered ancestor chain, or the + /// alias handed in cannot be normalized. Both fail the resolution closed: + /// no configuration is produced and nothing is guessed. + #[error("the resolution cannot run: the alias or the ancestor chain is unusable")] + UnavailableChain, +} + +/// The answer of one effective-configuration resolution. +/// +/// The per-family sharing modes and the resolved ownership the consumer needs +/// for its own authorization check are carried by the per-family results, each +/// of which names the `mode` that produced it and the `owner` whose value is +/// effective. +#[domain_model] +#[derive(Debug, Clone, PartialEq)] +pub struct EffectiveResolution { + /// The upstream-layer result. + pub upstream: EffectiveUpstreamConfig, + /// The route-layer result, when the chain holds a matching route. + pub route: Option, + /// The effective `enabled` state of the routing target: the conjunction of + /// the target's own flag with every matched ancestor row's flag. + pub enabled: bool, +} + +/// Resolves the effective configuration one proxy request is subject to. +/// +/// This is the routine the Data Plane calls with the normalized alias, the +/// method and the path — ADR 0006's `CP.resolve_proxy_target(alias, method, +/// path)`. It writes nothing and reads only through the store. +/// +/// # Errors +/// +/// Returns [`ResolveError::UnavailableChain`] when the chain cannot be +/// ordered or the alias cannot be normalized; the caller answers the platform +/// 500 problem shape and never a partial configuration. +pub fn resolve_effective( + store: &OagwStore, + calling_tenant: Uuid, + ancestors: &[Uuid], + alias: &str, + selector: &RouteSelector, +) -> Result, ResolveError> { + // @cpt-begin:cpt-cf-oagw-flow-resolve-effective-config:p1:inst-res-request + // The Data Plane supplies the alias and the calling tenant it resolved + // from the SecurityContext; this feature registers no endpoint of its own. + // @cpt-end:cpt-cf-oagw-flow-resolve-effective-config:p1:inst-res-request + + // @cpt-begin:cpt-cf-oagw-flow-resolve-effective-config:p1:inst-res-normalize + // The same normalization routine the write path uses, so a resolution can + // never disagree with a stored alias about shape, case, or a trailing dot. + let normalized = Alias::parse(alias).map_err(|_| ResolveError::UnavailableChain)?; + // @cpt-end:cpt-cf-oagw-flow-resolve-effective-config:p1:inst-res-normalize + + // @cpt-begin:cpt-cf-oagw-flow-resolve-effective-config:p1:inst-res-chain + // The chain the caller obtained from the platform tenant-resolver, ordered + // calling tenant first. + let chain = TenantChain::from_resolver(calling_tenant, ancestors); + // @cpt-end:cpt-cf-oagw-flow-resolve-effective-config:p1:inst-res-chain + + // @cpt-begin:cpt-cf-oagw-flow-resolve-effective-config:p1:inst-res-chain-if + if chain.is_none() { + // @cpt-begin:cpt-cf-oagw-flow-resolve-effective-config:p1:inst-res-chain-return + // An unordered or cyclic chain cannot decide who shadows whom, so the + // resolution fails closed instead of ordering candidates against it. + return Err(ResolveError::UnavailableChain); + // @cpt-end:cpt-cf-oagw-flow-resolve-effective-config:p1:inst-res-chain-return + } + // @cpt-end:cpt-cf-oagw-flow-resolve-effective-config:p1:inst-res-chain-if + + // @cpt-begin:cpt-cf-oagw-flow-resolve-effective-config:p1:inst-res-chain-else + // @cpt-begin:cpt-cf-oagw-flow-resolve-effective-config:p1:inst-res-walk + let candidates = walk_candidates(store, calling_tenant, ancestors, &normalized) + .map_err(|_| ResolveError::UnavailableChain)?; + // @cpt-end:cpt-cf-oagw-flow-resolve-effective-config:p1:inst-res-walk + + // @cpt-begin:cpt-cf-oagw-flow-resolve-effective-config:p1:inst-res-shadow + let Some(shadow) = shadow_resolve(&candidates, &normalized) else { + // No chain element holds the alias: the not-found outcome the consumer + // answers 404. No configuration is produced. + return Ok(None); + }; + // @cpt-end:cpt-cf-oagw-flow-resolve-effective-config:p1:inst-res-shadow + + // @cpt-begin:cpt-cf-oagw-flow-resolve-effective-config:p1:inst-res-merge-upstream + let upstream = merge_upstream_layer(&shadow.target, &shadow.bindings); + // @cpt-end:cpt-cf-oagw-flow-resolve-effective-config:p1:inst-res-merge-upstream + + // @cpt-begin:cpt-cf-oagw-flow-resolve-effective-config:p1:inst-res-merge-route + let route = merge_route_layer(store, &shadow, selector); + // @cpt-end:cpt-cf-oagw-flow-resolve-effective-config:p1:inst-res-merge-route + + // @cpt-begin:cpt-cf-oagw-flow-resolve-effective-config:p1:inst-res-return + Ok(Some(EffectiveResolution { + enabled: shadow.enabled, + upstream, + route, + })) + // @cpt-end:cpt-cf-oagw-flow-resolve-effective-config:p1:inst-res-return + // @cpt-end:cpt-cf-oagw-flow-resolve-effective-config:p1:inst-res-chain-else +} + +/// What one merge layer holds of its own, as opposed to what it contributes to +/// its descendants. +/// +/// An ancestor layer holds nothing of its own: its families reach the merge +/// only through its `contributed` member, which carries nothing for a family +/// the row marked `private`. +#[derive(Default)] +struct OwnFamilies<'a> { + auth: Option<&'a AuthConfig>, + rate_limit: Option<&'a RateLimitConfig>, + plugins: Option<&'a PluginsConfig>, + cors: Option<&'a CorsConfig>, + tags: &'a [String], +} + +/// One layer of a merge: a row's contribution to its descendants and what it +/// holds of its own. +/// +/// The layers arrive most distant first with the routing target last, which is +/// the root-to-child application order DECOMPOSITION §1.5 states and which +/// makes the target's own values the ones applied last. +struct MergeLayer<'a> { + /// The tenant that owns the row of this layer. + tenant_id: Uuid, + /// What the row contributes to its descendants: nothing for the routing + /// target, which is the local layer of the merge. + contributed: ContributedFamilies, + /// The modes the row itself declares, which are the fallback when no + /// ancestor contributes a family. + modes: FamilyModes, + /// The row's own family values. + own: OwnFamilies<'a>, +} + +/// Merges the upstream layer: the routing target's row as the base, the +/// ancestor bindings applied from root to child. +#[must_use] +pub fn merge_upstream_layer( + target: &ChainCandidate, + bindings: &[AncestorBinding], +) -> EffectiveUpstreamConfig { + // @cpt-begin:cpt-cf-oagw-algo-field-family-merge:p1:inst-merge-base + // The routing target's row is the base every family starts from, and the + // bindings are held most distant first so the merge applies root to child. + let base = &target.row.upstream; + let mut layers: Vec> = bindings + .iter() + .map(|binding| MergeLayer { + tenant_id: binding.tenant_id, + contributed: binding.contributed.clone(), + modes: modes_of_contributed(&binding.contributed), + own: OwnFamilies::default(), + }) + .collect(); + // The routing target is the local layer: it contributes nothing, because + // its own values are the ones the merge applies last. + layers.push(MergeLayer { + tenant_id: target.tenant_id, + contributed: ContributedFamilies { + auth: None, + rate_limit: None, + plugins: None, + cors: None, + tags: None, + }, + modes: target.modes, + own: OwnFamilies { + auth: base.auth.as_ref(), + rate_limit: base.rate_limit.as_ref(), + plugins: base.plugins.as_ref(), + cors: base.cors.as_ref(), + tags: &base.tags, + }, + }); + // @cpt-end:cpt-cf-oagw-algo-field-family-merge:p1:inst-merge-base + + // @cpt-begin:cpt-cf-oagw-algo-field-family-merge:p1:inst-merge-loop + // @cpt-begin:cpt-cf-oagw-algo-field-family-merge:p1:inst-merge-apply + // Every family the layer carries is merged with its own strategy row, and + // each result carries the sharing mode and the owner that produced it. + let auth = merge_auth(&layers); + let rate_limit = merge_rate_limit(&layers); + let plugins = merge_plugins(&layers); + let cors = merge_cors(&layers); + let tags = merge_tags(&layers); + // @cpt-end:cpt-cf-oagw-algo-field-family-merge:p1:inst-merge-apply + // @cpt-end:cpt-cf-oagw-algo-field-family-merge:p1:inst-merge-loop + + // @cpt-begin:cpt-cf-oagw-algo-field-family-merge:p1:inst-merge-upstream-else + // @cpt-begin:cpt-cf-oagw-algo-field-family-merge:p1:inst-merge-upstream + // @cpt-begin:cpt-cf-oagw-algo-field-family-merge:p1:inst-merge-return + EffectiveUpstreamConfig { + tenant_id: target.tenant_id, + upstream_id: target.upstream_id, + auth, + rate_limit, + plugins, + cors, + tags, + } + // @cpt-end:cpt-cf-oagw-algo-field-family-merge:p1:inst-merge-return + // @cpt-end:cpt-cf-oagw-algo-field-family-merge:p1:inst-merge-upstream + // @cpt-end:cpt-cf-oagw-algo-field-family-merge:p1:inst-merge-upstream-else +} + +/// Merges the route layer along the same chain: the closest matching route +/// wins, and the more distant matching routes contribute their families with +/// the same strategies. +/// +/// A route carries no authentication family, so the result has no `auth` +/// member to skip. A route's modes are read from each family's own `sharing` +/// member, which the shipped route schema defaults to `private`. +#[must_use] +pub fn merge_route_layer( + store: &OagwStore, + shadow: &ShadowResolution, + selector: &RouteSelector, +) -> Option { + // The chain elements that hold an alias-matched upstream row are the only + // ones whose routes can match; each is read once, scoped to its own + // tenant, which is what keeps a route row of a tenant outside the chain + // out of the candidate set. + let mut matched: Vec<(usize, Uuid, Route)> = Vec::new(); + for binding in &shadow.bindings { + collect_routes( + store, + binding.tenant_id, + binding.upstream_id, + binding.depth, + selector, + &mut matched, + ); + } + collect_routes( + store, + shadow.target.tenant_id, + shadow.target.upstream_id, + shadow.target.depth, + selector, + &mut matched, + ); + // Most distant first, so the closest matching route is the last layer and + // takes priority. + matched.sort_by_key(|(depth, _, _)| std::cmp::Reverse(*depth)); + let (_, local_tenant, local_route) = matched.last()?; + + let mut layers: Vec> = Vec::new(); + for (_, tenant_id, route) in &matched[..matched.len() - 1] { + let contributed = route_contributed(route); + let modes = modes_of_contributed(&contributed); + layers.push(MergeLayer { + tenant_id: *tenant_id, + contributed, + modes, + own: OwnFamilies::default(), + }); + } + layers.push(MergeLayer { + tenant_id: *local_tenant, + contributed: ContributedFamilies { + auth: None, + rate_limit: None, + plugins: None, + cors: None, + tags: None, + }, + modes: modes_of_contributed(&route_contributed(local_route)), + own: OwnFamilies { + auth: None, + rate_limit: local_route.rate_limit.as_ref(), + plugins: local_route.plugins.as_ref(), + cors: local_route.cors.as_ref(), + tags: &local_route.tags, + }, + }); + + // @cpt-begin:cpt-cf-oagw-algo-field-family-merge:p1:inst-merge-route-if + // @cpt-begin:cpt-cf-oagw-algo-field-family-merge:p1:inst-merge-route + // @cpt-begin:cpt-cf-oagw-algo-field-family-merge:p1:inst-merge-return + // The auth family is skipped: a route carries no authentication family, so + // there is no inherited auth configuration for a route to resolve. + Some(EffectiveRouteConfig { + tenant_id: *local_tenant, + route_id: local_route.id, + upstream_id: local_route.upstream_id, + rate_limit: merge_rate_limit(&layers), + plugins: merge_plugins(&layers), + cors: merge_cors(&layers), + tags: merge_tags(&layers), + }) + // @cpt-end:cpt-cf-oagw-algo-field-family-merge:p1:inst-merge-return + // @cpt-end:cpt-cf-oagw-algo-field-family-merge:p1:inst-merge-route + // @cpt-end:cpt-cf-oagw-algo-field-family-merge:p1:inst-merge-route-if +} + +/// The modes one row declares, read from the families it contributes. +/// +/// A family the row does not contribute takes the schema default `private`, +/// which is what [`FamilyModes::new`] supplies for whatever it is not told +/// about. +fn modes_of_contributed(contributed: &ContributedFamilies) -> FamilyModes { + FamilyModes::new( + contributed.auth.as_ref().map(|item| item.mode), + contributed.rate_limit.as_ref().map(|item| item.mode), + contributed.plugins.as_ref().map(|item| item.mode), + contributed.cors.as_ref().map(|item| item.mode), + ) +} + +/// Reads one chain element's routes for one upstream and keeps the ones the +/// selector matches. +fn collect_routes( + store: &OagwStore, + tenant_id: Uuid, + upstream_id: Uuid, + depth: usize, + selector: &RouteSelector, + matched: &mut Vec<(usize, Uuid, Route)>, +) { + for row in store.routes_of_upstream(tenant_id, upstream_id) { + if matches_selector(&row.route, selector) { + matched.push((depth, tenant_id, row.route)); + } + } +} + +/// Whether one route matches the selector the resolution carries. +fn matches_selector(route: &Route, selector: &RouteSelector) -> bool { + match selector { + RouteSelector::Http { method, path } => route + .match_config + .http + .as_ref() + .is_some_and(|http: &HttpMatch| { + (http.methods.is_empty() || http.methods.iter().any(|declared| declared == method)) + && path_matches(&http.path, path) + }), + RouteSelector::Grpc { service, rpc } => route + .match_config + .grpc + .as_ref() + .is_some_and(|grpc: &GrpcMatch| &grpc.service == service && &grpc.method == rpc), + } +} + +/// Whether the configured match path addresses the request path. +/// +/// A configured path matches itself and every path it prefixes at a segment +/// boundary, which is how a gateway route table is read: `/v1` addresses +/// `/v1` and `/v1/chat`, and never `/v1chat`. +fn path_matches(configured: &str, request: &str) -> bool { + if configured == request { + return true; + } + request + .strip_prefix(configured) + .is_some_and(|tail| tail.starts_with('/')) +} + +/// The authentication family of one merged layer. +/// +/// The closest layer that carries a value decides the owner, and an `enforce` +/// ancestor decides it for every closer layer. The routing target's own +/// binding is the closest layer, so it wins over an `inherit` base without +/// consuming any permission: `private` blocks the visibility of the ancestor's +/// value, not the descendant's own configuration. +fn merge_auth(layers: &[MergeLayer<'_>]) -> Option { + let local = layers.last()?; + let mut base: Option<(Uuid, SharingMode, AuthConfig)> = None; + for layer in layers { + let Some(contribution) = &layer.contributed.auth else { + continue; + }; + match contribution.mode { + SharingMode::Enforce => { + return Some(EffectiveAuth { + owner: layer.tenant_id, + mode: SharingMode::Enforce, + auth: contribution.value.clone(), + }); + } + SharingMode::Inherit => { + if base.is_none() { + base = Some((layer.tenant_id, SharingMode::Inherit, contribution.value.clone())); + } + } + SharingMode::Private => {} + } + } + let Some(own) = local.own.auth else { + // The routing target carries no `auth` object of its own, so the + // inherited one is the effective one. + return base.map(|(owner, mode, auth)| EffectiveAuth { owner, mode, auth }); + }; + Some(EffectiveAuth { + owner: local.tenant_id, + mode: local.modes.mode_of(Family::Auth), + auth: own.clone(), + }) +} + +/// The rate-limit family of one merged layer. +/// +/// The minimum of the visible sustained rates applies, normalized per second +/// and reported in the winner's window, with the minimum of the visible burst +/// capacities under the same gate; the remaining members are carried unchanged +/// from the closest visible object, which is the routing target's own whenever +/// it carries one. +fn merge_rate_limit(layers: &[MergeLayer<'_>]) -> Option { + let local = layers.last()?; + let mut visible: Vec> = Vec::new(); + for layer in layers { + let Some(contribution) = &layer.contributed.rate_limit else { + continue; + }; + visible.push(VisibleLimit { + owner: layer.tenant_id, + mode: contribution.mode, + limit: &contribution.value, + }); + } + // An ancestor that marks the family `private` contributes nothing, so the + // routing target's own limit is then the only participant — and a routing + // target with no `rate_limit` at all resolves to no limit rather than to + // the ancestor's. + if let Some(own) = local.own.rate_limit { + visible.push(VisibleLimit { + owner: local.tenant_id, + mode: local.modes.mode_of(Family::RateLimit), + limit: own, + }); + } + merged_rate_limit(&visible) +} + +/// One visible rate limit in a merge, with the tenant that declares it and the +/// mode that made it visible. +struct VisibleLimit<'a> { + owner: Uuid, + mode: SharingMode, + limit: &'a RateLimitConfig, +} + +/// The minimum over the visible limits. +fn merged_rate_limit(visible: &[VisibleLimit<'_>]) -> Option { + let mut winner: Option<(Uuid, &Sustained)> = None; + for limit in visible { + let Some(sustained) = limit.limit.sustained.as_ref() else { + continue; + }; + let closer = match winner { + None => true, + Some((_, held)) => per_common_scale(sustained) < per_common_scale(held), + }; + if closer { + winner = Some((limit.owner, sustained)); + } + } + let Some((owner, sustained)) = winner else { + // No visible limit carries a sustained rate, so there is no limit to + // report. + return None; + }; + let capacity = visible + .iter() + .filter_map(|limit| { + limit + .limit + .burst + .as_ref() + .map(|burst: &Burst| burst.capacity) + }) + .min(); + // The members no strategy merges are carried unchanged from the closest + // visible object, which is the routing target's own whenever it carries a + // `rate_limit` at all. + let carrier = visible.last()?.limit; + Some(EffectiveRateLimit { + owner, + mode: strictest(visible.iter().map(|limit| limit.mode)), + rate_limit: RateLimitConfig { + sharing: carrier.sharing, + algorithm: carrier.algorithm, + sustained: Some(sustained.clone()), + burst: capacity.map(|capacity| Burst { capacity }), + scope: carrier.scope, + strategy: carrier.strategy, + cost: carrier.cost, + }, + }) +} + +/// The plugin family of one merged layer. +/// +/// The ancestors' items come first and the routing target's own last, so an +/// `enforce` ancestor's items are never removable by a replacement that omits +/// them. +fn merge_plugins(layers: &[MergeLayer<'_>]) -> Option { + let local = layers.last()?; + let mut items: Vec = Vec::new(); + let mut owners: Vec = Vec::new(); + let mut modes: Vec = Vec::new(); + for layer in layers { + let Some(contribution) = &layer.contributed.plugins else { + continue; + }; + items.extend(contribution.value.items.iter().cloned()); + owners.push(layer.tenant_id); + modes.push(contribution.mode); + } + let own_items: &[String] = local + .own + .plugins + .map(|plugins| plugins.items.as_slice()) + .unwrap_or(&[]); + items.extend(own_items.iter().cloned()); + if items.is_empty() && local.own.plugins.is_none() { + return None; + } + if !own_items.is_empty() { + modes.push(local.modes.mode_of(Family::Plugins)); + // The routing target's own items are in the chain too, so its tenant is + // a contributor of the result. + owners.push(local.tenant_id); + } + // The nearest items decide the owner: the routing target's own when it + // contributes items, otherwise the closest contributing ancestor. + let owner = if own_items.is_empty() { + owners.last().copied().unwrap_or(local.tenant_id) + } else { + local.tenant_id + }; + Some(EffectivePluginChain { + owner, + mode: strictest(modes), + items, + // The layers arrive most distant first, which is the order the + // contributors are recorded in. + contributors: owners, + }) +} + +/// The CORS family of one merged layer. +/// +/// `enforce` decides the whole object; `inherit` unions `allowed_origins` +/// only, and never the header rules; `private` leaves the routing target's own +/// object alone. +fn merge_cors(layers: &[MergeLayer<'_>]) -> Option { + let local = layers.last()?; + let mut origins: Vec = Vec::new(); + let mut closest: Option<(Uuid, CorsConfig)> = None; + let mut modes: Vec = Vec::new(); + for layer in layers { + let Some(contribution) = &layer.contributed.cors else { + continue; + }; + match contribution.mode { + SharingMode::Enforce => { + return Some(EffectiveCors { + owner: layer.tenant_id, + mode: SharingMode::Enforce, + cors: contribution.value.clone(), + }); + } + SharingMode::Inherit => { + for origin in &contribution.value.allowed_origins { + if !origins.contains(origin) { + origins.push(origin.clone()); + } + } + modes.push(SharingMode::Inherit); + closest = Some((layer.tenant_id, contribution.value.clone())); + } + SharingMode::Private => {} + } + } + let Some(own) = local.own.cors else { + // The routing target carries no CORS object of its own, so the + // inherited origins are the effective ones and the closest + // contributing ancestor owns them. + let (owner, inherited) = closest?; + return Some(EffectiveCors { + owner, + mode: strictest(modes), + cors: CorsConfig { + sharing: inherited.sharing, + enabled: inherited.enabled, + allowed_origins: origins, + allowed_methods: inherited.allowed_methods, + expose_headers: inherited.expose_headers, + allow_credentials: inherited.allow_credentials, + }, + }); + }; + // The routing target's own origins join the union in the same root-to-child + // order the other families apply, so the ancestors' origins come first. + let mut allowed = origins; + for origin in &own.allowed_origins { + if !allowed.contains(origin) { + allowed.push(origin.clone()); + } + } + modes.push(local.modes.mode_of(Family::Cors)); + Some(EffectiveCors { + owner: local.tenant_id, + mode: strictest(modes), + cors: CorsConfig { + sharing: own.sharing, + enabled: own.enabled, + allowed_origins: allowed, + allowed_methods: own.allowed_methods.clone(), + expose_headers: own.expose_headers.clone(), + allow_credentials: own.allow_credentials, + }, + }) +} + +/// The tag family of one merged layer: the add-only union, so a descendant +/// adds and can never remove an inherited tag. +fn merge_tags(layers: &[MergeLayer<'_>]) -> EffectiveTagSet { + let mut tags: Vec = Vec::new(); + let mut contributors: Vec = Vec::new(); + for layer in layers { + let mut added = false; + for tag in layer.own.tags { + if !tags.contains(tag) { + tags.push(tag.clone()); + added = true; + } + } + for tag in layer.contributed.tags.iter().flatten() { + if !tags.contains(tag) { + tags.push(tag.clone()); + added = true; + } + } + if added { + contributors.push(layer.tenant_id); + } + } + EffectiveTagSet { tags, contributors } +} + +/// The strictest sharing mode among the layers whose value reached a result. +/// +/// `enforce` is strictest, `inherit` is the base a permitted descendant may +/// replace, and `private` contributes nothing at all, so a result that carries +/// a value from an `enforce` layer reports `enforce` even when a closer layer +/// put its own value beside it. +pub(crate) fn strictest(modes: impl IntoIterator) -> SharingMode { + let mut strictest = SharingMode::Private; + for mode in modes { + strictest = match (strictest, mode) { + (SharingMode::Enforce, _) | (_, SharingMode::Enforce) => SharingMode::Enforce, + (SharingMode::Inherit, _) | (_, SharingMode::Inherit) => SharingMode::Inherit, + _ => SharingMode::Private, + }; + } + strictest +} + +/// Brings one sustained rate to the common scale the comparison needs. +/// +/// `min` is stated over values that carry a window of `second`, `minute`, +/// `hour`, or `day`, so `100/second` against `5000/minute` is not decidable +/// without a common unit. The scale is requests per day: multiplying every +/// rate up to the widest declared window compares the rates without the +/// division and the rounding a narrower common scale would need. +fn per_common_scale(sustained: &Sustained) -> u64 { + const SECONDS_PER_MINUTE: u64 = 60; + const SECONDS_PER_HOUR: u64 = 60 * SECONDS_PER_MINUTE; + const SECONDS_PER_DAY: u64 = 24 * SECONDS_PER_HOUR; + match sustained.window { + Some(Window::Minute) => sustained + .rate + .saturating_mul(SECONDS_PER_DAY / SECONDS_PER_MINUTE), + Some(Window::Hour) => sustained + .rate + .saturating_mul(SECONDS_PER_DAY / SECONDS_PER_HOUR), + Some(Window::Second) => sustained.rate.saturating_mul(SECONDS_PER_DAY), + Some(Window::Day) | None => sustained.rate, + } +} diff --git a/gears/system/oagw/oagw/src/control_plane/match_uniqueness.rs b/gears/system/oagw/oagw/src/control_plane/match_uniqueness.rs new file mode 100644 index 0000000..1776c78 --- /dev/null +++ b/gears/system/oagw/oagw/src/control_plane/match_uniqueness.rs @@ -0,0 +1,112 @@ +//! Route match uniqueness — `cpt-cf-oagw-algo-match-uniqueness`. +//! +//! One key per declared method, compared against the enabled routes of the +//! same upstream excluding the row being replaced. The comparison set is the +//! derived enabled-match index the store maintains on every write, which is +//! what makes the check a lookup rather than a scan. Two routes that differ in +//! any one of the three components never collide, and two disabled routes with +//! identical keys are stored without a conflict. +//! +//! A hit answers the catalogue's `MatchConflict` row — 409 — naming the +//! colliding route's identifier, which is system-generated and so carries no +//! request value. + +use uuid::Uuid; + +use crate::domain::error::{DomainError, ErrorKind}; +use crate::domain::route::Route; +use crate::store::{MatchKey, OagwStore}; + +/// Expands one route into one key per declared method. +/// +/// A route that declares three methods contributes three keys, each carrying +/// the path and the priority. +#[must_use] +pub fn match_keys(route: &Route) -> Vec { + // @cpt-begin:cpt-cf-oagw-algo-match-uniqueness:p1:inst-match-expand + let priority = route.priority.unwrap_or_default(); + let http = &route.match_config.http; + let grpc = &route.match_config.grpc; + let mut keys = Vec::new(); + if let Some(http) = http { + keys.extend(http.methods.iter().map(|method| MatchKey { + upstream_id: route.upstream_id, + path: http.path.clone(), + priority, + method: method.clone(), + })); + } + if let Some(grpc) = grpc { + keys.push(MatchKey { + upstream_id: route.upstream_id, + path: grpc.service.clone(), + priority, + method: grpc.method.clone(), + }); + } + keys + // @cpt-end:cpt-cf-oagw-algo-match-uniqueness:p1:inst-match-expand +} + +/// Confirms no other enabled route of the same upstream holds an incoming key. +/// +/// # Errors +/// +/// Returns the `MatchConflict` catalogue row — which answers 409 — naming the +/// colliding route's identifier on the first hit. +#[allow(clippy::result_large_err)] +pub fn confirm( + keys: &[MatchKey], + comparison: &std::collections::BTreeMap, + replaced: Option, +) -> Result<(), DomainError> { + // @cpt-begin:cpt-cf-oagw-algo-match-uniqueness:p1:inst-match-loop + for key in keys { + // @cpt-begin:cpt-cf-oagw-algo-match-uniqueness:p1:inst-match-collide-if + if let Some(holder) = comparison.get(key) + && Some(*holder) != replaced + { + // @cpt-begin:cpt-cf-oagw-algo-match-uniqueness:p1:inst-match-collide + return Err(conflict(*holder)); + // @cpt-end:cpt-cf-oagw-algo-match-uniqueness:p1:inst-match-collide + } + // @cpt-end:cpt-cf-oagw-algo-match-uniqueness:p1:inst-match-collide-if + } + // @cpt-end:cpt-cf-oagw-algo-match-uniqueness:p1:inst-match-loop + // @cpt-begin:cpt-cf-oagw-algo-match-uniqueness:p1:inst-match-return + Ok(()) + // @cpt-end:cpt-cf-oagw-algo-match-uniqueness:p1:inst-match-return +} + +/// Confirms the match rule of one route against the store's derived index. +/// +/// The comparison set is restricted to the enabled routes of the same upstream +/// and excludes the row being replaced; a disabled incoming route contributes +/// no key and is never in conflict. +/// +/// # Errors +/// +/// Returns the `MatchConflict` catalogue row on the first hit. +#[allow(clippy::result_large_err)] +pub fn confirm_route( + store: &OagwStore, + tenant_id: Uuid, + route: &Route, + replaced: Option, +) -> Result<(), DomainError> { + // @cpt-begin:cpt-cf-oagw-algo-match-uniqueness:p1:inst-match-set + if !route.enabled.unwrap_or(true) { + return Ok(()); + } + let comparison = store.enabled_match_index(tenant_id); + // @cpt-end:cpt-cf-oagw-algo-match-uniqueness:p1:inst-match-set + confirm(&match_keys(route), &comparison, replaced) +} + +/// The 409 the first colliding key answers with. +fn conflict(holder: Uuid) -> DomainError { + DomainError::gateway( + ErrorKind::MatchConflict, + format!("route {holder} already holds this match rule"), + ) +} diff --git a/gears/system/oagw/oagw/src/control_plane/mod.rs b/gears/system/oagw/oagw/src/control_plane/mod.rs new file mode 100644 index 0000000..4de2df0 --- /dev/null +++ b/gears/system/oagw/oagw/src/control_plane/mod.rs @@ -0,0 +1,43 @@ +//! Control-plane business logic of the management half. +//! +//! One module per CDSL routine, plus the service facade and the two seams the +//! data-plane features consume. No `axum`/`http` type appears here: the layer +//! takes and returns domain types, `serde_json::Value`, and `Uuid`. +//! +//! ## Layering +//! +//! - [`validation`] — `cpt-cf-oagw-algo-request-validate` +//! - [`alias_derive`] — `cpt-cf-oagw-algo-alias-derive` +//! - [`bind`] — `cpt-cf-oagw-algo-bind-create-tags` +//! - [`binding`] — `cpt-cf-oagw-algo-plugin-ref-resolve` and +//! `cpt-cf-oagw-algo-binding-validate` +//! - [`scoping`] — `cpt-cf-oagw-algo-tenant-scope` +//! - [`odata`] — `cpt-cf-oagw-algo-odata-list` +//! - [`plugin_def`] — the plugin create body's validation and the wire phase +//! vocabulary the three contracts declare +//! - [`replace`] — `cpt-cf-oagw-algo-put-replace-diff` +//! - [`match_uniqueness`] — `cpt-cf-oagw-algo-match-uniqueness` +//! - [`chain`] — `cpt-cf-oagw-algo-tenant-chain-walk` +//! - [`shadow`] — `cpt-cf-oagw-algo-alias-shadow-resolve` +//! - [`effective`] — `cpt-cf-oagw-algo-field-family-merge` and the +//! `cpt-cf-oagw-flow-resolve-effective-config` entry point +//! - [`sharing`] — `cpt-cf-oagw-algo-sharing-mode-decision` +//! - [`cache`] — the Control Plane L1 configuration cache and the rate-limit +//! deletion notification seam +//! - [`service`] — `ManagementService`, the only caller of the store + +pub mod alias_derive; +pub mod bind; +pub mod binding; +pub mod cache; +pub mod chain; +pub mod effective; +pub mod match_uniqueness; +pub mod odata; +pub mod plugin_def; +pub mod replace; +pub mod scoping; +pub mod service; +pub mod shadow; +pub mod sharing; +pub mod validation; diff --git a/gears/system/oagw/oagw/src/control_plane/odata.rs b/gears/system/oagw/oagw/src/control_plane/odata.rs new file mode 100644 index 0000000..62e419e --- /dev/null +++ b/gears/system/oagw/oagw/src/control_plane/odata.rs @@ -0,0 +1,791 @@ +//! OData list parameter parsing and bounding — `cpt-cf-oagw-algo-odata-list`. +//! +//! The five parameters are parsed from the raw query string, bounded, and +//! validated against the surface the resource kind exposes; a malformed or +//! unexposed expression is never interpreted as an absent one. The module is +//! pure: it takes the parsed parameters and the rows one tenant scan read, and +//! returns the page and the projection it was built with. No `axum`/`http` +//! type appears here. +//! +//! Every detail names the offending parameter and never echoes a value. + +use std::cmp::Ordering; + +use uuid::Uuid; + +use crate::control_plane::validation::ResourceKind; +use crate::domain::error::{DomainError, ErrorKind}; +use crate::gts; +use crate::store::{PluginRow, RouteRow, UpstreamRow}; + +/// `$top` the table declares as the default. +pub const DEFAULT_TOP: u64 = 50; +/// `$top` the table declares as the hard ceiling. +pub const MAX_TOP: u64 = 100; + +/// The five parameter names the list surface declares. +const FILTER_KEY: &str = "$filter"; +const SELECT_KEY: &str = "$select"; +const ORDERBY_KEY: &str = "$orderby"; +const TOP_KEY: &str = "$top"; +const SKIP_KEY: &str = "$skip"; + +/// Upstream fields a `$filter` may name; the last row is the tag field, which +/// matches a parent holding the tag in its tag table. +const UPSTREAM_FILTER: [&str; 4] = ["id", "alias", "enabled", "tag"]; +/// Upstream fields an `$orderby` may name; every one is single-valued per +/// parent row. +const UPSTREAM_ORDER: [&str; 3] = ["id", "alias", "enabled"]; +/// Upstream properties a `$select` may name. +const UPSTREAM_SELECT: [&str; 11] = [ + "id", + "alias", + "protocol", + "enabled", + "server", + "auth", + "headers", + "rate_limit", + "cors", + "plugins", + "tags", +]; +/// Route fields a `$filter` may name. +const ROUTE_FILTER: [&str; 7] = [ + "id", + "upstream_id", + "path", + "method", + "priority", + "enabled", + "tag", +]; +/// Route fields an `$orderby` may name. +const ROUTE_ORDER: [&str; 4] = ["id", "upstream_id", "priority", "enabled"]; +/// Route properties a `$select` may name. +const ROUTE_SELECT: [&str; 9] = [ + "id", + "upstream_id", + "priority", + "enabled", + "match", + "rate_limit", + "cors", + "plugins", + "tags", +]; +/// Plugin fields a `$filter` may name; `type` names the family literal. +const PLUGIN_FILTER: [&str; 4] = ["id", "type", "plugin_type", "name"]; +/// Plugin fields an `$orderby` may name: DESIGN's plugin table declares none, +/// so the surface admits no ordering at all. +const PLUGIN_ORDER: [&str; 0] = []; +/// Plugin properties a `$select` may name. +const PLUGIN_SELECT: [&str; 9] = [ + "id", + "plugin_type", + "name", + "description", + "config_schema", + "phases", + "source_code", + "last_used_at", + "gc_eligible_at", +]; + +/// The catalogue one list call reads, as the three tables expose different +/// parameter surfaces. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ListKind { + /// The upstream catalogue. + Upstream, + /// The route catalogue. + Route, + /// The custom plugin catalogue. + Plugin, +} + +impl From for ListKind { + fn from(kind: ResourceKind) -> Self { + match kind { + ResourceKind::Upstream => Self::Upstream, + ResourceKind::Route => Self::Route, + } + } +} + +/// The five parsed list parameters, with their defaults applied. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListQuery { + /// Bounded page size: the parameter's value, capped at the ceiling. + pub top: u64, + /// Non-negative offset into the tenant-scoped result set. + pub skip: u64, + /// The parsed filter, or `None` when the parameter was absent. + pub filter: Option, + /// The parsed ordering, or `None` when the parameter was absent. + pub orderby: Option, + /// The projected properties; empty means the full representation. + pub select: Vec, +} + +/// One parsed `$filter`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Filter { + /// The comparisons, joined by `and`. + pub terms: Vec, +} + +/// One `field eq 'value'` comparison. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Term { + /// The field the comparison names. + pub field: Field, + /// The value the comparison compares against, unquoted. + pub value: String, +} + +/// The field a filter comparison or an ordering names. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Field { + /// The parent row's identifier. + Id, + /// The upstream's normalized alias. + Alias, + /// Whether the row is enabled. + Enabled, + /// The route's owning upstream. + UpstreamId, + /// The route's match path. + Path, + /// The route's declared method. + Method, + /// The route's match-uniqueness ordering. + Priority, + /// A tag the parent holds. + Tag, + /// The plugin's family literal. + PluginType, + /// The plugin's human-readable name. + Name, +} + +/// One parsed `$orderby`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OrderBy { + /// The field the ordering names. + pub field: Field, + /// Whether the ordering is descending. + pub descending: bool, +} + +/// One assembled page. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Page { + /// The page's parent rows, in the order the parameters produced. + pub items: Vec, + /// The projection the page was built with; empty means the full + /// representation. + pub projection: Vec, + /// The bounded page size the parameters produced, ceiling included. + pub top: u64, +} + +/// The raw parameter values the query string carried. +#[derive(Debug, Default)] +struct Raw { + filter: Option, + select: Option, + orderby: Option, + top: Option, + skip: Option, +} + +/// The accumulated parameter defects of one query string. +#[derive(Debug, Default)] +struct Defects(Vec); + +impl Defects { + /// Rejects a parameter the closed list surface does not declare. + fn unknown(&mut self, parameter: &str) { + self.0 + .push(format!("unknown list parameter '{parameter}'")); + } + + /// Rejects a paging parameter whose value is not a non-negative integer. + fn paging(&mut self, parameter: &str) { + self.0 + .push(format!("{parameter} is not a non-negative integer")); + } + + /// Rejects a filter that cannot be parsed. + fn filter(&mut self) { + self.0 + .push(String::from("$filter is not a well-formed filter expression")); + } + + /// Rejects a filter that names a field the kind does not expose. + fn filter_field(&mut self) { + self.0 + .push(String::from("$filter names a field the resource kind does not expose")); + } + + /// Rejects an ordering that cannot be parsed. + fn orderby(&mut self) { + self.0 + .push(String::from("$orderby is not a well-formed ordering expression")); + } + + /// Rejects an ordering that names an unorderable field. + fn orderby_field(&mut self) { + self.0 + .push(String::from("$orderby names a field the resource kind does not order by")); + } + + /// Rejects a projection that names an unexposed property. + fn select(&mut self) { + self.0 + .push(String::from("$select names a property the resource kind does not expose")); + } + + /// Whether every parameter was admitted. + fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// The single validation error naming every offending parameter. + fn into_error(self) -> DomainError { + DomainError::gateway(ErrorKind::ValidationError, self.0.join(", ")) + } +} + +/// Parses and bounds the five list parameters of one query string. +/// +/// # Errors +/// +/// Returns one gateway validation error naming every offending parameter: an +/// unknown parameter name, a `$top` or `$skip` that is not a non-negative +/// integer, a `$filter` or `$orderby` that cannot be parsed or names a field +/// the resource kind does not expose, and a `$select` naming a property +/// outside the selectable set. +#[allow(clippy::result_large_err)] +pub fn parse(kind: ListKind, query: &str) -> Result { + let mut raw = Raw::default(); + let mut defects = Defects::default(); + + // @cpt-begin:cpt-cf-oagw-algo-odata-list:p1:inst-odata-parse + for (key, value) in form_urlencoded::parse(query.as_bytes()) { + let (key, value) = (key.into_owned(), value.into_owned()); + match key.as_str() { + FILTER_KEY => raw.filter = Some(value), + SELECT_KEY => raw.select = Some(value), + ORDERBY_KEY => raw.orderby = Some(value), + TOP_KEY => raw.top = Some(value), + SKIP_KEY => raw.skip = Some(value), + _ => defects.unknown(&key), + } + } + // @cpt-end:cpt-cf-oagw-algo-odata-list:p1:inst-odata-parse + + // @cpt-begin:cpt-cf-oagw-algo-odata-list:p1:inst-odata-paging + let top = paging(TOP_KEY, raw.top.as_deref(), &mut defects); + let skip = paging(SKIP_KEY, raw.skip.as_deref(), &mut defects); + // @cpt-end:cpt-cf-oagw-algo-odata-list:p1:inst-odata-paging + + // @cpt-begin:cpt-cf-oagw-algo-odata-list:p1:inst-odata-top-if + let top = top.map(|top| { + // @cpt-begin:cpt-cf-oagw-algo-odata-list:p1:inst-odata-top-cap + top.min(MAX_TOP) + // @cpt-end:cpt-cf-oagw-algo-odata-list:p1:inst-odata-top-cap + }); + // @cpt-end:cpt-cf-oagw-algo-odata-list:p1:inst-odata-top-if + + // @cpt-begin:cpt-cf-oagw-algo-odata-list:p1:inst-odata-expressions + let filter = match raw.filter { + None => None, + Some(text) => match parse_filter(kind, &text) { + Ok(filter) => Some(filter), + Err(malformed) => { + if malformed { + defects.filter(); + } else { + defects.filter_field(); + } + None + } + }, + }; + let orderby = match raw.orderby { + None => None, + Some(text) => match parse_orderby(kind, &text) { + Ok(orderby) => Some(orderby), + Err(malformed) => { + if malformed { + defects.orderby(); + } else { + defects.orderby_field(); + } + None + } + }, + }; + let mut select = Vec::new(); + if let Some(text) = raw.select { + match parse_select(kind, &text) { + Ok(projected) => select = projected, + Err(()) => defects.select(), + } + } + // @cpt-end:cpt-cf-oagw-algo-odata-list:p1:inst-odata-expressions + + // @cpt-begin:cpt-cf-oagw-algo-odata-list:p1:inst-odata-fail-if + if !defects.is_empty() { + // @cpt-begin:cpt-cf-oagw-algo-odata-list:p1:inst-odata-fail-return + return Err(defects.into_error()); + // @cpt-end:cpt-cf-oagw-algo-odata-list:p1:inst-odata-fail-return + } + // @cpt-end:cpt-cf-oagw-algo-odata-list:p1:inst-odata-fail-if + + Ok(ListQuery { + top: top.unwrap_or(DEFAULT_TOP), + skip: skip.unwrap_or(0), + filter, + orderby, + select, + }) +} + +/// Applies the parsed parameters to one tenant scan and assembles the page. +/// +/// The scan is the one query set the page costs: it carries every parent row +/// of the calling tenant with its dependent rows, so the page is built without +/// one query per parent. The tenant equality was applied by the scan before +/// any parameter of this module ran. +#[must_use] +pub fn apply_upstream(query: &ListQuery, scan: Vec) -> Page { + // @cpt-begin:cpt-cf-oagw-algo-odata-list:p1:inst-odata-apply + let filtered = match &query.filter { + None => scan, + Some(filter) => scan + .into_iter() + .filter(|row| filter.terms.iter().all(|term| upstream_matches(row, term))) + .collect(), + }; + let ordered = order(filtered, query.orderby.as_ref(), upstream_order); + let bounded: Vec = bounded(ordered, query.skip, query.top) + .into_iter() + .collect(); + // @cpt-end:cpt-cf-oagw-algo-odata-list:p1:inst-odata-apply + + // @cpt-begin:cpt-cf-oagw-algo-odata-list:p1:inst-odata-assemble + // Every parent row of the page already carries its tag rows: the scan + // materialized them in the same pass, so the page costs no further query. + // @cpt-end:cpt-cf-oagw-algo-odata-list:p1:inst-odata-assemble + + // @cpt-begin:cpt-cf-oagw-algo-odata-list:p1:inst-odata-return + Page { + items: bounded, + projection: query.select.clone(), + top: query.top, + } + // @cpt-end:cpt-cf-oagw-algo-odata-list:p1:inst-odata-return +} + +/// Applies the parsed parameters to one tenant route scan and assembles the +/// page. +/// +/// The scan is the one query set the page costs: it carries every route row of +/// the calling tenant with its match, method, and tag rows. +#[must_use] +pub fn apply_route(query: &ListQuery, scan: Vec) -> Page { + // @cpt-begin:cpt-cf-oagw-algo-odata-list:p1:inst-odata-apply + let filtered = match &query.filter { + None => scan, + Some(filter) => scan + .into_iter() + .filter(|row| filter.terms.iter().all(|term| route_matches(row, term))) + .collect(), + }; + let ordered = order(filtered, query.orderby.as_ref(), route_order); + let bounded: Vec = bounded(ordered, query.skip, query.top).into_iter().collect(); + // @cpt-end:cpt-cf-oagw-algo-odata-list:p1:inst-odata-apply + + // @cpt-begin:cpt-cf-oagw-algo-odata-list:p1:inst-odata-assemble + // Every route row of the page already carries its match, method, and tag + // rows: the scan materialized them in the same pass, so the page costs no + // further query. + // @cpt-end:cpt-cf-oagw-algo-odata-list:p1:inst-odata-assemble + + // @cpt-begin:cpt-cf-oagw-algo-odata-list:p1:inst-odata-return + Page { + items: bounded, + projection: query.select.clone(), + top: query.top, + } + // @cpt-end:cpt-cf-oagw-algo-odata-list:p1:inst-odata-return +} + +/// Applies the parsed parameters to one tenant plugin scan and assembles the +/// page. +/// +/// The scan is the one query set the page costs: it carries every plugin row +/// of the calling tenant. DESIGN's plugin table declares no `$orderby`, so the +/// parsed surface answers none and the rows keep their catalogue order. +#[must_use] +pub fn apply_plugin(query: &ListQuery, scan: Vec) -> Page { + // @cpt-begin:cpt-cf-oagw-algo-odata-list:p1:inst-odata-apply + let filtered = match &query.filter { + None => scan, + Some(filter) => scan + .into_iter() + .filter(|row| filter.terms.iter().all(|term| plugin_matches(row, term))) + .collect(), + }; + let ordered = order(filtered, query.orderby.as_ref(), plugin_order); + let bounded: Vec = bounded(ordered, query.skip, query.top) + .into_iter() + .collect(); + // @cpt-end:cpt-cf-oagw-algo-odata-list:p1:inst-odata-apply + + // @cpt-begin:cpt-cf-oagw-algo-odata-list:p1:inst-odata-assemble + // A plugin row is a single catalogue row with no dependents, so the page + // costs no further query. + // @cpt-end:cpt-cf-oagw-algo-odata-list:p1:inst-odata-assemble + + // @cpt-begin:cpt-cf-oagw-algo-odata-list:p1:inst-odata-return + Page { + items: bounded, + projection: query.select.clone(), + top: query.top, + } + // @cpt-end:cpt-cf-oagw-algo-odata-list:p1:inst-odata-return +} + +/// The plugin ordering comparison of one field: no field is orderable, so the +/// comparison answers equal and the catalogue order stands. +fn plugin_order(_left: &PluginRow, _right: &PluginRow, _field: Field) -> Ordering { + Ordering::Equal +} + +/// Parses one paging value, or records the defect and answers `None`. +fn paging(key: &str, value: Option<&str>, defects: &mut Defects) -> Option { + let value = value?; + match value.parse::() { + Ok(parsed) => Some(parsed), + Err(_) => { + defects.paging(key); + None + } + } +} + +/// Parses one `$filter` into its comparisons. +/// +/// Answers `Err(true)` for a malformed expression and `Err(false)` for a +/// well-formed one naming a field the kind does not expose. +fn parse_filter(kind: ListKind, text: &str) -> Result { + let mut terms = Vec::new(); + let mut at = 0; + loop { + let field_start = skip_spaces(text, at); + let field_end = identifier_end(text, field_start); + if field_end == field_start { + return Err(true); + } + let field = &text[field_start..field_end]; + + let keyword_start = skip_spaces(text, field_end); + if !text[keyword_start..].starts_with("eq") { + return Err(true); + } + let quote_start = skip_spaces(text, keyword_start + "eq".len()); + let Some(opening) = text[quote_start..].find('\'') else { + return Err(true); + }; + if opening != 0 { + return Err(true); + } + let value_start = quote_start + 1; + let Some(offset) = text[value_start..].find('\'') else { + return Err(true); + }; + let closing = value_start + offset; + let value = String::from(&text[value_start..closing]); + + let admitted = filter_field(kind, field).ok_or(false)?; + terms.push(Term { + field: admitted, + value, + }); + + let tail = skip_spaces(text, closing + 1); + if tail >= text.len() { + break; + } + if !text[tail..].starts_with("and") { + return Err(true); + } + at = tail + "and".len(); + if skip_spaces(text, at) == at { + return Err(true); + } + } + Ok(Filter { terms }) +} + +/// Resolves one filter field name against the surface the kind exposes. +fn filter_field(kind: ListKind, field: &str) -> Option { + let exposed: &[&str] = match kind { + ListKind::Upstream => &UPSTREAM_FILTER, + ListKind::Route => &ROUTE_FILTER, + ListKind::Plugin => &PLUGIN_FILTER, + }; + if !exposed.contains(&field) { + return None; + } + Some(match field { + "id" => Field::Id, + "alias" => Field::Alias, + "enabled" => Field::Enabled, + "upstream_id" => Field::UpstreamId, + "path" => Field::Path, + "method" => Field::Method, + "priority" => Field::Priority, + "plugin_type" | "type" => Field::PluginType, + "name" => Field::Name, + _ => Field::Tag, + }) +} + +/// Parses one `$orderby` into its field and direction. +/// +/// Answers `Err(true)` for a malformed expression and `Err(false)` for a +/// well-formed one naming a field the kind does not order by. +fn parse_orderby(kind: ListKind, text: &str) -> Result { + let trimmed = text.trim(); + let Some((field, direction)) = trimmed.split_once(' ') else { + return order_field(kind, trimmed) + .map(|field| OrderBy { + field, + descending: false, + }) + .ok_or(false); + }; + let admitted = order_field(kind, field).ok_or(false)?; + match direction.trim() { + "asc" => Ok(OrderBy { + field: admitted, + descending: false, + }), + "desc" => Ok(OrderBy { + field: admitted, + descending: true, + }), + _ => Err(true), + } +} + +/// Resolves one orderable field name against the surface the kind exposes. +fn order_field(kind: ListKind, field: &str) -> Option { + let orderable: &[&str] = match kind { + ListKind::Upstream => &UPSTREAM_ORDER, + ListKind::Route => &ROUTE_ORDER, + ListKind::Plugin => &PLUGIN_ORDER, + }; + if !orderable.contains(&field) { + return None; + } + Some(match field { + "id" => Field::Id, + "alias" => Field::Alias, + "enabled" => Field::Enabled, + "upstream_id" => Field::UpstreamId, + _ => Field::Priority, + }) +} + +/// Parses one `$select` into its projected properties. +fn parse_select(kind: ListKind, text: &str) -> Result, ()> { + let selectable: &[&str] = match kind { + ListKind::Upstream => &UPSTREAM_SELECT, + ListKind::Route => &ROUTE_SELECT, + ListKind::Plugin => &PLUGIN_SELECT, + }; + let mut projected = Vec::new(); + for name in text.split(',') { + let name = name.trim(); + if !selectable.contains(&name) { + return Err(()); + } + if !projected.iter().any(|held| held == name) { + projected.push(String::from(name)); + } + } + Ok(projected) +} + +/// Whether one upstream row satisfies one comparison. +fn upstream_matches(row: &UpstreamRow, term: &Term) -> bool { + match term.field { + Field::Id => uuid_value(&term.value) == Some(row.upstream.id), + // The alias compares case-insensitively, as normalization stored it. + Field::Alias => row + .upstream + .alias + .as_deref() + .is_some_and(|alias| alias.eq_ignore_ascii_case(&term.value)), + Field::Enabled => bool_value(&term.value).is_some_and(|value| value == row.upstream.enabled), + Field::Tag => row.tags.contains(&term.value), + Field::Path | Field::Method | Field::Priority | Field::UpstreamId => false, + Field::PluginType | Field::Name => false, + } +} + +/// Whether one route row satisfies one comparison. +fn route_matches(row: &RouteRow, term: &Term) -> bool { + match term.field { + Field::Id => uuid_value(&term.value) == Some(row.route.id), + Field::UpstreamId => uuid_value(&term.value) == Some(row.route.upstream_id), + Field::Enabled => { + bool_value(&term.value).is_some_and(|value| value == route_enabled(row)) + } + Field::Priority => { + term.value == row.route.priority.unwrap_or_default().to_string() + } + Field::Path => route_paths(row).contains(&term.value.as_str()), + Field::Method => route_methods(row) + .iter() + .any(|method| method.eq_ignore_ascii_case(&term.value)), + Field::Tag => row.tags.contains(&term.value), + // A route declares no alias, so the field is not exposed to a filter. + Field::Alias => false, + Field::PluginType | Field::Name => false, + } +} + +/// Whether one plugin row satisfies one comparison. +fn plugin_matches(row: &PluginRow, term: &Term) -> bool { + match term.field { + Field::Id => uuid_value(&term.value) == Some(row.plugin.id), + Field::PluginType => row.plugin.plugin_type == term.value, + Field::Name => row.plugin.name == term.value, + Field::Alias + | Field::Enabled + | Field::UpstreamId + | Field::Path + | Field::Method + | Field::Priority + | Field::Tag => false, + } +} + +/// The route's resolved enabled value, as the stored row always carries one. +fn route_enabled(row: &RouteRow) -> bool { + row.route.enabled.unwrap_or(true) +} + +/// The paths the route's match rows declare. +fn route_paths(row: &RouteRow) -> Vec<&str> { + match &row.route.match_config.http { + Some(http) => vec![http.path.as_str()], + None => Vec::new(), + } +} + +/// The methods the route's match and method rows declare. +fn route_methods(row: &RouteRow) -> Vec { + match &row.route.match_config.http { + Some(http) => http.methods.clone(), + None => match &row.route.match_config.grpc { + Some(grpc) => vec![grpc.method.clone()], + None => Vec::new(), + }, + } +} + +/// Orders the rows, then offsets and bounds the page. +fn order( + rows: Vec, + orderby: Option<&OrderBy>, + compare: impl Fn(&T, &T, Field) -> Ordering, +) -> Vec { + let Some(orderby) = orderby else { + return rows; + }; + let field = orderby.field; + let mut rows = rows; + if orderby.descending { + rows.sort_by(|left, right| compare(right, left, field)); + } else { + rows.sort_by(|left, right| compare(left, right, field)); + } + rows +} + +/// Applies the offset and the bound to an ordered result set. +fn bounded(rows: Vec, skip: u64, top: u64) -> Vec { + let offset = usize::try_from(skip).unwrap_or(0); + let bound = usize::try_from(top).unwrap_or(0); + rows.into_iter().skip(offset).take(bound).collect() +} + +/// The upstream ordering comparison of one field. +fn upstream_order(left: &UpstreamRow, right: &UpstreamRow, field: Field) -> Ordering { + match field { + Field::Id => left.upstream.id.cmp(&right.upstream.id), + Field::Alias => left.upstream.alias.cmp(&right.upstream.alias), + Field::Enabled => left.upstream.enabled.cmp(&right.upstream.enabled), + Field::Path | Field::Method | Field::Priority | Field::UpstreamId | Field::Tag => { + Ordering::Equal + } + Field::PluginType | Field::Name => Ordering::Equal, + } +} + +/// The route ordering comparison of one field. +fn route_order(left: &RouteRow, right: &RouteRow, field: Field) -> Ordering { + match field { + Field::Id => left.route.id.cmp(&right.route.id), + Field::UpstreamId => left.route.upstream_id.cmp(&right.route.upstream_id), + Field::Priority => left.route.priority.cmp(&right.route.priority), + Field::Enabled => left.route.enabled.cmp(&right.route.enabled), + Field::Alias | Field::Path | Field::Method | Field::Tag => Ordering::Equal, + Field::PluginType | Field::Name => Ordering::Equal, + } +} + +/// The `Uuid` a comparison value names, accepting the anonymous GTS instance +/// identifier of either resource kind or a bare `Uuid`. +fn uuid_value(value: &str) -> Option { + for prefix in [gts::UPSTREAM_TYPE, gts::ROUTE_TYPE] { + if let Some(id) = gts::parse_gts_instance(prefix, value) { + return Some(id); + } + } + Uuid::parse_str(value).ok() +} + +/// The `bool` a comparison value names. +fn bool_value(value: &str) -> Option { + match value { + "true" => Some(true), + "false" => Some(false), + _ => None, + } +} + +/// The first character boundary at or after `at` that is not a space. +fn skip_spaces(text: &str, at: usize) -> usize { + text[at..] + .bytes() + .take_while(|byte| *byte == b' ' || *byte == b'\t') + .count() + + at +} + +/// The end of the identifier that starts at `at`. +fn identifier_end(text: &str, at: usize) -> usize { + at + text[at..] + .bytes() + .take_while(|byte| byte.is_ascii_alphanumeric() || *byte == b'_' || *byte == b'.') + .count() +} diff --git a/gears/system/oagw/oagw/src/control_plane/plugin_def.rs b/gears/system/oagw/oagw/src/control_plane/plugin_def.rs new file mode 100644 index 0000000..fd9925a --- /dev/null +++ b/gears/system/oagw/oagw/src/control_plane/plugin_def.rs @@ -0,0 +1,295 @@ +//! Plugin definition validation and the wire phase vocabulary. +//! +//! The plugin create body carries no shipped schema of its own: no +//! `plugin.v1.schema.json` is frozen beside the upstream and route schemas, so +//! the five properties the create flow accepts are checked here in code. The +//! check is a closed one — the root admits exactly the members the flow names, +//! `plugin_type` names one of the three plugin families, the declared phases +//! are a subset of the phases that family supports, the configuration schema +//! is an object, the description is a string, and the source is present and +//! non-empty. The Starlark source is never parsed, compiled, or executed here: +//! create-time validation covers the declared fields only (§1.5). +//! +//! Every failing property is accumulated into **one** +//! `DomainError::gateway(ErrorKind::ValidationError, ..)` whose detail names +//! the failing properties, comma-separated, and never carries a request body +//! value — the same shape the upstream and route validators produce. + +use serde_json::Value; +use uuid::Uuid; + +use crate::domain::error::{DomainError, ErrorKind}; +use crate::domain::plugin_contract::{PluginFamily, PluginPhase}; +use crate::gts; + +/// The root members a plugin create body may carry. +const PLUGIN_ROOT: [&str; 6] = [ + "plugin_type", + "name", + "description", + "config_schema", + "phases", + "source_code", +]; + +/// The wire literal of one phase a plugin may declare. +/// +/// The three on-phase literals DESIGN §3.1 names for the transform family — +/// `on_request`, `on_response`, `on_error` — are the vocabulary every family +/// declares in: the auth family's single credential-injection phase and the +/// guard family's two evaluation phases are request- and response-phase work +/// in the same sense, and the error phase is the transform contract's alone. +const PHASE_ON_REQUEST: &str = "on_request"; +const PHASE_ON_RESPONSE: &str = "on_response"; +const PHASE_ON_ERROR: &str = "on_error"; + +/// The wire literals one family admits, in contract order. +#[must_use] +pub const fn wire_phases(family: PluginFamily) -> &'static [&'static str] { + match family { + PluginFamily::Auth => &[PHASE_ON_REQUEST], + PluginFamily::Guard => &[PHASE_ON_REQUEST, PHASE_ON_RESPONSE], + PluginFamily::Transform => &[PHASE_ON_REQUEST, PHASE_ON_RESPONSE, PHASE_ON_ERROR], + } +} + +/// The plugin phase one wire literal names within one family. +#[must_use] +pub fn phase_of(family: PluginFamily, literal: &str) -> Option { + match (family, literal) { + (PluginFamily::Auth, PHASE_ON_REQUEST) => Some(PluginPhase::Auth), + (PluginFamily::Guard, PHASE_ON_REQUEST) => Some(PluginPhase::GuardRequest), + (PluginFamily::Guard, PHASE_ON_RESPONSE) => Some(PluginPhase::GuardResponse), + (PluginFamily::Transform, PHASE_ON_REQUEST) => Some(PluginPhase::TransformRequest), + (PluginFamily::Transform, PHASE_ON_RESPONSE) => Some(PluginPhase::TransformResponse), + (PluginFamily::Transform, PHASE_ON_ERROR) => Some(PluginPhase::TransformError), + _ => None, + } +} + +/// The validated body of a plugin create. +#[derive(Debug, Clone, PartialEq)] +pub struct ValidatedPlugin { + /// The family the body's `plugin_type` named. + pub family: PluginFamily, + /// The plugin name, as submitted. + pub name: String, + /// The description, when the body carried one. + pub description: Option, + /// The configuration schema, when the body carried one. + pub config_schema: Option, + /// The declared phases, as the wire literals the body carried. + pub phases: Vec, + /// The Starlark source, verbatim. + pub source_code: String, +} + +/// Accumulated failing properties of one plugin create body. +#[derive(Debug, Default)] +struct Defects(Vec); + +impl Defects { + /// Adds one failing property by name, dropping a repeat. + fn add(&mut self, property: &str) { + if !self.0.contains(&property.to_owned()) { + self.0.push(property.to_owned()); + } + } + + /// Whether every property passed. + fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// The single validation error naming every failing property. + fn into_error(self) -> DomainError { + let detail = if self.0.is_empty() { + String::from("the request body is not valid for the resource kind") + } else { + self.0.join(", ") + }; + DomainError::gateway(ErrorKind::ValidationError, detail) + } +} + +/// Validates one plugin create body against the families and the phase +/// vocabulary the contracts declare. +/// +/// The family is what the caller needs before the permission arm is selected, +/// so it is returned with the validated definition. +/// +/// # Errors +/// +/// Returns one gateway validation error naming every failing property. +#[allow(clippy::result_large_err)] +pub fn validate_plugin(body: &Value) -> Result { + let mut defects = Defects::default(); + + // @cpt-begin:cpt-cf-oagw-flow-plugin-create:p1:inst-pl-create-type + // The requested plugin_type names one of the three plugin base types, and + // the declared phases are a subset of the phases that type supports: both + // checks read the contracts the registries were built from, so a body that + // names no family or a phase the family does not expose is refused before + // any row is written. + let fields = match body.as_object() { + Some(fields) => fields, + None => { + defects.add("plugin_type"); + defects.add("name"); + defects.add("source_code"); + return Err(defects.into_error()); + } + }; + for key in fields.keys() { + if !PLUGIN_ROOT.contains(&key.as_str()) { + defects.add(&format!("unknown property '{key}' at root")); + } + } + + let family = fields + .get("plugin_type") + .and_then(Value::as_str) + .and_then(PluginFamily::from_type_literal); + if family.is_none() { + // A submitted value the catalogue names but no family backs is told + // apart from one the catalogue does not know at all, so an operator + // can tell a reserved-but-unimplemented type from a typo. Neither + // detail carries the submitted value. + match fields.get("plugin_type").and_then(Value::as_str) { + Some(value) if gts::plugin_catalog::is_known_identifier(value) => { + defects.add("plugin_type names a catalogue identifier no plugin family backs"); + } + _ => defects.add("plugin_type"), + } + } + // @cpt-end:cpt-cf-oagw-flow-plugin-create:p1:inst-pl-create-type + + let name = fields.get("name").and_then(Value::as_str); + if name.is_none_or(str::is_empty) { + defects.add("name"); + } + if let Some(description) = fields.get("description") + && description.as_str().is_none() + { + defects.add("description"); + } + let config_schema = fields.get("config_schema"); + if let Some(schema) = config_schema + && schema.as_object().is_none() + { + defects.add("config_schema"); + } + let phases = declared_phases(fields, family, &mut defects); + let source = fields.get("source_code").and_then(Value::as_str); + if source.is_none_or(str::is_empty) { + defects.add("source_code"); + } + + // @cpt-begin:cpt-cf-oagw-flow-plugin-create:p1:inst-pl-create-validate-if + if !defects.is_empty() { + // @cpt-begin:cpt-cf-oagw-flow-plugin-create:p1:inst-pl-create-validate-return + // RETURN 400 naming every failing property; no row is written, and the + // source is never parsed or executed at create time. + return Err(defects.into_error()); + // @cpt-end:cpt-cf-oagw-flow-plugin-create:p1:inst-pl-create-validate-return + } + // @cpt-end:cpt-cf-oagw-flow-plugin-create:p1:inst-pl-create-validate-if + + // @cpt-begin:cpt-cf-oagw-flow-plugin-create:p1:inst-pl-create-validate-else + // @cpt-begin:cpt-cf-oagw-flow-plugin-create:p1:inst-pl-create-validate-continue + // Continue with the validated definition and the verbatim source. A field + // the checks above recorded a defect for has already answered, so each + // binding below holds the value the body carried. + let Some(family) = family else { + return Err(defects.into_error()); + }; + let Some(name) = name else { + return Err(defects.into_error()); + }; + let Some(source) = source else { + return Err(defects.into_error()); + }; + Ok(ValidatedPlugin { + family, + name: name.to_owned(), + description: fields + .get("description") + .and_then(Value::as_str) + .map(str::to_owned), + config_schema: config_schema.cloned(), + phases, + source_code: source.to_owned(), + }) + // @cpt-end:cpt-cf-oagw-flow-plugin-create:p1:inst-pl-create-validate-continue + // @cpt-end:cpt-cf-oagw-flow-plugin-create:p1:inst-pl-create-validate-else +} + +/// The declared phases one body carries, checked against the family's set. +fn declared_phases( + fields: &serde_json::Map, + family: Option, + defects: &mut Defects, +) -> Vec { + let Some(phases) = fields.get("phases") else { + return Vec::new(); + }; + let Some(carried) = phases.as_array() else { + defects.add("phases"); + return Vec::new(); + }; + let mut declared = Vec::new(); + for (index, phase) in carried.iter().enumerate() { + let Some(literal) = phase.as_str() else { + defects.add(&format!("phases[{index}]")); + continue; + }; + // A phase outside the family's supported set is refused by name. + let admitted = family.is_some_and(|family| wire_phases(family).contains(&literal)); + if !admitted { + defects.add(&format!("phases[{index}]")); + continue; + } + if !declared.iter().any(|held| held == literal) { + declared.push(literal.to_owned()); + } + } + declared +} + +/// The anonymous GTS instance identifier of one plugin row, derived from the +/// family its `plugin_type` names. +#[must_use] +pub fn plugin_instance(family: PluginFamily, id: Uuid) -> String { + gts::gts_instance(family.base_type(), id) +} + +/// The family one create body's `plugin_type` names, read before any +/// validation runs so the body selects the permission arm the handler +/// enforces. +#[must_use] +pub fn declared_family(body: &Value) -> Option { + body.get("plugin_type") + .and_then(Value::as_str) + .and_then(PluginFamily::from_type_literal) +} + +/// The 400 the duplicate plugin name answers with: a name taken within the +/// calling tenant is a validation failure naming the property, because no +/// catalogue row answers it and the detail discloses nothing about another +/// tenant's catalogue. +#[allow(clippy::result_large_err)] +pub fn name_taken() -> DomainError { + DomainError::gateway( + ErrorKind::ValidationError, + "name is already held by another plugin of the calling tenant", + ) +} + +/// The 409 the in-use protection answers with. +#[allow(clippy::result_large_err)] +pub fn plugin_in_use() -> DomainError { + DomainError::gateway( + ErrorKind::PluginInUse, + "the plugin is still referenced and cannot be deleted", + ) +} \ No newline at end of file diff --git a/gears/system/oagw/oagw/src/control_plane/replace.rs b/gears/system/oagw/oagw/src/control_plane/replace.rs new file mode 100644 index 0000000..14d9418 --- /dev/null +++ b/gears/system/oagw/oagw/src/control_plane/replace.rs @@ -0,0 +1,195 @@ +//! Full-replacement diff — `cpt-cf-oagw-algo-put-replace-diff`. +//! +//! Builds the write set one `PUT` applies in one transaction: the immutable +//! fields come from the addressed row, every configuration family is +//! overwritten with the body's value, the optional families the body omits are +//! cleared, the tags are replaced in full, and `enabled` is the one family +//! carried forward when the body omits it. +//! +//! The diff is where the two cross-row checks of a replacement run: a route +//! re-runs match uniqueness against the other enabled routes of its upstream, +//! and an upstream recomputes its derived alias and compares it with the +//! stored one. + +use uuid::Uuid; + +use crate::control_plane::alias_derive; +use crate::domain::alias::Alias; +use crate::control_plane::match_uniqueness; +use crate::control_plane::validation::ResourceKind; +use crate::domain::error::{DomainError, ErrorKind}; +use crate::domain::route::Route; +use crate::domain::upstream::Upstream; +use crate::store::{OagwStore, RouteRow, UpstreamRow}; + +/// The write set one replacement produces, applied in one transaction. +#[derive(Debug, Clone, PartialEq)] +pub struct WriteSet { + /// Owning tenant, taken from the addressed row and never from the body. + pub tenant_id: Uuid, + /// Addressed identifier, taken from the addressed row and never from the + /// body. + pub id: Uuid, + /// The row content to write. + pub value: T, + /// Whether anything differs; a write set that is empty still reaches the + /// cache flush. + pub changed: bool, +} + +/// Builds the write set an upstream replacement applies. +/// +/// # Errors +/// +/// Returns a validation error when the body states an identifier other than +/// the addressed one, and the `AliasConflict` catalogue row — which answers +/// 409 — when the replacement endpoints derive a different alias. +#[allow(clippy::result_large_err)] +pub fn upstream_diff( + stored: &UpstreamRow, + stated_id: Option, + mut replacement: Upstream, +) -> Result, DomainError> { + // @cpt-begin:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-immutable + if let Some(stated) = stated_id + && stated != stored.upstream.id + { + return Err(immutable(ResourceKind::Upstream, "id")); + } + let tenant_id = stored.tenant_id; + let id = stored.upstream.id; + // @cpt-end:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-immutable + + // @cpt-begin:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-upstream-if + let stored_alias = stored + .upstream + .alias + .as_deref() + .and_then(|alias| Alias::parse(alias).ok()); + let alias = alias_derive::resolve( + &replacement.server.endpoints, + replacement.alias.as_deref(), + stored_alias.as_ref(), + ); + // @cpt-begin:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-upstream-alias + // The replacement flow answers a recomputed alias that differs from the + // stored one with 409 `AliasConflict`: the alias is immutable across + // updates and the stored alias is left unchanged. + // @cpt-begin:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-alias-return + let alias = alias?; + // @cpt-end:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-alias-return + // @cpt-end:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-upstream-alias + // @cpt-end:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-upstream-if + replacement.alias = Some(alias.to_string()); + + // @cpt-begin:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-clear + replacement.id = id; + // @cpt-end:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-clear + + // @cpt-begin:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-tags + let tags = replacement.tags.clone(); + // @cpt-end:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-tags + + let changed = { + let mut candidate = replacement.clone(); + let mut held = stored.upstream.clone(); + candidate.tags = Vec::new(); + held.tags = Vec::new(); + candidate != held || tags != stored.tags + }; + + // @cpt-begin:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-return + // @cpt-begin:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-empty-if + // @cpt-begin:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-empty + Ok(WriteSet { + tenant_id, + id, + value: replacement, + changed, + }) + // @cpt-end:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-empty + // @cpt-end:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-empty-if + // @cpt-end:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-return +} + +/// Builds the write set a route replacement applies. +/// +/// # Errors +/// +/// Returns a validation error when the body states an identifier or an +/// upstream reference other than the addressed row's, and the `MatchConflict` +/// catalogue row — which answers 409 — when another enabled route of the same +/// upstream holds the match rule. +#[allow(clippy::result_large_err)] +pub fn route_diff( + store: &OagwStore, + stored: &RouteRow, + stated_id: Option, + mut replacement: Route, +) -> Result, DomainError> { + // @cpt-begin:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-immutable + if let Some(stated) = stated_id + && stated != stored.route.id + { + return Err(immutable(ResourceKind::Route, "id")); + } + let tenant_id = stored.tenant_id; + let id = stored.route.id; + // @cpt-end:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-immutable + + // @cpt-begin:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-route-if + // @cpt-begin:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-route-upstream + if replacement.upstream_id != Uuid::nil() && replacement.upstream_id != stored.route.upstream_id + { + return Err(immutable(ResourceKind::Route, "upstream_id")); + } + replacement.upstream_id = stored.route.upstream_id; + // @cpt-end:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-route-upstream + // @cpt-end:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-route-if + + // @cpt-begin:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-route-unique + match_uniqueness::confirm_route(store, tenant_id, &replacement, Some(id))?; + // @cpt-end:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-route-unique + + // @cpt-begin:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-clear + // Every configuration family is overwritten with the body's value, so an + // optional family the body omits is cleared; `enabled` is the one family + // carried forward when the body omits it. + replacement.id = id; + replacement.enabled = replacement.enabled.or(stored.route.enabled); + // @cpt-end:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-clear + + // @cpt-begin:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-tags + let tags = replacement.tags.clone(); + // @cpt-end:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-tags + + let changed = { + let mut candidate = replacement.clone(); + let mut held = stored.route.clone(); + candidate.tags = Vec::new(); + held.tags = Vec::new(); + candidate != held || tags != stored.tags + }; + + // @cpt-begin:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-return + // @cpt-begin:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-empty-if + // @cpt-begin:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-empty + Ok(WriteSet { + tenant_id, + id, + value: replacement, + changed, + }) + // @cpt-end:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-empty + // @cpt-end:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-empty-if + // @cpt-end:cpt-cf-oagw-algo-put-replace-diff:p1:inst-diff-return +} + +/// The validation failure for a body that states an immutable field with a +/// value other than the addressed row's. +fn immutable(kind: ResourceKind, field: &str) -> DomainError { + DomainError::gateway( + ErrorKind::ValidationError, + format!("{field} of a {} is immutable", kind.as_str()), + ) +} diff --git a/gears/system/oagw/oagw/src/control_plane/scoping.rs b/gears/system/oagw/oagw/src/control_plane/scoping.rs new file mode 100644 index 0000000..8836009 --- /dev/null +++ b/gears/system/oagw/oagw/src/control_plane/scoping.rs @@ -0,0 +1,128 @@ +//! Tenant scoping — `cpt-cf-oagw-algo-tenant-scope`. +//! +//! The store methods are the predicate: they apply the tenant equality in the +//! same predicate as every other key, so no read or write can address a row +//! the calling tenant does not own. This module exposes only the small +//! decisions the service builds on top of that predicate — which tenant a +//! request carries, what a path-addressed miss answers, and what a body +//! reference the caller does not own answers. +//! +//! No tenant hierarchy is ever walked here: the walk that resolves an +//! ancestor's configuration for a descendant belongs to the hierarchical +//! configuration feature, and the management API's view of an ancestor +//! resource stays empty by construction. + +// @cpt-dod:cpt-cf-oagw-dod-tenant-scoping:p1 +use uuid::Uuid; + +use crate::domain::error::{DomainError, ErrorKind}; +use crate::store::{OagwStore, RouteRow, UpstreamRow}; +use toolkit_security::SecurityContext; + +/// The calling tenant a request carries. +/// +/// # Errors +/// +/// Returns the catalogue's 401 row when the context carries no tenant, which +/// is the authenticated-subject invariant every request is checked against +/// before any store access. +#[allow(clippy::result_large_err)] +pub fn calling_tenant(context: &SecurityContext) -> Result { + // @cpt-begin:cpt-cf-oagw-algo-tenant-scope:p1:inst-scope-tenant + let tenant = context.subject_tenant_id(); + if tenant.is_nil() { + return Err(DomainError::gateway( + ErrorKind::AuthenticationFailed, + "the request carries no tenant", + )); + } + Ok(tenant) + // @cpt-end:cpt-cf-oagw-algo-tenant-scope:p1:inst-scope-tenant +} + +/// The one upstream row the addressed read or write resolves to. +/// +/// # Errors +/// +/// Returns the 404 row when no row of the calling tenant matches; a +/// nonexistent identifier and a foreign-owned one answer the same way. +#[allow(clippy::result_large_err)] +pub fn resolve_upstream( + store: &OagwStore, + tenant_id: Uuid, + id: Uuid, +) -> Result { + store.get_upstream(tenant_id, id).ok_or_else(path_miss) +} + +/// The one route row the addressed read or write resolves to. +/// +/// # Errors +/// +/// Returns the 404 row when no row of the calling tenant matches; a +/// nonexistent identifier and a foreign-owned one answer the same way. +#[allow(clippy::result_large_err)] +pub fn resolve_route( + store: &OagwStore, + tenant_id: Uuid, + id: Uuid, +) -> Result { + store.get_route(tenant_id, id).ok_or_else(path_miss) +} + +/// Resolves the upstream a route body references on the identifier and the +/// calling tenant. +/// +/// # Errors +/// +/// Returns a validation error when the reference does not resolve under the +/// calling tenant: a body reference that is not owned by the caller is a +/// validation failure and not a disclosure about another tenant, so it +/// answers 400 and never 404. +#[allow(clippy::result_large_err)] +pub fn resolve_referenced_upstream( + store: &OagwStore, + tenant_id: Uuid, + upstream_id: Uuid, +) -> Result { + // @cpt-begin:cpt-cf-oagw-algo-tenant-scope:p1:inst-scope-upstream-if + if store.get_upstream(tenant_id, upstream_id).is_none() { + // @cpt-begin:cpt-cf-oagw-algo-tenant-scope:p1:inst-scope-upstream + return Err(DomainError::gateway( + ErrorKind::ValidationError, + "upstream_id does not reference an upstream of the calling tenant", + )); + // @cpt-end:cpt-cf-oagw-algo-tenant-scope:p1:inst-scope-upstream + } + // @cpt-end:cpt-cf-oagw-algo-tenant-scope:p1:inst-scope-upstream-if + store + .get_upstream(tenant_id, upstream_id) + .ok_or_else(path_miss) +} + +/// The rows of one tenant a list page is built from. +#[must_use] +pub fn list_upstreams(store: &OagwStore, tenant_id: Uuid) -> Vec { + store.list_upstreams(tenant_id) +} + +/// The route rows of one tenant a list page is built from. +#[must_use] +pub fn list_routes(store: &OagwStore, tenant_id: Uuid) -> Vec { + store.list_routes(tenant_id) +} + +/// The 404 a path-addressed miss answers with. +/// +/// A nonexistent identifier and a foreign-owned one are deliberately +/// indistinguishable: the detail is a constant, the catalogue row is the same, +/// and no row's existence is disclosed. The identifier the request could not +/// parse answers the same row, because an identifier that is not the resource +/// kind's anonymous GTS instance addresses nothing. +#[must_use] +pub fn path_miss() -> DomainError { + DomainError::gateway( + ErrorKind::RouteNotFound, + "the addressed resource does not exist for the calling tenant", + ) +} diff --git a/gears/system/oagw/oagw/src/control_plane/service.rs b/gears/system/oagw/oagw/src/control_plane/service.rs new file mode 100644 index 0000000..dab0071 --- /dev/null +++ b/gears/system/oagw/oagw/src/control_plane/service.rs @@ -0,0 +1,1494 @@ +//! Management service — the only caller of the store. +//! +//! One method per endpoint operation, each running the FEATURE's step order for +//! its flow: validate the body, resolve the tenant-scoped row, apply the +//! cross-row check, write in one transaction, flush the Control Plane cache +//! before the response is produced. The service takes and returns domain types, +//! `serde_json::Value`, and `Uuid`; no `axum`/`http` type appears here. +//! +//! A store failure is never a catalogue variant: it becomes +//! [`ServiceError::Storage`], which the API layer answers with the platform's +//! RFC 9457 500 problem shape. + +use std::sync::Arc; + +use serde_json::Value; +use uuid::Uuid; + +use crate::control_plane::alias_derive; +use crate::control_plane::bind; +use crate::control_plane::cache::{ControlPlaneCache, DeletionObservers, RateLimitCleanup}; +use crate::control_plane::chain; +use crate::control_plane::match_uniqueness; +use crate::control_plane::odata::{self, ListKind, Page}; +use crate::control_plane::plugin_def; +use crate::control_plane::replace; +use crate::control_plane::scoping; +use crate::control_plane::shadow; +use crate::control_plane::sharing::{self, Decisions, OverridePermissions, Refusal}; +use crate::control_plane::validation::{ResourceKind, Validator, WriteKind}; +use crate::domain::alias::Alias; +use crate::domain::effective::{AncestorBinding, Family}; +use crate::domain::error::{DomainError, ErrorKind}; +use crate::control_plane::binding; +use crate::domain::plugin::Plugin; +use crate::domain::plugin_contract::NamedPluginRegistry; +use crate::domain::route::Route; +use crate::domain::upstream::Upstream; +use crate::gts; +use crate::store::{ + BindingWrite, OagwStore, PluginGcReport, PluginRow, RouteRow, StoreError, UpstreamRow, +}; + +/// Why one operation failed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ServiceError { + /// A failure of the flow's own: a foundation catalogue row answers it. + Domain(DomainError), + /// A descendant override permission the calling token does not hold. + /// + /// This is deliberately not a [`DomainError`] variant: no row of the OAGW + /// error catalogue answers it, and the API layer answers it with a bare + /// 403 problem answer whose detail names neither the family the body + /// carried nor the mode any ancestor declared. + Forbidden { + /// The resource kind the refused operation addressed. + resource: ResourceKind, + /// The `oagw:upstream:*` permission the token does not hold, when the + /// refused family names one; the CORS family names none and the + /// sharing mode alone governs it. + permission: Option<&'static str>, + }, + /// A persistence failure: no catalogue row answers it, and the API layer + /// answers it with the platform's RFC 9457 500 problem shape. + Storage { reason: String }, +} + +impl ServiceError { + /// The catalogue row a `Domain` failure answers with. + /// + /// # Panics + /// + /// Never: the caller checks `is_storage` first. + #[must_use] + pub fn domain(&self) -> &DomainError { + match self { + Self::Domain(error) => error, + Self::Forbidden { .. } | Self::Storage { .. } => { + unreachable!("a refused permission or a storage failure carries no catalogue row") + } + } + } + + /// Whether the failure is a persistence failure. + #[must_use] + pub const fn is_storage(&self) -> bool { + matches!(self, Self::Storage { .. }) + } + + /// The permission a refused descendant override names, when its family + /// carries one. + #[must_use] + pub const fn permission(&self) -> Option<&'static str> { + match self { + Self::Forbidden { permission, .. } => *permission, + Self::Domain(_) | Self::Storage { .. } => None, + } + } +} + +impl From for ServiceError { + fn from(error: DomainError) -> Self { + Self::Domain(error) + } +} + +impl From for ServiceError { + fn from(error: StoreError) -> Self { + match error { + // The same 409 the pre-write check produces, when the store's own + // batch check is the one that observed it. + StoreError::AliasConflict => Self::Domain(alias_conflict()), + StoreError::MatchConflict { + colliding_route_id, + } => Self::Domain(conflict(colliding_route_id)), + // The same 400 naming `name` the pre-write duplicate check + // produces, when the store's own batch check is the one that + // observed it. + StoreError::PluginNameConflict => Self::Domain(plugin_def::name_taken()), + StoreError::Invariant { reason } => Self::Storage { reason }, + } + } +} + +/// The effective lifecycle state of one row as the calling tenant observes it. +/// +/// `Enabled` is the initial state: a successfully persisted row is immediately +/// meaningful. Deletion is not a state; it removes the row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Lifecycle { + /// The row is available for proxy routing. + Enabled, + /// The row is not available for proxy routing. + Disabled, +} + +impl Lifecycle { + /// The state a written `enabled` flag puts the row in. + #[must_use] + pub const fn of(enabled: bool) -> Self { + if enabled { + Self::Enabled + } else { + Self::Disabled + } + } + + /// Whether the row is effectively enabled for a descendant tenant. + /// + /// A contributing ancestor row that is disabled keeps the descendant + /// effectively disabled without any write reaching the descendant row; the + /// hierarchy walk that resolves that ancestor state belongs to the + /// hierarchical configuration feature, which passes it in here. + #[must_use] + pub const fn effective(self, ancestor_disabled: bool) -> bool { + // @cpt-begin:cpt-cf-oagw-state-config-lifecycle:p1:inst-state-ancestor-guard + // The guard is structural: no descendant write can reach an ancestor + // row, so an ancestor `Disabled` state wins over a descendant `Enabled` + // one and the descendant stays effectively disabled. + !ancestor_disabled && matches!(self, Self::Enabled) + // @cpt-end:cpt-cf-oagw-state-config-lifecycle:p1:inst-state-ancestor-guard + } +} + +/// The management half of the gear. +pub struct ManagementService { + store: Arc, + validator: Validator, + cache: Arc, + observers: Arc, + plugins: NamedPluginRegistry, +} + +impl ManagementService { + /// Builds the service over an empty store and the compiled validators. + /// + /// # Errors + /// + /// Returns the compilation error of the four request-body validators. + #[allow(clippy::result_large_err)] + pub fn new( + store: Arc, + config: &crate::config::OagwConfig, + cache: Arc, + ) -> Result { + Ok(Self { + store, + validator: Validator::compile(config)?, + cache, + observers: Arc::new(DeletionObservers::new()), + plugins: NamedPluginRegistry::with_builtins(), + }) + } + + /// The store the service owns, for the read paths the API layer needs. + #[must_use] + pub fn store(&self) -> &OagwStore { + &self.store + } + + /// The cache the write path advances. + #[must_use] + pub fn cache(&self) -> &ControlPlaneCache { + &self.cache + } + + /// Registers the rate-limit cleanup the deletion seam notifies. + pub fn register_deletion_observer(&self, observer: Arc) { + self.observers.register(observer); + } + + /// Resolves and validates the plugin bindings one parent body carries, + /// through `cpt-cf-oagw-algo-binding-validate`, and answers the write set + /// the parent's single-transaction write applies. + /// + /// # Errors + /// + /// Returns the validation error that names every failing item with its + /// position and reason, which is indistinguishable from any other + /// validation failure of the parent write. + #[allow(clippy::result_large_err)] + fn binding_write( + &self, + tenant_id: Uuid, + body: &Value, + is_upstream: bool, + ) -> Result { + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-validate + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-fail-if + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-fail-return + // Every reference is resolved and every item validated before the + // parent write is attempted, so a body that names an unresolvable + // plugin writes no parent row and no binding row. + let write = binding::validate( + &self.store, + tenant_id, + &self.plugins, + body, + is_upstream, + crate::store::unix_now(), + ) + .map_err(ServiceError::from)?; + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-fail-return + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-fail-else + // ELSE every item resolved and every check held, and the write set the + // routine built rides on the parent's single transaction. + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-fail-else + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-fail-if + Ok(write) + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-validate + } + + /// Creates one upstream over the ancestor chain the caller obtained: + /// `POST /oagw/v1/upstreams`. + /// + /// The same steps as [`Self::create_upstream`], with the hierarchical bind + /// decision of `cpt-cf-oagw-flow-bind-ancestor-upstream` between the alias + /// derivation and the write: a create whose normalized alias matches an + /// ancestor's upstream is a bind requiring `oagw:upstream:bind`, answered + /// 201 with the descendant's own row rather than 409. The chain arrives + /// ordered, calling tenant first; an empty chain is the ordinary create. + /// + /// # Errors + /// + /// Returns a validation error when the body is not valid or the endpoint + /// set derives no alias, the `AliasConflict` row when another upstream of + /// the calling tenant holds the alias, the 403 of a missing descendant + /// override permission and the 400 of an `enforce` family, and a storage + /// failure when the walk could not be ordered or the write could not be + /// applied. + #[allow(clippy::result_large_err)] + pub fn create_upstream_in_chain( + &self, + tenant_id: Uuid, + ancestors: &[Uuid], + permissions: &OverridePermissions, + body: &Value, + ) -> Result { + // @cpt-begin:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-validate + // The body is validated and the alias derived before the walk runs, by + // the same two routines the ordinary create uses. + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-parent-validate + // @cpt-begin:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-validate + let validated = self + .validator + .validate_upstream(WriteKind::Create, body) + .map_err(ServiceError::from)?; + // @cpt-end:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-validate + // The shipped schema confirms the `plugins` envelope and its + // `sharing` enum; the shape of the items that envelope carries is the + // plugin routine's answer, not this schema's. + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-parent-validate + let mut upstream = validated.value; + + let plugin_write = self.binding_write(tenant_id, body, true)?; + + // @cpt-begin:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-alias + // @cpt-begin:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-alias-if + // @cpt-begin:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-alias-return + let alias = + alias_derive::resolve(&upstream.server.endpoints, upstream.alias.as_deref(), None) + .map_err(ServiceError::from)?; + // @cpt-end:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-alias-return + // @cpt-end:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-alias-if + // @cpt-begin:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-alias-else + // @cpt-begin:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-alias-continue + upstream.alias = Some(alias.to_string()); + // @cpt-end:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-alias-continue + // @cpt-end:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-alias-else + // @cpt-end:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-alias + // @cpt-end:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-validate + upstream.id = Uuid::new_v4(); + + // @cpt-begin:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-own-scope + // The calling tenant's own scope is confirmed first: a duplicate answers + // 409 before the walk runs, so an ancestor's alias can never be + // mistaken for a same-tenant conflict. + if self.store.upstream_by_alias(tenant_id, &alias).is_some() { + return Err(ServiceError::Domain(alias_conflict())); + } + // @cpt-end:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-own-scope + + // @cpt-begin:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-walk + let bindings = self.ancestor_bindings(tenant_id, ancestors, &alias)?; + // @cpt-end:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-walk + + // @cpt-begin:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-noancestor-if + let decided = if bindings.is_empty() { + // @cpt-begin:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-noancestor + // No ancestor holds the alias: the operation is an ordinary create + // and no permission beyond `create` is consumed. + Ok(Decisions { + families: Vec::new(), + }) + // @cpt-end:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-noancestor + } else { + // @cpt-end:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-noancestor-if + // @cpt-begin:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-ancestor-else + // @cpt-begin:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-perm-if + // The bind itself needs `oagw:upstream:bind`, and it needs it + // whatever the body carries: the permission check runs before + // every per-family sharing check, so a caller that lacks the + // permission never learns which families the ancestor enforces. + if !permissions.holds(gts::PERMISSION_BIND) { + // @cpt-begin:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-perm-return + return Err(ServiceError::Forbidden { + resource: ResourceKind::Upstream, + permission: Some(gts::PERMISSION_BIND), + }); + // @cpt-end:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-perm-return + } + // @cpt-end:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-perm-if + // @cpt-begin:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-decide + // Every family the body carries is decided against the ancestor's + // per-family sharing modes and the calling tenant's permission set. + sharing::decide(&bindings, &carried_families(&upstream), permissions) + // @cpt-end:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-decide + // @cpt-end:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-ancestor-else + }; + + // @cpt-begin:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-write-else + // ELSE every family the body carries is writable, so the bind routine + // records the binding the walk resolved and produces the write set for + // the descendant's own row; a refusal it returns is answered below and + // writes no row. + // @cpt-end:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-write-else + // @cpt-begin:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-write + let write_set = match bind::write_set(tenant_id, bindings.len(), upstream, &decided) { + Ok(write_set) => write_set, + // @cpt-begin:cpt-cf-oagw-algo-sharing-mode-decision:p1:inst-decide-refusal-if + Err(Refusal::Permission { family }) => { + // @cpt-begin:cpt-cf-oagw-algo-sharing-mode-decision:p1:inst-decide-refusal-return + // A per-family sharing check refused the override after the + // bind permission was confirmed, so the decision's refusal is + // returned and no row is written. + return Err(ServiceError::Forbidden { + resource: ResourceKind::Upstream, + permission: family.override_permission(), + }); + // @cpt-end:cpt-cf-oagw-algo-sharing-mode-decision:p1:inst-decide-refusal-return + } + // @cpt-end:cpt-cf-oagw-algo-sharing-mode-decision:p1:inst-decide-refusal-if + // @cpt-begin:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-enforce-if + Err(Refusal::Enforced { family }) => { + // @cpt-begin:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-enforce-return + return Err(ServiceError::Domain(enforced_family_error(family))); + // @cpt-end:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-enforce-return + } + // @cpt-end:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-enforce-if + }; + // @cpt-end:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-write + + // @cpt-begin:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-scope + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-write + // The binding rows and the two auth plugin identity columns are + // written by the same batch the parent row is, so a parent that fails + // writes no binding and a binding that fails writes no parent. + let written = match self.store.insert_upstream_with_bindings( + write_set.tenant_id, + &write_set.value, + &plugin_write, + ) { + // @cpt-begin:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-conflict-if + Err(StoreError::AliasConflict) => { + // @cpt-begin:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-conflict-return + return Err(ServiceError::Domain(alias_conflict())); + // @cpt-end:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-conflict-return + } + // @cpt-end:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-conflict-if + // @cpt-begin:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-else + outcome => outcome.map_err(ServiceError::from)?, + // @cpt-end:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-else + }; + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-write + // @cpt-end:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-scope + + self.cache.flush_for(tenant_id); + // @cpt-begin:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-return + // @cpt-begin:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-return + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-return + // The ancestor's rows are unchanged by the operation. + Ok(written) + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-return + // @cpt-end:cpt-cf-oagw-flow-bind-ancestor-upstream:p1:inst-bind-return + // @cpt-end:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-return + } + + /// Creates one upstream: `POST /oagw/v1/upstreams`. + /// + /// The ordinary create: no ancestor chain is consulted, so no bind is ever + /// performed and no permission beyond `create` is consumed. The + /// hierarchical form is [`Self::create_upstream_in_chain`]. + /// + /// # Errors + /// + /// Returns a validation error when the body is not valid or the endpoint + /// set derives no alias, the `AliasConflict` row when another upstream of + /// the calling tenant holds the alias, and a storage failure when the + /// write could not be applied. + #[allow(clippy::result_large_err)] + pub fn create_upstream( + &self, + tenant_id: Uuid, + body: &Value, + ) -> Result { + self.create_upstream_in_chain(tenant_id, &[], &OverridePermissions::none(), body) + } + + /// Reads one upstream: `GET /oagw/v1/upstreams/{id}`. + /// + /// # Errors + /// + /// Returns the 404 row when no row of the calling tenant matches. + #[allow(clippy::result_large_err)] + pub fn read_upstream(&self, tenant_id: Uuid, id: Uuid) -> Result { + // @cpt-begin:cpt-cf-oagw-flow-config-read-list:p1:inst-read-scope + let row = + scoping::resolve_upstream(&self.store, tenant_id, id).map_err(ServiceError::from)?; + // @cpt-end:cpt-cf-oagw-flow-config-read-list:p1:inst-read-scope + // @cpt-begin:cpt-cf-oagw-flow-config-read-list:p1:inst-read-single-else + // ELSE the operation is a single read: the one row the predicate + // resolved, with its dependent tag rows already attached. + // @cpt-begin:cpt-cf-oagw-flow-config-read-list:p1:inst-read-single + // The resolved row already carries its dependent tag rows. + // @cpt-end:cpt-cf-oagw-flow-config-read-list:p1:inst-read-single + // @cpt-end:cpt-cf-oagw-flow-config-read-list:p1:inst-read-single-else + // @cpt-begin:cpt-cf-oagw-flow-config-read-list:p1:inst-read-return + Ok(row) + // @cpt-end:cpt-cf-oagw-flow-config-read-list:p1:inst-read-return + } + + /// Lists the upstreams of the calling tenant: `GET /oagw/v1/upstreams`. + /// + /// # Errors + /// + /// Returns a validation error naming the offending parameter. + #[allow(clippy::result_large_err)] + pub fn list_upstreams( + &self, + tenant_id: Uuid, + query: &str, + ) -> Result, ServiceError> { + // @cpt-begin:cpt-cf-oagw-flow-config-read-list:p1:inst-read-list-if + // @cpt-begin:cpt-cf-oagw-flow-config-read-list:p1:inst-read-list + let parsed = odata::parse(ResourceKind::Upstream.into(), query).map_err(ServiceError::from)?; + let scan = scoping::list_upstreams(&self.store, tenant_id); + // @cpt-end:cpt-cf-oagw-flow-config-read-list:p1:inst-read-list + // @cpt-end:cpt-cf-oagw-flow-config-read-list:p1:inst-read-list-if + // @cpt-begin:cpt-cf-oagw-flow-config-read-list:p1:inst-read-return + Ok(odata::apply_upstream(&parsed, scan)) + // @cpt-end:cpt-cf-oagw-flow-config-read-list:p1:inst-read-return + } + + /// Replaces one upstream over the ancestor chain the caller obtained: + /// `PUT /oagw/v1/upstreams/{id}`. + /// + /// The same steps as [`Self::replace_upstream`], with the sharing-mode + /// decision of `cpt-cf-oagw-flow-override-inherited-field` between the + /// write set and the write: a family the ancestor marks `enforce` answers + /// 400 and an `inherit` family whose override permission the token does + /// not hold answers 403, in that order, and neither writes the row. The + /// chain arrives ordered, calling tenant first; an empty chain decides + /// every family `own`. + /// + /// The same steps serve the enable/disable flow: there is no dedicated + /// enable or disable operation, the flag travels on the replacement body. + /// + /// # Errors + /// + /// Returns the 404 row when no row of the calling tenant matches, a + /// validation error when the body is not valid or states a different + /// identifier, the `AliasConflict` row when the replacement endpoints + /// derive a different alias, the 403 of a missing descendant override + /// permission and the 400 of an `enforce` family, and a storage failure + /// when the walk could not be ordered or the write could not be applied. + #[allow(clippy::result_large_err)] + pub fn replace_upstream_in_chain( + &self, + tenant_id: Uuid, + id: Uuid, + ancestors: &[Uuid], + permissions: &OverridePermissions, + body: &Value, + ) -> Result { + // @cpt-begin:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-issue + // A replacement may carry an explicit `enabled` value; the ten + // management paths hold no dedicated enable or disable operation. + // @cpt-end:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-issue + + // @cpt-begin:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-scope + // The row is resolved by identifier and calling tenant only, so an + // ancestor's row can never satisfy the predicate and a descendant can + // never address one. + // @cpt-begin:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-scope + // @cpt-begin:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-scope-if + // The same resolution answers the enable/disable flow: a non-match is + // the only reason a descendant cannot re-enable an ancestor-disabled + // resource through this API. + // @cpt-begin:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-scope + // @cpt-begin:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-scope-if + // @cpt-begin:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-scope-return + // RETURN 404: a nonexistent identifier and a foreign-owned one answer + // the same way, so the endpoint discloses nothing about other tenants' + // resources. + // @cpt-begin:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-scope-return + // @cpt-begin:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-scope-if + // @cpt-begin:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-scope-return + let stored = + scoping::resolve_upstream(&self.store, tenant_id, id).map_err(ServiceError::from)?; + // @cpt-end:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-scope-return + // @cpt-end:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-scope-if + // @cpt-end:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-scope-return + // @cpt-end:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-scope-return + // @cpt-end:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-scope-if + // @cpt-end:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-scope-if + // @cpt-begin:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-scope-else + // ELSE the row resolved under the calling tenant's scope. + // @cpt-begin:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-scope-else + // ELSE the tenant's own row resolved, and it is the only row the + // replacement can address. + // @cpt-end:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-scope-else + // @cpt-end:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-scope-else + // @cpt-begin:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-scope-else + // ELSE the row resolved under the calling tenant's scope. + // @cpt-end:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-scope-else + // @cpt-end:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-scope + // @cpt-end:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-scope + // @cpt-end:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-scope + + // @cpt-begin:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-scope-else + // @cpt-begin:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-load + // The stored row and its dependent tag rows are the diff baseline. + // @cpt-end:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-load + + // @cpt-begin:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-put-if + // IF the operation is a replacement; the deletion branch is + // [`Self::delete_upstream`]. + // @cpt-begin:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-validate + // The replacement body is validated and the write set built for the + // tenant's own row before the chain walk and the sharing-mode decision + // run against it. + // @cpt-begin:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-validate + // @cpt-begin:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-validate + let validated = self + .validator + .validate_upstream(WriteKind::Replacement, body) + .map_err(ServiceError::from)?; + // @cpt-end:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-validate + // @cpt-end:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-validate + // @cpt-begin:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-diff + // The routine recomputes the derived alias from the replacement + // endpoints, confirms the immutable fields, and builds the write set. + // @cpt-dod:cpt-cf-oagw-dod-full-replacement-put:p1 + let carried = carried_families(&validated.value); + let write_set = replace::upstream_diff(&stored, validated.stated_id, validated.value) + .map_err(ServiceError::from)?; + // @cpt-end:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-diff + + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-parent-validate + // The replacement body's plugin items and auth identity are resolved + // after the parent's own validation, and before any row is written. + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-parent-validate + let plugin_write = self.binding_write(tenant_id, body, true)?; + // @cpt-end:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-validate + + // @cpt-begin:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-decide + // The walk resolves the ancestor binding the row participates in, from + // the stored row's immutable alias, and the sharing-mode decision + // evaluates every family the body carries against it. The families are + // read off the validated body, not off the write set: a family the + // body omits is written by nobody and takes no part in the decision. + let bindings = self.upstream_ancestor_bindings(tenant_id, ancestors, &stored)?; + let decided = sharing::decide(&bindings, &carried, permissions); + // @cpt-end:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-decide + + // @cpt-begin:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-perm-if + if let Err(Refusal::Permission { family }) = &decided { + // @cpt-begin:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-perm-return + // The permission check precedes every per-family sharing check, so + // the tenant uses the ancestor's value as-is and never learns which + // families the ancestor enforces. + return Err(ServiceError::Forbidden { + resource: ResourceKind::Upstream, + permission: family.override_permission(), + }); + // @cpt-end:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-perm-return + } + // @cpt-end:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-perm-if + + // @cpt-begin:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-enforce-if + if let Err(Refusal::Enforced { family }) = &decided { + // @cpt-begin:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-enforce-return + // This 400 is reached only once the permission check above has + // passed; the stored row is left unchanged. + return Err(ServiceError::Domain(enforced_family_error(*family))); + // @cpt-end:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-enforce-return + } + // @cpt-end:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-enforce-if + + // @cpt-begin:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-alias-if + // The routine returned 409 when the recomputed alias differs from the + // stored one: the alias is immutable across updates and the stored + // alias is left unchanged. + // @cpt-end:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-alias-if + // @cpt-begin:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-alias-else + // ELSE the recomputed alias equals the stored one and the write set + // applies. + // @cpt-begin:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-write-else + // ELSE every family the body carries is writable: the write set applies + // to the tenant's own row, no ancestor row is written, and the + // inherited tags stay in the effective set whatever the body's tag list + // holds. + // @cpt-end:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-write-else + // @cpt-begin:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-write + if !write_set.changed { + // A write set that changes nothing is applied anyway, so the cache + // flush still runs and the response carries the stored row. + self.cache.flush_for(write_set.tenant_id); + return Ok(stored); + } + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-write + // The full replacement of the binding rows is written by the same + // batch the parent row is, so a body that omits the `plugins` + // sub-object unlinks every plugin in one commit. + let written = self + .store + .replace_upstream_with_bindings( + write_set.tenant_id, + write_set.id, + &write_set.value, + &plugin_write, + ) + .map_err(ServiceError::from)?; + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-write + // @cpt-end:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-write + // @cpt-end:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-alias-else + // @cpt-end:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-put-if + + // @cpt-dod:cpt-cf-oagw-dod-enable-disable:p1 + // @cpt-begin:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-write + self.cache.flush_for(tenant_id); + // @cpt-end:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-write + // @cpt-begin:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-off-if + let _state = Lifecycle::of(written.upstream.enabled); + // @cpt-begin:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-off + // A written `false` puts the row in `Disabled` for its owner and for + // every descendant tenant without any further write. + // @cpt-begin:cpt-cf-oagw-state-config-lifecycle:p1:inst-state-disable + // The `Enabled` to `Disabled` transition of the owning tenant's write. + // @cpt-end:cpt-cf-oagw-state-config-lifecycle:p1:inst-state-disable + // @cpt-end:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-off + // @cpt-begin:cpt-cf-oagw-state-config-lifecycle:p1:inst-state-ancestor-disable + // The ancestor-driven `Enabled` to `Disabled` transition reaches this + // row through no write: it is observed only from the descendant's side, + // and the hierarchy walk that detects it belongs to + // `cpt-cf-oagw-feature-hierarchical-config`. + // @cpt-end:cpt-cf-oagw-state-config-lifecycle:p1:inst-state-ancestor-disable + // @cpt-end:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-off-if + // @cpt-begin:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-on-else + // @cpt-begin:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-on + // @cpt-begin:cpt-cf-oagw-state-config-lifecycle:p1:inst-state-enable + // The `Disabled` to `Enabled` transition holds only where no + // contributing ancestor row is disabled. + // @cpt-end:cpt-cf-oagw-state-config-lifecycle:p1:inst-state-enable + // @cpt-end:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-on + // @cpt-end:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-on-else + // @cpt-end:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-scope-else + // @cpt-begin:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-return + // @cpt-begin:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-return + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-return + Ok(written) + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-return + // @cpt-end:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-return + // @cpt-end:cpt-cf-oagw-flow-enable-disable:p1:inst-en-dis-return + } + + /// Replaces one upstream: `PUT /oagw/v1/upstreams/{id}`. + /// + /// The ordinary replacement: no ancestor chain is consulted, so every + /// family the body carries is decided `own`. The hierarchical form is + /// [`Self::replace_upstream_in_chain`]. + /// + /// # Errors + /// + /// Returns the 404 row when no row of the calling tenant matches, a + /// validation error when the body is not valid or states a different + /// identifier, the `AliasConflict` row when the replacement endpoints + /// derive a different alias, and a storage failure when the write could + /// not be applied. + #[allow(clippy::result_large_err)] + pub fn replace_upstream( + &self, + tenant_id: Uuid, + id: Uuid, + body: &Value, + ) -> Result { + self.replace_upstream_in_chain(tenant_id, id, &[], &OverridePermissions::none(), body) + } + + /// Deletes one upstream and, by cascade, its routes: `DELETE + /// /oagw/v1/upstreams/{id}`. + /// + /// # Errors + /// + /// Returns the 404 row when no row of the calling tenant matches, and a + /// storage failure when the deletion could not be applied. + #[allow(clippy::result_large_err)] + pub fn delete_upstream(&self, tenant_id: Uuid, id: Uuid) -> Result { + // @cpt-begin:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-delete-else + // ELSE the operation is a deletion: the row resolves under the calling + // tenant's scope, then the row and its cascaded dependents leave in one + // transaction. + scoping::resolve_upstream(&self.store, tenant_id, id) + .map_err(ServiceError::from)?; + let deleted = self + .store + .delete_upstream(tenant_id, id) + .map_err(ServiceError::from)?; + // @cpt-end:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-delete-else + + self.cache.flush_for(tenant_id); + // A successful deletion notifies the registered rate-limit cleanup; a + // failed one notifies nothing. + self.observers.upstream_deleted(tenant_id, id); + // @cpt-begin:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-return + Ok(deleted) + // @cpt-end:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-return + } + + /// Creates one route: `POST /oagw/v1/routes`. + /// + /// # Errors + /// + /// Returns a validation error when the body is not valid or references an + /// upstream the calling tenant does not own, the `MatchConflict` row when + /// another enabled route holds the match key, and a storage failure when + /// the write could not be applied. + #[allow(clippy::result_large_err)] + pub fn create_route(&self, tenant_id: Uuid, body: &Value) -> Result { + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-parent-validate + // @cpt-begin:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-validate + let validated = self + .validator + .validate_route(WriteKind::Create, body) + .map_err(ServiceError::from)?; + // @cpt-end:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-validate + // The shipped schema confirms the `plugins` envelope; the item shape + // is the plugin routine's answer. + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-parent-validate + let mut route = validated.value; + + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-validate + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-fail-if + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-fail-return + // A route binds no auth plugin, so its body's `auth` member is a + // schema failure the validation above already answered, and the + // routine only records the rule here. + let plugin_write = self.binding_write(tenant_id, body, false)?; + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-fail-return + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-fail-else + // ELSE the write set carries one binding row per resolvable item, at + // the positions the body submitted. + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-fail-else + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-fail-if + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-validate + + // @cpt-begin:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-resolve + // @cpt-begin:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-resolve-if + scoping::resolve_referenced_upstream(&self.store, tenant_id, route.upstream_id) + .map_err(ServiceError::from)?; + // @cpt-begin:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-resolve-return + // An unresolvable `upstream_id` was answered 400 above: an + // ancestor-owned upstream is not directly addressable as a route + // target, so a route can only be created under a target the caller + // owns. + // @cpt-end:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-resolve-return + // @cpt-end:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-resolve-if + // @cpt-begin:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-resolve-else + // @cpt-begin:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-resolve-continue + // ELSE continue with the resolved upstream. + // @cpt-end:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-resolve-continue + // @cpt-end:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-resolve-else + // @cpt-end:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-resolve + + // @cpt-begin:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-unique + // @cpt-begin:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-unique-if + match_uniqueness::confirm_route(&self.store, tenant_id, &route, None) + .map_err(ServiceError::from)?; + // @cpt-begin:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-unique-return + // A collision was answered 409 above, naming the colliding route, and + // no row was written. + // @cpt-end:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-unique-return + // @cpt-end:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-unique-if + // @cpt-begin:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-unique-else + // ELSE no other enabled route of that upstream holds the match key. + // @cpt-end:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-unique-else + // @cpt-end:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-unique + + route.id = Uuid::new_v4(); + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-write + // The route's binding rows are written by the same batch its own row + // is, at the positions the body submitted. + let written = self + .store + .insert_route_with_bindings(tenant_id, &route, &plugin_write) + .map_err(ServiceError::from)?; + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-write + self.cache.flush_for(tenant_id); + // @cpt-begin:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-return + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-return + Ok(written) + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-return + // @cpt-end:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-return + } + + /// Reads one route: `GET /oagw/v1/routes/{id}`. + /// + /// # Errors + /// + /// Returns the 404 row when no row of the calling tenant matches. + #[allow(clippy::result_large_err)] + pub fn read_route(&self, tenant_id: Uuid, id: Uuid) -> Result { + // @cpt-begin:cpt-cf-oagw-flow-config-read-list:p1:inst-read-scope + let row = scoping::resolve_route(&self.store, tenant_id, id).map_err(ServiceError::from)?; + // @cpt-end:cpt-cf-oagw-flow-config-read-list:p1:inst-read-scope + // @cpt-begin:cpt-cf-oagw-flow-config-read-list:p1:inst-read-return + Ok(row) + // @cpt-end:cpt-cf-oagw-flow-config-read-list:p1:inst-read-return + } + + /// Lists the routes of the calling tenant: `GET /oagw/v1/routes`. + /// + /// # Errors + /// + /// Returns a validation error naming the offending parameter. + #[allow(clippy::result_large_err)] + pub fn list_routes(&self, tenant_id: Uuid, query: &str) -> Result, ServiceError> { + // @cpt-begin:cpt-cf-oagw-flow-config-read-list:p1:inst-read-list-if + // @cpt-begin:cpt-cf-oagw-flow-config-read-list:p1:inst-read-list + let parsed = odata::parse(ResourceKind::Route.into(), query).map_err(ServiceError::from)?; + let scan = scoping::list_routes(&self.store, tenant_id); + // @cpt-end:cpt-cf-oagw-flow-config-read-list:p1:inst-read-list + // @cpt-end:cpt-cf-oagw-flow-config-read-list:p1:inst-read-list-if + // @cpt-begin:cpt-cf-oagw-flow-config-read-list:p1:inst-read-return + Ok(odata::apply_route(&parsed, scan)) + // @cpt-end:cpt-cf-oagw-flow-config-read-list:p1:inst-read-return + } + + /// Replaces one route over the ancestor chain the caller obtained: + /// `PUT /oagw/v1/routes/{id}`. + /// + /// The same steps as [`Self::replace_route`], with the sharing-mode + /// decision of `cpt-cf-oagw-flow-override-inherited-field` on the families + /// a route carries. A route participates in the hierarchy through its + /// upstream's alias, so the ancestors the walk resolves for that alias — + /// and the routes those upstreams hold — are the row's ancestors; a route + /// carries no `auth` family, so there is no inherited auth configuration + /// for it to override. + /// + /// # Errors + /// + /// Returns the 404 row when no row of the calling tenant matches, a + /// validation error when the body is not valid or states a different + /// identifier or upstream reference, the `MatchConflict` row when another + /// enabled route holds the match key, the 403 of a missing descendant + /// override permission and the 400 of an `enforce` family, and a storage + /// failure when the walk could not be ordered or the write could not be + /// applied. + #[allow(clippy::result_large_err)] + pub fn replace_route_in_chain( + &self, + tenant_id: Uuid, + id: Uuid, + ancestors: &[Uuid], + permissions: &OverridePermissions, + body: &Value, + ) -> Result { + // @cpt-begin:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-scope-if + let stored = + scoping::resolve_route(&self.store, tenant_id, id).map_err(ServiceError::from)?; + // @cpt-end:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-scope-if + // @cpt-begin:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-load + // @cpt-begin:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-validate + let validated = self + .validator + .validate_route(WriteKind::Replacement, body) + .map_err(ServiceError::from)?; + // @cpt-end:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-validate + // The DoD scope marker for `cpt-cf-oagw-dod-full-replacement-put` is + // declared once per file, at the upstream replacement. + let carried = carried_route_families(&validated.value); + let write_set = + replace::route_diff(&self.store, &stored, validated.stated_id, validated.value) + .map_err(ServiceError::from)?; + // @cpt-end:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-load + + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-parent-validate + // The replacement body's plugin items are resolved after the parent's + // own validation, and before any row is written. + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-parent-validate + let plugin_write = self.binding_write(tenant_id, body, false)?; + + // @cpt-begin:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-decide + // The walk resolves the ancestor routes the row participates in, + // through its upstream's alias, and the sharing-mode decision + // evaluates every family the body carries against them. + let bindings = self.route_ancestor_bindings(tenant_id, ancestors, &stored)?; + let decided = sharing::decide(&bindings, &carried, permissions); + // @cpt-end:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-decide + + // @cpt-begin:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-perm-if + if let Err(Refusal::Permission { family }) = &decided { + // @cpt-begin:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-perm-return + return Err(ServiceError::Forbidden { + resource: ResourceKind::Route, + permission: family.override_permission(), + }); + // @cpt-end:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-perm-return + } + // @cpt-end:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-perm-if + + // @cpt-begin:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-enforce-if + if let Err(Refusal::Enforced { family }) = &decided { + // @cpt-begin:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-enforce-return + return Err(ServiceError::Domain(enforced_family_error(*family))); + // @cpt-end:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-enforce-return + } + // @cpt-end:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-enforce-if + + // @cpt-begin:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-write-else + // ELSE every family the body carries is writable: the write set applies + // to the tenant's own row, no ancestor row is written, and the + // inherited tags stay in the effective set whatever the body's tag list + // holds. + // @cpt-end:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-write-else + // @cpt-begin:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-put-if + // @cpt-begin:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-write + if !write_set.changed { + // A write set that changes nothing is applied anyway, so the cache + // flush still runs and the response carries the stored row. + self.cache.flush_for(write_set.tenant_id); + return Ok(stored); + } + let written = self + .store + .replace_route_with_bindings( + write_set.tenant_id, + write_set.id, + &write_set.value, + &plugin_write, + ) + .map_err(ServiceError::from)?; + self.cache.flush_for(write_set.tenant_id); + // @cpt-end:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-write + // @cpt-end:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-put-if + // @cpt-begin:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-return + // @cpt-begin:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-return + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-return + Ok(written) + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-return + // @cpt-end:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-return + // @cpt-end:cpt-cf-oagw-flow-override-inherited-field:p1:inst-ovr-return + } + + /// Replaces one route: `PUT /oagw/v1/routes/{id}`. + /// + /// The ordinary replacement: no ancestor chain is consulted, so every + /// family the body carries is decided `own`. The hierarchical form is + /// [`Self::replace_route_in_chain`]. The route's `enabled` flag travels on + /// the replacement body, exactly as an upstream's does. + /// + /// # Errors + /// + /// Returns the 404 row when no row of the calling tenant matches, a + /// validation error when the body is not valid or states a different + /// identifier or upstream reference, the `MatchConflict` row when another + /// enabled route holds the match key, and a storage failure when the write + /// could not be applied. + #[allow(clippy::result_large_err)] + pub fn replace_route( + &self, + tenant_id: Uuid, + id: Uuid, + body: &Value, + ) -> Result { + self.replace_route_in_chain(tenant_id, id, &[], &OverridePermissions::none(), body) + } + + /// Deletes one route and its dependent rows: `DELETE /oagw/v1/routes/{id}`. + /// + /// # Errors + /// + /// Returns the 404 row when no row of the calling tenant matches, and a + /// storage failure when the deletion could not be applied. + #[allow(clippy::result_large_err)] + pub fn delete_route(&self, tenant_id: Uuid, id: Uuid) -> Result { + // @cpt-begin:cpt-cf-oagw-flow-route-delete:p1:inst-rt-del-scope-if + // @cpt-begin:cpt-cf-oagw-flow-route-delete:p1:inst-rt-del-scope + scoping::resolve_route(&self.store, tenant_id, id) + .map_err(ServiceError::from)?; + // @cpt-end:cpt-cf-oagw-flow-route-delete:p1:inst-rt-del-scope + // @cpt-begin:cpt-cf-oagw-flow-route-delete:p1:inst-rt-del-scope-return + // A row that did not resolve was answered 404 above; the two causes are + // deliberately indistinguishable. + // @cpt-end:cpt-cf-oagw-flow-route-delete:p1:inst-rt-del-scope-return + // @cpt-end:cpt-cf-oagw-flow-route-delete:p1:inst-rt-del-scope-if + // @cpt-begin:cpt-cf-oagw-flow-route-delete:p1:inst-rt-del-scope-else + let deleted = self + .store + .delete_route(tenant_id, id) + .map_err(ServiceError::from)?; + // @cpt-end:cpt-cf-oagw-flow-route-delete:p1:inst-rt-del-scope-else + + self.cache.flush_for(tenant_id); + // A successful deletion notifies the registered rate-limit cleanup; a + // failed one notifies nothing. + self.observers.route_deleted(tenant_id, id); + // @cpt-begin:cpt-cf-oagw-flow-route-delete:p1:inst-rt-del-return + Ok(deleted) + // @cpt-end:cpt-cf-oagw-flow-route-delete:p1:inst-rt-del-return + } + + /// Creates one custom plugin: `POST /oagw/v1/plugins`. + /// + /// The body selects the permission arm before any validation runs, so the + /// family is what the handler authorized against. The source is stored + /// verbatim and never parsed or executed here: the create flow's contract + /// is the declared fields alone. + /// + /// # Errors + /// + /// Returns a validation error naming every failing property, the same + /// validation error naming `name` when another plugin of the calling + /// tenant holds the name, and a storage failure when the write could not + /// be applied. + #[allow(clippy::result_large_err)] + pub fn create_plugin(&self, tenant_id: Uuid, body: &Value) -> Result { + // The registry check and the accumulated property validation are + // `cpt-cf-oagw-algo-plugin-contract-registry`, which the create flow + // calls before any row is written. + let validated = plugin_def::validate_plugin(body).map_err(ServiceError::from)?; + let plugin = Plugin { + id: Uuid::new_v4(), + tenant_id, + plugin_type: validated.family.as_str().to_owned(), + name: validated.name, + description: validated.description, + config_schema: validated.config_schema, + phases: validated.phases, + source_code: validated.source_code, + // `last_used_at` stays unset until the data plane first resolves + // the plugin, and `gc_eligible_at` stays unset until the lifecycle + // moves the row. + last_used_at: None, + gc_eligible_at: None, + }; + + // @cpt-begin:cpt-cf-oagw-flow-plugin-create:p1:inst-pl-create-dup + let taken = self + .store + .list_plugins(tenant_id) + .iter() + .any(|row| row.plugin.name == plugin.name); + // @cpt-end:cpt-cf-oagw-flow-plugin-create:p1:inst-pl-create-dup + // @cpt-begin:cpt-cf-oagw-flow-plugin-create:p1:inst-pl-create-dup-if + if taken { + // @cpt-begin:cpt-cf-oagw-flow-plugin-create:p1:inst-pl-create-dup-return + // RETURN 400 naming `name`; the calling tenant's catalogue holds + // another plugin of the same name and no row is written. + return Err(ServiceError::from(plugin_def::name_taken())); + // @cpt-end:cpt-cf-oagw-flow-plugin-create:p1:inst-pl-create-dup-return + } + // @cpt-end:cpt-cf-oagw-flow-plugin-create:p1:inst-pl-create-dup-if + // @cpt-begin:cpt-cf-oagw-flow-plugin-create:p1:inst-pl-create-dup-else + let written = self + .store + .insert_plugin(tenant_id, &plugin) + .map_err(ServiceError::from)?; + // @cpt-end:cpt-cf-oagw-flow-plugin-create:p1:inst-pl-create-dup-else + + self.cache.flush_for(tenant_id); + // @cpt-begin:cpt-cf-oagw-flow-plugin-create:p1:inst-pl-create-return + Ok(written) + // @cpt-end:cpt-cf-oagw-flow-plugin-create:p1:inst-pl-create-return + } + + /// Reads one custom plugin: `GET /oagw/v1/plugins/{id}`. + /// + /// # Errors + /// + /// Returns the 404 row when no row of the calling tenant matches, which + /// is also the answer for a named built-in plugin, which has no row. + #[allow(clippy::result_large_err)] + pub fn read_plugin(&self, tenant_id: Uuid, id: Uuid) -> Result { + // @cpt-begin:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-scope + let resolved = self.store.get_plugin(tenant_id, id); + // @cpt-end:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-scope + // @cpt-begin:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-404-if + let Some(row) = resolved else { + // @cpt-begin:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-404-return + // RETURN 404: a missing identifier, a foreign one, and a named + // plugin without a row are deliberately indistinguishable. + return Err(ServiceError::from(scoping::path_miss())); + // @cpt-end:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-404-return + }; + // @cpt-end:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-404-if + // @cpt-begin:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-404-else + // @cpt-begin:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-assemble + // The row as stored: the configuration schema and the source are + // carried verbatim and never re-rendered. + // @cpt-end:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-assemble + // @cpt-end:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-404-else + // @cpt-begin:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-return + Ok(row) + // @cpt-end:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-return + } + + /// Lists the custom plugins of the calling tenant: + /// `GET /oagw/v1/plugins`. + /// + /// # Errors + /// + /// Returns a validation error naming the offending parameter. + #[allow(clippy::result_large_err)] + pub fn list_plugins(&self, tenant_id: Uuid, query: &str) -> Result, ServiceError> { + // @cpt-begin:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-scope + let parsed = odata::parse(ListKind::Plugin, query).map_err(ServiceError::from)?; + let scan = self.store.list_plugins(tenant_id); + // @cpt-end:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-scope + // @cpt-begin:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-return + Ok(odata::apply_plugin(&parsed, scan)) + // @cpt-end:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-return + } + + /// Reads the Starlark source of one custom plugin: + /// `GET /oagw/v1/plugins/{id}/source`. + /// + /// # Errors + /// + /// Returns the 404 row when no row of the calling tenant matches. + #[allow(clippy::result_large_err)] + pub fn read_plugin_source(&self, tenant_id: Uuid, id: Uuid) -> Result { + // @cpt-begin:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-scope + let resolved = self.store.get_plugin(tenant_id, id); + // @cpt-end:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-scope + // @cpt-begin:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-404-if + let Some(row) = resolved else { + // @cpt-begin:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-404-return + return Err(ServiceError::from(scoping::path_miss())); + // @cpt-end:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-404-return + }; + // @cpt-end:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-404-if + // @cpt-begin:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-404-else + // @cpt-begin:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-assemble + // The source path returns the stored source alone and no other member + // of the row. + // @cpt-end:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-assemble + // @cpt-end:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-404-else + // @cpt-begin:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-return + Ok(row.plugin.source_code) + // @cpt-end:cpt-cf-oagw-flow-plugin-read-source:p1:inst-pl-read-return + } + + /// Deletes one custom plugin: `DELETE /oagw/v1/plugins/{id}`. + /// + /// The reference scan runs before the write and answers 409 when any + /// binding row or upstream `auth_plugin_uuid` still carries the plugin. + /// + /// # Errors + /// + /// Returns the 404 row when no row of the calling tenant matches, the 409 + /// in-use variant when a reference remains, and a storage failure when the + /// deletion could not be applied. + #[allow(clippy::result_large_err)] + pub fn delete_plugin(&self, tenant_id: Uuid, id: Uuid) -> Result { + // @cpt-begin:cpt-cf-oagw-flow-plugin-delete:p1:inst-pl-del-scope + let resolved = self.store.get_plugin(tenant_id, id); + // @cpt-end:cpt-cf-oagw-flow-plugin-delete:p1:inst-pl-del-scope + // @cpt-begin:cpt-cf-oagw-flow-plugin-delete:p1:inst-pl-del-404-if + let Some(_) = resolved else { + // @cpt-begin:cpt-cf-oagw-flow-plugin-delete:p1:inst-pl-del-404-return + // RETURN 404, indistinguishable between the three causes. + return Err(ServiceError::from(scoping::path_miss())); + // @cpt-end:cpt-cf-oagw-flow-plugin-delete:p1:inst-pl-del-404-return + }; + // @cpt-end:cpt-cf-oagw-flow-plugin-delete:p1:inst-pl-del-404-if + // @cpt-begin:cpt-cf-oagw-flow-plugin-delete:p1:inst-pl-del-404-else + // @cpt-begin:cpt-cf-oagw-flow-plugin-delete:p1:inst-pl-del-inuse + // @cpt-dod:cpt-cf-oagw-dod-plugin-inuse-gc:p1 + let in_use = self.store.plugin_in_use(tenant_id, id); + // @cpt-end:cpt-cf-oagw-flow-plugin-delete:p1:inst-pl-del-inuse + // @cpt-end:cpt-cf-oagw-flow-plugin-delete:p1:inst-pl-del-404-else + + // @cpt-begin:cpt-cf-oagw-flow-plugin-delete:p1:inst-pl-del-inuse-if + // @cpt-begin:cpt-cf-oagw-state-plugin-lifecycle:p1:inst-state-inuse-guard + // @cpt-begin:cpt-cf-oagw-algo-plugin-inuse-gc:p1:inst-inuse-delete-if + if in_use { + // @cpt-begin:cpt-cf-oagw-flow-plugin-delete:p1:inst-pl-del-inuse-return + // @cpt-begin:cpt-cf-oagw-algo-plugin-inuse-gc:p1:inst-inuse-delete-return + // RETURN 409 PluginInUse: the row and its gc_eligible_at are left + // exactly as the reference scan found them. + return Err(ServiceError::from(plugin_def::plugin_in_use())); + // @cpt-end:cpt-cf-oagw-algo-plugin-inuse-gc:p1:inst-inuse-delete-return + // @cpt-end:cpt-cf-oagw-flow-plugin-delete:p1:inst-pl-del-inuse-return + } + // @cpt-end:cpt-cf-oagw-algo-plugin-inuse-gc:p1:inst-inuse-delete-if + // @cpt-end:cpt-cf-oagw-state-plugin-lifecycle:p1:inst-state-inuse-guard + // @cpt-end:cpt-cf-oagw-flow-plugin-delete:p1:inst-pl-del-inuse-if + // @cpt-begin:cpt-cf-oagw-flow-plugin-delete:p1:inst-pl-del-inuse-else + // @cpt-begin:cpt-cf-oagw-algo-plugin-inuse-gc:p1:inst-inuse-delete-else + // @cpt-begin:cpt-cf-oagw-algo-plugin-inuse-gc:p1:inst-inuse-continue + let deleted = self + .store + .delete_plugin(tenant_id, id) + .map_err(ServiceError::from)?; + // @cpt-end:cpt-cf-oagw-algo-plugin-inuse-gc:p1:inst-inuse-continue + // @cpt-end:cpt-cf-oagw-algo-plugin-inuse-gc:p1:inst-inuse-delete-else + // @cpt-end:cpt-cf-oagw-flow-plugin-delete:p1:inst-pl-del-inuse-else + + self.cache.flush_for(tenant_id); + // @cpt-begin:cpt-cf-oagw-flow-plugin-delete:p1:inst-pl-del-return + Ok(deleted) + // @cpt-end:cpt-cf-oagw-flow-plugin-delete:p1:inst-pl-del-return + } + + /// Runs one pass of the periodic garbage-collection job of §1.4. + /// + /// The pass marks every custom row whose reference set is empty and which + /// carries no marking, then deletes the rows whose TTL has elapsed and + /// whose reference set is still empty when it runs. The cache is flushed + /// for the rows the pass removed, so a resolution no longer answers for a + /// row the store no longer holds. + /// + /// # Errors + /// + /// Returns the storage failure when the pass could not be applied, which + /// leaves every row exactly as it was. + #[allow(clippy::result_large_err)] + pub fn run_plugin_garbage_collection(&self) -> Result { + self.run_plugin_garbage_collection_at(crate::store::unix_now()) + } + + /// Runs one pass of the job at an explicit instant, which the tests use to + /// place a row on either side of the TTL. + /// + /// # Errors + /// + /// Returns the storage failure the pass answers, which leaves every row + /// exactly as it was. + #[allow(clippy::result_large_err)] + pub fn run_plugin_garbage_collection_at(&self, now: u64) -> Result { + let report = self.store.run_plugin_garbage_collection(now); + if !report.collected.is_empty() { + self.cache.flush(); + } + Ok(report) + } + + /// The ancestor bindings the chain holds for one upstream row, at a depth + /// greater than the calling tenant's. + /// + /// The row participates in the hierarchy through its own immutable alias, + /// which the walk matches against the chain. + #[allow(clippy::result_large_err)] + fn upstream_ancestor_bindings( + &self, + tenant_id: Uuid, + ancestors: &[Uuid], + row: &UpstreamRow, + ) -> Result, ServiceError> { + let Some(alias) = row + .upstream + .alias + .as_deref() + .and_then(|alias| Alias::parse(alias).ok()) + else { + return Ok(Vec::new()); + }; + self.ancestor_bindings(tenant_id, ancestors, &alias) + } + + /// The ancestor bindings the chain holds for one normalized alias, at a + /// depth greater than the calling tenant's. + /// + /// An unordered or cyclic chain is a storage failure: the operation fails + /// closed with the platform 500 problem shape and writes nothing, exactly + /// as an unavailable chain does. The per-element reads are the + /// tenant-scoped reads `cpt-cf-oagw-algo-tenant-chain-walk` issues; a + /// tenant whose rows the calling tenant cannot read is never a candidate. + #[allow(clippy::result_large_err)] + fn ancestor_bindings( + &self, + tenant_id: Uuid, + ancestors: &[Uuid], + alias: &Alias, + ) -> Result, ServiceError> { + let candidates = chain::walk_candidates(&self.store, tenant_id, ancestors, alias) + .map_err(|error| ServiceError::Storage { + reason: error.to_string(), + })?; + Ok(candidates + .iter() + .filter(|candidate| candidate.depth > 0) + .map(|candidate| AncestorBinding { + tenant_id: candidate.tenant_id, + depth: candidate.depth, + upstream_id: candidate.upstream_id, + enabled: candidate.enabled, + contributed: shadow::contributed(&candidate.row.upstream), + }) + .collect()) + } + + /// The ancestor route bindings one route row participates in. + /// + /// A route inherits through its upstream's alias: the ancestor upstreams + /// the walk matches on that alias are the ones whose routes a resolution + /// reads, so every route those upstreams hold is an ancestor of the row. + /// An owning upstream the store cannot resolve — which a validated route + /// reference cannot produce — contributes no ancestor, because no ancestor + /// value can then be obtained for the row. + #[allow(clippy::result_large_err)] + fn route_ancestor_bindings( + &self, + tenant_id: Uuid, + ancestors: &[Uuid], + row: &RouteRow, + ) -> Result, ServiceError> { + let Some(owner) = self.store.get_upstream(tenant_id, row.route.upstream_id) else { + return Ok(Vec::new()); + }; + let Some(alias) = owner + .upstream + .alias + .as_deref() + .and_then(|alias| Alias::parse(alias).ok()) + else { + return Ok(Vec::new()); + }; + let candidates = chain::walk_candidates(&self.store, tenant_id, ancestors, &alias) + .map_err(|error| ServiceError::Storage { + reason: error.to_string(), + })?; + let mut bindings = Vec::new(); + for candidate in candidates.iter().filter(|candidate| candidate.depth > 0) { + for route in self + .store + .routes_of_upstream(candidate.tenant_id, candidate.upstream_id) + { + bindings.push(AncestorBinding { + tenant_id: candidate.tenant_id, + depth: candidate.depth, + upstream_id: candidate.upstream_id, + enabled: !matches!(route.route.enabled, Some(false)), + contributed: shadow::route_contributed(&route.route), + }); + } + } + Ok(bindings) + } +} + +/// The 409 the alias uniqueness constraint answers with. +fn alias_conflict() -> DomainError { + // @cpt-dod:cpt-cf-oagw-dod-alias-derivation:p1 + DomainError::gateway( + ErrorKind::AliasConflict, + "another upstream of the calling tenant already holds the alias", + ) +} + +/// The sharing-bearing families one upstream body carries, in the order the +/// decision table reads them. +/// +/// `tags` carries no sharing field and never reaches the decision; a family +/// the body omits is written by nobody and takes no part in it either. +fn carried_families(value: &Upstream) -> Vec { + let mut carried = Vec::new(); + if value.auth.is_some() { + carried.push(Family::Auth); + } + if value.rate_limit.is_some() { + carried.push(Family::RateLimit); + } + if value.plugins.is_some() { + carried.push(Family::Plugins); + } + if value.cors.is_some() { + carried.push(Family::Cors); + } + carried +} + +/// The sharing-bearing families one route body carries. +/// +/// A route carries no `auth` family, so there is no inherited auth +/// configuration for a descendant route to override, and `tags` carries no +/// sharing field. +fn carried_route_families(value: &Route) -> Vec { + let mut carried = Vec::new(); + if value.rate_limit.is_some() { + carried.push(Family::RateLimit); + } + if value.plugins.is_some() { + carried.push(Family::Plugins); + } + if value.cors.is_some() { + carried.push(Family::Cors); + } + carried +} + +/// The 400 an `enforce` refusal answers with: a validation error naming the +/// family the ancestor enforces, carrying no request-body value and no other +/// detail of the ancestor's configuration. +fn enforced_family_error(family: Family) -> DomainError { + DomainError::gateway( + ErrorKind::ValidationError, + format!( + "the {} family is enforced by an ancestor and cannot be set", + family_label(family) + ), + ) +} + +/// The schema-member name of one family, which is how a refusal names it. +const fn family_label(family: Family) -> &'static str { + match family { + Family::Auth => "auth", + Family::RateLimit => "rate_limit", + Family::Plugins => "plugins", + Family::Cors => "cors", + } +} + +/// The 409 the match uniqueness constraint answers with, naming the route that +/// holds the key. The detail carries an identifier, never a body value. +fn conflict(holder: Uuid) -> DomainError { + DomainError::gateway( + ErrorKind::MatchConflict, + format!("route {holder} already holds this match rule"), + ) +} + +/// The anonymous GTS instance identifier of one upstream row. +#[must_use] +pub fn upstream_instance(id: Uuid) -> String { + gts::gts_instance(gts::UPSTREAM_TYPE, id) +} + +/// The anonymous GTS instance identifier of one route row. +#[must_use] +pub fn route_instance(id: Uuid) -> String { + gts::gts_instance(gts::ROUTE_TYPE, id) +} diff --git a/gears/system/oagw/oagw/src/control_plane/shadow.rs b/gears/system/oagw/oagw/src/control_plane/shadow.rs new file mode 100644 index 0000000..05f43e6 --- /dev/null +++ b/gears/system/oagw/oagw/src/control_plane/shadow.rs @@ -0,0 +1,245 @@ +//! Ancestor alias resolution and shadowing — `cpt-cf-oagw-algo-alias-shadow-resolve`. +//! +//! The walk supplies the ordered candidate set; this module turns it into the +//! three answers the merge needs: the routing target, which is the candidate +//! at the smallest depth, the ordered ancestor bindings, which are the +//! remaining candidates from the most distant to the least, and the effective +//! `enabled` state, which is the conjunction of the target's own flag with the +//! flag of every matched ancestor row regardless of the sharing modes those +//! rows declare. +//! +//! A candidate whose stored alias disagrees with the resolved one on the +//! normalized form is never a candidate: [`shadow_resolve`] re-derives the +//! stored alias through [`Alias::parse`] and compares the value objects, so +//! case, a trailing dot, and a port can never make a candidate set disagree +//! with a stored alias. +//! +//! The target decides where a request goes. The bindings decide what the +//! request is subject to. A shadowing descendant can replace the target and +//! can replace the values the ancestor marked `inherit`, and it can never +//! replace the values the ancestor marked `enforce` or raise the effective +//! `enabled` state. + +// @cpt-dod:cpt-cf-oagw-dod-alias-shadowing:p1 + +use toolkit_macros::domain_model; + +use crate::control_plane::chain::{ChainCandidate, modes_of}; +use crate::domain::alias::Alias; +use crate::domain::effective::{AncestorBinding, ContributedFamilies, Family, FamilyContribution}; +use crate::domain::route::Route; +use crate::domain::upstream::{SharingMode, Upstream}; + +/// The four families a sharing-mode decision addresses, in merge order. +const FAMILIES: [Family; 4] = [ + Family::Auth, + Family::RateLimit, + Family::Plugins, + Family::Cors, +]; + +/// The answer of one alias shadow resolve. +#[domain_model] +#[derive(Debug, Clone, PartialEq)] +pub struct ShadowResolution { + /// The candidate at the smallest depth: the routing target. + pub target: ChainCandidate, + /// The remaining candidates, most distant first. + pub bindings: Vec, + /// The target's own `enabled` flag conjoined with every matched ancestor + /// row's flag. + pub enabled: bool, +} + +/// Resolves the ordered candidate set against the normalized alias. +/// +/// `None` is the not-found outcome an empty candidate set produces, which the +/// consumer answers 404; the merge runs only on a shadow resolution. +#[must_use] +pub fn shadow_resolve(candidates: &[ChainCandidate], alias: &Alias) -> Option { + // @cpt-begin:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-compare + let matched: Vec<&ChainCandidate> = candidates + .iter() + .filter(|candidate| stored_alias(&candidate.row.upstream).as_ref() == Some(alias)) + .collect(); + // @cpt-end:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-compare + + // @cpt-begin:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-empty-if + if matched.is_empty() { + // @cpt-begin:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-empty-return + return None; + // @cpt-end:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-empty-return + } + // @cpt-end:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-empty-if + + // @cpt-begin:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-else + // @cpt-begin:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-target + // The candidates arrive ordered by increasing depth, so the closest match + // wins and a descendant's row shadows an ancestor's. + let target = matched.iter().min_by_key(|candidate| candidate.depth).copied()?; + // @cpt-end:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-target + + // @cpt-begin:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-bindings + let mut ancestors: Vec<&ChainCandidate> = matched + .iter() + .copied() + .filter(|candidate| candidate.depth != target.depth) + .collect(); + ancestors.sort_by_key(|candidate| std::cmp::Reverse(candidate.depth)); + // @cpt-end:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-bindings + + // @cpt-begin:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-loop + let bindings: Vec = ancestors + .iter() + .map(|candidate| AncestorBinding { + tenant_id: candidate.tenant_id, + depth: candidate.depth, + upstream_id: candidate.upstream_id, + enabled: candidate.enabled, + contributed: contributed(&candidate.row.upstream), + }) + .collect(); + // @cpt-end:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-loop + + // @cpt-begin:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-enabled + // `enabled` is a row-level state and carries no sharing field, so one + // disabled ancestor disables the resource for every descendant without a + // write, and no descendant write can raise it. + let enabled = matched.iter().all(|candidate| candidate.enabled); + // @cpt-end:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-enabled + + // @cpt-begin:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-return + Some(ShadowResolution { + target: target.clone(), + bindings, + enabled, + }) + // @cpt-end:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-return + // @cpt-end:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-else +} + +/// The normalized alias one stored upstream row carries, when it carries one. +fn stored_alias(upstream: &Upstream) -> Option { + upstream + .alias + .as_deref() + .and_then(|stored| Alias::parse(stored).ok()) +} + +/// The families one ancestor row contributes to a merge. +/// +/// `enforce` and `inherit` both carry the row's value into the merge — one as +/// a forced value, the other as the base a descendant may override — and +/// `private` carries nothing at all, so the value is never read into a result, +/// copied onto any row, or echoed in any answer. `tags` carries no sharing +/// field and always contributes. +#[must_use] +pub fn contributed(upstream: &Upstream) -> ContributedFamilies { + let modes = modes_of(upstream); + let mut families = ContributedFamilies { + auth: None, + rate_limit: None, + plugins: None, + cors: None, + tags: Some(upstream.tags.clone()), + }; + for family in FAMILIES { + let mode = modes.mode_of(family); + // @cpt-begin:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-enforce-if + if mode == SharingMode::Enforce { + // @cpt-begin:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-enforce + carry(&mut families, family, mode, upstream); + // @cpt-end:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-enforce + } + // @cpt-end:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-enforce-if + // @cpt-begin:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-inherit-if + else if mode == SharingMode::Inherit { + // @cpt-begin:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-inherit + carry(&mut families, family, mode, upstream); + // @cpt-end:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-inherit + } + // @cpt-end:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-inherit-if + // @cpt-begin:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-private-else + else { + // @cpt-begin:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-private + // Carries nothing for that family: no field of `families` is + // written, so the value cannot reach any result. + // @cpt-end:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-private + } + // @cpt-end:cpt-cf-oagw-algo-alias-shadow-resolve:p1:inst-shadow-private-else + } + families +} + +/// The families one ancestor route row contributes to a merge. +/// +/// A route carries no authentication family, so `auth` is always `None` for a +/// route-layer binding, and `tags` always contributes. The mode a route +/// declares is read from the family's own `sharing` member, which the shipped +/// route schema defaults to `private`. +#[must_use] +pub fn route_contributed(route: &Route) -> ContributedFamilies { + let share = |sharing: Option| sharing.unwrap_or(SharingMode::Private); + ContributedFamilies { + auth: None, + rate_limit: route.rate_limit.as_ref().and_then(|limit| { + visible(share(limit.sharing), limit.clone()) + }), + plugins: route.plugins.as_ref().and_then(|plugins| { + visible(share(plugins.sharing), plugins.clone()) + }), + cors: route.cors.as_ref().and_then(|cors| visible(share(cors.sharing), cors.clone())), + tags: Some(route.tags.clone()), + } +} + +/// The contribution one family makes when its mode admits one. +fn visible(mode: SharingMode, value: V) -> Option> { + match mode { + SharingMode::Enforce | SharingMode::Inherit => Some(FamilyContribution { mode, value }), + SharingMode::Private => None, + } +} + +/// Writes one family's contribution when the row carries the family at all. +fn carry( + families: &mut ContributedFamilies, + family: Family, + mode: SharingMode, + upstream: &Upstream, +) { + match family { + Family::Auth => { + if let Some(auth) = &upstream.auth { + families.auth = Some(FamilyContribution { + mode, + value: auth.clone(), + }); + } + } + Family::RateLimit => { + if let Some(rate_limit) = &upstream.rate_limit { + families.rate_limit = Some(FamilyContribution { + mode, + value: rate_limit.clone(), + }); + } + } + Family::Plugins => { + if let Some(plugins) = &upstream.plugins { + families.plugins = Some(FamilyContribution { + mode, + value: plugins.clone(), + }); + } + } + Family::Cors => { + if let Some(cors) = &upstream.cors { + families.cors = Some(FamilyContribution { + mode, + value: cors.clone(), + }); + } + } + } +} diff --git a/gears/system/oagw/oagw/src/control_plane/sharing.rs b/gears/system/oagw/oagw/src/control_plane/sharing.rs new file mode 100644 index 0000000..691f4d1 --- /dev/null +++ b/gears/system/oagw/oagw/src/control_plane/sharing.rs @@ -0,0 +1,305 @@ +//! Sharing-mode and permission decision — `cpt-cf-oagw-algo-sharing-mode-decision`. +//! +//! A descendant write that names a family an ancestor also configures is not +//! an ordinary write: the ancestor's sharing mode for that family decides +//! whether the descendant's value is its own configuration, overrides a base +//! the ancestor supplied, or is refused. This module answers that question +//! for every family a body carries, in one pass, and refuses the whole write +//! when any one family refuses, so a caller is never made to retry once per +//! blocked family. +//! +//! The four permissions the decision consults are the descendant override +//! permissions of DESIGN §3.2 — `oagw:upstream:bind`, `oagw:upstream:override_auth`, +//! `oagw:upstream:override_rate`, and `oagw:upstream:add_plugins`. They are +//! evaluated here, inside this feature's flows, and denied by default: a +//! permission the calling token does not literally carry is a permission the +//! caller does not hold, and an unknown permission literal is never held +//! either. CORS carries no permission at all, so for that family the sharing +//! mode alone decides. + +// @cpt-dod:cpt-cf-oagw-dod-sharing-mode-decision:p1 +// @cpt-dod:cpt-cf-oagw-dod-descendant-override-permissions:p1 + +use toolkit_security::SecurityContext; + +use crate::control_plane::effective::strictest; +use crate::domain::effective::{AncestorBinding, Family}; +use crate::domain::upstream::SharingMode; +use crate::gts; + +/// The four descendant override permissions one token may carry, resolved from +/// the token's own scopes. +/// +/// The platform grants and stores no permission of its own for these four, so +/// the set is exactly what the bearer token asserts: a scope is held when the +/// token names it, and the platform's unrestricted sentinel — a token whose +/// scope list is `["*"]` — names every one of them. Every other token denies +/// by default, which is the posture DESIGN §3.2 states for a descendant that +/// holds none of them: it resolves, proxies, and inherits, and cannot change +/// what it inherits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OverridePermissions { + /// `oagw:upstream:bind` — the bind-style create against an ancestor's alias. + bind: bool, + /// `oagw:upstream:override_auth` — the auth override of an `inherit` family. + auth: bool, + /// `oagw:upstream:override_rate` — an own rate limit under the minimum. + rate: bool, + /// `oagw:upstream:add_plugins` — appending own items to an inherited chain. + plugins: bool, +} + +/// The platform's unrestricted-scope sentinel, documented on +/// `SecurityContext::token_scopes` as first-party and unrestricted. +const UNRESTRICTED: &str = "*"; + +impl OverridePermissions { + /// Reads the four permissions off one authenticated subject. + #[must_use] + pub fn of(context: &SecurityContext) -> Self { + let scopes = context.token_scopes(); + let holds = |permission: &str| { + scopes + .iter() + .any(|scope| scope == permission || scope == UNRESTRICTED) + }; + Self { + bind: holds(gts::PERMISSION_BIND), + auth: holds(gts::PERMISSION_OVERRIDE_AUTH), + rate: holds(gts::PERMISSION_OVERRIDE_RATE), + plugins: holds(gts::PERMISSION_ADD_PLUGINS), + } + } + + /// The set that holds none of the four: the deny-by-default answer for a + /// token whose scope list asserts nothing this feature recognizes. + #[must_use] + pub const fn none() -> Self { + Self { + bind: false, + auth: false, + rate: false, + plugins: false, + } + } + + /// Whether the token carries one permission literal. + /// + /// A literal outside the four is never held: this feature denies by + /// default, and an unknown permission is not one it can grant. + #[must_use] + pub fn holds(&self, permission: &str) -> bool { + match permission { + gts::PERMISSION_BIND => self.bind, + gts::PERMISSION_OVERRIDE_AUTH => self.auth, + gts::PERMISSION_OVERRIDE_RATE => self.rate, + gts::PERMISSION_ADD_PLUGINS => self.plugins, + _ => false, + } + } +} + +/// The decision one family received. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DecisionKind { + /// The descendant's value is its own configuration; no ancestor value + /// exists to inherit and no override permission is consumed. + Own, + /// The ancestor's value is the base and the body's value overrides it. + InheritBase, + /// The ancestor's value is applied in every resolution and nothing of the + /// body's reaches the row. + Forced, +} + +impl DecisionKind { + /// Whether the decision lets the body's value reach the row. + #[must_use] + pub const fn writes(self) -> bool { + matches!(self, Self::Own | Self::InheritBase) + } +} + +/// Why one family refused the write. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Refusal { + /// 403: the family's override permission is not held, so the descendant + /// uses the ancestor's value as-is. + Permission { + /// The family whose override permission was not held. + family: Family, + }, + /// 400: the ancestor marks the family `enforce` and the body carries a + /// value for it. + Enforced { + /// The family the ancestor enforces. + family: Family, + }, +} + +impl Refusal { + /// The family the refusal names. + #[must_use] + pub const fn family(self) -> Family { + match self { + Self::Permission { family } | Self::Enforced { family } => family, + } + } + + /// The override permission the refusal answers with, when it is a + /// permission refusal. + #[must_use] + pub const fn permission(self) -> Option<&'static str> { + match self { + Self::Permission { family } => family.override_permission(), + Self::Enforced { .. } => None, + } + } +} + +/// One family's decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FamilyDecision { + /// The family the decision answers. + pub family: Family, + /// The decision the ancestor's mode and the permission set produced. + pub kind: DecisionKind, +} + +/// The per-family answers of one write. +/// +/// Only the families the body carries appear: a family the body omits is +/// written by nobody and takes no part in the decision. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Decisions { + /// One decision per family the body carries. + pub families: Vec, +} + +impl Decisions { + /// The decision one carried family received. + #[must_use] + pub fn kind_of(&self, family: Family) -> Option { + self.families + .iter() + .find(|decision| decision.family == family) + .map(|decision| decision.kind) + } + + /// Whether the body's value for one family may reach the row. + /// + /// A family the body does not carry is never written, so it answers + /// `false` here as well: there is no value to write. + #[must_use] + pub fn writes(&self, family: Family) -> bool { + self.kind_of(family).is_some_and(DecisionKind::writes) + } + + /// Whether an ancestor forces one family, so nothing of the body's may + /// reach the row. + #[must_use] + pub fn forced(&self, family: Family) -> bool { + self.kind_of(family) == Some(DecisionKind::Forced) + } +} + +/// Decides every family a write body carries against the ancestor bindings the +/// chain walk resolved. +/// +/// The refusal, when there is one, is the first in the order the feature fixes: +/// the permission 403 before any `enforce` 400, so a caller that lacks a +/// permission never learns which families its ancestor enforces. +/// +/// # Errors +/// +/// Returns the refusal of the highest-priority blocked family. +pub fn decide( + ancestors: &[AncestorBinding], + carried: &[Family], + permissions: &OverridePermissions, +) -> Result { + // @cpt-begin:cpt-cf-oagw-algo-sharing-mode-decision:p1:inst-decide-loop + let mut families: Vec = Vec::new(); + let mut refusals: Vec = Vec::new(); + for family in carried { + // A family no ancestor contributes is the descendant's own + // configuration whether the ancestor holds it `private` or not at all: + // a `private` value is never carried into a binding, so both arrive + // here as an empty contribution. + let modes: Vec = ancestors + .iter() + .filter(|binding| binding.contributes(*family)) + .map(|binding| binding.mode_of(*family)) + .collect(); + + // @cpt-begin:cpt-cf-oagw-algo-sharing-mode-decision:p1:inst-decide-noancestor-if + let decided = if modes.is_empty() { + // @cpt-begin:cpt-cf-oagw-algo-sharing-mode-decision:p1:inst-decide-noancestor + Ok(DecisionKind::Own) + // @cpt-end:cpt-cf-oagw-algo-sharing-mode-decision:p1:inst-decide-noancestor + } else { + // @cpt-begin:cpt-cf-oagw-algo-sharing-mode-decision:p1:inst-decide-row + decide_row(*family, &modes, permissions) + // @cpt-end:cpt-cf-oagw-algo-sharing-mode-decision:p1:inst-decide-row + }; + // @cpt-end:cpt-cf-oagw-algo-sharing-mode-decision:p1:inst-decide-noancestor-if + + match decided { + Ok(kind) => families.push(FamilyDecision { + family: *family, + kind, + }), + Err(refusal) => refusals.push(refusal), + } + } + // @cpt-end:cpt-cf-oagw-algo-sharing-mode-decision:p1:inst-decide-loop + + // @cpt-begin:cpt-cf-oagw-algo-sharing-mode-decision:p1:inst-decide-refusal-if + if let Some(refusal) = first(refusals) { + // @cpt-begin:cpt-cf-oagw-algo-sharing-mode-decision:p1:inst-decide-refusal-return + return Err(refusal); + // @cpt-end:cpt-cf-oagw-algo-sharing-mode-decision:p1:inst-decide-refusal-return + } + // @cpt-end:cpt-cf-oagw-algo-sharing-mode-decision:p1:inst-decide-refusal-if + + // @cpt-begin:cpt-cf-oagw-algo-sharing-mode-decision:p1:inst-decide-return + Ok(Decisions { families }) + // @cpt-end:cpt-cf-oagw-algo-sharing-mode-decision:p1:inst-decide-return +} + +/// The first refusal in the order the feature fixes: the permission 403 before +/// any `enforce` 400. +fn first(refusals: Vec) -> Option { + refusals + .iter() + .find(|refusal| matches!(refusal, Refusal::Permission { .. })) + .copied() + .or_else(|| refusals.into_iter().next()) +} + +/// Decides one family from the table, over the modes of every ancestor that +/// contributes it. +/// +/// The strictest contributed mode decides: one `enforce` ancestor is enough to +/// refuse the value, and one `inherit` ancestor is enough to demand the +/// permission. A `private` contribution never reaches this row, because a +/// `private` family is never carried into a binding. +fn decide_row( + family: Family, + modes: &[SharingMode], + permissions: &OverridePermissions, +) -> Result { + let mode = strictest(modes.iter().copied()); + match mode { + SharingMode::Enforce => Err(Refusal::Enforced { family }), + SharingMode::Inherit => match family.override_permission() { + // CORS carries no permission, so its mode alone decides. + None => Ok(DecisionKind::InheritBase), + Some(permission) if permissions.holds(permission) => Ok(DecisionKind::InheritBase), + Some(_) => Err(Refusal::Permission { family }), + }, + // Unreachable from the walk: a `private` family contributes nothing. + // Held as `own` so the table's first row stays total. + SharingMode::Private => Ok(DecisionKind::Own), + } +} diff --git a/gears/system/oagw/oagw/src/control_plane/validation.rs b/gears/system/oagw/oagw/src/control_plane/validation.rs new file mode 100644 index 0000000..f91c35f --- /dev/null +++ b/gears/system/oagw/oagw/src/control_plane/validation.rs @@ -0,0 +1,1031 @@ +//! Request validation — `cpt-cf-oagw-algo-request-validate`. +//! +//! The two frozen JSON Schemas are the primary check: one validator per +//! resource kind per write kind (four in all; the upstream create and +//! replacement schemas are the same file, the route replacement schema is the +//! same file with `upstream_id` removed from the root `required` array). The +//! code-level families that follow are the ones the schema cannot express — +//! the §1.5 overlays, the endpoint-pool homogeneity, the scheme admission +//! posture, and the shape checks the schema states only as `format`. +//! +//! Every failing property is accumulated into **one** +//! `DomainError::gateway(ErrorKind::ValidationError, ..)` whose detail names +//! the failing properties, comma-separated, and never carries a request body +//! value: a name only, such as `unknown property 'x' at root` or +//! `server.endpoints[1].port`. + +use std::borrow::Cow; + +use serde_json::Value; +use uuid::Uuid; + +use crate::config::OagwConfig; +use crate::domain::alias::EndpointHost; +use crate::domain::error::{DomainError, ErrorKind}; +use crate::domain::route::{MatchConfig, Route}; +use crate::domain::scheme::Scheme; +use crate::domain::upstream::{ + AuthConfig, CorsConfig, HeadersConfig, PluginsConfig, RateLimitConfig, ServerConfig, Upstream, +}; +use crate::gts; + +use jsonschema::error::ValidationErrorKind; +use jsonschema::paths::{Location, LocationSegment}; + +/// The shipped upstream schema, read from the frozen file. +pub const UPSTREAM_SCHEMA: &str = include_str!("../../../docs/schemas/upstream.v1.schema.json"); +/// The shipped route schema, read from the frozen file. +pub const ROUTE_SCHEMA: &str = include_str!("../../../docs/schemas/route.v1.schema.json"); + +/// Declared schema default for `server.endpoints[].port`. +const DEFAULT_ENDPOINT_PORT: u16 = 443; +/// The seven literals `definitions.cors.allowed_methods` admits. +const CORS_METHODS: [&str; 7] = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]; +/// The five literals `definitions.http_match.methods` admits. +const HTTP_METHODS: [&str; 5] = ["GET", "POST", "PUT", "DELETE", "PATCH"]; +/// Scheme prefix a credential reference must carry; never resolved here. +const CREDENTIAL_SCHEME: &str = "cred://"; +/// Route root properties a create may carry: the route schema root is open, +/// so the allowed set is enforced here. +const ROUTE_ROOT_CREATE: [&str; 8] = [ + "tags", + "upstream_id", + "match", + "plugins", + "rate_limit", + "cors", + "priority", + "enabled", +]; +/// Route root properties a replacement may carry: the create set with +/// `upstream_id` replaced by `id`. +const ROUTE_ROOT_REPLACEMENT: [&str; 8] = [ + "id", + "tags", + "match", + "plugins", + "rate_limit", + "cors", + "priority", + "enabled", +]; + +/// Which resource kind a request body carries. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ResourceKind { + /// An `oagw_upstream` write. + Upstream, + /// An `oagw_route` write. + Route, +} + +impl ResourceKind { + /// Lowercase singular name of the resource kind. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Upstream => "upstream", + Self::Route => "route", + } + } +} + +/// Whether a request body creates a new row or replaces an addressed one. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum WriteKind { + /// `POST`: the full required set applies and `id` is system-generated. + Create, + /// `PUT`: the required set is narrowed and `enabled` carries forward. + Replacement, +} + +/// The validated body of an upstream write. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, PartialEq)] +pub struct ValidatedUpstream { + /// The identifier the body stated, if any. A create must state none; a + /// replacement's must equal the addressed row, which + /// `cpt-cf-oagw-algo-put-replace-diff` confirms. + pub stated_id: Option, + /// The validated configuration, with `id` left for the caller to assign. + pub value: Upstream, +} + +/// The validated body of a route write. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, PartialEq)] +pub struct ValidatedRoute { + /// The identifier the body stated, if any; see [`ValidatedUpstream`]. + pub stated_id: Option, + /// The validated configuration, with `id` left for the caller to assign. + pub value: Route, +} + +/// The four compiled validators, one per resource kind per write kind. +pub struct Validator { + upstream_create: jsonschema::Validator, + upstream_replacement: jsonschema::Validator, + route_create: jsonschema::Validator, + route_replacement: jsonschema::Validator, + allow_http_upstream: bool, +} + +/// Accumulated failing properties of one request body. +#[derive(Debug, Default)] +struct Defects { + details: Vec, +} + +/// One checked endpoint: the raw body values the pool check compares. +struct CheckedEndpoint { + scheme: Scheme, + port: u16, +} + +impl Defects { + /// Adds a detail, dropping a repeat of one already recorded. + fn add(&mut self, detail: String) { + if !self.details.contains(&detail) { + self.details.push(detail); + } + } + + /// Adds one failing property by name. + fn add_property(&mut self, property: &str) { + self.add(property.to_owned()); + } + + /// Whether every family passed. + fn is_empty(&self) -> bool { + self.details.is_empty() + } + + /// The single validation error naming every failing property. + fn into_error(self) -> DomainError { + let detail = if self.details.is_empty() { + String::from("the request body is not valid for the resource kind") + } else { + self.details.join(", ") + }; + DomainError::gateway(ErrorKind::ValidationError, detail) + } +} + +impl Validator { + #[allow(clippy::result_large_err)] + /// Compiles the four validators from the two shipped schemas. + /// + /// # Errors + /// + /// Returns a gateway [`DomainError`] when a shipped schema cannot be + /// parsed or compiled; the gear surfaces that at init, so startup fails + /// fast instead of serving an unvalidated write path. + pub fn compile(config: &OagwConfig) -> Result { + let mut upstream_schema = parse_schema(UPSTREAM_SCHEMA)?; + let mut route_schema = parse_schema(ROUTE_SCHEMA)?; + + // @cpt-dod:cpt-cf-oagw-dod-binding-model:p1 + // The feature-local override for `plugins.items`: the two shipped + // schemas declare the items as bare identifier strings, while the + // binding item this feature validates and writes is the object that + // carries a `position`, a `plugin_ref`, an optional `plugin_uuid`, and + // a configuration. The item shape is therefore not confirmed by the + // schema pass at all — the `plugins` envelope and its `sharing` enum + // stay under the shipped schema — and is validated instead by the + // binding validation, which names every failing item with its position + // and the reason. The frozen schemas are rewritten in a validation + // copy only, never on disk. + override_plugin_items(&mut upstream_schema); + override_plugin_items(&mut route_schema); + + // The upstream create and replacement schemas are the same file: the + // upstream schema declares no `required` narrowing. + let upstream_create = + compile_validator(&upstream_schema, ResourceKind::Upstream)?; + let upstream_replacement = + compile_validator(&upstream_schema, ResourceKind::Upstream)?; + + // The route replacement schema is the same JSON with `upstream_id` + // removed from the root `required` array. + let mut narrowed = route_schema.clone(); + narrow_route_replacement(&mut narrowed); + let route_create = compile_validator(&route_schema, ResourceKind::Route)?; + let route_replacement = compile_validator(&narrowed, ResourceKind::Route)?; + + Ok(Self { + upstream_create, + upstream_replacement, + route_create, + route_replacement, + allow_http_upstream: config.allow_http_upstream, + }) + } + + /// Validates an upstream body against the schema and the code-level + /// families. + /// + /// # Errors + /// + /// Returns one gateway validation error naming every failing property. +#[allow(clippy::result_large_err)] + pub fn validate_upstream( + &self, + write: WriteKind, + body: &Value, + ) -> Result { + let mut defects = Defects::default(); + + // @cpt-begin:cpt-cf-oagw-algo-request-validate:p1:inst-val-parse + self.schema_pass(ResourceKind::Upstream, write, body, &mut defects); + // @cpt-end:cpt-cf-oagw-algo-request-validate:p1:inst-val-parse + + if let Some(fields) = body.as_object() { + self.check_upstream_families(write, fields, &mut defects); + } + + // @cpt-begin:cpt-cf-oagw-algo-request-validate:p1:inst-val-fail-if + if !defects.is_empty() { + // @cpt-begin:cpt-cf-oagw-algo-request-validate:p1:inst-val-fail-return + return Err(defects.into_error()); + // @cpt-end:cpt-cf-oagw-algo-request-validate:p1:inst-val-fail-return + } + // @cpt-end:cpt-cf-oagw-algo-request-validate:p1:inst-val-fail-if + + // @cpt-begin:cpt-cf-oagw-algo-request-validate:p1:inst-val-return + build_upstream(body) + // @cpt-end:cpt-cf-oagw-algo-request-validate:p1:inst-val-return + } + + /// Validates a route body against the schema and the code-level families. + /// + /// # Errors + /// + /// Returns one gateway validation error naming every failing property. +#[allow(clippy::result_large_err)] + pub fn validate_route( + &self, + write: WriteKind, + body: &Value, + ) -> Result { + let mut defects = Defects::default(); + + self.schema_pass(ResourceKind::Route, write, body, &mut defects); + + if let Some(fields) = body.as_object() { + self.check_route_families(write, fields, &mut defects); + } + + if !defects.is_empty() { + return Err(defects.into_error()); + } + + build_route(write, body) + } + + /// Runs the schema pass and records every failing property. + /// + /// When `allow_http_upstream` lifts the posture, the `http` scheme literal + /// — which the frozen schema enum does not declare — is neutralized to + /// `https` in a validation copy only, so the two shipped validators stay + /// the only schema check; the code-level admission check runs on the + /// original body and rejects `http` when the posture is not lifted. + fn schema_pass( + &self, + kind: ResourceKind, + write: WriteKind, + body: &Value, + defects: &mut Defects, + ) { + let validator = match (kind, write) { + (ResourceKind::Upstream, WriteKind::Create) => &self.upstream_create, + (ResourceKind::Upstream, WriteKind::Replacement) => &self.upstream_replacement, + (ResourceKind::Route, WriteKind::Create) => &self.route_create, + (ResourceKind::Route, WriteKind::Replacement) => &self.route_replacement, + }; + + let subject = neutralized(body, self.allow_http_upstream); + for error in validator.iter_errors(&subject) { + defects.add(describe(&error)); + } + } + + /// The upstream code-level families, in the FEATURE's order. + fn check_upstream_families( + &self, + write: WriteKind, + fields: &serde_json::Map, + defects: &mut Defects, + ) { + // @cpt-begin:cpt-cf-oagw-algo-request-validate:p1:inst-val-unknown + check_upstream_root(write, fields, defects); + // @cpt-end:cpt-cf-oagw-algo-request-validate:p1:inst-val-unknown + + // @cpt-begin:cpt-cf-oagw-algo-request-validate:p1:inst-val-required + check_required(ResourceKind::Upstream, write, fields, defects); + // @cpt-end:cpt-cf-oagw-algo-request-validate:p1:inst-val-required + + // @cpt-begin:cpt-cf-oagw-algo-request-validate:p1:inst-val-endpoint-loop + let endpoints = fields + .get("server") + .and_then(|server| server.get("endpoints")) + .and_then(Value::as_array); + // @cpt-begin:cpt-cf-oagw-algo-request-validate:p1:inst-val-endpoint + let checked = check_endpoints(endpoints, self.allow_http_upstream, defects); + // @cpt-end:cpt-cf-oagw-algo-request-validate:p1:inst-val-endpoint + // @cpt-end:cpt-cf-oagw-algo-request-validate:p1:inst-val-endpoint-loop + + // @cpt-begin:cpt-cf-oagw-algo-request-validate:p1:inst-val-pool-if + // @cpt-begin:cpt-cf-oagw-algo-request-validate:p1:inst-val-pool + check_pool(&checked, defects); + // @cpt-end:cpt-cf-oagw-algo-request-validate:p1:inst-val-pool + // @cpt-end:cpt-cf-oagw-algo-request-validate:p1:inst-val-pool-if + + // @cpt-begin:cpt-cf-oagw-algo-request-validate:p1:inst-val-enums + check_protocol(fields, defects); + check_tags(fields.get("tags"), "tags", defects); + // @cpt-end:cpt-cf-oagw-algo-request-validate:p1:inst-val-enums + + // @cpt-begin:cpt-cf-oagw-algo-request-validate:p1:inst-val-subs + check_sub_configurations(fields, defects); + // @cpt-end:cpt-cf-oagw-algo-request-validate:p1:inst-val-subs + } + + /// The route code-level families, in the FEATURE's order. + fn check_route_families( + &self, + write: WriteKind, + fields: &serde_json::Map, + defects: &mut Defects, + ) { + check_route_root(write, fields, defects); + check_required(ResourceKind::Route, write, fields, defects); + + // @cpt-begin:cpt-cf-oagw-algo-request-validate:p1:inst-val-route-if + // @cpt-begin:cpt-cf-oagw-algo-request-validate:p1:inst-val-match + check_match(fields, defects); + // @cpt-end:cpt-cf-oagw-algo-request-validate:p1:inst-val-match + // @cpt-end:cpt-cf-oagw-algo-request-validate:p1:inst-val-route-if + + check_protocol(fields, defects); + check_tags(fields.get("tags"), "tags", defects); + check_sub_configurations(fields, defects); + } +} + +/// Parses one shipped schema document. +#[allow(clippy::result_large_err)] +fn parse_schema(text: &str) -> Result { + serde_json::from_str(text).map_err(|_| { + DomainError::gateway(ErrorKind::RouteError, "the shipped schema is not valid JSON") + }) +} + +/// Compiles one shipped schema document. +#[allow(clippy::result_large_err)] +fn compile_validator( + schema: &Value, + kind: ResourceKind, +) -> Result { + jsonschema::validator_for(schema).map_err(|_| { + DomainError::gateway( + ErrorKind::RouteError, + format!("the shipped {} schema could not be compiled", kind.as_str()), + ) + }) +} + +/// Removes `upstream_id` from the root `required` array of the route schema. +fn narrow_route_replacement(schema: &mut Value) { + let Some(required) = schema.get_mut("required").and_then(Value::as_array_mut) else { + return; + }; + required.retain(|entry| entry.as_str() != Some("upstream_id")); +} + +/// Rewrites the `plugins.items` constraint of one shipped schema to an +/// unconstrained array, so the item shape the shipped oneOf does not express +/// is left to the binding validation. +/// +/// The rewrite is total: an item that is neither an identifier nor a binding +/// object is refused by the binding validation, which names the item and its +/// position, rather than by a schema pass that could only name the path. +fn override_plugin_items(schema: &mut Value) { + let Some(items) = schema + .get_mut("properties") + .and_then(|properties| properties.get_mut("plugins")) + .and_then(|plugins| plugins.get_mut("properties")) + .and_then(|properties| properties.get_mut("items")) + else { + return; + }; + *items = serde_json::json!({ "type": "array" }); +} + +/// Rewrites every `http` endpoint scheme to `https` when the posture is +/// lifted, so the frozen schema enum admits the body. +fn neutralized(body: &Value, allow_http_upstream: bool) -> Cow<'_, Value> { + if !allow_http_upstream { + return Cow::Borrowed(body); + } + match lift_http_schemes(body) { + None => Cow::Borrowed(body), + Some(rewritten) => Cow::Owned(rewritten), + } +} + +/// Clones `body` with every `http` endpoint scheme rewritten to `https`, or +/// answers `None` when no endpoint carries it. +fn lift_http_schemes(body: &Value) -> Option { + let endpoints = body + .get("server") + .and_then(|server| server.get("endpoints")) + .and_then(Value::as_array)?; + if !endpoints + .iter() + .any(|endpoint| endpoint.get("scheme").and_then(Value::as_str) == Some("http")) + { + return None; + } + + let mut rewritten = body.clone(); + let slots = rewritten + .get_mut("server") + .and_then(|server| server.get_mut("endpoints")) + .and_then(Value::as_array_mut)?; + for endpoint in slots { + if endpoint.get("scheme").and_then(Value::as_str) == Some("http") + && let Some(scheme) = endpoint.get_mut("scheme") + { + *scheme = Value::String(String::from("https")); + } + } + Some(rewritten) +} + +/// Renders a schema error as the failing property, naming only. +fn describe(error: &jsonschema::ValidationError<'_>) -> String { + let path = property_path(error.instance_path()); + match error.kind() { + ValidationErrorKind::AdditionalProperties { unexpected } => { + let container = if path.is_empty() { + String::from("root") + } else { + path.clone() + }; + unexpected + .iter() + .map(|name| format!("unknown property '{name}' at {container}")) + .collect::>() + .join(", ") + } + ValidationErrorKind::Required { property } => { + let name = property.as_str().unwrap_or_default(); + if path.is_empty() { + format!("{name} is required") + } else { + format!("{path}.{name} is required") + } + } + _ => { + if path.is_empty() { + String::from("root") + } else { + path + } + } + } +} + +/// Renders a JSON Pointer as a dotted property path with `[n]` array indices. +fn property_path(location: &Location) -> String { + let mut path = String::new(); + for segment in location { + match segment { + LocationSegment::Property(name) => { + if !path.is_empty() { + path.push('.'); + } + path.push_str(name.as_ref()); + } + LocationSegment::Index(index) => { + path.push('['); + path.push_str(index.to_string().as_str()); + path.push(']'); + } + } + } + path +} + +/// Enforces the route root property set the open schema root cannot express. +fn check_route_root( + write: WriteKind, + fields: &serde_json::Map, + defects: &mut Defects, +) { + let allowed: [&str; 8] = match write { + WriteKind::Create => ROUTE_ROOT_CREATE, + WriteKind::Replacement => ROUTE_ROOT_REPLACEMENT, + }; + for key in fields.keys() { + if !allowed.contains(&key.as_str()) { + defects.add(format!("unknown property '{key}' at root")); + } + } +} + +/// Enforces the upstream root rule: `id` is system-generated on a create and +/// `tenant_id` is never caller-supplied. +fn check_upstream_root( + write: WriteKind, + fields: &serde_json::Map, + defects: &mut Defects, +) { + if write == WriteKind::Create && fields.contains_key("id") { + defects.add_property("id"); + } + if fields.contains_key("tenant_id") { + defects.add_property("tenant_id"); + } +} + +/// Checks the required properties, branching on create versus replacement. +fn check_required( + kind: ResourceKind, + write: WriteKind, + fields: &serde_json::Map, + defects: &mut Defects, +) { + // @cpt-begin:cpt-cf-oagw-dod-request-validation:p1:inst-val-required-branch + let required: &[&str] = match (kind, write) { + (ResourceKind::Upstream, _) => &["server", "protocol"], + (ResourceKind::Route, WriteKind::Create) => &["upstream_id", "match"], + (ResourceKind::Route, WriteKind::Replacement) => &["match"], + }; + for property in required { + if !fields.contains_key(*property) { + defects.add(format!("{property} is required")); + } + } + // @cpt-end:cpt-cf-oagw-dod-request-validation:p1:inst-val-required-branch +} + +/// The per-endpoint shape check the schema states only as `format`. +fn check_endpoints( + endpoints: Option<&Vec>, + allow_http_upstream: bool, + defects: &mut Defects, +) -> Vec { + let Some(endpoints) = endpoints else { + return Vec::new(); + }; + + let mut checked = Vec::with_capacity(endpoints.len()); + for (index, endpoint) in endpoints.iter().enumerate() { + let prefix = format!("server.endpoints[{index}]"); + let Some(fields) = endpoint.as_object() else { + defects.add_property(&prefix); + continue; + }; + + if let Some(host) = fields.get("host").and_then(Value::as_str) + && EndpointHost::parse(host).is_err() + { + defects.add_property(&format!("{prefix}.host")); + } + + let Some(scheme) = fields + .get("scheme") + .and_then(Value::as_str) + .and_then(parse_scheme) + else { + continue; + }; + + let Some(port) = endpoint_port(fields.get("port")) else { + defects.add_property(&format!("{prefix}.port")); + continue; + }; + + // @cpt-begin:cpt-cf-oagw-algo-request-validate:p1:inst-val-http-if + if !scheme.is_write_admitted(allow_http_upstream) { + // @cpt-begin:cpt-cf-oagw-algo-request-validate:p1:inst-val-http + defects.add_property(&format!("{prefix}.scheme")); + continue; + // @cpt-end:cpt-cf-oagw-algo-request-validate:p1:inst-val-http + } + // @cpt-end:cpt-cf-oagw-algo-request-validate:p1:inst-val-http-if + + checked.push(CheckedEndpoint { scheme, port }); + } + checked +} + +/// Resolves the endpoint port, applying the declared schema default. +fn endpoint_port(port: Option<&Value>) -> Option { + let raw = match port { + None => return Some(DEFAULT_ENDPOINT_PORT), + Some(port) => port.as_u64()?, + }; + u16::try_from(raw).ok().filter(|port| *port >= 1) +} + +/// Rejects a pool that mixes two schemes or two ports. +fn check_pool(endpoints: &[CheckedEndpoint], defects: &mut Defects) { + let Some((first, rest)) = endpoints.split_first() else { + return; + }; + for (offset, endpoint) in rest.iter().enumerate() { + let index = offset + 1; + if endpoint.scheme != first.scheme { + defects.add_property(&format!("server.endpoints[{index}].scheme")); + } + if endpoint.port != first.port { + defects.add_property(&format!("server.endpoints[{index}].port")); + } + } +} + +/// Parses an endpoint scheme literal. +fn parse_scheme(text: &str) -> Option { + match text { + "http" => Some(Scheme::Http), + "https" => Some(Scheme::Https), + "wss" => Some(Scheme::Wss), + "wt" => Some(Scheme::Wt), + "grpc" => Some(Scheme::Grpc), + _ => None, + } +} + +/// Checks the protocol enum. +fn check_protocol(fields: &serde_json::Map, defects: &mut Defects) { + let Some(protocol) = fields.get("protocol").and_then(Value::as_str) else { + return; + }; + if protocol != gts::PROTOCOL_HTTP && protocol != gts::PROTOCOL_GRPC { + defects.add_property("protocol"); + } +} + +/// Checks every tag against the shipped pattern. +fn check_tags(tags: Option<&Value>, prefix: &str, defects: &mut Defects) { + let Some(tags) = tags.and_then(Value::as_array) else { + return; + }; + for (index, tag) in tags.iter().enumerate() { + if !tag.as_str().is_some_and(tag_matches) { + defects.add_property(&format!("{prefix}[{index}]")); + } + } +} + +/// The shipped tag pattern `^[a-z0-9_-]+$`, as a character check. +fn tag_matches(tag: &str) -> bool { + !tag.is_empty() + && tag + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-') +} + +/// Checks the `match` shape and the §1.5 `priority` requirement. +fn check_match(fields: &serde_json::Map, defects: &mut Defects) { + let Some(matched) = fields.get("match") else { + return; + }; + let Some(branches) = matched.as_object() else { + defects.add_property("match"); + return; + }; + + match (branches.get("http"), branches.get("grpc")) { + (Some(http), None) => check_http_match(http, defects), + (None, Some(grpc)) => check_grpc_match(grpc, defects), + _ => { + defects.add_property("match"); + return; + } + } + + if fields.get("priority").and_then(Value::as_i64).is_none() { + defects.add_property("priority"); + } +} + +/// Checks the `http` match branch. +fn check_http_match(http: &Value, defects: &mut Defects) { + let Some(fields) = http.as_object() else { + defects.add_property("match.http"); + return; + }; + let methods = fields.get("methods").and_then(Value::as_array); + let admitted = methods.is_some_and(|methods| { + !methods.is_empty() + && methods + .iter() + .all(|method| HTTP_METHODS.contains(&method.as_str().unwrap_or_default())) + }); + if !admitted { + defects.add_property("match.http.methods"); + } + if fields + .get("path") + .and_then(Value::as_str) + .is_none_or(str::is_empty) + { + defects.add_property("match.http.path"); + } + if let Some(mode) = fields.get("path_suffix_mode").and_then(Value::as_str) + && mode != "disabled" + && mode != "append" + { + defects.add_property("match.http.path_suffix_mode"); + } +} + +/// Checks the `grpc` match branch. +fn check_grpc_match(grpc: &Value, defects: &mut Defects) { + let Some(fields) = grpc.as_object() else { + defects.add_property("match.grpc"); + return; + }; + for property in ["service", "method"] { + if fields + .get(property) + .and_then(Value::as_str) + .is_none_or(str::is_empty) + { + defects.add_property(&format!("match.grpc.{property}")); + } + } +} + +/// Checks the `rate_limit`, `cors`, and credential-reference sub-objects. +fn check_sub_configurations(fields: &serde_json::Map, defects: &mut Defects) { + if let Some(rate_limit) = fields.get("rate_limit") { + check_rate_limit(rate_limit, "rate_limit", defects); + } + if let Some(cors) = fields.get("cors") { + check_cors(cors, "cors", defects); + } + if let Some(auth) = fields.get("auth") { + check_credential_reference(auth, defects); + } +} + +/// Checks the `definitions.rate_limit` ranges over the raw body, so an +/// out-of-range integer is named before serde ever sees it. +fn check_rate_limit(rate_limit: &Value, prefix: &str, defects: &mut Defects) { + let Some(fields) = rate_limit.as_object() else { + defects.add_property(prefix); + return; + }; + + let sustained_rate = fields + .get("sustained") + .and_then(|sustained| sustained.get("rate")) + .and_then(Value::as_i64); + if !sustained_rate.is_some_and(|rate| rate >= 1) { + defects.add_property(&format!("{prefix}.sustained.rate")); + } + + if let Some(capacity) = fields.get("burst").and_then(|burst| burst.get("capacity")) + && capacity.as_i64().is_none_or(|capacity| capacity < 1) + { + defects.add_property(&format!("{prefix}.burst.capacity")); + } + + if let Some(cost) = fields.get("cost") + && cost.as_i64().is_none_or(|cost| cost < 1) + { + defects.add_property(&format!("{prefix}.cost")); + } +} + +/// Checks the `definitions.cors` shape. +fn check_cors(cors: &Value, prefix: &str, defects: &mut Defects) { + let Some(fields) = cors.as_object() else { + defects.add_property(prefix); + return; + }; + + if !fields.get("enabled").is_some_and(Value::is_boolean) { + defects.add_property(&format!("{prefix}.enabled")); + } + + let origins = fields.get("allowed_origins").and_then(Value::as_array); + if let Some(origins) = origins { + for (index, origin) in origins.iter().enumerate() { + let property = format!("{prefix}.allowed_origins[{index}]"); + match origin.as_str() { + None => defects.add_property(&property), + Some(text) if text != "*" && !carries_scheme(text) => { + defects.add_property(&property); + } + Some(_) => {} + } + } + } + + let methods = fields + .get("allowed_methods") + .and_then(Value::as_array) + .into_iter() + .flatten(); + for (index, method) in methods.enumerate() { + if !CORS_METHODS.contains(&method.as_str().unwrap_or_default()) { + defects.add_property(&format!("{prefix}.allowed_methods[{index}]")); + } + } + + let wildcard = origins.is_some_and(|origins| { + origins + .iter() + .any(|origin| origin.as_str() == Some("*")) + }); + if fields.get("allow_credentials").and_then(Value::as_bool) == Some(true) && wildcard { + defects.add_property(&format!("{prefix}.allowed_origins")); + } +} + +/// Whether an origin literal carries a URI scheme, which is what +/// `format: uri` asks for on an origin. +fn carries_scheme(origin: &str) -> bool { + let Some(separator) = origin.find("://") else { + return false; + }; + let scheme = &origin[..separator]; + !scheme.is_empty() + && scheme + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'+' || byte == b'-' || byte == b'.') +} + +/// Checks a credential reference for its scheme prefix only; the value is +/// never resolved and never echoed. +fn check_credential_reference(auth: &Value, defects: &mut Defects) { + if let Some(reference) = auth.get("secret_ref") { + check_credential_prefix(reference, "auth.secret_ref", defects); + } + if let Some(reference) = auth + .get("config") + .and_then(|config| config.get("secret_ref")) + { + check_credential_prefix(reference, "auth.config.secret_ref", defects); + } +} + +/// Names the property when the reference does not carry the `cred://` prefix. +fn check_credential_prefix(reference: &Value, property: &str, defects: &mut Defects) { + if let Some(text) = reference.as_str() + && !text.starts_with(CREDENTIAL_SCHEME) + { + defects.add_property(property); + } +} + +/// The upstream body the caller wrote, with the schema defaults applied. +#[derive(Debug, serde::Deserialize)] +struct UpstreamInput { + /// System-generated identifier; a create must not state one. + #[serde(default)] + id: Option, + /// Whether the upstream is enabled; the schema default is `true`. + #[serde(default)] + enabled: Option, + /// Caller-supplied alias, normalized by alias derivation. + #[serde(default)] + alias: Option, + /// Flat tags. + #[serde(default)] + tags: Vec, + /// Server endpoints; required. + server: ServerConfig, + /// Protocol GTS identifier; required. + protocol: String, + /// Authentication plugin binding. + #[serde(default)] + auth: Option, + /// Header transformation rules. + #[serde(default)] + headers: Option, + /// Plugin chain. + #[serde(default)] + plugins: Option, + /// Rate limiting configuration. + #[serde(default)] + rate_limit: Option, + /// CORS configuration. + #[serde(default)] + cors: Option, +} + +/// The route body the caller wrote, with the schema defaults applied. +#[derive(Debug, serde::Deserialize)] +struct RouteInput { + /// System-generated identifier; a create must not state one. + #[serde(default)] + id: Option, + /// Referenced upstream; required on a create, rejected on a replacement. + #[serde(default)] + upstream_id: Option, + /// Protocol-scoped inbound matching rules; required. + #[serde(rename = "match")] + match_config: MatchConfig, + /// Plugin chain. + #[serde(default)] + plugins: Option, + /// Rate limiting configuration. + #[serde(default)] + rate_limit: Option, + /// Flat tags. + #[serde(default)] + tags: Vec, + /// Route-level CORS configuration, added per §1.5. + #[serde(default)] + cors: Option, + /// Match-uniqueness ordering, added per §1.5 and required. + #[serde(default)] + priority: Option, + /// Enable/disable semantics, added per §1.5. + #[serde(default)] + enabled: Option, +} + +/// Builds the validated upstream once every family passed. +#[allow(clippy::result_large_err)] +fn build_upstream(body: &Value) -> Result { + let input: UpstreamInput = serde_json::from_value(body.clone()) + .map_err(|error| DomainError::gateway(ErrorKind::ValidationError, serde_field(&error)))?; + + Ok(ValidatedUpstream { + stated_id: input.id, + value: Upstream { + id: Uuid::nil(), + enabled: input.enabled.unwrap_or(true), + alias: input.alias, + tags: input.tags, + server: with_default_ports(input.server), + protocol: input.protocol, + auth: input.auth, + headers: input.headers, + plugins: input.plugins, + rate_limit: input.rate_limit, + cors: input.cors, + }, + }) +} + +/// Builds the validated route once every family passed. +#[allow(clippy::result_large_err)] +fn build_route(write: WriteKind, body: &Value) -> Result { + let input: RouteInput = serde_json::from_value(body.clone()) + .map_err(|error| DomainError::gateway(ErrorKind::ValidationError, serde_field(&error)))?; + + // A create defaults `enabled` to `true`; a replacement leaves it absent so + // the stored value carries forward. + let enabled = match write { + WriteKind::Create => Some(input.enabled.unwrap_or(true)), + WriteKind::Replacement => input.enabled, + }; + + Ok(ValidatedRoute { + stated_id: input.id, + value: Route { + id: Uuid::nil(), + upstream_id: input.upstream_id.unwrap_or_default(), + match_config: input.match_config, + plugins: input.plugins, + rate_limit: input.rate_limit, + tags: input.tags, + cors: input.cors, + priority: input.priority, + enabled, + }, + }) +} + +/// Applies the schema default port to every endpoint that omits one. +fn with_default_ports(mut server: ServerConfig) -> ServerConfig { + for endpoint in &mut server.endpoints { + if endpoint.port.is_none() { + endpoint.port = Some(DEFAULT_ENDPOINT_PORT); + } + } + server +} + +/// The one property name a serde deserialization failure reports. +fn serde_field(error: &serde_json::Error) -> String { + let message = error.to_string(); + let Some(start) = message.find('`') else { + return String::from("body"); + }; + let rest = &message[start + 1..]; + match rest.find('`') { + Some(end) => rest[..end].to_owned(), + None => String::from("body"), + } +} diff --git a/gears/system/oagw/oagw/src/data_plane/cache.rs b/gears/system/oagw/oagw/src/data_plane/cache.rs new file mode 100644 index 0000000..6bba189 --- /dev/null +++ b/gears/system/oagw/oagw/src/data_plane/cache.rs @@ -0,0 +1,227 @@ +//! The Data Plane L1 configuration cache. +//! +//! Realizes `cpt-cf-oagw-algo-dp-cache`: a per-instance LRU of 1000 entries +//! with no TTL, populated lazily on read, invalidated explicitly and in process +//! by the flush the configuration write path notifies, and holding exactly the +//! resolved upstream configurations and their route candidate sets that ADR +//! 0006's DP State scopes to the Data Plane. ADR 0005's two key shapes are +//! narrowed here as the FEATURE §1.5 table records: `upstream:{tenant_id}:{alias}` +//! for the entry itself and `route:{upstream_id}:{method}:{path_prefix}` for the +//! route keys the entry's candidate set was read under, which the prefix flush +//! drops with it. +//! +//! The cache never holds a response body, credential material, a cached access +//! token, or rate-limit state: `cpt-cf-oagw-principle-no-cache` places the +//! response on the caller and the upstream, and the credential material belongs +//! to the chain. It runs no periodic sync, no TTL expiry, and no background +//! refresh — the explicit flush is the only mechanism. + +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; + +use parking_lot::Mutex; +use uuid::Uuid; + +use crate::domain::proxy::ResolvedUpstream; + +/// The entry count ADR 0006 fixes for the Data Plane L1 cache. +pub const DP_CACHE_CAPACITY: usize = 1000; + +// @cpt-dod:cpt-cf-oagw-dod-dp-cache:p1 + +/// One cached resolution: the resolved configuration and the route keys its +/// candidate set was read under. +struct Entry { + /// The upstream the entry resolves: the identity the upstream-scoped flush + /// matches on, since the key itself carries the alias. + upstream_id: Uuid, + value: Arc, + /// The `route:{upstream_id}:{method}:{path_prefix}` keys the entry covers, + /// dropped together with it by the prefix flush. + route_keys: Vec, +} + +/// The Data Plane L1 configuration cache. +/// +/// Cloned handles share one cache, which is what makes the flush of one write +/// visible to every later read in the process. +#[derive(Clone, Default)] +pub struct DpCache { + inner: Arc>, +} + +#[derive(Default)] +struct State { + entries: HashMap, + /// Least-recently-used order, least recent first. + order: VecDeque, +} + +impl DpCache { + /// Creates an empty cache. + #[must_use] + pub fn new() -> Self { + // @cpt-begin:cpt-cf-oagw-algo-dp-cache:p1:inst-cache-nosync + // The cache runs no periodic sync, no TTL expiry, and no background + // refresh: the explicit flush the configuration write path notifies is + // the only mechanism that removes an entry, which is what + // DECOMPOSITION §1.3(10) records and what keeps the invalidation in the + // same process as the write that required it. + // @cpt-end:cpt-cf-oagw-algo-dp-cache:p1:inst-cache-nosync + Self::default() + } + + /// The `upstream:{tenant_id}:{alias}` key shape of ADR 0005. + #[must_use] + pub fn upstream_key(tenant_id: Uuid, alias: &str) -> String { + format!("upstream:{tenant_id}:{alias}") + } + + /// The `route:{upstream_id}:{method}:{path_prefix}` key shape of ADR 0005. + #[must_use] + pub fn route_key(upstream_id: Uuid, method: &str, path_prefix: &str) -> String { + format!("route:{upstream_id}:{method}:{path_prefix}") + } + + /// Looks a resolved configuration up, marking it most recently used. + #[must_use] + pub fn get(&self, key: &str) -> Option> { + // @cpt-begin:cpt-cf-oagw-algo-dp-cache:p1:inst-cache-lookup + // The read path of `cpt-cf-oagw-algo-resolve-consume` looks the key up + // here and the entry comes back when it is present, with the hit moved + // to the most recently used end of the order. + let mut state = self.inner.lock(); + let value = state.entries.get(key)?; + let hit = Arc::clone(&value.value); + state.order.retain(|candidate| candidate != key); + state.order.push_back(String::from(key)); + // @cpt-begin:cpt-cf-oagw-algo-dp-cache:p1:inst-cache-return + // RETURN the hit, the inserted entry, or the invalidated key set: this + // branch is the hit, the insert answers the miss, and the flush + // answers the invalidation. + Some(hit) + // @cpt-end:cpt-cf-oagw-algo-dp-cache:p1:inst-cache-return + // @cpt-end:cpt-cf-oagw-algo-dp-cache:p1:inst-cache-lookup + } + + /// Inserts a resolution under the upstream key, recording the route keys it + /// covers, and evicts the least-recently-used entry at the capacity. + pub fn insert(&self, key: String, value: Arc, route_keys: Vec) { + // @cpt-begin:cpt-cf-oagw-algo-dp-cache:p1:inst-cache-miss-else + // The ELSE of the lookup: the resolution runs and its result arrives + // here, so the next read of the key is a hit. + // @cpt-end:cpt-cf-oagw-algo-dp-cache:p1:inst-cache-miss-else + // @cpt-begin:cpt-cf-oagw-algo-dp-cache:p1:inst-cache-insert + let mut state = self.inner.lock(); + state.order.retain(|candidate| candidate != &key); + state.order.push_back(key.clone()); + state.entries.insert( + key, + Entry { + upstream_id: value.upstream_id, + value, + route_keys, + }, + ); + // The least-recently-used entry leaves when the 1000-entry ceiling of + // ADR 0006 is reached, and no entry beyond it is ever held. + while state.entries.len() > DP_CACHE_CAPACITY { + let Some(evicted) = state.order.pop_front() else { + break; + }; + state.entries.remove(&evicted); + } + // @cpt-end:cpt-cf-oagw-algo-dp-cache:p1:inst-cache-insert + } + + /// Flushes the entries of one tenant: the tenant's upstream keys and the + /// route keys those entries cover, and nothing else. + /// + /// This is the flush `cpt-cf-oagw-algo-dp-cache` step 3 executes when the + /// configuration write path notifies it, so one write never discards an + /// unrelated tenant's entries. + pub fn flush_tenant(&self, tenant_id: Uuid) { + let prefix = format!("upstream:{tenant_id}:"); + self.flush_by(|key, _entry| key.starts_with(&prefix)); + } + + /// Flushes one upstream's entry and its route keys, leaving the rest of the + /// tenant's entries in place. + pub fn flush_upstream(&self, tenant_id: Uuid, upstream_id: Uuid) { + let prefix = format!("upstream:{tenant_id}:"); + self.flush_by(|key, entry| { + key.starts_with(&prefix) && entry.upstream_id == upstream_id + }); + } + + /// The number of entries the cache holds. + #[must_use] + pub fn len(&self) -> usize { + self.inner.lock().entries.len() + } + + /// Whether the cache holds no entry. + #[must_use] + pub fn is_empty(&self) -> bool { + self.inner.lock().entries.is_empty() + } + + /// Drops every entry the predicate names, together with the route keys the + /// dropped entries recorded. + fn flush_by(&self, doomed_by: impl Fn(&str, &Entry) -> bool) { + let mut state = self.inner.lock(); + let doomed: Vec = state + .entries + .iter() + .filter(|(key, entry)| doomed_by(key, entry)) + .map(|(key, _)| key.clone()) + .collect(); + if doomed.is_empty() { + return; + } + let mut route_keys: Vec = Vec::new(); + for key in &doomed { + if let Some(entry) = state.entries.remove(key) { + route_keys.extend(entry.route_keys); + } + state.order.retain(|candidate| candidate != key); + } + for key in route_keys { + state.entries.remove(&key); + state.order.retain(|candidate| candidate != &key); + } + } +} + +// @cpt-begin:cpt-cf-oagw-algo-dp-cache:p1:inst-cache-invalidate-if +// The write path of `cpt-cf-oagw-feature-control-plane-config` notifies this +// feature's flush routine through the seam the generation advance rides: a +// successful write of any kind reaches here in the same process and before the +// write's response is produced. +impl crate::control_plane::cache::DataPlaneFlush for DpCache { + fn configuration_written(&self, tenant_id: Uuid) { + // @cpt-begin:cpt-cf-oagw-algo-dp-cache:p1:inst-cache-invalidate + // The flush this feature owns: the entries the write affects leave + // now, so a read that follows the write never resolves against a chain + // the write superseded. + // @cpt-end:cpt-cf-oagw-algo-dp-cache:p1:inst-cache-invalidate + // @cpt-begin:cpt-cf-oagw-algo-dp-cache:p1:inst-cache-flush-prefix + // The flush is by key prefix — this tenant's upstream keys and the + // route keys those entries cover — so one write never discards an + // unrelated tenant's entries. + self.flush_tenant(tenant_id); + // @cpt-end:cpt-cf-oagw-algo-dp-cache:p1:inst-cache-flush-prefix + } +} +// @cpt-end:cpt-cf-oagw-algo-dp-cache:p1:inst-cache-invalidate-if + +// @cpt-begin:cpt-cf-oagw-algo-dp-cache:p1:inst-cache-invalidate-else +// The ELSE of the notification: a write that failed against the store returns +// before it reaches the seam, and reaches this routine never. +// @cpt-end:cpt-cf-oagw-algo-dp-cache:p1:inst-cache-invalidate-else + +// @cpt-begin:cpt-cf-oagw-algo-dp-cache:p1:inst-cache-invalidate-none +// So a failed write leaves the cache untouched: the database the write was +// tried against is unchanged, and the entries it would have dropped still hold +// the configuration every read is subject to. +// @cpt-end:cpt-cf-oagw-algo-dp-cache:p1:inst-cache-invalidate-none diff --git a/gears/system/oagw/oagw/src/data_plane/classify.rs b/gears/system/oagw/oagw/src/data_plane/classify.rs new file mode 100644 index 0000000..31a76c0 --- /dev/null +++ b/gears/system/oagw/oagw/src/data_plane/classify.rs @@ -0,0 +1,105 @@ +//! Classification of one proxy answer and its error-source tag. +//! +//! Realizes `cpt-cf-oagw-algo-response-classify`: the two rows ADR 0007 +//! states. An answer the gateway produced is mapped by the API layer through +//! the foundation's problem mapping, which already sets the `gateway` tag, and +//! this module owns the other row: the upstream answer, whatever its status, +//! which is passed through with its body unmodified, tagged `upstream`, and +//! subjected to the `headers.response` rules — and never cached, per +//! `cpt-cf-oagw-principle-no-cache`, because the Data Plane L1 cache holds +//! configurations and no response body. +//! +//! The stream branch of the classification is `cpt-cf-oagw-feature-streaming`'s: +//! the head form of this routine hands the body over to that feature's pump +//! rather than carrying one, so the tag is decided here before any body byte +//! moves and the body is transferred by the routine that owns it. + +use crate::domain::proxy::{PluginMutations, ProxyResponse}; +use crate::domain::upstream::HeadersConfig; + +// @cpt-dod:cpt-cf-oagw-dod-error-source:p1 + +/// Produces the upstream-sourced answer the caller is answered with, body +/// included. +/// +/// The `headers.response` rules of the resolved upstream and the response-phase +/// mutations of the plugin chain are applied to the upstream header map before +/// the body travels with it; the framing headers the transform drops are +/// dropped here for the same reason they are dropped there — the gateway +/// buffers the body and states its length itself. +#[must_use] +pub fn classify_upstream( + status: u16, + upstream_headers: Vec<(String, String)>, + body: Vec, + headers: &HeadersConfig, + mutations: &PluginMutations, +) -> ProxyResponse { + // @cpt-begin:cpt-cf-oagw-algo-response-classify:p1:inst-cls-upstream-else + // The ELSE of the classification: the answer was produced by the upstream + // and not by the gateway, so no problem-details mapping is applied to it. + // @cpt-begin:cpt-cf-oagw-algo-response-classify:p1:inst-cls-upstream + // The upstream answer passes through with its status and body unmodified, + // whatever its status is, and the tag is decided before any body byte + // moves. + let transformed = + super::headers::transform_response(&upstream_headers, headers, mutations); + // @cpt-end:cpt-cf-oagw-algo-response-classify:p1:inst-cls-upstream + // @cpt-end:cpt-cf-oagw-algo-response-classify:p1:inst-cls-upstream-else + + // @cpt-begin:cpt-cf-oagw-algo-response-classify:p1:inst-cls-stream-if + // The stream branch is `cpt-cf-oagw-feature-streaming`'s, and the tag is + // decided here before any body byte moves. + // @cpt-begin:cpt-cf-oagw-algo-response-classify:p1:inst-cls-stream + // The exchange this routine is handed has already been read to completion + // by the caller, so the branch assembles the body it was given; the + // incremental transfer of a body still in flight is the head form below. + // @cpt-end:cpt-cf-oagw-algo-response-classify:p1:inst-cls-stream + // @cpt-end:cpt-cf-oagw-algo-response-classify:p1:inst-cls-stream-if + + // @cpt-begin:cpt-cf-oagw-algo-response-classify:p1:inst-cls-stream-else + // The ELSE of the stream branch: the body is held whole. + // @cpt-begin:cpt-cf-oagw-algo-response-classify:p1:inst-cls-return + // Assemble the `ProxyResponse` and return it. + let response = ProxyResponse::upstream(status, transformed, body); + // @cpt-end:cpt-cf-oagw-algo-response-classify:p1:inst-cls-return + // @cpt-end:cpt-cf-oagw-algo-response-classify:p1:inst-cls-stream-else + + // @cpt-begin:cpt-cf-oagw-algo-response-classify:p1:inst-cls-nocache-return + // RETURN the `ProxyResponse`, and never cache it: + // `cpt-cf-oagw-principle-no-cache` places the response on the caller and + // the upstream, so the Data Plane L1 cache of + // `cpt-cf-oagw-algo-dp-cache` never holds it. + response + // @cpt-end:cpt-cf-oagw-algo-response-classify:p1:inst-cls-nocache-return +} + +/// Produces the upstream-sourced answer the caller is answered with, without +/// the body. +/// +/// This is the form `cpt-cf-oagw-feature-streaming` is handed at the response +/// header boundary: the tag and the `headers.response` rules are decided here, +/// and the body is not, because it is still in flight and the pump that +/// transfers it is the routine that owns it. The framing headers are dropped +/// with the rest, so a length the gateway no longer states is never emitted. +#[must_use] +pub fn classify_upstream_head( + status: u16, + upstream_headers: Vec<(String, String)>, + headers: &HeadersConfig, + mutations: &PluginMutations, +) -> ProxyResponse { + let transformed = super::headers::transform_response(&upstream_headers, headers, mutations); + ProxyResponse::upstream(status, transformed, Vec::new()) +} + +/// The stream posture of this run: no answer is buffered to completion before +/// it is answered, so the streaming feature's pump owns the transfer of every +/// body the classification hands over. +pub const STREAMS_BUFFERED: bool = true; + +/// The content type the answer carries, read from the upstream header map. +#[must_use] +pub fn content_type_of(response: &ProxyResponse) -> Option<&str> { + response.header("content-type") +} diff --git a/gears/system/oagw/oagw/src/data_plane/endpoint.rs b/gears/system/oagw/oagw/src/data_plane/endpoint.rs new file mode 100644 index 0000000..84bbb31 --- /dev/null +++ b/gears/system/oagw/oagw/src/data_plane/endpoint.rs @@ -0,0 +1,234 @@ +//! Endpoint selection over the resolved upstream's pool. +//! +//! Realizes `cpt-cf-oagw-algo-endpoint-select` and the six-row behaviour matrix +//! of ADR 0001's Appendix A: the required header for a common-suffix alias, +//! the optional but validated header otherwise, round-robin when no header is +//! supplied, and no load balancing at all for a single-endpoint pool. +//! +//! The per-upstream round-robin counter is a per-instance state of the kind +//! ADR 0006 assigns to the Data Plane, and is the only load-balancing behaviour +//! this feature delivers: no weighting, no health exclusion, no stickiness. + +use std::sync::Arc; + +use parking_lot::Mutex; +use uuid::Uuid; + +use crate::domain::error::{DomainError, ErrorKind}; +use crate::domain::proxy::{EndpointChoice, ResolvedUpstream, SelectedEndpoint}; + +/// The routing header the selection reads and strips. +pub const TARGET_HOST_HEADER: &str = "x-oagw-target-host"; + +/// The round-robin counters of the Data Plane, per upstream. +#[derive(Clone, Default)] +pub struct RoundRobin { + counters: Arc>>, +} + +impl RoundRobin { + /// Creates an empty counter set. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Reads and advances the counter of one upstream. + fn next(&self, upstream_id: Uuid, modulo: usize) -> usize { + let mut counters = self.counters.lock(); + let entry = counters.entry(upstream_id).or_default(); + let value = *entry; + *entry = (value + 1) % modulo; + value + } +} + +// @cpt-dod:cpt-cf-oagw-dod-endpoint-selection:p1 + +/// Selects the endpoint a request is sent to. +/// +/// `supplied` is the `X-OAGW-Target-Host` value as received, before the header +/// is stripped from the outbound map; the selection is the only reader of it. +/// +/// # Errors +/// +#[allow(clippy::result_large_err)] +/// Returns the three 400 variants the matrix produces: `MissingTargetHost` for +/// a multi-endpoint pool whose alias is a common suffix and whose request named +/// no endpoint, `InvalidTargetHost` for a malformed value, and +/// `UnknownTargetHost` for a value that matches no configured host. +pub fn select_endpoint( + resolved: &ResolvedUpstream, + supplied: Option<&str>, + round_robin: &RoundRobin, +) -> Result { + // @cpt-begin:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-present-if + if let Some(value) = supplied { + // @cpt-begin:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-format + // The value is validated as a hostname or an IP address with no port, + // no path, and no special character, which is what DESIGN §3.3's + // `InvalidTargetHost` row requires of the header. + let parsed = parse_target_host(value); + // @cpt-end:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-format + // @cpt-begin:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-format-if + let Some(host) = parsed else { + // @cpt-begin:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-format-return + return Err(DomainError::gateway( + ErrorKind::InvalidTargetHost, + "the X-OAGW-Target-Host value is not a bare host name or IP literal", + )); + // @cpt-end:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-format-return + }; + // @cpt-end:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-format-if + + // @cpt-begin:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-format-else + // The ELSE of the format check: the value parses as a bare host, so it + // is matched against the pool. + // @cpt-end:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-format-else + + // @cpt-begin:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-match + // The validated value is matched case-insensitively against the + // endpoint hosts of the resolved upstream. + let matched = resolved + .endpoints + .iter() + .find(|endpoint| endpoint.host.as_str().eq_ignore_ascii_case(&host)) + .cloned(); + // @cpt-end:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-match + + // @cpt-begin:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-unknown-if + let Some(endpoint) = matched else { + // @cpt-begin:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-unknown-return + let configured: Vec<&str> = resolved + .endpoints + .iter() + .map(|endpoint| endpoint.host.as_str()) + .collect(); + let mut unknown = DomainError::gateway( + ErrorKind::UnknownTargetHost, + "the X-OAGW-Target-Host value names no configured endpoint host", + ); + unknown.detail = + format!("the value {host:?} names none of the configured hosts {configured:?}"); + return Err(unknown); + // @cpt-end:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-unknown-return + }; + // @cpt-end:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-unknown-if + + // @cpt-begin:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-unknown-else + // The ELSE of the host check: the value named one of the pool's hosts, + // so that endpoint answers the request. + // @cpt-end:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-unknown-else + + // @cpt-begin:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-unknown-else-return + return Ok(SelectedEndpoint { + endpoint, + choice: EndpointChoice::Header, + }); + // @cpt-end:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-unknown-else-return + } + // @cpt-end:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-present-if + + // @cpt-begin:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-absent-else + // The ELSE of the header check: no target host was supplied, so the + // endpoint count and the alias derivation kind decide. + // @cpt-end:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-absent-else + + // @cpt-begin:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-absent + // The pool is never empty through the API: the schema states `minItems: 1` + // and the store refuses the row. A pool that nevertheless arrives empty is + // a routing configuration defect, and the selection fails closed on it + // rather than dividing by zero at the round-robin counter. + if resolved.endpoints.is_empty() { + return Err(DomainError::gateway( + ErrorKind::RouteError, + "the resolved upstream declares no endpoint the request could be sent to", + )); + } + // @cpt-end:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-absent + + // @cpt-begin:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-single-if + if let [endpoint] = resolved.endpoints.as_slice() { + // @cpt-begin:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-single + return Ok(SelectedEndpoint { + endpoint: endpoint.clone(), + choice: EndpointChoice::Only, + }); + // @cpt-end:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-single + } + // @cpt-end:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-single-if + + // @cpt-begin:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-suffix-if + if resolved.alias_derivation == crate::domain::proxy::AliasDerivation::Derived { + // @cpt-begin:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-suffix-return + let configured: Vec<&str> = resolved + .endpoints + .iter() + .map(|endpoint| endpoint.host.as_str()) + .collect(); + let mut missing = DomainError::gateway( + ErrorKind::MissingTargetHost, + "a multi-endpoint upstream whose alias is a common suffix requires X-OAGW-Target-Host", + ); + missing.detail = format!("the valid values are the configured hosts {configured:?}"); + return Err(missing); + // @cpt-end:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-suffix-return + } + // @cpt-end:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-suffix-if + + // @cpt-begin:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-rr-else + // The ELSE of the derivation check: a multi-endpoint pool whose alias is + // the explicit kind needs no header, and load balancing selects. + // @cpt-end:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-rr-else + + // @cpt-begin:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-rr + let index = round_robin.next(resolved.upstream_id, resolved.endpoints.len()); + // @cpt-end:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-rr + + // @cpt-begin:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-rr-return + Ok(SelectedEndpoint { + endpoint: resolved.endpoints[index % resolved.endpoints.len()].clone(), + choice: EndpointChoice::LoadBalanced, + }) + // @cpt-end:cpt-cf-oagw-algo-endpoint-select:p1:inst-ep-rr-return +} + +/// Parses a supplied target host: a bare host name or IP literal, no port, no +/// path, no scheme, no special character. +/// +/// The value is matched case-insensitively against the configured hosts, so the +/// normalized form is what the comparison sees. +#[must_use] +fn parse_target_host(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + // The only colon a bare target may carry is an IPv6 literal's, which the + // endpoint-host parser admits; any other punctuation names a port, a path, + // a scheme, or an authority, and is malformed for this header. + let lowered = trimmed.to_ascii_lowercase(); + if crate::domain::EndpointHost::parse(&lowered).is_ok() { + return Some(lowered); + } + let forbidden = ['/', ':', '@', '?', '#', '\\', ' ']; + if trimmed.chars().any(|character| forbidden.contains(&character)) { + return None; + } + crate::domain::EndpointHost::parse(&lowered).is_ok().then_some(lowered) +} + +/// The round-robin counter of one upstream, exposed for the diagnostics of the +/// observability feature. +#[must_use] +pub fn counter_of(round_robin: &RoundRobin, upstream_id: Uuid) -> Option { + round_robin.counters.lock().get(&upstream_id).copied() +} + +/// The shared round-robin state the Data Plane holds. +/// +/// The type is `Clone` over an `Arc`, so one handle reaches every handler. +#[must_use] +pub fn shared() -> Arc { + Arc::new(RoundRobin::new()) +} diff --git a/gears/system/oagw/oagw/src/data_plane/execute.rs b/gears/system/oagw/oagw/src/data_plane/execute.rs new file mode 100644 index 0000000..b62b3a3 --- /dev/null +++ b/gears/system/oagw/oagw/src/data_plane/execute.rs @@ -0,0 +1,540 @@ +//! Execution of the composed plugin chain around one proxy exchange. +//! +//! Realizes `cpt-cf-oagw-algo-chain-execute`: the phase order Auth, then +//! Guards on the request, then Transform on the request, then the upstream +//! call, then Guards and Transform on the response, and Transform on the error +//! when the call fails — the order DESIGN §3.2 Plugin System states and ADR +//! 0002's execution order repeats. The composition +//! (`cpt-cf-oagw-algo-chain-compose` of `cpt-cf-oagw-feature-plugin-system`) +//! produced the schedule; this module owns the two things the composition does +//! not: the sandbox discipline of `cpt-cf-oagw-nfr-starlark-sandbox` and the +//! `last_used_at` record of the FEATURE §1.5. +//! +//! Every custom plugin this gear binds is a stored Starlark row, and no +//! Starlark interpreter exists in this deployment to hold one to its limits, so +//! no custom step ever executes: the chain answers the `PluginNotFound` failure +//! the FEATURE requires instead, and the `last_used_at` set this module records +//! is consequently always empty. + +use std::sync::Arc; + +use serde_json::Value; +use uuid::Uuid; + +use crate::store::OagwStore; +use crate::domain::context::{AuthContext, RequestContext, ResponseContext}; +use crate::domain::error::{DomainError, ErrorKind}; +use crate::domain::plugin_contract::{ + GuardDecision, PluginFailure, SandboxLimits, +}; +use crate::domain::proxy::{PluginMutations, ProxyContext}; +use crate::plugins::chain::{ComposedAuth, ComposedChain, ComposedStep}; + +// @cpt-dod:cpt-cf-oagw-dod-chain-execution:p1 + +/// Runs the request leg of the chain: auth, then the guards, then the +/// transforms. +/// +/// The mutations returned are the header entries the plugins added or mutated +/// and the names they removed, which `cpt-cf-oagw-algo-header-transform` +/// carries into the outbound map after the configuration rules have run. +/// +/// # Errors +/// +/// Returns the `PluginNotFound` failure for a custom step whose limits cannot +/// be enforced, the `AuthenticationFailed` and `SecretNotFound` failures the +/// credential resolution maps, the `ValidationError` failure a request-phase +/// guard rejection answers, and the `ProtocolError` failure a sandbox breach +/// answers. +#[allow(clippy::result_large_err)] +pub async fn run_request_phase( + chain: &ComposedChain, + context: &ProxyContext, + limits: &SandboxLimits, +) -> Result { + // @cpt-begin:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-unresolvable-if + // A custom plugin's limits cannot be enforced in this deployment: no + // interpreter exists to strip the network, file, and import capabilities + // from, so the step is refused rather than run, and the composition's + // binding is never silently dropped. + if !enforceable(chain) { + // @cpt-begin:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-unresolvable-return + return Err(DomainError::gateway( + ErrorKind::PluginNotFound, + "the bound plugin's sandbox limits cannot be enforced in this deployment, so the chain is refused", + )); + // @cpt-end:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-unresolvable-return + } + // @cpt-end:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-unresolvable-if + + // @cpt-begin:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-unresolvable-else + // The ELSE of the enforceability check: every composed binding resolves to + // an implementation this deployment can hold to its limits, so the phases + // run in the composed order. + // @cpt-end:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-unresolvable-else + + let mut mutations = PluginMutations::default(); + + // @cpt-begin:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-auth + // The credential material the phase resolves leaves the call in the auth + // context's headers and nowhere else. + let mut auth = AuthContext::new(context.tenant_id, context.subject_id); + // @cpt-begin:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-auth-if + match &chain.auth { + ComposedAuth::Noop => {} + ComposedAuth::Builtin { plugin, config } => { + // @cpt-begin:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-auth-return + // A reference the store refuses, or a credential the upstream + // rejects, is the failure the caller answers 401 or 500 with, and + // no later phase runs. + plugin + .authenticate(&mut auth, config) + .await + .map_err(failure_of)?; + // @cpt-end:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-auth-return + } + ComposedAuth::Custom { .. } => { + return Err(DomainError::gateway( + ErrorKind::PluginNotFound, + "the bound auth plugin is a stored source whose limits cannot be enforced", + )); + } + } + // @cpt-end:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-auth-if + for (name, value) in &auth.headers { + mutations.set.push((name.clone(), value.clone())); + } + // @cpt-end:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-auth + + // @cpt-begin:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-guards + // The guards read the request as the caller issued it, and a rejection is + // answered with the phase-specific status ADR 0009 states for the request + // phase. + let mut request = request_context(context); + // @cpt-begin:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-guards-if + for step in &chain.guard_request { + // @cpt-begin:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-guards-return + run_guard(step, &request, limits)?; + // @cpt-end:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-guards-return + } + // @cpt-end:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-guards-if + // @cpt-begin:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-guards-else + // The ELSE of the guard phase: every guard allowed the request, so the + // transforms run on it next. + // @cpt-end:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-guards-else + // @cpt-end:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-guards + + // @cpt-begin:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-transform + // The transforms mutate one shared context in composed order, and only the + // entries they added or mutated and the names they removed reach the + // outbound map, which is what keeps the passthrough mode the configuration + // declares the only thing that forwards an inbound header. + let snapshot = request.headers.clone(); + for step in &chain.transform_request { + run_transform(step, &mut request, limits)?; + } + for (name, value) in &request.headers { + if snapshot.get(name) != Some(value) { + mutations.set.push((name.clone(), value.clone())); + } + } + for name in snapshot.keys() { + if !request.headers.contains_key(name) { + mutations.removed.push(name.clone()); + } + } + // @cpt-end:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-transform + + Ok(mutations) +} + +/// Runs the response leg of the chain: the guards, then the transforms. +/// +/// # Errors +/// +/// Returns the `ProtocolError` failure a response-phase guard rejection +/// answers, and the `ProtocolError` failure a sandbox breach answers. +#[allow(clippy::result_large_err)] +pub fn run_response_phase( + chain: &ComposedChain, + status: u16, + upstream_headers: &[(String, String)], + limits: &SandboxLimits, +) -> Result { + let mut mutations = PluginMutations::default(); + + // @cpt-begin:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-response + // The response leg runs after the upstream call, in the order the contract + // declares: guards first, then transforms. The guard phase on the response + // is the `guard_response` contract ADR 0002 declares, and its rejection is + // answered with the phase-specific status ADR 0009 states for the response + // phase. + let mut response = ResponseContext::new(status); + for (name, value) in upstream_headers { + response.set_header(name, value.clone()); + } + // @cpt-begin:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-response-if + for step in &chain.guard_response { + // @cpt-begin:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-response-return + run_response_guard(step, &response, limits)?; + // @cpt-end:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-response-return + } + // @cpt-end:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-response-if + let snapshot = response.headers.clone(); + for step in &chain.transform_response { + run_response_transform(step, &mut response, limits)?; + } + for (name, value) in &response.headers { + if snapshot.get(name) != Some(value) { + mutations.set.push((name.clone(), value.clone())); + } + } + for name in snapshot.keys() { + if !response.headers.contains_key(name) { + mutations.removed.push(name.clone()); + } + } + // @cpt-end:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-response + + // @cpt-begin:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-response-else-return + // The RETURN of the response leg: the authenticated and transformed + // request and response inputs, whose header entries the caller carries + // into the answer. + Ok(mutations) + // @cpt-end:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-response-else-return +} + +/// Runs the error leg of the chain on the failure the upstream call produced. +/// +/// A transform that declares the error phase mutates the failure's context, and +/// the caller maps the mutated failure through the foundation's problem +/// mapping; no guard runs on the error leg, and no custom step ever reaches it. +pub fn run_error_phase(chain: &ComposedChain, error: &mut DomainError) { + // @cpt-begin:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-response-else + // On a failed call the transform phase runs on the error instead of on the + // response, and every mutation it performed is applied to the failure the + // caller answers with. + for step in &chain.transform_error { + if let Some(transform) = transform_of(step) { + super::sandbox::invoke(&crate::domain::plugin_contract::SANDBOX_LIMITS, || { + transform.transform_error(&mut error.context, step_config(step)); + }) + .unwrap_or(()); + } + } + // @cpt-end:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-response-else +} + +/// The custom plugin rows the chain would have executed, for the +/// `last_used_at` record. +/// +/// The set is always empty in this deployment, because no custom step ever +/// executes; the function exists so the record is written from the same chain +/// the request ran and so a deployment that does execute custom sources gains +/// the record without a second mechanism. +#[must_use] +pub fn executed_custom_plugins(chain: &ComposedChain) -> Vec { + let mut used: Vec = Vec::new(); + if let ComposedAuth::Custom { row, .. } = &chain.auth { + used.push(row.id); + } + for step in chain + .guard_request + .iter() + .chain(chain.transform_request.iter()) + .chain(chain.guard_response.iter()) + .chain(chain.transform_response.iter()) + .chain(chain.transform_error.iter()) + { + if let ComposedStep::Custom { row, .. } = step { + used.push(row.id); + } + } + used.sort(); + used.dedup(); + used +} + +/// Writes `last_used_at` for every custom plugin that executed, coalesced per +/// plugin, after the response is produced, and feeding no decision. +pub fn record_last_used(store: &OagwStore, chain: &ComposedChain, now: u64) { + // @cpt-begin:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-lastused + // The record is issued after the response is produced and reads nothing + // back: no decision consumes `last_used_at`, so the write is the whole of + // the obligation. + let used = executed_custom_plugins(chain); + if used.is_empty() { + return; + } + store.record_plugin_use(&used, now); + // @cpt-end:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-lastused +} + +/// Whether every step of the chain can be held to the sandbox limits. +fn enforceable(chain: &ComposedChain) -> bool { + let limits = crate::domain::plugin_contract::SANDBOX_LIMITS; + let auth_kind = match &chain.auth { + ComposedAuth::Noop | ComposedAuth::Builtin { .. } => { + super::sandbox::InvocationKind::Builtin + } + ComposedAuth::Custom { .. } => super::sandbox::InvocationKind::CustomSource, + }; + if !super::sandbox::enforceable(auth_kind, &limits) { + return false; + } + chain + .guard_request + .iter() + .chain(chain.transform_request.iter()) + .chain(chain.guard_response.iter()) + .chain(chain.transform_response.iter()) + .chain(chain.transform_error.iter()) + .all(|step| step_kind(step).is_some_and(|kind| super::sandbox::enforceable(kind, &limits))) +} + +/// The invocation kind of one step, or `None` when the step declares no +/// implementation this run can invoke. +fn step_kind(step: &ComposedStep) -> Option { + match step { + ComposedStep::Builtin { .. } => Some(super::sandbox::InvocationKind::Builtin), + ComposedStep::Custom { .. } => Some(super::sandbox::InvocationKind::CustomSource), + } +} + +/// Runs one request-phase guard under the sandbox discipline. +#[allow(clippy::result_large_err)] +fn run_guard( + step: &ComposedStep, + request: &RequestContext, + limits: &SandboxLimits, +) -> Result<(), DomainError> { + let Some(guard) = guard_of(step) else { + return Err(unresolved(step)); + }; + let config = step_config(step); + let bytes = serde_json::to_vec(&config).map_or(0, |encoded| encoded.len()) + + request + .headers + .iter() + .map(|(name, value)| name.len() + value.len()) + .sum::(); + super::sandbox::admit( + step_kind(step).unwrap_or(super::sandbox::InvocationKind::Builtin), + limits, + bytes, + ) + .map_err(sandbox_refusal)?; + let decision = super::sandbox::invoke(limits, || guard.guard_request(request, config)) + .map_err(sandbox_failure)?; + rejection_of(&decision) +} + +/// Runs one response-phase guard under the sandbox discipline. +#[allow(clippy::result_large_err)] +fn run_response_guard( + step: &ComposedStep, + response: &ResponseContext, + limits: &SandboxLimits, +) -> Result<(), DomainError> { + let Some(guard) = guard_of(step) else { + return Err(unresolved(step)); + }; + let config = step_config(step); + super::sandbox::admit( + step_kind(step).unwrap_or(super::sandbox::InvocationKind::Builtin), + limits, + 0, + ) + .map_err(sandbox_refusal)?; + let decision = super::sandbox::invoke(limits, || guard.guard_response(response, config)) + .map_err(sandbox_failure)?; + rejection_of(&decision) +} + +/// Runs one request-phase transform under the sandbox discipline. +#[allow(clippy::result_large_err)] +fn run_transform( + step: &ComposedStep, + request: &mut RequestContext, + limits: &SandboxLimits, +) -> Result<(), DomainError> { + let Some(transform) = transform_of(step) else { + return Err(unresolved(step)); + }; + let config = step_config(step); + super::sandbox::admit( + step_kind(step).unwrap_or(super::sandbox::InvocationKind::Builtin), + limits, + 0, + ) + .map_err(sandbox_refusal)?; + super::sandbox::invoke(limits, || transform.transform_request(request, config)) + .map_err(sandbox_failure) +} + +/// Runs one response-phase transform under the sandbox discipline. +#[allow(clippy::result_large_err)] +fn run_response_transform( + step: &ComposedStep, + response: &mut ResponseContext, + limits: &SandboxLimits, +) -> Result<(), DomainError> { + let Some(transform) = transform_of(step) else { + return Err(unresolved(step)); + }; + let config = step_config(step); + super::sandbox::admit( + step_kind(step).unwrap_or(super::sandbox::InvocationKind::Builtin), + limits, + 0, + ) + .map_err(sandbox_refusal)?; + super::sandbox::invoke(limits, || transform.transform_response(response, config)) + .map_err(sandbox_failure) +} + +/// The guard implementation of one step, or `None` for a step that declares +/// none this run can invoke. +fn guard_of(step: &ComposedStep) -> Option> { + match step { + ComposedStep::Builtin { guard, .. } => guard.clone(), + ComposedStep::Custom { .. } => None, + } +} + +/// The transform implementation of one step, or `None` for a step that declares +/// none this run can invoke. +fn transform_of( + step: &ComposedStep, +) -> Option> { + match step { + ComposedStep::Builtin { transform, .. } => transform.clone(), + ComposedStep::Custom { .. } => None, + } +} + +/// The configuration one step was bound with. +fn step_config(step: &ComposedStep) -> &Value { + match step { + ComposedStep::Builtin { config, .. } | ComposedStep::Custom { config, .. } => config, + } +} + +/// The verdict a guard produced, mapped to the phase status the caller answers. +#[allow(clippy::result_large_err)] +fn rejection_of(decision: &GuardDecision) -> Result<(), DomainError> { + match decision { + GuardDecision::Allow => Ok(()), + GuardDecision::Reject { code, message } => { + let mut error = DomainError::gateway( + ErrorKind::ValidationError, + "the request or the response violates the contract a guard plugin enforces", + ); + error.detail = format!("{code}: {message}"); + Err(error) + } + } +} + +/// The typed failure a credential resolution returned, mapped onto the +/// catalogue rows `cpt-cf-oagw-algo-credential-resolution` names. +#[allow(clippy::result_large_err)] +fn failure_of(failure: PluginFailure) -> DomainError { + match failure { + PluginFailure::AuthenticationFailed => DomainError::gateway( + ErrorKind::AuthenticationFailed, + "the credential store declined the reference for the calling tenant or subject", + ), + PluginFailure::SecretNotFound => DomainError::gateway( + ErrorKind::SecretNotFound, + "the credential store resolved no secret for the configured reference", + ), + PluginFailure::CredentialShape | PluginFailure::Configuration { .. } => { + DomainError::gateway( + ErrorKind::RouteError, + "the credential reference or the plugin configuration is unusable", + ) + } + PluginFailure::Unavailable => DomainError::gateway( + ErrorKind::LinkUnavailable, + "the credential store or the identity provider was unreachable", + ), + } +} + +/// The failure a sandbox refusal is answered with. +#[allow(clippy::result_large_err)] +fn sandbox_refusal(refusal: super::sandbox::SandboxRefusal) -> DomainError { + match refusal { + super::sandbox::SandboxRefusal::Unenforceable => DomainError::gateway( + ErrorKind::PluginNotFound, + "the plugin's sandbox limits cannot be enforced, so the invocation is refused", + ), + super::sandbox::SandboxRefusal::OverBudget { reason } => { + let mut error = DomainError::gateway( + ErrorKind::ProtocolError, + "the invocation exceeds the budget the sandbox holds it to", + ); + error.detail = reason; + error + } + } +} + +/// The failure a sandbox breach is answered with. +#[allow(clippy::result_large_err)] +fn sandbox_failure(failure: super::sandbox::SandboxFailure) -> DomainError { + // @cpt-begin:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-sandbox-if + // A custom plugin that breached a sandbox limit, exceeded its + // per-invocation timeout, or raised an error is the ELSE IF of the + // response-phase checks, and the failure carries the gateway error source, + // because the breach happened inside the gateway and not at the upstream. + // @cpt-begin:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-sandbox-return + let answer = match failure { + super::sandbox::SandboxFailure::Raised => DomainError::gateway( + ErrorKind::ProtocolError, + "the plugin invocation raised an error and its mutations were discarded", + ), + super::sandbox::SandboxFailure::Timeout { limit_millis } => { + let mut error = DomainError::gateway( + ErrorKind::ProtocolError, + "the plugin invocation exceeded its per-invocation timeout", + ); + error.detail = format!("the invocation was held to {limit_millis} milliseconds"); + error + } + }; + // @cpt-end:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-sandbox-return + // No partial mutation the plugin performed survives the breach: the + // invocation was discarded whole, so the caller answers with this failure + // and nothing the plugin wrote. + answer + // @cpt-end:cpt-cf-oagw-algo-chain-execute:p1:inst-chain-sandbox-if +} + +/// The failure a step that declares no invokable implementation is answered +/// with. +#[allow(clippy::result_large_err)] +fn unresolved(step: &ComposedStep) -> DomainError { + DomainError::gateway( + ErrorKind::PluginNotFound, + format!( + "the bound plugin {} resolves to no implementation this deployment executes", + step.plugin_ref() + ), + ) +} + +/// The request context the guards and transforms read, built from the inbound +/// request. +fn request_context(context: &ProxyContext) -> RequestContext { + let mut request = RequestContext::new( + context.method.clone(), + context.request_path(), + context.query.clone(), + ); + for (name, value) in &context.headers { + request.set_header(name, value.clone()); + } + request +} diff --git a/gears/system/oagw/oagw/src/data_plane/forward.rs b/gears/system/oagw/oagw/src/data_plane/forward.rs new file mode 100644 index 0000000..e01ab25 --- /dev/null +++ b/gears/system/oagw/oagw/src/data_plane/forward.rs @@ -0,0 +1,662 @@ +//! The outbound forwarding of one proxy exchange. +//! +//! Realizes `cpt-cf-oagw-algo-outbound-forward`: the dial-time scheme check, +//! the shared outbound client ADR 0006 assigns the Data Plane, the adaptive +//! per-host HTTP version detection of DESIGN §3.2 Security Considerations, the +//! deadline `proxy_timeout_secs` carries, and the single send. The upstream +//! response is returned as received for `cpt-cf-oagw-algo-response-classify`. +//! +//! The single send is the whole of the retry posture this routine has: the +//! gateway never re-issues the client request (`cpt-cf-oagw-principle-no-retry`), +//! and the connector's own endpoint and connection attempts stay inside it — +//! exactly the clause of `cpt-cf-oagw-fr-request-proxy` that permits an +//! intermediary to retry a connection and not a request. An upstream 401 +//! triggers no refresh, no retry, and no re-send: it is answered under the +//! error-source classification, not here. +//! +//! The routine is split at the seam `cpt-cf-oagw-feature-streaming` consumes: +//! [`OutboundClient::begin`] dials the endpoint and writes the request, and the +//! [`LiveExchange`] it returns is the upstream half of the exchange. Reading +//! the response header is the boundary `RequestTimeout` bounds and the last +//! moment at which the exchange can still be answered as a whole; what +//! `cpt-cf-oagw-algo-stream-pump` reads past that boundary is the body or the +//! tunnel, and the idle deadline is the only deadline over it. + +use std::collections::HashMap; +use std::net::ToSocketAddrs; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use parking_lot::Mutex; +use pingora_core::connectors::http::Connector; +use pingora_core::protocols::http::client::HttpSession; +use pingora_core::protocols::tls::ALPN; +use pingora_core::upstreams::peer::{HttpPeer, PeerOptions}; +use pingora_http::RequestHeader; + +use crate::config::OagwConfig; +use crate::domain::error::{DomainError, ErrorKind}; +use crate::domain::proxy::{OutboundRequest, ProxyResponse}; +use crate::domain::scheme::Scheme; + +/// The lifetime of a per-host HTTP version cache entry, which DESIGN §3.2 +/// states as 1 hour. +pub const HTTP_VERSION_TTL: Duration = Duration::from_secs(3600); + +/// The scheme of a forwarded request: HTTP over TLS, or plaintext. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Transport { + /// HTTP over TLS, where ALPN negotiates the version. + Tls, + /// Plaintext HTTP, which has no ALPN to negotiate with and is always + /// HTTP/1.1 in this run. + Plain, +} + +/// The HTTP version a host is known to answer, as one cache entry of the +/// adaptive detection holds it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Version { + /// The host negotiated HTTP/2, and `ALPN::H2H1` is advertised again. + H2, + /// The host fell back to HTTP/1.1, and only `ALPN::H1` is advertised. + H1, +} + +#[derive(Debug, Clone, Copy)] +struct VersionEntry { + version: Version, + recorded_at: Instant, +} + +/// The shared outbound client the Data Plane holds. +/// +/// One connector per process, constructed once and reused, with the per-host +/// version cache beside it. A cloned handle shares both, which is what makes a +/// detected version visible to every later request in the process. +#[derive(Clone)] +pub struct OutboundClient { + connector: Arc, + versions: Arc>>, +} + +impl Default for OutboundClient { + fn default() -> Self { + Self::new() + } +} + +impl OutboundClient { + /// Constructs the shared client, once per process. + #[must_use] + pub fn new() -> Self { + Self { + connector: Arc::new(Connector::new(None)), + versions: Arc::new(Mutex::new(HashMap::new())), + } + } + + /// The version preference one host is dialled with. + /// + /// A host with no entry, or with an entry past its hour, is dialled with + /// HTTP/2 preferred so the ALPN negotiation can report what it supports; a + /// host known to fall back is dialled HTTP/1.1 only; a plaintext host has + /// no ALPN to negotiate and is dialled HTTP/1.1. + fn preference_of(&self, host: &str, transport: Transport) -> Version { + if transport == Transport::Plain { + return Version::H1; + } + let mut versions = self.versions.lock(); + match versions.get(host) { + Some(entry) if entry.recorded_at.elapsed() < HTTP_VERSION_TTL => entry.version, + _ => { + versions.remove(host); + Version::H2 + } + } + } + + /// Forwards one request and returns the upstream response as received. + /// + /// This is the form the proxy path takes for an exchange whose answer is + /// classified and assembled whole. [`OutboundClient::begin`] is the same + /// send up to the response header, which is where + /// `cpt-cf-oagw-algo-stream-mode-select` selects the transfer mode of the + /// body and `cpt-cf-oagw-algo-stream-pump` takes the transfer over. + /// + /// # Errors + /// + /// Returns the `ProtocolError` failure for a scheme that is never dialled + /// and for the plaintext dial the lifted posture does not authorize, the + /// `LinkUnavailable` failure for an endpoint that cannot be resolved or + /// reached, and the two timeout failures for a connection or an exchange + /// that outlives the deadline. + #[allow(clippy::result_large_err)] + pub async fn send( + &self, + request: &OutboundRequest, + config: &OagwConfig, + ) -> Result { + // @cpt-begin:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-return + let mut live = self.begin(request, config).await?; + let head = live.head().await?; + let body = live.finish().await?; + Ok(ProxyResponse::upstream(head.status, head.headers, body)) + // @cpt-end:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-return + } + + /// Opens the upstream half of one exchange and sends the request over it. + /// + /// The routine dials the selected endpoint, writes the outbound request + /// once, and returns the [`LiveExchange`] whose response head the caller + /// reads next. No response byte is read here, because the mode the body is + /// transferred in is selected from the response headers and not from the + /// body. + /// + /// # Errors + /// + /// Returns the `ProtocolError` failure for a scheme that is never dialled + /// and for the plaintext dial the lifted posture does not authorize, the + /// `LinkUnavailable` failure for an endpoint that cannot be resolved or + /// reached, and the two timeout failures for a connection that outlives the + /// deadline. + #[allow(clippy::result_large_err)] + pub async fn begin( + &self, + request: &OutboundRequest, + config: &OagwConfig, + ) -> Result { + // @cpt-begin:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-scheme + // The dial-time check is a separate check against the same constraint + // the write-time acceptance tests: recording a lifted posture never + // authorizes a plaintext dial, and `wt` and `grpc` are never dialed. + let transport = match request.scheme { + // @cpt-begin:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-scheme-wt-if + Scheme::Wt | Scheme::Grpc => { + // @cpt-begin:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-scheme-wt-return + return Err(DomainError::gateway( + ErrorKind::ProtocolError, + "the selected endpoint's scheme is never dialed by this gateway", + )); + // @cpt-end:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-scheme-wt-return + } + // @cpt-end:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-scheme-wt-if + // @cpt-begin:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-scheme-http-if + Scheme::Http if !config.allow_http_upstream => { + // @cpt-begin:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-scheme-http-return + return Err(DomainError::gateway( + ErrorKind::ProtocolError, + "the plaintext endpoint scheme is not admitted by the runtime posture", + )); + // @cpt-end:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-scheme-http-return + } + // @cpt-end:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-scheme-http-if + Scheme::Http => Transport::Plain, + Scheme::Https | Scheme::Wss => Transport::Tls, + }; + // @cpt-end:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-scheme + + // @cpt-begin:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-scheme-else + // The ELSE of the scheme check: the endpoint's scheme is one this + // posture dials, so the request proceeds over the shared client. + // @cpt-end:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-scheme-else + + // @cpt-begin:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-client + // The shared client is constructed once and reused; this call only + // borrows it. + let client = self.connector.clone(); + // @cpt-end:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-client + + // @cpt-begin:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-version + // On the first request to a host, HTTP/2 is attempted through ALPN + // during the TLS handshake; on success the supported version is cached + // for that host, on fallback HTTP/1.1 is, and on every subsequent + // request the cached version is used. An outbound request that carries + // an `Upgrade` header is dialled HTTP/1.1 in either case, because + // `Connection` and `Upgrade` are HTTP/1.1 hop-by-hop headers and the + // extended CONNECT that would carry a tunnel over HTTP/2 is not a + // mechanism this run delivers, which is the bound DESIGN §3.2 Security + // Considerations' HTTP version negotiation note places on a tunnel. + let upgraded = request + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("upgrade")); + let preference = if upgraded { + Version::H1 + } else { + self.preference_of(&request.host, transport) + }; + // @cpt-end:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-version + + // @cpt-begin:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-deadline + // The one deadline applies to both the connection-establishment phase + // and the exchange phase. + let deadline = Duration::from_secs(config.proxy_timeout_secs.max(1)); + // @cpt-end:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-deadline + + // @cpt-begin:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-link-if + // The endpoint host cannot be resolved or reached at all: the dial is + // refused as a link failure rather than attempted against an address + // the connector would have to unwrap. + let address = (request.host.as_str(), request.port.unwrap_or(443)) + .to_socket_addrs() + .map_err(|_| { + // @cpt-begin:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-link-return + link_unavailable() + // @cpt-end:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-link-return + })? + .next() + .ok_or_else(link_unavailable)?; + // @cpt-end:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-link-if + + let mut options = PeerOptions::new(); + options.connection_timeout = Some(deadline); + options.total_connection_timeout = Some(deadline); + options.read_timeout = Some(deadline); + options.write_timeout = Some(deadline); + options.verify_cert = true; + options.verify_hostname = true; + options.alpn = advertised(preference, transport); + let mut peer = HttpPeer::new(address, transport == Transport::Tls, request.host.clone()); + peer.options = options; + + // @cpt-begin:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-send + // The request is sent once. The connector's own endpoint and connection + // attempts stay inside it; the gateway never re-issues the client + // request. + // @cpt-begin:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-send-else + // The ELSE of the three deadline and link checks: the endpoint + // resolved and the deadline is set, so the request is sent once. + let (session, _reused) = client + .get_http_session(&peer) + .await + .map_err(|error| forward_failure(&error))?; + let mut live = LiveExchange { + client, + versions: Arc::clone(&self.versions), + host: request.host.clone(), + session, + peer, + deadline, + }; + // The deadline is carried on the session itself from here on, so the + // read of the answer's head and the write of the request are bounded by + // it and not only the connection the dial established. The pump lifts + // the read half when it takes the body over, because the body's only + // deadline is the idle one. + live.session.set_read_timeout(Some(deadline)); + live.session.set_write_timeout(Some(deadline)); + live.write(request).await?; + Ok(live) + // @cpt-end:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-send-else + // @cpt-end:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-send + } +} + +/// The upstream half of one live exchange, after the request was written and +/// before the answer is read. +/// +/// The response header is read first, because it is the boundary +/// `proxy_timeout_secs` bounds and the last moment at which the exchange can +/// still be answered as a whole. Past it, the body is read one chunk at a time +/// or consumed away into the raw stream an upgrade is carried over, and the +/// session is never returned to the shared client's pool while either is still +/// in flight. +pub struct LiveExchange { + client: Arc, + versions: Arc>>, + host: String, + session: HttpSession, + peer: HttpPeer, + deadline: Duration, +} + +/// The head of the upstream's answer, as received. +#[derive(Debug, Clone)] +pub struct ResponseHead { + /// The response status as the upstream sent it. + pub status: u16, + /// The response headers, lower-cased and in arrival order. + pub headers: Vec<(String, String)>, +} + +impl ResponseHead { + /// The first value of one header, compared case-insensitively. + #[must_use] + pub fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|(candidate, _)| candidate.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.as_str()) + } +} + +impl LiveExchange { + /// Writes the outbound request over the session the dial opened. + /// + /// # Errors + /// + /// Returns the `ProtocolError` failure for a request header that cannot be + /// built, and the failures the connector reports for a write that outlives + /// the deadline. + #[allow(clippy::result_large_err)] + async fn write(&mut self, request: &OutboundRequest) -> Result<(), DomainError> { + // The outbound path already carries the query the route admitted. + let path_and_query = request.path.clone(); + let mut header = RequestHeader::build( + request.method.as_str(), + path_and_query.as_bytes(), + Some(request.headers.len()), + ) + .map_err(|error| protocol_failure(&error))?; + for (name, value) in &request.headers { + header + .insert_header(name.clone(), value.as_str()) + .map_err(|error| protocol_failure(&error))?; + } + // The gateway holds the whole request body, so its length is known and is + // stated: a body sent without a length has no framing an HTTP/1.1 reader + // can trust, and the upstream would read it as an empty one. This is the + // framing half of `inst-fwd-send`, which the single send carries. + if !request.body.is_empty() + && !request + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("content-length")) + { + header + .insert_header("content-length", request.body.len().to_string()) + .map_err(|error| protocol_failure(&error))?; + } + self.session + .write_request_header(Box::new(header)) + .await + .map_err(|error| forward_failure(&error))?; + if request.body.is_empty() { + self.session + .finish_request_body() + .await + .map_err(|error| forward_failure(&error))?; + } else { + self.session + .write_request_body( + bytes::Bytes::from(request.body.clone()), + true, + ) + .await + .map_err(|error| forward_failure(&error))?; + } + Ok(()) + } + + /// Reads the upstream's response header, which is the boundary the + /// `RequestTimeout` deadline bounds and the last moment at which the + /// exchange can still be answered as a whole. + /// + /// # Errors + /// + /// Returns the `RequestTimeout` failure for a header that does not arrive + /// within the deadline, and the `ProtocolError` failure for an answer that + /// carries no response header at all. + #[allow(clippy::result_large_err)] + pub async fn head(&mut self) -> Result { + self.session + .read_response_header() + .await + .map_err(|error| forward_failure(&error))?; + let Some(answered) = self.session.response_header() else { + return Err(DomainError::gateway( + ErrorKind::ProtocolError, + "the upstream answered no response header", + )); + }; + self.record_negotiated(); Ok(ResponseHead { + status: answered.status.as_u16(), + headers: answered + .headers + .iter() + .map(|(name, value)| { + ( + name.as_str().to_ascii_lowercase(), + String::from_utf8_lossy(value.as_bytes()).into_owned(), + ) + }) + .collect(), + }) + } + + /// Records the HTTP version this dial negotiated, for the hour it stays + /// valid. + /// + /// The detection is the adaptive per-host negotiation DESIGN §3.2 Security + /// Considerations states, and it is recorded at the response header for the + /// same reason the preference is read at the dial: a version is a property + /// of the connection the dial opened and not of the answer it carried. + fn record_negotiated(&self) { + let negotiated = match &self.session { + HttpSession::H2(_) => Some(Version::H2), + HttpSession::H1(_) => Some(Version::H1), + HttpSession::Custom(_) => None, + }; + if let Some(version) = negotiated { + record_version(&self.versions, &self.host, version); + } + } + + /// Reads the next chunk of the body, or none at its end. + /// + /// The caller bounds the wait with the idle deadline of the stream, which + /// is the only deadline over a body once the response headers have arrived, + /// so the session's own read timeout is lifted here. + /// + /// # Errors + /// + /// Returns the failure the connector reports for a read that failed. + #[allow(clippy::result_large_err)] + pub async fn chunk(&mut self) -> Result, DomainError> { + self.session.set_read_timeout(None); + self.session + .read_response_body() + .await + .map_err(|error| forward_failure(&error)) + } + + /// Reads the body to completion and returns it. + /// + /// # Errors + /// + /// Returns the failure the connector reports for a read that outlives the + /// deadline or fails. + #[allow(clippy::result_large_err)] + pub async fn finish(mut self) -> Result, DomainError> { + let mut body: Vec = Vec::new(); + while let Some(chunk) = self.chunk().await? { + body.extend_from_slice(&chunk); + } + self.release().await; + Ok(body) + } + + /// Returns the session to the shared client's pool, which closes it when + /// its answer is not reusable — a body still in flight is not — and keeps + /// it when it is. + /// + /// The pump calls this at the clean end of a body transfer. A stream that + /// is still being transferred never reaches it: the exchange is dropped + /// instead, which closes the connection, because a session whose body the + /// pump has not drained cannot answer a later exchange. + pub async fn release(self) { + self.client + .release_http_session(self.session, &self.peer, Some(self.deadline)) + .await; + } + + /// Writes one chunk of the tunnel to the upstream half. + /// + /// A taken-up upgrade ends the message the session was carrying, so its + /// body writer is turned to the close-delimited form that moves the bytes + /// that belong to no message as they are written and flushes each one, and + /// the chunk is written with no end signalled: the tunnel ends by tearing + /// the half down and never by finishing a body. + /// + /// # Errors + /// + /// Returns the failure the session reports for a write that failed. + #[allow(clippy::result_large_err)] + pub async fn write_upstream(&mut self, chunk: &[u8]) -> Result<(), DomainError> { + if let HttpSession::H1(client) = &mut self.session { + client.maybe_upgrade_body_writer(); + } + self.session + .write_request_body(bytes::Bytes::copy_from_slice(chunk), false) + .await + .map_err(|error| forward_failure(&error)) + } + + /// Tears the session down, which closes the connection the tunnel was + /// carried over in both directions. + /// + /// The exchange never reaches the shared client's pool on this path, so + /// the abrupt giving-up the session offers is the one that applies. + pub async fn teardown(mut self) { + self.session.shutdown().await; + } +} + +/// The ALPN one dial advertises, from the cached preference and the transport. +fn advertised(preference: Version, transport: Transport) -> ALPN { + match (preference, transport) { + (Version::H2, Transport::Tls) => ALPN::H2H1, + _ => ALPN::H1, + } +} + +/// Records the version a host negotiated, for the hour it stays valid. +fn record_version(versions: &Mutex>, host: &str, version: Version) { + versions.lock().insert( + String::from(host), + VersionEntry { + version, + recorded_at: Instant::now(), + }, + ); +} + +/// The failure a connector or an exchange error is answered with, mapped onto +/// the catalogue rows the three phases of the forward name. +#[allow(clippy::result_large_err)] +fn forward_failure(error: &pingora_core::Error) -> DomainError { + failure_of_type(&error.etype) +} + +/// The catalogue failure one connector error type maps onto. +#[allow(clippy::result_large_err)] +fn failure_of_type(etype: &pingora_core::ErrorType) -> DomainError { + match etype { + pingora_core::ErrorType::ConnectTimedout | pingora_core::ErrorType::TLSHandshakeTimedout => { + // @cpt-begin:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-deadline-conn-if + // @cpt-begin:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-deadline-conn-return + DomainError::gateway( + ErrorKind::ConnectionTimeout, + "the connection to the selected endpoint was not established within the deadline", + ) + // @cpt-end:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-deadline-conn-return + // @cpt-end:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-deadline-conn-if + } + pingora_core::ErrorType::ReadTimedout | pingora_core::ErrorType::WriteTimedout => { + // @cpt-begin:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-deadline-req-if + // @cpt-begin:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-deadline-req-return + DomainError::gateway( + ErrorKind::RequestTimeout, + "the exchange with the selected endpoint exceeded the deadline", + ) + // @cpt-end:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-deadline-req-return + // @cpt-end:cpt-cf-oagw-algo-outbound-forward:p1:inst-fwd-deadline-req-if + } + pingora_core::ErrorType::ConnectRefused + | pingora_core::ErrorType::ConnectNoRoute + | pingora_core::ErrorType::ConnectError + | pingora_core::ErrorType::SocketError + | pingora_core::ErrorType::TLSHandshakeFailure + | pingora_core::ErrorType::InvalidCert + | pingora_core::ErrorType::HandshakeError + | pingora_core::ErrorType::ConnectionClosed => link_unavailable(), + _ => DomainError::gateway( + ErrorKind::ProtocolError, + "the exchange with the selected endpoint failed as a protocol error", + ), + } +} + +/// The failure a response header or a request header cannot be built with. +#[allow(clippy::result_large_err)] +fn protocol_failure(error: &pingora_core::Error) -> DomainError { + DomainError::gateway( + ErrorKind::ProtocolError, + format!("the outbound request or its answer is not valid: {error}"), + ) +} + +/// The 503 failure an unreachable endpoint is answered with. +#[allow(clippy::result_large_err)] +fn link_unavailable() -> DomainError { + DomainError::gateway( + ErrorKind::LinkUnavailable, + "the selected endpoint could not be resolved or reached", + ) +} + +// @cpt-dod:cpt-cf-oagw-dod-outbound-forwarding:p1 +#[cfg(test)] +mod tests { + use super::{record_version, HTTP_VERSION_TTL, OutboundClient, Transport, Version, VersionEntry}; + + #[test] + fn a_host_with_no_entry_is_dialled_http2_preferred() { + let client = OutboundClient::new(); + assert_eq!( + client.preference_of("tls.vendor.com", Transport::Tls), + Version::H2, + "the first request advertises both so ALPN can report what it supports" + ); + } + + #[test] + fn a_plaintext_host_has_no_version_to_negotiate() { + let client = OutboundClient::new(); + record_version(&client.versions, "plain.vendor.com", Version::H2); + assert_eq!( + client.preference_of("plain.vendor.com", Transport::Plain), + Version::H1, + "no ALPN runs on a plaintext dial" + ); + } + + #[test] + fn a_negotiated_version_is_preferred_for_the_next_request() { + let client = OutboundClient::new(); + record_version(&client.versions, "tls.vendor.com", Version::H1); + assert_eq!( + client.preference_of("tls.vendor.com", Transport::Tls), + Version::H1, + "the second request uses the cached result" + ); + } + + #[test] + fn an_entry_past_its_hour_is_no_longer_used() { + let client = OutboundClient::new(); + client.versions.lock().insert( + String::from("tls.vendor.com"), + VersionEntry { + version: Version::H1, + recorded_at: std::time::Instant::now() - HTTP_VERSION_TTL, + }, + ); + assert_eq!( + client.preference_of("tls.vendor.com", Transport::Tls), + Version::H2, + "the cache entry is dropped once its hour has passed" + ); + } +} diff --git a/gears/system/oagw/oagw/src/data_plane/headers.rs b/gears/system/oagw/oagw/src/data_plane/headers.rs new file mode 100644 index 0000000..d5d169b --- /dev/null +++ b/gears/system/oagw/oagw/src/data_plane/headers.rs @@ -0,0 +1,292 @@ +//! Header transformation of a proxy exchange. +//! +//! Realizes `cpt-cf-oagw-algo-header-transform`: the pure function that turns +//! the validated `ProxyContext` header map and the resolved upstream's +//! `headers` rules into the `OutboundRequest` header map, and turns the +//! upstream response's headers into the ones the caller receives. It is the +//! response half of `cpt-cf-oagw-fr-header-transform` and the request half of +//! `cpt-cf-oagw-dod-header-transformation`. +//! +//! The four categories of DESIGN §3.2 Headers Transformation are handled in +//! order: the routing header is dropped because endpoint selection already +//! consumed it, the eight hop-by-hop headers are dropped, the +//! `headers.request.passthrough` mode decides what of the remainder is +//! forwarded with the caller's `Authorization` never a candidate, and the +//! `set`/`add`/`remove` rules run after the passthrough decision. The +//! `Host`/`:authority` replacement is the endpoint's, and never the routing +//! function of `X-OAGW-Target-Host`. + +use crate::domain::error::{DomainError, ErrorKind}; +use crate::domain::proxy::{PluginMutations, ProxyContext, SelectedEndpoint}; +use crate::domain::stream::{HANDSHAKE_HEADERS, UpgradeDetection}; +use crate::domain::upstream::{HeadersConfig, Passthrough}; + +/// The port the shipped schema documents as an endpoint's default, which an +/// authority does not restate. +const DEFAULT_ENDPOINT_PORT: u16 = 443; + +// @cpt-dod:cpt-cf-oagw-dod-header-transformation:p1 + +/// Builds the outbound request header map. +/// +/// `plugin_headers` are the entries the plugin chain added or mutated during +/// the request phase, carried in after the configuration rules so a plugin sees +/// the transformed request and not the inbound one. +/// +/// `handshake` is the upgrade detection `cpt-cf-oagw-feature-streaming` made +/// before this routine ran: a detected upgrade request carries the suspension +/// of two of the eight hop-by-hop headers and the admission of the handshake's +/// own request headers, and every other request carries none. +/// +/// # Errors +/// +/// Returns the `ValidationError` failure when the resulting map carries a +/// value with CR or LF, or an invalid well-known header for the direction. +#[allow(clippy::result_large_err)] +pub fn transform_request( + context: &ProxyContext, + headers: &HeadersConfig, + selected: &SelectedEndpoint, + mutations: &PluginMutations, + handshake: Option, +) -> Result, DomainError> { + let rules = headers.request.clone().unwrap_or_default(); + let mut outbound: Vec<(String, String)> = Vec::new(); + + // @cpt-begin:cpt-cf-oagw-algo-header-transform:p1:inst-hdr-routing + // The routing header is consumed by endpoint selection and never forwarded. + // @cpt-end:cpt-cf-oagw-algo-header-transform:p1:inst-hdr-routing + + // @cpt-begin:cpt-cf-oagw-algo-header-transform:p1:inst-hdr-hop + // The eight hop-by-hop headers of DESIGN §3.2's table are stripped. The + // exception that suspends two of them belongs to the streaming feature and + // reaches this routine only as the suspension a detected upgrade request + // carries, so a plain request/response exchange is stripped over all eight. + // @cpt-end:cpt-cf-oagw-algo-header-transform:p1:inst-hdr-hop + let suspended: &[&str] = if handshake.is_some() { + &["upgrade", "connection"] + } else { + &[] + }; + + // @cpt-begin:cpt-cf-oagw-algo-header-transform:p1:inst-hdr-passthrough + // `none`, the shipped-schema default, forwards no inbound header; + // `allowlist` forwards exactly the names of `passthrough_allowlist`; `all` + // forwards the remainder. The caller's `Authorization` value is never a + // passthrough candidate in any mode: the platform middleware consumed it + // before this routine ran, and no step of the flow reads it again. + let mode = rules.passthrough.unwrap_or(Passthrough::None); + let mut allowed: Vec = Vec::new(); + if mode == Passthrough::Allowlist { + allowed = rules + .passthrough_allowlist + .iter() + .map(|name| name.to_ascii_lowercase()) + .collect(); + } + for (name, value) in &context.headers { + let lowered = name.to_ascii_lowercase(); + if lowered == "x-oagw-target-host" || lowered == "authorization" { + continue; + } + // @cpt-begin:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-six + // The other six hop-by-hop headers stay stripped exactly as the + // unconditional rule strips them, because an upgrade changes the + // meaning of neither, so only the two the suspension names are kept. + if super::validate::HOP_BY_HOP.contains(&lowered.as_str()) + && !suspended.contains(&lowered.as_str()) + { + continue; + } + // @cpt-end:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-six + // @cpt-begin:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-sec + // The WebSocket handshake's own request headers reach the upstream + // regardless of the resolved `headers.request.passthrough` mode, + // including at that mode's shipped default of `none`, because they are + // the fields a handshake is judged by and the default forwards none of + // them. No other inbound header is admitted by the suspension. + let admitted = handshake.is_some() + && (suspended.contains(&lowered.as_str()) + || HANDSHAKE_HEADERS.contains(&lowered.as_str())); + // @cpt-end:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-sec + let forwarded = admitted + || match mode { + Passthrough::None => false, + Passthrough::Allowlist => allowed.contains(&lowered), + Passthrough::All => true, + }; + if forwarded { + outbound.push((name.clone(), value.clone())); + } + } + // @cpt-end:cpt-cf-oagw-algo-header-transform:p1:inst-hdr-passthrough + + // @cpt-begin:cpt-cf-oagw-algo-header-transform:p1:inst-hdr-rules + // The rules run in set, add, remove order, so a set overwrites and an add + // appends. + // @cpt-begin:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-rules + // The configured rules and the authority replacement are applied to a + // handshake request exactly as to any other, so the handshake request is + // transformed exactly as a non-upgrade request would be apart from the + // suspension. + for (name, value) in &rules.set { + set_header(&mut outbound, name, value); + } + for (name, value) in &rules.add { + add_header(&mut outbound, name, value); + } + for name in &rules.remove { + remove_header(&mut outbound, name); + } + // @cpt-end:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-rules + // @cpt-end:cpt-cf-oagw-algo-header-transform:p1:inst-hdr-rules + + // @cpt-begin:cpt-cf-oagw-algo-header-transform:p1:inst-hdr-host + // The endpoint's host replaces `Host` on HTTP/1.1 and `:authority` on + // HTTP/2; the two are the same replacement at the two protocol layers, and + // neither ever replaces the routing function of the target-host header. + let authority = authority_of(&selected.endpoint); + set_header(&mut outbound, "host", &authority); + // @cpt-end:cpt-cf-oagw-algo-header-transform:p1:inst-hdr-host + + // @cpt-begin:cpt-cf-oagw-algo-header-transform:p1:inst-hdr-plugin-loop + for (name, value) in &mutations.set { + // @cpt-begin:cpt-cf-oagw-algo-header-transform:p1:inst-hdr-plugin + set_header(&mut outbound, name, value); + // @cpt-end:cpt-cf-oagw-algo-header-transform:p1:inst-hdr-plugin + } + for name in &mutations.removed { + remove_header(&mut outbound, name); + } + // @cpt-end:cpt-cf-oagw-algo-header-transform:p1:inst-hdr-plugin-loop + + // @cpt-begin:cpt-cf-oagw-algo-header-transform:p1:inst-hdr-invalid-if + if let Some(defect) = invalid_header(&outbound) { + // @cpt-begin:cpt-cf-oagw-algo-header-transform:p1:inst-hdr-invalid-return + return Err(invalid_error(&defect)); + // @cpt-end:cpt-cf-oagw-algo-header-transform:p1:inst-hdr-invalid-return + } + // @cpt-end:cpt-cf-oagw-algo-header-transform:p1:inst-hdr-invalid-if + + // @cpt-begin:cpt-cf-oagw-algo-header-transform:p1:inst-hdr-invalid-else + // The ELSE of the validity check: every header the outbound map carries is + // a well-formed name and value, so the request is forwarded as built. + // @cpt-end:cpt-cf-oagw-algo-header-transform:p1:inst-hdr-invalid-else + + // @cpt-begin:cpt-cf-oagw-algo-header-transform:p1:inst-hdr-return + Ok(outbound) + // @cpt-end:cpt-cf-oagw-algo-header-transform:p1:inst-hdr-return +} + +/// Applies the `headers.response` rules to an upstream response's headers and +/// carries the response-phase plugin mutations. +/// +/// The upstream's `content-length` and `transfer-encoding` are dropped with the +/// framing: the gateway buffers the body and re-states the length itself, so a +/// declared length that no longer describes the body it travels with is never +/// emitted. The `X-OAGW-Error-Source` tag the classification adds is not a +/// `headers.response` concern and is never removed here. +#[must_use] +pub fn transform_response( + upstream_headers: &[(String, String)], + headers: &HeadersConfig, + mutations: &PluginMutations, +) -> Vec<(String, String)> { + // @cpt-begin:cpt-cf-oagw-algo-header-transform:p1:inst-hdr-response + let rules = headers.response.clone().unwrap_or_default(); + let mut outbound: Vec<(String, String)> = upstream_headers + .iter() + .filter(|(name, _)| { + let lowered = name.to_ascii_lowercase(); + lowered != "content-length" && lowered != "transfer-encoding" + }) + .cloned() + .collect(); + for (name, value) in &rules.set { + set_header(&mut outbound, name, value); + } + for (name, value) in &rules.add { + add_header(&mut outbound, name, value); + } + for name in &rules.remove { + remove_header(&mut outbound, name); + } + for (name, value) in &mutations.set { + set_header(&mut outbound, name, value); + } + for name in &mutations.removed { + remove_header(&mut outbound, name); + } + outbound + // @cpt-end:cpt-cf-oagw-algo-header-transform:p1:inst-hdr-response +} + +/// Overwrites every entry of one name, keeping the position of the first. +fn set_header(map: &mut Vec<(String, String)>, name: &str, value: &str) { + let mut written = false; + map.retain_mut(|entry| { + if entry.0.eq_ignore_ascii_case(name) { + if written { + return false; + } + entry.1 = String::from(value); + written = true; + } + true + }); + if !written { + map.push((String::from(name), String::from(value))); + } +} + +/// Appends one entry under a name that may already be present. +fn add_header(map: &mut Vec<(String, String)>, name: &str, value: &str) { + map.push((String::from(name), String::from(value))); +} + +/// Removes every entry of one name. +fn remove_header(map: &mut Vec<(String, String)>, name: &str) { + map.retain(|entry| !entry.0.eq_ignore_ascii_case(name)); +} + +/// The first invalid entry of the map, if the map carries one. +/// +/// A CR or LF in a value is a header-injection vector in every direction; a +/// `Host` or `:authority` value that is not a valid authority is the invalid +/// well-known header of DESIGN §3.2's rule for the request direction. +fn invalid_header(map: &[(String, String)]) -> Option { + for (name, value) in map { + if value.contains('\r') || value.contains('\n') || value.contains('\0') { + return Some(format!("{name} carries a control character in its value")); + } + let lowered = name.to_ascii_lowercase(); + if (lowered == "host" || lowered == ":authority") && value.trim().is_empty() { + return Some(format!("{name} is empty")); + } + } + None +} + +/// The 400 failure an invalid header map answers with. +fn invalid_error(defect: &str) -> DomainError { + let mut error = DomainError::gateway( + ErrorKind::ValidationError, + "the transformed header map is not valid for the direction", + ); + error.detail = String::from(defect); + error +} + +/// The authority the selected endpoint is addressed by. +/// +/// The shipped schema documents an endpoint port default of `443`, so a port +/// that carries the default is not restated in the authority and any other is. +#[must_use] +fn authority_of(endpoint: &crate::domain::Endpoint) -> String { + match endpoint.port { + Some(port) if port != DEFAULT_ENDPOINT_PORT => { + format!("{}:{port}", endpoint.host.as_str()) + } + _ => String::from(endpoint.host.as_str()), + } +} diff --git a/gears/system/oagw/oagw/src/data_plane/match_route.rs b/gears/system/oagw/oagw/src/data_plane/match_route.rs new file mode 100644 index 0000000..f01e8e1 --- /dev/null +++ b/gears/system/oagw/oagw/src/data_plane/match_route.rs @@ -0,0 +1,325 @@ +//! Route matching over the resolved candidate set. +//! +//! Realizes `cpt-cf-oagw-algo-route-match`: the method allowlist, the longest +//! path prefix, the `priority` tie-break, and the `path_suffix_mode` decision. +//! The chain-level selection of which routes are candidates at all is +//! `cpt-cf-oagw-feature-hierarchical-config`'s, and this module only evaluates +//! the candidate set that feature produced — the split §1.5 of the FEATURE +//! records, which keeps one route selection from having two owners. + +use uuid::Uuid; + +use crate::domain::effective::{EffectiveCors, EffectivePluginChain, EffectiveRateLimit}; +use crate::domain::error::{DomainError, ErrorKind}; +use crate::domain::proxy::{MatchedRoute, ResolvedUpstream, RouteCandidate}; +use crate::domain::route::PathSuffixMode; +use crate::domain::upstream::SharingMode; + +/// The merged families of the route layer a match carries. +pub type MergedRouteFamilies = ( + Uuid, + Option, + Option, + Option, +); + +/// The outcome of one match, which the caller maps to an answer. +#[derive(Debug, Clone)] +pub enum MatchOutcome { + /// A route matched and the outbound path was built. + Matched(Box), + /// No candidate matched the method or the path. + NoMatch, + /// The path suffix is supplied to a route whose mode rejects it. + SuffixRejected, +} + +// @cpt-dod:cpt-cf-oagw-dod-route-matching:p1 + +/// Selects the `MatchedRoute` from the candidate set of the resolved upstream. +/// +/// The `resolved` carries the candidates; `method` and `path` are the request's, +/// where `path` is the request path the route paths address, and `suffix` is +/// the path suffix as supplied, which is `None` when the request addressed the +/// alias alone and only decides the `disabled` rejection. The tail the outbound +/// path appends is the request path's own remainder beyond the selected route's +/// path, which the matcher reads after it has selected. +#[must_use] +pub fn match_route( + resolved: &ResolvedUpstream, + merged_route: Option<&MergedRouteFamilies>, + method: &str, + path: &str, + suffix: Option<&str>, +) -> MatchOutcome { + // @cpt-begin:cpt-cf-oagw-algo-route-match:p1:inst-match-method + // The method allowlist is the first filter: a route that does not declare + // the request method is never a candidate, whatever its path. + let by_method: Vec<&RouteCandidate> = resolved + .route_candidates + .iter() + .filter(|candidate| enabled(candidate)) + .filter(|candidate| { + candidate + .route + .match_config + .http + .as_ref() + .is_some_and(|http| http.methods.iter().any(|declared| declared == method)) + }) + .collect(); + // @cpt-end:cpt-cf-oagw-algo-route-match:p1:inst-match-method + + // @cpt-begin:cpt-cf-oagw-algo-route-match:p1:inst-match-method-if + if by_method.is_empty() { + // @cpt-begin:cpt-cf-oagw-algo-route-match:p1:inst-match-method-return + return MatchOutcome::NoMatch; + // @cpt-end:cpt-cf-oagw-algo-route-match:p1:inst-match-method-return + } + // @cpt-end:cpt-cf-oagw-algo-route-match:p1:inst-match-method-if + + // @cpt-begin:cpt-cf-oagw-algo-route-match:p1:inst-match-method-else + // The ELSE of the method filter: the candidates that declared the request + // method go on to the path filter, which is the only other filter a + // candidate is subject to. + // @cpt-end:cpt-cf-oagw-algo-route-match:p1:inst-match-method-else + + // @cpt-begin:cpt-cf-oagw-algo-route-match:p1:inst-match-prefix + // The longest configured path that addresses the request path wins. + let by_prefix: Vec<&&RouteCandidate> = by_method + .iter() + .filter(|candidate| { + candidate + .route + .match_config + .http + .as_ref() + .is_some_and(|http| path_matches(&http.path, path)) + }) + .collect(); + // @cpt-end:cpt-cf-oagw-algo-route-match:p1:inst-match-prefix + + // @cpt-begin:cpt-cf-oagw-algo-route-match:p1:inst-match-noprefix-if + if by_prefix.is_empty() { + // @cpt-begin:cpt-cf-oagw-algo-route-match:p1:inst-match-noprefix-return + return MatchOutcome::NoMatch; + // @cpt-end:cpt-cf-oagw-algo-route-match:p1:inst-match-noprefix-return + } + // @cpt-end:cpt-cf-oagw-algo-route-match:p1:inst-match-noprefix-if + + // @cpt-begin:cpt-cf-oagw-algo-route-match:p1:inst-match-tie-if + // The longest prefix wins; of the candidates sharing it, the smallest + // `priority` value wins, and a route that declares none is the least + // specific of its prefix group. + let longest = longest_prefix(&by_prefix); + let finalists: Vec<&&RouteCandidate> = by_prefix + .iter() + .filter(|candidate| { + candidate + .route + .match_config + .http + .as_ref() + .is_some_and(|http| http.path.len() == longest) + }) + .copied() + .collect(); + // @cpt-begin:cpt-cf-oagw-algo-route-match:p1:inst-match-tie + // The tie-break of the prefix group: the smallest declared `priority` + // value wins, and `None` sorts after every declared value. + let selected = finalists + .into_iter() + .min_by_key(|candidate| candidate.route.priority.unwrap_or(i64::MAX)) + .copied(); + // @cpt-end:cpt-cf-oagw-algo-route-match:p1:inst-match-tie + let Some(selected) = selected else { + return MatchOutcome::NoMatch; + }; + // @cpt-end:cpt-cf-oagw-algo-route-match:p1:inst-match-tie-if + + // @cpt-begin:cpt-cf-oagw-algo-route-match:p1:inst-match-suffix-none-else + // The ELSE of the disabled rejection: the selected route admits the suffix + // as presented, and the outbound path is decided by the mode. + // @cpt-end:cpt-cf-oagw-algo-route-match:p1:inst-match-suffix-none-else + + + let Some(http) = selected.route.match_config.http.as_ref() else { + return MatchOutcome::NoMatch; + }; + + // @cpt-begin:cpt-cf-oagw-algo-route-match:p1:inst-match-suffix-read + // The shipped-schema default is `append`, which is what a route that + // declares no mode gets. + let mode = selected + .route + .match_config + .http + .as_ref() + .and_then(|http| http.path_suffix_mode) + .unwrap_or(PathSuffixMode::Append); + // @cpt-end:cpt-cf-oagw-algo-route-match:p1:inst-match-suffix-read + + // @cpt-begin:cpt-cf-oagw-algo-route-match:p1:inst-match-suffix-disabled-if + if mode == PathSuffixMode::Disabled && suffix.is_some() { + // @cpt-begin:cpt-cf-oagw-algo-route-match:p1:inst-match-suffix-disabled-return + return MatchOutcome::SuffixRejected; + // @cpt-end:cpt-cf-oagw-algo-route-match:p1:inst-match-suffix-disabled-return + } + // @cpt-end:cpt-cf-oagw-algo-route-match:p1:inst-match-suffix-disabled-if + + // @cpt-begin:cpt-cf-oagw-algo-route-match:p1:inst-match-suffix-append-else + // The ELSE IF of the suffix decision: the mode is `append` and a path + // suffix was supplied, so the outbound path is the route's own with the + // tail appended. + // @cpt-begin:cpt-cf-oagw-algo-route-match:p1:inst-match-suffix-append + // The tail the request carried beyond the matched path: the part of the + // request path that `append` puts on the outbound path. A request that + // addressed the route path alone carries none, and a remainder of only + // separators appends nothing, because `append_suffix` trims them. + let tail = if path.len() > http.path.len() { + &path[http.path.len()..] + } else { + "" + }; + // @cpt-end:cpt-cf-oagw-algo-route-match:p1:inst-match-suffix-append + // @cpt-end:cpt-cf-oagw-algo-route-match:p1:inst-match-suffix-append-else + + let outbound_path = match (mode, suffix) { + (PathSuffixMode::Append, Some(_)) => append_suffix(&http.path, tail), + // @cpt-begin:cpt-cf-oagw-algo-route-match:p1:inst-match-suffix-none-else + // The ELSE of the suffix decision: no suffix was supplied, or the mode + // forwards none, so the outbound path is the route's own. + // @cpt-begin:cpt-cf-oagw-algo-route-match:p1:inst-match-suffix-none + _ => http.path.clone(), + // @cpt-end:cpt-cf-oagw-algo-route-match:p1:inst-match-suffix-none + // @cpt-end:cpt-cf-oagw-algo-route-match:p1:inst-match-suffix-none-else + }; + + // @cpt-begin:cpt-cf-oagw-algo-route-match:p1:inst-match-return + let (rate_limit, plugins, cors) = merged_families(selected, merged_route); + MatchOutcome::Matched(Box::new(MatchedRoute { + tenant_id: selected.tenant_id, + route_id: selected.route.id, + priority: selected.route.priority, + outbound_path, + match_pattern: http.path.clone(), + query_allowlist: http.query_allowlist.clone(), + rate_limit, + plugins, + cors, + })) + // @cpt-end:cpt-cf-oagw-algo-route-match:p1:inst-match-return +} + +/// Maps a match outcome to the failure the caller answers, which is the 404 of +/// an unmatched route or the 400 of a rejected suffix. +/// +/// # Errors +/// +/// Returns the `RouteNotFound` error for the no-match outcome and the +/// `ValidationError` error for a suffix a route with `path_suffix_mode: +/// disabled` received. +pub fn failure_of(outcome: &MatchOutcome) -> Option { + match outcome { + MatchOutcome::Matched(_) => None, + MatchOutcome::NoMatch => Some(DomainError::gateway( + ErrorKind::RouteNotFound, + "no route of the resolved upstream matches the request method and path", + )), + MatchOutcome::SuffixRejected => Some(DomainError::gateway( + ErrorKind::ValidationError, + "the request supplies a path suffix to a route that declares path_suffix_mode disabled", + )), + } +} + +/// Whether a candidate's route is enabled, which is the route's own flag. +fn enabled(candidate: &RouteCandidate) -> bool { + candidate.route.enabled != Some(false) +} + +/// Whether the configured path addresses the request path. +/// +/// A configured path matches itself and every path it prefixes at a segment +/// boundary, which is how the hierarchical feature reads the same table: +/// `/v1` addresses `/v1` and `/v1/chat`, and never `/v1chat`. +#[must_use] +fn path_matches(configured: &str, request: &str) -> bool { + if configured == request { + return true; + } + request.starts_with(configured) && request.as_bytes().get(configured.len()) == Some(&b'/') +} + +/// The length of the longest configured prefix in the candidate set. +fn longest_prefix(candidates: &[&&RouteCandidate]) -> usize { + candidates + .iter() + .filter_map(|candidate| { + candidate + .route + .match_config + .http + .as_ref() + .map(|http| http.path.len()) + }) + .max() + .unwrap_or(0) +} + +/// Joins the route path with the supplied suffix at a path boundary. +fn append_suffix(route_path: &str, suffix: &str) -> String { + let trimmed = suffix.trim_matches('/'); + if trimmed.is_empty() { + return route_path.to_owned(); + } + format!("{}/{}", route_path.trim_end_matches('/'), trimmed) +} + +/// The merged families of the selected route. +/// +/// The merged result of the hierarchy walk is used when it resolved the same +/// route; a route the walk's selector did not resolve contributes its own +/// values, which is the route-layer answer for a longer-prefix route on the +/// same upstream the coarse selector skipped. +fn merged_families( + selected: &RouteCandidate, + merged: Option<&MergedRouteFamilies>, +) -> ( + Option, + Option, + Option, +) { + match merged { + Some((route_id, rate_limit, plugins, cors)) if route_id == &selected.route.id => { + (rate_limit.clone(), plugins.clone(), cors.clone()) + } + _ => ( + selected.route.rate_limit.clone().map(|rate_limit| { + let mode = rate_limit.sharing.unwrap_or(SharingMode::Private); + EffectiveRateLimit { + owner: selected.tenant_id, + mode, + rate_limit, + } + }), + selected.route.plugins.clone().map(|plugins| { + let mode = plugins.sharing.unwrap_or(SharingMode::Private); + EffectivePluginChain { + owner: selected.tenant_id, + mode, + items: plugins.items, + contributors: vec![selected.tenant_id], + } + }), + // A route the walk's selector did not resolve contributes its own + // CORS object, with the mode its own `sharing` names, which is the + // same route-layer answer the other two families give. + selected.route.cors.clone().map(|cors| EffectiveCors { + owner: selected.tenant_id, + mode: cors.sharing.unwrap_or(SharingMode::Private), + cors, + }), + ), + } +} diff --git a/gears/system/oagw/oagw/src/data_plane/mod.rs b/gears/system/oagw/oagw/src/data_plane/mod.rs new file mode 100644 index 0000000..4f5cc62 --- /dev/null +++ b/gears/system/oagw/oagw/src/data_plane/mod.rs @@ -0,0 +1,43 @@ +//! Data Plane of the `oagw` gear. +//! +//! One module per CDSL routine of `cpt-cf-oagw-feature-data-plane-proxy`, free +//! of transport types exactly as `control_plane` is: the handler assembles the +//! transport shapes from the domain entities these routines produce and maps +//! their failures through the foundation's error mapping. The layer holds the +//! two pieces of state `cpt-cf-oagw-adr-state-management` assigns the Data +//! Plane and this feature owns — the L1 configuration cache and the shared +//! outbound client — plus the per-upstream round-robin counter. +//! +//! The request path is, in the order the proxy flow states it: +//! [`cache`] → [`resolve`] → [`match_route`] → [`endpoint`] → [`validate`] → +//! the rate-limit seam → [`execute`] → [`headers`] → [`forward`]. + +// @cpt-dod:cpt-cf-oagw-dod-low-latency:p1 + +pub mod cache; +pub mod classify; +pub mod endpoint; +pub mod execute; +pub mod forward; +pub mod headers; +pub mod match_route; +pub mod observability; +pub mod ratelimit; +pub mod resolve; +pub mod sandbox; +pub mod stream; +pub mod validate; + +pub use cache::{DpCache, DP_CACHE_CAPACITY}; +pub use classify::{classify_upstream, classify_upstream_head}; +pub use endpoint::{select_endpoint, RoundRobin}; +pub use execute::run_request_phase; +pub use headers::{transform_request, transform_response}; +pub use match_route::{match_route, MatchOutcome}; +pub use ratelimit::{ + LimitIdentity, LimitVerdict, RateLimitHeaders, RegistryCleanup, RESPONSE_HEADERS_GATE, + SharedLimits, check, rate_limit_headers, upstream_prefix, +}; +pub use resolve::{consume, Resolution}; +pub use stream::{incremental, tunnel}; +pub use validate::{validate_body, validate_inbound}; diff --git a/gears/system/oagw/oagw/src/data_plane/observability.rs b/gears/system/oagw/oagw/src/data_plane/observability.rs new file mode 100644 index 0000000..ce45f2d --- /dev/null +++ b/gears/system/oagw/oagw/src/data_plane/observability.rs @@ -0,0 +1,1972 @@ +//! The in-process observation seam of `cpt-cf-oagw-feature-observability`. +//! +//! Three mechanisms live here, which is the whole of the feature's runtime +//! presence: the [`MetricsRegistry`] the twelve families of DESIGN §4.2 are +//! collected into and rendered from, the audit emitter that turns an +//! [`AuditEvent`] into one JSON line and hands it to an [`AuditSink`], and the +//! [`Observability`] facade that joins them and owns the two build-time +//! constants' state — the sampling decision is the correlation context's, and +//! the failure-log bound is this module's window. +//! +//! The seam is in-process because the single-executable deployment is what +//! makes it the only mechanism the feature has +//! (`cpt-cf-oagw-constraint-toolkit-deploy`): a scrape of `GET /oagw/v1/metrics` +//! reads the same process that served the traffic, and the audit stream is +//! written to the stdout that process already owns. No persisted state is +//! created, transitioned, or retained: a restart empties the counters, resets +//! the gauges, and loses no record that had not already been written. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use parking_lot::Mutex; +use uuid::Uuid; + +use crate::domain::error::ErrorKind; +use crate::domain::observability::{ + AuditEvent, CorrelationContext, MetricLabelSet, SamplingDecision, AUTH_FAILURE_LOG_INTERVAL_MS, + AUTH_FAILURE_LOG_LIMIT, CORRELATION_HEADER, ERROR_TYPE_UPSTREAM, HISTOGRAM_BUCKETS, + is_high_volume_pattern, +}; +use crate::domain::proxy::ProxyResponse; +use crate::domain::ratelimit::{BreakerPhase, BreakerTransition}; + +/// The `host` value the three answer families carry for a request the gateway +/// answered without resolving an upstream. +/// +/// Such a request addressed an alias the gateway names no configured upstream +/// for, so the alias it carries is one the caller invented, and filing the +/// answer under it would let a caller grow the label set without bound — the +/// outcome the cardinality rules of DESIGN §4.2 exist to prevent. The one +/// literal is not a hostname and cannot collide with a configured alias, which +/// `Alias::parse` normalizes, and `oagw_requests_in_flight` keeps the addressed +/// alias because its raise and its lower must name the same series. +pub const UNRESOLVED_HOST: &str = "_unresolved"; + +/// The families the exposition declares, with the type and the help line each +/// is rendered with. +/// +/// The twelve rows are the twelve families DESIGN §4.2 enumerates, in the order +/// that section lists them, and no row outside them is declared: adding a +/// family no supplied document names is the one cardinality breach the +/// FEATURE's §5 forbids outright. +const FAMILIES: [Family; 12] = [ + Family { + name: "oagw_requests_total", + kind: Kind::Counter, + help: "Proxy requests the gateway served, by upstream, method, route, and status.", + exposed: true, + }, + Family { + name: "oagw_request_duration_seconds", + kind: Kind::Histogram, + help: "Request duration by phase, in seconds.", + exposed: true, + }, + Family { + name: "oagw_requests_in_flight", + kind: Kind::Gauge, + help: "Proxy requests admitted and not yet finished, by upstream.", + exposed: true, + }, + Family { + name: "oagw_errors_total", + kind: Kind::Counter, + help: "Failed requests, by upstream, route, and error type.", + exposed: true, + }, + Family { + name: "oagw_circuit_breaker_state", + kind: Kind::Gauge, + help: "The phase the circuit breaker of an upstream holds.", + exposed: true, + }, + Family { + name: "oagw_rate_limit_exceeded_total", + kind: Kind::Counter, + help: "Requests a rate limit refused, by upstream and route.", + exposed: true, + }, + Family { + name: "oagw_circuit_breaker_transitions_total", + kind: Kind::Counter, + help: "Circuit-breaker transitions, by upstream and the two phases.", + exposed: true, + }, + Family { + name: "oagw_rate_limit_usage_ratio", + kind: Kind::Gauge, + help: "The allowance ratio of the effective rate limit, from 0.0 to 1.0.", + exposed: true, + }, + Family { + name: "oagw_routing_target_host_used", + kind: Kind::Counter, + help: "Requests that named their endpoint through the routing header.", + exposed: true, + }, + Family { + name: "oagw_routing_endpoint_selected", + kind: Kind::Counter, + help: "Endpoint selections, by upstream, endpoint, and selection method.", + exposed: true, + }, + Family { + name: "oagw_upstream_available", + kind: Kind::Gauge, + help: "Whether the breaker of an upstream admits an attempt: 1 when it does, 0 when it does not.", + exposed: true, + }, + Family { + name: "oagw_upstream_connections", + kind: Kind::Gauge, + help: "The shared outbound client's connection-pool occupancy, by state.", + exposed: false, + }, +]; + +/// The exposition kind of one family. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Kind { + Counter, + Gauge, + Histogram, +} + +impl Kind { + fn label(self) -> &'static str { + match self { + Kind::Counter => "counter", + Kind::Gauge => "gauge", + Kind::Histogram => "histogram", + } + } +} + +/// One declared family: its name, its exposition kind, its help line, and +/// whether the gear exposes the state it describes. +/// +/// A family whose underlying state is not exposed is declared — its label set +/// is part of `MetricLabelSet` — and omitted from the exposition, rather than +/// emitted as a constant the gear does not measure. +#[derive(Debug, Clone, Copy)] +struct Family { + name: &'static str, + kind: Kind, + help: &'static str, + exposed: bool, +} + +/// One series of one family: the label pairs in the family's declared order. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct SeriesKey { + family: &'static str, + labels: Vec<(&'static str, String)>, +} + +impl SeriesKey { + fn new(family: &'static str, labels: &[(&'static str, String)]) -> Self { + Self { + family, + labels: labels.to_vec(), + } + } + + /// The series' label pairs reordered into the family's declared order. + /// + /// A label the family does not declare is a cardinality breach, and the + /// series that carries one is rendered as none: the declaration is the + /// checkable property the cardinality rules of the FEATURE's §5 rest on. + fn ordered(&self) -> Option> { + let declared = MetricLabelSet::labels_of(self.family)?; + let mut ordered = Vec::with_capacity(declared.len()); + for key in declared { + let value = self + .labels + .iter() + .find(|(candidate, _)| candidate == key) + .map(|(_, value)| value.clone())?; + ordered.push((*key, value)); + } + Some(ordered) + } +} + +/// The accumulated observation of one histogram series. +#[derive(Debug, Clone, Copy, Default)] +struct Histogram { + buckets: [u64; HISTOGRAM_BUCKETS.len()], + sum: f64, + count: u64, +} + +impl Histogram { + fn observe(&mut self, value: f64) { + for (index, bound) in HISTOGRAM_BUCKETS.iter().enumerate() { + if value <= *bound { + self.buckets[index] += 1; + } + } + self.sum += value; + self.count += 1; + } +} + +/// The collector the twelve families are observed into. +/// +/// Every family is a map from its label-value combinations to its value, and a +/// family that has observed nothing holds no series, which is what the +/// renderer reports as a family with its type and help and no samples. +#[derive(Debug, Default)] +pub struct MetricsRegistry { + inner: Mutex, +} + +#[derive(Debug, Default)] +struct Inner { + counters: HashMap, + gauges: HashMap, + histograms: HashMap, +} + +impl MetricsRegistry { + /// Creates an empty registry. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Increments one counter series by one. + pub fn increment(&self, family: &'static str, labels: &[(&'static str, String)]) { + let mut inner = self.inner.lock(); + *inner + .counters + .entry(SeriesKey::new(family, labels)) + .or_default() += 1; + } + + /// Adds a delta to one gauge series, which is the raise and the lower of + /// the in-flight gauge. + pub fn add(&self, family: &'static str, labels: &[(&'static str, String)], delta: f64) { + let mut inner = self.inner.lock(); + *inner.gauges.entry(SeriesKey::new(family, labels)).or_default() += delta; + } + + /// Sets one gauge series to a value, which is how a state another feature + /// owns is reported. + pub fn set(&self, family: &'static str, labels: &[(&'static str, String)], value: f64) { + let mut inner = self.inner.lock(); + inner + .gauges + .insert(SeriesKey::new(family, labels), value); + } + + /// Records one observation into a histogram series. + pub fn observe(&self, family: &'static str, labels: &[(&'static str, String)], value: f64) { + let mut inner = self.inner.lock(); + inner + .histograms + .entry(SeriesKey::new(family, labels)) + .or_default() + .observe(value); + } + + /// Renders the Prometheus text exposition of the twelve families. + /// + /// A family that has observed nothing is rendered with its type and help + /// and no samples rather than omitted, so a scraper that reads the + /// exposition sees the catalogue and not a moving subset of it. The label + /// values are escaped as the text exposition format requires, and the + /// series of a family are rendered in label order so a scrape is stable. + #[must_use] + pub fn render(&self) -> String { + // @cpt-begin:cpt-cf-oagw-algo-metrics-render:p1:inst-amr-read + // Each of the twelve collectors is read at the moment the scrape is + // served, so a family describing state another feature owns reports + // that state as it stands and not as it stood at the last observation. + let inner = self.inner.lock(); + let mut out = String::new(); + // @cpt-end:cpt-cf-oagw-algo-metrics-render:p1:inst-amr-read + for family in FAMILIES { + if !family.exposed { + continue; + } + // @cpt-begin:cpt-cf-oagw-algo-metrics-render:p1:inst-amr-headers + // A `# HELP` line and a `# TYPE` line are emitted for each family, + // declaring counter, gauge, or histogram as DESIGN §4.2 assigns, + // and a family that has observed nothing is emitted with its type + // and help and no samples rather than omitted. + out.push_str("# HELP "); + out.push_str(family.name); + out.push(' '); + out.push_str(&escape_help(family.help)); + out.push('\n'); + out.push_str("# TYPE "); + out.push_str(family.name); + out.push(' '); + out.push_str(family.kind.label()); + out.push('\n'); + // @cpt-end:cpt-cf-oagw-algo-metrics-render:p1:inst-amr-headers + let mut series: Vec = Vec::new(); + match family.kind { + Kind::Counter => { + for (key, value) in &inner.counters { + if key.family != family.name { + continue; + } + if let Some(ordered) = key.ordered() { + series.push(Series { + labels: ordered, + suffix: "", + value: value.to_string(), + }); + } + } + } + Kind::Gauge => { + for (key, value) in &inner.gauges { + if key.family != family.name { + continue; + } + if let Some(ordered) = key.ordered() { + series.push(Series { + labels: ordered, + suffix: "", + value: render_gauge(*value), + }); + } + } + } + Kind::Histogram => { + // @cpt-begin:cpt-cf-oagw-algo-metrics-render:p1:inst-amr-histogram + // The histogram family is rendered as its `_bucket` series + // over the twelve buckets DESIGN §4.2 states, with the + // `le` label, plus its `_sum` and its `_count` series, and + // every other family as one series per label-value + // combination its set admits. + for (key, histogram) in &inner.histograms { + if key.family != family.name { + continue; + } + let Some(ordered) = key.ordered() else { + continue; + }; + for (index, bound) in HISTOGRAM_BUCKETS.iter().enumerate() { + // `observe` already folds every observation into + // every bucket at or above it, so the stored count + // is the cumulative one the `le` label promises; + // folding it again here would double-count. + let mut labels = ordered.clone(); + labels.push(("le", render_bound(*bound))); + series.push(Series { + labels, + suffix: "_bucket", + value: histogram.buckets[index].to_string(), + }); + } + let mut labels = ordered.clone(); + labels.push(("le", String::from("+Inf"))); + series.push(Series { + labels, + suffix: "_bucket", + value: histogram.count.to_string(), + }); + series.push(Series { + labels: ordered.clone(), + suffix: "_sum", + value: render_gauge(histogram.sum), + }); + series.push(Series { + labels: ordered.clone(), + suffix: "_count", + value: histogram.count.to_string(), + }); + } + // @cpt-end:cpt-cf-oagw-algo-metrics-render:p1:inst-amr-histogram + } + } + series.sort_by(|left, right| { + let left = format!("{}{}", left.suffix, render_labels(&left.labels)); + let right = format!("{}{}", right.suffix, render_labels(&right.labels)); + left.cmp(&right) + }); + // @cpt-begin:cpt-cf-oagw-algo-metrics-render:p1:inst-amr-values + // Every label value is rendered under the closed sets §1.5 + // records, so no value reaches the exposition that the cardinality + // rules would exclude, and no label of any family carries a tenant + // value. + for series in series { + out.push_str(family.name); + out.push_str(series.suffix); + out.push_str(&render_labels(&series.labels)); + out.push(' '); + out.push_str(&series.value); + out.push('\n'); + } + // @cpt-end:cpt-cf-oagw-algo-metrics-render:p1:inst-amr-values + } + // @cpt-begin:cpt-cf-oagw-algo-metrics-render:p1:inst-amr-return + // RETURN the rendered exposition; the caller answers it with the + // content type the text exposition format names and writes no audit + // record for the scrape. + out + // @cpt-end:cpt-cf-oagw-algo-metrics-render:p1:inst-amr-return + } +} + +/// One rendered series: its label pairs, the histogram suffix it carries, and +/// the value it reports. +struct Series { + labels: Vec<(&'static str, String)>, + suffix: &'static str, + value: String, +} + +/// Renders one label list as the text exposition format writes it. +fn render_labels(labels: &[(&'static str, String)]) -> String { + if labels.is_empty() { + return String::new(); + } + let rendered: Vec = labels + .iter() + .map(|(key, value)| format!("{key}=\"{}\"", escape_label(value))) + .collect(); + format!("{{{}}}", rendered.join(",")) +} + +/// Renders a gauge value, with the whole and fractional forms the format +/// distinguishes. +fn render_gauge(value: f64) -> String { + if value == value.trunc() && value.abs() < 1_000_000.0 { + format!("{}", value as i64) + } else { + format!("{value}") + } +} + +/// Renders a histogram bucket bound as the format writes it. +fn render_bound(bound: f64) -> String { + if bound == bound.trunc() { + format!("{}", bound as i64) + } else { + format!("{bound}") + } +} + +/// Escapes a help line: backslash and newline, as the format requires. +fn escape_help(value: &str) -> String { + value.replace('\\', "\\\\").replace('\n', "\\n") +} + +/// Escapes a label value: backslash, double quote, and newline. +fn escape_label(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") +} + +/// The destination an audit record is written to. +/// +/// The stdout sink is the destination DESIGN §4.3 names and the one this +/// feature writes to; a test supplies its own, so no test observes another +/// test's stream. +pub trait AuditSink: Send + Sync { + /// Writes one record, already serialized as one line without its newline. + fn write(&self, record: &str); +} + +/// The stdout sink, which is the destination DESIGN §4.3 names. +/// +/// The line is written through a single locked write so the bytes of one +/// record are never interleaved with the bytes of another. +#[derive(Debug, Default)] +pub struct StdoutSink; + +impl AuditSink for StdoutSink { + fn write(&self, record: &str) { + use std::io::Write; + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + let _ = writeln!(handle, "{record}"); + let _ = handle.flush(); + } +} + +/// The test sink, which holds the records a test wrote. +/// +/// A test owns its own instance, so no test observes another's stream. +#[derive(Debug, Default)] +pub struct CollectingSink { + records: Mutex>, +} + +impl CollectingSink { + /// Creates an empty sink. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// The records written so far, oldest first. + #[must_use] + pub fn records(&self) -> Vec { + self.records.lock().clone() + } + + /// The records written so far, parsed as JSON objects. + /// + /// # Errors + /// + /// Returns the parse error of the first record that is not one JSON + /// object, which is the property the single-line rule states. + pub fn parsed(&self) -> Result, serde_json::Error> { + self.records() + .iter() + .map(|record| serde_json::from_str(record)) + .collect() + } +} + +impl AuditSink for CollectingSink { + fn write(&self, record: &str) { + self.records.lock().push(String::from(record)); + } +} + +/// The interval state of the failure-log bound. +#[derive(Debug)] +struct FloodWindow { + interval_started: Instant, + written: u32, +} + +impl Default for FloodWindow { + fn default() -> Self { + Self { + interval_started: Instant::now(), + written: 0, + } + } +} + +impl FloodWindow { + /// Whether one more authentication-failure record may be written in the + /// interval the window is counting. + /// + /// The records beyond the bound are dropped and not queued, so the answer + /// is the whole of the decision and the caller holds nothing back. + // @cpt-dod:cpt-cf-oagw-dod-obs-sampling:p1 + fn admits(&mut self, now: Instant) -> bool { + if now + .saturating_duration_since(self.interval_started) + .as_millis() + >= u128::from(AUTH_FAILURE_LOG_INTERVAL_MS) + { + self.interval_started = now; + self.written = 0; + } + if self.written >= AUTH_FAILURE_LOG_LIMIT { + return false; + } + self.written += 1; + true + } +} + +/// The runtime the feature observes through. +/// +/// One per process, held by [`crate::OagwState`] beside the state ADR 0006 +/// assigns the Data Plane, so a scrape and the proxy path read the same +/// collectors. +pub struct Observability { + registry: MetricsRegistry, + sink: parking_lot::RwLock>, + flood: Mutex, +} + +impl std::fmt::Debug for Observability { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Observability") + .field("registry", &self.registry) + .field("flood", &self.flood) + .finish_non_exhaustive() + } +} + +impl Default for Observability { + fn default() -> Self { + Self::new() + } +} + +impl Observability { + /// Creates the runtime with the stdout sink. + #[must_use] + pub fn new() -> Self { + Self::with_sink(Arc::new(StdoutSink)) + } + + /// Creates the runtime with the sink the caller supplies. + #[must_use] + pub fn with_sink(sink: Arc) -> Self { + Self { + registry: MetricsRegistry::new(), + sink: parking_lot::RwLock::new(sink), + flood: Mutex::new(FloodWindow::default()), + } + } + + /// Replaces the sink, returning the one it replaced. + /// + /// The swap is a test's way of owning its stream, and no record is held + /// across the swap: whatever was written went to the sink that was current + /// when it was written. + pub fn swap_sink(&self, sink: Arc) -> Arc { + std::mem::replace(&mut *self.sink.write(), sink) + } + + /// The collector the twelve families are observed into. + #[must_use] + pub fn registry(&self) -> &MetricsRegistry { + &self.registry + } + + /// Renders the exposition the scrape is answered with. + #[must_use] + pub fn render(&self) -> String { + self.registry.render() + } + + /// Raises the in-flight gauge for one admitted request. + /// + /// The label is the alias the request addressed, which is the point at the + /// path's entry where the value the `host` label carries exists; the lower + /// at the exit uses the same value, so a request that never resolves a + /// target still returns the gauge to its prior value. + pub fn raise_in_flight(&self, host: &str) { + self.registry.add( + "oagw_requests_in_flight", + &[(MetricLabelSet::HOST, String::from(host))], + 1.0, + ); + } + + /// Lowers the in-flight gauge for one finished exchange. + pub fn lower_in_flight(&self, host: &str) { + self.registry.add( + "oagw_requests_in_flight", + &[(MetricLabelSet::HOST, String::from(host))], + -1.0, + ); + } + + /// Emits the audit record one event names, applying the gates of the + /// emitter's algorithm and writing one line to the sink. + /// + /// `high_volume` is the classification of the route the record describes, + /// which only a success record is sampled against; a failed request, a + /// breaker transition, and a configuration change are never sampled, and + /// an authentication-failure record is bounded instead. + // @cpt-dod:cpt-cf-oagw-dod-obs-audit:p1 + pub fn emit(&self, event: AuditEvent, high_volume: bool, sampling: SamplingDecision) { + // @cpt-begin:cpt-cf-oagw-algo-audit-emit:p1:inst-ae-event + // The event name the record carries is the one the outcome selected, + // which is one of the twelve literals the closed set holds; the caller + // built the record with it and this routine writes nothing outside it. + // @cpt-end:cpt-cf-oagw-algo-audit-emit:p1:inst-ae-event + // @cpt-begin:cpt-cf-oagw-algo-audit-emit:p1:inst-ae-populate + // The fields the event calls for were populated by the caller from the + // execution context, the correlation context, and the sibling states, + // and the unpopulated ones are omitted at serialization rather than + // written null or empty. + // @cpt-end:cpt-cf-oagw-algo-audit-emit:p1:inst-ae-populate + // @cpt-begin:cpt-cf-oagw-algo-audit-emit:p1:inst-ae-redact + // The redaction ran before the record was built: the fourteen-field + // record admits no body, no query parameter, and no header value but + // the correlation header's, and no credential material, no `cred://` + // reference value, and no control character reaches any field it + // carries. + // @cpt-end:cpt-cf-oagw-algo-audit-emit:p1:inst-ae-redact + // @cpt-begin:cpt-cf-oagw-algo-audit-emit:p1:inst-ae-level + // The level was assigned by the mapping of §1.5 when the record was + // built, and no record is written at DEBUG. + // @cpt-end:cpt-cf-oagw-algo-audit-emit:p1:inst-ae-level + // @cpt-begin:cpt-cf-oagw-algo-audit-emit:p1:inst-ae-sample-if + // The success record of a high-volume route is the one record the + // sampling decision governs, and the decision is the correlation + // context's, read once per request. + if event.event.as_deref() == Some(crate::domain::observability::EVENT_REQUEST_SUCCEEDED) + && high_volume + && sampling == SamplingDecision::Drop + { + return; + } + // @cpt-end:cpt-cf-oagw-algo-audit-emit:p1:inst-ae-sample-if + // @cpt-begin:cpt-cf-oagw-algo-audit-emit:p1:inst-ae-sample + // Apply the 1/100 decision of the correlation context and drop the + // record when the decision is not to sample. + // @cpt-end:cpt-cf-oagw-algo-audit-emit:p1:inst-ae-sample + // @cpt-begin:cpt-cf-oagw-algo-audit-emit:p1:inst-ae-unbound + // A failed request, a circuit-breaker transition, and a configuration + // change reach this routine unsampled and unbound, because each is + // either an event an operator must see or an event that is by + // definition not high-volume: the caller passed the classification + // that says so, and the gate above read only the success record of a + // high-volume route. + // @cpt-end:cpt-cf-oagw-algo-audit-emit:p1:inst-ae-unbound + // @cpt-begin:cpt-cf-oagw-algo-audit-emit:p1:inst-ae-flood-if + // The authentication-failure record is the one record the failure-log + // bound governs, and the surplus is dropped and not queued. + if event.event.as_deref() == Some(crate::domain::observability::EVENT_AUTH_FAILED) + && !self.flood.lock().admits(Instant::now()) + { + return; + } + // @cpt-end:cpt-cf-oagw-algo-audit-emit:p1:inst-ae-flood-if + // @cpt-begin:cpt-cf-oagw-algo-audit-emit:p1:inst-ae-bound + // Apply the failure-log bound and drop the record when the interval's + // allowance is spent. + // @cpt-end:cpt-cf-oagw-algo-audit-emit:p1:inst-ae-bound + // @cpt-begin:cpt-cf-oagw-algo-audit-emit:p1:inst-ae-else + // A failed request, a circuit-breaker transition, and a configuration + // change are never sampled and never bound. + // @cpt-end:cpt-cf-oagw-algo-audit-emit:p1:inst-ae-else + // @cpt-begin:cpt-cf-oagw-algo-audit-emit:p1:inst-ae-write + // The record is serialized as one JSON object with the fourteen field + // names in the order DESIGN §4.3 lists them and written as one line. + let record = serialize(&event); + self.sink.read().write(&record); + // @cpt-end:cpt-cf-oagw-algo-audit-emit:p1:inst-ae-write + // @cpt-begin:cpt-cf-oagw-algo-audit-emit:p1:inst-ae-return + // RETURN nothing: the routine produces no value the caller uses, holds + // nothing after the write, and never revisits a record it wrote. + // @cpt-end:cpt-cf-oagw-algo-audit-emit:p1:inst-ae-return + } + + /// The count of authentication-failure records the current interval has + /// admitted, which a test reads to prove the bound. + #[must_use] + pub fn auth_failures_written(&self) -> u32 { + self.flood.lock().written + } + + /// Emits the configuration-change record one completed management write + /// produced. + /// + /// The event name carries the resource kind and the operation, the writer's + /// tenant and subject are the identity fields, and the path and method are + /// the management path addressed: `host`, `duration_ms`, `request_size`, + /// and `response_size` are omitted, because no proxy exchange happened. + /// A configuration change is by definition not high-volume, so the record + /// is never sampled. + pub fn config_change( + &self, + event_name: &'static str, + tenant_id: Option, + principal_id: Option, + method: &str, + path: &str, + status: u16, + ) { + let event = AuditEvent { + timestamp: Some(timestamp_of(SystemTime::now())), + level: Some(String::from("INFO")), + event: Some(String::from(event_name)), + request_id: None, + tenant_id: tenant_id.map(|tenant| tenant.to_string()), + principal_id, + host: None, + path: Some(String::from(path)), + method: Some(String::from(method)), + status: Some(status), + duration_ms: None, + request_size: None, + response_size: None, + error_type: None, + }; + self.emit(event, false, SamplingDecision::Keep); + } +} + +/// The redaction the emitter applies before any field is serialized. +/// +/// The fourteen-field record admits no body, no query parameter, and no header +/// value other than the correlation header's, and the `path` field is the one +/// a caller-controlled string could reach a query through, so it is truncated +/// at the query marker; a field that carries a `cred://` reference value, a +/// bearer token, or a control character is dropped rather than written, +/// because a value that cannot be written safely has no value for the record. +// @cpt-dod:cpt-cf-oagw-dod-obs-redaction:p1 +fn redact(name: &'static str, value: String) -> Option { + let value = if name == "path" { + match value.split_once('?') { + Some((prefix, _)) => String::from(prefix), + None => value, + } + } else { + value + }; + if value.contains("cred://") || value.contains("Bearer ") { + return None; + } + if value.bytes().any(|byte| byte < 0x20 || byte == 0x7F) { + return None; + } + Some(value) +} + +/// Serializes one record as one JSON object, with the fourteen field names in +/// the order DESIGN §4.3 lists them and no fifteenth member. +/// +/// A field with no value is omitted rather than written null or empty, which +/// is the omission rule the record's own type states. +#[must_use] +pub fn serialize(event: &AuditEvent) -> String { + let mut out = String::from("{"); + let mut first = true; + for (name, value) in event.populated() { + let Some(value) = redact(name, value) else { + continue; + }; + if !first { + out.push(','); + } + first = false; + out.push('"'); + out.push_str(name); + out.push_str("\":\""); + out.push_str(&escape_json(&value)); + out.push('"'); + } + out.push('}'); + out +} + +/// Escapes a field value as a JSON string literal. +fn escape_json(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + character if (character as u32) < 0x20 => { + out.push_str(&format!("\\u{:04x}", character as u32)); + } + character => out.push(character), + } + } + out +} + +/// The instant a record is issued at, read once and formatted as RFC 3339 in +/// UTC. +/// +/// The format is the one a JSON log consumer reads and the record carries no +/// timezone of its own, because the record's instant is the instant the write +/// was issued at and nothing else. +#[must_use] +pub fn timestamp_of(at: SystemTime) -> String { + let since = at + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::from_secs(0)); + let (days, seconds) = (since.as_secs() / 86_400, since.as_secs() % 86_400); + let (year, month, day) = civil_from_days(i64::try_from(days).unwrap_or(0)); + format!( + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", + seconds / 3_600, + (seconds % 3_600) / 60, + seconds % 60 + ) +} + +/// The civil date of a day count from the epoch, which the timestamp reads. +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let shifted = days + 719_468; + let era = shifted.div_euclid(146_097); + let day_of_era = shifted.rem_euclid(146_097); + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let shifted_prime = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * shifted_prime + 2) / 5 + 1; + let month = if shifted_prime < 10 { + shifted_prime + 3 + } else { + shifted_prime - 9 + }; + (if month <= 2 { year + 1 } else { year }, month as u32, day as u32) +} + +/// The phase timings the proxy path stamps as it goes, which the duration +/// family reads. +/// +/// The four phases are the bounded set whose durations the request's execution +/// context carries, and no per-plugin, per-route, or per-upstream phase value +/// is ever recorded. +#[derive(Debug, Clone, Copy)] +pub struct PhaseTimings { + entry: Instant, + resolve: Option, + chain: Option, + upstream: Option, +} + +impl PhaseTimings { + /// Starts the timings at the path's entry. + #[must_use] + pub fn started() -> Self { + Self { + entry: Instant::now(), + resolve: None, + chain: None, + upstream: None, + } + } + + /// Stamps the moment the resolution completed. + pub fn resolved(&mut self) { + self.resolve = Some(Instant::now()); + } + + /// Stamps the moment the composed chain completed. + pub fn chained(&mut self) { + self.chain = Some(Instant::now()); + } + + /// Stamps the moment the outbound forward completed. + pub fn forwarded(&mut self) { + self.upstream = Some(Instant::now()); + } + + /// The durations of the phases that completed, in the order the path runs + /// them, plus `total` measured to the moment the read is made. + /// + /// A phase is the span from its predecessor's stamp to its own, so the + /// four numbers sum to the whole of the request and none of them restates + /// another's: `resolve` runs from the entry to the resolution's completion, + /// `chain` from there to the composed chain's, and `upstream` from there to + /// the forward's. A phase the path never reached is absent rather than + /// reported as a zero-length span, which is what keeps the per-phase mean + /// an operator reads free of requests that touched no upstream. + #[must_use] + pub fn durations(&self) -> Vec<(&'static str, f64)> { + let at = Instant::now(); + let mut phases = Vec::with_capacity(4); + for (name, from, to) in [ + ("resolve", Some(self.entry), self.resolve), + ("chain", self.resolve, self.chain), + ("upstream", self.chain, self.upstream), + ] { + if let Some(seconds) = self.span(from, to) { + phases.push((name, seconds)); + } + } + phases.push(("total", at.saturating_duration_since(self.entry).as_secs_f64())); + phases + } + + /// The seconds a phase ran for, which is `None` when the phase never + /// completed and the phase before it never completed either. + fn span(&self, from: Option, to: Option) -> Option { + let to = to?; + let from = from.unwrap_or(self.entry); + Some(to.saturating_duration_since(from).as_secs_f64()) + } + + /// The seconds the whole request took, which is the `total` phase. + #[must_use] + pub fn total_seconds(&self) -> f64 { + Instant::now().saturating_duration_since(self.entry).as_secs_f64() + } +} + +/// The endpoint selection the proxy path performed, which the routing families +/// report. +#[derive(Debug, Clone)] +pub struct EndpointObservation { + /// The upstream the selection selected from. + pub upstream_id: Uuid, + /// The endpoint host the selection named. + pub endpoint_host: String, + /// How the selection chose it. + pub method: &'static str, + /// Whether the request named its target through the routing header. + pub used_header: bool, +} + +/// The breaker state and transitions the rate-limiting feature's machine +/// reported, read without mutating it. +#[derive(Debug, Clone, Default)] +pub struct BreakerObservation { + /// The phase the machine holds at the exit. + pub phase: Option, + /// The transitions the machine reported over the exchange. + pub transitions: Vec, +} + +/// The rate-limit outcome the check produced, read without mutating the +/// machine that produced it. +#[derive(Debug, Clone, Copy, Default)] +pub struct RateLimitObservation { + /// Whether the check refused the request. + pub exceeded: bool, + /// The allowance ratio the check computed, when it computed one. + pub usage_ratio: Option, +} + +/// The exchange the proxy path served, as the exit step of the observed flow +/// reads it. +/// +/// Every member is a value the path already computed: the observation adds no +/// computation of its own beyond the reading of them. +#[derive(Debug, Clone, Default)] +pub struct Exchange { + /// The resolved upstream's alias, which is the `host` label and the audit + /// `host` field. + pub host: Option, + /// The matched route's normalized match pattern, which is the `http.route` + /// label and the audit `path` field. + pub route: Option, + /// The request method as issued. + pub method: String, + /// The status the caller was answered with. + pub status: Option, + /// The catalogue failure the gateway answered with, when it did. + pub error: Option, + /// The phase timings the path stamped. + pub timings: Option, + /// The request bytes as transferred. + pub request_size: u64, + /// The response bytes as transferred, which a streamed exchange defers to + /// its transfer's end. + pub response_size: Option, + /// The endpoint selection, when one ran. + pub endpoint: Option, + /// The breaker observation, when a machine was consulted. + pub breaker: Option, + /// The rate-limit outcome, when a limit was in force. + pub rate_limit: Option, + /// The streamed session whose end defers the whole observation. + pub session: Option>>, + /// Whether the exchange's answer was produced by the gateway, which is + /// what decides whether a `trace_id` echo is carried. + pub gateway_answer: bool, + /// Whether the answer is the one the proxy path gives a caller whose + /// identity or permission it could not establish, which §1.5 row 177 names + /// as an authentication failure this feature records under `auth.failed`. + pub authentication_failure: bool, + /// Whether the path resolved a configured upstream before it answered, + /// which decides whether the answer is filed under the resolved alias or + /// under [`UNRESOLVED_HOST`]. + pub upstream_resolved: bool, +} + +impl Exchange { + /// Whether the answer the caller received is one the observed flow records + /// as failed: a gateway error, or an upstream answer with a failure status. + #[must_use] + pub fn failed(&self) -> bool { + self.error.is_some() + || self.status.is_some_and(|status| status >= 400) + } + + /// The `error_type` value the record and the error family carry. + /// + /// A gateway error carries its catalogue row's slug; an upstream failure + /// status carries the one literal the catalogue has no row for; a + /// successful answer carries none. A gateway answer that names no + /// catalogue variant — a CORS refusal, a 401, a 403 the enforcer + /// produced — carries none either, because `error_type` is closed at the + /// catalogue's slugs plus `upstream` and a status the gateway produced + /// itself is neither. + #[must_use] + pub fn error_type(&self) -> Option<&'static str> { + if let Some(kind) = self.error { + return crate::domain::observability::error_slug_of(kind.gts_type()); + } + if !self.gateway_answer && self.status.is_some_and(|status| status >= 400) { + return Some(ERROR_TYPE_UPSTREAM); + } + None + } + + /// Whether the route the exchange served is one the sampling ratio + /// governs. + #[must_use] + pub fn high_volume(&self) -> bool { + self.route + .as_deref() + .is_some_and(is_high_volume_pattern) + } +} + +impl Observability { + /// Observes one finished exchange and emits its record. + /// + /// This is the exit step of `cpt-cf-oagw-flow-request-observed`: the + /// metric families are updated from the execution context and the sibling + /// states, and the audit record is written once. A streamed exchange is + /// observed by its [`DeferredObservation`] instead, which carries the same + /// exchange to its transfer's end. + pub fn observe(&self, exchange: &Exchange, correlation: Option<&CorrelationContext>) { + // @cpt-begin:cpt-cf-oagw-algo-metrics-observe:p1:inst-amo-return + let Some(host) = exchange.host.clone() else { + return; + }; + // @cpt-end:cpt-cf-oagw-algo-metrics-observe:p1:inst-amo-return + // @cpt-begin:cpt-cf-oagw-algo-metrics-observe:p1:inst-amo-labels + // The label values are derived under the rules of §1.5: the host is + // the resolved upstream's alias, the route is the matched route's + // normalized match pattern and never the raw request path, the method + // is the standard verb or `_OTHER`, and the status is the numeric code + // the caller received. + // + // A request the gateway answered without resolving an upstream — a + // refused or unmatched alias — names no upstream, so its answer is + // filed under the one bounded literal rather than under the alias the + // caller invented, which is what keeps the label set of the three + // answer families out of caller control. The in-flight gauge is the + // one family that keeps the addressed alias, because its raise at the + // correlate step and its lower here must name the same series. + let answer_host = if exchange.upstream_resolved { + host.clone() + } else { + String::from(UNRESOLVED_HOST) + }; + let route = exchange.route.clone(); + let method = crate::domain::observability::normalize_method(&exchange.method); + let status = exchange.status.unwrap_or_default().to_string(); + let labels = [ + (MetricLabelSet::HOST, answer_host.clone()), + (MetricLabelSet::HTTP_METHOD, String::from(method)), + ( + MetricLabelSet::HTTP_ROUTE, + route.clone().unwrap_or_default(), + ), + (MetricLabelSet::HTTP_STATUS, status), + ]; + // @cpt-end:cpt-cf-oagw-algo-metrics-observe:p1:inst-amo-labels + // @cpt-begin:cpt-cf-oagw-algo-metrics-observe:p1:inst-amo-requests + // The request is counted once, with the four labels of its set, and + // the status carried is the one the caller received, whatever produced + // it. + self.registry.increment("oagw_requests_total", &labels); + // @cpt-end:cpt-cf-oagw-algo-metrics-observe:p1:inst-amo-requests + // @cpt-begin:cpt-cf-oagw-algo-metrics-observe:p1:inst-amo-duration + // The four phases the execution context carries are the only phases + // observed. + if let Some(timings) = exchange.timings { + for (phase, seconds) in timings.durations() { + let labels = vec![ + (MetricLabelSet::HOST, answer_host.clone()), + ( + MetricLabelSet::HTTP_ROUTE, + route.clone().unwrap_or_default(), + ), + (MetricLabelSet::PHASE, String::from(phase)), + ]; + self.registry + .observe("oagw_request_duration_seconds", &labels, seconds); + } + } + // @cpt-end:cpt-cf-oagw-algo-metrics-observe:p1:inst-amo-duration + // @cpt-begin:cpt-cf-oagw-algo-metrics-observe:p1:inst-amo-inflight + // The lower half of the raise the correlate step performed. + self.lower_in_flight(&host); + // @cpt-end:cpt-cf-oagw-algo-metrics-observe:p1:inst-amo-inflight + // @cpt-begin:cpt-cf-oagw-algo-metrics-observe:p1:inst-amo-error-if + // A gateway error carries its catalogue slug, an upstream failure + // status carries the `upstream` literal, and a bare refusal carries + // neither. + if let Some(error_type) = exchange.error_type() { + // @cpt-begin:cpt-cf-oagw-algo-metrics-observe:p1:inst-amo-error + // The error is counted with the host, the route, and the type of + // the failure, and a successful answer increments nothing in this + // family. + self.registry.increment( + "oagw_errors_total", + &[ + (MetricLabelSet::HOST, answer_host), + (MetricLabelSet::HTTP_ROUTE, route.clone().unwrap_or_default()), + (MetricLabelSet::ERROR_TYPE, String::from(error_type)), + ], + ); + // @cpt-end:cpt-cf-oagw-algo-metrics-observe:p1:inst-amo-error + } + // @cpt-end:cpt-cf-oagw-algo-metrics-observe:p1:inst-amo-error-if + // @cpt-begin:cpt-cf-oagw-algo-metrics-observe:p1:inst-amo-error-else + // @cpt-begin:cpt-cf-oagw-algo-metrics-observe:p1:inst-amo-no-error + // Nothing is incremented in that family: a successful request is not + // an error and no series of this feature reports success as one. + // @cpt-end:cpt-cf-oagw-algo-metrics-observe:p1:inst-amo-no-error + // @cpt-end:cpt-cf-oagw-algo-metrics-observe:p1:inst-amo-error-else + self.observe_siblings(exchange, &host, &route, correlation); + self.emit_record(exchange, correlation, &host, &route); + } + + /// Reads the state the sibling features own into the six series that + /// report it, without mutating any of it. + fn observe_siblings( + &self, + exchange: &Exchange, + host: &str, + route: &Option, + correlation: Option<&CorrelationContext>, + ) { + // @cpt-begin:cpt-cf-oagw-algo-metrics-observe:p1:inst-amo-ratelimit + // The breaker machine is read, never driven: the phase it holds, the + // transitions it reported, the refusals it produced, and the allowance + // ratio it computed are all read here. + if let Some(breaker) = &exchange.breaker { + if let Some(phase) = breaker.phase { + self.registry.set( + "oagw_circuit_breaker_state", + &[(MetricLabelSet::HOST, String::from(host))], + breaker_gauge(phase), + ); + if let Some(endpoint_host) = exchange + .endpoint + .as_ref() + .map(|endpoint| endpoint.endpoint_host.clone()) + { + self.registry.set( + "oagw_upstream_available", + &[ + (MetricLabelSet::HOST, String::from(host)), + (MetricLabelSet::ENDPOINT, endpoint_host), + ], + f64::from(u8::from(admits(phase))), + ); + } + } + for transition in &breaker.transitions { + self.registry.increment( + "oagw_circuit_breaker_transitions_total", + &[ + (MetricLabelSet::HOST, String::from(host)), + ( + MetricLabelSet::FROM_STATE, + String::from(crate::domain::observability::breaker_state_label( + transition.from, + )), + ), + ( + MetricLabelSet::TO_STATE, + String::from(crate::domain::observability::breaker_state_label( + transition.to, + )), + ), + ], + ); + // The transition's own record: the series above carries the + // two states as its labels, and the record is the line an + // operator who is not scraping sees. It is written in addition + // to the one record the request produces, never instead of it, + // and it is never sampled. + self.emit( + AuditEvent { + timestamp: Some(timestamp_of(SystemTime::now())), + level: Some(String::from(if transition.to == BreakerPhase::Open { + "WARN" + } else { + "INFO" + })), + event: Some(String::from( + crate::domain::observability::EVENT_BREAKER_TRANSITIONED, + )), + request_id: correlation.map(|correlation| correlation.request_id.clone()), + tenant_id: correlation + .and_then(|correlation| correlation.tenant_id.map(|t| t.to_string())), + principal_id: correlation + .and_then(|correlation| correlation.principal_id.clone()), + host: Some(String::from(host)), + path: route.clone(), + method: Some(exchange.method.clone()), + status: None, + duration_ms: None, + request_size: None, + response_size: None, + error_type: None, + }, + false, + SamplingDecision::Keep, + ); + } + } + if let Some(rate_limit) = exchange.rate_limit { + if rate_limit.exceeded { + self.registry.increment( + "oagw_rate_limit_exceeded_total", + &[ + (MetricLabelSet::HOST, String::from(host)), + ( + MetricLabelSet::PATH, + route.clone().unwrap_or_default(), + ), + ], + ); + } + if let Some(ratio) = rate_limit.usage_ratio { + self.registry.set( + "oagw_rate_limit_usage_ratio", + &[ + (MetricLabelSet::HOST, String::from(host)), + (MetricLabelSet::PATH, route.clone().unwrap_or_default()), + ], + ratio, + ); + } + } + // @cpt-end:cpt-cf-oagw-algo-metrics-observe:p1:inst-amo-ratelimit + // @cpt-begin:cpt-cf-oagw-algo-metrics-observe:p1:inst-amo-routing + // The selection the proxy path performed is reported with the method + // it recorded, and the routing header's use with it. + if let Some(endpoint) = &exchange.endpoint { + self.registry.increment( + "oagw_routing_endpoint_selected", + &[ + ( + MetricLabelSet::UPSTREAM_ID, + endpoint.upstream_id.to_string(), + ), + ( + MetricLabelSet::ENDPOINT_HOST, + endpoint.endpoint_host.clone(), + ), + ( + MetricLabelSet::SELECTION_METHOD, + String::from(endpoint.method), + ), + ], + ); + if endpoint.used_header { + self.registry.increment( + "oagw_routing_target_host_used", + &[ + ( + MetricLabelSet::UPSTREAM_ID, + endpoint.upstream_id.to_string(), + ), + ( + MetricLabelSet::ENDPOINT_HOST, + endpoint.endpoint_host.clone(), + ), + ], + ); + } + } + // @cpt-end:cpt-cf-oagw-algo-metrics-observe:p1:inst-amo-routing + // @cpt-begin:cpt-cf-oagw-algo-metrics-observe:p1:inst-amo-health + // The connection-pool occupancy is not exposed by the shared outbound + // client, so the family that reports it is rendered with its type and + // help and no samples rather than as a constant, and nothing is + // observed into it here. + // @cpt-end:cpt-cf-oagw-algo-metrics-observe:p1:inst-amo-health + // @cpt-begin:cpt-cf-oagw-algo-metrics-observe:p1:inst-amo-no-tenant + // No label above carries a tenant value, which is the one rule of the + // cardinality management that has no exception. + // @cpt-end:cpt-cf-oagw-algo-metrics-observe:p1:inst-amo-no-tenant + } + + /// Builds, gates, and writes the record the exchange produces. + fn emit_record( + &self, + exchange: &Exchange, + correlation: Option<&CorrelationContext>, + host: &str, + route: &Option, + ) { + // @cpt-begin:cpt-cf-oagw-flow-request-observed:p1:inst-ro-emit + // The record is built from the execution context, the correlation + // context, and the sibling states, and is written once. + // @cpt-begin:cpt-cf-oagw-flow-request-observed:p1:inst-ro-failed-if + // The level, the event literal, and the error type are the three + // fields the failure of the exchange decides. + let (level, event_name, error_type) = if exchange.failed() { + // An upstream failure status is a failure of the kind §1.5 + // assigns ERROR to: the upstream answered the caller with a status + // that is not an answer, and the gateway carries no catalogue + // variant for it. + let upstream_failure = exchange.error.is_none() + && !exchange.gateway_answer + && exchange.status.is_some_and(|status| status >= 400); + let level = if upstream_failure || exchange.authentication_failure { + "ERROR" + } else { + crate::domain::observability::request_event_of(true, exchange.error).1 + }; + // The authorization refusal the proxy path answers — a caller whose + // identity or whose permission it could not establish — is the + // authentication failure §1.5 row 177 names, so it is carried under + // the `auth.failed` literal at the ERROR level the mapping assigns + // it and the failure-log bound of §1.5 governs it. + let event_name = if exchange.authentication_failure + || matches!( + exchange.error, + Some( + crate::domain::error::ErrorKind::AuthenticationFailed + | crate::domain::error::ErrorKind::SecretNotFound + ) + ) { + crate::domain::observability::EVENT_AUTH_FAILED + } else { + crate::domain::observability::request_event_of(true, exchange.error).0 + }; + // @cpt-begin:cpt-cf-oagw-flow-request-observed:p1:inst-ro-failed-record + // The record is a failed record: its level is the one the mapping + // assigns — ERROR for an upstream failure, a timeout, and an + // authentication failure, WARN for a rate-limit refusal and a + // breaker-open answer, and INFO for every other refusal — its + // `error_type` is the catalogue slug of the variant the gateway + // answered or `upstream` for an upstream failure status, and every + // field a success record carries is present alongside it. + ( + level, + event_name, + exchange.error_type().map(String::from), + ) + // @cpt-end:cpt-cf-oagw-flow-request-observed:p1:inst-ro-failed-record + // @cpt-end:cpt-cf-oagw-flow-request-observed:p1:inst-ro-failed-if + // @cpt-begin:cpt-cf-oagw-flow-request-observed:p1:inst-ro-failed-else + } else { + // @cpt-begin:cpt-cf-oagw-flow-request-observed:p1:inst-ro-success-record + // The record is a success record at INFO, its `error_type` is + // omitted, and its sampling decision is the high-volume-route + // decision §1.5 records. + ("INFO", "proxy_request.succeeded", None) + // @cpt-end:cpt-cf-oagw-flow-request-observed:p1:inst-ro-success-record + }; + // @cpt-end:cpt-cf-oagw-flow-request-observed:p1:inst-ro-failed-else + let event = AuditEvent { + timestamp: Some(timestamp_of(SystemTime::now())), + request_id: correlation.map(|correlation| correlation.request_id.clone()), + tenant_id: correlation + .and_then(|correlation| correlation.tenant_id.map(|tenant| tenant.to_string())), + principal_id: correlation.and_then(|correlation| correlation.principal_id.clone()), + host: Some(String::from(host)), + path: route.clone(), + method: Some(exchange.method.clone()), + status: exchange.status, + duration_ms: exchange.timings.map(|timings| { + u64::try_from((timings.total_seconds() * 1_000.0).round() as i64) + .unwrap_or_default() + }), + request_size: Some(exchange.request_size), + response_size: exchange.response_size, + level: Some(String::from(level)), + event: Some(String::from(event_name)), + error_type, + }; + let high_volume = exchange.high_volume(); + let sampling = correlation.map_or(SamplingDecision::Drop, |correlation| { + correlation.sampling + }); + self.emit(event, high_volume, sampling); + // @cpt-end:cpt-cf-oagw-flow-request-observed:p1:inst-ro-emit + } + + /// Installs the deferred observation of a streamed exchange. + /// + /// The observation runs when the transfer's session ends, so the in-flight + /// gauge stays raised for the whole of the transfer and the record carries + /// the byte counts as transferred. + #[must_use] + pub fn defer( + self: Arc, + exchange: Exchange, + correlation: Option, + ) -> DeferredObservation { + DeferredObservation { + observability: self, + exchange: Some(exchange), + correlation, + } + } +} + +/// The deferred observation of a streamed exchange, which runs when the +/// transfer's session ends. +/// +/// The guard holds the exchange the handler assembled and the correlation +/// context the request carries; when it is dropped the observation reads the +/// session's final state and emits the record the exchange produces. It owns +/// its seam, because the tunnel of a taken-up upgrade outlives the handler +/// that answered its 101 and the guard travels into the task the tunnel runs +/// in. +#[derive(Debug)] +pub struct DeferredObservation { + observability: Arc, + exchange: Option, + correlation: Option, +} + +impl DeferredObservation { + /// Runs the observation now, reading whatever the session recorded. + fn run(&mut self) { + let Some(mut exchange) = self.exchange.take() else { + return; + }; + if let Some(session) = &exchange.session { + let session = session.lock(); + exchange.response_size = Some(session.moved); + } + self.observability + .observe(&exchange, self.correlation.as_ref()); + } +} + +impl Drop for DeferredObservation { + fn drop(&mut self) { + self.run(); + } +} + +/// The gauge value a breaker phase is reported under, as the state family +/// enumerates it. +fn breaker_gauge(phase: BreakerPhase) -> f64 { + match phase { + BreakerPhase::Closed => 0.0, + BreakerPhase::Open => 1.0, + BreakerPhase::HalfOpen => 2.0, + } +} + +/// Whether a breaker phase admits an attempt, which is what the availability +/// gauge reports as 1 and 0. +fn admits(phase: BreakerPhase) -> bool { + matches!(phase, BreakerPhase::Closed | BreakerPhase::HalfOpen) +} + +/// The `ProxyResponse` classification the exit reads, kept here so the exit +/// step of the observed flow and the classification of the proxy path cannot +/// disagree about what an upstream answer is. +#[must_use] +pub fn source_of(response: &ProxyResponse) -> &'static str { + match response.source { + crate::domain::error::ErrorSource::Gateway => "gateway", + crate::domain::error::ErrorSource::Upstream => "upstream", + } +} + +/// The correlation header's value as the request carried it, which is the one +/// header value a record may carry. +#[must_use] +pub fn correlation_header(headers: &[(String, String)]) -> Option<&str> { + let lower = CORRELATION_HEADER; + headers + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(lower)) + .map(|(_, value)| value.as_str()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::observability::{AUDIT_FIELDS, CorrelationContext, PHASES}; + + fn event(name: &str) -> AuditEvent { + AuditEvent { + timestamp: Some(String::from("2026-01-01T00:00:00Z")), + level: Some(String::from("INFO")), + event: Some(String::from(name)), + request_id: Some(String::from("req-1")), + ..AuditEvent::default() + } + } + + #[test] + fn serializes_in_the_tabulated_order() { + // A fully populated record: the emitted key sequence is the tabulated + // order itself, walked from `AUDIT_FIELDS`, so a field that drifted to + // another position fails here rather than passing under a five-way + // spot check. + let record = AuditEvent { + timestamp: Some(String::from("2026-09-08T00:00:00Z")), + level: Some(String::from("INFO")), + event: Some(String::from(crate::domain::observability::EVENT_REQUEST_SUCCEEDED)), + request_id: Some(String::from("req-1")), + tenant_id: Some(String::from("tenant-1")), + principal_id: Some(String::from("subject-1")), + host: Some(String::from("api.example.com")), + path: Some(String::from("/v1/things")), + method: Some(String::from("GET")), + status: Some(200), + duration_ms: Some(12), + request_size: Some(10), + response_size: Some(20), + error_type: Some(String::from(ERROR_TYPE_UPSTREAM)), + }; + let line = serialize(&record); + // Walking the tabulated names forward and requiring each to appear + // after the last one found is the order check: a field that drifted to + // an earlier position is not found after the cursor and fails here. + let mut cursor = 0_usize; + for name in AUDIT_FIELDS { + let needle = format!("\"{name}\":"); + let at = line[cursor..].find(&needle).expect(name); + cursor += at + needle.len(); + } + assert!(!line.contains("null")); + assert!(!line.contains("\"\"")); + } + + #[test] + fn omits_an_unpopulated_field_and_never_writes_null() { + let line = serialize(&event("proxy_request.succeeded")); + assert!(!line.contains("error_type")); + assert!(!line.contains("null")); + assert!(!line.contains("\"host\":\"\"")); + } + + #[test] + fn redacts_a_query_out_of_the_path() { + let mut record = event("proxy_request.succeeded"); + record.path = Some(String::from("/v1/things?secret=1")); + let line = serialize(&record); + assert!(line.contains("\"path\":\"/v1/things\"")); + assert!(!line.contains("secret")); + } + + #[test] + fn redacts_a_credential_reference_and_a_token() { + let mut record = event("config.upstream.created"); + record.path = Some(String::from("cred://store/key")); + assert!(!serialize(&record).contains("cred://")); + record.path = Some(String::from("Bearer abc")); + assert!(!serialize(&record).contains("Bearer")); + } + + #[test] + fn escapes_a_label_and_a_field_value() { + let registry = MetricsRegistry::new(); + registry.increment( + "oagw_requests_total", + &[ + (MetricLabelSet::HOST, String::from("host\"with\\quotes")), + (MetricLabelSet::HTTP_METHOD, String::from("GET")), + (MetricLabelSet::HTTP_ROUTE, String::from("/v1/things")), + (MetricLabelSet::HTTP_STATUS, String::from("200")), + ], + ); + let rendered = registry.render(); + let expected = format!("host=\"{}\"", escape_label("host\"with\\quotes")); + assert!(rendered.contains(&expected), "{rendered}"); + } + + #[test] + fn renders_every_exposed_family_with_its_type_and_help() { + let registry = MetricsRegistry::new(); + let rendered = registry.render(); + for family in FAMILIES { + if !family.exposed { + // A family whose underlying state is not exposed is omitted + // rather than emitted as a constant. + assert!(!rendered.contains(family.name)); + continue; + } + assert!(rendered.contains(&format!("# HELP {}", family.name))); + assert!( + rendered.contains(&format!("# TYPE {} {}", family.name, family.kind.label())) + ); + } + assert_eq!(FAMILIES.len(), 12); + assert!(!rendered.contains("oagw_upstream_connections")); + } + + #[test] + fn renders_a_histogram_series_over_twelve_buckets() { + let registry = MetricsRegistry::new(); + registry.observe( + "oagw_request_duration_seconds", + &[ + (MetricLabelSet::HOST, String::from("a")), + (MetricLabelSet::HTTP_ROUTE, String::from("/v1/t")), + (MetricLabelSet::PHASE, String::from("total")), + ], + 0.02, + ); + let rendered = registry.render(); + for bound in HISTOGRAM_BUCKETS { + assert!(rendered.contains(&format!("le=\"{}\"", render_bound(bound)))); + } + assert!(rendered.contains("le=\"+Inf\"")); + assert!(rendered.contains("_sum")); + assert!(rendered.contains("_count")); + assert!(rendered.contains("oagw_request_duration_seconds_bucket")); + } + + #[test] + fn renders_a_histogram_series_per_phase() { + let registry = MetricsRegistry::new(); + for phase in PHASES { + registry.observe( + "oagw_request_duration_seconds", + &[ + (MetricLabelSet::HOST, String::from("a")), + (MetricLabelSet::HTTP_ROUTE, String::from("/v1/t")), + (MetricLabelSet::PHASE, String::from(phase)), + ], + 0.01, + ); + } + let rendered = registry.render(); + for phase in PHASES { + assert!(rendered.contains(&format!("phase=\"{phase}\""))); + } + } + + #[test] + fn reports_a_gauge_that_returns_to_its_prior_value() { + let registry = MetricsRegistry::new(); + registry.add("oagw_requests_in_flight", &[(MetricLabelSet::HOST, String::from("a"))], 1.0); + registry.add("oagw_requests_in_flight", &[(MetricLabelSet::HOST, String::from("a"))], -1.0); + let rendered = registry.render(); + assert!(rendered.contains("oagw_requests_in_flight{host=\"a\"} 0")); + } + + #[test] + fn samples_a_high_volume_success_record_and_never_a_failed_one() { + let sink = Arc::new(CollectingSink::new()); + let observability = Observability::with_sink(Arc::clone(&sink) as Arc); + observability.emit( + event("proxy_request.succeeded"), + true, + SamplingDecision::Drop, + ); + assert!(sink.records().is_empty()); + observability.emit( + event("proxy_request.succeeded"), + true, + SamplingDecision::Keep, + ); + assert_eq!(sink.records().len(), 1); + observability.emit( + event("proxy_request.failed"), + true, + SamplingDecision::Drop, + ); + assert_eq!(sink.records().len(), 2); + } + + #[test] + fn bounds_the_authentication_failure_records() { + let sink = Arc::new(CollectingSink::new()); + let observability = Observability::with_sink(Arc::clone(&sink) as Arc); + for _ in 0..(AUTH_FAILURE_LOG_LIMIT * 3) { + observability.emit(event("auth.failed"), false, SamplingDecision::Keep); + } + assert_eq!( + sink.records().len(), + usize::try_from(AUTH_FAILURE_LOG_LIMIT).unwrap_or(0) + ); + } + + #[test] + fn reports_the_breaker_states_and_transitions() { + let registry = MetricsRegistry::new(); + registry.set( + "oagw_circuit_breaker_state", + &[(MetricLabelSet::HOST, String::from("a"))], + breaker_gauge(BreakerPhase::Open), + ); + registry.increment( + "oagw_circuit_breaker_transitions_total", + &[ + (MetricLabelSet::HOST, String::from("a")), + (MetricLabelSet::FROM_STATE, String::from("closed")), + (MetricLabelSet::TO_STATE, String::from("open")), + ], + ); + let rendered = registry.render(); + assert!(rendered.contains("oagw_circuit_breaker_state{host=\"a\"} 1")); + assert!(rendered.contains("oagw_circuit_breaker_transitions_total{host=\"a\",from_state=\"closed\",to_state=\"open\"} 1")); + } + + #[test] + fn reports_the_routing_families() { + let registry = MetricsRegistry::new(); + registry.increment( + "oagw_routing_endpoint_selected", + &[ + (MetricLabelSet::UPSTREAM_ID, String::from("u")), + (MetricLabelSet::ENDPOINT_HOST, String::from("h")), + (MetricLabelSet::SELECTION_METHOD, String::from("round_robin")), + ], + ); + let rendered = registry.render(); + assert!(rendered.contains( + "oagw_routing_endpoint_selected{upstream_id=\"u\",endpoint_host=\"h\",selection_method=\"round_robin\"} 1" + )); + } + + #[test] + fn reads_the_correlation_header_by_its_lowercase_name() { + let headers = vec![ + (String::from("Accept"), String::from("*/*")), + (String::from("X-Request-Id"), String::from("caller-1")), + ]; + assert_eq!(correlation_header(&headers), Some("caller-1")); + assert_eq!(correlation_header(&[]), None); + } + + #[test] + fn formats_a_timestamp_as_rfc3339() { + assert_eq!( + timestamp_of(UNIX_EPOCH), + String::from("1970-01-01T00:00:00Z") + ); + let later = UNIX_EPOCH + Duration::from_secs(1_767_225_600); + assert!(timestamp_of(later).ends_with("Z")); + assert_eq!(timestamp_of(later).len(), 20); + } + + #[test] + fn stamps_the_four_phases_in_order() { + let mut timings = PhaseTimings::started(); + timings.resolved(); + timings.chained(); + timings.forwarded(); + let durations = timings.durations(); + assert_eq!(durations.len(), 4); + assert_eq!(durations[0].0, "resolve"); + assert_eq!(durations[1].0, "chain"); + assert_eq!(durations[2].0, "upstream"); + assert_eq!(durations[3].0, "total"); + assert!(durations[3].1 >= durations[2].1); + } + + #[test] + fn omits_a_phase_the_path_never_reached() { + let mut timings = PhaseTimings::started(); + timings.resolved(); + let names: Vec<&str> = timings + .durations() + .into_iter() + .map(|(name, _)| name) + .collect(); + assert_eq!(names, vec!["resolve", "total"]); + assert!(timings.durations().into_iter().all(|(_, seconds)| seconds + >= 0.0)); + } + + #[test] + fn classifies_an_exchange_failure_and_its_error_type() { + let mut exchange = Exchange { + host: Some(String::from("api.example.com")), + route: Some(String::from("/v1/things/{id}")), + method: String::from("GET"), + status: Some(200), + ..Exchange::default() + }; + assert!(!exchange.failed()); + assert!(exchange.error_type().is_none()); + assert!(!exchange.high_volume()); + exchange.status = Some(502); + assert!(exchange.failed()); + assert_eq!(exchange.error_type(), Some(ERROR_TYPE_UPSTREAM)); + exchange.status = Some(404); + exchange.error = Some(ErrorKind::RouteNotFound); + assert_eq!(exchange.error_type(), Some("route.not_found")); + exchange.route = Some(String::from("/v1/things")); + assert!(exchange.high_volume()); + } + + #[test] + fn observes_a_finished_exchange_once() { + let sink = Arc::new(CollectingSink::new()); + let observability = Observability::with_sink(Arc::clone(&sink) as Arc); + let mut correlation = CorrelationContext::assign(Some("req-obs"), None, None); + correlation.sampling = SamplingDecision::Keep; + let mut timings = PhaseTimings::started(); + timings.resolved(); + observability.raise_in_flight("api.example.com"); + let exchange = Exchange { + host: Some(String::from("api.example.com")), + route: Some(String::from("/v1/things")), + method: String::from("GET"), + status: Some(200), + timings: Some(timings), + request_size: 10, + response_size: Some(20), + upstream_resolved: true, + ..Exchange::default() + }; + observability.observe(&exchange, Some(&correlation)); + let rendered = observability.render(); + assert!(rendered.contains("oagw_requests_total{host=\"api.example.com\",http.request.method=\"GET\",http.route=\"/v1/things\",http.response.status_code=\"200\"} 1")); + assert!(rendered.contains("oagw_requests_in_flight{host=\"api.example.com\"} 0")); + assert!(!rendered.contains("oagw_errors_total{")); + let records = sink.records(); + assert_eq!(records.len(), 1); + assert!(records[0].contains("\"request_id\":\"req-obs\"")); + assert!(records[0].contains("\"host\":\"api.example.com\"")); + assert!(records[0].contains("\"event\":\"proxy_request.succeeded\"")); + } + + #[test] + fn observes_a_failed_exchange_with_its_error_type() { + let sink = Arc::new(CollectingSink::new()); + let observability = Observability::with_sink(Arc::clone(&sink) as Arc); + let exchange = Exchange { + host: Some(String::from("api.example.com")), + route: Some(String::from("/v1/things")), + method: String::from("GET"), + status: Some(404), + error: Some(ErrorKind::RouteNotFound), + upstream_resolved: true, + ..Exchange::default() + }; + observability.observe(&exchange, None); + let rendered = observability.render(); + assert!(rendered.contains( + "oagw_errors_total{host=\"api.example.com\",http.route=\"/v1/things\",error_type=\"route.not_found\"} 1" + )); + let records = sink.records(); + assert_eq!(records.len(), 1); + assert!(records[0].contains("\"event\":\"proxy_request.failed\"")); + assert!(records[0].contains("\"error_type\":\"route.not_found\"")); + } + + #[test] + fn defers_an_observation_to_the_session_end() { + let sink = Arc::new(CollectingSink::new()); + let observability = + Arc::new(Observability::with_sink(Arc::clone(&sink) as Arc)); + let session = Arc::new(Mutex::new( + crate::domain::stream::StreamSession::open_for_incremental( + Uuid::nil(), + Uuid::nil(), + None, + ), + )); + session.lock().moved = 42; + observability.raise_in_flight("api.example.com"); + let exchange = Exchange { + host: Some(String::from("api.example.com")), + route: Some(String::from("/v1/stream/{id}")), + method: String::from("GET"), + status: Some(200), + session: Some(Arc::clone(&session)), + ..Exchange::default() + }; + { + let _deferred = Arc::clone(&observability).defer(exchange, None); + assert!(sink.records().is_empty()); + } + let records = sink.records(); + assert_eq!(records.len(), 1); + assert!(records[0].contains("\"response_size\":\"42\"")); + assert!( + observability + .render() + .contains("oagw_requests_in_flight{host=\"api.example.com\"} 0") + ); + } +} diff --git a/gears/system/oagw/oagw/src/data_plane/ratelimit.rs b/gears/system/oagw/oagw/src/data_plane/ratelimit.rs new file mode 100644 index 0000000..964ec24 --- /dev/null +++ b/gears/system/oagw/oagw/src/data_plane/ratelimit.rs @@ -0,0 +1,728 @@ +//! The rate-limit check the proxy path is held to, its over-limit strategies, +//! the header set a refusal carries, and the cleanup a deletion notifies. +//! +//! Realizes `cpt-cf-oagw-flow-rate-limit-check`, +//! `cpt-cf-oagw-flow-rate-limit-strategy`, `cpt-cf-oagw-flow-rate-limit-cleanup`, +//! and `cpt-cf-oagw-algo-rate-limit-headers` of +//! `cpt-cf-oagw-feature-rate-limiting`, whose algorithms and registry live in +//! [`crate::domain::ratelimit`]. The module holds no transport type: the +//! handler assembles the identity the check keys on from the security context +//! and the connection, and maps [`LimitVerdict`] to the 429, the 503, or the +//! forward the proxy flow answers with. +//! +//! The check runs at the position the proxy flow states, ahead of the composed +//! plugin chain and after the body validation, and it runs once per admission +//! attempt, the queue's releases being the re-runs §2 records. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use parking_lot::Mutex; +use uuid::Uuid; + +use crate::domain::proxy::{MatchedRoute, ResolvedUpstream}; +use crate::domain::ratelimit::{ + AcquireOutcome, EffectiveLimit, LimitLayer, LimitLayers, QUEUE_CAPACITY, QUEUE_WAIT, + RateLimiterRegistry, fold, sliding_window, token_bucket, token_bucket_capped, window_millis, +}; +use crate::domain::upstream::{Algorithm, RateLimitScope, Strategy}; + +/// The registry one gear holds, shared by the proxy path and the deletion +/// observer, which the check locks for the length of one acquisition and which +/// the queue's wait does not hold. +#[derive(Debug, Default, Clone)] +pub struct SharedLimits(Arc>); + +impl SharedLimits { + /// An empty registry. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// The registry one acquisition runs against. + pub fn lock(&self) -> parking_lot::MutexGuard<'_, RateLimiterRegistry> { + self.0.lock() + } +} + +/// The interval one queued request waits before it asks the counter again, +/// which spaces the re-runs the `queue` strategy performs inside its wait +/// bound. It is a build-time constant of this feature with no configuration +/// surface (§1.5). +const QUEUE_POLL: Duration = Duration::from_millis(20); + +/// The `response_headers` gate this run holds, which is the declared default +/// ADR 0003's field table gives and which no written configuration can change, +/// because the shipped `definitions.rate_limit` declares +/// `additionalProperties: false` and lists no such member (§1.5). +pub const RESPONSE_HEADERS_GATE: bool = true; + +/// The identifiers the counter key is formed from, which the proxy path +/// gathers from the security context and the inbound connection. +#[derive(Debug, Clone)] +pub struct LimitIdentity { + /// The calling tenant, which the `tenant` scope keys on and which both + /// fallbacks key on. + pub tenant: Uuid, + /// The authenticated subject, present when the caller is authenticated. + pub subject: Option, + /// The peer address of the inbound connection, present when the platform + /// exposes one for the connection that reached the gear. No proxying + /// header is parsed to recover it (§1.4). + pub peer: Option, +} + +impl LimitIdentity { + /// The identity of a request the platform authenticated and whose + /// connection it exposed. + #[must_use] + pub fn new(tenant: Uuid, subject: Option, peer: Option) -> Self { + Self { + tenant, + subject, + peer, + } + } +} + +/// The verdict one check produced, which the proxy path maps to an answer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LimitVerdict { + /// The request is forwarded: an admission under the `reject` or `queue` + /// strategy, or the release of a queued request. + Admitted, + /// The request is forwarded under the `degrade` strategy, with the burst + /// reserve withheld and no 429 produced for it (§1.5). + Degraded, + /// The request is refused with 429, carrying the header set of + /// `cpt-cf-oagw-algo-rate-limit-headers`. + Rejected(RateLimitHeaders), + /// The breaker for the resolved upstream is not admitting, answered 503 + /// before any charge and before the outbound attempt. + Open { + /// The seconds remaining of the open interval, which the 503 carries. + retry_after_seconds: u64, + }, +} + +/// The header set of a 429 answer, and the `retry_after_seconds` member the +/// problem body carries with it. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct RateLimitHeaders { + /// `X-RateLimit-Limit`, the effective sustained rate expressed per its + /// window. + pub limit: Option, + /// `X-RateLimit-Remaining`, the amount the counter still holds. + pub remaining: Option, + /// `X-RateLimit-Reset`, the epoch second the counter reaches its capacity + /// at, unset when no wall clock is available. + pub reset: Option, + /// `Retry-After`, the whole-second delay until the counter holds the cost. + pub retry_after: Option, + /// The `retry_after_seconds` member of the problem body, which carries the + /// same number `Retry-After` does. + pub retry_after_seconds: Option, +} + +impl RateLimitHeaders { + /// The pairs the 429 answer carries, in the order the flow sets them. + #[must_use] + pub fn pairs(&self) -> Vec<(String, String)> { + let mut pairs = Vec::new(); + for (name, value) in [ + ("X-RateLimit-Limit", &self.limit), + ("X-RateLimit-Remaining", &self.remaining), + ("X-RateLimit-Reset", &self.reset), + ("Retry-After", &self.retry_after), + ] { + if let Some(value) = value { + pairs.push((String::from(name), value.clone())); + } + } + pairs + } +} + +/// The resource whose `rate_limit` the effective limit came from, named as the +/// prefix of the counter key. +fn resource_of( + limit: &EffectiveLimit, + resolved: &ResolvedUpstream, + matched: &MatchedRoute, +) -> (&'static str, String) { + match limit.layer { + LimitLayer::Route => ("route", matched.route_id.to_string()), + LimitLayer::Upstream => ("upstream", resolved.upstream_id.to_string()), + } +} + +/// Forms the effective scope and its identifier, falling back to the `tenant` +/// scope when the configured scope's key cannot be formed (§1.4). +/// +/// A `user` scope with no authenticated subject and an `ip` scope with no +/// resolvable peer address are counters the gateway cannot key, and skipping +/// enforcement would turn a configured limit into no limit, so either falls +/// back to the calling tenant's counter. The fallback is the same for every +/// request that lacks the identifier, so a caller cannot move between scopes +/// to escape a limit. +fn scope_of( + limit: &EffectiveLimit, + identity: &LimitIdentity, + matched: &MatchedRoute, +) -> (RateLimitScope, String) { + match limit.scope { + RateLimitScope::Global => (RateLimitScope::Global, String::from("global")), + RateLimitScope::Tenant => (RateLimitScope::Tenant, identity.tenant.to_string()), + RateLimitScope::User => match &identity.subject { + Some(subject) => (RateLimitScope::User, subject.clone()), + None => (RateLimitScope::Tenant, identity.tenant.to_string()), + }, + RateLimitScope::Ip => match identity.peer.map(|addr| addr.ip()) { + Some(peer) => (RateLimitScope::Ip, peer.to_string()), + None => (RateLimitScope::Tenant, identity.tenant.to_string()), + }, + RateLimitScope::Route => (RateLimitScope::Route, matched.route_id.to_string()), + } +} + +/// The counter the effective `algorithm` selects, read and charged once, +/// against the capacity the strategy holds: the effective `burst.capacity`, +/// reduced to the sustained rate when the `degrade` strategy withholds the +/// burst reserve. +fn acquire( + registry: &mut RateLimiterRegistry, + key: &str, + limit: &EffectiveLimit, + now: Instant, +) -> AcquireOutcome { + match limit.algorithm { + Algorithm::TokenBucket if limit.strategy == Strategy::Degrade => { + let bucket = registry.bucket(key, limit.burst_capacity, &limit.sustained, now); + token_bucket_capped(bucket, limit.cost, limit.sustained.rate, now) + } + Algorithm::TokenBucket => { + let bucket = registry.bucket(key, limit.burst_capacity, &limit.sustained, now); + token_bucket(bucket, limit.cost, now) + } + Algorithm::SlidingWindow => { + // The window's capacity is its rate already, so the reserve's + // withholding leaves it unchanged (§1.5). + let length = + Duration::from_millis(u64::try_from(window_millis(limit.sustained.window)).unwrap_or(u64::MAX)); + let window = registry.window(key); + sliding_window(window, limit.cost, limit.sustained.rate, length, now) + } + } +} + +// @cpt-dod:cpt-cf-oagw-dod-rate-limit-latency:p1 + +/// Runs the rate-limit check one proxy request is held to. +/// +/// Returns [`LimitVerdict::Admitted`] to be forwarded, [`LimitVerdict::Rejected`] +/// to be answered 429, or [`LimitVerdict::Open`] to be answered 503; the +/// `queue` strategy holds the call inside this function for no longer than its +/// wait bound, re-running the acquisition on each release it polls. +pub async fn check( + shared: &SharedLimits, + resolved: &ResolvedUpstream, + matched: &MatchedRoute, + identity: &LimitIdentity, + now: Instant, +) -> LimitVerdict { + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-issue + // The check request carries the resolved upstream and route, the matched + // route's identity, the calling tenant and subject, the peer address, and + // the cost the effective limit charges. + // @cpt-end:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-issue + + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-none-if + let layers = LimitLayers { + upstream: resolved.rate_limit.as_ref(), + route: matched.rate_limit.as_ref(), + }; + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-none-return + let Some(limit) = fold(&layers) else { + // The no-limit outcome over every layer the resolution produced: the + // request is admitted with no charge, no counter, and no rate-limit + // header, because an unconfigured upstream is not silently limited by + // a default it never declared, and a limit declared at any one layer + // is enforced rather than bypassed by a guard that looked at two. + return LimitVerdict::Admitted; + }; + // @cpt-end:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-none-return + // @cpt-end:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-none-if + + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-none-else + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-fold + // The effective limit and its four carried members are the fold's output, + // produced here and carried through the rest of the check. + // @cpt-end:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-fold + // @cpt-end:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-none-else + + // The registry is only ever held inside the block below, never across the + // await the `queue` strategy performs: one held request must never pin the + // counter every other request is charged on. + let (limit, key, outcome) = { + let mut registry = shared.lock(); + + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-breaker-if + // The breaker machine for the resolved upstream is consulted before any + // charge and before the outbound attempt. + let breaker = registry.breaker(&upstream_prefix(resolved.upstream_id)); + if !breaker.admit(now) { + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-breaker-return + // RETURN 503 with the `CircuitBreakerOpen` variant, carrying + // `retry_after_seconds` set to the seconds remaining of the open + // interval. + return LimitVerdict::Open { + retry_after_seconds: breaker.retry_after_seconds(now), + }; + // @cpt-end:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-breaker-return + } + // @cpt-end:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-breaker-if + + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-breaker-else + // The ELSE of the breaker gate: the machine admits, so the counter is + // charged and the request proceeds. + // @cpt-end:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-breaker-else + + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-key + // The counter key carries the `{resource_type}:{resource_id}` prefix of + // the resource whose `rate_limit` the effective limit came from — the + // matched route when the effective limit is the route layer's, and the + // resolved upstream for every other layer — followed by the effective + // scope, its identifier, and the effective sustained window. + let (resource_type, resource_id) = resource_of(&limit, resolved, matched); + // @cpt-end:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-key + + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-key-fallback-if + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-key-fallback + // A scope whose identifier the request does not carry falls back to the + // `tenant` scope and its key rather than skip enforcement, and the + // fallback is the same for every request that lacks the identifier. + let (scope, scope_id) = scope_of(&limit, identity, matched); + // @cpt-end:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-key-fallback + // @cpt-end:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-key-fallback-if + + let key = RateLimiterRegistry::counter_key( + resource_type, + &resource_id, + scope, + &scope_id, + limit.sustained.window, + ); + + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-acquire + // The acquisition runs against the bucket or window the effective + // algorithm selects. + let outcome = acquire(&mut registry, &key, &limit, now); + // @cpt-end:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-acquire + + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-allow-if + // An admitted acquisition charges the cost to the counter and hands the + // request back to the proxy path to be forwarded, which under the + // `degrade` strategy is the admission its own flow reports and carries + // through to it. + if outcome.admitted && limit.strategy != Strategy::Degrade { + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-allow + // The charge happened in the acquisition, and the request is handed + // back with no rate-limit header, because ADR 0003 ties the header + // set to the 429 answer alone. + return LimitVerdict::Admitted; + // @cpt-end:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-allow + } + // @cpt-end:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-allow-if + + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-allow-else + // The refusal — or the `degrade` admission — is handed to the strategy + // flow with the counter state and the request's cost, and the registry + // goes out of scope here so it is never held across the wait. + (limit, key, outcome) + // @cpt-end:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-allow-else + }; + + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-over + let verdict = strategy(shared, &limit, &key, outcome, now).await; + // @cpt-end:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-over + + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-return + // RETURN the admission, the over-limit answer, or the breaker answer — the + // no-limit and breaker answers return through their own steps above — and + // record the outcome for `cpt-cf-oagw-feature-observability` to report + // without emitting a metric of its own: this feature registers no sink and + // emits no metric, and the record is what that feature reads. + tracing::debug!( + verdict = verdict_name(&verdict), + "the rate-limit check answered the proxy request" + ); + verdict + // @cpt-end:cpt-cf-oagw-flow-rate-limit-check:p1:inst-rlc-return +} + +/// The name the execution record carries for one verdict. +fn verdict_name(verdict: &LimitVerdict) -> &'static str { + match verdict { + LimitVerdict::Admitted => "admitted", + LimitVerdict::Degraded => "degraded", + LimitVerdict::Rejected(_) => "rejected", + LimitVerdict::Open { .. } => "breaker-open", + } +} + +/// Applies the configured over-limit strategy to one refused acquisition. +/// +/// This flow runs only when the check refused the acquisition — or, under +/// `degrade`, admitted it against the reduced allowance — and it produces the +/// only 429 answer in the gear. +async fn strategy( + shared: &SharedLimits, + limit: &EffectiveLimit, + key: &str, + outcome: AcquireOutcome, + now: Instant, +) -> LimitVerdict { + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-read + // The effective strategy is the folded one, which is `reject` when no + // layer declared one, that being the default ADR 0003's field table + // declares. + let strategy = limit.strategy; + // @cpt-end:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-read + + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-reject-if + if strategy == Strategy::Reject { + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-headers + // The header set and the `retry_after_seconds` value are built from + // the counter state at the refusal. + let headers = rate_limit_headers(limit, &outcome, RESPONSE_HEADERS_GATE); + // @cpt-end:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-headers + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-reject + // RETURN 429 with the `RateLimitExceeded` variant, the header set, and + // `X-OAGW-Error-Source: gateway`; nothing is forwarded and nothing is + // queued. + return LimitVerdict::Rejected(headers); + // @cpt-end:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-reject + } + // @cpt-end:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-reject-if + + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-queue-if + if strategy == Strategy::Queue { + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-queue-hold + // The slot is held for the wait bound, and the guard a cancelled wait + // drops dequeues it silently with no charge, so a queue slot is not + // spent on a caller that is no longer there. The registry is only ever + // held inside the block below, never across the poll sleep. + let mut slot = QueueSlot::new(shared, key); + let deadline = now + QUEUE_WAIT; + loop { + { + let mut registry = shared.lock(); + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-queue-full-if + // The queue for the counter key holds its bound: the 429 of the + // `reject` strategy is produced with no enqueueing, so the + // bound is a property of the strategy and not a condition the + // caller can wait out, and a caller cannot tell a full queue + // from an exhausted bucket. + if registry.queue_len(key) >= QUEUE_CAPACITY { + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-queue-full + slot.release(&mut registry); + return LimitVerdict::Rejected(rate_limit_headers( + limit, + &outcome, + RESPONSE_HEADERS_GATE, + )); + // @cpt-end:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-queue-full + } + // @cpt-end:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-queue-full-if + + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-queue-full-else + // The ELSE of the bound: the request takes its slot and the + // releases are run in the order the slots were enqueued, each + // release re-running the check. + // @cpt-end:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-queue-full-else + + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-queue-expire-if + if Instant::now() >= deadline { + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-queue-expire + // A request that has waited past the wait bound is answered + // the 429 of the `reject` strategy, dequeued, and charged + // nothing, so a request the queue cannot admit in time is + // refused rather than held. + slot.release(&mut registry); + return LimitVerdict::Rejected(rate_limit_headers( + limit, + &outcome, + RESPONSE_HEADERS_GATE, + )); + // @cpt-end:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-queue-expire + } + // @cpt-end:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-queue-expire-if + + // The release re-runs the check against the same counter, in the + // order the slots were enqueued: the slot that has waited + // longest asks first. + let released = acquire(&mut registry, key, limit, Instant::now()); + if released.admitted { + slot.release(&mut registry); + // A released and admitted request proceeds exactly as an + // immediately admitted one would, with no marker that it + // waited. + return LimitVerdict::Admitted; + } + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-queue-recheck-if + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-queue-recheck + // A released request that is refused again is not enqueued a + // second time, so the queue cannot become a retry loop and no + // request is held indefinitely; it waits out its own slot and + // is answered 429 by the expiry it meets. + // @cpt-end:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-queue-recheck + // @cpt-end:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-queue-recheck-if + } + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-queue-gone-if + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-queue-gone + // A client that disconnects while queued cancels this handler's + // future, which drops the slot's guard: the slot is dequeued + // silently, the request is charged nothing, and no answer is + // produced, because the caller that held it is no longer there. + // @cpt-end:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-queue-gone + // @cpt-end:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-queue-gone-if + tokio::time::sleep(QUEUE_POLL).await; + } + // @cpt-end:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-queue-hold + } + // @cpt-end:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-queue-if + + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-degrade-if + // The ELSE of the strategy: `degrade`, which withholds the burst reserve + // the effective algorithm has, the acquisition above having been measured + // against the allowance that leaves. + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-degrade-cover-if + // The allowance the degraded posture leaves covers the cost: the reduced + // capacity under `token_bucket`, and the unchanged window under + // `sliding_window`. + if outcome.admitted { + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-degrade-admit + // The charge is taken against that reduced capacity and the request is + // handed back to be forwarded, producing no 429 and no response + // transformation. + return LimitVerdict::Degraded; + // @cpt-end:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-degrade-admit + } + // @cpt-end:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-degrade-cover-if + + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-degrade-cover-else + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-return + // RETURN the strategy's outcome: the 429 the degraded posture's allowance + // cannot cover, because a strategy that admitted everything would be + // indistinguishable from no limit. + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-degrade-refuse + LimitVerdict::Rejected(rate_limit_headers(limit, &outcome, RESPONSE_HEADERS_GATE)) + // @cpt-end:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-degrade-refuse + // @cpt-end:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-return + // @cpt-end:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-degrade-cover-else + // @cpt-end:cpt-cf-oagw-flow-rate-limit-strategy:p1:inst-rst-degrade-if +} + +/// The queue slot of one held request, whose drop dequeues it. +/// +/// A client that disconnects while queued cancels the handler's future, which +/// drops the guard: the slot is dequeued silently, the request is charged +/// nothing, and no answer is produced, because the caller that held it is no +/// longer there. +struct QueueSlot<'a> { + shared: &'a SharedLimits, + key: String, + released: bool, +} + +impl<'a> QueueSlot<'a> { + fn new(shared: &'a SharedLimits, key: &str) -> Self { + Self { + shared, + key: String::from(key), + released: false, + } + } + + /// Dequeues the slot, marking it released so the drop that follows does + /// not dequeue a second one. + fn release(&mut self, registry: &mut RateLimiterRegistry) { + self.released = true; + registry.dequeue(&self.key); + } +} + +impl Drop for QueueSlot<'_> { + fn drop(&mut self) { + if !self.released { + self.shared.lock().dequeue(&self.key); + } + } +} + +/// Builds the header set of a 429 answer from the counter state at the +/// refusal. +/// +/// The set is produced for a refusal and for nothing else, and the gate the +/// `response_headers` member sets closes it entirely, headers and guidance +/// with them. +#[must_use] +pub fn rate_limit_headers( + limit: &EffectiveLimit, + outcome: &AcquireOutcome, + gate: bool, +) -> RateLimitHeaders { + // @cpt-begin:cpt-cf-oagw-algo-rate-limit-headers:p1:inst-hdr-gate-if + if !gate { + // @cpt-begin:cpt-cf-oagw-algo-rate-limit-headers:p1:inst-hdr-gate + // RETURN the empty set, and with it no `retry_after_seconds` member, so + // a deployment that withholds the headers withholds the guidance with + // them. + return RateLimitHeaders::default(); + // @cpt-end:cpt-cf-oagw-algo-rate-limit-headers:p1:inst-hdr-gate + } + // @cpt-end:cpt-cf-oagw-algo-rate-limit-headers:p1:inst-hdr-gate-if + + // @cpt-begin:cpt-cf-oagw-algo-rate-limit-headers:p1:inst-hdr-limit + // The effective sustained rate expressed per its window, which is the set + // ADR 0003's More Information section shows. + let limit_header = limit.sustained.rate.to_string(); + // @cpt-end:cpt-cf-oagw-algo-rate-limit-headers:p1:inst-hdr-limit + + // @cpt-begin:cpt-cf-oagw-algo-rate-limit-headers:p1:inst-hdr-remaining + // The amount the counter still holds — the tokens left in the bucket under + // `token_bucket`, and the sustained rate minus the charged total in the + // current window under `sliding_window` — which the refusal's own outcome + // reports in the currency of the algorithm that produced it. + let remaining = outcome.remaining.to_string(); + // @cpt-end:cpt-cf-oagw-algo-rate-limit-headers:p1:inst-hdr-remaining + + // @cpt-begin:cpt-cf-oagw-algo-rate-limit-headers:p1:inst-hdr-reset + // The epoch second at which the counter reaches its capacity, read from + // the wall clock: the instant the bucket is full again under + // `token_bucket`, and the instant the oldest charge ages out of the window + // under `sliding_window`. A wall clock that is unavailable leaves the + // header unset and the other three intact. + let reset = SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .map(|now| (now.as_secs().saturating_add(outcome.reset_seconds)).to_string()); + // @cpt-end:cpt-cf-oagw-algo-rate-limit-headers:p1:inst-hdr-reset + + // @cpt-begin:cpt-cf-oagw-algo-rate-limit-headers:p1:inst-hdr-retry + // The whole-second delay until the counter holds the request's `cost`, + // rounded up to at least 1, carried in `Retry-After` and in the problem + // body's `retry_after_seconds` member as the same number. + let retry = outcome.delay_seconds.max(1); + // @cpt-end:cpt-cf-oagw-algo-rate-limit-headers:p1:inst-hdr-retry + + // @cpt-begin:cpt-cf-oagw-algo-rate-limit-headers:p1:inst-hdr-return + // RETURN the header set and the value. + RateLimitHeaders { + limit: Some(limit_header), + remaining: Some(remaining), + reset, + retry_after: Some(retry.to_string()), + retry_after_seconds: Some(retry), + } + // @cpt-end:cpt-cf-oagw-algo-rate-limit-headers:p1:inst-hdr-return +} + +/// The breaker prefix of a resolved upstream, which is the key the breaker +/// machine is held under and the prefix the cleanup drops. +#[must_use] +pub fn upstream_prefix(upstream_id: Uuid) -> String { + format!("upstream:{upstream_id}") +} + +// @cpt-dod:cpt-cf-oagw-dod-rate-limit-state:p1 + +/// Runs the cleanup a successful upstream or route deletion notifies, dropping +/// every entry keyed under the deleted configuration's prefix. +/// +/// This flow runs on the management write path and not on the proxy path, so +/// it is the one flow in the feature no proxy request triggers, and it +/// completes before the delete's response is produced. +pub fn cleanup( + registry: &mut RateLimiterRegistry, + resource_type: &str, + resource_id: Uuid, +) -> usize { + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-cleanup:p2:inst-rcu-notify + // The notification carries the resource type and identifier of the row the + // write path deleted, in process and before the delete's response is + // produced. + // @cpt-end:cpt-cf-oagw-flow-rate-limit-cleanup:p2:inst-rcu-notify + + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-cleanup:p2:inst-rcu-upstream-if + if resource_type == "upstream" { + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-cleanup:p2:inst-rcu-upstream-drop + // Drop every entry whose key begins with that upstream's prefix, + // including the breaker machine held for it, and retain nothing. + let dropped = registry.drop_prefix(&upstream_prefix(resource_id)); + // @cpt-end:cpt-cf-oagw-flow-rate-limit-cleanup:p2:inst-rcu-upstream-drop + + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-cleanup:p2:inst-rcu-return + // RETURN the number of entries dropped, which is a diagnostic value + // and not a condition any caller branches on. + return dropped; + // @cpt-end:cpt-cf-oagw-flow-rate-limit-cleanup:p2:inst-rcu-return + } + // @cpt-end:cpt-cf-oagw-flow-rate-limit-cleanup:p2:inst-rcu-upstream-if + + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-cleanup:p2:inst-rcu-route-if + if resource_type == "route" { + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-cleanup:p2:inst-rcu-route-drop + // Drop every entry whose key begins with that route's prefix and + // retain the upstream's own buckets and its breaker machine, because + // the route's counters are not the upstream's. + let dropped = registry.drop_prefix(&format!("route:{resource_id}")); + // @cpt-end:cpt-cf-oagw-flow-rate-limit-cleanup:p2:inst-rcu-route-drop + + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-cleanup:p2:inst-rcu-return + // RETURN the number of entries dropped. + return dropped; + // @cpt-end:cpt-cf-oagw-flow-rate-limit-cleanup:p2:inst-rcu-return + } + // @cpt-end:cpt-cf-oagw-flow-rate-limit-cleanup:p2:inst-rcu-route-if + + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-cleanup:p2:inst-rcu-else + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-cleanup:p2:inst-rcu-none + // A notification that names no resource of the two is not a cleanup + // instruction, and a deletion that holds no bucket drops nothing, because + // the cleanup is idempotent over an absent key set. + // @cpt-end:cpt-cf-oagw-flow-rate-limit-cleanup:p2:inst-rcu-none + // @cpt-end:cpt-cf-oagw-flow-rate-limit-cleanup:p2:inst-rcu-else + + // @cpt-begin:cpt-cf-oagw-flow-rate-limit-cleanup:p2:inst-rcu-return + 0 + // @cpt-end:cpt-cf-oagw-flow-rate-limit-cleanup:p2:inst-rcu-return +} + +/// The observer the write path of the configuration feature notifies, which +/// runs the cleanup over the registry it holds. +pub struct RegistryCleanup { + registry: SharedLimits, +} + +impl RegistryCleanup { + /// The observer over one shared registry. + #[must_use] + pub fn new(registry: SharedLimits) -> Self { + Self { registry } + } +} + +impl crate::control_plane::cache::RateLimitCleanup for RegistryCleanup { + fn upstream_deleted(&self, _tenant_id: Uuid, upstream_id: Uuid) { + let mut registry = self.registry.lock(); + let _ = cleanup(&mut registry, "upstream", upstream_id); + } + + fn route_deleted(&self, _tenant_id: Uuid, route_id: Uuid) { + let mut registry = self.registry.lock(); + let _ = cleanup(&mut registry, "route", route_id); + } +} diff --git a/gears/system/oagw/oagw/src/data_plane/resolve.rs b/gears/system/oagw/oagw/src/data_plane/resolve.rs new file mode 100644 index 0000000..83317c1 --- /dev/null +++ b/gears/system/oagw/oagw/src/data_plane/resolve.rs @@ -0,0 +1,304 @@ +//! Consuming the effective configuration at proxy time. +//! +//! Realizes `cpt-cf-oagw-algo-resolve-consume`: the routine the proxy flow +//! reaches after authorization. It looks the normalized alias up in the Data +//! Plane L1 cache, resolves the effective configuration through +//! `cpt-cf-oagw-flow-resolve-effective-config` of +//! `cpt-cf-oagw-feature-hierarchical-config` on a miss, consumes the result in +//! the layer order upstream, then route, then tenant, and answers the four +//! outcomes the caller maps — a resolved configuration, the not-found outcome, +//! the disabled outcome, and the failed-closed outcome. +//! +//! The routine owns the consumption order and the cache, and no merge strategy: +//! the per-family merges are that feature's, called through its entry point. + +use std::sync::Arc; + +use uuid::Uuid; + +use crate::control_plane::alias_derive::{self, DeriveError}; +use crate::control_plane::cache::ControlPlaneCache; +use crate::control_plane::effective::{resolve_effective, ResolveError}; +use crate::control_plane::chain::walk_candidates; +use crate::control_plane::shadow::shadow_resolve; +use crate::data_plane::cache::{DpCache, DP_CACHE_CAPACITY}; +use crate::domain::effective::RouteSelector; +use crate::domain::proxy::{AliasDerivation, ResolvedUpstream, RouteCandidate}; + +/// The outcome of one consumption, which the caller maps to an answer. +/// +/// The not-found outcome covers both an empty candidate set and an unmatched +/// route, and is answered 404 with the `RouteNotFound` variant; the disabled +/// outcome is answered 503 with `LinkUnavailable`; the failed-closed outcome is +/// answered with the platform 500 problem shape and never forwards a request. +#[derive(Debug, Clone)] +pub enum Resolution { + /// The upstream and its route candidates resolved. + Resolved(Arc), + /// No chain element holds the alias. + NotFound, + /// The effective `enabled` state of the resolved upstream is false. + Disabled, + /// The chain is unavailable, unordered, or cyclic, or the alias cannot be + /// normalized: the resolution failed closed. + Failed, +} + +// @cpt-dod:cpt-cf-oagw-dod-effective-config:p1 + +/// Consumes the effective configuration one proxy request is subject to. +/// +/// The cache is consulted first and populated on a miss; the route keys the +/// candidate set was read under are recorded with the entry so the flush the +/// configuration write path notifies drops them with it. +#[must_use] +pub fn consume( + store: &crate::store::OagwStore, + cache: &DpCache, + calling_tenant: Uuid, + ancestors: &[Uuid], + alias: &str, + selector: &RouteSelector, +) -> Resolution { + // @cpt-begin:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-key + // The cache key of ADR 0005: the calling tenant and the normalized alias, + // which is the shape the flush matches on. + let normalized = match crate::domain::Alias::parse(alias) { + Ok(normalized) => normalized, + Err(_) => return Resolution::Failed, + }; + let key = DpCache::upstream_key(calling_tenant, normalized.to_string().as_str()); + // @cpt-end:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-key + + // @cpt-begin:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-hit-if + if let Some(hit) = cache.get(&key) { + // @cpt-begin:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-hit + // ADR 0006's flow with caching: the hit answers without a resolution + // call, so the outcomes the resolution would produce are the ones the + // entry recorded when it was populated. + return outcome_of(&hit); + // @cpt-end:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-hit + } + // @cpt-end:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-hit-if + + // @cpt-begin:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-miss-else + // The ELSE of the lookup: the key is not in the cache, so the resolution + // runs and its result is stored under the key of step 1. + // @cpt-end:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-miss-else + + // @cpt-begin:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-resolve + // The hierarchical-config flow: it walks the tenant chain with shadowing, + // computes the effective `enabled` state, and merges both layers. + let resolution = match resolve_effective(store, calling_tenant, ancestors, normalized.to_string().as_str(), selector) + { + Ok(Some(resolution)) => resolution, + Ok(None) => return Resolution::NotFound, + Err(ResolveError::UnavailableChain) => return Resolution::Failed, + }; + // @cpt-end:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-resolve + + // @cpt-begin:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-order + // The layer order of consumption is upstream, then route, then tenant: the + // merged upstream families are read from the upstream layer result, the + // route families from the route layer result, and the ownership is the + // tenant the routing target belongs to. + let (target, candidates) = candidate_set(store, calling_tenant, ancestors, &normalized); + let Some(target) = target else { + return Resolution::NotFound; + }; + let row = &target.row.upstream; + // @cpt-end:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-order + + // @cpt-begin:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-disabled-if + if !resolution.enabled { + // @cpt-begin:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-disabled-return + // A disabled upstream is never dialed, whatever the chain contributed. + return Resolution::Disabled; + // @cpt-end:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-disabled-return + } + // @cpt-end:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-disabled-if + + // @cpt-begin:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-grpc-if + if row.protocol == crate::gts::PROTOCOL_GRPC { + // @cpt-begin:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-grpc-return + // No HTTP match key is evaluated for a gRPC upstream, so the request is + // answered with the same not-found outcome any unmatched HTTP request + // gets. + return Resolution::NotFound; + // @cpt-end:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-grpc-return + } + // @cpt-end:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-grpc-if + + // @cpt-begin:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-ok-else + // The ELSE of the protocol check: the upstream is an HTTP one, so the + // resolved configuration and its route candidate set are produced. + // @cpt-end:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-ok-else + + // @cpt-begin:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-ok + let route_keys = candidates + .iter() + .filter_map(|candidate| { + let http = candidate.route.match_config.http.as_ref()?; + Some(DpCache::route_key( + candidate.route.upstream_id, + http.methods.first().map(String::as_str).unwrap_or("GET"), + http.path.as_str(), + )) + }) + .collect(); + let resolved = Arc::new(ResolvedUpstream { + tenant_id: target.tenant_id, + upstream_id: target.upstream_id, + alias: normalized.to_string(), + alias_derivation: derivation_of(&row.server.endpoints), + endpoints: row.server.endpoints.clone(), + protocol: row.protocol.clone(), + enabled: resolution.enabled, + headers: row.headers.clone().unwrap_or_default(), + rate_limit: resolution.upstream.rate_limit, + plugins: resolution.upstream.plugins, + cors: resolution.upstream.cors, + route_candidates: candidates, + }); + // @cpt-end:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-ok + + // @cpt-begin:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-store + cache.insert(key, Arc::clone(&resolved), route_keys); + // @cpt-end:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-store + + // @cpt-begin:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-return + Resolution::Resolved(resolved) + // @cpt-end:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-return +} + +/// The outcome a cached entry answers with. +fn outcome_of(entry: &ResolvedUpstream) -> Resolution { + if !entry.enabled { + return Resolution::Disabled; + } + if entry.is_grpc() { + return Resolution::NotFound; + } + if entry.route_candidates.is_empty() { + return Resolution::NotFound; + } + Resolution::Resolved(Arc::new(entry.clone())) +} + +/// The ordered route candidate set of the chain, most distant first. +/// +/// Every chain element that holds an alias-matched upstream row contributes the +/// enabled HTTP routes of its own row, which is the same scoping +/// `cpt-cf-oagw-algo-field-family-merge`'s route layer reads: a route of a +/// tenant outside the chain is never a candidate. +#[must_use] +fn candidate_set( + store: &crate::store::OagwStore, + calling_tenant: Uuid, + ancestors: &[Uuid], + alias: &crate::domain::Alias, +) -> ( + Option, + Vec, +) { + // @cpt-begin:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-fail-if + // The chain must be readable and orderable for a candidate set to exist at + // all: an unavailable, unordered, or cyclic chain, or a shadow resolution + // that finds no target, is the failed-closed outcome. + let walked = walk_candidates(store, calling_tenant, ancestors, alias) + .ok() + .and_then(|candidates| shadow_resolve(&candidates, alias).map(|shadow| (shadow, candidates))); + let Some((shadow, _candidates)) = walked else { + // @cpt-begin:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-fail-return + // Failure with no partial configuration: the caller answers the + // platform 500 problem shape and never forwards a request resolved + // against an incomplete chain. + return (None, Vec::new()); + // @cpt-end:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-fail-return + }; + // @cpt-end:cpt-cf-oagw-algo-resolve-consume:p1:inst-resc-fail-if + + let mut rows: Vec = Vec::new(); + for binding in &shadow.bindings { + for row in store.routes_of_upstream(binding.tenant_id, binding.upstream_id) { + if row.route.match_config.http.is_some() { + rows.push(RouteCandidate { + tenant_id: binding.tenant_id, + depth: binding.depth, + route: row.route, + }); + } + } + } + for row in store.routes_of_upstream(shadow.target.tenant_id, shadow.target.upstream_id) { + if row.route.match_config.http.is_some() { + rows.push(RouteCandidate { + tenant_id: shadow.target.tenant_id, + depth: shadow.target.depth, + route: row.route, + }); + } + } + (Some(shadow.target), rows) +} + +/// The alias derivation kind the endpoint-selection matrix keys on. +/// +/// A single-endpoint pool never reaches the derivation branch of the matrix, so +/// the kind is recorded as the explicit one; a multi-endpoint pool is derived +/// when the endpoint set yields a common suffix and explicit otherwise. +#[must_use] +fn derivation_of(endpoints: &[crate::domain::Endpoint]) -> AliasDerivation { + if endpoints.len() < 2 { + return AliasDerivation::Explicit; + } + match alias_derive::derive(endpoints) { + Ok(_) => AliasDerivation::Derived, + Err(DeriveError::NonDerivable) => AliasDerivation::Explicit, + } +} + +impl Resolution { + /// Maps the outcome to the catalogue failure the caller answers with. + /// + /// The resolved outcome is not a failure; the not-found outcome is the 404 + /// an unmatched request is answered with, and the disabled outcome is the + /// 503 an undialable upstream is answered with. The failed-closed outcome + /// carries no catalogue row: the caller answers it with the platform RFC + /// 9457 500 problem shape and never forwards a request, so `None` names no + /// success here — the caller tests the outcome directly. + #[must_use] + pub fn failure_of(&self) -> Option { + use crate::domain::error::{DomainError, ErrorKind}; + match self { + Self::Resolved(_) | Self::Failed => None, + Self::NotFound => Some(DomainError::gateway( + ErrorKind::RouteNotFound, + "no chain element holds the alias the request addressed", + )), + Self::Disabled => Some(DomainError::gateway( + ErrorKind::LinkUnavailable, + "the resolved upstream is disabled, so the request is never dialed", + )), + } + } +} + +/// The control-plane cache generation the resolution observed, for the +/// diagnostics of the observability feature. +/// +/// The Data Plane cache carries no generation of its own: its invalidation is +/// the explicit flush, and the generation the write path advances is the +/// control plane's. +#[must_use] +pub fn control_plane_generation(cache: &ControlPlaneCache) -> u64 { + cache.generation() +} + +/// The entry count the Data Plane cache holds, for the diagnostics of the +/// observability feature. +#[must_use] +pub fn cache_entries(cache: &DpCache) -> usize { + let _ = DP_CACHE_CAPACITY; + cache.len() +} diff --git a/gears/system/oagw/oagw/src/data_plane/sandbox.rs b/gears/system/oagw/oagw/src/data_plane/sandbox.rs new file mode 100644 index 0000000..8970378 --- /dev/null +++ b/gears/system/oagw/oagw/src/data_plane/sandbox.rs @@ -0,0 +1,174 @@ +//! The Starlark sandbox discipline one plugin invocation is held to. +//! +//! Realizes `cpt-cf-oagw-algo-starlark-sandbox`: the capability check, the two +//! per-invocation ceilings, and the try/catch the invocation runs under. It is +//! the enforcement point `cpt-cf-oagw-nfr-starlark-sandbox` names, whose +//! threshold is "Zero sandbox escapes; plugin execution timeout ≤ 100ms; memory +//! ≤ 10MB per invocation", and which the plugin contract's +//! [`crate::domain::plugin_contract::SANDBOX_LIMITS`] exposes without +//! enforcing. +//! +//! The enforcement is absolute in this deployment: the gear carries no Starlark +//! interpreter and none may be added, so an invocation of a stored custom +//! source is a limit that cannot be enforced — no network capability can be +//! removed from an interpreter that does not exist, and no ceiling can be +//! measured around a run that never happens — and the invocation is refused +//! before it is attempted. A built-in implementation is compiled into this +//! process, receives nothing but the phase context and the configuration value, +//! and is held to the two ceilings by the same wrapper. + +use std::panic::AssertUnwindSafe; +use std::time::{Duration, Instant}; + +use crate::domain::plugin_contract::SandboxLimits; + +/// The 100 ms ceiling of one invocation, as the contract publishes it. +pub const MAX_INVOCATION_MILLIS: u64 = 100; + +/// The 10 MB ceiling of one invocation, as the contract publishes it. +pub const MAX_INVOCATION_MEMORY_BYTES: usize = 10 * 1024 * 1024; + +/// The kind of implementation one invocation runs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InvocationKind { + /// A built-in implementation compiled into this process. + Builtin, + /// A stored custom plugin's Starlark source. + CustomSource, +} + +/// Why an invocation is refused before it is attempted. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SandboxRefusal { + /// The limits cannot be enforced for this kind in this deployment. + Unenforceable, + /// The invocation's inputs already exceed a ceiling the invocation is held + /// to, so it cannot run within its budget. + OverBudget { + /// Which ceiling the inputs exceed. + reason: String, + }, +} + +/// Why an invocation that ran was terminated and discarded. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SandboxFailure { + /// The invocation exceeded its wall-clock ceiling. + Timeout { + /// The ceiling the invocation breached, in milliseconds. + limit_millis: u64, + }, + /// The invocation raised an error, caught at the boundary. + Raised, +} + +// @cpt-dod:cpt-cf-oagw-dod-starlark-sandbox:p1 + +/// Confirms before execution that the invocation can be held to the limits. +/// +/// # Errors +/// +/// Returns [`SandboxRefusal::Unenforceable`] for a custom source, because no +/// interpreter exists in this deployment to hold the four prohibitions and the +/// two ceilings against, and [`SandboxRefusal::OverBudget`] when the inputs the +/// invocation would already receive exceed its memory ceiling. +pub fn admit( + kind: InvocationKind, + limits: &SandboxLimits, + input_bytes: usize, +) -> Result<(), SandboxRefusal> { + // @cpt-begin:cpt-cf-oagw-algo-starlark-sandbox:p1:inst-sandbox-capabilities + // A capability that cannot be removed is a limit that cannot be enforced, + // and the plugin is not run. A built-in implementation grants none: the + // invocation receives the phase context and the configuration value, and + // nothing else, so there is no network handle, no file handle, and no + // import table to take away. + if kind == InvocationKind::CustomSource + || limits.network_io + || limits.file_io + || limits.imports + { + return Err(SandboxRefusal::Unenforceable); + } + // @cpt-end:cpt-cf-oagw-algo-starlark-sandbox:p1:inst-sandbox-capabilities + + // @cpt-begin:cpt-cf-oagw-algo-starlark-sandbox:p1:inst-sandbox-limits + // The two ceilings are applied to this invocation alone, so one plugin's + // breach never consumes another's budget. The memory ceiling is checked + // against what the invocation is about to be handed: inputs already above + // it cannot run within the budget they are given. + if input_bytes > limits.max_invocation_memory_bytes { + return Err(SandboxRefusal::OverBudget { + reason: format!( + "the invocation's inputs are {input_bytes} bytes, above the {} the ceiling allows", + limits.max_invocation_memory_bytes + ), + }); + } + Ok(()) + // @cpt-end:cpt-cf-oagw-algo-starlark-sandbox:p1:inst-sandbox-limits +} + +/// Runs one invocation under the sandbox discipline. +/// +/// # Errors +/// +/// Returns [`SandboxFailure::Raised`] when the invocation panics, and +/// [`SandboxFailure::Timeout`] when it returned after its wall-clock ceiling; +/// either way every partial mutation the invocation performed is discarded, +/// because the caller receives the failure and not the value it produced. +pub fn invoke( + limits: &SandboxLimits, + run: impl FnOnce() -> T, +) -> Result { + // @cpt-begin:cpt-cf-oagw-algo-starlark-sandbox:p1:inst-sandbox-try + // The invocation runs here, with the phase implementation the chain handed + // over and nothing else in scope. + // @cpt-begin:cpt-cf-oagw-algo-starlark-sandbox:p1:inst-sandbox-run + let started = Instant::now(); + let outcome = std::panic::catch_unwind(AssertUnwindSafe(run)); + let elapsed = started.elapsed(); + // @cpt-end:cpt-cf-oagw-algo-starlark-sandbox:p1:inst-sandbox-run + // @cpt-end:cpt-cf-oagw-algo-starlark-sandbox:p1:inst-sandbox-try + + // @cpt-begin:cpt-cf-oagw-algo-starlark-sandbox:p1:inst-sandbox-catch + let value = outcome.map_err(|_| SandboxFailure::Raised)?; + // A breach is never retried and never re-issued: the value the invocation + // produced past its ceiling is discarded with the ceiling it breached. + if elapsed > Duration::from_millis(limits.max_invocation_millis) { + return Err(SandboxFailure::Timeout { + limit_millis: limits.max_invocation_millis, + }); + } + // @cpt-end:cpt-cf-oagw-algo-starlark-sandbox:p1:inst-sandbox-catch + + // @cpt-begin:cpt-cf-oagw-algo-starlark-sandbox:p1:inst-sandbox-catch-handle + // The termination is the catch's answer: the invocation is ended, every + // partial mutation it performed is discarded with the value it was + // producing, and the failure is reported to + // `cpt-cf-oagw-algo-chain-execute`, which answers 502 for it. + // @cpt-end:cpt-cf-oagw-algo-starlark-sandbox:p1:inst-sandbox-catch-handle + + // @cpt-begin:cpt-cf-oagw-algo-starlark-sandbox:p1:inst-sandbox-else + // The ELSE of the catch: the invocation ran inside both ceilings and + // raised nothing. + // @cpt-end:cpt-cf-oagw-algo-starlark-sandbox:p1:inst-sandbox-else + + // @cpt-begin:cpt-cf-oagw-algo-starlark-sandbox:p1:inst-sandbox-return + // RETURN the verdict or mutation, which the chain applies in the composed + // order. + Ok(value) + // @cpt-end:cpt-cf-oagw-algo-starlark-sandbox:p1:inst-sandbox-return +} + +/// Whether the sandbox limits are enforced for one invocation kind. +#[must_use] +pub fn enforceable(kind: InvocationKind, limits: &SandboxLimits) -> bool { + admit(kind, limits, 0).is_ok() +} + +/// The ceiling the contract publishes, when a caller needs the number it holds. +#[must_use] +pub fn published() -> SandboxLimits { + crate::domain::plugin_contract::SANDBOX_LIMITS +} diff --git a/gears/system/oagw/oagw/src/data_plane/stream.rs b/gears/system/oagw/oagw/src/data_plane/stream.rs new file mode 100644 index 0000000..78d9c8d --- /dev/null +++ b/gears/system/oagw/oagw/src/data_plane/stream.rs @@ -0,0 +1,414 @@ +//! The stream pump of `cpt-cf-oagw-algo-stream-pump`. +//! +//! Realizes `cpt-cf-oagw-feature-streaming`'s transfer: the body of one proxy +//! exchange moved one chunk at a time to the caller's half, and a taken-up +//! upgrade moved as raw bytes in both directions. The pump is one routine for +//! both transfer modes and for both directions, and the mode changes only +//! which halves it reads and writes: the `tunnel` mode reads and writes both, +//! the `incremental` mode reads the upstream half +//! `cpt-cf-oagw-algo-outbound-forward` opened and writes the caller's. +//! +//! The module holds no transport type of its own: it reads and writes the two +//! halves it is handed, and the caller assembles the body it returns into the +//! response the proxy path answers with. It implements +//! `cpt-cf-oagw-principle-no-cache` on this path, because it never holds a +//! complete response body, and it keeps the stream contract, because it +//! inspects no byte: no frame is parsed, no event is rewritten, and no +//! keepalive is injected. + +use std::sync::Arc; +use std::time::Instant; + +use futures_util::future::Either; +use futures_util::stream::{self, StreamExt}; +use parking_lot::Mutex; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use crate::data_plane::forward::LiveExchange; +use crate::domain::error::DomainError; +use crate::domain::stream::{StreamOutcome, StreamSession, answer_of}; + +/// The size of the buffer one tunnel direction reads into, which bounds the +/// bytes a direction holds between its read and its write. +const TUNNEL_BUFFER: usize = 8192; + +/// The error a torn-down body carries into the transport, which aborts it +/// rather than ending it cleanly: the caller of an exchange whose head was +/// already committed learns of the teardown from the truncation. +const TORN_DOWN: &str = "the stream was torn down before it ended"; + +/// The state one item of an incremental body is carried in. +type PumpState = ( + LiveExchange, + Arc>, + Option, + Disconnect, +); + +/// Records the outcome a caller's disconnect leaves a transfer in. +/// +/// The caller's half belongs to the transport, which the incremental pump +/// never reads: a caller that stops accepting the body ends the transfer by +/// dropping it, and the pump's state is dropped with it. That drop is the only +/// moment the disconnect is observable, and it is the moment the upstream half +/// is closed too, because the exchange it was carried on is dropped with the +/// body. A transfer that ended any other way has an outcome on the session +/// already, so the drop records nothing over it. +struct Disconnect(Arc>); + +impl Drop for Disconnect { + fn drop(&mut self) { + let mut session = self.0.lock(); + if session.is_open() { + session.disconnect(); + } + } +} + +/// Transfers the body of one exchange in the `incremental` mode. +/// +/// The first chunk is awaited here under the idle deadline, because the +/// response head is not committed until the caller assembles it, and a stall +/// or an abort before the first byte can still be answered as a whole — the +/// 504 and the 502 the two error answers carry. The stream this returns +/// continues the pump past that point, where a teardown can no longer be +/// answered and instead ends the body mid-transfer. +/// +/// # Errors +/// +/// Returns the `IdleTimeout` failure when no byte arrives within the idle +/// deadline, and the `StreamAborted` failure when the upstream half fails +/// before the first byte arrives. +#[allow(clippy::result_large_err)] +pub async fn incremental( + mut live: LiveExchange, + session: Arc>, +) -> Result>, DomainError> { + // @cpt-dod:cpt-cf-oagw-dod-stream-sse-forwarding:p1 + // The forwarding this DoD requires is the transfer below: the upstream half + // is read and the caller's half is written as bytes arrive, each chunk + // flushed by the transport that yields it, no frame parsed, no event + // rewritten, no keepalive injected, and the `headers.response` rules and + // the error-source tag already applied by + // `cpt-cf-oagw-algo-response-classify` before the body was handed over. + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-idle-if + // The idle timer is the only deadline over a body, and the wait for the + // first chunk is the last wait the exchange can still be answered across. + // @cpt-begin:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-idle-if + let first = match timeout_of(&session, live.chunk()).await { + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-idle-return + // @cpt-begin:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-idle-return + Err(()) => return Err(answer_of(StreamOutcome::Stalled).expect("the stall is answered")), + // @cpt-end:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-idle-return + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-idle-return + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-abort-if + // @cpt-begin:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-abort-if + Ok(Err(_failure)) => { + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-abort-return + // @cpt-begin:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-abort-return + return Err(answer_of(StreamOutcome::Aborted).expect("the abort is answered")); + // @cpt-end:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-abort-return + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-abort-return + } + // @cpt-end:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-abort-if + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-abort-if + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-upstream-if + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-upstream-close + Ok(Ok(None)) => { + // An upstream that ends its half before the first byte is a close + // and not an abort, so the caller receives a completed transfer + // with an empty body and no error answer at all: the bytes already + // read are the body's end and the caller's half is closed with it. + session.lock().upstream_closed(); + live.release().await; + return Ok(stream::empty().boxed()); + } + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-upstream-close + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-upstream-if + Ok(Ok(Some(chunk))) => chunk, + }; + // @cpt-end:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-idle-if + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-idle-if + + // @cpt-begin:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-moved + // A byte is counted as moved only once it has been read from one half and + // written and flushed to the other, which is the event the idle timer + // measures; the transport writes and flushes each item the pump yields + // before it reads the next, so a caller that accepts nothing moves no + // byte and is indistinguishable from an upstream that emits none. + session + .lock() + .record_moved(u64::try_from(first.len()).unwrap_or(u64::MAX)); + // @cpt-end:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-moved + + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-client-if + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-client-close + // The caller's half is the transport's and the pump never reads it, so a + // caller that stops accepting the body ends the transfer by dropping it. + // The guard rides the pump's state for exactly this: its drop is the + // moment the disconnect is observable, and it closes the upstream half and + // records the client-disconnect outcome the session carries with it. + let disconnect = Disconnect(Arc::clone(&session)); + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-client-close + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-client-if + + // @cpt-begin:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-flush + // The direction the `incremental` mode fixes is one way: the upstream half + // is read and the caller's is written, each chunk as soon as it is read + // and never accumulated into a complete response body, which is the + // implementation of `cpt-cf-oagw-principle-no-cache` this feature delivers. + Ok(stream::unfold( + (live, session, Some(first), disconnect), + |(live, session, pending, disconnect)| async move { + match pending { + // The chunk the head wait already read is the body's first + // item, so the bytes reach the caller in the order and at the + // cadence the upstream emitted them. + Some(chunk) => Some((Ok(chunk), (live, session, None, disconnect))), + None => next_chunk(live, session, disconnect).await, + } + }, + ) + .boxed()) + // @cpt-end:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-flush +} + +/// Reads the next chunk of an incremental body. +/// +/// The pump's state is returned with the item so the stream can carry on; +/// `None` ends the body, which the transport treats as a clean end, because +/// an upstream that closed its half finished the response. +async fn next_chunk( + mut live: LiveExchange, + session: Arc>, + disconnect: Disconnect, +) -> Option<(Result, PumpState)> { + match timeout_of(&session, live.chunk()).await { + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-idle-if + Err(()) => { + // The head is committed past the first chunk, so a stall and an + // abort here are recorded and end the body the transport is + // sending, which is the only way the caller learns of them. + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-idle-return + session.lock().stalled(); + Some((Err(String::from(TORN_DOWN)), (live, session, None, disconnect))) + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-idle-return + } + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-idle-if + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-abort-if + Ok(Err(_failure)) => { + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-abort-return + session.lock().abort_transfer(); + Some((Err(String::from(TORN_DOWN)), (live, session, None, disconnect))) + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-abort-return + } + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-abort-if + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-upstream-if + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-upstream-close + Ok(Ok(None)) => { + // The upstream's half ended: the bytes already read are the end of + // the body, the caller's half is closed by the transport that has + // no item left to yield, and the outcome is recorded. + session.lock().upstream_closed(); + live.release().await; + drop(disconnect); + None + } + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-upstream-close + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-upstream-if + Ok(Ok(Some(chunk))) => { + // @cpt-begin:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-idle-reset + // The idle timer is reset by the byte that just moved, which is + // why a healthy stream is never answered for being quiet between + // events and a stalled one is. + session + .lock() + .record_moved(u64::try_from(chunk.len()).unwrap_or(u64::MAX)); + Some((Ok(chunk), (live, session, None, disconnect))) + // @cpt-end:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-idle-reset + } + } +} + +/// Waits for the next chunk under the idle deadline in force. +/// +/// `Err(())` is the deadline expiring; the inner result is the read itself. +async fn timeout_of( + session: &Mutex, + read: impl std::future::Future, DomainError>>, +) -> Result, DomainError>, ()> { + let idle = session.lock().idle_timeout; + match tokio::time::timeout(idle, read).await { + Ok(inner) => Ok(inner), + Err(_elapsed) => Err(()), + } +} + +/// Transfers a taken-up upgrade as a byte tunnel in both directions. +/// +/// The two halves are read and written to each other for as long as either +/// moves a byte, framed by nothing and interpreted by nothing. The idle +/// deadline is applied to the absence of traffic in either direction, which is +/// the one timer a tunnel is under. The exchange never reaches the shared +/// client's pool: the two connections are torn down when the tunnel ends, and +/// the outcome is the record the request's execution context carries. +/// +/// The upstream half is read and written through the session the send opened, +/// because a 101 turns that session's reader and writer into the +/// close-delimited forms that carry the bytes which belong to no message — and +/// the bytes the upstream sent together with the 101 are the first of them, so +/// a half taken out of the session as a bare socket would drop them. +pub async fn tunnel( + mut live: LiveExchange, + session: Arc>, + caller: hyper::upgrade::Upgraded, +) { + // The caller's half is the upgrade the HTTP layer handed back, which the + // tokio half reads and writes through the adapter the two runtimes share. + let (mut caller_read, mut caller_write) = + tokio::io::split(hyper_util::rt::TokioIo::new(caller)); + // @cpt-begin:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-bounded + // One chunk is held between the halves at any moment: this buffer is what + // a tunnel read fills and nothing is read again until it has been written + // and flushed to the other half, and the `incremental` form of the same + // routine holds its one chunk in the single pending slot its pump state + // carries. A caller that stops accepting bytes therefore stops the reads + // that would fill it rather than growing it. + let mut caller_buffer = vec![0u8; TUNNEL_BUFFER]; + // @cpt-end:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-bounded + // The instant the last byte moved, shared by both directions, which is + // what the idle deadline is measured against. + let mut last_moved = Instant::now(); + + // @cpt-begin:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-direction + // Both directions are read, and whichever has the next byte is written to + // the other; the mode is what fixes the direction here, and the + // `incremental` form of the same routine reads only the upstream half. + let outcome: StreamOutcome = loop { + let idle = session.lock().idle_timeout; + let wait = idle.saturating_sub(last_moved.elapsed()); + let direction = tokio::time::timeout(wait, async { + tokio::select! { + read = caller_read.read(&mut caller_buffer) => Either::Left(read), + read = live.chunk() => Either::Right(read), + } + }) + .await; + // @cpt-begin:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-idle-if + match direction { + Err(_elapsed) => { + // The deadline is measured from the last byte either direction + // moved, so a direction that was silent while the other moved + // is not torn down for it. + // @cpt-begin:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-idle-return + if last_moved.elapsed() >= idle { + break StreamOutcome::Stalled; + } + // @cpt-end:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-idle-return + continue; + } + // @cpt-begin:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-teardown-client-if + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-client-if + // @cpt-begin:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-client-if + Ok(Either::Left(read)) => { + // @cpt-begin:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-teardown-client + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-client-close + // @cpt-begin:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-client-return + match read { + // The caller's half ends: the upstream half is closed with + // it, because the gateway conveys only the close. + Ok(0) => break StreamOutcome::ClientDisconnected, + Ok(moved) => { + // A write that fails tears the tunnel down with bytes + // still expected, which is the mid-flight abort the + // flow's abort branch answers 502 for. + // @cpt-begin:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-abort-if + // @cpt-begin:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-abort-return + if live.write_upstream(&caller_buffer[..moved]) + .await + .is_err() + { + break StreamOutcome::Aborted; + } + // @cpt-end:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-abort-return + // @cpt-end:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-abort-if + last_moved = Instant::now(); + session + .lock() + .record_moved(u64::try_from(moved).unwrap_or(u64::MAX)); + } + Err(_) => break StreamOutcome::Aborted, + } + // @cpt-end:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-client-return + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-client-close + // @cpt-end:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-teardown-client + } + // @cpt-end:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-client-if + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-client-if + // @cpt-end:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-teardown-client-if + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-upstream-if + // @cpt-begin:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-teardown-upstream-if + // @cpt-begin:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-upstream-if + Ok(Either::Right(read)) => { + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-upstream-close + // @cpt-begin:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-teardown-upstream + // @cpt-begin:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-upstream-return + match read { + // The upstream's half ends: the bytes already read are + // written to the caller before the caller's half is closed + // with it, which is why the write precedes the break. + Ok(None) => break StreamOutcome::UpstreamClosed, + Ok(Some(chunk)) => { + if write_to(&mut caller_write, &chunk).await.is_err() { + break StreamOutcome::Aborted; + } + last_moved = Instant::now(); + session + .lock() + .record_moved(u64::try_from(chunk.len()).unwrap_or(u64::MAX)); + } + // @cpt-begin:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-abort-if + Err(_) => break StreamOutcome::Aborted, + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-abort-if + } + // @cpt-end:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-upstream-return + // @cpt-end:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-teardown-upstream + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-upstream-close + } + // @cpt-end:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-upstream-if + // @cpt-end:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-teardown-upstream-if + // @cpt-end:cpt-cf-oagw-flow-stream-transfer:p1:inst-st-upstream-if + } + // @cpt-end:cpt-cf-oagw-flow-upgrade-proxy:p1:inst-up-idle-if + }; + // @cpt-end:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-direction + + // @cpt-begin:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-return + // Both halves are torn down together, because the outcome is the end of + // the tunnel and no half survives it. The record the request's execution + // context carries is the outcome the pump returns, which the flow's + // teardown branches and its abort branch all end in, and no log line, no + // metric, and no span is emitted for any of them. + let _ = caller_write.shutdown().await; + drop(caller_read); + live.teardown().await; + match outcome { + StreamOutcome::ClientDisconnected => session.lock().disconnect(), + StreamOutcome::UpstreamClosed => session.lock().upstream_closed(), + StreamOutcome::Stalled => session.lock().stalled(), + StreamOutcome::Aborted => session.lock().abort_transfer(), + } + // @cpt-end:cpt-cf-oagw-algo-stream-pump:p1:inst-sp-return +} + +/// Writes one chunk to a tunnel half and flushes it. +async fn write_to( + half: &mut tokio::io::WriteHalf, + chunk: &[u8], +) -> std::io::Result<()> +where + T: tokio::io::AsyncWrite + Unpin, +{ + half.write_all(chunk).await?; + half.flush().await +} diff --git a/gears/system/oagw/oagw/src/data_plane/validate.rs b/gears/system/oagw/oagw/src/data_plane/validate.rs new file mode 100644 index 0000000..4379dc6 --- /dev/null +++ b/gears/system/oagw/oagw/src/data_plane/validate.rs @@ -0,0 +1,356 @@ +//! Inbound and body validation of a proxy request. +//! +//! Realizes `cpt-cf-oagw-algo-inbound-validate` and +//! `cpt-cf-oagw-algo-body-validate`: the method, path, query, and header checks +//! against the matched route, and the framing and size checks the request body +//! is subject to before any of it is buffered. Both routines are the request +//! half of `cpt-cf-oagw-nfr-input-validation`, which requires invalid requests +//! to be rejected with 400 — and the size breach with 413 — and neither reads +//! any configuration beyond the matched route. + +use crate::domain::error::{DomainError, ErrorKind}; +use crate::domain::proxy::{MatchedRoute, ProxyContext}; + +/// The 100MB hard limit of `cpt-cf-oagw-constraint-body-limit`, read as +/// 100,000,000 bytes per the FEATURE §1.5 deviation. +pub const BODY_LIMIT_BYTES: usize = 100_000_000; + +/// The eight hop-by-hop headers of `cpt-cf-oagw-fr-header-transform`, which no +/// plain request/response exchange forwards. +pub const HOP_BY_HOP: [&str; 8] = [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]; + +/// The routing header the endpoint selection consumes and never forwards. +pub const ROUTING_HEADER: &str = "x-oagw-target-host"; + +// @cpt-dod:cpt-cf-oagw-dod-inbound-validation:p1 + +/// Validates the inbound request against the matched route. +/// +/// # Errors +/// +/// Returns the `ValidationError` failure the caller answers 400 with, naming +/// every failing property of the request. +#[allow(clippy::result_large_err)] +pub fn validate_inbound( + context: &ProxyContext, + matched: &MatchedRoute, +) -> Result<(), DomainError> { + // @cpt-begin:cpt-cf-oagw-algo-inbound-validate:p1:inst-inv-method + // The method allowlist was the first filter of the match; a failure here + // is a defect of the caller of this routine, not of the request. + // @cpt-end:cpt-cf-oagw-algo-inbound-validate:p1:inst-inv-method + + let mut rejected: Vec = Vec::new(); + + // @cpt-begin:cpt-cf-oagw-algo-inbound-validate:p1:inst-inv-path + // The outbound path was built by `cpt-cf-oagw-algo-route-match`, which + // already made the `path_suffix_mode` decision: the request path this + // routine reads is inside the matched route's own path space, so the check + // holds it to that and refuses a path that left it. + if !context.request_path().starts_with('/') { + rejected.push(String::from("path")); + } + // @cpt-end:cpt-cf-oagw-algo-inbound-validate:p1:inst-inv-path + + // @cpt-begin:cpt-cf-oagw-algo-inbound-validate:p1:inst-inv-query + if let Some(query) = context.query.as_deref() { + for (name, _) in parse_query(query) { + // @cpt-begin:cpt-cf-oagw-algo-inbound-validate:p1:inst-inv-query-if + if !matched.query_allowlist.iter().any(|allowed| allowed == &name) { + // @cpt-begin:cpt-cf-oagw-algo-inbound-validate:p1:inst-inv-query-return + // The offending parameter is named in the rejection the caller + // answers 400 with; it is collected here so one failure names + // every defect instead of one. + rejected.push(name); + // @cpt-end:cpt-cf-oagw-algo-inbound-validate:p1:inst-inv-query-return + } + // @cpt-end:cpt-cf-oagw-algo-inbound-validate:p1:inst-inv-query-if + } + } + // @cpt-begin:cpt-cf-oagw-algo-inbound-validate:p1:inst-inv-query-else + // The ELSE of the parameter check: a parameter the allowlist admits is + // carried to the outbound request untouched, and its value is never + // validated here. + // @cpt-end:cpt-cf-oagw-algo-inbound-validate:p1:inst-inv-query-else + // @cpt-end:cpt-cf-oagw-algo-inbound-validate:p1:inst-inv-query + + // @cpt-begin:cpt-cf-oagw-algo-inbound-validate:p1:inst-inv-headers + for (name, value) in &context.headers { + if value.contains('\r') || value.contains('\n') { + rejected.push(name.clone()); + } + } + // @cpt-end:cpt-cf-oagw-algo-inbound-validate:p1:inst-inv-headers + + // @cpt-begin:cpt-cf-oagw-algo-inbound-validate:p1:inst-inv-wellknown + // The well-known headers — `Content-Length` and `Content-Type` among them + // — are validated as set or adjusted values: the framing half of + // `cpt-cf-oagw-algo-body-validate` checks the two the body has, and the + // transformation half of `cpt-cf-oagw-algo-header-transform` sets what the + // hop-by-hop and routing rules strip or rewrite, so an invalid header + // reaches neither and is answered 400 here. + for (name, value) in &context.headers { + if value.trim().is_empty() { + rejected.push(name.clone()); + } + } + // @cpt-end:cpt-cf-oagw-algo-inbound-validate:p1:inst-inv-wellknown + + // @cpt-begin:cpt-cf-oagw-algo-inbound-validate:p1:inst-inv-fail-if + if !rejected.is_empty() { + // @cpt-begin:cpt-cf-oagw-algo-inbound-validate:p1:inst-inv-fail-return + // One rejection names every failing property, so a caller is not made + // to retry once per defect. + let mut error = DomainError::gateway( + ErrorKind::ValidationError, + "the request names a property the matched route does not admit", + ); + error.detail = format!( + "the matched route admits only the query parameters {:?} and header values without CR or LF; the request was refused for {rejected:?}", + matched.query_allowlist + ); + return Err(error); + // @cpt-end:cpt-cf-oagw-algo-inbound-validate:p1:inst-inv-fail-return + } + // @cpt-end:cpt-cf-oagw-algo-inbound-validate:p1:inst-inv-fail-if + + // @cpt-begin:cpt-cf-oagw-algo-inbound-validate:p1:inst-inv-fail-else + // The ELSE of the failure check: the request named no property the route + // refuses. + // @cpt-end:cpt-cf-oagw-algo-inbound-validate:p1:inst-inv-fail-else + + // @cpt-begin:cpt-cf-oagw-algo-inbound-validate:p1:inst-inv-return + Ok(()) + // @cpt-end:cpt-cf-oagw-algo-inbound-validate:p1:inst-inv-return +} + +// @cpt-dod:cpt-cf-oagw-dod-body-validation:p1 + +/// Reads the request body and validates it, answering the failures of the +/// framing headers before any byte of it is buffered. +/// +/// The order is the algorithm's: the declared size against the hard limit, the +/// framing headers, and only then the read, which stops at the first byte past +/// the limit; the buffered size is compared with the declared one last, when a +/// length was declared at all. +/// +/// # Errors +/// +/// Returns the `PayloadTooLarge` failure for a declared or actual size above +/// the hard limit, and the `ValidationError` failure for every framing defect +/// and for a body that cannot be read to its declared end. +#[allow(clippy::result_large_err)] +pub async fn read_body( + context: &ProxyContext, + body: axum::body::Body, +) -> Result, DomainError> { + // @cpt-begin:cpt-cf-oagw-algo-body-validate:p1:inst-body-limit-first + // The declared size is evaluated from the framing headers before any body + // byte is read into a buffer, which is what + // `cpt-cf-oagw-constraint-body-limit` requires and what keeps the check off + // the memory of the process. + let declared = declared_length(context); + // @cpt-end:cpt-cf-oagw-algo-body-validate:p1:inst-body-limit-first + + // @cpt-begin:cpt-cf-oagw-algo-body-validate:p1:inst-body-limit-if + if declared.is_some_and(|length| length > BODY_LIMIT_BYTES) { + // @cpt-begin:cpt-cf-oagw-algo-body-validate:p1:inst-body-limit-return + return Err(too_large()); + // @cpt-end:cpt-cf-oagw-algo-body-validate:p1:inst-body-limit-return + } + // @cpt-end:cpt-cf-oagw-algo-body-validate:p1:inst-body-limit-if + + // @cpt-begin:cpt-cf-oagw-algo-body-validate:p1:inst-body-limit-else + // The ELSE of the limit check: the declared size, when the request stated + // one, is at or under the hard limit, so the framing checks and the read + // proceed. + // @cpt-end:cpt-cf-oagw-algo-body-validate:p1:inst-body-limit-else + + // @cpt-begin:cpt-cf-oagw-algo-body-validate:p1:inst-body-framing + // `Content-Length` present and a valid integer, `Transfer-Encoding` present + // and equal to `chunked`, and never both on one request; a header value + // carrying CR or LF is the last row of the same table. + let defects = framing_defects(context); + // @cpt-end:cpt-cf-oagw-algo-body-validate:p1:inst-body-framing + + // @cpt-begin:cpt-cf-oagw-algo-body-validate:p1:inst-body-framing-if + if !defects.is_empty() { + // @cpt-begin:cpt-cf-oagw-algo-body-validate:p1:inst-body-framing-return + return Err(validation_error(&defects)); + // @cpt-end:cpt-cf-oagw-algo-body-validate:p1:inst-body-framing-return + } + // @cpt-end:cpt-cf-oagw-algo-body-validate:p1:inst-body-framing-if + + // @cpt-begin:cpt-cf-oagw-algo-body-validate:p1:inst-body-framing-else + // The ELSE of the framing check: the framing headers are the ones the + // table admits, so the body can be read to a trusted end. + // @cpt-end:cpt-cf-oagw-algo-body-validate:p1:inst-body-framing-else + + // @cpt-begin:cpt-cf-oagw-algo-body-validate:p1:inst-body-size + // The body is buffered up to the limit and no further: the read stops at + // the first byte past it, so nothing beyond the limit is ever held. + let buffer = read_under_limit(body).await?; + // @cpt-end:cpt-cf-oagw-algo-body-validate:p1:inst-body-size + + // @cpt-begin:cpt-cf-oagw-algo-body-validate:p1:inst-body-size-if + if let Some(length) = declared + && length != buffer.len() + { + // @cpt-begin:cpt-cf-oagw-algo-body-validate:p1:inst-body-size-return + return Err(validation_error(&[String::from( + "content-length does not match the actual body size", + )])); + // @cpt-end:cpt-cf-oagw-algo-body-validate:p1:inst-body-size-return + } + // @cpt-end:cpt-cf-oagw-algo-body-validate:p1:inst-body-size-if + + // @cpt-begin:cpt-cf-oagw-algo-body-validate:p1:inst-body-size-else + // The ELSE of the size comparison: the read body is the size the request + // declared, or the request declared none and the read is its own truth. + // @cpt-end:cpt-cf-oagw-algo-body-validate:p1:inst-body-size-else + + // @cpt-begin:cpt-cf-oagw-algo-body-validate:p1:inst-body-return + Ok(buffer) + // @cpt-end:cpt-cf-oagw-algo-body-validate:p1:inst-body-return +} + +/// Validates a body that is already buffered, which is the byte-level half of +/// [`read_body`] and the form the unit tests drive. +/// +/// # Errors +/// +/// Returns the same failures [`read_body`] answers with, evaluated over the +/// bytes as given. +#[allow(clippy::result_large_err)] +pub fn validate_body(context: &ProxyContext, body: &[u8]) -> Result<(), DomainError> { + let declared = declared_length(context); + if declared.is_some_and(|length| length > BODY_LIMIT_BYTES) { + return Err(too_large()); + } + let defects = framing_defects(context); + if !defects.is_empty() { + return Err(validation_error(&defects)); + } + if body.len() > BODY_LIMIT_BYTES { + return Err(too_large()); + } + if let Some(length) = declared + && length != body.len() + { + return Err(validation_error(&[String::from( + "content-length does not match the actual body size", + )])); + } + Ok(()) +} + +/// The framing defects of a request body, which need no body byte to name. +fn framing_defects(context: &ProxyContext) -> Vec { + let content_length = context.header_values("content-length"); + let transfer_encoding: Vec = context + .header_values("transfer-encoding") + .iter() + .map(|value| value.trim().to_ascii_lowercase()) + .collect(); + let mut framing: Vec = Vec::new(); + if content_length.len() > 1 { + framing.push(String::from("content-length is declared more than once")); + } + if let Some(declared) = content_length.first() + && declared.parse::().is_err() + { + framing.push(String::from("content-length is not a valid integer")); + } + if !transfer_encoding.is_empty() { + if !content_length.is_empty() { + framing.push(String::from( + "content-length and transfer-encoding are both declared", + )); + } + if transfer_encoding.len() > 1 + || transfer_encoding.iter().any(|value| value != "chunked") + { + framing.push(String::from("transfer-encoding is not chunked")); + } + } + for (name, value) in &context.headers { + if value.contains('\r') || value.contains('\n') { + framing.push(format!("{name} carries a CR or LF in its value")); + } + } + framing +} + +/// Reads the body off the wire up to the hard limit, refusing the read at the +/// first byte past it. +#[allow(clippy::result_large_err)] +async fn read_under_limit(body: axum::body::Body) -> Result, DomainError> { + let mut buffer: Vec = Vec::new(); + let mut stream = body.into_data_stream(); + while let Some(frame) = futures_util::StreamExt::next(&mut stream).await { + let chunk = frame.map_err(|_| read_failure())?; + if buffer.len() + chunk.len() > BODY_LIMIT_BYTES { + return Err(too_large()); + } + buffer.extend_from_slice(&chunk); + } + Ok(buffer) +} + +/// The declared `Content-Length`, as one size when the header is present and +/// parses, and `None` when it is absent or malformed. +fn declared_length(context: &ProxyContext) -> Option { + context + .header_values("content-length") + .first() + .and_then(|value| value.parse::().ok()) +} + +/// The 413 failure a body above the hard limit is answered with. +#[allow(clippy::result_large_err)] +fn too_large() -> DomainError { + DomainError::gateway( + ErrorKind::PayloadTooLarge, + "the request body exceeds the hard limit the gateway enforces", + ) +} + +/// The failure a body that cannot be read off the wire is answered with. +#[allow(clippy::result_large_err)] +fn read_failure() -> DomainError { + DomainError::gateway( + ErrorKind::ValidationError, + "the request body could not be read to its declared end", + ) +} + +/// Builds the 400 failure one or more framing or inbound defects answer with. +fn validation_error(defects: &[String]) -> DomainError { + let mut error = DomainError::gateway( + ErrorKind::ValidationError, + "the request body or its framing is not valid", + ); + error.detail = format!("the request was refused for: {}", defects.join("; ")); + error +} + +/// Splits a query string into its decoded name-value pairs. +/// +/// The pairs are the raw decoded names only: the allowlist comparison is on the +/// parameter name, and a value is never validated here. +#[must_use] +pub fn parse_query(query: &str) -> Vec<(String, String)> { + form_urlencoded::parse(query.as_bytes()) + .map(|(name, value)| (name.into_owned(), value.into_owned())) + .collect() +} + 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..6686ff1 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/alias.rs @@ -0,0 +1,336 @@ +//! `Alias`, `Hostname`, and `EndpointHost` value objects. +//! +//! Realizes `cpt-cf-oagw-algo-alias-normalize`: normalization and validation +//! live here once, so write-time storage and proxy-time resolution cannot +//! disagree about what an alias looks like. Alias derivation (single +//! hostname, longest common registrable suffix, rejection of a bare public +//! suffix) and alias immutability across updates are delivered by the +//! control-plane-config feature, which calls these constructors on every +//! value it stores or resolves. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +/// RFC 1123: maximum total length of a hostname (no trailing dot). +const MAX_HOSTNAME_LEN: usize = 253; +/// RFC 1123: maximum length of a single hostname label. +const MAX_LABEL_LEN: usize = 63; + +/// Why a normalized alias or host string was rejected. +/// +/// The offending input is deliberately not carried: `detail` never contains +/// configuration values, and an alias is an operator-supplied string. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum AliasError { + /// The value was empty once surrounding whitespace and trailing dots + /// were stripped. + #[error("alias or host is empty after trimming")] + Empty, + /// The value carried a non-ASCII byte; it is never transliterated. + #[error("alias or host carries a non-ASCII byte")] + NonAscii, + /// A label violated RFC 1123: empty, leading or trailing hyphen, or a + /// character outside `[a-z0-9-]`. + #[error("alias or host carries an RFC 1123-invalid label")] + InvalidLabel, + /// The `:port` suffix was not an integer from 1 to 65535. + #[error("port must be an integer from 1 to 65535")] + InvalidPort, + /// The value, or one of its labels, exceeded the RFC 1123 length limit. + #[error("alias or host exceeds the RFC 1123 length limit")] + TooLong, +} + +/// Normalizes an input string: trims surrounding whitespace, rejects +/// non-ASCII bytes, and lowercases to ASCII. +fn normalize(input: &str) -> Result { + // @cpt-begin:cpt-cf-oagw-algo-alias-normalize:p1:inst-alias-trim + let trimmed = input.trim(); + // @cpt-end:cpt-cf-oagw-algo-alias-normalize:p1:inst-alias-trim + + // @cpt-begin:cpt-cf-oagw-algo-alias-normalize:p1:inst-alias-empty-if + if trimmed.is_empty() { + // @cpt-begin:cpt-cf-oagw-algo-alias-normalize:p1:inst-alias-empty-return + return Err(AliasError::Empty); + // @cpt-end:cpt-cf-oagw-algo-alias-normalize:p1:inst-alias-empty-return + } + // @cpt-end:cpt-cf-oagw-algo-alias-normalize:p1:inst-alias-empty-if + + // @cpt-begin:cpt-cf-oagw-algo-alias-normalize:p1:inst-alias-lower + if !trimmed.is_ascii() { + return Err(AliasError::NonAscii); + } + let lowered = trimmed.to_ascii_lowercase(); + // @cpt-end:cpt-cf-oagw-algo-alias-normalize:p1:inst-alias-lower + + Ok(lowered) +} + +/// Strips all trailing dots: FQDN notation is tolerated on input and never +/// stored. Applied to the host part so a `:port` suffix cannot hide the dots +/// the caller wrote before it. +fn strip_trailing_dots(host: &str) -> Result<&str, AliasError> { + let stripped = host.trim_end_matches('.'); + if stripped.is_empty() { + return Err(AliasError::Empty); + } + Ok(stripped) +} + +/// Validates RFC 1123 hostname syntax on an already-normalized string. +fn validate_rfc1123(host: &str) -> Result<(), AliasError> { + // @cpt-begin:cpt-cf-oagw-algo-alias-normalize:p1:inst-alias-rfc1123 + if host.is_empty() { + return Err(AliasError::Empty); + } + if host.len() > MAX_HOSTNAME_LEN { + return Err(AliasError::TooLong); + } + for label in host.split('.') { + if label.is_empty() { + return Err(AliasError::InvalidLabel); + } + if label.len() > MAX_LABEL_LEN { + return Err(AliasError::TooLong); + } + let hyphen_edged = label.starts_with('-') || label.ends_with('-'); + let in_label_set = label + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'); + if hyphen_edged || !in_label_set { + return Err(AliasError::InvalidLabel); + } + } + Ok(()) + // @cpt-end:cpt-cf-oagw-algo-alias-normalize:p1:inst-alias-rfc1123 +} + +/// Splits a normalized `host:port` value, validating the port as an integer +/// from 1 to 65535. The port participates in alias identity, so it is kept in +/// the value rather than folded away. +fn split_port(normalized: &str) -> Result<(&str, Option), AliasError> { + // @cpt-begin:cpt-cf-oagw-algo-alias-normalize:p1:inst-alias-port-if + // @cpt-begin:cpt-cf-oagw-algo-alias-normalize:p1:inst-alias-port-keep + let Some((host, port_raw)) = normalized.rsplit_once(':') else { + return Ok((normalized, None)); + }; + if host.is_empty() || port_raw.is_empty() { + return Err(AliasError::InvalidPort); + } + let port: u16 = port_raw.parse().map_err(|_| AliasError::InvalidPort)?; + if port == 0 { + return Err(AliasError::InvalidPort); + } + Ok((host, Some(port))) + // @cpt-end:cpt-cf-oagw-algo-alias-normalize:p1:inst-alias-port-keep + // @cpt-end:cpt-cf-oagw-algo-alias-normalize:p1:inst-alias-port-if +} + +/// A normalized host name with no port: RFC 1123 syntax, ASCII lowercase, +/// trailing dots stripped. +/// +/// Construction goes through [`Hostname::parse`] or the `TryFrom` +/// conversions; there is no way to build one from an unnormalized string. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct Hostname(String); + +impl Hostname { + /// Parses and normalizes a host name. + /// + /// # Errors + /// + /// Returns [`AliasError`] when the input is empty, carries a non-ASCII + /// byte, or violates RFC 1123. + pub fn parse(input: &str) -> Result { + let normalized = normalize(input)?; + let host = strip_trailing_dots(&normalized)?; + validate_rfc1123(host)?; + // @cpt-begin:cpt-cf-oagw-algo-alias-normalize:p1:inst-alias-return + Ok(Self(host.to_owned())) + // @cpt-end:cpt-cf-oagw-algo-alias-normalize:p1:inst-alias-return + } + + /// The normalized host name, with no port and no trailing dot. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl TryFrom<&str> for Hostname { + type Error = AliasError; + + fn try_from(value: &str) -> Result { + Self::parse(value) + } +} + +impl TryFrom for Hostname { + type Error = AliasError; + + fn try_from(value: String) -> Result { + Self::parse(&value) + } +} + +impl From for String { + fn from(value: Hostname) -> Self { + value.0 + } +} + +impl fmt::Display for Hostname { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +/// A normalized routing alias: a [`Hostname`] plus an optional port. +/// +/// The port participates in identity, so `api.openai.com` and +/// `api.openai.com:8443` are distinct aliases. Construction goes through +/// [`Alias::parse`] or the `TryFrom` conversions, and the value round-trips +/// through its normalized `Display` form. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct Alias { + host: Hostname, + port: Option, +} + +impl Alias { + /// Parses and normalizes an alias, optionally carrying a `:port` suffix. + /// + /// # Errors + /// + /// Returns [`AliasError`] when the input is empty, carries a non-ASCII + /// byte, violates RFC 1123, or carries an out-of-range port. + pub fn parse(input: &str) -> Result { + let normalized = normalize(input)?; + let (host_part, port) = split_port(&normalized)?; + let host = strip_trailing_dots(host_part)?; + validate_rfc1123(host)?; + // @cpt-begin:cpt-cf-oagw-algo-alias-normalize:p1:inst-alias-return + Ok(Self { + host: Hostname(host.to_owned()), + port, + }) + // @cpt-end:cpt-cf-oagw-algo-alias-normalize:p1:inst-alias-return + } + + /// The normalized host part of the alias, with no port. + #[must_use] + pub fn host(&self) -> &Hostname { + &self.host + } + + /// The port part of the alias, when the input carried one. + #[must_use] + pub const fn port(&self) -> Option { + self.port + } +} + +impl TryFrom<&str> for Alias { + type Error = AliasError; + + fn try_from(value: &str) -> Result { + Self::parse(value) + } +} + +impl TryFrom for Alias { + type Error = AliasError; + + fn try_from(value: String) -> Result { + Self::parse(&value) + } +} + +impl From for String { + fn from(value: Alias) -> Self { + value.to_string() + } +} + +impl fmt::Display for Alias { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.port { + None => f.write_str(self.host.as_str()), + Some(port) => write!(f, "{}:{port}", self.host.as_str()), + } + } +} + +/// The `host` value of a configured endpoint: an RFC 1123 host name or an IP +/// literal (IPv4 or IPv6). +/// +/// The shipped upstream schema admits all three forms for +/// `server.endpoints[].host` while [`Hostname`] admits only RFC 1123 names, +/// so endpoints carry their own value object rather than weakening +/// [`Hostname`]. The port is a separate endpoint property and is never +/// carried here. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct EndpointHost(String); + +impl EndpointHost { + /// Parses and normalizes an endpoint host, accepting an RFC 1123 name or + /// an IPv4/IPv6 literal. + /// + /// # Errors + /// + /// Returns [`AliasError`] when the input is empty, carries a non-ASCII + /// byte, violates RFC 1123, and is not an IP literal. + pub fn parse(input: &str) -> Result { + let normalized = normalize(input)?; + let host = strip_trailing_dots(&normalized)?; + if validate_rfc1123(host).is_ok() { + return Ok(Self(host.to_owned())); + } + let literal = host + .parse::() + .map_err(|_| AliasError::InvalidLabel)?; + Ok(Self(literal.to_string())) + } + + /// The normalized endpoint host. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl TryFrom<&str> for EndpointHost { + type Error = AliasError; + + fn try_from(value: &str) -> Result { + Self::parse(value) + } +} + +impl TryFrom for EndpointHost { + type Error = AliasError; + + fn try_from(value: String) -> Result { + Self::parse(&value) + } +} + +impl From for String { + fn from(value: EndpointHost) -> Self { + value.0 + } +} + +impl fmt::Display for EndpointHost { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} diff --git a/gears/system/oagw/oagw/src/domain/context.rs b/gears/system/oagw/oagw/src/domain/context.rs new file mode 100644 index 0000000..1631275 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/context.rs @@ -0,0 +1,153 @@ +//! The plugin-execution contexts the three plugin contracts take. +//! +//! `DECOMPOSITION` §2.4 lists `AuthContext`, `RequestContext`, +//! `ResponseContext`, and `ErrorContext` as entities the plugin-system feature +//! consumes *from* `cpt-cf-oagw-feature-gear-foundation`, and that feature's +//! own §1.5 records that its entity list names only `ErrorContext` among the +//! four. This module is the vocabulary that closes the recorded gap: it holds +//! the three contexts the foundation never named, at the same layer and with +//! the same layering rules, so the plugin contracts declared beside them and +//! the proxy path that executes them cannot disagree about what a context +//! carries. `ErrorContext` is not redeclared: the foundation's own +//! [`crate::domain::error::ErrorContext`] is the fourth context. +//! +//! No `http`/`axum` type appears here: a header is a lowercase name and a +//! string value, a status is a `u16`, and a body is never carried at all — the +//! contexts carry what a plugin reads and writes, and nothing more. + +use std::collections::BTreeMap; + +use toolkit_macros::domain_model; +use uuid::Uuid; + +/// The context an auth plugin runs in and writes its credential into. +/// +/// The plugin's whole output is a header: the credential material it resolved +/// is injected as the value of one header and never stored anywhere else. The +/// identity the context carries is the projection of the request's +/// `SecurityContext` — the subject tenant and the subject identifier — which +/// is the identity both the credential store's sharing policy and the token +/// cache's isolation key are asked about. +#[domain_model] +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AuthContext { + /// Tenant of the subject the request was authenticated as. + pub tenant_id: Uuid, + /// Subject identifier, when the request was authenticated as one. + pub subject_id: Option, + /// The outbound headers the auth plugin writes. + pub headers: BTreeMap, +} + +impl AuthContext { + /// Builds the context of one authenticated subject. + #[must_use] + pub fn new(tenant_id: Uuid, subject_id: Option) -> Self { + Self { + tenant_id, + subject_id, + headers: BTreeMap::new(), + } + } + + /// The subject identifier, when the request was authenticated as one. + #[must_use] + pub fn subject_id(&self) -> Option { + self.subject_id + } + + /// Writes one outbound header. Names are stored lowercased, so a plugin + /// that writes `Authorization` and a proxy that reads `authorization` + /// cannot disagree. + pub fn set_header(&mut self, name: &str, value: impl Into) { + self.headers.insert(header_name(name), value.into()); + } + + /// Reads one outbound header. + #[must_use] + pub fn header(&self, name: &str) -> Option<&str> { + self.headers.get(&header_name(name)).map(String::as_str) + } +} + +/// The context a guard or transform plugin reads the inbound request from and +/// a transform plugin mutates. +#[domain_model] +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RequestContext { + /// Request method. + pub method: String, + /// Request path, as the request carried it. + pub path: String, + /// Query string, without the leading `?`. + pub query: Option, + /// Request headers, names lowercased. + pub headers: BTreeMap, +} + +impl RequestContext { + /// Builds the context of one request. + #[must_use] + pub fn new(method: String, path: String, query: Option) -> Self { + Self { + method, + path, + query, + headers: BTreeMap::new(), + } + } + + /// Reads one request header. + #[must_use] + pub fn header(&self, name: &str) -> Option<&str> { + self.headers.get(&header_name(name)).map(String::as_str) + } + + /// Sets one request header. + pub fn set_header(&mut self, name: &str, value: impl Into) { + self.headers.insert(header_name(name), value.into()); + } + + /// Removes one request header, leaving the rest untouched. + pub fn remove_header(&mut self, name: &str) { + self.headers.remove(&header_name(name)); + } +} + +/// The context a guard or transform plugin reads the upstream response from +/// and a transform plugin mutates. +#[domain_model] +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ResponseContext { + /// Upstream response status. + pub status: u16, + /// Response headers, names lowercased. + pub headers: BTreeMap, +} + +impl ResponseContext { + /// Builds the context of one response. + #[must_use] + pub fn new(status: u16) -> Self { + Self { + status, + headers: BTreeMap::new(), + } + } + + /// Reads one response header. + #[must_use] + pub fn header(&self, name: &str) -> Option<&str> { + self.headers.get(&header_name(name)).map(String::as_str) + } + + /// Sets one response header. + pub fn set_header(&mut self, name: &str, value: impl Into) { + self.headers.insert(header_name(name), value.into()); + } +} + +/// Lowercases a header name, so every context agrees on one spelling. +fn header_name(name: &str) -> String { + name.to_ascii_lowercase() +} 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..a2e74c3 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/cors.rs @@ -0,0 +1,456 @@ +//! CORS decision of the `oagw` gear. +//! +//! Realizes `cpt-cf-oagw-dod-cors-entities`: [`CorsDecision`] and the +//! preflight response shape declared once, in the domain layer, free of +//! transport and persistence types, referencing `CorsConfig` of +//! `cpt-cf-oagw-feature-gear-foundation` and `EffectiveCors` of +//! `cpt-cf-oagw-feature-hierarchical-config` rather than redeclaring either. +//! +//! The three routines the FEATURE's CDSL §3 states are here too, because they +//! are functions over this state and over nothing else: +//! `cpt-cf-oagw-algo-cors-fold` ([`fold`]), +//! `cpt-cf-oagw-algo-cors-decide` ([`decide`]), and +//! `cpt-cf-oagw-algo-cors-preflight-headers` ([`preflight_answer`]). None of +//! them writes to storage, holds state between requests, or reaches a network. +//! +//! The two 403 answers the decision names are not [`crate::domain::error`] +//! catalogue variants — DESIGN §3.3's catalogue is closed at 22 variants, and +//! the two `type` identifiers ADR 0004 spells live in [`crate::gts`] beside +//! it and are consumed by the API layer's problem mapping. + +use crate::domain::effective::EffectiveCors; +use crate::domain::upstream::{CorsConfig, SharingMode}; + +// @cpt-dod:cpt-cf-oagw-dod-cors-entities:p1 + +/// The `Access-Control-Max-Age` a preflight answer carries, which is the value +/// ADR 0004's preflight example states and the shipped `definitions.cors` +/// declares no configuration surface for (§1.5). +pub const PREFLIGHT_MAX_AGE: &str = "86400"; + +/// The `Vary` value a preflight answer carries, which is the three-member +/// value ADR 0004's preflight example shows. +pub const PREFLIGHT_VARY: &str = + "Origin, Access-Control-Request-Method, Access-Control-Request-Headers"; + +/// The `Vary` value every actual-request answer this feature decorates +/// carries, which is the always-present one ADR 0004's Security Considerations +/// state. +pub const VARY_ORIGIN: &str = "Origin"; + +/// The shipped schema's declared default for a method list no layer declares. +fn default_methods() -> Vec { + vec![String::from("GET"), String::from("POST")] +} + +/// The effective configuration the fold produced. +/// +/// `enabled` is a member of the effective configuration the CDSL §3 names, and +/// the fold never yields a policy whose prevailing `enabled` is false: such a +/// family folds to the absent outcome, which is the branch the flow reads. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EffectiveCorsPolicy { + /// Whether the prevailing configuration is enabled, which is always true + /// in a policy the fold produced. + pub enabled: bool, + /// The effective origins, exact-matched, `*` included. + pub allowed_origins: Vec, + /// The effective methods, exact-matched against the schema's literals. + pub allowed_methods: Vec, + /// The headers exposed to the browser beyond the safelisted ones. + pub expose_headers: Vec, + /// Whether credentials are allowed on an admitted response. + pub allow_credentials: bool, +} + +/// Why an actual cross-origin request was refused. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CorsRefusal { + /// The `Origin` the request carried is not named by the effective list. + Origin, + /// The method the request carried is not named by the effective list. + Method, +} + +impl CorsRefusal { + /// The problem `title` ADR 0004 gives the refusal's `type`. + #[must_use] + pub const fn title(self) -> &'static str { + match self { + Self::Origin => "CORS Origin Not Allowed", + Self::Method => "CORS Method Not Allowed", + } + } + + /// The problem `type` identifier ADR 0004 spells for the refusal. + #[must_use] + pub const fn gts_type(self) -> &'static str { + match self { + Self::Origin => crate::gts::ERR_CORS_ORIGIN_NOT_ALLOWED, + Self::Method => crate::gts::ERR_CORS_METHOD_NOT_ALLOWED, + } + } +} + +/// The decoration an admitted actual request carries on the response the proxy +/// path assembles. +/// +/// `Access-Control-Allow-Origin` carries the request's own `Origin` value and +/// never the literal `*`, so one rule covers the credentialed and the +/// non-credentialed configuration alike (§1.5). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CorsDecoration { + /// The echoed origin of `Access-Control-Allow-Origin`. + pub allow_origin: String, + /// Whether `Access-Control-Allow-Credentials` is emitted. + pub allow_credentials: bool, + /// The `Access-Control-Expose-Headers` list, empty when the header is + /// omitted. + pub expose_headers: Vec, + /// The `Vary` value the answer carries. + pub vary: &'static str, +} + +impl CorsDecoration { + /// The header pairs the response carries, in emission order. + #[must_use] + pub fn headers(&self) -> Vec<(String, String)> { + let mut headers = vec![ + (String::from("Access-Control-Allow-Origin"), self.allow_origin.clone()), + (String::from("Vary"), String::from(self.vary)), + ]; + if self.allow_credentials { + headers.push(( + String::from("Access-Control-Allow-Credentials"), + String::from("true"), + )); + } + if !self.expose_headers.is_empty() { + headers.push(( + String::from("Access-Control-Expose-Headers"), + self.expose_headers.join(", "), + )); + } + headers + } +} + +/// The verdict of one actual cross-origin request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CorsDecision { + /// The request is admitted and the decoration rides the forwarded answer. + Allowed(CorsDecoration), + /// The request is refused for the reason carried, before anything is + /// forwarded. + Refused(CorsRefusal), +} + +/// The 204 preflight answer: the status and the header set it carries. +/// +/// The shape is the one ADR 0004's preflight example spells, without the +/// `Access-Control-Allow-Credentials` whose only appearance in that ADR is on +/// an actual-request response (§1.5). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreflightAnswer { + /// The status of the answer: always `204`. + pub status: u16, + /// The header pairs of the answer, in emission order. + pub headers: Vec<(String, String)>, +} + +/// The members one layer result declares, taken from its own object. +struct Declared { + origins: Option>, + methods: Option>, + expose: Option>, + credentials: bool, +} + +/// The members one layer result declares: a list member the object leaves +/// empty is an omission rather than a declaration, because the shipped schema +/// defaults it and a written object that omits the member deserializes to the +/// empty list (§1.5). +fn declared_of(cors: &CorsConfig) -> Declared { + Declared { + origins: (!cors.allowed_origins.is_empty()).then(|| cors.allowed_origins.clone()), + methods: (!cors.allowed_methods.is_empty()).then(|| cors.allowed_methods.clone()), + expose: (!cors.expose_headers.is_empty()).then(|| cors.expose_headers.clone()), + credentials: cors.allow_credentials, + } +} + +// @cpt-dod:cpt-cf-oagw-dod-cors-hierarchy:p1 + +/// Applies the per-member overlay across the two layer results. +/// +/// The union under `inherit`, the forcing under `enforce`, and the withholding +/// under `private` were applied across the ancestor chain by the hierarchical +/// feature's merge, which reported one result per layer, so this routine only +/// takes each member from the last layer result that declares it, in the +/// upstream, then route order. It never unions across layers and never +/// re-walks the chain. +/// +/// Returns the absent outcome when no layer carries a `cors` object or the +/// prevailing `enabled` is false, which is the branch the enforcement flow +/// reads to enforce and decorate nothing. +#[must_use] +pub fn fold( + upstream: Option<&EffectiveCors>, + route: Option<&EffectiveCors>, +) -> Option { + // @cpt-begin:cpt-cf-oagw-algo-cors-fold:p1:inst-cf-prevail + // The two layer results are consumed in the upstream, then route order, so + // the last layer result that declares a member prevails. + let (upstream, route) = (upstream, route); + // @cpt-begin:cpt-cf-oagw-algo-cors-fold:p1:inst-cf-enforce-if + // An ancestor `enforce` decides the whole object: no descendant override + // can widen what the ancestor forced, so the route layer is not read. + if upstream.is_some_and(|layer| layer.mode == SharingMode::Enforce) { + // @cpt-begin:cpt-cf-oagw-algo-cors-fold:p1:inst-cf-enforce + let layer = upstream.expect("the upstream layer is present"); + // @cpt-end:cpt-cf-oagw-algo-cors-fold:p1:inst-cf-enforce + return policy_of(&layer.cors); + // @cpt-end:cpt-cf-oagw-algo-cors-fold:p1:inst-cf-enforce-if + } + // @cpt-end:cpt-cf-oagw-algo-cors-fold:p1:inst-cf-prevail + // @cpt-begin:cpt-cf-oagw-algo-cors-fold:p1:inst-cf-enforce-else + // The ELSE of the enforce check: both layers take part in the overlay. + // @cpt-end:cpt-cf-oagw-algo-cors-fold:p1:inst-cf-enforce-else + + // @cpt-begin:cpt-cf-oagw-algo-cors-fold:p1:inst-cf-inherit + // Each member is taken from the last layer result that declares it: the + // origins are already unioned where an ancestor marked the family + // `inherit`, and the ancestor's value is already withheld where it marked + // it `private`, so the overlay unions nothing. + let mut origins: Option> = None; + let mut methods: Option> = None; + let mut expose: Option> = None; + let mut credentials = false; + let mut enabled = false; + for layer in [upstream, route].into_iter().flatten() { + let declared = declared_of(&layer.cors); + if declared.origins.is_some() { + origins = declared.origins; + } + if declared.methods.is_some() { + methods = declared.methods; + } + if declared.expose.is_some() { + expose = declared.expose; + } + credentials = declared.credentials; + enabled = layer.cors.enabled; + } + // @cpt-end:cpt-cf-oagw-algo-cors-fold:p1:inst-cf-inherit + + // @cpt-begin:cpt-cf-oagw-algo-cors-fold:p1:inst-cf-defaults + // The shipped schema's declared default for a member neither layer result + // declares: `GET` and `POST` for the methods, an empty exposure, `false` + // for the credentials, and no default for the origins, because an absent + // or empty `allowed_origins` allows no origin rather than every one. + let origins = origins.unwrap_or_default(); + let methods = methods.unwrap_or_else(default_methods); + let expose = expose.unwrap_or_default(); + // @cpt-end:cpt-cf-oagw-algo-cors-fold:p1:inst-cf-defaults + + // @cpt-begin:cpt-cf-oagw-algo-cors-fold:p1:inst-cf-none-if + // No layer carries a `cors` object, or the prevailing `enabled` is false: + // both are the absent-family outcome the flow enforces nothing on. + if !enabled { + // @cpt-begin:cpt-cf-oagw-algo-cors-fold:p1:inst-cf-none + return None; + // @cpt-end:cpt-cf-oagw-algo-cors-fold:p1:inst-cf-none + } + // @cpt-end:cpt-cf-oagw-algo-cors-fold:p1:inst-cf-none-if + + // @cpt-begin:cpt-cf-oagw-algo-cors-fold:p1:inst-cf-return + Some(EffectiveCorsPolicy { + enabled: true, + allowed_origins: origins, + allowed_methods: methods, + expose_headers: expose, + allow_credentials: credentials, + }) + // @cpt-end:cpt-cf-oagw-algo-cors-fold:p1:inst-cf-return +} + +/// The policy one layer's own object states, which the `enforce` branch takes +/// whole. +fn policy_of(cors: &CorsConfig) -> Option { + Some(EffectiveCorsPolicy { + enabled: cors.enabled, + allowed_origins: cors.allowed_origins.clone(), + allowed_methods: if cors.allowed_methods.is_empty() { + default_methods() + } else { + cors.allowed_methods.clone() + }, + expose_headers: cors.expose_headers.clone(), + allow_credentials: cors.allow_credentials, + }) +} + +/// The problem `detail` of a refusal, which names the offending value and no +/// allowed one. +/// +/// The detail strings are the two ADR 0004 gives, and neither names an allowed +/// origin nor an allowed method, so a disallowed caller learns nothing about +/// the list that refused it (§1.5). +#[must_use] +pub fn refusal_detail(reason: CorsRefusal, origin: &str, method: &str) -> String { + match reason { + CorsRefusal::Origin => format!("Origin '{origin}' not in allowed origins list"), + CorsRefusal::Method => format!("Method '{method}' not in allowed methods list"), + } +} + +// @cpt-dod:cpt-cf-oagw-dod-cors-enforcement:p1 + +/// Decides one actual cross-origin request against the effective policy. +/// +/// The origin comparison is the exact matching ADR 0004 states: the whole +/// value against a configured entry, or a `*` entry against anything, with the +/// scheme and the port significant and no pattern, no suffix, no suffix-of, +/// and no case folding performed. The method check runs only after the origin +/// comparison has passed. +#[must_use] +pub fn decide( + policy: &EffectiveCorsPolicy, + origin: Option<&str>, + method: &str, +) -> CorsDecision { + // @cpt-begin:cpt-cf-oagw-algo-cors-decide:p1:inst-cd-credwild-if + // A configuration the write-time validation refused is failed closed here + // rather than served permissively: the origin set is treated as empty. + if policy.allow_credentials && policy.allowed_origins.iter().any(|entry| entry == "*") { + // @cpt-begin:cpt-cf-oagw-algo-cors-decide:p1:inst-cd-credwild + return CorsDecision::Refused(CorsRefusal::Origin); + // @cpt-end:cpt-cf-oagw-algo-cors-decide:p1:inst-cd-credwild + } + // @cpt-end:cpt-cf-oagw-algo-cors-decide:p1:inst-cd-credwild-if + + // @cpt-begin:cpt-cf-oagw-algo-cors-decide:p1:inst-cd-origin + // The whole value must equal an entry, or an entry must be `*`: no + // pattern, no suffix, no suffix-of, and no case-folding comparison is + // performed, and no trailing slash is stripped. + let Some(origin) = origin else { + return CorsDecision::Refused(CorsRefusal::Origin); + }; + let allowed = policy + .allowed_origins + .iter() + .any(|entry| entry == "*" || entry == origin); + // @cpt-end:cpt-cf-oagw-algo-cors-decide:p1:inst-cd-origin + + // @cpt-begin:cpt-cf-oagw-algo-cors-decide:p1:inst-cd-origin-if + if !allowed { + // @cpt-begin:cpt-cf-oagw-algo-cors-decide:p1:inst-cd-origin-refuse + return CorsDecision::Refused(CorsRefusal::Origin); + // @cpt-end:cpt-cf-oagw-algo-cors-decide:p1:inst-cd-origin-refuse + } + // @cpt-end:cpt-cf-oagw-algo-cors-decide:p1:inst-cd-origin-if + + // @cpt-begin:cpt-cf-oagw-algo-cors-decide:p1:inst-cd-method + // The method is an exact member test against the literals the shipped + // schema enumerates, answered only after the origin comparison passed. + let method_allowed = policy.allowed_methods.iter().any(|entry| entry == method); + // @cpt-end:cpt-cf-oagw-algo-cors-decide:p1:inst-cd-method + + // @cpt-begin:cpt-cf-oagw-algo-cors-decide:p1:inst-cd-method-if + if !method_allowed { + // @cpt-begin:cpt-cf-oagw-algo-cors-decide:p1:inst-cd-method-refuse + return CorsDecision::Refused(CorsRefusal::Method); + // @cpt-end:cpt-cf-oagw-algo-cors-decide:p1:inst-cd-method-refuse + } + // @cpt-end:cpt-cf-oagw-algo-cors-decide:p1:inst-cd-method-if + + // @cpt-begin:cpt-cf-oagw-algo-cors-decide:p1:inst-cd-allow + // The decoration echoes the request's own origin, carries the credentials + // header exactly when the configuration allows them, carries the exposure + // only when the list names one, and always carries `Vary: Origin`. + CorsDecision::Allowed(CorsDecoration { + allow_origin: String::from(origin), + allow_credentials: policy.allow_credentials, + expose_headers: policy.expose_headers.clone(), + vary: VARY_ORIGIN, + }) + // @cpt-end:cpt-cf-oagw-algo-cors-decide:p1:inst-cd-allow +} + +// @cpt-dod:cpt-cf-oagw-dod-cors-preflight:p1 + +/// Builds the 204 preflight answer from the request's own three header values. +/// +/// The routine reads no configuration and resolves no upstream, which is what +/// makes the answer usable when the upstream is unreachable. A value the +/// platform delivered that cannot be formed into a response header arrives as +/// [`None`], and the header that would echo it is omitted from the answer +/// rather than emitted, which is the omission the caller records in the +/// request's execution context (§1.5). +#[must_use] +pub fn preflight_answer( + origin: Option<&str>, + request_method: Option<&str>, + request_headers: Option<&str>, +) -> PreflightAnswer { + let mut headers: Vec<(String, String)> = Vec::new(); + // @cpt-begin:cpt-cf-oagw-algo-cors-preflight-headers:p1:inst-cph-origin + // The request's own origin, byte-exact, omitted when it cannot be formed. + if let Some(origin) = origin { + headers.push(( + String::from("Access-Control-Allow-Origin"), + String::from(origin), + )); + } + // @cpt-end:cpt-cf-oagw-algo-cors-preflight-headers:p1:inst-cph-origin + + // @cpt-begin:cpt-cf-oagw-algo-cors-preflight-headers:p1:inst-cph-methods + // The request's own requested method, byte-exact, omitted when it cannot + // be formed. + if let Some(request_method) = request_method { + headers.push(( + String::from("Access-Control-Allow-Methods"), + String::from(request_method), + )); + } + // @cpt-end:cpt-cf-oagw-algo-cors-preflight-headers:p1:inst-cph-methods + + // @cpt-begin:cpt-cf-oagw-algo-cors-preflight-headers:p1:inst-cph-headers-if + if let Some(requested) = request_headers { + // @cpt-begin:cpt-cf-oagw-algo-cors-preflight-headers:p1:inst-cph-headers + // The requested headers verbatim, with no allowlist applied and no + // name reordered. + headers.push(( + String::from("Access-Control-Allow-Headers"), + String::from(requested), + )); + // @cpt-end:cpt-cf-oagw-algo-cors-preflight-headers:p1:inst-cph-headers + } + // @cpt-end:cpt-cf-oagw-algo-cors-preflight-headers:p1:inst-cph-headers-if + + // @cpt-begin:cpt-cf-oagw-algo-cors-preflight-headers:p1:inst-cph-headers-else + // @cpt-begin:cpt-cf-oagw-algo-cors-preflight-headers:p1:inst-cph-no-headers + // The ELSE of the requested-headers check: the header is omitted, because + // a preflight that names no request header asks about none. + // @cpt-end:cpt-cf-oagw-algo-cors-preflight-headers:p1:inst-cph-no-headers + // @cpt-end:cpt-cf-oagw-algo-cors-preflight-headers:p1:inst-cph-headers-else + + // @cpt-begin:cpt-cf-oagw-algo-cors-preflight-headers:p1:inst-cph-max-age + // The constant max age of ADR 0004's preflight example. + headers.push(( + String::from("Access-Control-Max-Age"), + String::from(PREFLIGHT_MAX_AGE), + )); + // @cpt-end:cpt-cf-oagw-algo-cors-preflight-headers:p1:inst-cph-max-age + + // @cpt-begin:cpt-cf-oagw-algo-cors-preflight-headers:p1:inst-cph-vary + // The three-member value, so a cache cannot serve one preflight's answer + // to a request that asked about a different origin, method, or header set. + headers.push((String::from("Vary"), String::from(PREFLIGHT_VARY))); + // @cpt-end:cpt-cf-oagw-algo-cors-preflight-headers:p1:inst-cph-vary + + // @cpt-begin:cpt-cf-oagw-algo-cors-preflight-headers:p1:inst-cph-return + // The 204 status with that header set and no body. + PreflightAnswer { status: 204, headers } + // @cpt-end:cpt-cf-oagw-algo-cors-preflight-headers:p1:inst-cph-return +} diff --git a/gears/system/oagw/oagw/src/domain/effective.rs b/gears/system/oagw/oagw/src/domain/effective.rs new file mode 100644 index 0000000..dd84c13 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/effective.rs @@ -0,0 +1,432 @@ +//! Effective configuration — the result types of the hierarchical +//! configuration feature. +//! +//! Every type here is what a resolution answers with and nothing else: the +//! ordered chain the platform tenant-resolver supplies, the alias-match link +//! between a descendant's row and a more distant ancestor's row, the sharing +//! modes a row declares per field family, and the two per-layer results the +//! downstream features consume. No transport and no persistence type appears, +//! and no type here carries a value an ancestor marked `private`, because an +//! [`AncestorBinding`] has no field to put one in. +//! +//! The names are the DECOMPOSITION §2.3 names — `EffectiveUpstreamConfig` and +//! `EffectiveRouteConfig` — not the `EffectiveUpstream` / `MatchedRoute` names +//! of the ADR 0006 request-flow sketch. + +// @cpt-dod:cpt-cf-oagw-dod-effective-config-result:p1 + +use std::collections::BTreeSet; + +use toolkit_macros::domain_model; +use uuid::Uuid; + +use crate::domain::upstream::{AuthConfig, CorsConfig, PluginsConfig, RateLimitConfig, SharingMode}; + +/// One configuration field family the sharing modes and the merge address. +/// +/// The four variants are exactly the four families the shipped schemas give a +/// `sharing` member; `tags` carries no sharing field and never reaches a +/// sharing-mode decision. +#[domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Family { + /// The authentication plugin binding. + Auth, + /// The rate limiting configuration. + RateLimit, + /// The plugin chain. + Plugins, + /// The CORS configuration. + Cors, +} + +impl Family { + /// The descendant override permission the four-permission table of DESIGN + /// §3.2 names for this family, or `None` for the CORS family, which the + /// sharing mode alone governs. + #[must_use] + pub const fn override_permission(self) -> Option<&'static str> { + match self { + Self::Auth => Some(crate::gts::PERMISSION_OVERRIDE_AUTH), + Self::RateLimit => Some(crate::gts::PERMISSION_OVERRIDE_RATE), + Self::Plugins => Some(crate::gts::PERMISSION_ADD_PLUGINS), + // The four-permission table names no permission for CORS: inventing + // a fifth one is outside this feature's authority, so the sharing + // mode alone decides. + Self::Cors => None, + } + } +} + +/// The sharing modes one row declares for the four sharing-bearing families. +/// +/// A family the row does not declare takes the schema default `private`, which +/// is why every field is a plain [`SharingMode`] and never an `Option`. +#[domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FamilyModes { + /// The mode of the `auth` family. + pub auth: SharingMode, + /// The mode of the `rate_limit` family. + pub rate_limit: SharingMode, + /// The mode of the `plugins` family. + pub plugins: SharingMode, + /// The mode of the `cors` family. + pub cors: SharingMode, +} + +impl FamilyModes { + /// Builds the modes from the four `sharing` members a row carries, taking + /// the schema default for the ones it omits. + #[must_use] + pub fn new( + auth: Option, + rate_limit: Option, + plugins: Option, + cors: Option, + ) -> Self { + let fallback = || SharingMode::Private; + Self { + auth: auth.unwrap_or_else(fallback), + rate_limit: rate_limit.unwrap_or_else(fallback), + plugins: plugins.unwrap_or_else(fallback), + cors: cors.unwrap_or_else(fallback), + } + } + + /// The mode one family carries. + #[must_use] + pub const fn mode_of(self, family: Family) -> SharingMode { + match family { + Family::Auth => self.auth, + Family::RateLimit => self.rate_limit, + Family::Plugins => self.plugins, + Family::Cors => self.cors, + } + } +} + +/// The ordered ancestor chain the platform tenant-resolver supplies. +/// +/// The chain runs from the calling tenant to the platform root, inclusive of +/// both ends, without a repeated element, and the calling tenant is its first +/// element — so its depth is zero and its rows are the closest candidates. A +/// chain the resolver answers with a repeated element, or with the calling +/// tenant anywhere but first, is an unavailable chain: [`TenantChain::from_resolver`] +/// answers `None` and the caller fails closed rather than ordering candidates +/// against a chain it cannot order. +#[domain_model] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TenantChain { + tenants: Vec, +} + +impl TenantChain { + /// Builds the chain the resolver's ancestor answer produces. + /// + /// Tenants the resolver retired — `status: deleted` — are dropped before + /// the chain is ordered, because a retired tenant is not an active + /// participant of any resolution. `None` answers an unordered or cyclic + /// answer, which the caller fails closed on. + #[must_use] + pub fn from_resolver(calling_tenant: Uuid, ancestors: &[Uuid]) -> Option { + let mut tenants: Vec = Vec::with_capacity(ancestors.len() + 1); + match ancestors.first() { + // The resolver already answered the calling tenant first. + Some(first) if *first == calling_tenant => tenants.extend_from_slice(ancestors), + // The calling tenant appears anywhere else: the resolver's answer + // runs from the root towards the leaf, which is the same answer a + // cycle in the tree produces, and neither can be ordered. + _ if ancestors.contains(&calling_tenant) => return None, + // The resolver answered the ancestors only, or nothing at all. + _ => { + tenants.push(calling_tenant); + tenants.extend_from_slice(ancestors); + } + } + if has_duplicate(&tenants) { + return None; + } + Some(Self { tenants }) + } + + /// Builds the chain from the tenant identifiers, calling tenant first. + /// + /// # Errors + /// + /// Answers [`ChainError::Cyclic`] when the chain repeats an element, and + /// [`ChainError::Empty`] when it carries none; both are unavailable chains + /// the caller fails closed on. + pub fn from_ordered(tenants: Vec) -> Result { + if tenants.is_empty() { + return Err(ChainError::Empty); + } + if has_duplicate(&tenants) { + return Err(ChainError::Cyclic); + } + Ok(Self { tenants }) + } + + /// The tenants of the chain, calling tenant first, root last. + #[must_use] + pub fn tenants(&self) -> &[Uuid] { + &self.tenants + } + + /// The calling tenant, which is always the first element. + #[must_use] + pub fn calling_tenant(&self) -> Uuid { + self.tenants[0] + } + + /// The depth of one tenant in the chain: `0` for the calling tenant and + /// growing towards the root. `None` for a tenant outside the chain, for + /// which no lookup is ever issued. + #[must_use] + pub fn depth_of(&self, tenant: Uuid) -> Option { + self.tenants.iter().position(|known| *known == tenant) + } + + /// Whether the chain carries one tenant. + #[must_use] + pub fn contains(&self, tenant: Uuid) -> bool { + self.tenants.contains(&tenant) + } +} + +/// Why a chain could not be ordered. +#[domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChainError { + /// The chain carried no tenant. + Empty, + /// The chain repeated an element, so it is a cycle. + Cyclic, +} + +/// Whether one list repeats an element. +fn has_duplicate(tenants: &[Uuid]) -> bool { + let seen: BTreeSet<&Uuid> = tenants.iter().collect(); + seen.len() != tenants.len() +} + +/// One family value an ancestor contributes to a merge, with the mode that +/// decided the contribution. +/// +/// A family the ancestor marks `private` contributes nothing at all, so it +/// produces no [`FamilyContribution`] and the value is never read into a +/// result, copied onto any row, or echoed in any answer. +#[domain_model] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FamilyContribution { + /// The sharing mode the contributing row declares. + pub mode: SharingMode, + /// The value the contributing row carries. + pub value: V, +} + +/// The families one ancestor row contributes to a merge. +/// +/// `None` is the structural form of `private`: the family has no field to +/// carry a value in, so an ancestor value marked `private` cannot reach any +/// result. `auth` is always `None` for a route-layer binding, because a route +/// carries no authentication family. +#[domain_model] +#[derive(Debug, Clone, PartialEq)] +pub struct ContributedFamilies { + /// The authentication binding, when the ancestor contributes it. + pub auth: Option>, + /// The rate limit, when the ancestor contributes it. + pub rate_limit: Option>, + /// The plugin chain, when the ancestor contributes it. + pub plugins: Option>, + /// The CORS configuration, when the ancestor contributes it. + pub cors: Option>, + /// The tags, which always contribute: `tags` carries no sharing field. + pub tags: Option>, +} + +/// The alias-match link between a descendant's row and a more distant +/// ancestor's row with the same normalized alias. +/// +/// The binding is not materialized: no table, column, or join row records it, +/// and it exists only for as long as the configuration that produced it does. +/// It carries the contributed families of the ancestor row, never the families +/// that ancestor marked `private`. +#[domain_model] +#[derive(Debug, Clone, PartialEq)] +pub struct AncestorBinding { + /// The tenant that owns the ancestor row. + pub tenant_id: Uuid, + /// The depth of that tenant in the chain; always greater than the + /// descendant's. + pub depth: usize, + /// The identifier of the ancestor's upstream row. + pub upstream_id: Uuid, + /// The ancestor row's `enabled` flag, which participates in the effective + /// enabled state regardless of every sharing mode. + pub enabled: bool, + /// The families the ancestor row contributes. + pub contributed: ContributedFamilies, +} + +impl AncestorBinding { + /// The mode the binding's row declares for one family, taken from the + /// contribution when it contributes and `private` when it does not. + #[must_use] + pub fn mode_of(&self, family: Family) -> SharingMode { + let mode = match family { + Family::Auth => self.contributed.auth.as_ref().map(|item| item.mode), + Family::RateLimit => self.contributed.rate_limit.as_ref().map(|item| item.mode), + Family::Plugins => self.contributed.plugins.as_ref().map(|item| item.mode), + Family::Cors => self.contributed.cors.as_ref().map(|item| item.mode), + }; + mode.unwrap_or(SharingMode::Private) + } + + /// Whether the binding contributes one family at all. + #[must_use] + pub fn contributes(&self, family: Family) -> bool { + match family { + Family::Auth => self.contributed.auth.is_some(), + Family::RateLimit => self.contributed.rate_limit.is_some(), + Family::Plugins => self.contributed.plugins.is_some(), + Family::Cors => self.contributed.cors.is_some(), + } + } +} + +/// The authentication family of one per-layer result. +#[domain_model] +#[derive(Debug, Clone, PartialEq)] +pub struct EffectiveAuth { + /// The tenant whose authentication object is the effective one. + pub owner: Uuid, + /// The sharing mode that produced the result. + pub mode: SharingMode, + /// The effective authentication object. + pub auth: AuthConfig, +} + +/// The rate-limit family of one per-layer result. +#[domain_model] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EffectiveRateLimit { + /// The tenant whose limit the effective value descends from. + pub owner: Uuid, + /// The sharing mode that produced the result. + pub mode: SharingMode, + /// The effective limit: the minimum of the visible sustained rates and the + /// minimum of the visible burst capacities, with the remaining members + /// carried unchanged. + pub rate_limit: RateLimitConfig, +} + +/// The plugin family of one per-layer result. +#[domain_model] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EffectivePluginChain { + /// The tenant whose chain items are the nearest of the effective ones. + pub owner: Uuid, + /// The sharing mode that produced the result. + pub mode: SharingMode, + /// The effective chain: the ancestors' items followed by the + /// descendant's, in that order. + pub items: Vec, + /// The tenants whose items are in the chain, most distant first. + pub contributors: Vec, +} + +/// The CORS family of one per-layer result. +#[domain_model] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EffectiveCors { + /// The tenant whose CORS object is the effective one. + pub owner: Uuid, + /// The sharing mode that produced the result. + pub mode: SharingMode, + /// The effective CORS configuration. + pub cors: CorsConfig, +} + +/// The tag family of one per-layer result. +/// +/// `tags` carries no sharing field, so there is no mode to report: the result +/// is the add-only union of every contributor's tags. +#[domain_model] +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct EffectiveTagSet { + /// The effective tags: the union of the ancestors' and the descendant's. + pub tags: Vec, + /// The tenants whose tags are in the set, most distant first. + pub contributors: Vec, +} + +/// The upstream-layer result of one resolution. +#[domain_model] +#[derive(Debug, Clone, PartialEq)] +pub struct EffectiveUpstreamConfig { + /// The tenant that owns the routing target: the resolved ownership the + /// consumer's own authorization check needs. + pub tenant_id: Uuid, + /// The routing target's identifier. + pub upstream_id: Uuid, + /// The authentication family, when the merged families produced one. + pub auth: Option, + /// The rate-limit family, when the merged families produced one. + pub rate_limit: Option, + /// The plugin family, when the merged families produced one. + pub plugins: Option, + /// The CORS family, when the merged families produced one. + pub cors: Option, + /// The tag family. + pub tags: EffectiveTagSet, +} + +/// The route-layer result of one resolution. +/// +/// A route carries no authentication family, so the result has no `auth` +/// member to skip. +#[domain_model] +#[derive(Debug, Clone, PartialEq)] +pub struct EffectiveRouteConfig { + /// The tenant that owns the matched route. + pub tenant_id: Uuid, + /// The matched route's identifier. + pub route_id: Uuid, + /// The upstream the matched route belongs to. + pub upstream_id: Uuid, + /// The rate-limit family, when the merged families produced one. + pub rate_limit: Option, + /// The plugin family, when the merged families produced one. + pub plugins: Option, + /// The CORS family, when the merged families produced one. + pub cors: Option, + /// The tag family. + pub tags: EffectiveTagSet, +} + +/// The route selector a resolution matches against, ADR 0006's `method` and +/// `path`. +/// +/// The matching this type drives is the candidate selection of the route layer +/// alone: which chain element's route the resolution resolves. Applying the +/// result to a proxy request belongs to the data-plane proxy feature. +#[domain_model] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RouteSelector { + /// An HTTP request: the request method and the request path. + Http { + /// The request method. + method: String, + /// The request path. + path: String, + }, + /// A gRPC request: the fully qualified service and the RPC method. + Grpc { + /// The fully qualified service name. + service: String, + /// The RPC method name. + rpc: String, + }, +} 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..1790d6d --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/error.rs @@ -0,0 +1,357 @@ +//! Canonical error type and mapping inputs. +//! +//! Realizes `cpt-cf-oagw-dod-error-catalogue`: one `ErrorKind` variant per +//! error type in the DESIGN §3.3 catalogue plus the two management-conflict +//! variants added per §1.5, each carrying its HTTP status, its GTS `type` +//! identifier, its Retriable flag, and its `gateway`/`upstream` source tag. +//! +//! The catalogue table is stated once, in the `match` arms below. The GTS +//! identifiers come verbatim from [`crate::gts`] — never synthesized from a +//! variant name. No `http`/`axum` type appears here; the RFC 9457 mapping +//! that consumes this type lives in [`crate::api`]. + +use std::fmt; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::gts; + +/// Why a domain model value failed its structural invariant. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum ModelError { + /// `server.endpoints` must carry at least one endpoint. + #[error("server.endpoints requires at least one endpoint")] + NoEndpoints, + /// `route.match` must carry exactly one of `http` or `grpc`. + #[error("route.match requires exactly one of http or grpc")] + AmbiguousMatch, +} + +/// Where a failure originated: the gateway itself, or the upstream service +/// it proxied to (`cpt-cf-oagw-adr-error-source-distinction`). +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ErrorSource { + /// The gateway answered the failure itself; mapped to + /// `application/problem+json`. + Gateway, + /// The upstream service answered the failure; passed through with its own + /// body and content type, never rewritten into a problem body. + Upstream, +} + +impl ErrorSource { + /// The value of the `X-OAGW-Error-Source` header. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Gateway => "gateway", + Self::Upstream => "upstream", + } + } +} + +impl fmt::Display for ErrorSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Optional correlation and routing context attached to a [`DomainError`]. +/// +/// Every present member becomes an extension field of the problem body; no +/// member is ever defaulted. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ErrorContext { + /// Identifier of the upstream that failed. + #[serde(default)] + pub upstream_id: Option, + /// Host the request was routed to. + #[serde(default)] + pub host: Option, + /// Request path. + #[serde(default)] + pub path: Option, + /// Suggested retry delay in seconds; emitted as `Retry-After` only for + /// the six retriable rows. + #[serde(default)] + pub retry_after_seconds: Option, + /// Correlation identifier, populated by the observability feature when a + /// correlation context is available. + #[serde(default)] + pub trace_id: Option, +} + +// @cpt-dod:cpt-cf-oagw-dod-error-catalogue:p1 +/// One variant per error type in the catalogue. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ErrorKind { + /// Routing configuration error (400). + RouteError, + /// Validation error (400). + ValidationError, + /// The routed target has no host (400). + MissingTargetHost, + /// The routed target host is malformed (400). + InvalidTargetHost, + /// The routed target host is not configured (400). + UnknownTargetHost, + /// Authentication failed (401). + AuthenticationFailed, + /// No route matched (404). + RouteNotFound, + /// The plugin is referenced by a configuration (409). + PluginInUse, + /// The alias is already taken (409), added per §1.5. + AliasConflict, + /// The match rule conflicts with an existing route (409), added per §1.5. + MatchConflict, + /// The request body exceeds the configured ceiling (413). + PayloadTooLarge, + /// The rate limit is exhausted (429). + RateLimitExceeded, + /// The credential reference does not resolve (500). + SecretNotFound, + /// The upstream spoke a protocol the gateway cannot follow (502). + ProtocolError, + /// The upstream answered with a failure (502). + DownstreamError, + /// The stream was aborted (502). + StreamAborted, + /// No usable link to the upstream (503). + LinkUnavailable, + /// The circuit breaker is open (503). + CircuitBreakerOpen, + /// The referenced plugin is not resolvable (503). + PluginNotFound, + /// Establishing the connection timed out (504). + ConnectionTimeout, + /// The upstream did not answer in time (504). + RequestTimeout, + /// The idle deadline expired (504). + IdleTimeout, +} + +impl ErrorKind { + /// Fixed human-readable problem `title` for the variant. + #[must_use] + pub const fn title(self) -> &'static str { + match self { + Self::RouteError => "Routing configuration error", + Self::ValidationError => "Validation error", + Self::MissingTargetHost => "Missing target host", + Self::InvalidTargetHost => "Invalid target host", + Self::UnknownTargetHost => "Unknown target host", + Self::AuthenticationFailed => "Authentication failed", + Self::RouteNotFound => "Route not found", + Self::PluginInUse => "Plugin in use", + Self::AliasConflict => "Alias conflict", + Self::MatchConflict => "Match conflict", + Self::PayloadTooLarge => "Payload too large", + Self::RateLimitExceeded => "Rate limit exceeded", + Self::SecretNotFound => "Secret not found", + Self::ProtocolError => "Protocol error", + Self::DownstreamError => "Downstream error", + Self::StreamAborted => "Stream aborted", + Self::LinkUnavailable => "Link unavailable", + Self::CircuitBreakerOpen => "Circuit breaker open", + Self::PluginNotFound => "Plugin not found", + Self::ConnectionTimeout => "Connection timeout", + Self::RequestTimeout => "Request timeout", + Self::IdleTimeout => "Idle timeout", + } + } + + /// HTTP status of the catalogue row. + #[must_use] + pub const fn http_status(self) -> u16 { + // @cpt-begin:cpt-cf-oagw-algo-error-mapping:p1:inst-errmap-type + match self { + Self::RouteError + | Self::ValidationError + | Self::MissingTargetHost + | Self::InvalidTargetHost + | Self::UnknownTargetHost => 400, + Self::AuthenticationFailed => 401, + Self::RouteNotFound => 404, + Self::PluginInUse | Self::AliasConflict | Self::MatchConflict => 409, + Self::PayloadTooLarge => 413, + Self::RateLimitExceeded => 429, + Self::SecretNotFound => 500, + Self::ProtocolError | Self::DownstreamError | Self::StreamAborted => 502, + Self::LinkUnavailable | Self::CircuitBreakerOpen | Self::PluginNotFound => 503, + Self::ConnectionTimeout | Self::RequestTimeout | Self::IdleTimeout => 504, + } + // @cpt-end:cpt-cf-oagw-algo-error-mapping:p1:inst-errmap-type + } + + /// Full GTS identifier of the catalogue row, `gts.cf.core.errors.err.v1~` + /// prefix included, taken verbatim from the catalogue table. + #[must_use] + pub const fn gts_type(self) -> &'static str { + match self { + Self::RouteError | Self::ValidationError => gts::ERR_VALIDATION, + Self::MissingTargetHost => gts::ERR_MISSING_TARGET_HOST, + Self::InvalidTargetHost => gts::ERR_INVALID_TARGET_HOST, + Self::UnknownTargetHost => gts::ERR_UNKNOWN_TARGET_HOST, + Self::AuthenticationFailed => gts::ERR_AUTH_FAILED, + Self::RouteNotFound => gts::ERR_ROUTE_NOT_FOUND, + Self::PluginInUse => gts::ERR_PLUGIN_IN_USE, + Self::AliasConflict => gts::ERR_ALIAS_CONFLICT, + Self::MatchConflict => gts::ERR_MATCH_CONFLICT, + Self::PayloadTooLarge => gts::ERR_PAYLOAD_TOO_LARGE, + Self::RateLimitExceeded => gts::ERR_RATE_LIMIT_EXCEEDED, + Self::SecretNotFound => gts::ERR_SECRET_NOT_FOUND, + Self::ProtocolError => gts::ERR_PROTOCOL_ERROR, + Self::DownstreamError => gts::ERR_DOWNSTREAM_ERROR, + Self::StreamAborted => gts::ERR_STREAM_ABORTED, + Self::LinkUnavailable => gts::ERR_LINK_UNAVAILABLE, + Self::CircuitBreakerOpen => gts::ERR_CIRCUIT_BREAKER_OPEN, + Self::PluginNotFound => gts::ERR_PLUGIN_NOT_FOUND, + Self::ConnectionTimeout => gts::ERR_TIMEOUT_CONNECTION, + Self::RequestTimeout => gts::ERR_TIMEOUT_REQUEST, + Self::IdleTimeout => gts::ERR_TIMEOUT_IDLE, + } + } + + /// `true` for the six catalogue rows DESIGN §3.3 marks `Yes`: + /// `RateLimitExceeded`, `LinkUnavailable`, `CircuitBreakerOpen`, + /// `ConnectionTimeout`, `RequestTimeout`, `IdleTimeout`. + /// + /// `DownstreamError` is resolved non-retriable per §1.5: the retry + /// decision for a 502 belongs to the caller and to the data-plane proxy, + /// which owns upstream-failure policy. + #[must_use] + pub const fn is_retriable(self) -> bool { + matches!( + self, + Self::RateLimitExceeded + | Self::LinkUnavailable + | Self::CircuitBreakerOpen + | Self::ConnectionTimeout + | Self::RequestTimeout + | Self::IdleTimeout + ) + } +} + +impl fmt::Display for ErrorKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.title()) + } +} + +impl std::error::Error for ErrorKind {} + +/// One gateway failure, carrying its catalogue row and its context. +/// +/// The context is not duplicated per variant: `kind` selects the catalogue +/// row, and every variant shares the same `detail`, `source`, and `context` +/// shape. +/// +/// `Display` and `std::error::Error` are implemented by hand rather than +/// derived: the mandated `source: ErrorSource` field would otherwise be +/// picked up by thiserror's automatic source detection and presented as the +/// error's cause chain. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DomainError { + /// The catalogue row this failure answers with. + pub kind: ErrorKind, + /// Caller-supplied human-readable detail. + /// + /// Never contains credential material, a `cred://` reference value, or a + /// configuration value; the mapper adds nothing to it. + pub detail: String, + /// Whether the gateway or the upstream produced the failure. + pub source: ErrorSource, + /// Optional correlation and routing context. + pub context: ErrorContext, +} + +impl DomainError { + /// Builds a gateway-sourced failure with empty context. + #[must_use] + pub fn gateway(kind: ErrorKind, detail: impl Into) -> Self { + Self { + kind, + detail: detail.into(), + source: ErrorSource::Gateway, + context: ErrorContext::default(), + } + } + + /// Builds an upstream-sourced failure with empty context. + #[must_use] + pub fn upstream(kind: ErrorKind, detail: impl Into) -> Self { + Self { + kind, + detail: detail.into(), + source: ErrorSource::Upstream, + context: ErrorContext::default(), + } + } + + /// Builds a gateway-sourced retriable failure whose `retry_after_seconds` + /// the problem body and the `Retry-After` header carry, which is the + /// context member the rate-limit and breaker answers are produced with. + #[must_use] + pub fn with_retry_after( + kind: ErrorKind, + detail: impl Into, + retry_after_seconds: Option, + ) -> Self { + Self { + kind, + detail: detail.into(), + source: ErrorSource::Gateway, + context: ErrorContext { + retry_after_seconds, + ..ErrorContext::default() + }, + } + } + + /// HTTP status of the catalogue row this failure answers with. + #[must_use] + pub const fn http_status(&self) -> u16 { + self.kind.http_status() + } + + /// Full GTS identifier of the catalogue row this failure answers with. + #[must_use] + pub const fn gts_type(&self) -> &'static str { + self.kind.gts_type() + } + + /// `true` for the six catalogue rows DESIGN §3.3 marks `Yes`. + #[must_use] + pub const fn is_retriable(&self) -> bool { + self.kind.is_retriable() + } + + /// `Retry-After` value in seconds: emitted only for the six retriable + /// rows, and only when the context carries one. + #[must_use] + pub const fn retry_after_seconds(&self) -> Option { + if self.kind.is_retriable() { + self.context.retry_after_seconds + } else { + None + } + } +} + +impl fmt::Display for DomainError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.kind, self.detail) + } +} + +impl std::error::Error for DomainError {} diff --git a/gears/system/oagw/oagw/src/domain/gear_state.rs b/gears/system/oagw/oagw/src/domain/gear_state.rs new file mode 100644 index 0000000..dcf3f04 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/gear_state.rs @@ -0,0 +1,94 @@ +//! `GearFoundationState` — the provisioning state machine of the gear +//! (`cpt-cf-oagw-state-gear-foundation-lifecycle`). + +use serde::{Deserialize, Serialize}; + +/// Lifecycle of the `oagw` gear foundation. +/// +/// `StartupFailed` is terminal: the runtime aborts startup, so no later +/// feature ever observes a half-initialized gear. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GearFoundationState { + /// The gear is not registered with the runtime yet. + Unregistered, + /// The init hook completed with a validated `OagwConfig`. + Configured, + /// Every catalogue entry is registered and the registry catalogue is in + /// its ready phase. + TypeCatalogProvisioned, + /// The gear reports readiness; it serves only the router mount point, + /// which carries no routes in this feature. + Ready, + /// Terminal: configuration loading, validation, or a catalogue entry + /// failed. + StartupFailed, +} + +impl GearFoundationState { + /// `true` when no declared transition leaves this state: `Ready` is the + /// end of the successful path and `StartupFailed` the end of the failing + /// one. + #[must_use] + pub const fn is_terminal(self) -> bool { + // @cpt-begin:cpt-cf-oagw-state-gear-foundation-lifecycle:p1:inst-state-terminal + matches!(self, Self::Ready | Self::StartupFailed) + // @cpt-end:cpt-cf-oagw-state-gear-foundation-lifecycle:p1:inst-state-terminal + } + + /// Whether the transition `self -> next` is one of the declared + /// transitions. + #[must_use] + pub const fn can_transition_to(self, next: Self) -> bool { + matches!( + (self, next), + (Self::Unregistered, Self::Configured) + | (Self::Unregistered, Self::StartupFailed) + | (Self::Configured, Self::TypeCatalogProvisioned) + | (Self::Configured, Self::StartupFailed) + | (Self::TypeCatalogProvisioned, Self::Ready) + ) + } + + /// Applies a transition, keeping the state machine honest. + /// + /// # Errors + /// + /// Returns [`InvalidTransition`] naming both endpoints when the + /// transition is not one of the declared ones. + pub fn transition(self, next: Self) -> Result { + if self.can_transition_to(next) { + Ok(next) + } else { + Err(InvalidTransition { + from: self, + to: next, + }) + } + } +} + +impl std::fmt::Display for GearFoundationState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let rendered = match self { + Self::Unregistered => "unregistered", + Self::Configured => "configured", + Self::TypeCatalogProvisioned => "type_catalog_provisioned", + Self::Ready => "ready", + Self::StartupFailed => "startup_failed", + }; + f.write_str(rendered) + } +} + +/// A transition outside the declared set of the state machine. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("invalid gear-foundation transition from {from} to {to}")] +pub struct InvalidTransition { + /// The state the transition started from. + pub from: GearFoundationState, + /// The state the transition attempted to reach. + pub to: GearFoundationState, +} 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..22000f0 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/mod.rs @@ -0,0 +1,67 @@ +//! Domain layer of the `oagw` gear. +//! +//! DDD-Light layering: nothing in this module may reference a transport +//! (`http`, `axum`, `hyper`) or persistence (`sqlx`, `sea_orm`) type. HTTP +//! statuses are `u16`, headers are strings, and every type here is marked with +//! `#[toolkit_macros::domain_model]` so a field that pulls in an +//! infrastructure type fails macro expansion. + +// @cpt-dod:cpt-cf-oagw-dod-domain-model-types:p1 +pub mod alias; +pub mod context; +pub mod cors; +pub mod effective; +pub mod error; +pub mod gear_state; +pub mod observability; +pub mod plugin; +pub mod plugin_contract; +pub mod proxy; +pub mod ratelimit; +pub mod route; +pub mod scheme; +pub mod stream; +pub mod upstream; + +pub use alias::{Alias, AliasError, EndpointHost, Hostname}; +pub use context::{AuthContext, RequestContext, ResponseContext}; +pub use cors::{CorsDecision, CorsDecoration, CorsRefusal, EffectiveCorsPolicy, PreflightAnswer}; +pub use effective::{ + AncestorBinding, ChainError, ContributedFamilies, EffectiveAuth, EffectiveCors, + EffectivePluginChain, EffectiveRateLimit, EffectiveRouteConfig, EffectiveTagSet, + EffectiveUpstreamConfig, Family, FamilyContribution, FamilyModes, RouteSelector, TenantChain, +}; +pub use error::{DomainError, ErrorContext, ErrorKind, ErrorSource, ModelError}; +pub use gear_state::{GearFoundationState, InvalidTransition}; +pub use observability::{ + AuditEvent, CorrelationContext, CorrelationSource, MetricLabelSet, SamplingDecision, + AUDIT_EVENTS, +}; +pub use plugin::Plugin; +pub use plugin_contract::{ + AuthPlugin, AuthPluginRegistry, GuardDecision, GuardPlugin, GuardPluginRegistry, PluginFailure, + PluginFamily, PluginPhase, PluginResolveError, SandboxLimits, TransformPlugin, + TransformPluginRegistry, SANDBOX_LIMITS, +}; +pub use ratelimit::{ + AcquireOutcome, BreakerPhase, BudgetAllocation, BudgetMode, BudgetOutcome, CircuitBreakerState, + EffectiveLimit, LimitLayer, LimitLayers, RateLimiterRegistry, SlidingWindow, TokenBucket, + allocate_budget, fold, per_common_scale, sliding_window, token_bucket, token_bucket_capped, + window_millis, +}; +pub use proxy::{ + AliasDerivation, EndpointChoice, MatchedRoute, OutboundRequest, ProxyContext, ProxyResponse, + ResolvedUpstream, RouteCandidate, SelectedEndpoint, +}; +pub use route::{GrpcMatch, HttpMatch, MatchConfig, PathSuffixMode, Route}; +pub use scheme::Scheme; +pub use stream::{ + answer_of, select_mode, upgrade_detection, HalfSide, HANDSHAKE_HEADERS, IDLE_TIMEOUT, + IDLE_TIMEOUT_SECS, StreamHalf, StreamLifecycle, StreamOutcome, StreamSession, StreamTransition, + TransferMode, UpgradeAnswer, UpgradeDetection, UpgradeHandshake, +}; +pub use upstream::{ + Algorithm, AuthConfig, Burst, CorsConfig, Endpoint, HeadersConfig, Passthrough, PluginsConfig, + RateLimitConfig, RateLimitScope, RequestHeaderRules, ResponseHeaderRules, ServerConfig, + SharingMode, Strategy, Sustained, Upstream, Window, +}; diff --git a/gears/system/oagw/oagw/src/domain/observability.rs b/gears/system/oagw/oagw/src/domain/observability.rs new file mode 100644 index 0000000..a5af5d9 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/observability.rs @@ -0,0 +1,812 @@ +//! The observability vocabulary of `cpt-cf-oagw-feature-observability`. +//! +//! Three entities DECOMPOSITION §2.9 names live here: [`CorrelationContext`], +//! [`AuditEvent`], and [`MetricLabelSet`]. The rest of the module is the +//! closed value sets the FEATURE's §1.5 deviations record — the correlation +//! header and its admission bound, the sampling ratio and the auth-failure +//! bound, the five method literals, the histogram buckets, the audit field +//! order, and the twelve event literals — so a caller that needs one of them +//! reads a constant instead of restating a rule. +//! +//! Layering: no transport type appears here. The correlation header value +//! arrives as a `&str` the caller read from the request, the audit event is +//! plain data, and the label set is a declaration and not a per-request value. + +use uuid::Uuid; + +// @cpt-dod:cpt-cf-oagw-dod-obs-correlation:p1 + +/// The header the platform injects the request identifier into, whose value is +/// the one header value any record may carry. +/// +/// `config/e2e-local.yaml`'s `opentelemetry.tracing.http.inject_request_id_header` +/// names it, and it is the one name of the allowlist DESIGN §4.3 states and +/// §1.5 closes. +pub const CORRELATION_HEADER: &str = "x-request-id"; + +/// The longest caller-supplied correlation identifier the admission check +/// admits. +/// +/// A value longer than this is unbounded, and an unbounded value is the thing +/// the check exists to refuse: it is discarded and a UUID is generated in its +/// place (§1.5). +pub const CORRELATION_MAX_LEN: usize = 128; + +/// The denominator of the high-volume sampling ratio DESIGN §4.3's example +/// states: one success record in this many is kept. +/// +/// A build-time constant with no configuration surface (§1.5): no key of +/// [`crate::config::OagwConfig`] and no upstream or route configuration +/// reaches it. +pub const HIGH_VOLUME_SAMPLE_ONE_IN: u64 = 100; + +/// The most authentication-failure records one interval may carry. +/// +/// The records beyond the bound within the interval are dropped and not +/// queued, because a queue of unsent failure records is the flood the bound +/// exists to prevent (§1.5). +pub const AUTH_FAILURE_LOG_LIMIT: u32 = 20; + +/// The length of the interval the failure-log bound is counted over. +pub const AUTH_FAILURE_LOG_INTERVAL_MS: u64 = 1_000; + +/// The twelve buckets of `oagw_request_duration_seconds`, in seconds. +pub const HISTOGRAM_BUCKETS: [f64; 12] = [ + 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, +]; + +/// The `error_type` value a response the upstream answered with a failure +/// status carries, which is the one value the error catalogue has no row for. +pub const ERROR_TYPE_UPSTREAM: &str = "upstream"; + +/// The four phases whose durations `oagw_request_duration_seconds` carries. +pub const PHASES: [&str; 4] = ["resolve", "chain", "upstream", "total"]; + +/// The three selection methods `oagw_routing_endpoint_selected` carries. +pub const SELECTION_METHODS: [&str; 3] = ["explicit_header", "round_robin", "default"]; + +/// The three connection states `oagw_upstream_connections` carries. +pub const CONNECTION_STATES: [&str; 3] = ["idle", "active", "max"]; + +/// The five method literals the shipped route schema declares, in the order +/// its `enum` lists them. +pub const METHOD_LITERALS: [&str; 5] = ["GET", "POST", "PUT", "DELETE", "PATCH"]; + +/// The value `http.request.method` carries for a method outside +/// [`METHOD_LITERALS`]. +pub const METHOD_OTHER: &str = "_OTHER"; + +/// The fourteen field names of an audit record, in the order DESIGN §4.3 +/// tabulates them and in which they are serialized. +pub const AUDIT_FIELDS: [&str; 14] = [ + "timestamp", + "level", + "event", + "request_id", + "tenant_id", + "principal_id", + "host", + "path", + "method", + "status", + "duration_ms", + "request_size", + "response_size", + "error_type", +]; + +/// The twelve event literals §1.5 closes the `event` value at. +pub const AUDIT_EVENTS: [&str; 12] = [ + "proxy_request.succeeded", + "proxy_request.failed", + "config.upstream.created", + "config.upstream.overridden", + "config.upstream.deleted", + "config.route.created", + "config.route.overridden", + "config.route.deleted", + "config.plugin.created", + "config.plugin.deleted", + "auth.failed", + "breaker.transitioned", +]; + +/// The event literal of a successful proxy request. +pub const EVENT_REQUEST_SUCCEEDED: &str = AUDIT_EVENTS[0]; +/// The event literal of a failed proxy request. +pub const EVENT_REQUEST_FAILED: &str = AUDIT_EVENTS[1]; +/// The event literal of an upstream the write path created. +pub const EVENT_UPSTREAM_CREATED: &str = AUDIT_EVENTS[2]; +/// The event literal of an upstream a replacement overrode. +pub const EVENT_UPSTREAM_OVERRIDDEN: &str = AUDIT_EVENTS[3]; +/// The event literal of an upstream the write path deleted. +pub const EVENT_UPSTREAM_DELETED: &str = AUDIT_EVENTS[4]; +/// The event literal of a route the write path created. +pub const EVENT_ROUTE_CREATED: &str = AUDIT_EVENTS[5]; +/// The event literal of a route a replacement overrode. +pub const EVENT_ROUTE_OVERRIDDEN: &str = AUDIT_EVENTS[6]; +/// The event literal of a route the write path deleted. +pub const EVENT_ROUTE_DELETED: &str = AUDIT_EVENTS[7]; +/// The event literal of a plugin the write path created. +pub const EVENT_PLUGIN_CREATED: &str = AUDIT_EVENTS[8]; +/// The event literal of a plugin the write path deleted. +pub const EVENT_PLUGIN_DELETED: &str = AUDIT_EVENTS[9]; +/// The event literal of a failed authentication. +pub const EVENT_AUTH_FAILED: &str = AUDIT_EVENTS[10]; +/// The event literal of a circuit-breaker transition. +pub const EVENT_BREAKER_TRANSITIONED: &str = AUDIT_EVENTS[11]; + +/// The four levels an audit record is written at. +pub const AUDIT_LEVELS: [&str; 4] = ["INFO", "WARN", "ERROR", "DEBUG"]; + +/// Normalizes a request method to the standard verb or `_OTHER`. +/// +/// The five literals the shipped route schema declares are carried as +/// themselves; every other method, including a lowercase spelling of one of +/// them, is `_OTHER`, because a value the schema does not declare is not a +/// value the label set admits (§1.5). +#[must_use] +pub fn normalize_method(method: &str) -> &'static str { + let upper = method.to_ascii_uppercase(); + if METHOD_LITERALS.contains(&upper.as_str()) { + match upper.as_str() { + "GET" => "GET", + "POST" => "POST", + "PUT" => "PUT", + "DELETE" => "DELETE", + _ => "PATCH", + } + } else { + METHOD_OTHER + } +} + +/// The slug a catalogue row carries, read from the GTS `type` identifier. +/// +/// The identifier is `gts.cf.core.errors.err.v1~cf.oagw.{slug}.v1`, so the +/// slug is what the prefix and the `.v1` suffix enclose; a variant whose +/// identifier is not formed that way maps to nothing, because inventing a slug +/// the catalogue does not carry is the cardinality breach the closed set +/// exists to prevent. +#[must_use] +pub fn error_slug_of(gts_type: &str) -> Option<&str> { + let marker = "cf.oagw."; + let start = gts_type.rfind(marker)? + marker.len(); + let rest = >s_type[start..]; + let end = rest.strip_suffix(".v1")?; + if end.is_empty() { + None + } else { + Some(end) + } +} + +/// Whether a caller-supplied correlation value is admitted. +/// +/// The value is admitted when it is non-empty, no longer than +/// [`CORRELATION_MAX_LEN`], and made only of printable ASCII with no control +/// character: every byte in `0x20..=0x7E`, so neither a control byte below +/// `0x20` nor `0x7F` nor any higher byte is carried into a record (§1.5). +#[must_use] +pub fn correlation_admitted(value: &str) -> bool { + !value.is_empty() + && value.len() <= CORRELATION_MAX_LEN + && value.bytes().all(|byte| (0x20..=0x7E).contains(&byte)) + // A value the serializer would redact is not admitted either, so a + // caller cannot trade a correlation identifier for its absence: the + // value that fails here takes the generation branch at the entry and + // the record it produces carries a `request_id`. + && !value.contains("cred://") + && !value.contains("Bearer ") +} + +/// Whether the correlation value arrived from the inbound header or was +/// generated. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CorrelationSource { + /// The platform-injected header carried a value the admission check + /// accepted. + InboundHeader, + /// No header arrived, or the value it carried failed the check. + Generated, +} + +/// Whether the request's success record survives the high-volume sampling +/// gate. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SamplingDecision { + /// The record is written when the route it describes is high-volume. + Keep, + /// The record is dropped when the route it describes is high-volume. + Drop, +} + +/// The correlation state of one proxy request. +/// +/// One per request, carried as a member of [`crate::domain::proxy::ProxyContext`], +/// and read by every routine of the feature that writes on the request's +/// behalf. The tenant and subject identifiers are the platform-resolved ones: +/// an identifier the platform did not resolve is recorded as an absence and +/// never as a synthesized value (§1.4). +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CorrelationContext { + /// The correlation identifier the record's `request_id` field carries. + pub request_id: String, + /// Where the identifier came from. + pub source: CorrelationSource, + /// The calling tenant, when the platform resolved one. + pub tenant_id: Option, + /// The authenticated subject, when the platform resolved one. + pub principal_id: Option, + /// The sampling decision the request's success record is subject to. + pub sampling: SamplingDecision, +} + +impl CorrelationContext { + /// Assigns the correlation state of one request. + /// + /// Realizes `cpt-cf-oagw-algo-correlate`: the header value is adopted when + /// the admission check accepts it and a UUID is generated otherwise, the + /// resolved identity is recorded as it stands, and the sampling decision is + /// read once per request so the decision is constant for the request no + /// matter which route it turns out to match. + #[must_use] + pub fn assign( + inbound: Option<&str>, + tenant_id: Option, + principal_id: Option, + ) -> Self { + // @cpt-begin:cpt-cf-oagw-algo-correlate:p1:inst-ac-read + // The correlation header the platform injected is the value this + // routine reads, and the absence of one is an absence and not a + // synthesized value. + let read = inbound; + // @cpt-end:cpt-cf-oagw-algo-correlate:p1:inst-ac-read + // @cpt-begin:cpt-cf-oagw-algo-correlate:p1:inst-ac-adopt-if + // The header is adopted only when a value arrived and the admission + // check admits it; the check is the bounded, printable, + // no-control-character test of §1.5. + let adopted = read.is_some_and(correlation_admitted); + // @cpt-end:cpt-cf-oagw-algo-correlate:p1:inst-ac-adopt-if + let request_id = if adopted { + // @cpt-begin:cpt-cf-oagw-algo-correlate:p1:inst-ac-adopt + // The value the header carried passes the bounded printable check, + // so it is the identifier the request carries and the record + // writes. + inbound.unwrap_or_default().to_owned() + // @cpt-end:cpt-cf-oagw-algo-correlate:p1:inst-ac-adopt + } else { + // @cpt-begin:cpt-cf-oagw-algo-correlate:p1:inst-ac-adopt-else + // The header was absent or its value failed the admission check: + // both are the branch that generates. + // @cpt-end:cpt-cf-oagw-algo-correlate:p1:inst-ac-adopt-else + // @cpt-begin:cpt-cf-oagw-algo-correlate:p1:inst-ac-generate + // No header, or a value the check refused: a UUID is generated in + // its place, so the request is still correlated and still + // recorded, and an unbounded or non-printable value never reaches + // a record. + Uuid::new_v4().to_string() + // @cpt-end:cpt-cf-oagw-algo-correlate:p1:inst-ac-generate + }; + // @cpt-begin:cpt-cf-oagw-algo-correlate:p1:inst-ac-ident + // The tenant and the subject are the identifiers the platform + // resolved, recorded as the caller supplied them; an identifier the + // platform did not resolve is recorded as an absence and never as a + // synthesized value. + // @cpt-end:cpt-cf-oagw-algo-correlate:p1:inst-ac-ident + // @cpt-begin:cpt-cf-oagw-algo-correlate:p1:inst-ac-sampling + // The sampling decision is read once per request from the identifier + // alone, so a route is neither sampled into silence nor out of it by a + // second decision. + let sampling = Self::sampling_of(&request_id); + // @cpt-end:cpt-cf-oagw-algo-correlate:p1:inst-ac-sampling + // @cpt-begin:cpt-cf-oagw-algo-correlate:p1:inst-ac-return + // RETURN the context: it is carried on the `ProxyContext` of the + // request and read by every routine of §3 that writes on the request's + // behalf. + Self { + source: if adopted { + CorrelationSource::InboundHeader + } else { + CorrelationSource::Generated + }, + request_id, + tenant_id, + principal_id, + sampling, + } + // @cpt-end:cpt-cf-oagw-algo-correlate:p1:inst-ac-return + } + + /// The sampling decision one request identifier is subject to. + /// + /// The roll is a function of the identifier alone, so the decision is read + /// once per request and is the same decision whoever re-derives it: a + /// route cannot be sampled into silence or out of it by a second decision + /// (§1.5). One identifier in [`HIGH_VOLUME_SAMPLE_ONE_IN`] keeps its + /// record. + #[must_use] + pub fn sampling_of(request_id: &str) -> SamplingDecision { + if fnv1a(request_id.as_bytes()).is_multiple_of(HIGH_VOLUME_SAMPLE_ONE_IN) { + SamplingDecision::Keep + } else { + SamplingDecision::Drop + } + } +} + +/// The FNV-1a 64-bit hash the sampling roll and the identifier's admission +/// bucket read. +fn fnv1a(bytes: &[u8]) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01B3); + } + hash +} + +/// Whether a route match pattern names a high-volume route. +/// +/// The classification is a build-time rule of this feature with no +/// configuration surface: a pattern that names a collection — a path with no +/// `{...}` parameter segment, so every request to it addresses the same +/// resource set — is the read-heavy route the ratio is stated over, and a +/// pattern that names a single resource is not. +#[must_use] +pub fn is_high_volume_pattern(pattern: &str) -> bool { + !pattern.contains('{') +} + +/// One structured record of DESIGN §4.3. +/// +/// Exactly the fourteen fields that section tabulates and no fifteenth member: +/// the human-readable message a failed request's prose clause names is carried +/// by no field, because DECOMPOSITION §2.9 fixes the field set as the fourteen +/// names. A field with no value for the event is absent, and is never written +/// as null or as an empty string. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct AuditEvent { + /// The instant the record was issued at, read once per record. + pub timestamp: Option, + /// The level the mapping of §1.5 assigns. + pub level: Option, + /// The event name, from the closed set [`AUDIT_EVENTS`] closes. + pub event: Option, + /// The correlation identifier the request carries. + pub request_id: Option, + /// The calling tenant, when the platform resolved one. + pub tenant_id: Option, + /// The authenticated subject, when the platform resolved one. + pub principal_id: Option, + /// The resolved upstream's alias, for a proxy-path record. + pub host: Option, + /// The matched route's normalized match pattern, or the management path. + pub path: Option, + /// The request method. + pub method: Option, + /// The status the caller was answered with. + pub status: Option, + /// The measured duration, in milliseconds. + pub duration_ms: Option, + /// The request bytes as transferred. + pub request_size: Option, + /// The response bytes as transferred. + pub response_size: Option, + /// The catalogue slug of the failure, or [`ERROR_TYPE_UPSTREAM`]. + pub error_type: Option, +} + +impl AuditEvent { + /// The field name and value pairs the record carries, in the order DESIGN + /// §4.3 tabulates them. + /// + /// A field with no value is omitted rather than written null or empty, + /// which is what the serializer of the record iterates. The order is + /// [`AUDIT_FIELDS`]'s own: the constant is walked, so the tabulated order + /// and the emitted order are one order that cannot drift apart. + #[must_use] + pub fn populated(&self) -> Vec<(&'static str, String)> { + let mut fields: Vec<(&'static str, String)> = Vec::with_capacity(AUDIT_FIELDS.len()); + for name in AUDIT_FIELDS { + let value = match name { + "timestamp" => self.timestamp.clone(), + "level" => self.level.clone(), + "event" => self.event.clone(), + "request_id" => self.request_id.clone(), + "tenant_id" => self.tenant_id.clone(), + "principal_id" => self.principal_id.clone(), + "host" => self.host.clone(), + "path" => self.path.clone(), + "method" => self.method.clone(), + "status" => self.status.map(|status| status.to_string()), + "duration_ms" => self.duration_ms.map(|duration| duration.to_string()), + "request_size" => self.request_size.map(|size| size.to_string()), + "response_size" => self.response_size.map(|size| size.to_string()), + "error_type" => self.error_type.clone(), + _ => None, + }; + if let Some(value) = value { + fields.push((name, value)); + } + } + fields + } +} + +/// The shared label vocabulary of DESIGN §4.2 and the per-family subsets that +/// section enumerates. +/// +/// A declared concept and not a per-request value: it is the vocabulary the +/// twelve families share, held once, which is why the cardinality rules of the +/// FEATURE's §5 are checkable as a property of this declaration rather than as +/// a property of a call site. +// @cpt-dod:cpt-cf-oagw-dod-obs-cardinality:p1 +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct MetricLabelSet; + +impl MetricLabelSet { + /// The upstream alias, the one OAGW-specific label DESIGN §4.2 names. + pub const HOST: &'static str = "host"; + /// The normalized route match pattern. + pub const HTTP_ROUTE: &'static str = "http.route"; + /// The standard verb or [`METHOD_OTHER`]. + pub const HTTP_METHOD: &'static str = "http.request.method"; + /// The numeric status of the answer the caller received. + pub const HTTP_STATUS: &'static str = "http.response.status_code"; + /// One of [`PHASES`]. + pub const PHASE: &'static str = "phase"; + /// A catalogue slug or [`ERROR_TYPE_UPSTREAM`]. + pub const ERROR_TYPE: &'static str = "error_type"; + /// The normalized route match pattern, the same value [`Self::HTTP_ROUTE`] + /// carries. + pub const PATH: &'static str = "path"; + /// The breaker phase a transition started from. + pub const FROM_STATE: &'static str = "from_state"; + /// The breaker phase a transition ended at. + pub const TO_STATE: &'static str = "to_state"; + /// One of [`CONNECTION_STATES`]. + pub const STATE: &'static str = "state"; + /// The upstream identifier the routing families report. + pub const UPSTREAM_ID: &'static str = "upstream_id"; + /// The endpoint host the selection named. + pub const ENDPOINT_HOST: &'static str = "endpoint_host"; + /// The endpoint the availability gauge reports. + pub const ENDPOINT: &'static str = "endpoint"; + /// One of [`SELECTION_METHODS`]. + pub const SELECTION_METHOD: &'static str = "selection_method"; + + /// `oagw_requests_total`. + pub const REQUESTS_TOTAL: &'static [&'static str] = + &[Self::HOST, Self::HTTP_METHOD, Self::HTTP_ROUTE, Self::HTTP_STATUS]; + /// `oagw_request_duration_seconds`. + pub const REQUEST_DURATION: &'static [&'static str] = + &[Self::HOST, Self::HTTP_ROUTE, Self::PHASE]; + /// `oagw_requests_in_flight`. + pub const IN_FLIGHT: &'static [&'static str] = &[Self::HOST]; + /// `oagw_errors_total`. + pub const ERRORS_TOTAL: &'static [&'static str] = + &[Self::HOST, Self::HTTP_ROUTE, Self::ERROR_TYPE]; + /// `oagw_circuit_breaker_state`. + pub const BREAKER_STATE: &'static [&'static str] = &[Self::HOST]; + /// `oagw_rate_limit_exceeded_total`. + pub const RATE_LIMIT_EXCEEDED: &'static [&'static str] = &[Self::HOST, Self::PATH]; + /// `oagw_circuit_breaker_transitions_total`. + pub const BREAKER_TRANSITIONS: &'static [&'static str] = + &[Self::HOST, Self::FROM_STATE, Self::TO_STATE]; + /// `oagw_rate_limit_usage_ratio`. + pub const RATE_LIMIT_USAGE: &'static [&'static str] = &[Self::HOST, Self::PATH]; + /// `oagw_routing_target_host_used`. + pub const ROUTING_TARGET_USED: &'static [&'static str] = + &[Self::UPSTREAM_ID, Self::ENDPOINT_HOST]; + /// `oagw_routing_endpoint_selected`. + pub const ROUTING_SELECTED: &'static [&'static str] = + &[Self::UPSTREAM_ID, Self::ENDPOINT_HOST, Self::SELECTION_METHOD]; + /// `oagw_upstream_available`. + pub const UPSTREAM_AVAILABLE: &'static [&'static str] = &[Self::HOST, Self::ENDPOINT]; + /// `oagw_upstream_connections`. + pub const UPSTREAM_CONNECTIONS: &'static [&'static str] = &[Self::HOST, Self::STATE]; + + /// The label set of one family, by its registry name. + /// + /// The twelve names are the twelve families DESIGN §4.2 enumerates; a name + /// outside them has no set, because no family the catalogue does not name + /// is declared. + #[must_use] + pub fn labels_of(family: &str) -> Option<&'static [&'static str]> { + match family { + "oagw_requests_total" => Some(Self::REQUESTS_TOTAL), + "oagw_request_duration_seconds" => Some(Self::REQUEST_DURATION), + "oagw_requests_in_flight" => Some(Self::IN_FLIGHT), + "oagw_errors_total" => Some(Self::ERRORS_TOTAL), + "oagw_circuit_breaker_state" => Some(Self::BREAKER_STATE), + "oagw_rate_limit_exceeded_total" => Some(Self::RATE_LIMIT_EXCEEDED), + "oagw_circuit_breaker_transitions_total" => Some(Self::BREAKER_TRANSITIONS), + "oagw_rate_limit_usage_ratio" => Some(Self::RATE_LIMIT_USAGE), + "oagw_routing_target_host_used" => Some(Self::ROUTING_TARGET_USED), + "oagw_routing_endpoint_selected" => Some(Self::ROUTING_SELECTED), + "oagw_upstream_available" => Some(Self::UPSTREAM_AVAILABLE), + "oagw_upstream_connections" => Some(Self::UPSTREAM_CONNECTIONS), + _ => None, + } + } +} + +/// The label value a breaker phase is reported under, in the spelling +/// `cpt-cf-oagw-state-circuit-breaker` declares. +#[must_use] +pub fn breaker_state_label(phase: crate::domain::ratelimit::BreakerPhase) -> &'static str { + use crate::domain::ratelimit::BreakerPhase; + match phase { + BreakerPhase::Closed => "closed", + BreakerPhase::Open => "open", + BreakerPhase::HalfOpen => "half_open", + } +} + +/// The `selection_method` value an endpoint choice is reported under. +#[must_use] +pub fn selection_method_label(choice: crate::domain::proxy::EndpointChoice) -> &'static str { + use crate::domain::proxy::EndpointChoice; + match choice { + EndpointChoice::Header => "explicit_header", + EndpointChoice::LoadBalanced => "round_robin", + EndpointChoice::Only => "default", + } +} + +/// The `event` literal and the level the mapping of §1.5 assigns a proxy-path +/// record. +/// +/// A request the gateway answered from an error it produced, or the upstream +/// answered with a failure status, is the failed record; every other answer is +/// the success record. The level is ERROR for an upstream failure, a timeout, +/// and an authentication failure, WARN for a rate-limit refusal and a +/// breaker-open answer, and INFO for every other answer, which is the mapping +/// DESIGN §4.3 tabulates and §1.5 applies. +/// +/// The two credential-resolution failures the chain reports — a reference the +/// credential store declined and a reference it resolved no secret for — are +/// the authentication failures §1.5 row 177 names as ones this feature +/// records, so they are carried under the `auth.failed` literal of the closed +/// set rather than under the generic failed-request one, at the ERROR level +/// the mapping assigns them. +#[must_use] +pub fn request_event_of(failed: bool, kind: Option) -> (&'static str, &'static str) { + use crate::domain::error::ErrorKind; + if !failed { + return (EVENT_REQUEST_SUCCEEDED, "INFO"); + } + if matches!( + kind, + Some(ErrorKind::AuthenticationFailed | ErrorKind::SecretNotFound) + ) { + return (EVENT_AUTH_FAILED, "ERROR"); + } + let level = match kind { + Some(ErrorKind::RateLimitExceeded | ErrorKind::CircuitBreakerOpen) => "WARN", + Some( + ErrorKind::DownstreamError + | ErrorKind::ProtocolError + | ErrorKind::StreamAborted + | ErrorKind::LinkUnavailable + | ErrorKind::ConnectionTimeout + | ErrorKind::RequestTimeout + | ErrorKind::IdleTimeout, + ) => "ERROR", + _ => "INFO", + }; + (EVENT_REQUEST_FAILED, level) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_the_five_literals() { + for method in METHOD_LITERALS { + assert_eq!(normalize_method(method), method); + } + } + + #[test] + fn normalizes_everything_else_to_other() { + assert_eq!(normalize_method("OPTIONS"), METHOD_OTHER); + assert_eq!(normalize_method("head"), METHOD_OTHER); + assert_eq!(normalize_method("CONNECT"), METHOD_OTHER); + assert_eq!(normalize_method(""), METHOD_OTHER); + } + + #[test] + fn reads_the_slug_out_of_the_catalogue_identifier() { + assert_eq!(error_slug_of(crate::gts::ERR_AUTH_FAILED), Some("auth.failed")); + assert_eq!( + error_slug_of(crate::gts::ERR_ROUTE_NOT_FOUND), + Some("route.not_found") + ); + assert_eq!( + error_slug_of(crate::gts::ERR_CIRCUIT_BREAKER_OPEN), + Some("circuit_breaker.open") + ); + } + + #[test] + fn admits_a_bounded_printable_value() { + assert!(correlation_admitted("0123456789abcdef")); + assert!(correlation_admitted(&"x".repeat(CORRELATION_MAX_LEN))); + } + + #[test] + fn refuses_an_unbounded_or_controlled_value() { + assert!(!correlation_admitted(&"x".repeat(CORRELATION_MAX_LEN + 1))); + assert!(!correlation_admitted("")); + assert!(!correlation_admitted("with\ttab")); + assert!(!correlation_admitted("with\nnewline")); + assert!(!correlation_admitted("with\u{7f}del")); + assert!(!correlation_admitted("with\u{00e9}accent")); + } + + #[test] + fn adopts_an_admitted_value_and_generates_otherwise() { + let adopted = CorrelationContext::assign(Some("caller-id"), Some(Uuid::nil()), None); + assert_eq!(adopted.request_id, "caller-id"); + assert_eq!(adopted.source, CorrelationSource::InboundHeader); + assert_eq!(adopted.tenant_id, Some(Uuid::nil())); + assert_eq!(adopted.principal_id, None); + + let refused = CorrelationContext::assign(Some("bad\tvalue"), None, None); + assert_eq!(refused.source, CorrelationSource::Generated); + assert!(!refused.request_id.is_empty()); + + let absent = CorrelationContext::assign(None, None, None); + assert_eq!(absent.source, CorrelationSource::Generated); + } + + #[test] + fn keeps_one_identifier_in_the_ratio() { + // One in a hundred of two thousand identifiers is twenty, with slack + // for the roll's distribution over a small sample. + let kept = (0..2000u32) + .map(|index| CorrelationContext::sampling_of(&index.to_string())) + .filter(|decision| *decision == SamplingDecision::Keep) + .count(); + assert!( + (5..=60).contains(&kept), + "expected a small fraction kept, got {kept}" + ); + // The roll is a function of the identifier alone, so the decision is + // the same whoever re-derives it. + assert_eq!( + CorrelationContext::sampling_of("stable"), + CorrelationContext::sampling_of("stable") + ); + } + + #[test] + fn classifies_a_collection_pattern_high_volume() { + assert!(is_high_volume_pattern("/v1/things")); + assert!(!is_high_volume_pattern("/v1/things/{id}")); + assert!(!is_high_volume_pattern("/v1/things/{id}/parts")); + } + + #[test] + fn tabulates_the_fourteen_fields() { + assert_eq!(AUDIT_FIELDS.len(), 14); + assert_eq!(AUDIT_FIELDS[0], "timestamp"); + assert_eq!(AUDIT_FIELDS[13], "error_type"); + } + + #[test] + fn omits_an_unpopulated_field() { + let event = AuditEvent { + level: Some("INFO".to_owned()), + event: Some("proxy_request.succeeded".to_owned()), + request_id: Some("r".to_owned()), + ..AuditEvent::default() + }; + let fields = event.populated(); + assert_eq!(fields.len(), 3); + assert!(AUDIT_EVENTS.contains(&"proxy_request.succeeded")); + assert!(AUDIT_EVENTS.len() == 12); + assert!(AUDIT_LEVELS.len() == 4); + } + + #[test] + fn maps_the_request_events_and_levels() { + use crate::domain::error::ErrorKind; + let (event, level) = request_event_of(false, None); + assert_eq!((event, level), ("proxy_request.succeeded", "INFO")); + let (event, level) = request_event_of(true, Some(ErrorKind::RouteNotFound)); + assert_eq!((event, level), ("proxy_request.failed", "INFO")); + let (event, level) = request_event_of(true, Some(ErrorKind::RateLimitExceeded)); + assert_eq!((event, level), ("proxy_request.failed", "WARN")); + let (event, level) = request_event_of(true, Some(ErrorKind::CircuitBreakerOpen)); + assert_eq!((event, level), ("proxy_request.failed", "WARN")); + let (event, level) = request_event_of(true, Some(ErrorKind::RequestTimeout)); + assert_eq!((event, level), ("proxy_request.failed", "ERROR")); + let (event, level) = request_event_of(true, Some(ErrorKind::AuthenticationFailed)); + assert_eq!((event, level), ("auth.failed", "ERROR")); + let (event, level) = request_event_of(true, Some(ErrorKind::SecretNotFound)); + assert_eq!((event, level), ("auth.failed", "ERROR")); + } + + #[test] + fn maps_the_selection_methods_and_breaker_states() { + use crate::domain::proxy::EndpointChoice; + use crate::domain::ratelimit::BreakerPhase; + assert_eq!( + selection_method_label(EndpointChoice::Header), + "explicit_header" + ); + assert_eq!(selection_method_label(EndpointChoice::LoadBalanced), "round_robin"); + assert_eq!(selection_method_label(EndpointChoice::Only), "default"); + assert_eq!(breaker_state_label(BreakerPhase::Closed), "closed"); + assert_eq!(breaker_state_label(BreakerPhase::Open), "open"); + assert_eq!(breaker_state_label(BreakerPhase::HalfOpen), "half_open"); + } + + #[test] + fn declares_a_set_for_each_of_the_twelve_families() { + for family in AUDIT_EVENTS.iter().take(0) { + assert!(family.ends_with("proxy_request")); + } + let families = [ + "oagw_requests_total", + "oagw_request_duration_seconds", + "oagw_requests_in_flight", + "oagw_errors_total", + "oagw_circuit_breaker_state", + "oagw_rate_limit_exceeded_total", + "oagw_circuit_breaker_transitions_total", + "oagw_rate_limit_usage_ratio", + "oagw_routing_target_host_used", + "oagw_routing_endpoint_selected", + "oagw_upstream_available", + "oagw_upstream_connections", + ]; + assert_eq!(families.len(), 12); + for family in families { + assert!(MetricLabelSet::labels_of(family).is_some(), "{family}"); + assert!(!family.contains("tenant")); + } + assert!(MetricLabelSet::labels_of("oagw_not_a_family").is_none()); + } + + #[test] + fn carries_no_tenant_key_in_any_label_set() { + let families = [ + MetricLabelSet::REQUESTS_TOTAL, + MetricLabelSet::REQUEST_DURATION, + MetricLabelSet::IN_FLIGHT, + MetricLabelSet::ERRORS_TOTAL, + MetricLabelSet::BREAKER_STATE, + MetricLabelSet::RATE_LIMIT_EXCEEDED, + MetricLabelSet::BREAKER_TRANSITIONS, + MetricLabelSet::RATE_LIMIT_USAGE, + MetricLabelSet::ROUTING_TARGET_USED, + MetricLabelSet::ROUTING_SELECTED, + MetricLabelSet::UPSTREAM_AVAILABLE, + MetricLabelSet::UPSTREAM_CONNECTIONS, + ]; + assert_eq!(families.len(), 12); + for set in families { + for key in set { + assert!(!key.contains("tenant"), "{key}"); + } + } + } + + #[test] + fn states_the_histogram_buckets() { + assert_eq!(HISTOGRAM_BUCKETS.len(), 12); + assert_eq!(HISTOGRAM_BUCKETS[0], 0.001); + assert_eq!(HISTOGRAM_BUCKETS[11], 10.0); + assert!(HISTOGRAM_BUCKETS.windows(2).all(|pair| pair[0] < pair[1])); + } +} diff --git a/gears/system/oagw/oagw/src/domain/plugin.rs b/gears/system/oagw/oagw/src/domain/plugin.rs new file mode 100644 index 0000000..aa4bebe --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/plugin.rs @@ -0,0 +1,46 @@ +//! `Plugin` aggregate (DESIGN §3.1, for which no schema is shipped). +//! +//! Custom tenant-defined Starlark plugins are persisted keyed by `id`; named +//! built-in plugins are resolved via an in-process registry and never stored. +//! The row carries the two members the create flow accepts beyond the class's +//! own — the optional description and the declared phases — because a plugin +//! created with them would otherwise be created with members no read could +//! return. Timestamps are unix seconds: no chrono dependency is available at +//! this layer, and no persistence type may appear here. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// A custom tenant-defined plugin. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Plugin { + /// Plugin identifier; equals the UUID for a UUID-backed plugin. + pub id: Uuid, + /// Owning tenant. + pub tenant_id: Uuid, + /// One of the three plugin-family literals: `auth`, `guard`, or + /// `transform`. The literal selects the base type of the plugin's + /// anonymous GTS identifier. + pub plugin_type: String, + /// Human-readable plugin name. + pub name: String, + /// Optional human-readable description. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// JSON Schema of the plugin configuration, absent when the plugin + /// declares none. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_schema: Option, + /// The phases the plugin declares, as the wire literals. + #[serde(default)] + pub phases: Vec, + /// Starlark source of the plugin. + pub source_code: String, + /// Unix seconds of the last use, for garbage collection. + #[serde(default)] + pub last_used_at: Option, + /// Unix seconds after which the plugin becomes eligible for collection. + #[serde(default)] + pub gc_eligible_at: Option, +} diff --git a/gears/system/oagw/oagw/src/domain/plugin_contract.rs b/gears/system/oagw/oagw/src/domain/plugin_contract.rs new file mode 100644 index 0000000..5281a3c --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/plugin_contract.rs @@ -0,0 +1,854 @@ +//! The three plugin contracts, one registry per contract, and the catalogue +//! distinctions a resolution answers with. +//! +//! Realizes `cpt-cf-oagw-algo-plugin-contract-registry` and +//! `cpt-cf-oagw-dod-plugin-contracts-registries`: [`AuthPlugin`], +//! [`GuardPlugin`], and [`TransformPlugin`] are declared here with one +//! registry each — [`AuthPluginRegistry`], [`GuardPluginRegistry`], +//! [`TransformPluginRegistry`] — and the three registries are kept separate by +//! construction, because each holds its own table and accepts only its own +//! trait object, so an auth identifier is never looked up in the guard or +//! transform registry and a guard identifier never in the transform one. +//! +//! The [`AuthContext`], [`RequestContext`], and [`ResponseContext`] parameters +//! are the foundation's shared vocabulary, declared in +//! [`crate::domain::context`]; the fourth, `ErrorContext`, is the foundation's +//! [`crate::domain::error::ErrorContext`]. None of the four is redeclared +//! here. +//! +//! The sandbox limits of `cpt-cf-oagw-nfr-starlark-sandbox` are part of this +//! surface as [`SANDBOX_LIMITS`], and none of them is enforced here: their +//! enforcement is execution-time work that belongs to the data-plane proxy +//! feature. A custom plugin's Starlark source is therefore never parsed, +//! compiled, sandbox-checked, or executed by anything in this module. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::Value; +use toolkit_macros::domain_model; + +use crate::domain::context::{AuthContext, RequestContext, ResponseContext}; +use crate::domain::error::ErrorContext; +use crate::gts::{AUTH_PLUGIN_TYPE, GUARD_PLUGIN_TYPE, TRANSFORM_PLUGIN_TYPE}; + +/// The sandbox limits the contract surface exposes, and enforces none of. +pub const SANDBOX_LIMITS: SandboxLimits = SandboxLimits { + network_io: false, + file_io: false, + imports: false, + max_invocation_millis: 100, + max_invocation_memory_bytes: 10 * 1024 * 1024, +}; + +/// The limits a Starlark plugin invocation is held to at execution time. +/// +/// Every member is a ceiling the data-plane proxy applies when it runs a +/// plugin; this module publishes them so a caller can read what an invocation +/// is entitled to and so the two features cannot disagree about the numbers. +#[domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SandboxLimits { + /// Whether an invocation may open a network connection. + pub network_io: bool, + /// Whether an invocation may touch the file system. + pub file_io: bool, + /// Whether an invocation may import a module. + pub imports: bool, + /// Wall-clock ceiling of one invocation, in milliseconds. + pub max_invocation_millis: u64, + /// Memory ceiling of one invocation, in bytes. + pub max_invocation_memory_bytes: usize, +} + +/// One of the three plugin families a plugin belongs to. +/// +/// The family is what the `plugin_type` literal names, what the anonymous GTS +/// identifier's base type names, and what the binding slot carries; all three +/// spellings agree, and [`PluginFamily::from_type_literal`] and +/// [`PluginFamily::parse_identifier`] are the two places the agreement is +/// established. +#[domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum PluginFamily { + /// The authentication plugin family: one per upstream, never on a route. + Auth, + /// The guard plugin family: many per upstream and per route. + Guard, + /// The transform plugin family: many per upstream and per route. + Transform, +} + +impl PluginFamily { + /// The three literals `plugin_type` admits, in catalogue order. + pub const LITERALS: [&str; 3] = ["auth", "guard", "transform"]; + + /// The family one `plugin_type` literal names, or `None` for any other + /// spelling. + #[must_use] + pub fn from_type_literal(literal: &str) -> Option { + match literal { + "auth" => Some(Self::Auth), + "guard" => Some(Self::Guard), + "transform" => Some(Self::Transform), + _ => None, + } + } + + /// The lowercase singular name the family is written as on the wire. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Auth => "auth", + Self::Guard => "guard", + Self::Transform => "transform", + } + } + + /// The base type schema the family's anonymous GTS identifiers are + /// instances of. + #[must_use] + pub const fn base_type(self) -> &'static str { + match self { + Self::Auth => AUTH_PLUGIN_TYPE, + Self::Guard => GUARD_PLUGIN_TYPE, + Self::Transform => TRANSFORM_PLUGIN_TYPE, + } + } + + /// The phases the family's contract exposes. + #[must_use] + pub const fn supported_phases(self) -> &'static [PluginPhase] { + match self { + Self::Auth => &[PluginPhase::Auth], + Self::Guard => &[PluginPhase::GuardRequest, PluginPhase::GuardResponse], + Self::Transform => &[ + PluginPhase::TransformRequest, + PluginPhase::TransformResponse, + PluginPhase::TransformError, + ], + } + } + + /// Parses an anonymous GTS identifier into its family and the instance + /// part after the `~` separator. + /// + /// A bare UUID names a custom plugin row and is answered with the UUID and + /// no family: the row's own `plugin_type` names the family, not the + /// identifier. An identifier that is not a plugin identifier at all is + /// answered `None`. + #[must_use] + pub fn parse_identifier(identifier: &str) -> Option<(Option, &str)> { + // @cpt-begin:cpt-cf-oagw-algo-plugin-contract-registry:p1:inst-reg-parse + let Some(instance) = identifier + .strip_prefix(AUTH_PLUGIN_TYPE) + .map(|tail| (Self::Auth, tail)) + .or_else(|| { + identifier + .strip_prefix(GUARD_PLUGIN_TYPE) + .map(|tail| (Self::Guard, tail)) + }) + .or_else(|| { + identifier + .strip_prefix(TRANSFORM_PLUGIN_TYPE) + .map(|tail| (Self::Transform, tail)) + }) + else { + // No plugin base type prefixes it: the only bare form a binding + // may name is the custom plugin's UUID. + let is_uuid = uuid::Uuid::parse_str(identifier).is_ok(); + return is_uuid.then_some((None, identifier)); + }; + let (family, instance) = instance; + let is_named = !instance.is_empty() && !instance.contains('~'); + is_named.then_some((Some(family), instance)) + // @cpt-end:cpt-cf-oagw-algo-plugin-contract-registry:p1:inst-reg-parse + } + + /// Whether one identifier is the anonymous GTS identifier of this family. + #[must_use] + pub fn names(self, identifier: &str) -> bool { + identifier.starts_with(self.base_type()) + && identifier.len() > self.base_type().len() + } +} + +/// One phase a plugin implementation may declare. +#[domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum PluginPhase { + /// Credential injection, before the guards run. + Auth, + /// Guard evaluation before the upstream call. + GuardRequest, + /// Guard evaluation after the upstream call. + GuardResponse, + /// Request transformation before the upstream call. + TransformRequest, + /// Response transformation after the upstream call. + TransformResponse, + /// Error transformation when the upstream call fails. + TransformError, +} + +/// Why an identifier could not be resolved to an implementation. +/// +/// The two catalogue reasons are deliberately distinct so a caller can tell a +/// reserved-but-unimplemented identifier from a typo, and neither carries +/// anything about the registry's contents beyond the fact of the failure. +#[domain_model] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PluginResolveError { + /// The identifier is one of the six catalog-only identifiers: registered + /// in the types-registry only, with no backing implementation anywhere. + Reserved { + /// The identifier that was resolved. + identifier: String, + }, + /// The identifier is neither registered nor reserved: no catalogue row + /// names it and no registry holds it. + Unknown { + /// The identifier that was resolved. + identifier: String, + }, + /// The implementation is registered but does not declare the phase that + /// was asked for. + PhaseNotDeclared { + /// The identifier that was resolved. + identifier: String, + /// The phase the implementation does not declare. + phase: PluginPhase, + }, +} + +/// The credential-injection contract. +/// +/// One implementation per upstream, never on a route. The single phase it +/// exposes is the credential injection that runs before the guards. +#[async_trait] +pub trait AuthPlugin: Send + Sync { + /// Whether the implementation declares one phase. + fn declares(&self, phase: PluginPhase) -> bool; + + /// Resolves the credential the configuration names and writes it into the + /// context. The material leaves this call in the context's headers and + /// nowhere else. + /// + /// # Errors + /// + /// Returns the typed failure the caller maps onto the catalogue, without + /// echoing a reference value or any material. + async fn authenticate( + &self, + ctx: &mut AuthContext, + config: &Value, + ) -> Result<(), PluginFailure>; +} + +/// The request-and-response evaluation contract. +/// +/// Many implementations per upstream and per route. Both phases are exposed by +/// every guard implementation; one that evaluates only one of them answers +/// `false` for the other in `declares`. +pub trait GuardPlugin: Send + Sync { + /// Whether the implementation declares one phase. + fn declares(&self, phase: PluginPhase) -> bool; + + /// Evaluates the request before the upstream call. + fn guard_request(&self, ctx: &RequestContext, config: &Value) -> GuardDecision; + + /// Evaluates the response after the upstream call. + fn guard_response(&self, ctx: &ResponseContext, config: &Value) -> GuardDecision; +} + +/// The request, response, and error mutation contract. +/// +/// Many implementations per upstream and per route. The three phases are the +/// whole surface, and one that mutates only the request answers `false` for +/// the other two in `declares`. +pub trait TransformPlugin: Send + Sync { + /// Whether the implementation declares one phase. + fn declares(&self, phase: PluginPhase) -> bool; + + /// Mutates the request before the upstream call. + fn transform_request(&self, ctx: &mut RequestContext, config: &Value); + + /// Mutates the response after the upstream call. + fn transform_response(&self, ctx: &mut ResponseContext, config: &Value); + + /// Mutates the error context when the upstream call fails. + fn transform_error(&self, ctx: &mut ErrorContext, config: &Value); +} + +/// The allow-or-reject verdict a guard plugin returns. +/// +/// A rejection carries the machine-readable code ADR 0009 names and the +/// message the answer carries; the HTTP status and the catalogue row are +/// decided by the phase the rejection happened in, not by the guard. +#[domain_model] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GuardDecision { + /// The request or the response passes the configured contract. + Allow, + /// The request or the response violates it. + Reject { + /// Machine-readable rejection code, `REQUIRED_HEADER_MISSING` for the + /// required-headers guard. + code: String, + /// Human-readable message naming the violated property. + message: String, + }, +} + +impl GuardDecision { + /// Builds a rejection with its code and message. + #[must_use] + pub fn reject(code: impl Into, message: impl Into) -> Self { + Self::Reject { + code: code.into(), + message: message.into(), + } + } + + /// Whether the verdict allows what was evaluated. + #[must_use] + pub const fn is_allowed(&self) -> bool { + matches!(self, Self::Allow) + } +} + +/// The typed failure a plugin returns, before any mapping onto the catalogue. +/// +/// No variant carries a credential reference value or resolved material: the +/// reason a failure names is a property of the configuration, never a copy of +/// one. +#[domain_model] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PluginFailure { + /// A credential reference in the configuration failed the `cred://` shape + /// check. Never reached the credential store. + CredentialShape, + /// The credential store resolved no secret for a reference. + SecretNotFound, + /// The credential store declined a reference for the calling tenant or + /// subject, or the identity provider refused the exchange. + AuthenticationFailed, + /// The credential store or the identity provider was unreachable. + Unavailable, + /// The plugin configuration is unusable for the phase that ran. + Configuration { + /// Which property of the configuration is unusable. + reason: String, + }, +} +/// The registry the `AuthPlugin` implementations are held in. +/// +/// The two Client Credentials variants are constructed with the token cache +/// and the credential store they resolve through, which is why the built-in +/// set is built through `AuthPluginRegistry::with_builtins` rather than +/// through `register`. +#[derive(Clone, Default)] +pub struct AuthPluginRegistry { + /// The registered implementations, keyed on the full identifier. + entries: BTreeMap>, +} + +impl AuthPluginRegistry { + /// Creates an empty registry. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Creates the registry with the four backed auth implementations of the + /// built-in catalogue registered at initialization. + /// + /// The `apikey` variant resolves its reference through the credential + /// store it is handed; the two Client Credentials variants resolve their + /// two references through the same store and hold the token cache the + /// ceilings of `token_cache` give them. The `noop` variant holds nothing. + #[must_use] + pub fn with_builtins( + store: Arc, + token_cache: crate::plugins::token_cache::TokenCacheConfig, + ) -> Self { + // @cpt-begin:cpt-cf-oagw-dod-builtin-catalogue:p1:inst-catalog-auth-registry + let mut registry = Self::default(); + // One cache per variant: the cache key carries the auth method tag, so + // two variants never share an entry, and each cache is its own + // instance as ADR 0008's plugin sketch builds them. + let cache = || crate::plugins::token_cache::TokenCache::new(token_cache); + for (identifier, plugin) in [ + ( + crate::gts::plugin_catalog::AUTH_NOOP, + Arc::new(crate::plugins::builtin::NoopAuthPlugin) + as Arc, + ), + ( + crate::gts::plugin_catalog::AUTH_APIKEY, + Arc::new(crate::plugins::builtin::ApiKeyAuthPlugin::new(Arc::clone(&store))) + as Arc, + ), + ( + crate::gts::plugin_catalog::AUTH_OAUTH2_CLIENT_CRED, + Arc::new(crate::plugins::builtin::OAuth2ClientCredAuthPlugin::new( + Arc::clone(&store), + toolkit_auth::ClientAuthMethod::Form, + cache(), + )) as Arc, + ), + ( + crate::gts::plugin_catalog::AUTH_OAUTH2_CLIENT_CRED_BASIC, + Arc::new(crate::plugins::builtin::OAuth2ClientCredAuthPlugin::new( + Arc::clone(&store), + toolkit_auth::ClientAuthMethod::Basic, + cache(), + )) as Arc, + ), + ] { + registry.register(identifier, plugin); + } + registry + // @cpt-end:cpt-cf-oagw-dod-builtin-catalogue:p1:inst-catalog-auth-registry + } + + /// Registers one implementation under its full anonymous GTS identifier. + /// A re-registration over an existing identifier replaces it, which is + /// what a re-registration over byte-identical content amounts to. + pub fn register(&mut self, identifier: &str, plugin: Arc) { + self.entries.insert(String::from(identifier), plugin); + } + + /// The identifiers the registry holds, in sorted order. + #[must_use] + pub fn identifiers(&self) -> Vec<&str> { + self.entries.keys().map(String::as_str).collect() + } + + /// The number of registered implementations. + #[must_use] + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether the registry holds no implementation. + #[must_use] + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Resolves one identifier, answering the catalogue distinctions before + /// the registry is consulted at all. + /// + /// # Errors + /// + /// Returns [`PluginResolveError::Reserved`] for a catalog-only identifier + /// and [`PluginResolveError::Unknown`] for every identifier no entry of + /// this registry backs, whether the catalogue names it for another family + /// or names it not at all. + pub fn resolve(&self, identifier: &str) -> Result, PluginResolveError> { + resolve_entry(&self.entries, identifier, |plugin| Arc::clone(plugin)) + } + + /// Resolves one identifier for one phase, refusing an implementation that + /// does not declare the phase asked for. + /// + /// # Errors + /// + /// Returns the same refusals as [`Self::resolve`], plus + /// [`PluginResolveError::PhaseNotDeclared`] when the resolved + /// implementation does not declare the phase. + pub fn resolve_for_phase( + &self, + identifier: &str, + phase: PluginPhase, + ) -> Result, PluginResolveError> { + let plugin = self.resolve(identifier)?; + if plugin.declares(phase) { + Ok(plugin) + } else { + Err(PluginResolveError::PhaseNotDeclared { + identifier: String::from(identifier), + phase, + }) + } + } + + /// The phases the identifier's implementation declares, in the family's + /// supported order, or `None` when the identifier does not resolve. + #[must_use] + pub fn declared_phases(&self, identifier: &str) -> Option> { + let plugin = self.resolve(identifier).ok()?; + Some(declared_phases( + |phase| plugin.declares(phase), + PluginFamily::Auth, + )) + } +} + +/// The registry the `GuardPlugin` implementations are held in. +#[derive(Clone, Default)] +pub struct GuardPluginRegistry { + /// The registered implementations, keyed on the full identifier. + entries: BTreeMap>, +} + +impl GuardPluginRegistry { + /// Creates an empty registry. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Creates the registry with the one backed guard implementation of the + /// built-in catalogue registered at initialization. + /// + /// The catalogue's other two guard identifiers are core data-plane + /// behaviour rather than guard implementations, so no second entry exists + /// to register. + #[must_use] + pub fn with_builtins() -> Self { + // @cpt-begin:cpt-cf-oagw-dod-builtin-catalogue:p1:inst-catalog-guard-registry + let mut registry = Self::default(); + registry.register( + crate::gts::plugin_catalog::GUARD_REQUIRED_HEADERS, + Arc::new(crate::plugins::builtin::RequiredHeadersGuardPlugin), + ); + registry + // @cpt-end:cpt-cf-oagw-dod-builtin-catalogue:p1:inst-catalog-guard-registry + } + + /// Registers one implementation under its full anonymous GTS identifier. + pub fn register(&mut self, identifier: &str, plugin: Arc) { + self.entries.insert(String::from(identifier), plugin); + } + + /// The identifiers the registry holds, in sorted order. + #[must_use] + pub fn identifiers(&self) -> Vec<&str> { + self.entries.keys().map(String::as_str).collect() + } + + /// The number of registered implementations. + #[must_use] + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether the registry holds no implementation. + #[must_use] + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Resolves one identifier, answering the catalogue distinctions before + /// the registry is consulted at all. + /// + /// # Errors + /// + /// Returns [`PluginResolveError::Reserved`] for a catalog-only identifier + /// and [`PluginResolveError::Unknown`] for every identifier no entry of + /// this registry backs, whether the catalogue names it for another family + /// or names it not at all. + pub fn resolve(&self, identifier: &str) -> Result, PluginResolveError> { + resolve_entry(&self.entries, identifier, |plugin| Arc::clone(plugin)) + } + + /// Resolves one identifier for one phase, refusing an implementation that + /// does not declare the phase asked for. + /// + /// # Errors + /// + /// Returns the same refusals as [`Self::resolve`], plus + /// [`PluginResolveError::PhaseNotDeclared`]. + pub fn resolve_for_phase( + &self, + identifier: &str, + phase: PluginPhase, + ) -> Result, PluginResolveError> { + let plugin = self.resolve(identifier)?; + if plugin.declares(phase) { + Ok(plugin) + } else { + Err(PluginResolveError::PhaseNotDeclared { + identifier: String::from(identifier), + phase, + }) + } + } + + /// The phases the identifier's implementation declares, or `None` when the + /// identifier does not resolve. + #[must_use] + pub fn declared_phases(&self, identifier: &str) -> Option> { + let plugin = self.resolve(identifier).ok()?; + Some(declared_phases( + |phase| plugin.declares(phase), + PluginFamily::Guard, + )) + } +} + +/// The registry the `TransformPlugin` implementations are held in. +#[derive(Clone, Default)] +pub struct TransformPluginRegistry { + /// The registered implementations, keyed on the full identifier. + entries: BTreeMap>, +} + +impl TransformPluginRegistry { + /// Creates an empty registry. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Creates the registry with the one backed transform implementation of + /// the built-in catalogue registered at initialization. + /// + /// The catalogue's other two transform identifiers are core data-plane + /// instrumentation rather than transform implementations, so no second + /// entry exists to register. + #[must_use] + pub fn with_builtins() -> Self { + // @cpt-begin:cpt-cf-oagw-dod-builtin-catalogue:p1:inst-catalog-transform-registry + let mut registry = Self::default(); + registry.register( + crate::gts::plugin_catalog::TRANSFORM_REQUEST_ID, + Arc::new(crate::plugins::builtin::RequestIdTransformPlugin), + ); + registry + // @cpt-end:cpt-cf-oagw-dod-builtin-catalogue:p1:inst-catalog-transform-registry + } + + /// Registers one implementation under its full anonymous GTS identifier. + pub fn register(&mut self, identifier: &str, plugin: Arc) { + self.entries.insert(String::from(identifier), plugin); + } + + /// The identifiers the registry holds, in sorted order. + #[must_use] + pub fn identifiers(&self) -> Vec<&str> { + self.entries.keys().map(String::as_str).collect() + } + + /// The number of registered implementations. + #[must_use] + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether the registry holds no implementation. + #[must_use] + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Resolves one identifier, answering the catalogue distinctions before + /// the registry is consulted at all. + /// + /// # Errors + /// + /// Returns [`PluginResolveError::Reserved`] for a catalog-only identifier + /// and [`PluginResolveError::Unknown`] for every identifier no entry of + /// this registry backs, whether the catalogue names it for another family + /// or names it not at all. + pub fn resolve( + &self, + identifier: &str, + ) -> Result, PluginResolveError> { + resolve_entry(&self.entries, identifier, |plugin| Arc::clone(plugin)) + } + + /// Resolves one identifier for one phase, refusing an implementation that + /// does not declare the phase asked for. + /// + /// # Errors + /// + /// Returns the same refusals as [`Self::resolve`], plus + /// [`PluginResolveError::PhaseNotDeclared`]. + pub fn resolve_for_phase( + &self, + identifier: &str, + phase: PluginPhase, + ) -> Result, PluginResolveError> { + let plugin = self.resolve(identifier)?; + if plugin.declares(phase) { + Ok(plugin) + } else { + Err(PluginResolveError::PhaseNotDeclared { + identifier: String::from(identifier), + phase, + }) + } + } + + /// The phases the identifier's implementation declares, or `None` when the + /// identifier does not resolve. + #[must_use] + pub fn declared_phases(&self, identifier: &str) -> Option> { + let plugin = self.resolve(identifier).ok()?; + Some(declared_phases( + |phase| plugin.declares(phase), + PluginFamily::Transform, + )) + } +} + +/// The lookup one registry performs: the catalogue distinctions first, then +/// the registry's own table. +fn resolve_entry(entries: &BTreeMap, identifier: &str, clone: F) -> Result +where + F: FnOnce(&T) -> T, +{ + // @cpt-begin:cpt-cf-oagw-algo-plugin-contract-registry:p1:inst-reg-catalog-if + // @cpt-begin:cpt-cf-oagw-algo-plugin-contract-registry:p1:inst-reg-catalog-return + // The catalogue table is consulted before the registry, so a reserved + // identifier is never mistaken for an unknown one. + if crate::gts::plugin_catalog::is_catalog_only(identifier) { + return Err(PluginResolveError::Reserved { + identifier: String::from(identifier), + }); + } + // @cpt-end:cpt-cf-oagw-algo-plugin-contract-registry:p1:inst-reg-catalog-return + // @cpt-end:cpt-cf-oagw-algo-plugin-contract-registry:p1:inst-reg-catalog-if + // @cpt-begin:cpt-cf-oagw-algo-plugin-contract-registry:p1:inst-reg-else + // @cpt-begin:cpt-cf-oagw-algo-plugin-contract-registry:p1:inst-reg-lookup + let found = entries.get(identifier); + // @cpt-end:cpt-cf-oagw-algo-plugin-contract-registry:p1:inst-reg-lookup + // @cpt-begin:cpt-cf-oagw-algo-plugin-contract-registry:p1:inst-reg-empty-if + // A registry answers `Unknown` for every identifier it does not hold, + // including a backed identifier of another family: the separation the + // three registries enforce is that an auth identifier is never + // resolvable from the guard or transform registry. + let Some(entry) = found else { + // @cpt-begin:cpt-cf-oagw-algo-plugin-contract-registry:p1:inst-reg-empty-return + return Err(PluginResolveError::Unknown { + identifier: String::from(identifier), + }); + // @cpt-end:cpt-cf-oagw-algo-plugin-contract-registry:p1:inst-reg-empty-return + }; + // @cpt-end:cpt-cf-oagw-algo-plugin-contract-registry:p1:inst-reg-empty-if + // @cpt-begin:cpt-cf-oagw-algo-plugin-contract-registry:p1:inst-reg-empty-else + // @cpt-begin:cpt-cf-oagw-algo-plugin-contract-registry:p1:inst-reg-return + Ok(clone(entry)) + // @cpt-end:cpt-cf-oagw-algo-plugin-contract-registry:p1:inst-reg-return + // @cpt-end:cpt-cf-oagw-algo-plugin-contract-registry:p1:inst-reg-empty-else + // @cpt-end:cpt-cf-oagw-algo-plugin-contract-registry:p1:inst-reg-else +} + +/// The phases one resolved implementation declares, in the family's order. +/// +/// The declaration predicate is passed rather than the implementation, because +/// the three contracts are three distinct traits and no shared supertrait +/// names `declares`. +fn declared_phases( + declares: impl Fn(PluginPhase) -> bool, + family: PluginFamily, +) -> Vec { + family + .supported_phases() + .iter() + .copied() + .filter(|phase| declares(*phase)) + .collect() +} + +/// What the management surface needs to know about one named plugin: the +/// family its base type names and the phases its implementation declares. +/// +/// No implementation object is carried: a binding write asks only whether an +/// identifier resolves and what it declares, and never invokes it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NamedIdentity { + /// The family the identifier's base type names. + pub family: PluginFamily, + /// The phases the implementation declares, in the family's order. + pub phases: Vec, +} + +/// The registry the management surface resolves named plugin identifiers +/// through. +/// +/// The three implementation registries answer `authenticate`, `guard_*`, and +/// `transform_*`; this one answers the question a binding write asks — is +/// this named identifier backed by an implementation, and which phases does +/// that implementation declare. It is built from the same built-in entries the +/// implementation registries register, with the phases each declares, and it +/// holds no implementation, so a management write that resolves identifiers +/// gains no credential-store dependency and cannot invoke a plugin. +#[derive(Clone, Default)] +pub struct NamedPluginRegistry { + /// The named identities, keyed on the full identifier. + entries: BTreeMap, +} + +impl NamedPluginRegistry { + /// Creates an empty registry. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Creates the registry with the built-in identities of the catalogue: the + /// guard and transform entries are read off the implementations those two + /// registries register, and the auth entries are the four backed auth + /// identifiers, each declaring the single credential-injection phase the + /// auth contract exposes. + #[must_use] + pub fn with_builtins() -> Self { + let mut registry = Self::default(); + let guard = GuardPluginRegistry::with_builtins(); + for identifier in guard.identifiers() { + let phases = guard.declared_phases(identifier).unwrap_or_default(); + registry.record(identifier, PluginFamily::Guard, phases); + } + let transform = TransformPluginRegistry::with_builtins(); + for identifier in transform.identifiers() { + let phases = transform.declared_phases(identifier).unwrap_or_default(); + registry.record(identifier, PluginFamily::Transform, phases); + } + for (identifier, family) in crate::gts::plugin_catalog::BACKED + .iter() + .filter(|(_, family)| *family == PluginFamily::Auth) + { + registry.record( + identifier, + *family, + family.supported_phases().to_vec(), + ); + } + registry + } + + /// Records one named identity under its full identifier. + pub fn record(&mut self, identifier: &str, family: PluginFamily, phases: Vec) { + self.entries.insert( + String::from(identifier), + NamedIdentity { family, phases }, + ); + } + + /// The identifiers the registry holds, in sorted order. + #[must_use] + pub fn identifiers(&self) -> Vec<&str> { + self.entries.keys().map(String::as_str).collect() + } + + /// Resolves one named identifier to the identity the implementation + /// registry holds for it. + /// + /// The lookup is the same one the implementation registries perform: the + /// catalogue distinctions first, then the registry's own table, so a + /// catalog-only identifier answers `Reserved` and an identifier no + /// implementation backs answers `Unknown` — including a backed identifier + /// of another family. + /// + /// # Errors + /// + /// Returns the same refusals [`GuardPluginRegistry::resolve`] does. + pub fn resolve(&self, identifier: &str) -> Result { + resolve_entry(&self.entries, identifier, |entry| entry.clone()) + } +} diff --git a/gears/system/oagw/oagw/src/domain/proxy.rs b/gears/system/oagw/oagw/src/domain/proxy.rs new file mode 100644 index 0000000..9a9a852 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/proxy.rs @@ -0,0 +1,297 @@ +//! The Data Plane proxy entities (FEATURE §5, `cpt-cf-oagw-dod-proxy-entities`). +//! +//! Six entities carry one proxy request from the handler to the upstream and +//! back: [`ProxyContext`] is what the caller sent, [`ResolvedUpstream`] is what +//! the tenant chain resolved, [`MatchedRoute`] is the route that matched it, +//! [`SelectedEndpoint`] is the endpoint that was chosen, [`OutboundRequest`] is +//! what was sent, and [`ProxyResponse`] is what came back. Two smaller types +//! travel with them: the alias derivation kind the endpoint-selection matrix +//! keys on, and one route candidate of the set that candidate is selected from. +//! +//! Every member is a domain type. The transport half of a proxy request — the +//! header map the HTTP layer holds, the connector's peer, the response body +//! stream — is assembled in the API layer from these values and never appears +//! here, which is what keeps the entities free of transport and persistence +//! types. The effective-configuration types the resolution produces +//! (`EffectiveUpstreamConfig`, `EffectiveRouteConfig`) are referenced from +//! `cpt-cf-oagw-feature-hierarchical-config`, and the error context from the +//! foundation, rather than redeclared. + +use uuid::Uuid; + +use crate::domain::effective::{EffectivePluginChain, EffectiveRateLimit}; +use crate::domain::error::ErrorSource; +use crate::domain::route::Route; +use crate::domain::upstream::{Endpoint, HeadersConfig}; + +// @cpt-dod:cpt-cf-oagw-dod-proxy-entities:p1 + +/// How the resolved upstream's alias relates to its endpoint set. +/// +/// `cpt-cf-oagw-algo-alias-derive` of `cpt-cf-oagw-feature-control-plane-config` +/// records at write time whether the alias was derived from a common suffix; +/// the fact is recomputable from the endpoint set alone, so the Data Plane +/// carries it as this kind and never persists it. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AliasDerivation { + /// The alias was derived from the endpoint set's common suffix, which makes + /// `X-OAGW-Target-Host` required for a multi-endpoint pool. + Derived, + /// The alias was supplied, which makes the header optional. + Explicit, +} + +/// The upstream a proxy request resolved to, with the route candidates of its +/// chain. +/// +/// The per-family sharing modes the hierarchy walk carried are not restated: +/// the merged families are the ones `cpt-cf-oagw-feature-hierarchical-config` +/// produced, and the rate-limit family is carried here for +/// `cpt-cf-oagw-feature-rate-limiting`, which consumes this entity from inside +/// the resolved proxy context. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone)] +pub struct ResolvedUpstream { + /// The tenant that owns the routing target. + pub tenant_id: Uuid, + /// The routing target's identifier. + pub upstream_id: Uuid, + /// The normalized alias the request addressed. + pub alias: String, + /// The alias derivation kind the endpoint-selection matrix keys on. + pub alias_derivation: AliasDerivation, + /// The endpoint pool, homogeneous in scheme, port, and protocol. + pub endpoints: Vec, + /// The upstream protocol literal: `cf.core.oagw.http.v1` or + /// `cf.core.oagw.grpc.v1`. + pub protocol: String, + /// The effective `enabled` state: the target's own flag conjoined with + /// every matched ancestor row's. + pub enabled: bool, + /// The header transformation rules in both directions. + pub headers: HeadersConfig, + /// The merged rate-limit family, carried for the rate-limiting feature. + pub rate_limit: Option, + /// The merged plugin family of the upstream layer. + pub plugins: Option, + /// The merged CORS family of the upstream layer, carried for + /// `cpt-cf-oagw-feature-cors`, which consumes this entity from inside the + /// resolved proxy context. + pub cors: Option, + /// The ordered route candidate set of the chain, most distant first. + pub route_candidates: Vec, +} + +impl ResolvedUpstream { + /// Whether the resolved upstream is a gRPC one. + /// + /// No HTTP match key is evaluated for such an upstream, so the request is + /// answered before matching, per the §1.5 deviation. + #[must_use] + pub fn is_grpc(&self) -> bool { + self.protocol == crate::gts::PROTOCOL_GRPC + } +} + +/// One route of the candidate set `cpt-cf-oagw-algo-route-match` selects from. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone)] +pub struct RouteCandidate { + /// The tenant that owns the route row. + pub tenant_id: Uuid, + /// The chain depth of the element that holds it: `0` is the routing + /// target, larger values are the ancestors. + pub depth: usize, + /// The route row as stored. + pub route: Route, +} + +/// The route `cpt-cf-oagw-algo-route-match` selected, with the outbound path it +/// produced. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone)] +pub struct MatchedRoute { + /// The tenant that owns the selected route. + pub tenant_id: Uuid, + /// The selected route's identifier. + pub route_id: Uuid, + /// The selected route's priority, the ascending tie-break of §1.5. + pub priority: Option, + /// The path the outbound request carries. + pub outbound_path: String, + /// The route's normalized match pattern, which the request path was + /// matched against and which the `http.route` label of + /// `cpt-cf-oagw-feature-observability` carries. + pub match_pattern: String, + /// The route's query allowlist, which admits no parameter when empty. + pub query_allowlist: Vec, + /// The merged rate-limit family of the route layer. + pub rate_limit: Option, + /// The merged plugin family of the route layer. + pub plugins: Option, + /// The merged CORS family of the route layer, carried for + /// `cpt-cf-oagw-feature-cors`. + pub cors: Option, +} + +/// How the endpoint was chosen, as the ADR 0001 matrix records it. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EndpointChoice { + /// The pool holds one endpoint; no load balancing ran. + Only, + /// The `X-OAGW-Target-Host` header named it. + Header, + /// The round-robin counter selected it. + LoadBalanced, +} + +/// The endpoint a request is sent to. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone)] +pub struct SelectedEndpoint { + /// The endpoint itself. + pub endpoint: Endpoint, + /// How it was chosen. + pub choice: EndpointChoice, +} + +/// The proxy request as the caller issued it, before any resolution. +/// +/// Headers are kept as the ordered pairs the HTTP layer read, with the names as +/// they arrived; every consumer matches on the lowercased name. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Default)] +pub struct ProxyContext { + /// The request method as issued. + pub method: String, + /// The alias path segment as issued, before normalization. + pub alias: String, + /// The path suffix after the alias, when the request carried one. + pub path_suffix: Option, + /// The raw query string, when the request carried one. + pub query: Option, + /// The request headers, in arrival order. + pub headers: Vec<(String, String)>, + /// The value of `X-OAGW-Target-Host`, read before it is stripped. + pub target_host: Option, + /// The calling tenant. + pub tenant_id: Uuid, + /// The authenticated subject, when the token carried one. + pub subject_id: Option, + /// The correlation state `cpt-cf-oagw-feature-observability` assigned at + /// the path's entry. A request that left the path before that step ran — + /// the CORS preflight is the one — carries none, and the absence is the + /// reason the preflight is recorded by no record and no series. + pub correlation: Option, +} + +impl ProxyContext { + /// The request path the route match and the plugin contexts read: the path + /// the request carried beyond the alias, which is the space a route's + /// `match.http.path` addresses. The alias itself is the routing key that + /// selected the upstream and is never part of the match. + #[must_use] + pub fn request_path(&self) -> String { + match self.path_suffix.as_deref() { + Some(suffix) if !suffix.is_empty() => format!("/{suffix}"), + _ => String::from("/"), + } + } + + /// Reads the first value of a header by its lowercased name. + #[must_use] + pub fn header(&self, name: &str) -> Option<&str> { + let lower = name.to_ascii_lowercase(); + self.headers + .iter() + .find(|(candidate, _)| candidate.to_ascii_lowercase() == lower) + .map(|(_, value)| value.as_str()) + } + + /// Reads every value of a header by its lowercased name. + #[must_use] + pub fn header_values(&self, name: &str) -> Vec<&str> { + let lower = name.to_ascii_lowercase(); + self.headers + .iter() + .filter(|(candidate, _)| candidate.to_ascii_lowercase() == lower) + .map(|(_, value)| value.as_str()) + .collect() + } +} + +/// The header entries the plugin chain added or mutated, and the names it +/// removed, in one phase. +/// +/// `cpt-cf-oagw-algo-header-transform` carries the entries into the outbound +/// map after the configuration rules have run, so a plugin sees the transformed +/// request and not the inbound one; the removed names are dropped from it for +/// the same reason. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PluginMutations { + /// The entries the phase added or wrote a new value for. + pub set: Vec<(String, String)>, + /// The names the phase removed. + pub removed: Vec, +} + +/// The request as transformed and ready to send. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone)] +pub struct OutboundRequest { + /// The method to send. + pub method: String, + /// The endpoint's scheme, checked at dial time. + pub scheme: crate::domain::scheme::Scheme, + /// The selected endpoint's host. + pub host: String, + /// The selected endpoint's port, when it declares one. + pub port: Option, + /// The outbound path, with the allowed query appended. + pub path: String, + /// The transformed header map, in the order it was built. + pub headers: Vec<(String, String)>, + /// The validated body. + pub body: Vec, +} + +/// The response the caller is answered with. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone)] +pub struct ProxyResponse { + /// The status to answer with. + pub status: u16, + /// The response headers, in the order they were assembled. + pub headers: Vec<(String, String)>, + /// The response body. + pub body: Vec, + /// Who produced the body, which is what the error-source header states. + pub source: ErrorSource, +} + +impl ProxyResponse { + /// Builds an upstream-sourced response: the status, headers, and body the + /// upstream produced, passed through as received. + #[must_use] + pub fn upstream(status: u16, headers: Vec<(String, String)>, body: Vec) -> Self { + Self { + status, + headers, + body, + source: ErrorSource::Upstream, + } + } + + /// Reads the first value of a header by its lowercased name. + #[must_use] + pub fn header(&self, name: &str) -> Option<&str> { + let lower = name.to_ascii_lowercase(); + self.headers + .iter() + .find(|(candidate, _)| candidate.to_ascii_lowercase() == lower) + .map(|(_, value)| value.as_str()) + } +} 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..b67f2a6 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/ratelimit.rs @@ -0,0 +1,1101 @@ +//! Rate-limit and circuit-breaker state of the `oagw` gear. +//! +//! Realizes `cpt-cf-oagw-dod-rate-limit-entities`: the four enforcement types +//! DECOMPOSITION §2.6 assigns `cpt-cf-oagw-feature-rate-limiting` — +//! [`TokenBucket`], [`RateLimiterRegistry`], [`BudgetAllocation`], and +//! [`CircuitBreakerState`] — declared once, in the domain layer, free of +//! transport and persistence types, referencing `RateLimitConfig` of +//! `cpt-cf-oagw-feature-gear-foundation` and `EffectiveRateLimit` of +//! `cpt-cf-oagw-feature-hierarchical-config` rather than redeclaring either, +//! and consuming the foundation's [`crate::domain::error::DomainError`] +//! catalogue for every answer it produces. +//! +//! The routines the feature's CDSL §3 states are here too, because they are +//! functions over this state and over the clock the caller hands them: +//! `cpt-cf-oagw-algo-effective-limit-fold` ([`fold`]), +//! `cpt-cf-oagw-algo-token-bucket` ([`token_bucket`]), and +//! `cpt-cf-oagw-algo-sliding-window` ([`sliding_window`]). The arithmetic is +//! integer throughout — milli-tokens for the refill a sub-second rate needs — +//! so a comparison never depends on a float rounding mode. +//! +//! Every instant the state holds is a [`std::time::Instant`]: the monotonic +//! clock the §1.4 assumption names. A monotonic instant cannot be formatted, +//! which is why the `X-RateLimit-Reset` header is derived at the API layer +//! from the wall clock and not here. + +use std::time::{Duration, Instant}; + +use crate::domain::effective::EffectiveRateLimit; +use crate::domain::upstream::{ + Algorithm, RateLimitConfig, RateLimitScope, Strategy, Sustained, Window, +}; + +/// The breaker trips on the fifth failed attempt inside its rolling window, +/// which is the threshold PRD §6.1 states and DESIGN §4.7(1) defers the +/// configuration of (§1.5). +pub const BREAKER_FAILURE_THRESHOLD: usize = 5; + +/// The rolling window the breaker counts failures inside, which PRD §6.1 +/// states as 30 seconds. +pub const BREAKER_FAILURE_WINDOW: Duration = Duration::from_secs(30); + +/// The interval an `open` machine serves before it admits one probe, a named +/// constant of this feature with no configuration surface and no sourced value +/// (§1.5). +pub const BREAKER_OPEN_INTERVAL: Duration = Duration::from_secs(30); + +/// The count bound of the `queue` strategy's in-process queue, a named +/// constant with no configuration surface and no sourced value (§1.5). +pub const QUEUE_CAPACITY: usize = 64; + +/// The wait bound of the `queue` strategy: a queued request that outwaits it +/// is answered 429 and charged nothing (§1.5). +pub const QUEUE_WAIT: Duration = Duration::from_millis(500); + +/// The scale the bucket arithmetic works in: one token is this many units, so +/// a rate of one token per day still refills a visible amount per second. +const TOKEN_SCALE: u128 = 1_000; + +/// The length of a window literal, in milliseconds. +#[must_use] +pub fn window_millis(window: Option) -> u128 { + const SECOND: u128 = 1_000; + match window { + Some(Window::Minute) => 60 * SECOND, + Some(Window::Hour) => 60 * 60 * SECOND, + Some(Window::Day) => 24 * 60 * 60 * SECOND, + Some(Window::Second) | None => SECOND, + } +} + +/// The sustained rate expressed per day, which is the common scale +/// `cpt-cf-oagw-algo-field-family-merge` normalizes to and the fold compares +/// on. +#[must_use] +pub fn per_common_scale(sustained: &Sustained) -> u64 { + let millis = window_millis(sustained.window); + let per_day = u128::from(sustained.rate) * 24 * 60 * 60 * 1_000; + u64::try_from(per_day / millis).unwrap_or(u64::MAX) +} + +/// The refill rate of a bucket, in milli-tokens per millisecond. +#[must_use] +fn refill_per_milli(sustained: &Sustained) -> u128 { + let millis = window_millis(sustained.window); + let per_window = u128::from(sustained.rate) * TOKEN_SCALE; + // A rate of at least 1 per the widest window still yields a non-zero + // milli-token amount per millisecond at this scale. + per_window / millis +} + +// @cpt-dod:cpt-cf-oagw-dod-rate-limit-entities:p1 + +/// One token bucket held for one counter key, the algorithm ADR 0003 selects +/// as the default. +/// +/// `tokens` is held in milli-tokens so a refill a sub-second rate produces is +/// never lost to truncation; `capacity` and the comparisons stay in whole +/// tokens, which is the currency the configuration and the headers report. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TokenBucket { + /// The tokens the bucket holds, in milli-tokens. + tokens_milli: u128, + /// The ceiling the refill is capped at, in whole tokens. + pub capacity: u64, + /// The refill rate, in milli-tokens per millisecond. + refill_per_milli: u128, + /// The instant the bucket was last read or refilled at. + pub updated: Instant, +} + +impl TokenBucket { + /// Initializes an absent bucket at full capacity, so a first burst is + /// admitted up to `burst.capacity` (§1.5). + #[must_use] + pub fn full(capacity: u64, sustained: &Sustained, now: Instant) -> Self { + Self { + tokens_milli: u128::from(capacity) * TOKEN_SCALE, + capacity, + refill_per_milli: refill_per_milli(sustained), + updated: now, + } + } + + /// The whole tokens the bucket holds after the last refill. + #[must_use] + pub fn tokens(&self) -> u64 { + u64::try_from(self.tokens_milli / TOKEN_SCALE).unwrap_or(u64::MAX) + } + + /// Adds the refill the elapsed time earns, capped at the capacity, and + /// stamps the update instant. + /// + /// A clock that moved backwards between two readings is no elapsed time at + /// all rather than a negative refill, so a clock adjustment adds no tokens + /// and removes none (§1.4). + pub fn refill(&mut self, now: Instant) { + let elapsed = now.saturating_duration_since(self.updated); + self.updated = now; + if elapsed.is_zero() { + return; + } + let millis = elapsed.as_millis(); + let earned = millis.saturating_mul(self.refill_per_milli); + let ceiling = u128::from(self.capacity) * TOKEN_SCALE; + self.tokens_milli = (self.tokens_milli + earned).min(ceiling); + } + + /// The whole-second delay until the bucket holds `cost`, rounded up. + /// + /// A `cost` the bucket cannot hold even when full saturates the shortfall, + /// which the refusal answers with the minimum delay of one second. + #[must_use] + pub fn delay_for(&self, cost: u64) -> u64 { + let shortfall = (u128::from(cost) * TOKEN_SCALE).saturating_sub(self.tokens_milli); + let millis = shortfall * TOKEN_SCALE / self.refill_per_milli / TOKEN_SCALE; + let seconds = millis / 1_000; + // Round up to a whole second, and never answer zero for a delay the + // bucket does need. + let whole = if millis.is_multiple_of(1_000) { seconds } else { seconds + 1 }; + u64::try_from(whole.max(1)).unwrap_or(u64::MAX) + } + + /// The whole-second delay until the bucket is full again, rounded up, which + /// is the `X-RateLimit-Reset` offset the header reports. + #[must_use] + pub fn full_in_seconds(&self) -> u64 { + let ceiling = u128::from(self.capacity) * TOKEN_SCALE; + if self.tokens_milli >= ceiling { + return 0; + } + let shortfall = ceiling - self.tokens_milli; + let millis = shortfall / self.refill_per_milli; + let seconds = millis / 1_000; + let whole = if millis.is_multiple_of(1_000) { seconds } else { seconds + 1 }; + u64::try_from(whole).unwrap_or(u64::MAX) + } +} + +/// One sliding window held for one counter key, the algorithm ADR 0003 prefers +/// where strict rate enforcement matters more than burst tolerance. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct SlidingWindow { + /// Every charge the window still holds, each with the instant it was + /// recorded at, oldest first. + charges: Vec<(Instant, u64)>, +} + +impl SlidingWindow { + /// Drops every charge whose instant falls outside the window length. + pub fn expire(&mut self, length: Duration, now: Instant) { + self.charges + .retain(|(at, _)| now.saturating_duration_since(*at) < length); + } + + /// The sum of the charges the window still holds. + #[must_use] + pub fn total(&self) -> u64 { + self.charges.iter().map(|(_, cost)| cost).sum() + } + + /// Records one charge at the current instant. + pub fn record(&mut self, cost: u64, now: Instant) { + self.charges.push((now, cost)); + } + + /// The whole-second delay until the window holds `cost`: the time until + /// enough of the oldest charges age out, rounded up. + #[must_use] + pub fn delay_for(&self, cost: u64, rate: u64, length: Duration, now: Instant) -> u64 { + let mut needed = u128::from(self.total() + cost) - u128::from(rate); + for (at, charge) in &self.charges { + let leaving = u128::from(*charge); + if leaving >= needed { + let waited = now.saturating_duration_since(*at); + let remaining = length.saturating_sub(waited); + let seconds = remaining.as_secs(); + return if remaining.subsec_nanos() == 0 { + seconds.max(1) + } else { + seconds + 1 + }; + } + needed -= leaving; + } + 1 + } + + /// The whole-second delay until the oldest charge ages out of the window, + /// rounded up, which is the `X-RateLimit-Reset` offset the header reports. + /// A window that holds no charge is already at its allowance. + #[must_use] + pub fn oldest_ages_out_in_seconds(&self, length: Duration, now: Instant) -> u64 { + let Some((oldest, _)) = self.charges.first() else { + return 0; + }; + let waited = now.saturating_duration_since(*oldest); + let remaining = length.saturating_sub(waited); + let seconds = remaining.as_secs(); + if remaining.subsec_nanos() == 0 { + seconds + } else { + seconds + 1 + } + } +} + +/// The verdict one acquisition attempt produced. +/// +/// A refused attempt records no charge, so the state it reports is the state +/// the next attempt is compared against. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AcquireOutcome { + /// Whether the counter covered the request's `cost`. + pub admitted: bool, + /// The amount the counter holds after the attempt, in the currency the + /// effective algorithm reports. + pub remaining: u64, + /// The whole-second delay until the counter holds the `cost`, which is the + /// `Retry-After` a refusal answers with; zero on an admission. + pub delay_seconds: u64, + /// The whole-second delay until the counter reaches its capacity again, + /// which is the `X-RateLimit-Reset` a refusal reports as an offset from the + /// wall clock; zero on an admission. + pub reset_seconds: u64, +} + +/// The layer whose `rate_limit` the effective limit came from, which is the +/// resource the counter key's prefix names. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LimitLayer { + /// The upstream layer won, so the prefix names the resolved upstream. + Upstream, + /// The route layer won, so the prefix names the matched route. + Route, +} + +/// The limit one request is enforced against, the fold's output. +/// +/// The sustained rate is the minimum of the visible layer rates, reported in +/// the winning layer's window; the capacity is the minimum of the visible +/// `burst.capacity` values or the sustained rate when no layer declares one; +/// and the four members that carry no merge come from the last layer that +/// declares each. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EffectiveLimit { + /// The effective sustained rate and its window. + pub sustained: Sustained, + /// The effective burst capacity. + pub burst_capacity: u64, + /// The algorithm the counter runs. + pub algorithm: Algorithm, + /// The counter scope. + pub scope: RateLimitScope, + /// The behaviour when the limit is exceeded. + pub strategy: crate::domain::upstream::Strategy, + /// The tokens one request costs. + pub cost: u64, + /// The layer whose `rate_limit` the sustained rate came from. + pub layer: LimitLayer, +} + +impl EffectiveLimit { + /// The default capacity: the sustained rate, which is the default ADR + /// 0003's field table declares for a `burst.capacity` no layer states. + #[must_use] + pub fn capacity(&self) -> u64 { + self.burst_capacity + } +} + +/// The layer values the resolution produced, in the order the fold applies +/// them. +/// +/// The upstream layer comes first and the route layer second; the tenant +/// layer's contributions arrive folded inside both by +/// `cpt-cf-oagw-algo-field-family-merge`, so the fold walks no chain and +/// applies no per-field merge strategy of its own. +#[derive(Debug, Clone, Copy, Default)] +pub struct LimitLayers<'a> { + /// The upstream layer value, ancestors included. + pub upstream: Option<&'a EffectiveRateLimit>, + /// The route layer value, ancestors included. + pub route: Option<&'a EffectiveRateLimit>, +} + +// @cpt-dod:cpt-cf-oagw-dod-rate-limit-hierarchy:p1 + +/// Folds the resolved layers into the one limit a request is enforced against. +/// +/// Returns `None` when no layer carries a `rate_limit` at all, which is the +/// outcome `cpt-cf-oagw-flow-rate-limit-check` enforces nothing on: an +/// unconfigured upstream is not silently limited by a default it never +/// declared. +#[must_use] +pub fn fold(layers: &LimitLayers<'_>) -> Option { + // @cpt-begin:cpt-cf-oagw-algo-effective-limit-fold:p1:inst-fold-collect + // Every layer value the resolution produced that a `private` ancestor did + // not withhold, in the order upstream, then route, then tenant. The merge + // withheld the private ones before the value arrived, so what is collected + // here is what is visible. + let visible: Vec<(&RateLimitConfig, LimitLayer)> = [ + layers.upstream.map(|limit| (&limit.rate_limit, LimitLayer::Upstream)), + layers.route.map(|limit| (&limit.rate_limit, LimitLayer::Route)), + ] + .into_iter() + .flatten() + .filter(|(limit, _)| limit.sustained.is_some()) + .collect(); + // @cpt-end:cpt-cf-oagw-algo-effective-limit-fold:p1:inst-fold-collect + + // @cpt-begin:cpt-cf-oagw-algo-effective-limit-fold:p1:inst-fold-none-if + if visible.is_empty() { + // @cpt-begin:cpt-cf-oagw-algo-effective-limit-fold:p1:inst-fold-none + // The no-limit outcome: no layer carries a `rate_limit`, so the check + // enforces nothing and charges nothing. + return None; + // @cpt-end:cpt-cf-oagw-algo-effective-limit-fold:p1:inst-fold-none + } + // @cpt-end:cpt-cf-oagw-algo-effective-limit-fold:p1:inst-fold-none-if + + // @cpt-begin:cpt-cf-oagw-algo-effective-limit-fold:p1:inst-fold-rate + // The minimum of the visible sustained rates, which the merge already + // normalized to one scale and reported in the winning layer's window; the + // comparison here is on that same scale, and the winner's own window is + // what the effective limit reports. + let mut winner: Option<(&RateLimitConfig, LimitLayer, &Sustained)> = None; + for (limit, layer) in &visible { + let Some(sustained) = limit.sustained.as_ref() else { + continue; + }; + let closer = match winner { + None => true, + Some((_, _, held)) => per_common_scale(sustained) < per_common_scale(held), + }; + if closer { + winner = Some((limit, *layer, sustained)); + } + } + let (_, layer, sustained) = winner?; + // @cpt-end:cpt-cf-oagw-algo-effective-limit-fold:p1:inst-fold-rate + + // @cpt-begin:cpt-cf-oagw-algo-effective-limit-fold:p1:inst-fold-burst + // The minimum of the visible `burst.capacity` values under the same mode + // gate, which is the merge ADR 0003's Example 1 performs beside the + // sustained one; the default is the sustained rate. + let burst_capacity = visible + .iter() + .filter_map(|(limit, _)| limit.burst.as_ref().map(|burst| burst.capacity)) + .min() + .unwrap_or(sustained.rate); + // @cpt-end:cpt-cf-oagw-algo-effective-limit-fold:p1:inst-fold-burst + + // @cpt-begin:cpt-cf-oagw-algo-effective-limit-fold:p1:inst-fold-members + // The four members that carry no merge come from the last layer that + // declares each, in the upstream, then route, then tenant order the + // layers are walked in, so the route layer's declaration prevails over the + // upstream layer's; a member no layer declares takes the default ADR + // 0003's field table declares. + let mut from_last_declaring = visible.iter().rev(); + // @cpt-end:cpt-cf-oagw-algo-effective-limit-fold:p1:inst-fold-members + + // @cpt-begin:cpt-cf-oagw-algo-effective-limit-fold:p1:inst-fold-return + // RETURN the effective limit and its four carried members. + Some(EffectiveLimit { + sustained: sustained.clone(), + burst_capacity, + algorithm: from_last_declaring + .clone() + .find_map(|(limit, _)| limit.algorithm) + .unwrap_or(Algorithm::TokenBucket), + scope: from_last_declaring + .clone() + .find_map(|(limit, _)| limit.scope) + .unwrap_or(RateLimitScope::Tenant), + strategy: from_last_declaring + .clone() + .find_map(|(limit, _)| limit.strategy) + .unwrap_or(Strategy::Reject), + cost: from_last_declaring + .find_map(|(limit, _)| limit.cost) + .unwrap_or(1), + layer, + }) + // @cpt-end:cpt-cf-oagw-algo-effective-limit-fold:p1:inst-fold-return +} + +// @cpt-dod:cpt-cf-oagw-dod-rate-limit-algorithms:p1 + +/// Runs one acquisition against a token bucket. +/// +/// The refill runs first, so a bucket that has been idle is read at its +/// refilled level and a refusal is measured against the tokens the bucket +/// holds now. +pub fn token_bucket(bucket: &mut TokenBucket, cost: u64, now: Instant) -> AcquireOutcome { + // @cpt-begin:cpt-cf-oagw-algo-token-bucket:p1:inst-tb-init-if + // @cpt-begin:cpt-cf-oagw-algo-token-bucket:p1:inst-tb-init + // An absent bucket is initialized at full capacity by the registry that + // holds it, which is this step read as the state it hands over; the bucket + // this routine receives is therefore never absent, and the refill below is + // its first act. + // @cpt-end:cpt-cf-oagw-algo-token-bucket:p1:inst-tb-init + // @cpt-end:cpt-cf-oagw-algo-token-bucket:p1:inst-tb-init-if + + // @cpt-begin:cpt-cf-oagw-algo-token-bucket:p1:inst-tb-refill + // Refill: the elapsed time since the bucket's last update multiplied by + // the refill rate, capped at the capacity, and stamped. + bucket.refill(now); + // @cpt-end:cpt-cf-oagw-algo-token-bucket:p1:inst-tb-refill + + // @cpt-begin:cpt-cf-oagw-algo-token-bucket:p1:inst-tb-compare + // Compare the refilled tokens against the request's `cost`. + let covered = bucket.tokens_milli >= u128::from(cost) * TOKEN_SCALE; + // @cpt-end:cpt-cf-oagw-algo-token-bucket:p1:inst-tb-compare + + // @cpt-begin:cpt-cf-oagw-algo-token-bucket:p1:inst-tb-allow-if + if covered { + // @cpt-begin:cpt-cf-oagw-algo-token-bucket:p1:inst-tb-allow + // Subtract the `cost` and report the admission and the tokens + // remaining. + bucket.tokens_milli -= u128::from(cost) * TOKEN_SCALE; + return AcquireOutcome { + admitted: true, + remaining: bucket.tokens(), + delay_seconds: 0, + reset_seconds: bucket.full_in_seconds(), + }; + // @cpt-end:cpt-cf-oagw-algo-token-bucket:p1:inst-tb-allow + } + // @cpt-end:cpt-cf-oagw-algo-token-bucket:p1:inst-tb-allow-if + + // @cpt-begin:cpt-cf-oagw-algo-token-bucket:p1:inst-tb-allow-else + // The ELSE of the comparison: the refusal, the tokens remaining, and the + // delay as the shortfall against the `cost` divided by the refill rate, + // rounded up to a whole second. A refused request records no charge. + // @cpt-begin:cpt-cf-oagw-algo-token-bucket:p1:inst-tb-refuse + let delay = bucket.delay_for(cost); + let reset = bucket.full_in_seconds(); + // @cpt-end:cpt-cf-oagw-algo-token-bucket:p1:inst-tb-refuse + // @cpt-end:cpt-cf-oagw-algo-token-bucket:p1:inst-tb-allow-else + AcquireOutcome { + admitted: false, + remaining: bucket.tokens(), + delay_seconds: delay, + reset_seconds: reset, + } +} + +/// Runs one acquisition against a token bucket whose burst reserve is +/// withheld, which is the `degrade` strategy's posture (§1.5). +/// +/// The reserve is the capacity above the sustained rate. The counter the +/// bucket holds is shared with every strategy the configuration names, so the +/// reserve is withheld for the attempt and never written back into the bucket: +/// a request that arrives while the reserve is withheld reads no more than the +/// sustained rate, and a token it spends is spent from the bucket itself. +pub fn token_bucket_capped( + bucket: &mut TokenBucket, + cost: u64, + ceiling: u64, + now: Instant, +) -> AcquireOutcome { + // The same refill and comparison as the uncapped acquisition, read through + // the reserve the ceiling withholds: the burst capacity above the + // sustained rate stays in the bucket and no acquisition may spend it, so + // the allowance the degraded posture leaves is the sustained rate and the + // reserve is what remains after it. + bucket.refill(now); + let reserve_milli = u128::from(bucket.capacity.saturating_sub(ceiling)) * TOKEN_SCALE; + let effective = bucket.tokens_milli.saturating_sub(reserve_milli); + let allowed = u64::try_from(effective / TOKEN_SCALE).unwrap_or(u64::MAX); + if effective >= u128::from(cost) * TOKEN_SCALE { + bucket.tokens_milli -= u128::from(cost) * TOKEN_SCALE; + return AcquireOutcome { + admitted: true, + remaining: allowed, + delay_seconds: 0, + reset_seconds: bucket.full_in_seconds(), + }; + } + AcquireOutcome { + admitted: false, + remaining: allowed, + delay_seconds: bucket.delay_for(cost), + reset_seconds: bucket.full_in_seconds(), + } +} + +/// Runs one acquisition against a sliding window. +pub fn sliding_window( + window: &mut SlidingWindow, + cost: u64, + rate: u64, + length: Duration, + now: Instant, +) -> AcquireOutcome { + // @cpt-begin:cpt-cf-oagw-algo-sliding-window:p1:inst-sw-expire + // Drop every charge recorded outside the window length, which is the + // conversion of the `second`, `minute`, `hour`, and `day` literals the + // shipped schema enumerates. + window.expire(length, now); + // @cpt-end:cpt-cf-oagw-algo-sliding-window:p1:inst-sw-expire + + // @cpt-begin:cpt-cf-oagw-algo-sliding-window:p1:inst-sw-sum + // Sum the charges that remain. + let total = window.total(); + // @cpt-end:cpt-cf-oagw-algo-sliding-window:p1:inst-sw-sum + + // @cpt-begin:cpt-cf-oagw-algo-sliding-window:p1:inst-sw-allow-if + if total + cost <= rate { + // @cpt-begin:cpt-cf-oagw-algo-sliding-window:p1:inst-sw-allow + // Record the `cost` at the current instant and report the admission + // and the new total. + window.record(cost, now); + return AcquireOutcome { + admitted: true, + remaining: rate - (total + cost), + delay_seconds: 0, + reset_seconds: window.oldest_ages_out_in_seconds(length, now), + }; + // @cpt-end:cpt-cf-oagw-algo-sliding-window:p1:inst-sw-allow + } + // @cpt-end:cpt-cf-oagw-algo-sliding-window:p1:inst-sw-allow-if + + // @cpt-begin:cpt-cf-oagw-algo-sliding-window:p1:inst-sw-allow-else + // The ELSE of the comparison: the refusal, the current total, and the + // delay as the time until the oldest recorded charge ages out enough to + // admit the `cost`. A refused request records no charge and so never + // extends the window against itself. + // @cpt-begin:cpt-cf-oagw-algo-sliding-window:p1:inst-sw-refuse + let delay = window.delay_for(cost, rate, length, now); + // @cpt-end:cpt-cf-oagw-algo-sliding-window:p1:inst-sw-refuse + // @cpt-end:cpt-cf-oagw-algo-sliding-window:p1:inst-sw-allow-else + AcquireOutcome { + admitted: false, + remaining: rate - total, + delay_seconds: delay, + reset_seconds: window.oldest_ages_out_in_seconds(length, now), + } +} + +/// The budget mode a parent configuration declares, which ADR 0003 gives three +/// of. +/// +/// No written configuration can carry a `budget` member (§1.5), so the modes +/// and their arithmetic are exercised at the domain layer and through this +/// routine's colocated tests, and they become reachable from a written +/// configuration only when a schema revision admits the member. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BudgetMode { + /// Tracks nothing: the mode ADR 0003 declares as the default for a leaf + /// tenant. + Unlimited, + /// Gives each child a fixed slice of the parent's budget. + Allocated, + /// Lets the children draw on the parent's total first-come-first-served, + /// with no individual guarantee. + Shared, +} + +/// The budget object a parent configuration declares. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BudgetAllocation { + /// The parent's total, which the shipped field table sets a minimum of 1. + pub total: u64, + /// The ratio the children's sum may reach, which the same table sets a + /// minimum of 1.0; held scaled by 100 so the arithmetic stays integer, so + /// `150` is a ratio of 1.5. + pub overcommit_ratio_percent: u64, +} + +/// The outcome of one budget validation or charge. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BudgetOutcome { + /// No tracking performed and nothing to validate. + Unlimited, + /// The children's sum fits the parent's ceiling. + Accepted { + /// The sum of the declared allocations and the candidate. + allocated: u64, + /// Whether the sum exceeds the parent's `total` while still fitting + /// the ratio's ceiling, which is the warning ADR 0003's worked + /// arithmetic shows for a ratio above 1.0. + over_total: bool, + }, + /// The sum exceeds the parent's ceiling. + Rejected { + /// The sum that was rejected. + allocated: u64, + }, + /// The charge was taken from the parent's pool. + Shared { + /// The amount the pool still holds after the charge. + remaining: u64, + }, +} + +/// Validates a child allocation against its parent's budget, or charges one +/// request against a `shared` pool. +/// +/// A parent with no budget at all is treated as `unlimited` rather than as a +/// rejection, because a mode that tracks nothing cannot be exceeded; the +/// write path answers a [`BudgetOutcome::Rejected`] 400 through the +/// foundation's `ValidationError` variant. +#[must_use] +pub fn allocate_budget( + parent: Option, + mode: BudgetMode, + pool_remaining: u64, + children_sum: u64, + candidate: u64, +) -> BudgetOutcome { + // A parent whose budget is absent while a child declares an allocation is + // a configuration the merged resolution would not have produced, and the + // error handling of this routine treats it as `unlimited` rather than as a + // rejection, because a mode that tracks nothing cannot be exceeded. + let tracked = mode != BudgetMode::Unlimited && parent.is_some(); + + // @cpt-begin:cpt-cf-oagw-algo-budget-allocate:p1:inst-bud-unlimited-if + if !tracked { + // @cpt-begin:cpt-cf-oagw-algo-budget-allocate:p1:inst-bud-unlimited + // RETURN the no-tracking outcome, no validation performed, which is + // the mode ADR 0003 declares as the default for a leaf tenant. + return BudgetOutcome::Unlimited; + // @cpt-end:cpt-cf-oagw-algo-budget-allocate:p1:inst-bud-unlimited + } + // @cpt-end:cpt-cf-oagw-algo-budget-allocate:p1:inst-bud-unlimited-if + + // @cpt-begin:cpt-cf-oagw-algo-budget-allocate:p1:inst-bud-allocated-if + if mode == BudgetMode::Allocated { + let Some(parent) = parent else { + return BudgetOutcome::Unlimited; + }; + // @cpt-begin:cpt-cf-oagw-algo-budget-allocate:p1:inst-bud-sum + // Sum the children's declared allocations together with the one under + // consideration. + let sum = children_sum.saturating_add(candidate); + // @cpt-end:cpt-cf-oagw-algo-budget-allocate:p1:inst-bud-sum + + // @cpt-begin:cpt-cf-oagw-algo-budget-allocate:p1:inst-bud-over-if + let ceiling = parent.total.saturating_mul(parent.overcommit_ratio_percent) / 100; + if sum > ceiling { + // @cpt-begin:cpt-cf-oagw-algo-budget-allocate:p1:inst-bud-over + // RETURN the rejection, which the write path answers 400. + return BudgetOutcome::Rejected { allocated: sum }; + // @cpt-end:cpt-cf-oagw-algo-budget-allocate:p1:inst-bud-over + } + // @cpt-end:cpt-cf-oagw-algo-budget-allocate:p1:inst-bud-over-if + + // @cpt-begin:cpt-cf-oagw-algo-budget-allocate:p1:inst-bud-over-else + // The ELSE of the ceiling check: the acceptance, with the warning + // recorded when the sum exceeds the parent's `total` but not the + // ratio's ceiling, which is the outcome ADR 0003's worked arithmetic + // shows for a ratio above 1.0. + // @cpt-begin:cpt-cf-oagw-algo-budget-allocate:p1:inst-bud-accept + return BudgetOutcome::Accepted { + allocated: sum, + over_total: sum > parent.total, + }; + // @cpt-end:cpt-cf-oagw-algo-budget-allocate:p1:inst-bud-accept + // @cpt-end:cpt-cf-oagw-algo-budget-allocate:p1:inst-bud-over-else + } + // @cpt-end:cpt-cf-oagw-algo-budget-allocate:p1:inst-bud-allocated-if + + // @cpt-begin:cpt-cf-oagw-algo-budget-allocate:p1:inst-bud-shared-if + // The `shared` mode: the request's `cost` is charged against the parent's + // pool counter with no per-child allocation validated, which is the + // first-come-first-served behaviour ADR 0003 states for the mode. + // @cpt-begin:cpt-cf-oagw-algo-budget-allocate:p1:inst-bud-shared + // RETURN the amount the pool still holds. + BudgetOutcome::Shared { + remaining: pool_remaining.saturating_sub(candidate), + } + // @cpt-end:cpt-cf-oagw-algo-budget-allocate:p1:inst-bud-shared + // @cpt-end:cpt-cf-oagw-algo-budget-allocate:p1:inst-bud-shared-if +} + +/// The phase of one circuit-breaker machine, the one state machine this +/// feature owns. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum BreakerPhase { + /// The upstream is in rotation and every attempt is admitted. + #[default] + Closed, + /// The upstream is out of rotation and every attempt is answered 503. + Open, + /// The open interval elapsed and one probe is in flight. + HalfOpen, +} + +/// One transition the breaker machine moved through, reported by the move +/// itself. +/// +/// The pair is what `oagw_circuit_breaker_transitions_total` is incremented +/// with, read from the machine that made the move and never re-derived by the +/// reader. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BreakerTransition { + /// The phase the machine moved from. + pub from: BreakerPhase, + /// The phase the machine moved to. + pub to: BreakerPhase, +} + +/// The rolling window, the open stamp, and the probe bound of one upstream's +/// breaker, which is what the `oagw_circuit_breaker_state` gauge reports +/// against the upstream alias. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CircuitBreakerState { + /// The phase the machine holds. + pub phase: BreakerPhase, + /// The instants the counted failures of the rolling window were recorded + /// at, oldest first. + pub failures: Vec, + /// The instant the current open interval began. + pub open_since: Option, + /// The transitions the machine has reported since a reader last drained + /// them, oldest first, bounded so a machine that moves between reads + /// cannot grow the record without limit. + reported: Vec, +} + +/// The most transitions one machine holds between two reads. +const BREAKER_REPORTED_CAPACITY: usize = 64; + +impl Default for CircuitBreakerState { + fn default() -> Self { + Self { + phase: BreakerPhase::Closed, + failures: Vec::new(), + open_since: None, + reported: Vec::new(), + } + } +} + +impl CircuitBreakerState { + /// Moves the machine to a phase, reporting the move it made. + /// + /// A move to the phase the machine already holds is not a transition and + /// is reported as none, so a reader that drains the log counts moves and + /// not attempts. + fn move_to(&mut self, to: BreakerPhase) { + let from = self.phase; + self.phase = to; + if from != to && self.reported.len() < BREAKER_REPORTED_CAPACITY { + self.reported.push(BreakerTransition { from, to }); + } + } + + /// The transitions the machine reported since the last read, oldest first, + /// draining them: a reader that takes the log takes the whole of it, so no + /// transition is reported twice and none is held after its read. + pub fn drain_reported(&mut self) -> Vec { + std::mem::take(&mut self.reported) + } + + /// Whether the machine admits one attempt right now. + /// + /// An `open` machine whose interval has elapsed moves to `half_open` and + /// grants the attempt that moved it the single probe; a `half_open` + /// machine admits nothing further until that probe resolves, so every + /// concurrent request for the same upstream is answered 503 (§1.5). + pub fn admit(&mut self, now: Instant) -> bool { + // @cpt-begin:cpt-cf-oagw-state-circuit-breaker:p1:inst-cb-probe + // The open interval elapsed: the machine moves to `half_open`, admits + // one probe — the attempt that asked — and no more. + if self.phase == BreakerPhase::Open { + let served = self + .open_since + .is_none_or(|since| now.saturating_duration_since(since) >= BREAKER_OPEN_INTERVAL); + if served { + self.move_to(BreakerPhase::HalfOpen); + return true; + } + return false; + } + self.phase == BreakerPhase::Closed + // @cpt-end:cpt-cf-oagw-state-circuit-breaker:p1:inst-cb-probe + } + + /// The seconds remaining of the open interval, which the 503 answer + /// carries as its `Retry-After`. + #[must_use] + pub fn retry_after_seconds(&self, now: Instant) -> u64 { + match (self.phase, self.open_since) { + (BreakerPhase::Open, Some(since)) => { + let served = now.saturating_duration_since(since); + BREAKER_OPEN_INTERVAL + .saturating_sub(served) + .as_secs() + .max(1) + } + _ => 1, + } + } + + /// Records one attempt outcome and returns the phase the machine holds. + /// + /// `succeeded` is whether the attempt produced an answer from the target + /// at all; `counted` is whether that answer is one of the three rows the + /// §1.5 deviation enumerates, the only rows evidence about the target's + /// reachability is made of. + pub fn count(&mut self, succeeded: bool, counted: bool, now: Instant) -> BreakerPhase { + // @cpt-begin:cpt-cf-oagw-algo-breaker-count:p1:inst-brc-absent-if + if !succeeded && !counted { + // @cpt-begin:cpt-cf-oagw-algo-breaker-count:p1:inst-brc-absent + // No evidence, no change: the breaker never opens on the absence + // of a classification (§1.5). The one exception is the stall: a + // `half_open` machine that never receives its probe's outcome + // returns to `open` when the open interval elapses again counted + // from the same stamp the transition read, so one lost + // classification cannot hold the upstream at 503 until a restart. + if self.phase == BreakerPhase::HalfOpen + && let Some(since) = self.open_since + && now.saturating_duration_since(since) >= 2 * BREAKER_OPEN_INTERVAL + { + self.move_to(BreakerPhase::Open); + } + return self.phase; + // @cpt-end:cpt-cf-oagw-algo-breaker-count:p1:inst-brc-absent + } + // @cpt-end:cpt-cf-oagw-algo-breaker-count:p1:inst-brc-absent-if + + // @cpt-begin:cpt-cf-oagw-algo-breaker-count:p1:inst-brc-success-if + if succeeded { + // @cpt-begin:cpt-cf-oagw-algo-breaker-count:p1:inst-brc-success + // Clear the failure count, and return a `half_open` machine to + // `closed`: the probe earned the upstream its way back. + self.failures.clear(); + if self.phase == BreakerPhase::HalfOpen { + // @cpt-begin:cpt-cf-oagw-state-circuit-breaker:p1:inst-cb-recover + // The probe attempt succeeded, so the machine returns to + // `closed` and drops the open-interval stamp with it. + self.move_to(BreakerPhase::Closed); + self.open_since = None; + // @cpt-end:cpt-cf-oagw-state-circuit-breaker:p1:inst-cb-recover + } + return self.phase; + // @cpt-end:cpt-cf-oagw-algo-breaker-count:p1:inst-brc-success + } + // @cpt-end:cpt-cf-oagw-algo-breaker-count:p1:inst-brc-success-if + + // @cpt-begin:cpt-cf-oagw-algo-breaker-count:p1:inst-brc-fail-if + // @cpt-begin:cpt-cf-oagw-algo-breaker-count:p1:inst-brc-record + // Append the failure to the rolling window and drop the entries older + // than the 30 seconds PRD §6.1 states. + self.failures.push(now); + self.failures + .retain(|at| now.saturating_duration_since(*at) <= BREAKER_FAILURE_WINDOW); + // @cpt-end:cpt-cf-oagw-algo-breaker-count:p1:inst-brc-record + // @cpt-end:cpt-cf-oagw-algo-breaker-count:p1:inst-brc-fail-if + + // @cpt-begin:cpt-cf-oagw-algo-breaker-count:p1:inst-brc-trip-if + if self.phase == BreakerPhase::Closed && self.failures.len() >= BREAKER_FAILURE_THRESHOLD + { + // @cpt-begin:cpt-cf-oagw-state-circuit-breaker:p1:inst-cb-trip + // @cpt-begin:cpt-cf-oagw-algo-breaker-count:p1:inst-brc-trip + // Move the machine to `open` and stamp the instant the open + // interval began, which is the trip of PRD §6.1's threshold. + self.move_to(BreakerPhase::Open); + self.open_since = Some(now); + // @cpt-end:cpt-cf-oagw-algo-breaker-count:p1:inst-brc-trip + // @cpt-end:cpt-cf-oagw-state-circuit-breaker:p1:inst-cb-trip + } + // @cpt-end:cpt-cf-oagw-algo-breaker-count:p1:inst-brc-trip-if + + // @cpt-begin:cpt-cf-oagw-state-circuit-breaker:p1:inst-cb-stay-open + // A further failure recorded while the machine is open neither + // re-trips it into a new classification nor extends the interval it is + // already serving: the stamp stays the instant the trip was recorded + // at, and the next admission is measured from it. + // @cpt-end:cpt-cf-oagw-state-circuit-breaker:p1:inst-cb-stay-open + + // @cpt-begin:cpt-cf-oagw-state-circuit-breaker:p1:inst-cb-reopen + // A probe that failed, or a counted failure recorded while the machine + // is already open: the machine returns to `open` and the open interval + // restarts from its beginning. A further failure while the machine is + // open neither re-trips it into a new classification nor extends the + // interval it is already serving, because the stamp is the instant the + // failure was recorded at and the next admission is measured from it. + if self.phase == BreakerPhase::HalfOpen { + self.move_to(BreakerPhase::Open); + self.open_since = Some(now); + } + // @cpt-end:cpt-cf-oagw-state-circuit-breaker:p1:inst-cb-reopen + + // @cpt-begin:cpt-cf-oagw-state-circuit-breaker:p1:inst-cb-stall + // A `half_open` machine whose probe outcome never arrives returns to + // `open` when the open interval elapses again counted from the same + // stamp the probe transition read, which the next [`Self::admit`] + // enforces, so one lost classification is a re-probe and not a + // permanent outage. + // @cpt-end:cpt-cf-oagw-state-circuit-breaker:p1:inst-cb-stall + + // @cpt-begin:cpt-cf-oagw-algo-breaker-count:p1:inst-brc-else + // The ELSE of the classification chain. + // @cpt-begin:cpt-cf-oagw-algo-breaker-count:p1:inst-brc-ignore + // A 4xx answer, an upstream error status passed through as + // `DownstreamError`, and a 429 this feature produced are not evidence + // about the target's reachability, so a `closed` machine that receives + // one changes nothing (§1.5). + // @cpt-end:cpt-cf-oagw-algo-breaker-count:p1:inst-brc-ignore + // @cpt-end:cpt-cf-oagw-algo-breaker-count:p1:inst-brc-else + + // @cpt-begin:cpt-cf-oagw-algo-breaker-count:p1:inst-brc-return + // RETURN the resulting state. + self.phase + // @cpt-end:cpt-cf-oagw-algo-breaker-count:p1:inst-brc-return + } +} + +/// The per-instance in-process registry ADR 0006 assigns to the Data Plane. +/// +/// Every bucket, window, budget pool, and breaker machine the gear holds is +/// keyed under the `{resource_type}:{resource_id}` prefix ADR 0003's key +/// structure gives, so two upstreams limited at the same `scope` never share a +/// counter and a prefix drop has exactly one owner. Nothing here is persisted, +/// and the restart loss of a counter is accepted (§1.5). +#[derive(Debug, Default)] +pub struct RateLimiterRegistry { + buckets: std::collections::HashMap, + windows: std::collections::HashMap, + breakers: std::collections::HashMap, + pools: std::collections::HashMap, + /// The in-process queue of the `queue` strategy, one slot per held request + /// with the instant it was enqueued at. + queue: std::collections::HashMap>, +} + +impl RateLimiterRegistry { + /// An empty registry. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// The counter key of one request: the `{resource_type}:{resource_id}` + /// prefix of the resource whose `rate_limit` the effective limit came + /// from, followed by the scope, the scope's identifier, and the effective + /// window (§1.5). + #[must_use] + pub fn counter_key( + resource_type: &str, + resource_id: &str, + scope: RateLimitScope, + scope_id: &str, + window: Option, + ) -> String { + format!( + "{resource_type}:{resource_id}:{scope:?}:{scope_id}:{:?}", + window_millis(window) + ) + } + + /// The bucket held for a key, initializing an absent one at full capacity + /// so a first burst is admitted up to `burst.capacity`. + pub fn bucket( + &mut self, + key: &str, + capacity: u64, + sustained: &Sustained, + now: Instant, + ) -> &mut TokenBucket { + self.buckets + .entry(key.to_owned()) + .or_insert_with(|| TokenBucket::full(capacity, sustained, now)) + } + + /// The window held for a key. + pub fn window(&mut self, key: &str) -> &mut SlidingWindow { + self.windows.entry(key.to_owned()).or_default() + } + + /// The breaker machine held for an upstream, initializing an absent one at + /// `closed`, which is the posture of a target whose configuration was + /// rewritten. + pub fn breaker(&mut self, upstream_prefix: &str) -> &mut CircuitBreakerState { + self.breakers + .entry(upstream_prefix.to_owned()) + .or_default() + } + + /// The budget pool held for a key. + pub fn pool(&mut self, key: &str, total: u64) -> &mut u64 { + self.pools.entry(key.to_owned()).or_insert(total) + } + + /// The number of requests the queue for a key holds. + #[must_use] + pub fn queue_len(&self, key: &str) -> usize { + self.queue.get(key).map_or(0, Vec::len) + } + + /// Enqueues one request's slot for a key. + /// + /// Returns `false` — and takes no slot — when the queue for the key + /// already holds its count bound, so the bound is a property of the + /// strategy and not a condition the caller can wait out. + pub fn enqueue(&mut self, key: &str, now: Instant) -> bool { + let slots = self.queue.entry(key.to_owned()).or_default(); + if slots.len() >= QUEUE_CAPACITY { + return false; + } + slots.push(now); + true + } + + /// Removes one request's slot, which a released, an expired, or a + /// disconnected request leaves the queue through; its slot returns to the + /// bound and it is charged nothing by the removal itself. + pub fn dequeue(&mut self, key: &str) { + if let Some(slots) = self.queue.get_mut(key) { + slots.pop(); + if slots.is_empty() { + self.queue.remove(key); + } + } + } + + /// Drops the expired slots of a key's queue: the requests that outwaited + /// the wait bound, which are answered 429 by their own wait and charged + /// nothing. + /// + /// Returns the number of slots the queue dropped, which is a diagnostic + /// value and no condition any caller branches on. + pub fn dequeue_expired(&mut self, key: &str, now: Instant) -> usize { + let Some(slots) = self.queue.get_mut(key) else { + return 0; + }; + let before = slots.len(); + slots.retain(|at| now.saturating_duration_since(*at) < QUEUE_WAIT); + let dropped = before - slots.len(); + if slots.is_empty() { + self.queue.remove(key); + } + dropped + } + + /// Drops every entry whose key begins with the given prefix and returns + /// the number of entries dropped, which is a diagnostic value and no + /// condition any caller branches on. + pub fn drop_prefix(&mut self, prefix: &str) -> usize { + let mut dropped = 0; + let before = self.buckets.len(); + self.buckets.retain(|key, _| !key.starts_with(prefix)); + dropped += before - self.buckets.len(); + let before = self.windows.len(); + self.windows.retain(|key, _| !key.starts_with(prefix)); + dropped += before - self.windows.len(); + let before = self.breakers.len(); + self.breakers.retain(|key, _| !key.starts_with(prefix)); + dropped += before - self.breakers.len(); + let before = self.pools.len(); + self.pools.retain(|key, _| !key.starts_with(prefix)); + dropped += before - self.pools.len(); + let before = self.queue.len(); + self.queue.retain(|key, _| !key.starts_with(prefix)); + dropped += before - self.queue.len(); + dropped + } +} diff --git a/gears/system/oagw/oagw/src/domain/route.rs b/gears/system/oagw/oagw/src/domain/route.rs new file mode 100644 index 0000000..277714e --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/route.rs @@ -0,0 +1,126 @@ +//! `Route` aggregate and its match configuration. +//! +//! Mirrors `schemas/route.v1.schema.json` property for property, plus the +//! §1.5 additions the shipped schema omits: the route-level `cors` object and +//! the `priority` and `enabled` attributes of the DESIGN §3.1 Route class. +//! Layering: no transport or persistence type appears here. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::domain::error::ModelError; +use crate::domain::upstream::{CorsConfig, PluginsConfig, RateLimitConfig}; + +/// How the proxy URL's `/{path_suffix}` is treated. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PathSuffixMode { + /// Rejects path suffix usage. + Disabled, + /// Appends it to `path`. + Append, +} + +/// HTTP match rules, used when the upstream protocol is HTTP. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HttpMatch { + /// HTTP methods supported by this route. + pub methods: Vec, + /// Path pattern for the route. + pub path: String, + /// Allow-listed query parameters. If empty, allow none. + #[serde(default)] + pub query_allowlist: Vec, + /// How to treat `/{path_suffix}` from the proxy URL. + #[serde(default)] + pub path_suffix_mode: Option, +} + +/// gRPC match rules, used when the upstream protocol is gRPC. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GrpcMatch { + /// Fully qualified gRPC service name, e.g. `foo.v1.UserService`. + pub service: String, + /// RPC method name, e.g. `GetUser`. + pub method: String, +} + +/// Protocol-scoped inbound matching rules. Exactly one of `http`/`grpc` must +/// be present. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MatchConfig { + /// HTTP match rules. + #[serde(default)] + pub http: Option, + /// gRPC match rules. + #[serde(default)] + pub grpc: Option, +} + +impl MatchConfig { + /// Validates the `oneOf` the schema states over `http` and `grpc`. + /// + /// # Errors + /// + /// Returns [`ModelError::AmbiguousMatch`] when neither or both of `http` + /// and `grpc` are present. + pub fn validate(&self) -> Result<(), ModelError> { + match (self.http.is_some(), self.grpc.is_some()) { + (true, false) | (false, true) => Ok(()), + _ => Err(ModelError::AmbiguousMatch), + } + } +} + +/// The `Route` aggregate: belongs to an upstream and defines match rules plus +/// the route-level overrides. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Route { + /// System-generated unique identifier. + pub id: Uuid, + /// Reference to the upstream service for this route; required by the + /// schema. + pub upstream_id: Uuid, + /// Protocol-scoped inbound matching rules; required by the schema. The + /// wire key stays `match`. + #[serde(rename = "match")] + pub match_config: MatchConfig, + /// Plugin chain. + #[serde(default)] + pub plugins: Option, + /// Rate limiting configuration. + #[serde(default)] + pub rate_limit: Option, + /// Flat tags for categorization and discovery. + #[serde(default)] + pub tags: Vec, + /// Route-level CORS configuration, added per §1.5. + #[serde(default)] + pub cors: Option, + /// Match-uniqueness ordering, added per §1.5. + #[serde(default)] + pub priority: Option, + /// Enable/disable semantics, added per §1.5. + #[serde(default)] + pub enabled: Option, +} + +impl Route { + /// Validates the structural invariants the shipped schema states. + /// + /// # Errors + /// + /// Returns [`ModelError::AmbiguousMatch`] when `match` does not carry + /// exactly one of `http` or `grpc`. + pub fn validate(&self) -> Result<(), ModelError> { + self.match_config.validate() + } +} diff --git a/gears/system/oagw/oagw/src/domain/scheme.rs b/gears/system/oagw/oagw/src/domain/scheme.rs new file mode 100644 index 0000000..cd9e91a --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/scheme.rs @@ -0,0 +1,66 @@ +//! `Scheme` value object — the write-time endpoint scheme literal. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +/// Endpoint scheme literal carried by an OAGW endpoint. +/// +/// The single write-time admission predicate lives on this type +/// ([`Scheme::is_write_admitted`]): `http` is legal only when the operator +/// lifted the posture with `oagw.config.allow_http_upstream`, every other +/// literal is always legal. Dialing is a different check owned by the data +/// plane feature and is deliberately not expressed here. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Scheme { + /// Plaintext HTTP. Admitted at write time only when + /// `oagw.config.allow_http_upstream` is `true`. + Http, + /// HTTP over TLS. Always admitted. + Https, + /// WebSocket over TLS. Always admitted. + Wss, + /// WebTransport. Always admitted. + Wt, + /// gRPC. Always admitted. + Grpc, +} + +impl Scheme { + /// Lowercase wire literal for this scheme. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Http => "http", + Self::Https => "https", + Self::Wss => "wss", + Self::Wt => "wt", + Self::Grpc => "grpc", + } + } + + /// Write-time admission predicate: `true` when this scheme literal may be + /// stored on a configured endpoint. + /// + /// `Scheme::Http` is admitted only when `allow_http_upstream` is `true`; + /// every other literal is admitted unconditionally. Recording the lifted + /// posture on the configuration does not by itself authorize a plaintext + /// dial. + #[must_use] + pub const fn is_write_admitted(self, allow_http_upstream: bool) -> bool { + // @cpt-begin:cpt-cf-oagw-algo-config-load-validate:p1:inst-config-http-if + match self { + Self::Http => allow_http_upstream, + Self::Https | Self::Wss | Self::Wt | Self::Grpc => true, + } + // @cpt-end:cpt-cf-oagw-algo-config-load-validate:p1:inst-config-http-if + } +} + +impl fmt::Display for Scheme { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} diff --git a/gears/system/oagw/oagw/src/domain/stream.rs b/gears/system/oagw/oagw/src/domain/stream.rs new file mode 100644 index 0000000..089d684 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/stream.rs @@ -0,0 +1,689 @@ +//! The streaming entities and the domain routines of the `oagw` gear. +//! +//! Realizes `cpt-cf-oagw-dod-stream-entities`, `cpt-cf-oagw-dod-stream-lifecycle`, +//! `cpt-cf-oagw-dod-stream-upgrade`, `cpt-cf-oagw-dod-stream-timeouts`, and +//! `cpt-cf-oagw-dod-stream-errors`: the two entities DECOMPOSITION §2.8 assigns +//! this entry, the one state machine it owns, and the parts of the §3 routines +//! that read no socket — [`upgrade_detection`] and [`select_mode`] of +//! `cpt-cf-oagw-algo-stream-mode-select`, and the suspension and the 101 +//! judgement of `cpt-cf-oagw-algo-upgrade-handshake`. The third routine, +//! `cpt-cf-oagw-algo-stream-pump`, moves bytes between two live connection +//! halves and lives in the data-plane layer beside +//! `cpt-cf-oagw-algo-outbound-forward`, which opened the upstream half; the +//! [`StreamHalf`] values a session carries are the description of those halves +//! that layer updates, and never a socket, a stream, or a body type. +//! +//! The lifecycle state a session carries is a state of +//! `cpt-cf-oagw-state-stream-lifecycle` and not a third type beside the two +//! entities, which is why [`StreamLifecycle`] is the machine and +//! [`StreamSession`] only holds one of its states. + +use std::time::Duration; + +use uuid::Uuid; + +use crate::domain::error::{DomainError, ErrorKind}; +use crate::domain::proxy::ProxyContext; + +// @cpt-dod:cpt-cf-oagw-dod-stream-entities:p1 + +/// The idle deadline of every streaming exchange, in seconds. +/// +/// A build-time constant of this feature with no configuration surface and no +/// sourced value (`cpt-cf-oagw-dod-stream-timeouts`, §1.5): no key of +/// `OagwConfig` carries it, no upstream or route configuration reaches it, and +/// the session reads it from this constant and from nothing else. +pub const IDLE_TIMEOUT_SECS: u64 = 60; + +/// The idle deadline of every streaming exchange. +/// +/// It measures the absence of traffic in either direction and never the +/// duration of the exchange, because the pump resets it on every byte that +/// moves. +pub const IDLE_TIMEOUT: Duration = Duration::from_secs(IDLE_TIMEOUT_SECS); + +/// The request headers a WebSocket handshake is judged by. +/// +/// These four reach the upstream on a detected upgrade request regardless of +/// the resolved `headers.request.passthrough` mode, including at that mode's +/// shipped default of `none` (§1.5). The first two are the handshake's +/// required fields and the last two are forwarded when the caller offered +/// them; no other inbound header is admitted by the suspension. +pub const HANDSHAKE_HEADERS: [&str; 4] = [ + "sec-websocket-key", + "sec-websocket-version", + "sec-websocket-extensions", + "sec-websocket-protocol", +]; + +/// Which of the two connection halves of a session a [`StreamHalf`] describes. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HalfSide { + /// The caller's half, held by the platform's inbound handler. + Caller, + /// The upstream's half, opened by `cpt-cf-oagw-algo-outbound-forward`. + Upstream, +} + +/// One of the two connection halves a [`StreamSession`] describes. +/// +/// A half is carried as the fact that it is open and not as the connection +/// itself, which is what keeps the entity free of transport types: the data +/// plane holds the connections and updates this member as it tears each one +/// down. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StreamHalf { + /// Which half of the exchange this is. + pub side: HalfSide, + /// Whether the half still carries bytes. + pub open: bool, +} + +impl StreamHalf { + /// The caller's half of a session that has just been opened. + #[must_use] + pub fn caller() -> Self { + Self { + side: HalfSide::Caller, + open: true, + } + } + + /// The upstream's half of a session that has just been opened. + #[must_use] + pub fn upstream() -> Self { + Self { + side: HalfSide::Upstream, + open: true, + } + } +} + +/// How a response body moves: as a bidirectional tunnel, or as a one-way +/// sequence of chunks the upstream emits. +/// +/// The mode has exactly two values and no third one that buffers a complete +/// response body, because `cpt-cf-oagw-principle-no-cache` forbids holding a +/// response (§1.5). +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransferMode { + /// The body of a taken-up handshake, moved in both directions between the + /// two halves and framed by nothing. + Tunnel, + /// Every other body, moved upstream to caller and flushed as it arrives. + Incremental, +} + +/// How a streaming exchange ended, recorded on the session that ended. +/// +/// The two teardown directions are clean closes and are not error answers; +/// the other two are the only outcomes this feature answers through +/// [`answer_of`]. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StreamOutcome { + /// The caller disconnected: the gateway closed the upstream half. + ClientDisconnected, + /// The upstream closed its half: the gateway closed the caller's half. + UpstreamClosed, + /// No byte moved in either direction for the idle deadline. + Stalled, + /// A half failed while bytes were still expected, whichever side. + Aborted, +} + +/// Why an exchange ended without a transfer, recorded on the session the +/// handshake opened in `Opening`. +/// +/// A session that never opened has no bytes in flight and takes the refusal +/// transition instead of the teardown ones, which is the only reason this +/// value exists: the answer the caller receives for such an exchange comes +/// from the routine that reports the failure, and never from [`answer_of`]. +// @cpt-dod:cpt-cf-oagw-dod-stream-lifecycle:p1 +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StreamLifecycle { + /// The outbound handshake request has been sent and no answer has arrived. + Opening, + /// The upstream took the handshake up, or the response headers of a body + /// transfer have arrived. + Open, + /// One side signalled the end and the other half is being torn down. + Closing, + /// Both halves are torn down and the outcome is recorded. + Closed, +} + +/// Why a lifecycle transition was refused. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("{transition} is not a transition of the {from:?} state")] +pub struct StreamTransition { + /// The state the refused transition was taken from. + pub from: StreamLifecycle, + /// The name of the refused transition. + pub transition: &'static str, +} + +impl StreamLifecycle { + /// The `Opening` to `Open` transition, on the answer that takes the + /// handshake up. + /// + /// # Errors + /// Returns [`StreamTransition`] for every state but `Opening`: a session + /// that is not in `Opening` has no handshake in flight to take up, and + /// `Closed` is terminal. + pub fn opened(self) -> Result { + // @cpt-begin:cpt-cf-oagw-state-stream-lifecycle:p1:inst-state-open + match self { + Self::Opening => Ok(Self::Open), + other => Err(StreamTransition { + from: other, + transition: "opened", + }), + } + // @cpt-end:cpt-cf-oagw-state-stream-lifecycle:p1:inst-state-open + } + + /// The `Opening` to `Closed` transition, on a refusal or a failure that + /// happened before any byte moved. + /// + /// # Errors + /// Returns [`StreamTransition`] for every state but `Opening`: a session + /// that opened takes the teardown transitions instead, and `Closed` is + /// terminal. + pub fn refused(self) -> Result { + // @cpt-begin:cpt-cf-oagw-state-stream-lifecycle:p1:inst-state-refused + match self { + Self::Opening => Ok(Self::Closed), + other => Err(StreamTransition { + from: other, + transition: "refused", + }), + } + // @cpt-end:cpt-cf-oagw-state-stream-lifecycle:p1:inst-state-refused + } + + /// The `Open` to `Closing` transition, on one side signalling the end. + /// + /// # Errors + /// Returns [`StreamTransition`] for every state but `Open`: a session that + /// never opened has no bytes in flight to drain and takes the refusal + /// transition instead, and a session that is closing is already draining. + pub fn closing(self) -> Result { + // @cpt-begin:cpt-cf-oagw-state-stream-lifecycle:p1:inst-state-closing + match self { + Self::Open => Ok(Self::Closing), + other => Err(StreamTransition { + from: other, + transition: "closing", + }), + } + // @cpt-end:cpt-cf-oagw-state-stream-lifecycle:p1:inst-state-closing + } + + /// The `Closing` to `Closed` transition, on the other half being torn + /// down and the outcome recorded. + /// + /// # Errors + /// Returns [`StreamTransition`] for every state but `Closing`, because no + /// byte is in flight in either direction when a session leaves `Closing` + /// and no other state owes a drain. + pub fn closed(self) -> Result { + // @cpt-begin:cpt-cf-oagw-state-stream-lifecycle:p1:inst-state-closed + match self { + Self::Closing => Ok(Self::Closed), + other => Err(StreamTransition { + from: other, + transition: "closed", + }), + } + // @cpt-end:cpt-cf-oagw-state-stream-lifecycle:p1:inst-state-closed + } + + /// The `Open` to `Closed` transition, on a mid-flight failure that aborts + /// the transfer on either side. + /// + /// This is the only transition that bypasses `Closing`, because a failed + /// half has nothing to drain and both halves are torn down together. + /// + /// # Errors + /// Returns [`StreamTransition`] for every state but `Open`: a session + /// that never opened was never transferring, and `Closed` is terminal. + pub fn aborted(self) -> Result { + // @cpt-begin:cpt-cf-oagw-state-stream-lifecycle:p1:inst-state-abort + match self { + Self::Open => Ok(Self::Closed), + other => Err(StreamTransition { + from: other, + transition: "aborted", + }), + } + // @cpt-end:cpt-cf-oagw-state-stream-lifecycle:p1:inst-state-abort + } +} + +/// One streaming exchange: its two connection halves, the transfer mode +/// selected for it, the response `Content-Type` recorded for it, the lifecycle +/// state it is in, the deadlines in force over it, and the outcome recorded +/// when it ended. +/// +/// The entity lives only as long as the two connections it describes and is +/// dropped with them: no state of [`StreamLifecycle`] is persisted, no table +/// of `cpt-cf-oagw-db-schema` is written by any routine of the feature, and a +/// restart changes no answer the feature gives. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone)] +pub struct StreamSession { + /// The tenant the proxied request addressed. + pub tenant_id: Uuid, + /// The upstream the exchange is carried to. + pub upstream_id: Uuid, + /// The transfer mode the response's headers selected. + pub mode: TransferMode, + /// The response `Content-Type` recorded for the session. + pub content_type: Option, + /// The state of `cpt-cf-oagw-state-stream-lifecycle` the session is in. + pub lifecycle: StreamLifecycle, + /// The idle deadline in force over the transfer. + pub idle_timeout: Duration, + /// The header-arrival deadline `cpt-cf-oagw-algo-outbound-forward` applies, + /// in force only while the session is in `Opening` and only for a session + /// whose send has begun; the body's only deadline is [`IDLE_TIMEOUT`]. + pub header_timeout: Option, + /// The caller's half. + pub caller: StreamHalf, + /// The upstream's half. + pub upstream: StreamHalf, + /// The bytes that have been read from one half and written and flushed to + /// the other, which is what the idle timer measures. + pub moved: u64, + /// The outcome recorded when the exchange ended. + pub outcome: Option, +} + +impl StreamSession { + /// Opens the session of a body transfer, in the `Open` state. + /// + /// An `incremental` session is never in `Opening`, because the feature is + /// first reached at the point the upstream's response headers have arrived + /// and there is no in-flight window for it to describe (§4). + #[must_use] + pub fn open_for_incremental( + tenant_id: Uuid, + upstream_id: Uuid, + content_type: Option, + ) -> Self { + Self { + tenant_id, + upstream_id, + mode: TransferMode::Incremental, + content_type, + lifecycle: StreamLifecycle::Open, + idle_timeout: IDLE_TIMEOUT, + header_timeout: None, + caller: StreamHalf::caller(), + upstream: StreamHalf::upstream(), + moved: 0, + outcome: None, + } + } + + /// Opens the session of a taken-up handshake, in the `Opening` state. + /// + /// The session exists for as long as the handshake is in flight and + /// carries the `tunnel` mode from the start, because the mode is what the + /// detection that preceded the send fixed. + #[must_use] + pub fn open_for_handshake(tenant_id: Uuid, upstream_id: Uuid) -> Self { + Self { + tenant_id, + upstream_id, + mode: TransferMode::Tunnel, + content_type: None, + lifecycle: StreamLifecycle::Opening, + idle_timeout: IDLE_TIMEOUT, + header_timeout: None, + caller: StreamHalf::caller(), + upstream: StreamHalf::upstream(), + moved: 0, + outcome: None, + } + } + + /// Closes a session whose handshake the upstream refused or whose send + /// failed before any byte moved, so no half survives it. + /// + /// The exchange was terminated before data, which is why the outcome + /// recorded is [`StreamOutcome::Aborted`]; the answer the caller receives + /// for such an exchange is the one `cpt-cf-oagw-algo-outbound-forward` + /// reports or the upstream's own answer, and never [`answer_of`]. + pub fn refuse(&mut self) { + if let Ok(closed) = self.lifecycle.refused() { + self.lifecycle = closed; + self.caller.open = false; + self.upstream.open = false; + self.outcome = Some(StreamOutcome::Aborted); + } + } + + /// Ends the session because the caller disconnected, which closes the + /// upstream half and takes the lifecycle through `Closing` to `Closed`. + pub fn disconnect(&mut self) { + self.teardown(StreamOutcome::ClientDisconnected); + } + + /// Ends the session because the upstream closed its half, which closes the + /// caller's half and takes the lifecycle through `Closing` to `Closed`. + pub fn upstream_closed(&mut self) { + self.teardown(StreamOutcome::UpstreamClosed); + } + + /// Ends the session because no byte moved in either direction for the idle + /// deadline, which tears both halves down. + pub fn stalled(&mut self) { + self.abort(StreamOutcome::Stalled); + } + + /// Ends the session because a half failed while bytes were still expected, + /// whichever side failed, which tears both halves down together. + pub fn abort_transfer(&mut self) { + self.abort(StreamOutcome::Aborted); + } + + /// Counts bytes once they have been written and flushed to the other half, + /// which is the event the idle timer measures. + pub fn record_moved(&mut self, bytes: u64) { + self.moved += bytes; + } + + /// Whether the session is still in the `Open` state with no outcome + /// recorded, which is the state a transfer that ended without an upstream + /// end, a stall, or an abort was left in: the caller's half went away and + /// the pump never learned why. + #[must_use] + pub fn is_open(&self) -> bool { + self.lifecycle == StreamLifecycle::Open && self.outcome.is_none() + } + + /// Takes the lifecycle through `Closing` to `Closed` for a clean end, and + /// tears both halves down with it. + fn teardown(&mut self, outcome: StreamOutcome) { + if self.lifecycle.closing().is_ok() { + self.lifecycle = StreamLifecycle::Closing; + } + if let Ok(closed) = self.lifecycle.closed() { + self.lifecycle = closed; + self.caller.open = false; + self.upstream.open = false; + self.outcome = Some(outcome); + } + } + + /// Moves the lifecycle straight to `Closed`, bypassing `Closing`, for an + /// exchange that was terminated with bytes still expected. + fn abort(&mut self, outcome: StreamOutcome) { + if let Ok(closed) = self.lifecycle.aborted() { + self.lifecycle = closed; + self.caller.open = false; + self.upstream.open = false; + self.outcome = Some(outcome); + } + } +} + +/// The upgrade request the three-part detection identified. +/// +/// The detection's only content is the judgement itself: the values that +/// satisfied the three parts stay in the request the proxy path holds, which +/// is where the suspension reads them from. The type is `Copy` for the same +/// reason, because the detection is held on the request from the +/// header-transformation step to the send and consumed once more after the +/// response headers arrive. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UpgradeDetection; + +/// How the handshake ended, judged from the upstream's answer. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpgradeAnswer { + /// The answer has not arrived. + NotJudged, + /// The upstream answered 101 and the handshake was taken up. + Taken, + /// The upstream answered anything else, which passes through unchanged. + NotTaken, + /// The send failed before the upstream answered at all. + FailedBeforeData, +} + +/// One upgrade exchange: the outbound handshake request's suspended headers +/// and the upstream's answer. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpgradeHandshake { + /// The header pairs the outbound handshake request carries: the two + /// hop-by-hop headers whose strip is suspended, and the handshake's own + /// request headers, in the values the caller sent. + pub suspended: Vec<(String, String)>, + /// The upstream's answer, or the fact that the handshake failed before + /// data. + pub answer: UpgradeAnswer, +} + +impl UpgradeHandshake { + /// Builds the handshake of a detected upgrade request from the headers the + /// caller sent. + /// + /// The suspension this records is the DESIGN §3.2 Headers Transformation + /// upgrade exception, which `cpt-cf-oagw-algo-header-transform` explicitly + /// does not apply: it is scoped to two of the eight hop-by-hop headers and + /// to the request direction, and it changes nothing else about the request + /// the proxy path would have sent. + #[must_use] + pub fn build(_detection: UpgradeDetection, context: &ProxyContext) -> Self { + let mut suspended: Vec<(String, String)> = Vec::new(); + // @cpt-begin:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-suspend + // The two headers whose strip the handshake suspends, in the values + // the caller sent, so the handshake reaches the upstream intact. The + // other six hop-by-hop headers of DESIGN §3.2's table stay stripped + // exactly as the unconditional rule strips them, which is why they are + // not recorded here: `cpt-cf-oagw-algo-header-transform` removes them + // under the suspension, and `cpt-cf-oagw-algo-upgrade-handshake`'s + // `inst-uh-six` step is that strip running unchanged. + for name in ["Upgrade", "Connection"] { + for value in context.header_values(name) { + suspended.push((String::from(name), String::from(value))); + } + } + // @cpt-end:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-suspend + // The handshake's own request headers are recorded with the suspended + // two, because they are the fields a handshake is judged by and the + // shipped `passthrough` default would otherwise forward none of them. + // Admitting them over that mode is `cpt-cf-oagw-algo-header-transform`'s + // act, which is where the suspension is applied. + for (name, value) in &context.headers { + if HANDSHAKE_HEADERS.contains(&name.to_ascii_lowercase().as_str()) { + suspended.push((String::from(name), String::from(value))); + } + } + Self { + suspended, + answer: UpgradeAnswer::NotJudged, + } + } + + /// Records the handshake as failed before data, which the caller answers + /// through the variants `cpt-cf-oagw-algo-outbound-forward` names. + pub fn failed_before_data(&mut self) { + self.answer = UpgradeAnswer::FailedBeforeData; + } + + /// Judges the handshake by the upstream's answer status. + /// + /// Only a 101 completes the handshake; any other answer is judged not + /// taken up, which passes through unchanged under the error-source + /// classification and leaves the connection a plain request/response + /// exchange, with no variant of the catalogue substituted for the answer + /// the upstream itself produced. + pub fn judge(&mut self, status: u16) { + // @cpt-begin:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-101-if + if status == 101 { + // @cpt-begin:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-101 + self.answer = UpgradeAnswer::Taken; + // @cpt-end:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-101 + return; + } + // @cpt-end:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-101-if + // @cpt-begin:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-101-else + // @cpt-begin:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-not-101 + self.answer = UpgradeAnswer::NotTaken; + // @cpt-end:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-not-101 + // @cpt-end:cpt-cf-oagw-algo-upgrade-handshake:p1:inst-uh-101-else + } +} + +/// What the mode-selection routine attaches to the mode it selected: the 101 +/// answer of a tunnel, or the response `Content-Type` of a body transfer. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SessionCarry { + /// The upstream's answer to a taken-up handshake. + Answer(u16), + /// The response `Content-Type` recorded for the session. + ContentType(String), +} + +/// Reads the three parts of the upgrade detection from the request. +/// +/// The method must be `GET`, the `Upgrade` header must name `websocket`, and +/// the `Connection` header must name the `upgrade` token. The last two are +/// compared case-insensitively, and `Connection` is matched over a +/// comma-separated token list; an `Upgrade` value carrying whitespace or a +/// list of protocols is matched against the one literal and against nothing +/// else, because the routine neither normalizes nor repairs a header value. +/// +/// The routine runs before `cpt-cf-oagw-algo-header-transform` builds the +/// outbound header map and before any strip runs, which is why it answers only +/// the detection question: the response does not exist yet. +// @cpt-dod:cpt-cf-oagw-dod-stream-upgrade:p1 +#[must_use] +pub fn upgrade_detection( + method: &str, + upgrade: Option<&str>, + connection: Option<&str>, +) -> Option { + // @cpt-begin:cpt-cf-oagw-algo-stream-mode-select:p1:inst-sms-request + // The three parts are read from the request as the proxy path holds them, + // before `cpt-cf-oagw-algo-header-transform` builds the outbound header + // map and before any strip runs. A method outside the shipped route + // schema's five literals cannot reach here, and the values are matched as + // the caller sent them and never normalized. + if method != "GET" { + return None; + } + let (upgrade, connection) = (upgrade?, connection?); + // @cpt-end:cpt-cf-oagw-algo-stream-mode-select:p1:inst-sms-request + // @cpt-begin:cpt-cf-oagw-algo-stream-mode-select:p1:inst-sms-upgrade-if + let parts_hold = upgrade.trim().eq_ignore_ascii_case("websocket") + && connection + .split(',') + .any(|token| token.trim().eq_ignore_ascii_case("upgrade")); + if parts_hold { + // @cpt-begin:cpt-cf-oagw-algo-stream-mode-select:p1:inst-sms-upgrade-return + // The detection is returned so `cpt-cf-oagw-algo-upgrade-handshake` + // builds the handshake and applies the suspension, and it is held on + // the request for the second half to consume after the send. + return Some(UpgradeDetection); + // @cpt-end:cpt-cf-oagw-algo-stream-mode-select:p1:inst-sms-upgrade-return + } + // @cpt-end:cpt-cf-oagw-algo-stream-mode-select:p1:inst-sms-upgrade-if + // @cpt-begin:cpt-cf-oagw-algo-stream-mode-select:p1:inst-sms-upgrade-else + // @cpt-begin:cpt-cf-oagw-algo-stream-mode-select:p1:inst-sms-not-upgrade + // No detection: the strip runs over all eight hop-by-hop headers and the + // exchange proceeds as a plain request/response transfer. + None + // @cpt-end:cpt-cf-oagw-algo-stream-mode-select:p1:inst-sms-not-upgrade + // @cpt-end:cpt-cf-oagw-algo-stream-mode-select:p1:inst-sms-upgrade-else +} + +/// Selects the transfer mode of a response body from the request and the +/// response headers. +/// +/// The routine runs in two halves at the two invocation points §1.5 records. +/// The first half is [`upgrade_detection`], which runs before the outbound +/// header map is built; this is the second half, which runs after the response +/// headers arrive and answers only the mode question, because the request has +/// already been sent. The mode is `tunnel` when the request was an upgrade +/// request and the answer is 101, and `incremental` for every other body, +/// whether or not its content type is `text/event-stream`; neither value +/// buffers a complete response body. +// @cpt-dod:cpt-cf-oagw-dod-stream-timeouts:p1 +#[must_use] +pub fn select_mode( + detection: Option, + status: u16, + content_type: Option<&str>, +) -> (TransferMode, Option) { + // @cpt-begin:cpt-cf-oagw-algo-stream-mode-select:p1:inst-sms-response + // The detection the first half recorded is the one this half consumes; the + // status and the content type are read from the response headers + // `cpt-cf-oagw-algo-outbound-forward` received. + let upgraded = detection.is_some(); + // @cpt-begin:cpt-cf-oagw-algo-stream-mode-select:p1:inst-sms-tunnel-if + if upgraded && status == 101 { + // @cpt-begin:cpt-cf-oagw-algo-stream-mode-select:p1:inst-sms-tunnel + let mode = TransferMode::Tunnel; + let carry = SessionCarry::Answer(status); + // @cpt-end:cpt-cf-oagw-algo-stream-mode-select:p1:inst-sms-tunnel + // @cpt-end:cpt-cf-oagw-algo-stream-mode-select:p1:inst-sms-tunnel-if + return (mode, Some(carry)); + } + // @cpt-end:cpt-cf-oagw-algo-stream-mode-select:p1:inst-sms-response + // @cpt-begin:cpt-cf-oagw-algo-stream-mode-select:p1:inst-sms-tunnel-else + // @cpt-begin:cpt-cf-oagw-algo-stream-mode-select:p1:inst-sms-incremental + // The mode is the same value for `text/event-stream` and for every other + // body, and neither buffers a complete response body. + let mode = TransferMode::Incremental; + let carry = content_type.map(|value| SessionCarry::ContentType(String::from(value))); + // @cpt-end:cpt-cf-oagw-algo-stream-mode-select:p1:inst-sms-incremental + // @cpt-end:cpt-cf-oagw-algo-stream-mode-select:p1:inst-sms-tunnel-else + // @cpt-begin:cpt-cf-oagw-algo-stream-mode-select:p1:inst-sms-return + (mode, carry) + // @cpt-end:cpt-cf-oagw-algo-stream-mode-select:p1:inst-sms-return +} + +/// The error answer an outcome of the pump is answered with. +/// +/// A stalled stream is answered 504 with `IdleTimeout` +/// (`gts.cf.core.errors.err.v1~cf.oagw.timeout.idle.v1`) and a mid-flight +/// termination 502 with `StreamAborted` +/// (`gts.cf.core.errors.err.v1~cf.oagw.stream.aborted.v1`), both carrying +/// `X-OAGW-Error-Source: gateway` through +/// `cpt-cf-oagw-algo-error-mapping`. Neither answer sets +/// `retry_after_seconds`, so neither carries `Retry-After`, including +/// `IdleTimeout`, which the catalogue marks retriable and which is answered +/// without the header because the gateway has no interval to state for a +/// stream that died (§1.5). +/// +/// The two teardown directions are clean closes and are answered with the +/// completed transfer rather than with an error, so they name no answer. +// @cpt-dod:cpt-cf-oagw-dod-stream-errors:p1 +#[must_use] +pub fn answer_of(outcome: StreamOutcome) -> Option { + match outcome { + StreamOutcome::Stalled => Some(DomainError::gateway( + ErrorKind::IdleTimeout, + "the stream moved no byte in either direction for the idle deadline", + )), + StreamOutcome::Aborted => Some(DomainError::gateway( + ErrorKind::StreamAborted, + "the stream was terminated mid-flight while bytes were still expected", + )), + StreamOutcome::ClientDisconnected | StreamOutcome::UpstreamClosed => None, + } +} diff --git a/gears/system/oagw/oagw/src/domain/upstream.rs b/gears/system/oagw/oagw/src/domain/upstream.rs new file mode 100644 index 0000000..6cce40c --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/upstream.rs @@ -0,0 +1,409 @@ +//! `Upstream` aggregate and its sub-configurations. +//! +//! Mirrors `schemas/upstream.v1.schema.json` property for property: no +//! missing, extra, or renamed field. `deny_unknown_fields` is applied only +//! where the schema sets `additionalProperties: false`. Layering: no +//! transport or persistence type appears here — statuses are `u16`, headers +//! are strings. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +use crate::domain::alias::EndpointHost; +use crate::domain::error::ModelError; +use crate::domain::scheme::Scheme; + +/// Hierarchical sharing mode: `private`, `inherit`, or `enforce`. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SharingMode { + /// Not visible to descendants. + Private, + /// Descendants can override. + Inherit, + /// Descendants cannot override. + Enforce, +} + +/// Which inbound headers are forwarded to the upstream. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Passthrough { + /// Forward no inbound header. + None, + /// Forward only the entries of `passthrough_allowlist`. + Allowlist, + /// Forward every inbound header. + All, +} + +/// Time window of a sustained rate. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Window { + /// One second. + Second, + /// One minute. + Minute, + /// One hour. + Hour, + /// One day. + Day, +} + +/// Rate limiting algorithm. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Algorithm { + /// Allows bursts. + TokenBucket, + /// Prevents boundary bursts. + SlidingWindow, +} + +/// Scope of the rate limit counters. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RateLimitScope { + /// One counter per node. + Global, + /// One counter per tenant. + Tenant, + /// One counter per user. + User, + /// One counter per client IP. + Ip, + /// One counter per route. + Route, +} + +/// Behaviour when the limit is exceeded. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Strategy { + /// Reject the request. + Reject, + /// Queue the request. + Queue, + /// Degrade the response. + Degrade, +} + +/// One configured endpoint of an upstream service. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Endpoint { + /// Endpoint scheme literal; see [`Scheme::is_write_admitted`]. + pub scheme: Scheme, + /// Host name or IP literal of the upstream service. + pub host: EndpointHost, + /// Endpoint port. The shipped schema documents a default of `443`. + #[serde(default)] + pub port: Option, +} + +/// The `server` configuration of an upstream: one or more endpoints. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerConfig { + /// At least one endpoint. + pub endpoints: Vec, +} + +impl ServerConfig { + /// Validates the structural invariant the schema states as `minItems: 1`. + /// + /// # Errors + /// + /// Returns [`ModelError::NoEndpoints`] when no endpoint is configured. + pub fn validate(&self) -> Result<(), ModelError> { + if self.endpoints.is_empty() { + return Err(ModelError::NoEndpoints); + } + Ok(()) + } +} + +/// Authentication plugin binding of an upstream. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AuthConfig { + /// Authentication plugin type, as a GTS identifier. + #[serde(rename = "type", default)] + pub r#type: Option, + /// Sharing mode for hierarchical configuration. + #[serde(default)] + pub sharing: Option, + /// Authentication plugin configuration. + #[serde(default)] + pub config: Option, +} + +/// Request header transformation rules. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RequestHeaderRules { + /// Headers to set (overwrite if exists). + #[serde(default)] + pub set: BTreeMap, + /// Headers to add (append, allow duplicates). + #[serde(default)] + pub add: BTreeMap, + /// Header names to remove from the inbound request. + #[serde(default)] + pub remove: Vec, + /// Which inbound headers to forward. + #[serde(default)] + pub passthrough: Option, + /// Headers to forward when `passthrough` is `allowlist`. + #[serde(default)] + pub passthrough_allowlist: Vec, +} + +/// Response header transformation rules. The shipped schema gives response +/// rules no passthrough fields. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ResponseHeaderRules { + /// Headers to set on the response to the client. + #[serde(default)] + pub set: BTreeMap, + /// Headers to add to the response. + #[serde(default)] + pub add: BTreeMap, + /// Headers to strip from the upstream response. + #[serde(default)] + pub remove: Vec, +} + +/// Header transformation rules for requests and responses. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HeadersConfig { + /// Rules applied to the inbound request. + #[serde(default)] + pub request: Option, + /// Rules applied to the response to the client. + #[serde(default)] + pub response: Option, +} + +/// Tokens replenished per window. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Sustained { + /// Tokens replenished per window. At least 1. + pub rate: u64, + /// Time window of the sustained rate. + #[serde(default)] + pub window: Option, +} + +/// Maximum burst size. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Burst { + /// Bucket capacity. Defaults to `sustained.rate` when not specified. + pub capacity: u64, +} + +/// Rate limiting configuration. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RateLimitConfig { + /// Sharing mode for rate limits; `enforce` means descendants cannot + /// exceed this limit. + #[serde(default)] + pub sharing: Option, + /// Rate limiting algorithm. + #[serde(default)] + pub algorithm: Option, + /// Sustained rate. + #[serde(default)] + pub sustained: Option, + /// Burst ceiling. + #[serde(default)] + pub burst: Option, + /// Scope for the rate limit counters. + #[serde(default)] + pub scope: Option, + /// Behaviour when the limit is exceeded. + #[serde(default)] + pub strategy: Option, + /// Tokens consumed per request. At least 1. + #[serde(default)] + pub cost: Option, +} + +/// CORS configuration. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CorsConfig { + /// Sharing mode for the CORS configuration. + #[serde(default)] + pub sharing: Option, + /// Whether CORS is enabled. + pub enabled: bool, + /// Allowed origins; `["*"]` means any origin. + #[serde(default)] + pub allowed_origins: Vec, + /// Allowed HTTP methods. + #[serde(default)] + pub allowed_methods: Vec, + /// Headers exposed to the browser beyond the CORS-safelisted ones. + #[serde(default)] + pub expose_headers: Vec, + /// Whether credentials (cookies, auth headers) are allowed. + #[serde(default)] + pub allow_credentials: bool, +} + +/// Plugin chain of an upstream or route. Each item is a built-in plugin GTS +/// identifier or a custom plugin UUID string. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginsConfig { + /// Sharing mode for the plugin chain. + #[serde(default)] + pub sharing: Option, + /// Plugins applied to this upstream service or route, as the canonical + /// identifier of every item the body carried. + /// + /// The shipped `upstream.v1` and `route.v1` schemas declare the items as + /// bare identifier strings, while the binding item this feature validates + /// and writes is the object that carries a `position`, a `plugin_ref`, an + /// optional `plugin_uuid`, and a configuration. The object form is + /// accepted here by reading its `plugin_ref`, and the position, the UUID, + /// and the configuration travel to the binding rows through the binding + /// validation of the plugin system, so the chain the domain type carries is + /// the identifier list the shipped schema names. + #[serde(default, deserialize_with = "deserialize_plugin_items")] + pub items: Vec, +} + +/// Reads one `plugins.items` array, accepting the bare identifier string the +/// shipped schema declares and the binding object the plugin system writes. +fn deserialize_plugin_items<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let carried = Vec::::deserialize(deserializer)?; + let mut items = Vec::with_capacity(carried.len()); + for item in carried { + let reference = match item { + // The bare identifier form the schema declares. + Value::String(identifier) => identifier, + // The binding object form: the reference is the identifier the + // item names, and the object's other members are the binding + // row's. + Value::Object(fields) => match fields.get("plugin_ref").and_then(Value::as_str) { + Some(reference) => String::from(reference), + None => { + return Err(serde::de::Error::custom( + "the item carries no plugin_ref to identify the plugin by", + )) + } + }, + other => { + return Err(serde::de::Error::custom(format!( + "the item is neither an identifier nor a plugin binding object: {other}" + ))) + } + }; + items.push(reference); + } + Ok(items) +} + +/// The `Upstream` aggregate: a tenant-scoped configuration object +/// representing an external service. +/// +/// Carries exactly the properties `schemas/upstream.v1.schema.json` declares. +#[toolkit_macros::domain_model] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Upstream { + /// System-generated unique identifier. + pub id: Uuid, + /// Whether this upstream is enabled. + #[serde(default = "default_enabled")] + pub enabled: bool, + /// Human-readable routing identifier, un-normalized at this layer. + #[serde(default)] + pub alias: Option, + /// Flat tags for categorization and discovery. + #[serde(default)] + pub tags: Vec, + /// Server endpoints; required by the schema. + pub server: ServerConfig, + /// Protocol used to connect, as a GTS identifier; required by the schema. + pub protocol: String, + /// Authentication plugin binding. + #[serde(default)] + pub auth: Option, + /// Header transformation rules. + #[serde(default)] + pub headers: Option, + /// Plugin chain. + #[serde(default)] + pub plugins: Option, + /// Rate limiting configuration. + #[serde(default)] + pub rate_limit: Option, + /// CORS configuration. + #[serde(default)] + pub cors: Option, +} + +/// Declared default for `Upstream::enabled`. +fn default_enabled() -> bool { + true +} + +impl Upstream { + /// Builds an enabled upstream with the required `server` and `protocol` + /// and every optional sub-configuration unset. + #[must_use] + pub fn new(id: Uuid, server: ServerConfig, protocol: String) -> Self { + Self { + id, + enabled: default_enabled(), + alias: None, + tags: Vec::new(), + server, + protocol, + auth: None, + headers: None, + plugins: None, + rate_limit: None, + cors: None, + } + } + + /// Validates the structural invariants the shipped schema states. + /// + /// # Errors + /// + /// Returns [`ModelError::NoEndpoints`] when `server` carries no endpoint. + pub fn validate(&self) -> Result<(), ModelError> { + self.server.validate() + } +} diff --git a/gears/system/oagw/oagw/src/gear.rs b/gears/system/oagw/oagw/src/gear.rs new file mode 100644 index 0000000..f8bbe52 --- /dev/null +++ b/gears/system/oagw/oagw/src/gear.rs @@ -0,0 +1,269 @@ +//! Gear declaration for the OAGW gear. +//! +//! Realizes `cpt-cf-oagw-flow-gear-init`, `cpt-cf-oagw-state-gear-foundation-lifecycle` +//! and `cpt-cf-oagw-dod-gear-registration`. The gear depends on +//! `types-registry` (topologically ordered by the runtime), loads its config in +//! `init`, provisions the GTS type catalogue in `post_init` — failing closed on +//! any refusal — and mounts the management surface on `/oagw/v1` in +//! `register_rest`. + +use std::sync::{Arc, OnceLock}; + +use async_trait::async_trait; +use toolkit::Gear; +use toolkit::GearCtx; +use toolkit::api::OpenApiRegistry; +use toolkit::contracts::{RestApiCapability, SystemCapability}; +use types_registry_sdk::TypesRegistryClient; + +use crate::api::rest::MOUNT_POINT; +use crate::api::rest::register_management_routes; +use crate::api::rest::state::OagwState; +use crate::config::{ConfigError, OagwConfig}; +use crate::gts::provisioning::provision; + +/// OAGW gear. +/// +/// ## Capabilities +/// +/// - `system` — provisioned during startup, before the data plane answers +/// - `rest` — mounts the ten management paths on `/oagw/v1` +/// +/// ## Dependencies +/// +/// - `types_registry` — the `ClientHub` supplier of the +/// [`TypesRegistryClient`] the type catalogue is provisioned through. +/// +/// The `AuthZ` resolver is resolved from the `ClientHub` opportunistically and +/// is **not** a declared dependency: a process that starts without it mounts a +/// management surface that answers 403 to every request, rather than failing +/// the startup or, worse, serving an unenforced surface. +// @cpt-dod:cpt-cf-oagw-dod-gear-registration:p1 +#[toolkit::gear( + name = "oagw", + capabilities = [system, rest], + deps = [types_registry] +)] +pub struct OagwGear { + // @cpt-begin:cpt-cf-oagw-flow-gear-init:p1:inst-gear-init-declare-config + config: OnceLock, + // @cpt-end:cpt-cf-oagw-flow-gear-init:p1:inst-gear-init-declare-config + registry: OnceLock>, + state: OnceLock>, +} + +impl Default for OagwGear { + fn default() -> Self { + Self { + config: OnceLock::new(), + registry: OnceLock::new(), + state: OnceLock::new(), + } + } +} + +impl OagwGear { + /// The configuration the gear started with, once `init` has run. + #[must_use] + pub fn config(&self) -> Option<&OagwConfig> { + self.config.get() + } + + /// The registry client resolved from the `ClientHub`, once `init` has run. + #[must_use] + pub fn registry(&self) -> Option<&Arc> { + self.registry.get() + } + + /// The shared state the mounted management surface serves, once + /// `register_rest` has run. + #[must_use] + pub fn state(&self) -> Option<&Arc> { + self.state.get() + } + + /// Loads the gear configuration, applies its validation, and stores it. + fn load_config(&self, ctx: &GearCtx) -> anyhow::Result<()> { + // @cpt-begin:cpt-cf-oagw-flow-gear-init:p1:inst-gear-init-load-config + let cfg: OagwConfig = ctx + .config_or_default::() + .map_err(|e| anyhow::anyhow!("oagw configuration could not be loaded: {e}"))?; + // @cpt-end:cpt-cf-oagw-flow-gear-init:p1:inst-gear-init-load-config + + // @cpt-begin:cpt-cf-oagw-flow-gear-init:p1:inst-gear-init-validate + // @cpt-begin:cpt-cf-oagw-flow-gear-init:p1:inst-gear-init-abort + cfg.validate().map_err(config_error)?; + // @cpt-end:cpt-cf-oagw-flow-gear-init:p1:inst-gear-init-abort + // @cpt-end:cpt-cf-oagw-flow-gear-init:p1:inst-gear-init-validate + + // @cpt-begin:cpt-cf-oagw-flow-gear-init:p1:inst-gear-init-else + // @cpt-begin:cpt-cf-oagw-state-gear-foundation-lifecycle:p1:inst-state-init-ok + self.config + .set(cfg) + .map_err(|_| anyhow::anyhow!("{} gear already initialized", Self::MODULE_NAME))?; + // @cpt-end:cpt-cf-oagw-state-gear-foundation-lifecycle:p1:inst-state-init-ok + // @cpt-end:cpt-cf-oagw-flow-gear-init:p1:inst-gear-init-else + Ok(()) + } + + /// Resolves the types-registry client the catalogue is provisioned through. + fn resolve_registry(&self, ctx: &GearCtx) -> anyhow::Result<()> { + // @cpt-begin:cpt-cf-oagw-flow-gear-init:p1:inst-gear-init-resolve-registry + let client = ctx + .client_hub() + .get::() + .map_err(|e| { + anyhow::anyhow!( + "oagw: the types-registry client is not available from the ClientHub: {e}" + ) + })?; + // @cpt-end:cpt-cf-oagw-flow-gear-init:p1:inst-gear-init-resolve-registry + + self.registry + .set(client) + .map_err(|_| anyhow::anyhow!("{} gear already initialized", Self::MODULE_NAME))?; + Ok(()) + } +} + +/// Turns a [`ConfigError`] into an `anyhow` error that names the offending key. +fn config_error(error: ConfigError) -> anyhow::Error { + anyhow::anyhow!("oagw configuration rejected: {error}") +} + +#[async_trait] +impl Gear for OagwGear { + async fn init(&self, ctx: &GearCtx) -> anyhow::Result<()> { + // @cpt-begin:cpt-cf-oagw-flow-gear-init:p1:inst-gear-init-runtime-call + // @cpt-begin:cpt-cf-oagw-state-gear-foundation-lifecycle:p1:inst-state-init-fail + self.load_config(ctx)?; + // @cpt-end:cpt-cf-oagw-state-gear-foundation-lifecycle:p1:inst-state-init-fail + self.resolve_registry(ctx)?; + // @cpt-end:cpt-cf-oagw-flow-gear-init:p1:inst-gear-init-runtime-call + tracing::info!( + gear = Self::MODULE_NAME, + "OAGW gear initialized: configuration loaded and types-registry client resolved" + ); + // @cpt-begin:cpt-cf-oagw-flow-gear-init:p1:inst-gear-init-return + Ok(()) + // @cpt-end:cpt-cf-oagw-flow-gear-init:p1:inst-gear-init-return + } +} + +#[async_trait] +impl SystemCapability for OagwGear { + /// Provisions the GTS type catalogue. + /// + /// Runs after every gear has initialized, so the types-registry client is + /// available. Any per-entry refusal or catastrophic SDK failure fails the + /// startup: the gear never reports readiness with an unprovisioned + /// catalogue. + async fn post_init(&self, _sys: &toolkit::runtime::SystemContext) -> anyhow::Result<()> { + // @cpt-begin:cpt-cf-oagw-flow-type-provisioning:p1:inst-type-prov-post-init-phase + // @cpt-begin:cpt-cf-oagw-flow-type-provisioning:p1:inst-type-prov-registry-init + let client = self + .registry + .get() + .ok_or_else(|| anyhow::anyhow!("oagw: types-registry client not initialized"))?; + // @cpt-end:cpt-cf-oagw-flow-type-provisioning:p1:inst-type-prov-registry-init + // @cpt-end:cpt-cf-oagw-flow-type-provisioning:p1:inst-type-prov-post-init-phase + + // @cpt-begin:cpt-cf-oagw-flow-type-provisioning:p1:inst-type-prov-enumerate + let provisioned = provision(client.as_ref()) + .await + // @cpt-begin:cpt-cf-oagw-flow-type-provisioning:p1:inst-type-prov-else + // @cpt-begin:cpt-cf-oagw-state-gear-foundation-lifecycle:p1:inst-state-provision-fail + .map_err(|e| anyhow::anyhow!("oagw: GTS type catalogue provisioning failed: {e}"))?; + // @cpt-end:cpt-cf-oagw-state-gear-foundation-lifecycle:p1:inst-state-provision-fail + // @cpt-end:cpt-cf-oagw-flow-type-provisioning:p1:inst-type-prov-else + // @cpt-end:cpt-cf-oagw-flow-type-provisioning:p1:inst-type-prov-enumerate + + // @cpt-begin:cpt-cf-oagw-state-gear-foundation-lifecycle:p1:inst-state-provisioned + tracing::info!( + total = provisioned.total, + succeeded = provisioned.succeeded, + "OAGW type catalogue provisioned; gear state advanced to type-catalog-provisioned" + ); + // @cpt-end:cpt-cf-oagw-state-gear-foundation-lifecycle:p1:inst-state-provisioned + + // @cpt-begin:cpt-cf-oagw-flow-type-provisioning:p1:inst-type-prov-return + Ok(()) + // @cpt-end:cpt-cf-oagw-flow-type-provisioning:p1:inst-type-prov-return + } +} + +impl RestApiCapability for OagwGear { + fn register_rest( + &self, + ctx: &GearCtx, + router: axum::Router, + _openapi: &dyn OpenApiRegistry, + ) -> anyhow::Result { + if self.state.get().is_none() { + let state = self.assemble_state(ctx)?; + self.state + .set(state) + .map_err(|_| anyhow::anyhow!("{} gear already mounted", Self::MODULE_NAME))?; + } + let state = self + .state + .get() + .ok_or_else(|| anyhow::anyhow!("oagw: the management surface was not assembled"))?; + // @cpt-begin:cpt-cf-oagw-flow-gear-init:p1:inst-gear-init-mount + let mounted = register_management_routes(router, Arc::clone(state)); + // @cpt-end:cpt-cf-oagw-flow-gear-init:p1:inst-gear-init-mount + + // @cpt-begin:cpt-cf-oagw-dod-management-routes:p1:inst-mgmt-routes-mount + tracing::debug!( + mount_point = MOUNT_POINT, + enforcer = state.enforcer().is_some(), + "OAGW management surface mounted" + ); + // @cpt-end:cpt-cf-oagw-dod-management-routes:p1:inst-mgmt-routes-mount + // @cpt-begin:cpt-cf-oagw-state-gear-foundation-lifecycle:p1:inst-state-ready + Ok(mounted) + // @cpt-end:cpt-cf-oagw-state-gear-foundation-lifecycle:p1:inst-state-ready + } +} + +impl OagwGear { + /// Assembles the shared state the management surface serves. + /// + /// The configuration `init` loaded is the one compiled here, and the + /// `AuthZ` client is resolved from the `ClientHub` when the hub carries + /// one: a resolver that never started leaves the enforcer absent, and the + /// mounted surface answers 403 to every management request. + fn assemble_state(&self, ctx: &GearCtx) -> anyhow::Result> { + let config = self + .config + .get() + .ok_or_else(|| anyhow::anyhow!("oagw: the configuration is not loaded; `register_rest` runs after `init`"))?; + let authz = ctx.client_hub().get::(); + if let Err(error) = &authz { + // @cpt-begin:cpt-cf-oagw-dod-authz-permissions:p1:inst-authz-absent + tracing::warn!( + error = %error, + "no AuthZ resolver resolved from the ClientHub; the OAGW management surface fails closed" + ); + // @cpt-end:cpt-cf-oagw-dod-authz-permissions:p1:inst-authz-absent + } + let resolver = + ctx.client_hub().get::(); + if let Err(error) = &resolver { + tracing::warn!( + error = %error, + "no tenant-resolver resolved from the ClientHub; every bind and every resolution fails closed" + ); + } + let cred_store = ctx.client_hub().get::(); + if let Err(error) = &cred_store { + tracing::warn!( + error = %error, + "no credential store resolved from the ClientHub; every credential resolution fails closed" + ); + } + OagwState::assemble(config, authz.ok(), resolver.ok(), cred_store.ok()) + .map(Arc::new) + .map_err(|error| anyhow::anyhow!("oagw: the management surface could not be assembled: {error}")) + } +} diff --git a/gears/system/oagw/oagw/src/gts/catalog.rs b/gears/system/oagw/oagw/src/gts/catalog.rs new file mode 100644 index 0000000..808fbca --- /dev/null +++ b/gears/system/oagw/oagw/src/gts/catalog.rs @@ -0,0 +1,311 @@ +//! Catalogue rows: the JSON entities handed to the types-registry. +//! +//! Realizes `cpt-cf-oagw-dod-gts-type-catalog`: 7 base type schemas, 2 +//! protocol instances, and 21 distinct error instances covering the 22 +//! `ErrorKind` variants. Realizes the types-registry half of +//! `cpt-cf-oagw-dod-builtin-catalogue`: the twelve plugin instances of +//! `crate::gts::plugin_catalog`, backed and catalog-only alike, registered +//! during the post-init phase. + +use serde_json::{Value, json}; +use toolkit_gts::gts_uri; + +use crate::domain::error::ErrorKind; +use crate::gts::{ + AUTH_PLUGIN_TYPE, ERR_ALIAS_CONFLICT, ERR_AUTH_FAILED, ERR_CIRCUIT_BREAKER_OPEN, + ERR_DOWNSTREAM_ERROR, ERR_INVALID_TARGET_HOST, ERR_LINK_UNAVAILABLE, ERR_MATCH_CONFLICT, + ERR_MISSING_TARGET_HOST, ERR_PAYLOAD_TOO_LARGE, ERR_PLUGIN_IN_USE, ERR_PLUGIN_NOT_FOUND, + ERR_PROTOCOL_ERROR, ERR_RATE_LIMIT_EXCEEDED, ERR_ROUTE_NOT_FOUND, ERR_SECRET_NOT_FOUND, + ERR_STREAM_ABORTED, ERR_TIMEOUT_CONNECTION, ERR_TIMEOUT_IDLE, ERR_TIMEOUT_REQUEST, + ERR_UNKNOWN_TARGET_HOST, ERR_VALIDATION, ERROR_TYPE, GUARD_PLUGIN_TYPE, PROTOCOL_GRPC, + PROTOCOL_HTTP, PROTOCOL_TYPE, ROUTE_TYPE, TRANSFORM_PLUGIN_TYPE, UPSTREAM_TYPE, +}; +use crate::gts::plugin_catalog::{ + AUTH_APIKEY, AUTH_NOOP, AUTH_OAUTH2_CLIENT_CRED, AUTH_OAUTH2_CLIENT_CRED_BASIC, + CATALOG_ONLY_AUTH_BASIC, CATALOG_ONLY_AUTH_BEARER, CATALOG_ONLY_GUARD_CORS, + CATALOG_ONLY_GUARD_TIMEOUT, CATALOG_ONLY_TRANSFORM_LOGGING, CATALOG_ONLY_TRANSFORM_METRICS, + GUARD_REQUIRED_HEADERS, TRANSFORM_REQUEST_ID, +}; + +/// Frozen upstream aggregate schema, mirrored byte for byte. +const UPSTREAM_SCHEMA_JSON: &str = include_str!("../../../docs/schemas/upstream.v1.schema.json"); +/// Frozen route aggregate schema, mirrored byte for byte. +const ROUTE_SCHEMA_JSON: &str = include_str!("../../../docs/schemas/route.v1.schema.json"); + +/// JSON Schema `draft-07` meta-schema URI used by every base type schema. +const DRAFT_07: &str = "http://json-schema.org/draft-07/schema#"; + +/// The 21 distinct error instance rows, paired with the `ErrorKind` whose +/// status, title, and Retriable flag the row carries. `RouteError` and +/// `ValidationError` share `cf.oagw.validation.error.v1`, so 21 identifiers +/// cover the 22 variants; `AliasConflict` and `MatchConflict` are the two +/// §1.5 additions. +const ERROR_KIND_ROWS: [(&str, ErrorKind); 21] = [ + (ERR_VALIDATION, ErrorKind::ValidationError), + (ERR_MISSING_TARGET_HOST, ErrorKind::MissingTargetHost), + (ERR_INVALID_TARGET_HOST, ErrorKind::InvalidTargetHost), + (ERR_UNKNOWN_TARGET_HOST, ErrorKind::UnknownTargetHost), + (ERR_AUTH_FAILED, ErrorKind::AuthenticationFailed), + (ERR_ROUTE_NOT_FOUND, ErrorKind::RouteNotFound), + (ERR_PLUGIN_IN_USE, ErrorKind::PluginInUse), + (ERR_ALIAS_CONFLICT, ErrorKind::AliasConflict), + (ERR_MATCH_CONFLICT, ErrorKind::MatchConflict), + (ERR_PAYLOAD_TOO_LARGE, ErrorKind::PayloadTooLarge), + (ERR_RATE_LIMIT_EXCEEDED, ErrorKind::RateLimitExceeded), + (ERR_SECRET_NOT_FOUND, ErrorKind::SecretNotFound), + (ERR_PROTOCOL_ERROR, ErrorKind::ProtocolError), + (ERR_DOWNSTREAM_ERROR, ErrorKind::DownstreamError), + (ERR_STREAM_ABORTED, ErrorKind::StreamAborted), + (ERR_LINK_UNAVAILABLE, ErrorKind::LinkUnavailable), + (ERR_CIRCUIT_BREAKER_OPEN, ErrorKind::CircuitBreakerOpen), + (ERR_PLUGIN_NOT_FOUND, ErrorKind::PluginNotFound), + (ERR_TIMEOUT_CONNECTION, ErrorKind::ConnectionTimeout), + (ERR_TIMEOUT_REQUEST, ErrorKind::RequestTimeout), + (ERR_TIMEOUT_IDLE, ErrorKind::IdleTimeout), +]; + +/// Why the catalogue could not be assembled. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum CatalogError { + /// A frozen schema file does not parse as JSON. + #[error("frozen schema for {type_id} is not valid JSON: {message}")] + FrozenSchema { + /// GTS identifier of the type whose frozen schema failed to parse. + type_id: String, + /// The parse failure. + message: String, + }, +} + +/// Builds one base type schema entity: `{"$id": , ...body}`. +fn schema_entity(type_id: &str, body: Value) -> Value { + let mut entity = serde_json::Map::new(); + entity.insert(String::from("$id"), Value::String(gts_uri!(type_id))); + if let Value::Object(fields) = body { + for (key, value) in fields { + entity.insert(key, value); + } + } + Value::Object(entity) +} + +/// Builds a permissive minimal base type schema: no `properties`, so +/// instances carry arbitrary fields. +fn minimal_schema(title: &str, description: &str) -> Value { + json!({ + "$schema": DRAFT_07, + "type": "object", + "title": title, + "description": description + }) +} + +/// Wraps a frozen JSON Schema file as a type-schema entity, injecting `$id` +/// as the first key so the registered entry mirrors the frozen input. +fn frozen_schema_entity(type_id: &str, frozen: &str) -> Result { + let parsed: Value = serde_json::from_str(frozen).map_err(|e| CatalogError::FrozenSchema { + type_id: type_id.to_owned(), + message: e.to_string(), + })?; + Ok(schema_entity(type_id, parsed)) +} + +/// Builds one instance entity: `{"id": , ...content}`. +fn instance_entity(gts_id: &str, content: Value) -> Value { + let mut entity = serde_json::Map::new(); + entity.insert(String::from("id"), Value::String(gts_id.to_owned())); + if let Value::Object(fields) = content { + for (key, value) in fields { + entity.insert(key, value); + } + } + Value::Object(entity) +} + +/// Builds one error instance entity from its catalogue metadata. +fn error_instance(gts_id: &str, kind: ErrorKind) -> Value { + instance_entity( + gts_id, + json!({ + "title": kind.title(), + "http_status": kind.http_status(), + "retriable": kind.is_retriable() + }), + ) +} + +/// The 7 base type schemas, parents first. The two aggregates mirror their +/// frozen schema files; the five other base types are permissive minimal +/// schemas. +/// +/// # Errors +/// +/// Returns [`CatalogError::FrozenSchema`] when a frozen schema file does not +/// parse as JSON. +pub fn base_type_schemas() -> Result, CatalogError> { + Ok(vec![ + frozen_schema_entity(UPSTREAM_TYPE, UPSTREAM_SCHEMA_JSON)?, + frozen_schema_entity(ROUTE_TYPE, ROUTE_SCHEMA_JSON)?, + schema_entity( + PROTOCOL_TYPE, + minimal_schema( + "OAGW Protocol", + "Protocol used to connect to an upstream service.", + ), + ), + schema_entity( + AUTH_PLUGIN_TYPE, + minimal_schema("OAGW Auth Plugin", "Authentication plugin base type."), + ), + schema_entity( + GUARD_PLUGIN_TYPE, + minimal_schema("OAGW Guard Plugin", "Guard plugin base type."), + ), + schema_entity( + TRANSFORM_PLUGIN_TYPE, + minimal_schema("OAGW Transform Plugin", "Transform plugin base type."), + ), + schema_entity( + ERROR_TYPE, + minimal_schema( + "OAGW Gateway Error", + "Namespace of every OAGW problem type.", + ), + ), + ]) +} + +/// The 12 plugin instance rows: the six backed implementations and the six +/// catalog-only identifiers, all registered in the types-registry during the +/// post-init phase. +fn plugin_instances() -> Vec { + let row = |gts_id: &str, name: &str, description: &str, backed: bool| { + instance_entity( + gts_id, + json!({ + "name": name, + "description": description, + "backed": backed + }), + ) + }; + vec![ + row( + AUTH_NOOP, + "noop", + "Authentication plugin that injects no credential.", + true, + ), + row( + AUTH_APIKEY, + "apikey", + "Authentication plugin that resolves an API key and injects it as a header.", + true, + ), + row( + AUTH_OAUTH2_CLIENT_CRED, + "oauth2_client_cred", + "OAuth2 Client Credentials plugin authenticating its client by form body.", + true, + ), + row( + AUTH_OAUTH2_CLIENT_CRED_BASIC, + "oauth2_client_cred_basic", + "OAuth2 Client Credentials plugin authenticating its client by basic header.", + true, + ), + row( + GUARD_REQUIRED_HEADERS, + "required_headers", + "Guard plugin that rejects a request or response missing a required header.", + true, + ), + row( + TRANSFORM_REQUEST_ID, + "request_id", + "Transform plugin that propagates the request identifier.", + true, + ), + row( + CATALOG_ONLY_AUTH_BASIC, + "basic", + "Reserved auth identifier: HTTP Basic is upstream transport configuration.", + false, + ), + row( + CATALOG_ONLY_AUTH_BEARER, + "bearer", + "Reserved auth identifier: a static bearer value is an upstream field, not a plugin.", + false, + ), + row( + CATALOG_ONLY_GUARD_TIMEOUT, + "timeout", + "Reserved guard identifier: timeouts are core data-plane behaviour.", + false, + ), + row( + CATALOG_ONLY_GUARD_CORS, + "cors", + "Reserved guard identifier: CORS is a dedicated aggregate field.", + false, + ), + row( + CATALOG_ONLY_TRANSFORM_LOGGING, + "logging", + "Reserved transform identifier: logging is core data-plane instrumentation.", + false, + ), + row( + CATALOG_ONLY_TRANSFORM_METRICS, + "metrics", + "Reserved transform identifier: metrics are core data-plane instrumentation.", + false, + ), + ] +} + +/// The 35 instances the base types own: 2 protocol values, 21 distinct error +/// identifiers, and the 12 plugin identifiers of the built-in and catalog-only +/// catalogue. +#[must_use] +pub fn instances() -> Vec { + // @cpt-begin:cpt-cf-oagw-algo-type-catalog-provisioning:p1:inst-catalog-collect + let mut rows = vec![ + instance_entity( + PROTOCOL_HTTP, + json!({ + "title": "HTTP protocol", + "description": "HTTP upstream protocol value." + }), + ), + instance_entity( + PROTOCOL_GRPC, + json!({ + "title": "gRPC protocol", + "description": "gRPC upstream protocol value." + }), + ), + ]; + rows.extend( + ERROR_KIND_ROWS + .iter() + .map(|(gts_id, kind)| error_instance(gts_id, *kind)), + ); + rows.extend(plugin_instances()); + rows + // @cpt-end:cpt-cf-oagw-algo-type-catalog-provisioning:p1:inst-catalog-collect +} + +/// The whole catalogue batch, ordered so a parent type never follows its +/// children. +/// +/// # Errors +/// +/// Returns [`CatalogError::FrozenSchema`] when a frozen schema file does not +/// parse as JSON. +pub fn catalog_entities() -> Result, CatalogError> { + let mut batch = base_type_schemas()?; + batch.extend(instances()); + Ok(batch) +} diff --git a/gears/system/oagw/oagw/src/gts/mod.rs b/gears/system/oagw/oagw/src/gts/mod.rs new file mode 100644 index 0000000..c584b11 --- /dev/null +++ b/gears/system/oagw/oagw/src/gts/mod.rs @@ -0,0 +1,136 @@ +//! GTS identifier constants of the OAGW catalogue. +//! +//! Realizes `cpt-cf-oagw-dod-gts-type-catalog`. Every identifier is written +//! as a `gts_id!` literal so the value is the catalogue row verbatim — the +//! problem `type` of an error is never synthesized from a variant name. +//! +//! Built-in plugin instance identifiers +//! (`gts.cf.core.oagw.{auth,guard,transform}_plugin.v1~cf.core.oagw.*.v1`) are +//! not written beside these: they are the twelve rows of +//! [`crate::gts::plugin_catalog`], the table the plugin-system feature owns, +//! which also decides which of them a registry backs and which are +//! catalog-only. + +// @cpt-dod:cpt-cf-oagw-dod-gts-type-catalog:p1 +pub mod catalog; +pub mod plugin_catalog; +pub mod provisioning; + +use toolkit_gts::gts_id; +use uuid::Uuid; + +/// Base type schema of the `Upstream` aggregate. +pub const UPSTREAM_TYPE: &str = gts_id!("cf.core.oagw.upstream.v1~"); +/// Base type schema of the `Route` aggregate. +pub const ROUTE_TYPE: &str = gts_id!("cf.core.oagw.route.v1~"); +/// Base type schema of the proxy API, whose `invoke` permission the Data Plane +/// enforces before any resolution runs. +pub const PROXY_TYPE: &str = gts_id!("cf.core.oagw.proxy.v1~"); +/// Base type schema of the metrics surface +/// `cpt-cf-oagw-feature-observability` registers, whose `read` permission the +/// scrape is enforced with. +pub const METRICS_TYPE: &str = gts_id!("cf.core.oagw.metrics.v1~"); +/// Base type schema of the protocol values. +pub const PROTOCOL_TYPE: &str = gts_id!("cf.core.oagw.protocol.v1~"); +/// Base type schema of the auth plugins. +pub const AUTH_PLUGIN_TYPE: &str = gts_id!("cf.core.oagw.auth_plugin.v1~"); +/// Base type schema of the guard plugins. +pub const GUARD_PLUGIN_TYPE: &str = gts_id!("cf.core.oagw.guard_plugin.v1~"); +/// Base type schema of the transform plugins. +pub const TRANSFORM_PLUGIN_TYPE: &str = gts_id!("cf.core.oagw.transform_plugin.v1~"); + +/// The descendant permission to create an upstream whose alias matches an +/// ancestor's: the bind of DESIGN §3.2's four-permission table. +pub const PERMISSION_BIND: &str = "oagw:upstream:bind"; +/// The descendant permission to override an `inherit` authentication family. +pub const PERMISSION_OVERRIDE_AUTH: &str = "oagw:upstream:override_auth"; +/// The descendant permission to declare an own rate limit under `min()`. +pub const PERMISSION_OVERRIDE_RATE: &str = "oagw:upstream:override_rate"; +/// The descendant permission to append own plugin items to an inherited chain. +pub const PERMISSION_ADD_PLUGINS: &str = "oagw:upstream:add_plugins"; +/// Base type schema of the gateway errors: the namespace of every problem +/// `type`. +pub const ERROR_TYPE: &str = gts_id!("cf.core.errors.err.v1~"); + +/// HTTP protocol instance. +pub const PROTOCOL_HTTP: &str = gts_id!("cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"); +/// gRPC protocol instance. +pub const PROTOCOL_GRPC: &str = gts_id!("cf.core.oagw.protocol.v1~cf.core.oagw.grpc.v1"); + +/// The `type` of the CORS origin refusal ADR 0004 spells, which is a bare +/// problem answer and not a catalogue row: DESIGN §3.3's catalogue is closed +/// at 22 variants over 21 identifiers and carries no 403 row (§1.5 of the +/// FEATURE). +pub const ERR_CORS_ORIGIN_NOT_ALLOWED: &str = + gts_id!("cf.core.errors.err.v1~cf.oagw.cors.origin_not_allowed.v1"); +/// The `type` of the CORS method refusal ADR 0004 spells, which is a bare +/// problem answer and not a catalogue row for the same reason. +pub const ERR_CORS_METHOD_NOT_ALLOWED: &str = + gts_id!("cf.core.errors.err.v1~cf.oagw.cors.method_not_allowed.v1"); + +/// `RouteError` and `ValidationError` share this identifier. +pub const ERR_VALIDATION: &str = gts_id!("cf.core.errors.err.v1~cf.oagw.validation.error.v1"); +/// `MissingTargetHost`. +pub const ERR_MISSING_TARGET_HOST: &str = + gts_id!("cf.core.errors.err.v1~cf.oagw.routing.missing_target_host.v1"); +/// `InvalidTargetHost`. +pub const ERR_INVALID_TARGET_HOST: &str = + gts_id!("cf.core.errors.err.v1~cf.oagw.routing.invalid_target_host.v1"); +/// `UnknownTargetHost`. +pub const ERR_UNKNOWN_TARGET_HOST: &str = + gts_id!("cf.core.errors.err.v1~cf.oagw.routing.unknown_target_host.v1"); +/// `AuthenticationFailed`. +pub const ERR_AUTH_FAILED: &str = gts_id!("cf.core.errors.err.v1~cf.oagw.auth.failed.v1"); +/// `RouteNotFound`. +pub const ERR_ROUTE_NOT_FOUND: &str = gts_id!("cf.core.errors.err.v1~cf.oagw.route.not_found.v1"); +/// `PluginInUse`. +pub const ERR_PLUGIN_IN_USE: &str = gts_id!("cf.core.errors.err.v1~cf.oagw.plugin.in_use.v1"); +/// `AliasConflict`, added per §1.5. +pub const ERR_ALIAS_CONFLICT: &str = gts_id!("cf.core.errors.err.v1~cf.oagw.alias.conflict.v1"); +/// `MatchConflict`, added per §1.5. +pub const ERR_MATCH_CONFLICT: &str = gts_id!("cf.core.errors.err.v1~cf.oagw.match.conflict.v1"); +/// `PayloadTooLarge`. +pub const ERR_PAYLOAD_TOO_LARGE: &str = + gts_id!("cf.core.errors.err.v1~cf.oagw.payload.too_large.v1"); +/// `RateLimitExceeded`. +pub const ERR_RATE_LIMIT_EXCEEDED: &str = + gts_id!("cf.core.errors.err.v1~cf.oagw.rate_limit.exceeded.v1"); +/// `SecretNotFound`. +pub const ERR_SECRET_NOT_FOUND: &str = gts_id!("cf.core.errors.err.v1~cf.oagw.secret.not_found.v1"); +/// `ProtocolError`. +pub const ERR_PROTOCOL_ERROR: &str = gts_id!("cf.core.errors.err.v1~cf.oagw.protocol.error.v1"); +/// `DownstreamError` (§1.5 resolves its `Depends` cell to non-retriable). +pub const ERR_DOWNSTREAM_ERROR: &str = gts_id!("cf.core.errors.err.v1~cf.oagw.downstream.error.v1"); +/// `StreamAborted`. +pub const ERR_STREAM_ABORTED: &str = gts_id!("cf.core.errors.err.v1~cf.oagw.stream.aborted.v1"); +/// `LinkUnavailable`. +pub const ERR_LINK_UNAVAILABLE: &str = gts_id!("cf.core.errors.err.v1~cf.oagw.link.unavailable.v1"); +/// `CircuitBreakerOpen`. +pub const ERR_CIRCUIT_BREAKER_OPEN: &str = + gts_id!("cf.core.errors.err.v1~cf.oagw.circuit_breaker.open.v1"); +/// `PluginNotFound`. +pub const ERR_PLUGIN_NOT_FOUND: &str = gts_id!("cf.core.errors.err.v1~cf.oagw.plugin.not_found.v1"); +/// `ConnectionTimeout`. +pub const ERR_TIMEOUT_CONNECTION: &str = + gts_id!("cf.core.errors.err.v1~cf.oagw.timeout.connection.v1"); +/// `RequestTimeout`. +pub const ERR_TIMEOUT_REQUEST: &str = gts_id!("cf.core.errors.err.v1~cf.oagw.timeout.request.v1"); +/// `IdleTimeout`. +pub const ERR_TIMEOUT_IDLE: &str = gts_id!("cf.core.errors.err.v1~cf.oagw.timeout.idle.v1"); + +/// Renders the anonymous GTS instance identifier one row answers to: the base +/// type schema followed by the row's `Uuid`. +#[must_use] +pub fn gts_instance(prefix: &str, id: Uuid) -> String { + format!("{prefix}{id}") +} + +/// Parses the anonymous GTS instance identifier of one resource kind into the +/// `Uuid` it names, accepting the bare `Uuid` as well. +#[must_use] +pub fn parse_gts_instance(prefix: &str, value: &str) -> Option { + value + .strip_prefix(prefix) + .and_then(|tail| Uuid::parse_str(tail).ok()) + .or_else(|| Uuid::parse_str(value).ok()) +} diff --git a/gears/system/oagw/oagw/src/gts/plugin_catalog.rs b/gears/system/oagw/oagw/src/gts/plugin_catalog.rs new file mode 100644 index 0000000..3effa59 --- /dev/null +++ b/gears/system/oagw/oagw/src/gts/plugin_catalog.rs @@ -0,0 +1,135 @@ +//! The twelve plugin instance identifiers of the built-in and catalog-only +//! catalogue. +//! +//! Realizes `cpt-cf-oagw-dod-builtin-catalogue`: the six identifiers a +//! registry backs, the six identifiers the types-registry reserves and no +//! registry backs, and the two questions any resolution asks of the table — +//! is this identifier catalog-only, and is it known at all. The table is the +//! one place the twelve identifiers are written, so a registry, a binding +//! validation, and a types-registry registration cannot disagree about what +//! the catalogue holds. + +use toolkit_gts::gts_id; + +use crate::gts::{AUTH_PLUGIN_TYPE, GUARD_PLUGIN_TYPE, TRANSFORM_PLUGIN_TYPE}; + +/// The four backed auth identifiers. +pub const AUTH_NOOP: &str = gts_id!("cf.core.oagw.auth_plugin.v1~cf.core.oagw.noop.v1"); +/// The API-key auth plugin, backed. +pub const AUTH_APIKEY: &str = gts_id!("cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1"); +/// The Form-authenticated OAuth2 Client Credentials plugin, backed. +pub const AUTH_OAUTH2_CLIENT_CRED: &str = + gts_id!("cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred.v1"); +/// The Basic-authenticated OAuth2 Client Credentials plugin, backed. +pub const AUTH_OAUTH2_CLIENT_CRED_BASIC: &str = + gts_id!("cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred_basic.v1"); + +/// The one backed guard identifier: the required-headers guard. +pub const GUARD_REQUIRED_HEADERS: &str = + gts_id!("cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1"); + +/// The one backed transform identifier: the request-identifier transform. +pub const TRANSFORM_REQUEST_ID: &str = + gts_id!("cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1"); + +/// The six backed identifiers, paired with the family each belongs to. +pub const BACKED: [(&str, crate::domain::plugin_contract::PluginFamily); 6] = [ + (AUTH_NOOP, crate::domain::plugin_contract::PluginFamily::Auth), + (AUTH_APIKEY, crate::domain::plugin_contract::PluginFamily::Auth), + ( + AUTH_OAUTH2_CLIENT_CRED, + crate::domain::plugin_contract::PluginFamily::Auth, + ), + ( + AUTH_OAUTH2_CLIENT_CRED_BASIC, + crate::domain::plugin_contract::PluginFamily::Auth, + ), + ( + GUARD_REQUIRED_HEADERS, + crate::domain::plugin_contract::PluginFamily::Guard, + ), + ( + TRANSFORM_REQUEST_ID, + crate::domain::plugin_contract::PluginFamily::Transform, + ), +]; + +/// The two catalog-only auth identifiers: reserved GTS identifiers with no +/// backing `AuthPlugin` implementation anywhere. +pub const CATALOG_ONLY_AUTH_BASIC: &str = + gts_id!("cf.core.oagw.auth_plugin.v1~cf.core.oagw.basic.v1"); +/// `bearer`, catalog-only. +pub const CATALOG_ONLY_AUTH_BEARER: &str = + gts_id!("cf.core.oagw.auth_plugin.v1~cf.core.oagw.bearer.v1"); +/// `timeout`, catalog-only: a core data-plane behaviour, not a guard. +pub const CATALOG_ONLY_GUARD_TIMEOUT: &str = + gts_id!("cf.core.oagw.guard_plugin.v1~cf.core.oagw.timeout.v1"); +/// `cors`, catalog-only: a dedicated field on the aggregates, not a guard. +pub const CATALOG_ONLY_GUARD_CORS: &str = + gts_id!("cf.core.oagw.guard_plugin.v1~cf.core.oagw.cors.v1"); +/// `logging`, catalog-only: core data-plane instrumentation, not a transform. +pub const CATALOG_ONLY_TRANSFORM_LOGGING: &str = + gts_id!("cf.core.oagw.transform_plugin.v1~cf.core.oagw.logging.v1"); +/// `metrics`, catalog-only: core data-plane instrumentation, not a transform. +pub const CATALOG_ONLY_TRANSFORM_METRICS: &str = + gts_id!("cf.core.oagw.transform_plugin.v1~cf.core.oagw.metrics.v1"); + +/// The six catalog-only identifiers. +pub const CATALOG_ONLY: [&str; 6] = [ + CATALOG_ONLY_AUTH_BASIC, + CATALOG_ONLY_AUTH_BEARER, + CATALOG_ONLY_GUARD_TIMEOUT, + CATALOG_ONLY_GUARD_CORS, + CATALOG_ONLY_TRANSFORM_LOGGING, + CATALOG_ONLY_TRANSFORM_METRICS, +]; + +/// All twelve identifiers of the catalogue: the six backed ones and the six +/// catalog-only ones. +#[must_use] +pub fn all() -> Vec<&'static str> { + BACKED + .iter() + .map(|(identifier, _)| *identifier) + .chain(CATALOG_ONLY) + .collect() +} + +/// Whether one identifier is one of the six catalog-only identifiers. +#[must_use] +pub fn is_catalog_only(identifier: &str) -> bool { + CATALOG_ONLY.contains(&identifier) +} + +/// Whether the catalogue names one identifier at all, backed or not. +#[must_use] +pub fn is_known_identifier(identifier: &str) -> bool { + BACKED.iter().any(|(known, _)| *known == identifier) || is_catalog_only(identifier) +} + +/// The family one known identifier belongs to, or `None` for an identifier the +/// catalogue does not name. +#[must_use] +pub fn family_of(identifier: &str) -> Option { + BACKED + .iter() + .find(|(known, _)| *known == identifier) + .map(|(_, family)| *family) +} + +/// Whether one base type schema prefix names one of the three plugin base +/// types, and which. +#[must_use] +pub fn family_of_prefix(prefix: &str) -> Option { + // @cpt-begin:cpt-cf-oagw-algo-plugin-contract-registry:p1:inst-reg-map + // The family names exactly one of the three registries, so mapping the + // base type schema to its family is what keeps an auth identifier out of + // the guard and transform registries. + match prefix { + AUTH_PLUGIN_TYPE => Some(crate::domain::plugin_contract::PluginFamily::Auth), + GUARD_PLUGIN_TYPE => Some(crate::domain::plugin_contract::PluginFamily::Guard), + TRANSFORM_PLUGIN_TYPE => Some(crate::domain::plugin_contract::PluginFamily::Transform), + _ => None, + } + // @cpt-end:cpt-cf-oagw-algo-plugin-contract-registry:p1:inst-reg-map +} diff --git a/gears/system/oagw/oagw/src/gts/provisioning.rs b/gears/system/oagw/oagw/src/gts/provisioning.rs new file mode 100644 index 0000000..5548b22 --- /dev/null +++ b/gears/system/oagw/oagw/src/gts/provisioning.rs @@ -0,0 +1,269 @@ +//! GTS type-catalogue provisioning routine. +//! +//! Realizes `cpt-cf-oagw-algo-type-catalog-provisioning` and +//! `cpt-cf-oagw-flow-type-provisioning`: one batch register, no retry, per +//! entry success or a typed failure, and an ERROR log per failing identifier +//! that carries the existing entry as evidence. Provisioning never overwrites +//! or deletes an entry it does not own. + +use tracing::{error, info}; +use types_registry_sdk::{RegisterResult, TypesRegistryClient}; + +use super::catalog::{self, CatalogError}; + +/// Outcome of a fully provisioned catalogue. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct CatalogProvisioned { + /// Number of entries the batch carried. + pub total: usize, + /// Number of entries accepted, including entries already registered with + /// byte-identical content. + pub succeeded: usize, +} + +/// One entry the registry refused. +#[derive(Debug, Clone)] +pub struct EntryFailure { + /// GTS identifier of the refused entry, `` when the registry + /// could not extract one. + pub gts_id: String, + /// The typed error the registry returned. + pub error: String, + /// Content summary of the entry already registered, when one could be + /// read back. Never overwritten or deleted. + pub existing: Option, +} + +/// Why provisioning failed. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ProvisioningError { + /// The catalogue could not be assembled from its frozen inputs. + #[error("the OAGW type catalogue could not be assembled: {0}")] + Catalog(CatalogError), + /// A catastrophic SDK failure: unavailable backend or call timeout. Not + /// retried and not partially re-issued. + #[error("types-registry batch register failed: {0}")] + Registry(String), + /// At least one per-entry registration failed. The gear never reports + /// readiness. + #[error("{failed} of {total} OAGW catalogue entries failed to register: {identifiers}")] + EntriesFailed { + /// Number of refused entries. + failed: usize, + /// Number of entries the batch carried. + total: usize, + /// Comma-separated failing GTS identifiers, for the failure message. + identifiers: String, + }, +} + +/// The per-entry failures behind a [`ProvisioningError::EntriesFailed`]. +/// +/// Kept out of the error value itself so the error stays `Clone + PartialEq` +/// while the failures carry their full typed detail. +#[derive(Debug, Clone, Default)] +pub struct EntryFailures { + /// Every refused entry, with its identifier, typed error, and the + /// existing entry read back from the registry. + pub entries: Vec, +} + +impl ProvisioningError { + /// The failing GTS identifiers, in submission order. + #[must_use] + pub fn failing_identifiers<'a>(&self, failures: &'a EntryFailures) -> Vec<&'a str> { + match self { + Self::Catalog(_) | Self::Registry(_) => Vec::new(), + Self::EntriesFailed { .. } => { + failures.entries.iter().map(|f| f.gts_id.as_str()).collect() + } + } + } +} + +/// Provisions the OAGW type catalogue through the types-registry. +/// +/// The batch is built parents-first; the registry additionally sorts it +/// lexicographically by GTS identifier, which guarantees a base type +/// (suffix `~`) precedes its instances. A catastrophic SDK failure fails the +/// phase immediately: no retry, and no continuation with an unprovisioned +/// catalogue. Entries registered before a failure stay in the registry — that +/// is the rollback story, since a re-run over identical content succeeds +/// instead of conflicting. +/// +/// On failure the caller receives the failing identifiers through +/// [`ProvisioningError::failing_identifiers`], and each one has already been +/// logged at ERROR. +/// +/// # Errors +/// +/// Returns [`ProvisioningError::Catalog`] when the catalogue cannot be +/// assembled, [`ProvisioningError::Registry`] for a catastrophic SDK failure, +/// and [`ProvisioningError::EntriesFailed`] when any entry is refused. +pub async fn provision( + client: &dyn TypesRegistryClient, +) -> Result { + let batch = catalog::catalog_entities().map_err(ProvisioningError::Catalog)?; + let total = batch.len(); + + // @cpt-begin:cpt-cf-oagw-algo-type-catalog-provisioning:p1:inst-catalog-try + // @cpt-begin:cpt-cf-oagw-algo-type-catalog-provisioning:p1:inst-catalog-register + let submitted = client.register(batch).await; + // @cpt-end:cpt-cf-oagw-algo-type-catalog-provisioning:p1:inst-catalog-register + // @cpt-end:cpt-cf-oagw-algo-type-catalog-provisioning:p1:inst-catalog-try + + // @cpt-begin:cpt-cf-oagw-algo-type-catalog-provisioning:p1:inst-catalog-catch + // CATCH a catastrophic SDK failure such as an unavailable backend: the + // post-init phase fails and the catalogue is never retried here. + // @cpt-begin:cpt-cf-oagw-algo-type-catalog-provisioning:p1:inst-catalog-catch-handle + let results = submitted.map_err(|e| ProvisioningError::Registry(e.to_string()))?; + // @cpt-end:cpt-cf-oagw-algo-type-catalog-provisioning:p1:inst-catalog-catch-handle + // @cpt-end:cpt-cf-oagw-algo-type-catalog-provisioning:p1:inst-catalog-catch + + let (failures, provisioned) = settle(client, results, total).await; + report(&failures, total); + + // @cpt-begin:cpt-cf-oagw-flow-type-provisioning:p1:inst-type-prov-check + if failures.entries.is_empty() { + // @cpt-begin:cpt-cf-oagw-flow-type-provisioning:p1:inst-type-prov-ready + report_success(&provisioned); + // @cpt-begin:cpt-cf-oagw-algo-type-catalog-provisioning:p1:inst-catalog-return + return Ok(provisioned); + // @cpt-end:cpt-cf-oagw-algo-type-catalog-provisioning:p1:inst-catalog-return + // @cpt-end:cpt-cf-oagw-flow-type-provisioning:p1:inst-type-prov-ready + } + // @cpt-end:cpt-cf-oagw-flow-type-provisioning:p1:inst-type-prov-check + + Err(ProvisioningError::EntriesFailed { + failed: failures.entries.len(), + total, + identifiers: failing_identifiers(&failures), + }) +} + +/// Classifies the per-entry results and reads back the existing entry for +/// every refusal. +/// +/// # Panics +/// +/// Never panics. +async fn settle( + client: &dyn TypesRegistryClient, + results: Vec, + total: usize, +) -> (EntryFailures, CatalogProvisioned) { + let mut provisioned = CatalogProvisioned { + total, + succeeded: 0, + }; + let mut failures = EntryFailures::default(); + + // @cpt-begin:cpt-cf-oagw-algo-type-catalog-provisioning:p1:inst-catalog-loop + for result in results { + match result { + // @cpt-begin:cpt-cf-oagw-algo-type-catalog-provisioning:p1:inst-catalog-idempotent + // An entry that succeeded — or that was already registered with + // byte-identical content — counts as success. + RegisterResult::Ok { .. } => provisioned.succeeded += 1, + // @cpt-end:cpt-cf-oagw-algo-type-catalog-provisioning:p1:inst-catalog-idempotent + // @cpt-begin:cpt-cf-oagw-algo-type-catalog-provisioning:p1:inst-catalog-collect-failure + RegisterResult::Err { gts_id, error } => { + let identifier = gts_id.unwrap_or_else(|| String::from("")); + let existing = read_back(client, &identifier).await; + failures.entries.push(EntryFailure { + gts_id: identifier, + error: error.to_string(), + existing, + }); + } + // @cpt-end:cpt-cf-oagw-algo-type-catalog-provisioning:p1:inst-catalog-collect-failure + } + } + // @cpt-end:cpt-cf-oagw-algo-type-catalog-provisioning:p1:inst-catalog-loop + + (failures, provisioned) +} + +/// Logs every failing identifier at ERROR with the typed error and the +/// existing entry as evidence; never skips an entry with a warning. +fn report(failures: &EntryFailures, total: usize) { + // @cpt-begin:cpt-cf-oagw-flow-type-provisioning:p1:inst-type-prov-fail + // @cpt-begin:cpt-cf-oagw-algo-type-catalog-provisioning:p1:inst-catalog-fail-if + if !failures.entries.is_empty() { + // @cpt-begin:cpt-cf-oagw-algo-type-catalog-provisioning:p1:inst-catalog-fail + for failure in &failures.entries { + error!( + gts_id = %failure.gts_id, + error = %failure.error, + existing = failure.existing.as_deref().unwrap_or(""), + "Failed to register an OAGW GTS catalogue entry" + ); + } + error!( + failed = failures.entries.len(), + total, + identifiers = %failing_identifiers(failures), + "OAGW type catalogue provisioning failed; readiness stays withheld" + ); + // @cpt-end:cpt-cf-oagw-algo-type-catalog-provisioning:p1:inst-catalog-fail + } + // @cpt-end:cpt-cf-oagw-algo-type-catalog-provisioning:p1:inst-catalog-fail-if + // @cpt-end:cpt-cf-oagw-flow-type-provisioning:p1:inst-type-prov-fail +} + +/// Comma-separated failing GTS identifiers, in submission order. +fn failing_identifiers(failures: &EntryFailures) -> String { + failures + .entries + .iter() + .map(|f| f.gts_id.as_str()) + .collect::>() + .join(", ") +} + +/// Reads back the existing registry entry for a refused identifier, as the +/// evidence an operator needs to decide which remediation applies: a stale +/// entry is the thing to remove, a wrong identifier is the thing to correct. +/// Never overwrites or deletes. +async fn read_back(client: &dyn TypesRegistryClient, gts_id: &str) -> Option { + let content = if gts_id.ends_with('~') { + client + .get_type_schema(gts_id) + .await + .ok() + .map(|schema| schema.raw_schema) + } else { + client + .get_instance(gts_id) + .await + .ok() + .map(|instance| instance.object) + }?; + + Some(content_summary(&content)) +} + +/// Short, single-line summary of an existing registry entry. +fn content_summary(content: &serde_json::Value) -> String { + match content.as_object() { + None => String::from(""), + Some(fields) => { + let keys: Vec<&String> = fields.keys().collect(); + format!( + "{} keys: {}", + keys.len(), + serde_json::to_string(&keys).unwrap_or_default() + ) + } + } +} + +/// On success, logs the provisioned catalogue so the startup trail shows the +/// phase completed. +fn report_success(provisioned: &CatalogProvisioned) { + info!( + total = provisioned.total, + succeeded = provisioned.succeeded, + "OAGW type catalogue provisioned" + ); +} diff --git a/gears/system/oagw/oagw/src/lib.rs b/gears/system/oagw/oagw/src/lib.rs index e69de29..14168d4 100644 --- a/gears/system/oagw/oagw/src/lib.rs +++ b/gears/system/oagw/oagw/src/lib.rs @@ -0,0 +1,61 @@ +//! OAGW gear — foundation. +//! +//! The root of the OAGW feature graph: the registered ToolKit gear, the +//! `OagwConfig` surface, the DDD-Light crate skeleton, the `Upstream` / `Route` +//! / `Plugin` domain model, the GTS identifier catalogue with its +//! types-registry provisioning, and the canonical [`DomainError`] mapped to +//! RFC 9457. +//! +//! ## Layering +//! +//! - [`domain`] — business vocabulary, free of transport and persistence types +//! - [`api`] — transport layer; the only module allowed to touch `axum`/`http` +//! - [`gts`] — GTS identifier catalogue and its types-registry provisioning +//! - [`plugins`] — the built-in plugin implementations, the `cred://` routine, +//! and the OAuth2 token cache +//! - [`config`] — the gear configuration surface +//! - [`store`] — the in-process transactional configuration store +//! - [`control_plane`] — the management business logic, one module per CDSL +//! routine, free of transport types +//! - [`gear`] — ToolKit gear wiring + +#![forbid(unsafe_code)] +#![deny(rust_2018_idioms)] + +pub mod api; +pub mod config; +pub mod control_plane; +pub mod data_plane; +pub mod domain; +pub mod gear; +pub mod gts; +pub mod plugins; +pub mod store; + +pub use api::rest::dto; +pub use api::rest::params; +pub use api::rest::problem; +pub use api::rest::state::OagwState; +pub use config::{ConfigError, OagwConfig, SsrfPolicy}; +pub use domain::{ + Algorithm, Alias, AliasError, AuthConfig, Burst, CorsConfig, DomainError, Endpoint, + EndpointHost, ErrorContext, ErrorKind, ErrorSource, GearFoundationState, GrpcMatch, + HeadersConfig, Hostname, HttpMatch, InvalidTransition, MatchConfig, ModelError, Passthrough, + PathSuffixMode, Plugin, PluginsConfig, RateLimitConfig, RateLimitScope, RequestHeaderRules, + ResponseHeaderRules, Route, Scheme, ServerConfig, SharingMode, Strategy, Sustained, Upstream, + Window, +}; +pub use control_plane::cache::{ControlPlaneCache, DeletionObservers, RateLimitCleanup}; +pub use control_plane::odata::{ListQuery, Page}; +pub use control_plane::service::{Lifecycle, ManagementService, ServiceError}; +pub use gear::OagwGear; +pub use store::{RouteRow, UpstreamRow}; +pub use gts::{ + AUTH_PLUGIN_TYPE, ERR_ALIAS_CONFLICT, ERR_AUTH_FAILED, ERR_CIRCUIT_BREAKER_OPEN, + ERR_DOWNSTREAM_ERROR, ERR_INVALID_TARGET_HOST, ERR_LINK_UNAVAILABLE, ERR_MATCH_CONFLICT, + ERR_MISSING_TARGET_HOST, ERR_PAYLOAD_TOO_LARGE, ERR_PLUGIN_IN_USE, ERR_PLUGIN_NOT_FOUND, + ERR_PROTOCOL_ERROR, ERR_RATE_LIMIT_EXCEEDED, ERR_ROUTE_NOT_FOUND, ERR_SECRET_NOT_FOUND, + ERR_STREAM_ABORTED, ERR_TIMEOUT_CONNECTION, ERR_TIMEOUT_IDLE, ERR_TIMEOUT_REQUEST, + ERR_UNKNOWN_TARGET_HOST, ERR_VALIDATION, GUARD_PLUGIN_TYPE, PROTOCOL_GRPC, PROTOCOL_HTTP, + PROTOCOL_TYPE, ROUTE_TYPE, TRANSFORM_PLUGIN_TYPE, UPSTREAM_TYPE, +}; diff --git a/gears/system/oagw/oagw/src/plugins/builtin.rs b/gears/system/oagw/oagw/src/plugins/builtin.rs new file mode 100644 index 0000000..53906f4 --- /dev/null +++ b/gears/system/oagw/oagw/src/plugins/builtin.rs @@ -0,0 +1,434 @@ +//! The six built-in plugin implementations the built-in catalogue backs. +//! +//! Realizes `cpt-cf-oagw-dod-builtin-catalogue`: the four auth identifiers, +//! the one guard identifier, and the one transform identifier that a registry +//! backs at initialization, each backed by the implementation its identifier +//! names and nothing else. The six catalog-only identifiers of the same +//! catalogue have no implementation here — `basic` and `bearer` have no +//! backing `AuthPlugin` anywhere, `timeout` and `cors` are core data-plane +//! behaviour, and `logging` and `metrics` are core data-plane instrumentation. +//! +//! Every implementation is stateless in the credential sense: material enters +//! through [`crate::plugins::credential::resolve_credential`] and leaves in +//! the context's headers, and no failure value names a reference, a material, +//! or an endpoint. + +use std::sync::Arc; + +use credstore_sdk::CredStoreClientV1; +use serde_json::Value; +use toolkit_auth::{ClientAuthMethod, OAuthClientConfig, SecretString, TokenError, fetch_token}; +use toolkit_security::SecurityContext; +use url::Url; + +use std::collections::BTreeMap; + +use crate::domain::context::{AuthContext, RequestContext, ResponseContext}; +use crate::domain::error::ErrorContext; +use crate::domain::plugin_contract::{ + AuthPlugin, GuardDecision, GuardPlugin, PluginFailure, PluginPhase, TransformPlugin, +}; +use crate::plugins::credential::{credential_key, resolve_credential}; +use crate::plugins::token_cache::{TokenCache, cache_key}; + +/// The header the `apikey` variant injects when the configuration names none. +pub const DEFAULT_API_KEY_HEADER: &str = "x-api-key"; + +/// The header the `request_id` transform propagates. +pub const REQUEST_ID_HEADER: &str = "x-request-id"; + +/// The code a required-headers rejection carries, in either phase (ADR 0009). +pub const REQUIRED_HEADER_MISSING: &str = "REQUIRED_HEADER_MISSING"; + +/// The configuration key the `apikey` variant reads its reference from. +pub const KEY_CREDENTIAL_REF: &str = "credential_ref"; +/// The configuration key the `apikey` variant reads its header name from. +pub const KEY_HEADER_NAME: &str = "header_name"; +/// The configuration key of the OAuth2 client identifier reference. +pub const KEY_CLIENT_ID_REF: &str = "client_id_ref"; +/// The configuration key of the OAuth2 client secret reference. +pub const KEY_CLIENT_SECRET_REF: &str = "client_secret_ref"; +/// The configuration key of the direct token endpoint. +pub const KEY_TOKEN_ENDPOINT: &str = "token_endpoint"; +/// The configuration key of the OIDC issuer the token endpoint is discovered +/// from. +pub const KEY_ISSUER_URL: &str = "issuer_url"; +/// The configuration key of the space-separated scope list. +pub const KEY_SCOPES: &str = "scopes"; +/// The configuration key of the request-phase header list. +pub const KEY_REQUIRED_REQUEST_HEADERS: &str = "required_request_headers"; +/// The configuration key of the response-phase header list. +pub const KEY_REQUIRED_RESPONSE_HEADERS: &str = "required_response_headers"; + +/// Reads one string-valued key from a plugin configuration. +fn string_field<'a>(config: &'a Value, key: &str) -> Option<&'a str> { + config.get(key).and_then(Value::as_str) +} + +/// The auth method tag a Client Credentials variant carries in its cache key. +fn auth_method_tag(method: ClientAuthMethod) -> &'static str { + match method { + ClientAuthMethod::Basic => "basic", + ClientAuthMethod::Form => "form", + } +} + +/// The security context one request's credential resolution runs under. +/// +/// The context is the projection of the `AuthContext` the data plane built: +/// the subject tenant it was authenticated under and the subject identifier it +/// carried, or the anonymous context when the request carried no subject. It +/// is what the credential store applies its own sharing policy to. +fn security_context(ctx: &AuthContext) -> Result { + SecurityContext::builder() + .subject_id(ctx.subject_id().unwrap_or_default()) + .subject_tenant_id(ctx.tenant_id) + .build() + .map_err(|_| PluginFailure::Configuration { + reason: String::from("the request carried no resolvable identity"), + }) +} + +/// The auth plugin that performs no authentication. +/// +/// The variant exists so an upstream that needs no credential can still carry +/// the one auth slot every upstream has, and so an `auth.type` naming it is +/// resolved rather than rejected. +pub struct NoopAuthPlugin; + +#[async_trait::async_trait] +impl AuthPlugin for NoopAuthPlugin { + fn declares(&self, phase: PluginPhase) -> bool { + matches!(phase, PluginPhase::Auth) + } + + async fn authenticate( + &self, + _ctx: &mut AuthContext, + _config: &Value, + ) -> Result<(), PluginFailure> { + Ok(()) + } +} + +/// The auth plugin that injects an API key resolved from the credential store. +/// +/// The configuration names the reference the key is held under and the header +/// it is injected as. The reference is validated for shape and resolved at +/// request time; the material is injected and never stored anywhere else. +pub struct ApiKeyAuthPlugin { + store: Arc, +} + +impl ApiKeyAuthPlugin { + /// Builds the plugin over the store its references resolve through. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } +} + +#[async_trait::async_trait] +impl AuthPlugin for ApiKeyAuthPlugin { + fn declares(&self, phase: PluginPhase) -> bool { + matches!(phase, PluginPhase::Auth) + } + + async fn authenticate(&self, ctx: &mut AuthContext, config: &Value) -> Result<(), PluginFailure> { + let reference = + string_field(config, KEY_CREDENTIAL_REF).ok_or(PluginFailure::Configuration { + reason: String::from("credential_ref is absent"), + })?; + credential_key(reference)?; + let header = string_field(config, KEY_HEADER_NAME).unwrap_or(DEFAULT_API_KEY_HEADER); + + // @cpt-begin:cpt-cf-oagw-algo-credential-resolution:p1:inst-cred-shape + let context = security_context(ctx)?; + let material = resolve_credential(Arc::clone(&self.store), &context, reference).await?; + // @cpt-end:cpt-cf-oagw-algo-credential-resolution:p1:inst-cred-shape + + ctx.set_header(header, material.expose()); + Ok(()) + } +} + +/// The Client Credentials auth plugin, in its `Form` and `Basic` variants. +/// +/// One instance per variant. The instance owns the token cache it serves and +/// the credential store its two references resolve through; it spawns nothing +/// and holds nothing across requests but the cache. +pub struct OAuth2ClientCredAuthPlugin { + store: Arc, + auth_method: ClientAuthMethod, + tag: &'static str, + cache: TokenCache, +} + +impl OAuth2ClientCredAuthPlugin { + /// Builds one variant over the store, the client-auth method, and the + /// cache the registry was constructed with. + #[must_use] + pub fn new( + store: Arc, + auth_method: ClientAuthMethod, + cache: TokenCache, + ) -> Self { + Self { + store, + auth_method, + tag: auth_method_tag(auth_method), + cache, + } + } +} + +#[async_trait::async_trait] +impl AuthPlugin for OAuth2ClientCredAuthPlugin { + fn declares(&self, phase: PluginPhase) -> bool { + matches!(phase, PluginPhase::Auth) + } + + // @cpt-begin:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-invoke + // The entry the Data Plane's invocation of `authenticate()` reaches: this + // method is the boundary the flow names, and every step below it is the + // credential-resolution contract it exposes. + async fn authenticate(&self, ctx: &mut AuthContext, config: &Value) -> Result<(), PluginFailure> { + // @cpt-begin:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-shape + let client_id_ref = credential_field(config, KEY_CLIENT_ID_REF)?; + let client_secret_ref = credential_field(config, KEY_CLIENT_SECRET_REF)?; + // @cpt-end:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-shape + + // @cpt-begin:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-key + let key = cache_key(ctx.tenant_id, ctx.subject_id(), self.tag, config); + // @cpt-end:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-key + + // @cpt-begin:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-hit-if + // A hit whose stored key equals the lookup key is served with no + // credential-store call and no exchange call at all. + if let Some(token) = self.cache.lookup(&key) { + // @cpt-end:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-hit-if + // @cpt-begin:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-hit + inject_bearer(ctx, token.expose()); + return Ok(()); + // @cpt-end:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-hit + } + // @cpt-begin:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-miss-else + let context = security_context(ctx)?; + // @cpt-begin:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-resolve + // The two references resolve through the credential routine in stored + // order, and the first one the store cannot answer fails the plugin. + let client_id = + resolve_credential(Arc::clone(&self.store), &context, client_id_ref).await?; + let client_secret = + resolve_credential(Arc::clone(&self.store), &context, client_secret_ref).await?; + // @cpt-end:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-resolve + // @cpt-end:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-miss-else + + let client_config = + client_credentials_config(config, self.auth_method, client_id, client_secret)?; + + // @cpt-begin:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-fetch-try + // @cpt-begin:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-fetch + let exchanged = fetch_token(client_config).await; + // @cpt-end:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-fetch + // @cpt-end:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-fetch-try + + // @cpt-begin:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-fetch-catch + // @cpt-begin:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-fetch-catch-handle + let fetched = exchanged.map_err(exchange_failure)?; + // @cpt-end:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-fetch-catch-handle + // @cpt-end:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-fetch-catch + + // @cpt-begin:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-ttl + // The entry's lifetime is the ceiling or the reported lifetime less the + // margin, whichever is shorter; the cache refuses the entry itself when + // the reported lifetime is at or below the margin. + let bearer = fetched.bearer; + let lifetime = fetched.expires_in; + // @cpt-end:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-ttl + + // @cpt-begin:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-store-if + // @cpt-begin:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-store + self.cache.store(&key, bearer.clone(), lifetime); + // @cpt-end:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-store + // @cpt-end:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-store-if + + // @cpt-begin:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-return + inject_bearer(ctx, bearer.expose()); + Ok(()) + // @cpt-end:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-return + } + // @cpt-end:cpt-cf-oagw-flow-oauth2-token-cache:p1:inst-tc-invoke +} + +/// Reads one credential reference out of the plugin configuration, refusing a +/// configuration that names none. +fn credential_field<'a>(config: &'a Value, key: &str) -> Result<&'a str, PluginFailure> { + let reference = string_field(config, key).ok_or(PluginFailure::Configuration { + reason: format!("{key} is absent"), + })?; + credential_key(reference)?; + Ok(reference) +} + +/// Builds the client-credentials configuration the one-shot exchange runs +/// with. +fn client_credentials_config( + config: &Value, + auth_method: ClientAuthMethod, + client_id: SecretString, + client_secret: SecretString, +) -> Result { + let token_endpoint = string_field(config, KEY_TOKEN_ENDPOINT); + let issuer_url = string_field(config, KEY_ISSUER_URL); + if token_endpoint.is_none() && issuer_url.is_none() { + return Err(PluginFailure::Configuration { + reason: String::from("one of token_endpoint or issuer_url is required"), + }); + } + let parse = |value: &str, key: &str| -> Result { + Url::parse(value).map_err(|_| PluginFailure::Configuration { + reason: format!("{key} is not a URL"), + }) + }; + Ok(OAuthClientConfig { + token_endpoint: token_endpoint + .map(|value| parse(value, KEY_TOKEN_ENDPOINT)) + .transpose()?, + issuer_url: issuer_url + .map(|value| parse(value, KEY_ISSUER_URL)) + .transpose()?, + client_id: client_id.expose().to_owned(), + client_secret, + scopes: scopes(config), + auth_method, + ..OAuthClientConfig::default() + }) +} + +/// The scope list the configuration asks for: space-separated, or an array. +fn scopes(config: &Value) -> Vec { + match config.get(KEY_SCOPES) { + Some(Value::String(value)) => value.split_whitespace().map(str::to_owned).collect(), + Some(Value::Array(items)) => items + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(), + _ => Vec::new(), + } +} + +/// Maps a failed exchange onto the typed failures the catalogue names. +/// +/// The token endpoint's answer is not the credential store's decline: an +/// exchange that fails after it was sent is an availability failure of the +/// token path, and one that was refused before it was sent is a configuration +/// failure. The mapped value carries no endpoint URL and no credential +/// material. +fn exchange_failure(error: TokenError) -> PluginFailure { + match error { + TokenError::ConfigError(_) => PluginFailure::Configuration { + reason: String::from("the token exchange configuration is invalid"), + }, + _ => PluginFailure::Unavailable, + } +} + +/// Writes the bearer value into the context's authorization header. +fn inject_bearer(ctx: &mut AuthContext, bearer: &str) { + ctx.set_header("authorization", format!("Bearer {bearer}")); +} + +/// The guard plugin that rejects a request or an upstream response that omits +/// a configured header (ADR 0009). +/// +/// Both phases are independent and fail open: absent or blank configuration is +/// a no-op in the phase that ran. Only presence is checked, case-insensitively, +/// and only the first missing header is reported. +pub struct RequiredHeadersGuardPlugin; + +/// Reads the comma-separated header list one phase checks for. +fn required_headers(config: &Value, key: &str) -> Vec { + match string_field(config, key) { + Some(value) => value + .split(',') + .map(str::trim) + .map(str::to_ascii_lowercase) + .filter(|name| !name.is_empty()) + .collect(), + None => Vec::new(), + } +} + +/// The first configured header the context does not carry, if any. +/// +/// Both contexts spell their names lowercased, and the configured list is +/// lowercased when it is read, so the comparison is the case-insensitive one +/// ADR 0009 asks for. +fn first_missing(required: &[String], headers: &BTreeMap) -> Option { + required + .iter() + .find(|name| !headers.contains_key(*name)) + .cloned() +} + +impl GuardPlugin for RequiredHeadersGuardPlugin { + fn declares(&self, phase: PluginPhase) -> bool { + matches!(phase, PluginPhase::GuardRequest | PluginPhase::GuardResponse) + } + + fn guard_request(&self, ctx: &RequestContext, config: &Value) -> GuardDecision { + let required = required_headers(config, KEY_REQUIRED_REQUEST_HEADERS); + match first_missing(&required, &ctx.headers) { + None => GuardDecision::Allow, + Some(missing) => GuardDecision::reject(REQUIRED_HEADER_MISSING, &missing), + } + } + + fn guard_response(&self, ctx: &ResponseContext, config: &Value) -> GuardDecision { + let required = required_headers(config, KEY_REQUIRED_RESPONSE_HEADERS); + match first_missing(&required, &ctx.headers) { + None => GuardDecision::Allow, + Some(missing) => GuardDecision::reject(REQUIRED_HEADER_MISSING, &missing), + } + } +} + +/// The transform plugin that propagates the request identifier. +/// +/// A request that arrived with one keeps it; a request that arrived without one +/// is given a fresh identifier, which the response carries as well. No +/// configuration is required. +pub struct RequestIdTransformPlugin; + +impl TransformPlugin for RequestIdTransformPlugin { + fn declares(&self, phase: PluginPhase) -> bool { + matches!( + phase, + PluginPhase::TransformRequest + | PluginPhase::TransformResponse + | PluginPhase::TransformError + ) + } + + fn transform_request(&self, ctx: &mut RequestContext, _config: &Value) { + if ctx.header(REQUEST_ID_HEADER).is_none() { + ctx.set_header(REQUEST_ID_HEADER, uuid::Uuid::new_v4().to_string()); + } + } + + fn transform_response(&self, ctx: &mut ResponseContext, _config: &Value) { + if ctx.header(REQUEST_ID_HEADER).is_none() { + ctx.set_header(REQUEST_ID_HEADER, uuid::Uuid::new_v4().to_string()); + } + } + + fn transform_error(&self, ctx: &mut ErrorContext, _config: &Value) { + // The error context carries no header set: its correlation slot is the + // trace identifier, so the identifier propagates there. + if ctx.trace_id.is_none() { + ctx.trace_id = Some(uuid::Uuid::new_v4().to_string()); + } + } +} diff --git a/gears/system/oagw/oagw/src/plugins/chain.rs b/gears/system/oagw/oagw/src/plugins/chain.rs new file mode 100644 index 0000000..fc0206a --- /dev/null +++ b/gears/system/oagw/oagw/src/plugins/chain.rs @@ -0,0 +1,400 @@ +//! Plugin chain composition and execution order. +//! +//! Realizes `cpt-cf-oagw-algo-chain-compose`: the per-phase sub-chains the +//! effective plugin binding sets compose to, in the order DESIGN §3.2 Plugin +//! System states — auth, then guards, then transforms on the request, then the +//! upstream call, then guards and transforms on the response, then the error +//! transform when the call fails. Within a phase the upstream layer's +//! positions run before the route layer's, and within a layer the stored +//! position decides, so `[U1, U2] + [R1, R2]` composes to `[U1, U2, R1, R2]`. +//! +//! The composition composes only the binding sets it is given: the +//! cross-layer concatenation of an ancestor's and a descendant's set is the +//! merge `cpt-cf-oagw-feature-hierarchical-config` performs, and it is never +//! re-derived here. Nothing here runs a plugin — the composed chain is the +//! schedule the data plane executes, and a custom binding is carried as the +//! persisted row whose source that execution runs. + +use std::sync::Arc; + +use serde_json::Value; +use uuid::Uuid; + +use crate::control_plane::binding::{self, ResolvedPlugin}; +use crate::domain::error::{DomainError, ErrorKind}; +use crate::domain::plugin::Plugin; +use crate::domain::plugin_contract::{PluginFamily, PluginPhase}; +use crate::plugins::PluginRegistries; +use crate::store::PluginBinding; + +/// The upstream's one auth plugin, as the composition resolved it from the +/// scalar identity columns. +#[derive(Clone)] +pub enum ComposedAuth { + /// The upstream binds no auth plugin, which resolves to the no-op + /// behaviour: the phase runs and injects nothing. + Noop, + /// A built-in auth implementation the registry holds. + Builtin { + /// The implementation the identifier resolved to. + plugin: Arc, + /// The configuration the upstream's `auth` sub-configuration carried. + config: Value, + }, + /// A custom auth row the data plane executes through the sandbox. + Custom { + /// The persisted row the identifier resolved to. + row: Plugin, + /// The configuration the upstream's `auth` sub-configuration carried. + config: Value, + }, +} + +impl std::fmt::Debug for ComposedAuth { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Noop => formatter.write_str("Noop"), + Self::Builtin { config, .. } => formatter.debug_tuple("Builtin").field(config).finish(), + Self::Custom { row, config } => formatter + .debug_struct("Custom") + .field("row", &row.id) + .field("config", config) + .finish(), + } + } +} + +/// One composed binding: a resolved plugin at one position of one layer, with +/// the configuration it was bound with. +#[derive(Clone)] +pub enum ComposedStep { + /// A built-in implementation the registry holds. + Builtin { + /// The canonical plugin identifier the binding was written with. + plugin_ref: String, + /// The chain position the binding was stored at. + position: u32, + /// The layer the binding came from: `true` for the upstream's own set. + upstream_layer: bool, + /// The configuration the binding carries. + config: Value, + /// The guard implementation, when the resolved plugin declares one. + guard: Option>, + /// The transform implementation, when the resolved plugin declares one. + transform: Option>, + }, + /// A persisted custom row the data plane executes through the sandbox. + Custom { + /// The canonical plugin identifier the binding was written with. + plugin_ref: String, + /// The chain position the binding was stored at. + position: u32, + /// The layer the binding came from: `true` for the upstream's own set. + upstream_layer: bool, + /// The configuration the binding carries. + config: Value, + /// The persisted row, whose declared phases the composition reads. + row: Plugin, + }, +} + +impl std::fmt::Debug for ComposedStep { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let (plugin_ref, position, upstream_layer, config) = match self { + Self::Builtin { + plugin_ref, + position, + upstream_layer, + config, + .. + } + | Self::Custom { + plugin_ref, + position, + upstream_layer, + config, + .. + } => (plugin_ref, position, upstream_layer, config), + }; + formatter + .debug_struct("ComposedStep") + .field("plugin_ref", plugin_ref) + .field("position", position) + .field("upstream_layer", upstream_layer) + .field("config", config) + .finish() + } +} + +impl ComposedStep { + /// The canonical plugin identifier the binding was written with. + #[must_use] + pub fn plugin_ref(&self) -> &str { + match self { + Self::Builtin { plugin_ref, .. } | Self::Custom { plugin_ref, .. } => plugin_ref, + } + } + + /// The chain position the binding was stored at. + #[must_use] + pub const fn position(&self) -> u32 { + match self { + Self::Builtin { position, .. } | Self::Custom { position, .. } => *position, + } + } + + /// The layer the binding came from: `true` for the upstream's own set. + #[must_use] + pub const fn upstream_layer(&self) -> bool { + match self { + Self::Builtin { upstream_layer, .. } | Self::Custom { upstream_layer, .. } => { + *upstream_layer + } + } + } + + /// The phases the resolved plugin declares. + /// + /// A built-in implementation declares them through its `declares` answers; + /// a persisted row declares them through the wire literals it was created + /// with, which the composition reads off the row it resolved. + #[must_use] + pub fn declares(&self, phase: PluginPhase) -> bool { + match self { + Self::Builtin { guard, transform, .. } => { + guard.as_ref().is_some_and(|guard| guard.declares(phase)) + || transform.as_ref().is_some_and(|transform| transform.declares(phase)) + } + Self::Custom { row, .. } => { + let family = PluginFamily::from_type_literal(&row.plugin_type) + .unwrap_or(PluginFamily::Transform); + row.phases + .iter() + .filter_map(|literal| { + crate::control_plane::plugin_def::phase_of(family, literal) + }) + .any(|declared| declared == phase) + } + } + } +} + +/// One composed chain: the upstream's auth plugin and the five per-phase +/// sub-chains the binding sets compose to, each in composed order. +#[derive(Clone)] +pub struct ComposedChain { + /// The auth step, resolved from the upstream's scalar identity columns. + pub auth: ComposedAuth, + /// Guards on the request. + pub guard_request: Vec, + /// Transforms on the request. + pub transform_request: Vec, + /// Guards on the response. + pub guard_response: Vec, + /// Transforms on the response. + pub transform_response: Vec, + /// Transforms on the error. + pub transform_error: Vec, +} + +impl std::fmt::Debug for ComposedChain { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ComposedChain") + .field("auth", &self.auth) + .field("guard_request", &self.guard_request) + .field("transform_request", &self.transform_request) + .field("guard_response", &self.guard_response) + .field("transform_response", &self.transform_response) + .field("transform_error", &self.transform_error) + .finish() + } +} + +/// Composes the per-phase sub-chains of one request's plugin schedule. +/// +/// The two layers are given in stored `position` order by the caller; the +/// composition orders them itself, upstream before route, so the caller cannot +/// hand it a layer order that contradicts DESIGN §3.2. +/// +/// # Errors +/// +/// Returns the 503 `PluginNotFound` gateway error for a composed binding that +/// resolves to no implementation, which is the answer the caller forwards; the +/// composition never silently drops a binding it was given. +#[allow(clippy::result_large_err)] +pub fn compose( + store: &crate::store::OagwStore, + tenant_id: Uuid, + named: &crate::domain::plugin_contract::NamedPluginRegistry, + registries: &PluginRegistries, + auth: Option<(&str, Option, &Value)>, + upstream: &[PluginBinding], + route: &[PluginBinding], +) -> Result { + // @cpt-begin:cpt-cf-oagw-algo-chain-compose:p1:inst-compose-auth + // The upstream's single auth plugin is resolved through the + // reference-resolution routine from the scalar identity columns; an + // upstream with none resolves to the no-op behaviour, and a route + // contributes no auth phase at all. + let composed_auth = match auth { + None => ComposedAuth::Noop, + Some((plugin_ref, plugin_uuid, config)) => { + let resolved = binding::resolve(store, tenant_id, named, plugin_ref, plugin_uuid) + .map_err(|failure| missing(&failure.reason()))?; + match resolved { + ResolvedPlugin::Named { .. } => ComposedAuth::Builtin { + plugin: registries.auth.resolve(plugin_ref).map_err(|_| { + missing("the bound auth plugin is no longer registered") + })?, + config: config.clone(), + }, + ResolvedPlugin::Custom { id, .. } => ComposedAuth::Custom { + row: store + .get_plugin(tenant_id, id) + .map(|row| row.plugin) + .ok_or_else(|| missing("the bound auth plugin is no longer stored"))?, + config: config.clone(), + }, + } + } + }; + // @cpt-end:cpt-cf-oagw-algo-chain-compose:p1:inst-compose-auth + + // @cpt-begin:cpt-cf-oagw-algo-chain-compose:p1:inst-compose-order + // The upstream layer's guard and transform bindings are ordered by + // `position`, then the route layer's, and the two are concatenated in that + // order. + let mut ordered: Vec<(bool, &PluginBinding)> = Vec::with_capacity(upstream.len() + route.len()); + ordered.extend(upstream.iter().map(|item| (true, item))); + ordered.extend(route.iter().map(|item| (false, item))); + // @cpt-end:cpt-cf-oagw-algo-chain-compose:p1:inst-compose-order + + let mut composed: Vec = Vec::with_capacity(ordered.len()); + // @cpt-begin:cpt-cf-oagw-algo-chain-compose:p1:inst-compose-loop + for (upstream_layer, item) in ordered { + // @cpt-begin:cpt-cf-oagw-algo-chain-compose:p1:inst-compose-resolve + // Each composed binding is resolved through the reference-resolution + // routine, and the phases the implementation declares are recorded + // beside it. + let resolved = binding::resolve( + store, + tenant_id, + named, + &item.plugin_ref, + item.plugin_uuid, + ) + .map_err(|failure| missing(&failure.reason()))?; + // @cpt-end:cpt-cf-oagw-algo-chain-compose:p1:inst-compose-resolve + + let step = match resolved { + ResolvedPlugin::Named { family, .. } => { + let (guard, transform) = implementations(registries, family, &item.plugin_ref)?; + ComposedStep::Builtin { + plugin_ref: item.plugin_ref.clone(), + position: item.position, + upstream_layer, + config: item.config.clone(), + guard, + transform, + } + } + ResolvedPlugin::Custom { id, .. } => ComposedStep::Custom { + plugin_ref: item.plugin_ref.clone(), + position: item.position, + upstream_layer, + config: item.config.clone(), + row: store + .get_plugin(tenant_id, id) + .map(|row| row.plugin) + .ok_or_else(|| missing("the bound plugin is no longer stored"))?, + }, + }; + composed.push(step); + } + // @cpt-end:cpt-cf-oagw-algo-chain-compose:p1:inst-compose-loop + + // @cpt-begin:cpt-cf-oagw-algo-chain-compose:p1:inst-compose-missing-if + // A composed binding that resolved to no implementation is reported to the + // caller rather than dropped: the bound plugin row was deleted after the + // binding was written, or its identifier is no longer registered. + if composed.len() != upstream.len() + route.len() { + // @cpt-begin:cpt-cf-oagw-algo-chain-compose:p1:inst-compose-missing + return Err(missing("a bound plugin no longer resolves")); + // @cpt-end:cpt-cf-oagw-algo-chain-compose:p1:inst-compose-missing + } + // @cpt-end:cpt-cf-oagw-algo-chain-compose:p1:inst-compose-missing-if + + // @cpt-begin:cpt-cf-oagw-algo-chain-compose:p1:inst-compose-phase-loop + let phases = [ + (PluginPhase::GuardRequest, 0_u8), + (PluginPhase::TransformRequest, 1), + (PluginPhase::GuardResponse, 2), + (PluginPhase::TransformResponse, 3), + (PluginPhase::TransformError, 4), + ]; + let mut subchains: [Vec; 5] = Default::default(); + for (phase, slot) in phases { + // @cpt-begin:cpt-cf-oagw-algo-chain-compose:p1:inst-compose-phase + // The sub-chain of composed bindings whose implementation declares + // that phase, preserving the composed order within it. + subchains[usize::from(slot)] = composed + .iter() + .filter(|step| step.declares(phase)) + .cloned() + .collect(); + // @cpt-end:cpt-cf-oagw-algo-chain-compose:p1:inst-compose-phase + } + // @cpt-end:cpt-cf-oagw-algo-chain-compose:p1:inst-compose-phase-loop + + // @cpt-begin:cpt-cf-oagw-algo-chain-compose:p1:inst-compose-return + let [guard_request, transform_request, guard_response, transform_response, transform_error] = + subchains; + Ok(ComposedChain { + auth: composed_auth, + guard_request, + transform_request, + guard_response, + transform_response, + transform_error, + }) + // @cpt-end:cpt-cf-oagw-algo-chain-compose:p1:inst-compose-return +} + +/// The implementations one named plugin identifier answers: at most one of the +/// two, and always the one its family's own registry backs. +type NamedImpls = ( + Option>, + Option>, +); + +/// The implementations one named plugin identifier answers in its own family's +/// registry, and in no other. +#[allow(clippy::result_large_err)] +fn implementations( + registries: &PluginRegistries, + family: PluginFamily, + plugin_ref: &str, +) -> Result { + match family { + PluginFamily::Auth => Ok((None, None)), + PluginFamily::Guard => Ok(( + Some(registries.guard.resolve(plugin_ref).map_err(|_| { + missing("the bound guard plugin is no longer registered") + })?), + None, + )), + PluginFamily::Transform => Ok(( + None, + Some(registries.transform.resolve(plugin_ref).map_err(|_| { + missing("the bound transform plugin is no longer registered") + })?), + )), + } +} + +/// The 503 the unresolved reference is reported with, which the caller answers +/// through the foundation catalogue's `PluginNotFound` variant. +fn missing(reason: &str) -> DomainError { + DomainError::gateway(ErrorKind::PluginNotFound, reason) +} diff --git a/gears/system/oagw/oagw/src/plugins/credential.rs b/gears/system/oagw/oagw/src/plugins/credential.rs new file mode 100644 index 0000000..0c9e7c3 --- /dev/null +++ b/gears/system/oagw/oagw/src/plugins/credential.rs @@ -0,0 +1,157 @@ +//! Credential-reference resolution: the `cred://` routine. +//! +//! Realizes `cpt-cf-oagw-algo-credential-resolution` and the +//! `cpt-cf-oagw-principle-cred-isolation` obligations it carries: this module +//! is the only thing in the gear that turns a credential reference into +//! material, it validates the reference for shape only, and it resolves the +//! material through nothing but the credential store, at request time, never +//! at management time. +//! +//! No failure value this module returns carries the reference it failed on or +//! the material it failed to resolve: [`PluginFailure`] names a shape, a +//! missing secret, a decline, an unreachable store, or an unusable +//! configuration, and names nothing else. +//! +//! Realizes `cpt-cf-oagw-dod-credential-isolation`. + +// @cpt-dod:cpt-cf-oagw-dod-credential-isolation:p1 + +use std::sync::Arc; + +use credstore_sdk::error::CredStoreError; +use credstore_sdk::models::SecretRef; +use credstore_sdk::CredStoreClientV1; +use toolkit_auth::SecretString; +use toolkit_security::SecurityContext; + +use crate::domain::plugin_contract::PluginFailure; + +/// The only credential scheme the gear accepts. +pub const CREDENTIAL_SCHEME: &str = "cred://"; + +/// Whether one string is a credential reference the store could be asked to +/// resolve: the `cred://` scheme, a non-empty remainder, no surrounding +/// whitespace, no fragment, and a remainder the credential store accepts as +/// its own key spelling. +/// +/// A reference that fails this check fails before any credential-store call, +/// and the answer carries no reason that names the reference. +#[must_use] +pub fn is_credential_reference(reference: &str) -> bool { + if reference != reference.trim() { + return false; + } + let Some(remainder) = reference.strip_prefix(CREDENTIAL_SCHEME) else { + return false; + }; + if remainder.is_empty() || remainder.contains('#') { + return false; + } + SecretRef::new(remainder).is_ok() +} + +/// Validates one reference for shape and returns the key the store resolves. +/// +/// # Errors +/// +/// Returns [`PluginFailure::CredentialShape`] for a reference that +/// [`is_credential_reference`] declines, before any store call is made. +pub fn credential_key(reference: &str) -> Result { + // @cpt-begin:cpt-cf-oagw-algo-credential-resolution:p1:inst-cred-shape + // The shape check is the whole of what the gear asks of a reference + // before the store: anything else is the store's own sharing policy. + let remainder = reference + .strip_prefix(CREDENTIAL_SCHEME) + .ok_or(PluginFailure::CredentialShape)?; + let key = SecretRef::new(remainder).map_err(|_| PluginFailure::CredentialShape)?; + Ok(key) + // @cpt-end:cpt-cf-oagw-algo-credential-resolution:p1:inst-cred-shape +} + +/// Resolves one credential reference into its material. +/// +/// The store applies its own sharing policy, including the ancestor sharing +/// the hierarchical resolution performs, so this routine asks it nothing about +/// who may read what: it hands over the calling tenant and subject and maps +/// the answer onto the typed failures the caller maps onto the catalogue. +/// +/// # Errors +/// +/// Returns [`PluginFailure::CredentialShape`] before any store call when the +/// reference is malformed, [`PluginFailure::SecretNotFound`] when the store +/// answers no material, [`PluginFailure::AuthenticationFailed`] when the store +/// declines the reference for the calling tenant or subject, +/// [`PluginFailure::Unavailable`] when the store is unreachable or fails, and +/// [`PluginFailure::Configuration`] when the material it returned cannot be +/// carried as text. +pub async fn resolve_credential( + store: Arc, + security_context: &SecurityContext, + reference: &str, +) -> Result { + let key = credential_key(reference)?; + + // @cpt-begin:cpt-cf-oagw-algo-credential-resolution:p1:inst-cred-try + // @cpt-begin:cpt-cf-oagw-algo-credential-resolution:p1:inst-cred-call + let answer = store.get(security_context, &key).await; + // @cpt-end:cpt-cf-oagw-algo-credential-resolution:p1:inst-cred-call + // @cpt-begin:cpt-cf-oagw-algo-credential-resolution:p1:inst-cred-catch + // @cpt-begin:cpt-cf-oagw-algo-credential-resolution:p1:inst-cred-catch-handle + let response = answer.map_err(store_failure)?; + // @cpt-end:cpt-cf-oagw-algo-credential-resolution:p1:inst-cred-catch-handle + // @cpt-end:cpt-cf-oagw-algo-credential-resolution:p1:inst-cred-catch + // @cpt-begin:cpt-cf-oagw-algo-credential-resolution:p1:inst-cred-missing-if + // @cpt-begin:cpt-cf-oagw-algo-credential-resolution:p1:inst-cred-missing-return + let value = response.ok_or(PluginFailure::SecretNotFound)?; + // @cpt-end:cpt-cf-oagw-algo-credential-resolution:p1:inst-cred-missing-return + // @cpt-end:cpt-cf-oagw-algo-credential-resolution:p1:inst-cred-missing-if + // @cpt-begin:cpt-cf-oagw-algo-credential-resolution:p1:inst-cred-ok-else + let material = String::from_utf8(value.value.as_bytes().to_vec()) + .map_err(|_| PluginFailure::Configuration { + reason: String::from("credential material is not valid UTF-8"), + })?; + // @cpt-begin:cpt-cf-oagw-algo-credential-resolution:p1:inst-cred-ok + // @cpt-begin:cpt-cf-oagw-algo-credential-resolution:p1:inst-cred-return + Ok(SecretString::new(material)) + // @cpt-end:cpt-cf-oagw-algo-credential-resolution:p1:inst-cred-return + // @cpt-end:cpt-cf-oagw-algo-credential-resolution:p1:inst-cred-ok + // @cpt-end:cpt-cf-oagw-algo-credential-resolution:p1:inst-cred-ok-else + // @cpt-end:cpt-cf-oagw-algo-credential-resolution:p1:inst-cred-try +} + +/// Maps a store failure onto the typed failures the catalogue names. +/// +/// The mapping is one-directional and carries no detail from the store: a +/// decline is an authentication failure, an absent secret is a missing secret, +/// and everything else is a store the gear could not reach. +fn store_failure(error: CredStoreError) -> PluginFailure { + match error { + // @cpt-begin:cpt-cf-oagw-algo-credential-resolution:p1:inst-cred-declined-if + // @cpt-begin:cpt-cf-oagw-algo-credential-resolution:p1:inst-cred-declined-return + CredStoreError::AccessDenied => PluginFailure::AuthenticationFailed, + // @cpt-end:cpt-cf-oagw-algo-credential-resolution:p1:inst-cred-declined-return + // @cpt-end:cpt-cf-oagw-algo-credential-resolution:p1:inst-cred-declined-if + CredStoreError::NotFound => PluginFailure::SecretNotFound, + CredStoreError::InvalidSecretRef { .. } => PluginFailure::CredentialShape, + _ => PluginFailure::Unavailable, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_reference_with_an_inner_space_is_malformed() { + assert!(!is_credential_reference("cred://api key")); + assert!(!is_credential_reference("cred://api-key\n")); + } + + #[test] + fn a_shape_failure_names_nothing() { + assert!(matches!( + credential_key("https://api-key"), + Err(PluginFailure::CredentialShape) + )); + } +} diff --git a/gears/system/oagw/oagw/src/plugins/mod.rs b/gears/system/oagw/oagw/src/plugins/mod.rs new file mode 100644 index 0000000..85fb2ea --- /dev/null +++ b/gears/system/oagw/oagw/src/plugins/mod.rs @@ -0,0 +1,109 @@ +//! The plugin implementations the plugin-system feature delivers. +//! +//! Three members: [`credential`] is the `cred://` routine that is the only +//! thing in the gear that turns a reference into material, [`token_cache`] is +//! the OAuth2 entry cache whose stored key is verified on every hit, and +//! [`builtin`] is the six implementations the built-in catalogue backs. The +//! registries that hold them are declared beside the contracts they serve, in +//! [`crate::domain::plugin_contract`]. +//! +//! Nothing here is reachable through an endpoint: material and cached tokens +//! enter and leave through the contexts the data plane builds. + +pub mod builtin; +pub mod chain; +pub mod credential; +pub mod token_cache; + +use std::sync::Arc; + +use credstore_sdk::CredStoreClientV1; +use toolkit_security::SecurityContext; + +pub use builtin::{ + ApiKeyAuthPlugin, NoopAuthPlugin, OAuth2ClientCredAuthPlugin, RequestIdTransformPlugin, + RequiredHeadersGuardPlugin, +}; +pub use chain::{ComposedAuth, ComposedChain, ComposedStep}; +pub use token_cache::{TokenCache, TokenCacheConfig}; + +use crate::domain::plugin_contract::{ + AuthPluginRegistry, GuardPluginRegistry, TransformPluginRegistry, +}; + +/// The three registries the gear serves the plugin contracts through, built +/// once at initialization and shared by every caller that resolves an +/// identifier. +#[derive(Clone)] +pub struct PluginRegistries { + /// The auth implementations, one per upstream. + pub auth: AuthPluginRegistry, + /// The guard implementations, many per upstream and per route. + pub guard: GuardPluginRegistry, + /// The transform implementations, many per upstream and per route. + pub transform: TransformPluginRegistry, +} + +impl PluginRegistries { + /// Builds the three registries with the six backed built-in implementations + /// registered at initialization. + /// + /// The auth registry resolves its references through the credential store + /// it is handed and its two Client Credentials variants with the cache + /// ceilings it is handed; the guard and transform registries hold their + /// stateless implementations. + #[must_use] + pub fn with_builtins( + store: Arc, + token_cache: TokenCacheConfig, + ) -> Self { + Self { + auth: AuthPluginRegistry::with_builtins(store, token_cache), + guard: GuardPluginRegistry::with_builtins(), + transform: TransformPluginRegistry::with_builtins(), + } + } + + /// Builds the registries a deployment serves, whose credential store may + /// be absent. + /// + /// A gear the hub resolved no `cred_store` client for still mounts the six + /// built-ins, over [`UnavailableCredStore`]: every credential resolution + /// that store answers fails closed as `PluginFailure::Unavailable`, so a + /// chain bound to an auth plugin is refused at execution time rather than + /// being served with material that was never resolved. The guard and + /// transform families are stateless and unaffected. + #[must_use] + pub fn for_deployment( + store: Option>, + token_cache: TokenCacheConfig, + ) -> Self { + Self::with_builtins( + store.unwrap_or_else(|| Arc::new(UnavailableCredStore)), + token_cache, + ) + } +} + +/// The credential store a deployment without one serves. +/// +/// The gear mounts the plugin registries at initialization whatever the hub +/// resolved, because the token cache they hold is shared state a later +/// resolution cannot rebuild; this store stands in for the absent client and +/// turns every resolution into the typed unavailability the routine maps, so +/// no request is ever answered with material that was not resolved. +pub struct UnavailableCredStore; + +#[async_trait::async_trait] +impl CredStoreClientV1 for UnavailableCredStore { + async fn get( + &self, + _ctx: &SecurityContext, + _key: &credstore_sdk::SecretRef, + ) -> Result, credstore_sdk::CredStoreError> { + Err(credstore_sdk::CredStoreError::ServiceUnavailable { + detail: String::from("no credential store is mounted in this deployment"), + retry_after: None, + }) + } +} diff --git a/gears/system/oagw/oagw/src/plugins/token_cache.rs b/gears/system/oagw/oagw/src/plugins/token_cache.rs new file mode 100644 index 0000000..92b76c1 --- /dev/null +++ b/gears/system/oagw/oagw/src/plugins/token_cache.rs @@ -0,0 +1,289 @@ +//! The OAuth2 token cache: four-component keys, verified hits, margin-aware +//! TTLs. +//! +//! Realizes `cpt-cf-oagw-algo-token-cache` and the cache half of +//! `cpt-cf-oagw-flow-oauth2-token-cache`. The cache is plugin-internal: no +//! endpoint reaches it, nothing it holds is persisted, and no background task +//! refreshes it — the one-shot exchange returns and ends, and a revoked or +//! rotated token stays served until its entry expires, which is the staleness +//! window ADR 0008 accepts. +//! +//! Every entry carries the key it was stored under, and every hit verifies +//! that key against the key being looked up. A mismatch is a miss, so a hash +//! collision can never hand one tenant's token to another. Realizes +//! `cpt-cf-oagw-dod-token-cache`. + +// @cpt-dod:cpt-cf-oagw-dod-token-cache:p1 + +use std::time::Duration; + +use pingora_memory_cache::MemoryCache; +use serde_json::Value; +use toolkit_auth::SecretString; +use uuid::Uuid; + +/// The lifetime an entry loses before it is served: a token the IdP reports as +/// living 30 seconds or fewer is injected once and never cached, so no entry is +/// ever served that is already at expiry. +pub const TOKEN_CACHE_SAFETY_MARGIN_SECS: u64 = 30; + +/// The configured ceilings of the cache, threaded to the plugin constructors +/// through `AuthPluginRegistry::with_builtins`. +/// +/// The values come from the gear configuration the foundation validated: +/// `token_cache_ttl_secs` and `token_cache_capacity`, whose defaults are +/// ADR 0008's 300 and 10000. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TokenCacheConfig { + /// Ceiling of one entry's lifetime. + pub ttl: Duration, + /// Maximum number of entries; eviction at the ceiling is the cache's own + /// policy, and the ceiling never causes a request to fail. + pub capacity: usize, +} + +impl TokenCacheConfig { + /// Builds the ceilings the two OAuth2 variants are constructed with. + #[must_use] + pub fn new(ttl: Duration, capacity: usize) -> Self { + Self { ttl, capacity } + } +} + +/// One cached entry: the bearer value and the key it was stored under. +/// +/// The key travels with the material so the verification on hit can compare it +/// against the key being looked up — the defence ADR 0008 records against the +/// cache hashing its keys to `u64` and never comparing them again. +#[derive(Clone)] +struct CachedToken { + /// The full lookup key the entry was stored under. + key: String, + /// The bearer value, wrapped so eviction zeroes it. + token: SecretString, +} + +/// The cache the two OAuth2 Client Credentials variants share the shape of. +/// +/// One instance per plugin, in memory, sized to `capacity` and bounded by +/// `ttl`. A lookup that finds no entry, finds an expired entry, or finds an +/// entry whose stored key differs is a miss, and a miss sends the caller +/// through the fetch path with no material read and no reference resolved. +pub struct TokenCache { + entries: MemoryCache, + ttl: Duration, +} + +impl TokenCache { + /// Builds the cache over the configured ceilings. + #[must_use] + pub fn new(config: TokenCacheConfig) -> Self { + Self { + entries: MemoryCache::new(config.capacity), + ttl: config.ttl, + } + } + + /// Reads the entry at `key`, answering the material only when the entry's + /// stored key equals the key being looked up. + /// + /// An expired entry and a mismatched entry are both a miss; the expired + /// one is dropped by the cache's own policy. + #[must_use] + pub fn lookup(&self, key: &str) -> Option { + // @cpt-begin:cpt-cf-oagw-algo-token-cache:p1:inst-cache-return + // @cpt-begin:cpt-cf-oagw-algo-token-cache:p1:inst-cache-get-try + // @cpt-begin:cpt-cf-oagw-algo-token-cache:p1:inst-cache-get + let (entry, _status) = self.entries.get(&String::from(key)); + // @cpt-end:cpt-cf-oagw-algo-token-cache:p1:inst-cache-get + // The cache is an in-process structure with no failure mode of its + // own, so the unavailable-cache catch the artifact carries has no + // exceptional path to guard: a lookup that reads nothing is the miss + // the `?` below answers, and never a request failure. + // @cpt-begin:cpt-cf-oagw-algo-token-cache:p1:inst-cache-catch + // @cpt-begin:cpt-cf-oagw-algo-token-cache:p1:inst-cache-catch-handle + let cached = entry?; + // @cpt-end:cpt-cf-oagw-algo-token-cache:p1:inst-cache-catch-handle + // @cpt-end:cpt-cf-oagw-algo-token-cache:p1:inst-cache-catch + // @cpt-begin:cpt-cf-oagw-algo-token-cache:p1:inst-cache-verify-if + if cached.key != key { + // @cpt-begin:cpt-cf-oagw-algo-token-cache:p1:inst-cache-miss-else + // @cpt-begin:cpt-cf-oagw-algo-token-cache:p1:inst-cache-miss + return None; + // @cpt-end:cpt-cf-oagw-algo-token-cache:p1:inst-cache-miss + // @cpt-end:cpt-cf-oagw-algo-token-cache:p1:inst-cache-miss-else + } + // @cpt-begin:cpt-cf-oagw-algo-token-cache:p1:inst-cache-verify + Some(cached.token) + // @cpt-end:cpt-cf-oagw-algo-token-cache:p1:inst-cache-verify + // @cpt-end:cpt-cf-oagw-algo-token-cache:p1:inst-cache-verify-if + // @cpt-end:cpt-cf-oagw-algo-token-cache:p1:inst-cache-get-try + // @cpt-end:cpt-cf-oagw-algo-token-cache:p1:inst-cache-return + } + + /// Stores one entry, answering whether it was stored. + /// + /// The entry's lifetime is the minimum of the configured ceiling and the + /// reported lifetime less the safety margin. A token whose reported + /// lifetime is at or below the margin is not stored: it is injected once + /// and never served from the cache. A failed fetch never reaches this + /// call, so a failed fetch is never cached. + pub fn store(&self, key: &str, token: SecretString, reported_lifetime: Duration) -> bool { + // @cpt-begin:cpt-cf-oagw-algo-token-cache:p1:inst-cache-put-if + let Some(ttl) = entry_ttl(self.ttl, reported_lifetime) else { + // @cpt-begin:cpt-cf-oagw-algo-token-cache:p1:inst-cache-noput-else + // @cpt-begin:cpt-cf-oagw-algo-token-cache:p1:inst-cache-noput + return false; + // @cpt-end:cpt-cf-oagw-algo-token-cache:p1:inst-cache-noput + // @cpt-end:cpt-cf-oagw-algo-token-cache:p1:inst-cache-noput-else + }; + // @cpt-begin:cpt-cf-oagw-algo-token-cache:p1:inst-cache-put + self.entries.put( + &String::from(key), + CachedToken { + key: String::from(key), + token, + }, + Some(ttl), + ); + // @cpt-end:cpt-cf-oagw-algo-token-cache:p1:inst-cache-put + true + // @cpt-end:cpt-cf-oagw-algo-token-cache:p1:inst-cache-put-if + } +} + +/// The lifetime one entry is held for: the configured ceiling or the reported +/// lifetime less the margin, whichever is shorter — or nothing at all when the +/// reported lifetime is at or below the margin. +fn entry_ttl(ceiling: Duration, reported_lifetime: Duration) -> Option { + let margin = Duration::from_secs(TOKEN_CACHE_SAFETY_MARGIN_SECS); + if reported_lifetime <= margin { + return None; + } + Some(ceiling.min(reported_lifetime - margin)) +} + +/// The component separator of a cache key. +/// +/// A control character no tenant identifier, no subject identifier, no auth +/// method tag, and no hash spelling carries, so four components stay four +/// components. +const KEY_SEPARATOR: char = '\u{1f}'; + +/// Builds the cache key of one authentication: the subject tenant, the +/// subject, the auth method tag of the variant, and the hash of the plugin +/// configuration. +/// +/// Each of the four components is present because its absence would break an +/// isolation boundary: the tenant keeps one tenant's token out of another +/// tenant's reach, the subject keeps the credential store's `private` sharing +/// mode meaningful, the method tag keeps the `Form` and `Basic` variants from +/// colliding over one configuration, and the hash keeps two upstreams whose +/// configurations differ — in the scopes as in anything else — on separate +/// entries. +#[must_use] +pub fn cache_key( + tenant: Uuid, + subject_id: Option, + auth_method_tag: &str, + config: &Value, +) -> String { + // @cpt-begin:cpt-cf-oagw-algo-token-cache:p1:inst-cache-key + let components = [ + component(&tenant.to_string()), + component(&subject_id.unwrap_or_default().to_string()), + component(auth_method_tag), + component(&hash_config(config)), + ]; + components.join(&KEY_SEPARATOR.to_string()) + // @cpt-end:cpt-cf-oagw-algo-token-cache:p1:inst-cache-key +} + +/// One key component, stripped of the separator it must never carry. +fn component(value: &str) -> String { + value.replace(KEY_SEPARATOR, "") +} + +/// Hashes a plugin configuration deterministically: every key, in sorted +/// order, nested values included. +/// +/// The same configuration hashes to the same value in every process and after +/// every restart, so two upstreams whose configurations agree share an entry +/// and two whose configurations differ never do. +#[must_use] +pub fn hash_config(config: &Value) -> String { + format!("{:016x}", fnv1a_64(canonical_form(config).as_bytes())) +} + +/// Renders a configuration value as the string its hash is taken over. +fn canonical_form(config: &Value) -> String { + match config { + Value::Object(fields) => { + let mut entries: Vec<(String, String)> = fields + .iter() + .map(|(key, value)| (key.clone(), canonical_form(value))) + .collect(); + entries.sort_by(|left, right| left.0.cmp(&right.0)); + entries + .into_iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join(",") + } + Value::Array(items) => items + .iter() + .map(canonical_form) + .collect::>() + .join(","), + other => other.to_string(), + } +} + +/// The 64-bit FNV-1a of one byte string. +/// +/// A fixed, published hash with no random seed, so the same configuration +/// hashes identically in every process. +fn fnv1a_64(bytes: &[u8]) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_lifetime_at_the_margin_is_not_stored() { + assert_eq!(entry_ttl(Duration::from_secs(300), Duration::from_secs(30)), None); + } + + #[test] + fn a_lifetime_above_the_margin_loses_the_margin() { + assert_eq!( + entry_ttl(Duration::from_secs(300), Duration::from_secs(45)), + Some(Duration::from_secs(15)) + ); + } + + #[test] + fn a_long_lifetime_is_capped_by_the_ceiling() { + assert_eq!( + entry_ttl(Duration::from_secs(300), Duration::from_secs(3_600)), + Some(Duration::from_secs(300)) + ); + } + + #[test] + fn an_absent_subject_and_the_nil_subject_are_the_same_component() { + let tenant = Uuid::from_u128(0x01); + let config = serde_json::json!({}); + assert_eq!( + cache_key(tenant, None, "form", &config), + cache_key(tenant, Some(Uuid::nil()), "form", &config) + ); + } +} diff --git a/gears/system/oagw/oagw/src/store/mod.rs b/gears/system/oagw/oagw/src/store/mod.rs new file mode 100644 index 0000000..ed9ad8f --- /dev/null +++ b/gears/system/oagw/oagw/src/store/mod.rs @@ -0,0 +1,1599 @@ +//! In-process transactional store of the management half. +//! +//! The persisted model is the ten-table set the two management features own — +//! `oagw_upstream`, `oagw_route`, `oagw_route_http_match`, +//! `oagw_route_grpc_match`, `oagw_route_method`, `oagw_upstream_tag`, +//! `oagw_route_tag`, `oagw_plugin`, `oagw_upstream_plugin`, and +//! `oagw_route_plugin` — modelled as one in-process table set guarded by a +//! single [`parking_lot::RwLock`]. Every multi-row write is one batch: the +//! batch is applied against a cloned table set and the clone is swapped in +//! only when every uniqueness check and the whole batch have succeeded, so a +//! failed write leaves no partial rows. The table shapes are plain and no +//! backend-specific feature is used, so portability across the PostgreSQL, +//! MySQL, and SQLite backends holds by construction; a real database handle +//! for those backends is a platform provisioning concern, not something this +//! store provisions. +//! +//! ## Access paths +//! +//! - The tenant-scoped scan is the read path over each table: every read and +//! write method takes the calling tenant and applies it in the same +//! predicate as the other keys, so a scan never yields a row whose +//! `tenant_id` differs from the caller's. +//! - [`MatchKey`] is the derived `(upstream_id, path, priority, method)` → +//! `route_id` index the match-uniqueness check looks up; every write and +//! every delete maintains it. +//! - Neither plugin-binding table carries a foreign key to `oagw_plugin`, +//! because named plugins have no row there; a plugin deletion therefore +//! removes no binding row. A binding row does name its parent, so the +//! parent's deletion cascades into its binding rows. +//! - A custom plugin's `gc_eligible_at` column is set once, by the eligibility +//! recompute a binding write runs or by the periodic job, at the moment the +//! row's reference set becomes empty, and is cleared the moment a reference +//! returns. + +/// Garbage-collection TTL of an unlinked custom plugin: 30 days in seconds, +/// the default DESIGN §3.2 Plugin Lifecycle Management gives the configurable +/// TTL. It is a constant here and not an `OagwConfig` key, because the +/// configuration surface DECOMPOSITION §2.1 declares closes at five keys. +pub const PLUGIN_GC_TTL_SECS: u64 = 30 * 24 * 60 * 60; + +/// The unix-second clock the write paths stamp their eligibility marking with. +/// +/// A stored instant is only ever compared with another stored instant, so a +/// single monotonic-enough wall clock read once per write is enough, and no +/// persistence type enters the signature. +#[must_use] +pub fn unix_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|since| since.as_secs()) + .unwrap_or_default() +} + +// @cpt-dod:cpt-cf-oagw-dod-plugin-persistence:p1 + +use std::collections::{BTreeMap, BTreeSet}; + +use parking_lot::RwLock; +use serde_json::Value; +use uuid::Uuid; + +use crate::domain::alias::Alias; +use crate::domain::plugin::Plugin; +use crate::domain::plugin_contract::PluginFamily; +use crate::domain::route::{GrpcMatch, HttpMatch, Route}; +use crate::domain::upstream::Upstream; + +/// Key of the `oagw_route_method` table: one row per declared method of a +/// route. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct RouteMethodKey { + /// Owning route. + pub route_id: Uuid, + /// Declared HTTP method. + pub method: String, +} + +/// Key of the derived enabled-match index: the `(upstream_id, path, priority, +/// method)` tuple two enabled routes of one upstream may not share. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct MatchKey { + /// Owning upstream. + pub upstream_id: Uuid, + /// Match path. + pub path: String, + /// Match-uniqueness ordering. + pub priority: i64, + /// Declared HTTP method. + pub method: String, +} + +/// The `oagw_upstream` row plus its dependent `oagw_upstream_tag` rows. +#[derive(Debug, Clone, PartialEq)] +pub struct UpstreamRow { + /// Owning tenant. + pub tenant_id: Uuid, + /// The tag rows of this parent, in sorted order. + pub tags: Vec, + /// The upstream row content; its `tags` projection is materialized from + /// the tag table, so `upstream.tags` and `tags` always agree. + pub upstream: Upstream, + /// The `auth_plugin_ref` column: the identifier of the one auth plugin the + /// upstream's `auth` sub-configuration binds, when it binds one. The + /// plugin system writes both columns inside the parent's transaction; no + /// other column of the row is its to write. + pub auth_plugin_ref: Option, + /// The `auth_plugin_uuid` column: the extracted UUID of that plugin, when + /// it is UUID-backed. The scalar column is what keeps the in-use check off + /// JSON scanning (DESIGN §3.1). + pub auth_plugin_uuid: Option, +} + +/// The `oagw_route` row plus its dependent match, method, and tag rows. +#[derive(Debug, Clone, PartialEq)] +pub struct RouteRow { + /// Owning tenant. + pub tenant_id: Uuid, + /// The tag rows of this parent, in sorted order. + pub tags: Vec, + /// The route row content; its `tags` projection is materialized from the + /// tag table, so `route.tags` and `tags` always agree. + pub route: Route, +} + +/// The `oagw_plugin` row: one custom tenant-defined plugin. +#[derive(Debug, Clone, PartialEq)] +pub struct PluginRow { + /// Owning tenant. + pub tenant_id: Uuid, + /// The plugin row content. + pub plugin: Plugin, +} + +/// The `oagw_upstream_plugin` / `oagw_route_plugin` row content: one binding +/// of one plugin into one parent's chain. +/// +/// Neither table carries a foreign key to `oagw_plugin`, because named plugins +/// have no row there; the reference is carried on every row and the UUID only +/// on a UUID-backed one. +#[derive(Debug, Clone, PartialEq)] +pub struct PluginBinding { + /// Chain position of the binding, contiguous from 0 within its parent. + pub position: u32, + /// The canonical plugin identifier the binding was written with. + pub plugin_ref: String, + /// The extracted UUID, present only for a UUID-backed plugin. + pub plugin_uuid: Option, + /// The plugin configuration the binding carries. + pub config: Value, +} + +/// What one pass of the periodic garbage-collection job did. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PluginGcReport { + /// The rows the pass marked eligible, each now carrying the instant its + /// TTL elapses. + pub marked: Vec, + /// The rows the pass deleted, whose TTL had elapsed with no reference + /// left. + pub collected: Vec, +} + +/// The plugin write set a parent write carries: the full replacement of the +/// parent's binding rows, and for an upstream the scalar identity columns of +/// its one auth plugin. +/// +/// The set is built by the plugin system's binding validation and consumed by +/// the parent's single-transaction write, so the two land together or not at +/// all. A parent written without a plugin body carries [`BindingWrite::none`]. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct BindingWrite { + /// The binding rows to write, in position order. + pub bindings: Vec, + /// The auth plugin identity to write to the upstream's scalar columns. + pub auth: Option, + /// Unix seconds the eligibility recompute marks an unlinked row at. + pub marked_at: u64, +} + +impl BindingWrite { + /// The empty write set: no binding row, no auth plugin, and the marking + /// instant a plain parent write reaches the eligibility recompute with. + #[must_use] + pub fn none(marked_at: u64) -> Self { + Self { + bindings: Vec::new(), + auth: None, + marked_at, + } + } + + /// The identifiers of the custom plugins this write references, which are + /// the rows whose eligibility the write may change. + #[must_use] + pub fn referenced_uuids(&self) -> BTreeSet { + self.bindings.iter().filter_map(|b| b.plugin_uuid).chain(self.auth.iter().filter_map(|a| a.plugin_uuid)).collect() + } +} + +/// The auth plugin identity an upstream row carries in its two scalar columns: +/// the canonical reference always, and the UUID only when the plugin is +/// UUID-backed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthIdentity { + /// The canonical plugin identifier the auth sub-configuration named. + pub plugin_ref: String, + /// The extracted UUID, present only for a UUID-backed plugin. + pub plugin_uuid: Option, +} + +/// Why a write batch was refused. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum StoreError { + /// Another upstream of the same tenant already holds the alias. + #[error("another upstream of the tenant already holds the alias")] + AliasConflict, + /// Another enabled route of the same upstream already holds the key. + #[error("another enabled route of the upstream already holds the match key")] + MatchConflict { + /// Identifier of the colliding route. + colliding_route_id: Uuid, + }, + /// Another plugin of the same tenant already holds the name. + #[error("another plugin of the tenant already holds the name")] + PluginNameConflict, + /// An internal invariant of the persisted model was breached; nothing was + /// written. + #[error("persisted model invariant breached: {reason}")] + Invariant { + /// Which invariant was breached. + reason: String, + }, +} + +/// The ten tables, keyed as the persisted model names them. +/// +/// @cpt-begin:cpt-cf-oagw-dod-persisted-model:p1:inst-table-shapes +#[derive(Debug, Clone, Default)] +struct Tables { + /// `oagw_upstream`, keyed on `id`. + upstreams: BTreeMap, + /// `oagw_route`, keyed on `id`. + routes: BTreeMap, + /// `oagw_route_http_match`, keyed on `route_id`. + route_http_match: BTreeMap, + /// `oagw_route_grpc_match`, keyed on `route_id`. + route_grpc_match: BTreeMap, + /// `oagw_route_method`, keyed on `(route_id, method)`. + route_methods: BTreeSet, + /// `oagw_upstream_tag`, keyed on `(parent_id, tag)`. + upstream_tags: BTreeSet<(Uuid, String)>, + /// `oagw_route_tag`, keyed on `(parent_id, tag)`. + route_tags: BTreeSet<(Uuid, String)>, + /// The derived `(upstream_id, path, priority, method)` → `route_id` index + /// over the enabled routes. + enabled_match_index: BTreeMap, + /// `oagw_plugin`, keyed on `id`. + plugins: BTreeMap, + /// `oagw_upstream_plugin`, keyed on `(parent_id, position)`. + upstream_plugins: BTreeMap<(Uuid, u32), PluginBinding>, + /// `oagw_route_plugin`, keyed on `(parent_id, position)`. + route_plugins: BTreeMap<(Uuid, u32), PluginBinding>, +} +// @cpt-end:cpt-cf-oagw-dod-persisted-model:p1:inst-table-shapes + +impl Tables { + /// Rewrites the tag rows of an upstream from the value being stored. + fn sync_upstream_tags(&mut self, id: Uuid, tags: &[String]) { + self.upstream_tags.retain(|(parent, _)| *parent != id); + for tag in tags { + self.upstream_tags.insert((id, tag.clone())); + } + } + + /// Rewrites the tag rows of a route from the value being stored. + fn sync_route_tags(&mut self, id: Uuid, tags: &[String]) { + self.route_tags.retain(|(parent, _)| *parent != id); + for tag in tags { + self.route_tags.insert((id, tag.clone())); + } + } + + /// Rewrites the binding rows of an upstream from the write being stored. + fn sync_upstream_plugins(&mut self, id: Uuid, bindings: &[PluginBinding]) { + self.upstream_plugins.retain(|(parent, _), _| *parent != id); + for binding in bindings { + self.upstream_plugins.insert((id, binding.position), binding.clone()); + } + } + + /// Rewrites the binding rows of a route from the write being stored. + fn sync_route_plugins(&mut self, id: Uuid, bindings: &[PluginBinding]) { + self.route_plugins.retain(|(parent, _), _| *parent != id); + for binding in bindings { + self.route_plugins.insert((id, binding.position), binding.clone()); + } + } + + /// The identifiers of the custom plugins the parent row references, before + /// or after a write: the binding rows' UUID-backed references and the + /// upstream's scalar `auth_plugin_uuid` column. + fn referenced_uuids(&self, tenant_id: Uuid, parent_id: Uuid) -> BTreeSet { + let mut referenced = BTreeSet::new(); + for ((parent, _), binding) in &self.upstream_plugins { + if *parent == parent_id && let Some(uuid) = binding.plugin_uuid { + referenced.insert(uuid); + } + } + for ((parent, _), binding) in &self.route_plugins { + if *parent == parent_id && let Some(uuid) = binding.plugin_uuid { + referenced.insert(uuid); + } + } + if let Some(row) = self.upstreams.get(&parent_id) + && row.tenant_id == tenant_id + && let Some(uuid) = row.auth_plugin_uuid + { + referenced.insert(uuid); + } + referenced + } + + /// Recomputes the garbage-collection eligibility of the custom plugins + /// whose reference set the write may have changed. + /// + /// A row whose reference set the write emptied is marked eligible, and one + /// that gained a reference loses the marking, so a plugin rebound before + /// the TTL elapses never disappears under a live binding. A row that is + /// already marked and stays unlinked keeps the marking it holds: the + /// marking happens once, at the moment the last reference is lost. The + /// marking stores the instant the TTL elapses, because the column is + /// declared as the instant after which the row is collectable and the job + /// deletes the rows whose stored instant is in the past. + fn recompute_plugin_eligibility( + &mut self, + _tenant_id: Uuid, + changed: &BTreeSet, + marked_at: u64, + ) { + for uuid in changed { + let referenced = plugin_is_referenced(self, *uuid); + let Some(row) = self.plugins.get_mut(uuid) else { + continue; + }; + if referenced { + // @cpt-begin:cpt-cf-oagw-algo-plugin-inuse-gc:p1:inst-inuse-relink-if + // @cpt-begin:cpt-cf-oagw-state-plugin-lifecycle:p1:inst-state-relink + // @cpt-begin:cpt-cf-oagw-algo-plugin-inuse-gc:p1:inst-inuse-relink + row.plugin.gc_eligible_at = None; + // @cpt-end:cpt-cf-oagw-algo-plugin-inuse-gc:p1:inst-inuse-relink + // @cpt-end:cpt-cf-oagw-state-plugin-lifecycle:p1:inst-state-relink + // @cpt-end:cpt-cf-oagw-algo-plugin-inuse-gc:p1:inst-inuse-relink-if + } else if row.plugin.gc_eligible_at.is_none() { + // @cpt-begin:cpt-cf-oagw-algo-plugin-inuse-gc:p1:inst-inuse-unlink-if + // @cpt-begin:cpt-cf-oagw-state-plugin-lifecycle:p1:inst-state-unlink + // @cpt-begin:cpt-cf-oagw-algo-plugin-inuse-gc:p1:inst-inuse-unlink + row.plugin.gc_eligible_at = Some(marked_at.saturating_add(PLUGIN_GC_TTL_SECS)); + // @cpt-end:cpt-cf-oagw-algo-plugin-inuse-gc:p1:inst-inuse-unlink + // @cpt-end:cpt-cf-oagw-state-plugin-lifecycle:p1:inst-state-unlink + // @cpt-end:cpt-cf-oagw-algo-plugin-inuse-gc:p1:inst-inuse-unlink-if + } + } + } + + /// Drops the match, method, and index rows of one route, so a replacement + /// is checked against every other route before its own rows are recorded. + fn clear_route_matches(&mut self, route_id: Uuid) { + self.route_http_match.remove(&route_id); + self.route_grpc_match.remove(&route_id); + self.route_methods.retain(|key| key.route_id != route_id); + self.enabled_match_index.retain(|_, held| *held != route_id); + } + + /// Records the match, method, and index rows of a route. + /// + /// Only an enabled route contributes index rows: the disabled-route + /// exemption of the match-uniqueness check is what makes a disabled route + /// free to share its key. + fn record_route_matches(&mut self, route: &Route) { + if let Some(http) = &route.match_config.http { + self.route_http_match.insert(route.id, http.clone()); + // @cpt-begin:cpt-cf-oagw-algo-match-uniqueness:p1:inst-match-set + for method in &http.methods { + self.route_methods.insert(RouteMethodKey { + route_id: route.id, + method: method.clone(), + }); + if route.enabled.unwrap_or_default() { + self.enabled_match_index + .insert(match_key(route, &http.path, method), route.id); + } + } + // @cpt-end:cpt-cf-oagw-algo-match-uniqueness:p1:inst-match-set + } + if let Some(grpc) = &route.match_config.grpc { + self.route_grpc_match.insert(route.id, grpc.clone()); + } + } + + /// Drops the dependent rows of one route. + fn drop_route_dependents(&mut self, route_id: Uuid) { + self.route_http_match.remove(&route_id); + self.route_grpc_match.remove(&route_id); + self.route_methods.retain(|key| key.route_id != route_id); + self.route_tags.retain(|(parent, _)| *parent != route_id); + self.enabled_match_index.retain(|_, held| *held != route_id); + } + + /// Checks the invariants a batch must preserve before it is swapped in. + fn verify(&self) -> Result<(), StoreError> { + self.verify_parents()?; + self.verify_dependents()?; + self.verify_plugins()?; + self.verify_index() + } + + /// Every route names an upstream of its own tenant. + fn verify_parents(&self) -> Result<(), StoreError> { + for row in self.routes.values() { + let Some(parent) = self.upstreams.get(&row.route.upstream_id) else { + return Err(missing_upstream()); + }; + if parent.tenant_id != row.tenant_id { + return Err(mismatched_tenant()); + } + } + Ok(()) + } + + /// Every dependent row names an existing route or upstream. + fn verify_dependents(&self) -> Result<(), StoreError> { + let http_routes = self.route_http_match.keys().copied(); + let grpc_routes = self.route_grpc_match.keys().copied(); + for route_id in http_routes.chain(grpc_routes) { + self.assert_route(route_id)?; + } + for key in &self.route_methods { + self.assert_route(key.route_id)?; + } + for (parent, _) in &self.route_tags { + self.assert_route(*parent)?; + } + for (parent, _) in &self.upstream_tags { + if !self.upstreams.contains_key(parent) { + return Err(StoreError::Invariant { + reason: String::from("tag row references a missing upstream"), + }); + } + } + for (parent, _) in self.upstream_plugins.keys() { + self.assert_upstream(*parent)?; + } + for (parent, _) in self.route_plugins.keys() { + self.assert_route(*parent)?; + } + Ok(()) + } + + /// Every plugin row is unique on `(tenant_id, name)` and every upstream + /// names its auth plugin identity at most implicitly: a UUID column is + /// carried only beside a reference. + fn verify_plugins(&self) -> Result<(), StoreError> { + let mut held: Vec<(Uuid, &str)> = Vec::new(); + for row in self.plugins.values() { + if PluginFamily::from_type_literal(&row.plugin.plugin_type).is_none() { + return Err(StoreError::Invariant { + reason: String::from("plugin row carries a plugin_type that names no family"), + }); + } + let name = row.plugin.name.as_str(); + if held + .iter() + .any(|(tenant, held_name)| *tenant == row.tenant_id && *held_name == name) + { + return Err(StoreError::PluginNameConflict); + } + held.push((row.tenant_id, name)); + } + for row in self.upstreams.values() { + if row.auth_plugin_uuid.is_some() && row.auth_plugin_ref.is_none() { + return Err(StoreError::Invariant { + reason: String::from("upstream carries an auth plugin uuid and no reference"), + }); + } + } + Ok(()) + } + + /// Every index entry agrees with the enabled route it names. + fn verify_index(&self) -> Result<(), StoreError> { + for (key, route_id) in &self.enabled_match_index { + let Some(row) = self.routes.get(route_id) else { + return Err(orphan_index()); + }; + let Some(http) = &row.route.match_config.http else { + return Err(non_http_index()); + }; + let agrees = row.route.enabled.unwrap_or_default() + && row.route.upstream_id == key.upstream_id + && http.path == key.path + && row.route.priority.unwrap_or_default() == key.priority + && http.methods.contains(&key.method); + if !agrees { + return Err(disagreeing_index()); + } + } + Ok(()) + } + + fn assert_route(&self, route_id: Uuid) -> Result<(), StoreError> { + if self.routes.contains_key(&route_id) { + Ok(()) + } else { + Err(StoreError::Invariant { + reason: String::from("dependent row references a missing route"), + }) + } + } + + fn assert_upstream(&self, upstream_id: Uuid) -> Result<(), StoreError> { + if self.upstreams.contains_key(&upstream_id) { + Ok(()) + } else { + Err(StoreError::Invariant { + reason: String::from("dependent row references a missing upstream"), + }) + } + } +} + +/// Builds the index key of one enabled route method. +fn match_key(route: &Route, path: &str, method: &str) -> MatchKey { + MatchKey { + upstream_id: route.upstream_id, + path: path.to_owned(), + priority: route.priority.unwrap_or_default(), + method: method.to_owned(), + } +} + +fn missing_upstream() -> StoreError { + StoreError::Invariant { + reason: String::from("route references a missing upstream"), + } +} + +fn mismatched_tenant() -> StoreError { + StoreError::Invariant { + reason: String::from("route and upstream tenants differ"), + } +} + +fn orphan_index() -> StoreError { + StoreError::Invariant { + reason: String::from("match index references a missing route"), + } +} + +fn non_http_index() -> StoreError { + StoreError::Invariant { + reason: String::from("match index references a non-http route"), + } +} + +fn disagreeing_index() -> StoreError { + StoreError::Invariant { + reason: String::from("match index disagrees with the route it names"), + } +} + +/// The in-process transactional store. +/// +/// Lives in an [`Arc`] and is `Send + Sync`; the whole table set sits behind +/// one [`RwLock`], so a batch and the reads around it are serialized. +pub struct OagwStore { + tables: RwLock, +} + +impl Default for OagwStore { + fn default() -> Self { + Self::new() + } +} + +impl OagwStore { + /// Creates an empty store. + #[must_use] + pub fn new() -> Self { + Self { + tables: RwLock::new(Tables::default()), + } + } + + /// Applies one batch against a cloned table set and swaps it in only when + /// the whole batch succeeded. + fn commit( + &self, + batch: impl FnOnce(&mut Tables) -> Result, + ) -> Result { + let mut guard = self.tables.write(); + let mut candidate = guard.clone(); + let produced = batch(&mut candidate)?; + candidate.verify()?; + *guard = candidate; + Ok(produced) + } + + /// Inserts one `oagw_upstream` row and its `oagw_upstream_tag` rows. + /// + /// The `(tenant_id, alias)` uniqueness check runs inside the same batch as + /// the insert, after the row has been applied to the candidate set, so a + /// violation leaves no row behind. + /// + /// # Errors + /// + /// Returns [`StoreError::AliasConflict`] when another upstream of the + /// calling tenant already holds the normalized alias, and + /// [`StoreError::Invariant`] when the batch would breach the model. + pub fn insert_upstream( + &self, + tenant_id: Uuid, + upstream: &Upstream, + ) -> Result { + self.insert_upstream_with_bindings(tenant_id, upstream, &BindingWrite::none(unix_now())) + } + + /// Inserts one `oagw_upstream` row together with the plugin bindings the + /// parent write carries. + /// + /// The binding rows and the two auth plugin identity columns are written + /// by the same batch the parent row is, so a parent that fails leaves no + /// binding behind and a binding that fails writes no parent. + /// + /// # Errors + /// + /// Returns the same refusals [`Self::insert_upstream`] does. + pub fn insert_upstream_with_bindings( + &self, + tenant_id: Uuid, + upstream: &Upstream, + write: &BindingWrite, + ) -> Result { + let mut stored = upstream.clone(); + stored.tags.clear(); + let tags = upstream.tags.clone(); + let alias = upstream.alias.clone(); + let id = stored.id; + // @cpt-begin:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-insert + self.commit(|tables| { + tables.upstreams.insert( + id, + UpstreamRow { + tenant_id, + tags: Vec::new(), + upstream: stored, + // The auth plugin identity columns are the plugin system's + // write, reached through the parent's own write path; an + // upstream written without that routine carries none. + auth_plugin_ref: write.auth.as_ref().map(|auth| auth.plugin_ref.clone()), + auth_plugin_uuid: write.auth.as_ref().and_then(|auth| auth.plugin_uuid), + }, + ); + tables.sync_upstream_tags(id, &tags); + tables.sync_upstream_plugins(id, &write.bindings); + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-gc + tables.recompute_plugin_eligibility(tenant_id, &write.referenced_uuids(), write.marked_at); + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-gc + hold_alias(tables, tenant_id, id, &alias)?; + read_upstream_row(tables, id) + }) + // @cpt-end:cpt-cf-oagw-flow-upstream-create:p1:inst-us-create-insert + } + + /// Reads one `oagw_upstream` row by identifier and calling tenant. + #[must_use] + pub fn get_upstream(&self, tenant_id: Uuid, id: Uuid) -> Option { + let tables = self.tables.read(); + // @cpt-begin:cpt-cf-oagw-algo-tenant-scope:p1:inst-scope-path-if + // @cpt-begin:cpt-cf-oagw-algo-tenant-scope:p1:inst-scope-path + let row = tables.upstreams.get(&id)?; + let owned = row.tenant_id == tenant_id; + // @cpt-end:cpt-cf-oagw-algo-tenant-scope:p1:inst-scope-path + // @cpt-end:cpt-cf-oagw-algo-tenant-scope:p1:inst-scope-path-if + owned.then(|| materialize_upstream(&tables, row)) + } + + /// Scans the `oagw_upstream` rows of the calling tenant. + #[must_use] + pub fn list_upstreams(&self, tenant_id: Uuid) -> Vec { + let tables = self.tables.read(); + // @cpt-begin:cpt-cf-oagw-algo-tenant-scope:p1:inst-scope-return + // @cpt-begin:cpt-cf-oagw-algo-tenant-scope:p1:inst-scope-list-if + // @cpt-begin:cpt-cf-oagw-algo-tenant-scope:p1:inst-scope-list + tables + .upstreams + .values() + .filter(|row| row.tenant_id == tenant_id) + .map(|row| materialize_upstream(&tables, row)) + .collect() + // @cpt-end:cpt-cf-oagw-algo-tenant-scope:p1:inst-scope-list + // @cpt-end:cpt-cf-oagw-algo-tenant-scope:p1:inst-scope-list-if + // @cpt-end:cpt-cf-oagw-algo-tenant-scope:p1:inst-scope-return + } + + /// Reads the `oagw_upstream` row of the calling tenant whose normalized + /// alias equals the one given. + /// + /// The tenant equality sits in the same predicate as the alias, so the + /// scan never yields a foreign tenant's row (`cpt-cf-oagw-principle-tenant-scope`), + /// and the comparison runs on the normalized form only — case-insensitive, + /// trailing dot dropped, port participating in identity — through + /// [`Alias::parse`], so a stored alias and a resolved alias cannot disagree + /// about shape. This is the read the hierarchical walk issues once per + /// chain element, keyed in the `upstream:{tenant_id}:{alias}` shape of + /// ADR 0005. + #[must_use] + pub fn upstream_by_alias(&self, tenant_id: Uuid, alias: &Alias) -> Option { + let tables = self.tables.read(); + // @cpt-begin:cpt-cf-oagw-algo-tenant-chain-walk:p1:inst-walk-lookup + tables + .upstreams + .values() + .filter(|row| row.tenant_id == tenant_id) + .find(|row| { + row.upstream + .alias + .as_deref() + .and_then(|held| Alias::parse(held).ok()) + .is_some_and(|held| held == *alias) + }) + .map(|row| materialize_upstream(&tables, row)) + // @cpt-end:cpt-cf-oagw-algo-tenant-chain-walk:p1:inst-walk-lookup + } + + /// Reads the `oagw_route` rows of the calling tenant that belong to one + /// upstream. + /// + /// The hierarchical resolution reads these to find the chain element whose + /// route matches the request, so the read is scoped exactly as + /// `upstream_by_alias` is: tenant first, then the owning upstream. + #[must_use] + pub fn routes_of_upstream(&self, tenant_id: Uuid, upstream_id: Uuid) -> Vec { + let tables = self.tables.read(); + tables + .routes + .values() + .filter(|row| row.tenant_id == tenant_id && row.route.upstream_id == upstream_id) + .map(|row| materialize_route(&tables, row)) + .collect() + } + + /// Replaces one `oagw_upstream` row and rewrites its tag rows. + /// + /// # Errors + /// + /// Returns [`StoreError::AliasConflict`] when another upstream of the + /// calling tenant already holds the normalized alias, and + /// [`StoreError::Invariant`] when the batch would breach the model. + pub fn replace_upstream( + &self, + tenant_id: Uuid, + id: Uuid, + upstream: &Upstream, + ) -> Result { + self.replace_upstream_with_bindings(tenant_id, id, upstream, &BindingWrite::none(unix_now())) + } + + /// Replaces one `oagw_upstream` row together with the plugin bindings the + /// parent write carries, in the same batch. + /// + /// The write set is the full replacement of the binding rows, so a body + /// that omits the `plugins` sub-object clears them. + /// + /// # Errors + /// + /// Returns the same refusals [`Self::replace_upstream`] does. + pub fn replace_upstream_with_bindings( + &self, + tenant_id: Uuid, + id: Uuid, + upstream: &Upstream, + write: &BindingWrite, + ) -> Result { + let mut stored = upstream.clone(); + stored.tags.clear(); + let tags = upstream.tags.clone(); + let alias = upstream.alias.clone(); + // @cpt-begin:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-put-write + self.commit(|tables| { + // The bindings the row held before the write are the ones whose + // reference set the write may have emptied. + let unlinked = tables.referenced_uuids(tenant_id, id); + assert_stored_upstream(tables, tenant_id, id, &stored)?; + tables.upstreams.insert( + id, + UpstreamRow { + tenant_id, + tags: Vec::new(), + upstream: stored, + // The plugin system's binding routine owns these two + // columns; a plain replacement that reaches the store + // directly carries none of it. + auth_plugin_ref: write.auth.as_ref().map(|auth| auth.plugin_ref.clone()), + auth_plugin_uuid: write.auth.as_ref().and_then(|auth| auth.plugin_uuid), + }, + ); + tables.sync_upstream_tags(id, &tags); + tables.sync_upstream_plugins(id, &write.bindings); + let changed = tables.referenced_uuids(tenant_id, id); + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-gc + tables.recompute_plugin_eligibility( + tenant_id, + &unlinked.union(&changed).copied().collect(), + write.marked_at, + ); + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-gc + hold_alias(tables, tenant_id, id, &alias)?; + read_upstream_row(tables, id) + }) + // @cpt-end:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-put-write + } + + /// Deletes one `oagw_upstream` row, cascading into its routes and their + /// dependents, and answers `false` when nothing matched the calling + /// tenant's predicate. + /// + /// # Errors + /// + /// Returns [`StoreError::Invariant`] when the batch would breach the + /// model. + pub fn delete_upstream(&self, tenant_id: Uuid, id: Uuid) -> Result { + // @cpt-begin:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-delete-write + // @cpt-begin:cpt-cf-oagw-algo-tenant-scope:p1:inst-scope-path-empty-if + // @cpt-begin:cpt-cf-oagw-algo-tenant-scope:p1:inst-scope-path-empty + let deleted = self.commit(|tables| { + let Some(row) = tables.upstreams.get(&id) else { + return Ok(false); + }; + if row.tenant_id != tenant_id { + return Ok(false); + } + let route_ids: Vec = tables + .routes + .values() + .filter(|route| route.route.upstream_id == id) + .map(|route| route.route.id) + .collect(); + for route_id in route_ids { + tables.routes.remove(&route_id); + tables.drop_route_dependents(route_id); + tables + .route_plugins + .retain(|(parent, _), _| *parent != route_id); + } + tables.upstream_tags.retain(|(parent, _)| *parent != id); + tables + .upstream_plugins + .retain(|(parent, _), _| *parent != id); + tables.upstreams.remove(&id); + Ok(true) + })?; + Ok(deleted) + // @cpt-end:cpt-cf-oagw-algo-tenant-scope:p1:inst-scope-path-empty + // @cpt-end:cpt-cf-oagw-algo-tenant-scope:p1:inst-scope-path-empty-if + // @cpt-end:cpt-cf-oagw-flow-upstream-replace-delete:p1:inst-us-rw-delete-write + } + + /// Inserts one `oagw_route` row with its match, method, and tag rows. + /// + /// The enabled-match-key uniqueness check runs inside the same batch as + /// the insert, after the row has been applied to the candidate set, so a + /// collision leaves no row behind. + /// + /// # Errors + /// + /// Returns [`StoreError::MatchConflict`] naming the colliding route when + /// an enabled route of the same upstream already holds the match key, and + /// [`StoreError::Invariant`] when the batch would breach the model. + pub fn insert_route(&self, tenant_id: Uuid, route: &Route) -> Result { + self.insert_route_with_bindings(tenant_id, route, &BindingWrite::none(unix_now())) + } + + /// Inserts one `oagw_route` row together with the plugin bindings the + /// parent write carries, in the same batch. + /// + /// # Errors + /// + /// Returns the same refusals [`Self::insert_route`] does. + pub fn insert_route_with_bindings( + &self, + tenant_id: Uuid, + route: &Route, + write: &BindingWrite, + ) -> Result { + let mut stored = route.clone(); + stored.tags.clear(); + let tags = route.tags.clone(); + let route_id = stored.id; + // @cpt-begin:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-insert + self.commit(|tables| { + tables.routes.insert( + route_id, + RouteRow { + tenant_id, + tags: Vec::new(), + route: stored, + }, + ); + tables.sync_route_tags(route_id, &tags); + tables.sync_route_plugins(route_id, &write.bindings); + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-gc + tables.recompute_plugin_eligibility(tenant_id, &write.referenced_uuids(), write.marked_at); + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-gc + tables.clear_route_matches(route_id); + hold_match_key(tables, tenant_id, route, route_id)?; + tables.record_route_matches(route); + read_route_row(tables, route_id) + }) + // @cpt-end:cpt-cf-oagw-flow-route-create:p1:inst-rt-create-insert + } + + /// Reads one `oagw_route` row by identifier and calling tenant. + #[must_use] + pub fn get_route(&self, tenant_id: Uuid, id: Uuid) -> Option { + let tables = self.tables.read(); + // @cpt-begin:cpt-cf-oagw-algo-tenant-scope:p1:inst-scope-predicate + let row = tables.routes.get(&id)?; + let owned = row.tenant_id == tenant_id; + // @cpt-end:cpt-cf-oagw-algo-tenant-scope:p1:inst-scope-predicate + owned.then(|| materialize_route(&tables, row)) + } + + /// Scans the `oagw_route` rows of the calling tenant, with the match, + /// method, and tag rows of the whole scan read in the same pass. + #[must_use] + pub fn list_routes(&self, tenant_id: Uuid) -> Vec { + let tables = self.tables.read(); + // @cpt-begin:cpt-cf-oagw-algo-tenant-scope:p1:inst-scope-list + tables + .routes + .values() + .filter(|row| row.tenant_id == tenant_id) + .map(|row| materialize_route(&tables, row)) + .collect() + // @cpt-end:cpt-cf-oagw-algo-tenant-scope:p1:inst-scope-list + } + + /// Replaces one `oagw_route` row and rewrites its dependent rows. + /// + /// # Errors + /// + /// Returns [`StoreError::MatchConflict`] naming the colliding route when + /// another enabled route of the same upstream already holds the match key, + /// and [`StoreError::Invariant`] when the batch would breach the model. + pub fn replace_route( + &self, + tenant_id: Uuid, + id: Uuid, + route: &Route, + ) -> Result { + self.replace_route_with_bindings(tenant_id, id, route, &BindingWrite::none(unix_now())) + } + + /// Replaces one `oagw_route` row together with the plugin bindings the + /// parent write carries, in the same batch. + /// + /// # Errors + /// + /// Returns the same refusals [`Self::replace_route`] does. + pub fn replace_route_with_bindings( + &self, + tenant_id: Uuid, + id: Uuid, + route: &Route, + write: &BindingWrite, + ) -> Result { + let mut stored = route.clone(); + stored.tags.clear(); + let tags = route.tags.clone(); + self.commit(|tables| { + let unlinked = tables.referenced_uuids(tenant_id, id); + assert_stored_route(tables, tenant_id, id, &stored)?; + tables.routes.insert( + id, + RouteRow { + tenant_id, + tags: Vec::new(), + route: stored, + }, + ); + tables.sync_route_tags(id, &tags); + tables.sync_route_plugins(id, &write.bindings); + let changed = tables.referenced_uuids(tenant_id, id); + // @cpt-begin:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-gc + tables.recompute_plugin_eligibility( + tenant_id, + &unlinked.union(&changed).copied().collect(), + write.marked_at, + ); + // @cpt-end:cpt-cf-oagw-flow-bind-plugins:p1:inst-bind-gc + tables.clear_route_matches(id); + hold_match_key(tables, tenant_id, route, id)?; + tables.record_route_matches(route); + read_route_row(tables, id) + }) + } + + /// Deletes one `oagw_route` row and its dependent rows, leaving the + /// upstream row untouched, and answers `false` when nothing matched the + /// calling tenant's predicate. + /// + /// # Errors + /// + /// Returns [`StoreError::Invariant`] when the batch would breach the + /// model. + pub fn delete_route(&self, tenant_id: Uuid, id: Uuid) -> Result { + // @cpt-begin:cpt-cf-oagw-flow-route-delete:p1:inst-rt-del-write + self.commit(|tables| { + let Some(row) = tables.routes.get(&id) else { + return Ok(false); + }; + if row.tenant_id != tenant_id { + return Ok(false); + } + tables.routes.remove(&id); + tables.drop_route_dependents(id); + tables + .route_plugins + .retain(|(parent, _), _| *parent != id); + Ok(true) + }) + // @cpt-end:cpt-cf-oagw-flow-route-delete:p1:inst-rt-del-write + } + + /// Inserts one `oagw_plugin` row. + /// + /// The `(tenant_id, name)` uniqueness check runs inside the same batch as + /// the insert, after the row has been applied to the candidate set, so a + /// violation leaves no row behind. `gc_eligible_at` is left unset and + /// `last_used_at` is left unset: a created plugin is linked to nothing and + /// has been used by nothing. + /// + /// # Errors + /// + /// Returns [`StoreError::PluginNameConflict`] when another plugin of the + /// calling tenant already holds the name, and [`StoreError::Invariant`] + /// when the batch would breach the model. + pub fn insert_plugin(&self, tenant_id: Uuid, plugin: &Plugin) -> Result { + let name = plugin.name.clone(); + let id = plugin.id; + // @cpt-begin:cpt-cf-oagw-flow-plugin-create:p1:inst-pl-create-insert + self.commit(|tables| { + tables.plugins.insert( + id, + PluginRow { + tenant_id, + plugin: plugin.clone(), + }, + ); + hold_plugin_name(tables, tenant_id, id, &name)?; + read_plugin_row(tables, id) + }) + // @cpt-end:cpt-cf-oagw-flow-plugin-create:p1:inst-pl-create-insert + } + + /// Reads one `oagw_plugin` row by identifier and calling tenant. + /// + /// A foreign-owned row and a nonexistent one answer `None` alike, and a + /// named plugin — which has no row at all — answers `None` through the + /// same predicate. + #[must_use] + pub fn get_plugin(&self, tenant_id: Uuid, id: Uuid) -> Option { + let tables = self.tables.read(); + owned_plugin(&tables, tenant_id, id).cloned() + } + + /// Scans the `oagw_plugin` rows of the calling tenant, in identifier + /// order. + /// + /// Named plugins are absent by construction: the scan reads the one table + /// the custom rows live in. + #[must_use] + pub fn list_plugins(&self, tenant_id: Uuid) -> Vec { + let tables = self.tables.read(); + tables + .plugins + .values() + .filter(|row| row.tenant_id == tenant_id) + .cloned() + .collect() + } + + /// Deletes one `oagw_plugin` row and answers `false` when nothing matched + /// the calling tenant's predicate. + /// + /// No binding row is removed by this deletion: neither binding table + /// carries a foreign key to `oagw_plugin`, so a plugin deletion removes no + /// reference to it (DESIGN §3.1). The in-use check that gates this write + /// is the caller's. + /// + /// # Errors + /// + /// Returns [`StoreError::Invariant`] when the batch would breach the + /// model. + pub fn delete_plugin(&self, tenant_id: Uuid, id: Uuid) -> Result { + // @cpt-begin:cpt-cf-oagw-flow-plugin-delete:p1:inst-pl-del-write + self.commit(|tables| { + let Some(row) = tables.plugins.get(&id) else { + return Ok(false); + }; + if row.tenant_id != tenant_id { + return Ok(false); + } + tables.plugins.remove(&id); + Ok(true) + }) + // @cpt-begin:cpt-cf-oagw-state-plugin-lifecycle:p1:inst-state-terminal + // `Deleted` is terminal: the row is gone, no binding row referenced + // it, and no transition returns it. + // @cpt-end:cpt-cf-oagw-state-plugin-lifecycle:p1:inst-state-terminal + // @cpt-end:cpt-cf-oagw-flow-plugin-delete:p1:inst-pl-del-write + } + + /// Whether any reference of the calling tenant holds one plugin. + /// + /// The scan reads the two binding tables' `plugin_uuid` column and the + /// upstream rows' scalar `auth_plugin_uuid` column; the scalar column is + /// what keeps this check off JSON scanning (DESIGN §3.1). A named plugin + /// has no row and is never addressed here. + #[must_use] + pub fn plugin_in_use(&self, tenant_id: Uuid, id: Uuid) -> bool { + let tables = self.tables.read(); + // @cpt-begin:cpt-cf-oagw-algo-plugin-inuse-gc:p1:inst-inuse-named-if + // A named plugin has no row in `oagw_plugin` at all, so the routine is + // not applicable to it: it is never stored, never garbage-collected, + // and never deleteable, and the caller answers its absence with 404 + // before it reaches this scan. + if owned_plugin(&tables, tenant_id, id).is_none() { + // @cpt-begin:cpt-cf-oagw-algo-plugin-inuse-gc:p1:inst-inuse-named-return + return false; + // @cpt-end:cpt-cf-oagw-algo-plugin-inuse-gc:p1:inst-inuse-named-return + } + // @cpt-end:cpt-cf-oagw-algo-plugin-inuse-gc:p1:inst-inuse-named-if + // @cpt-begin:cpt-cf-oagw-algo-plugin-inuse-gc:p1:inst-inuse-scan + // @cpt-begin:cpt-cf-oagw-algo-plugin-inuse-gc:p1:inst-inuse-return + plugin_is_referenced(&tables, id) + // @cpt-end:cpt-cf-oagw-algo-plugin-inuse-gc:p1:inst-inuse-return + // @cpt-end:cpt-cf-oagw-algo-plugin-inuse-gc:p1:inst-inuse-scan + } + + /// Sets the garbage-collection eligibility of one plugin row to the + /// instant `marked_at` plus the TTL of §1.5. + /// + /// The write the eligibility tests arrange their rows with: a live binding + /// write marks a row only when it removes the row's last reference, and + /// this method exists so a test can place a row on either side of the TTL + /// without driving a whole binding history to get there. + pub fn mark_plugin_eligible(&self, tenant_id: Uuid, id: Uuid, marked_at: u64) { + self.commit(|tables| { + if owned_plugin(tables, tenant_id, id).is_some() + && let Some(row) = tables.plugins.get_mut(&id) + { + row.plugin.gc_eligible_at = Some(marked_at.saturating_add(PLUGIN_GC_TTL_SECS)); + } + Ok(()) + }) + .expect("the eligibility marking is an in-memory write"); + } + + /// Records the last use of the named custom plugin rows at `now`. + /// + /// The write the data-plane proxy issues after the response is produced, + /// coalesced per plugin: no decision reads `last_used_at`, so the write is + /// the whole of the obligation, and a row that is no longer stored is left + /// alone rather than resurrected. + pub fn record_plugin_use(&self, used: &[Uuid], now: u64) { + self.commit(|tables| { + for id in used { + if let Some(row) = tables.plugins.get_mut(id) { + row.plugin.last_used_at = Some(now); + } + } + Ok(()) + }) + .expect("the last-use record is an in-memory write"); + } + + /// Runs one pass of the periodic garbage-collection job of §1.4 at `now`. + /// + /// The pass marks every custom row whose reference set is empty and which + /// carries no marking — whether it lost its last reference to a binding + /// write or never gained one at all — and then deletes only the rows whose + /// `gc_eligible_at` is in the past and whose reference set is still empty + /// at the moment it runs, so a plugin rebound between the marking and the + /// deletion is never removed. Everything else is left alone, and no + /// decision reads `last_used_at`. + #[must_use] + pub fn run_plugin_garbage_collection(&self, now: u64) -> PluginGcReport { + let mut tables = self.tables.write(); + let mut report = PluginGcReport::default(); + + // @cpt-begin:cpt-cf-oagw-state-plugin-lifecycle:p1:inst-state-unlink + // The job's own reference scan is the same one a binding write runs, + // applied to every row: a row it finds with no reference is marked, + // which is the `Linked` to `Unlinked` transition. + let scanned: BTreeSet = tables.plugins.keys().copied().collect(); + let marked_before: BTreeSet = scanned + .iter() + .copied() + .filter(|id| { + tables + .plugins + .get(id) + .is_some_and(|row| row.plugin.gc_eligible_at.is_some()) + }) + .collect(); + // @cpt-begin:cpt-cf-oagw-algo-plugin-inuse-gc:p1:inst-inuse-continue + // The reference set each row carries is the input the eligibility + // decision consumes: the recompute marks only the rows whose reference + // set is empty, so a row the scan finds still referenced is left with + // the eligibility it had. + tables.recompute_plugin_eligibility(Uuid::nil(), &scanned, now); + report.marked = scanned + .into_iter() + .filter(|id| { + !marked_before.contains(id) + && tables + .plugins + .get(id) + .is_some_and(|row| row.plugin.gc_eligible_at.is_some()) + }) + .collect(); + // @cpt-end:cpt-cf-oagw-algo-plugin-inuse-gc:p1:inst-inuse-continue + // @cpt-end:cpt-cf-oagw-state-plugin-lifecycle:p1:inst-state-unlink + + // @cpt-begin:cpt-cf-oagw-state-plugin-lifecycle:p1:inst-state-gc + for (id, _) in tables + .plugins + .iter() + .filter(|(_, row)| row.plugin.gc_eligible_at.is_some_and(|at| at <= now)) + .map(|(id, row)| (*id, row.clone())) + .collect::>() + { + if plugin_is_referenced(&tables, id) { + continue; + } + tables.plugins.remove(&id); + report.collected.push(id); + } + // @cpt-end:cpt-cf-oagw-state-plugin-lifecycle:p1:inst-state-gc + report + } + + /// Reads the `oagw_upstream_plugin` rows of one upstream, in position + /// order. + #[must_use] + pub fn upstream_plugin_rows(&self, tenant_id: Uuid, parent_id: Uuid) -> Vec { + let tables = self.tables.read(); + if owned_upstream(&tables, tenant_id, parent_id).is_none() { + return Vec::new(); + } + binding_rows(&tables.upstream_plugins, parent_id) + } + + /// Reads the `oagw_route_plugin` rows of one route, in position order. + #[must_use] + pub fn route_plugin_rows(&self, tenant_id: Uuid, parent_id: Uuid) -> Vec { + let tables = self.tables.read(); + if owned_route(&tables, tenant_id, parent_id).is_none() { + return Vec::new(); + } + binding_rows(&tables.route_plugins, parent_id) + } + + /// Reads the `oagw_route_http_match` row of one route. + #[must_use] + pub fn route_http_match(&self, tenant_id: Uuid, route_id: Uuid) -> Option { + let tables = self.tables.read(); + owned_route(&tables, tenant_id, route_id)?; + tables.route_http_match.get(&route_id).cloned() + } + + /// Reads the `oagw_route_grpc_match` row of one route. + #[must_use] + pub fn route_grpc_match(&self, tenant_id: Uuid, route_id: Uuid) -> Option { + let tables = self.tables.read(); + owned_route(&tables, tenant_id, route_id)?; + tables.route_grpc_match.get(&route_id).cloned() + } + + /// Reads the `oagw_route_method` rows of one route. + #[must_use] + pub fn route_methods(&self, tenant_id: Uuid, route_id: Uuid) -> Vec { + let tables = self.tables.read(); + if owned_route(&tables, tenant_id, route_id).is_none() { + return Vec::new(); + } + tables + .route_methods + .iter() + .filter(|key| key.route_id == route_id) + .map(|key| key.method.clone()) + .collect() + } + + /// Reads the `oagw_upstream_tag` rows of one upstream. + #[must_use] + pub fn upstream_tag_rows(&self, tenant_id: Uuid, parent_id: Uuid) -> Vec { + let tables = self.tables.read(); + if owned_upstream(&tables, tenant_id, parent_id).is_none() { + return Vec::new(); + } + tag_rows(&tables.upstream_tags, parent_id) + } + + /// Reads the `oagw_route_tag` rows of one route. + #[must_use] + pub fn route_tag_rows(&self, tenant_id: Uuid, parent_id: Uuid) -> Vec { + let tables = self.tables.read(); + if owned_route(&tables, tenant_id, parent_id).is_none() { + return Vec::new(); + } + tag_rows(&tables.route_tags, parent_id) + } + + /// Reads the derived enabled-match index restricted to the calling + /// tenant's routes. + #[must_use] + pub fn enabled_match_index(&self, tenant_id: Uuid) -> BTreeMap { + let tables = self.tables.read(); + tables + .enabled_match_index + .iter() + .filter(|(_, route_id)| { + tables + .routes + .get(*route_id) + .is_some_and(|row| row.tenant_id == tenant_id) + }) + .map(|(key, route_id)| (key.clone(), *route_id)) + .collect() + } + + /// Builds a store whose derived index names a route no route row backs. + /// + /// Test-only: the state is unreachable through the write path and exists + /// to exercise the invariant check that answers the storage failure. + #[cfg(feature = "test-utils")] + #[must_use] + pub fn with_orphaned_match_index() -> Self { + let store = Self::new(); + { + let mut tables = store.tables.write(); + tables.enabled_match_index.insert( + MatchKey { + upstream_id: Uuid::nil(), + path: String::from("/"), + priority: 0, + method: String::from("GET"), + }, + Uuid::nil(), + ); + } + store + } +} + +/// Reads back the upstream row a batch just wrote. +fn read_upstream_row(tables: &Tables, id: Uuid) -> Result { + let Some(row) = tables.upstreams.get(&id) else { + return Err(unreadable_row()); + }; + Ok(materialize_upstream(tables, row)) +} + +/// Reads back the route row a batch just wrote. +fn read_route_row(tables: &Tables, id: Uuid) -> Result { + let Some(row) = tables.routes.get(&id) else { + return Err(unreadable_row()); + }; + Ok(materialize_route(tables, row)) +} + +/// The row a committed batch must have left readable. +fn unreadable_row() -> StoreError { + StoreError::Invariant { + reason: String::from("committed batch left no readable row"), + } +} + +/// Reads back the plugin row a batch just wrote. +fn read_plugin_row(tables: &Tables, id: Uuid) -> Result { + let Some(row) = tables.plugins.get(&id) else { + return Err(unreadable_row()); + }; + Ok(row.clone()) +} + +/// Rejects a plugin name another plugin of the same tenant already holds, +/// excluding the row the batch is writing. +fn hold_plugin_name( + tables: &Tables, + tenant_id: Uuid, + written: Uuid, + name: &str, +) -> Result<(), StoreError> { + let taken = tables.plugins.values().any(|row| { + row.tenant_id == tenant_id && row.plugin.id != written && row.plugin.name == name + }); + if taken { + return Err(StoreError::PluginNameConflict); + } + Ok(()) +} + +/// The row of one plugin when the calling tenant owns it. +fn owned_plugin(tables: &Tables, tenant_id: Uuid, id: Uuid) -> Option<&PluginRow> { + let row = tables.plugins.get(&id)?; + (row.tenant_id == tenant_id).then_some(row) +} + +/// Whether any reference in the store carries one plugin. +/// +/// The scan is the scalar-column scan DESIGN §3.6 names: the two binding +/// tables' `plugin_uuid` column and the upstream rows' `auth_plugin_uuid` +/// column, and never a JSON configuration column. +fn plugin_is_referenced(tables: &Tables, id: Uuid) -> bool { + let bound = tables + .upstream_plugins + .values() + .chain(tables.route_plugins.values()) + .any(|binding| binding.plugin_uuid == Some(id)); + let owned = tables + .upstreams + .values() + .any(|row| row.auth_plugin_uuid == Some(id)); + bound || owned +} + +/// Copies the binding rows of one parent into a list, in position order. +fn binding_rows( + rows: &BTreeMap<(Uuid, u32), PluginBinding>, + parent_id: Uuid, +) -> Vec { + rows.iter() + .filter(|((parent, _), _)| *parent == parent_id) + .map(|(_, binding)| binding.clone()) + .collect() +} + +/// Rejects an alias another upstream of the same tenant already holds, +/// excluding the row the batch is writing. +fn hold_alias( + tables: &Tables, + tenant_id: Uuid, + written: Uuid, + alias: &Option, +) -> Result<(), StoreError> { + let Some(alias) = alias.as_deref() else { + return Ok(()); + }; + let taken = tables.upstreams.values().any(|row| { + row.tenant_id == tenant_id + && row.upstream.id != written + && row + .upstream + .alias + .as_deref() + .is_some_and(|held| held.eq_ignore_ascii_case(alias)) + }); + if taken { + return Err(StoreError::AliasConflict); + } + Ok(()) +} + +/// Rejects an enabled match key another enabled route of the same upstream +/// already holds, excluding the row the batch is writing. +fn hold_match_key( + tables: &Tables, + tenant_id: Uuid, + route: &Route, + written: Uuid, +) -> Result<(), StoreError> { + let Some(http) = &route.match_config.http else { + return Ok(()); + }; + if !route.enabled.unwrap_or_default() { + return Ok(()); + } + for method in &http.methods { + let key = match_key(route, &http.path, method); + if let Some(holder) = tables.enabled_match_index.get(&key) { + let same_tenant = tables + .routes + .get(holder) + .is_some_and(|row| row.tenant_id == tenant_id); + if same_tenant && *holder != written { + return Err(StoreError::MatchConflict { + colliding_route_id: *holder, + }); + } + } + } + Ok(()) +} + +/// The row of one upstream when the calling tenant owns it. +fn owned_upstream(tables: &Tables, tenant_id: Uuid, id: Uuid) -> Option<&UpstreamRow> { + let row = tables.upstreams.get(&id)?; + (row.tenant_id == tenant_id).then_some(row) +} + +/// The row of one route when the calling tenant owns it. +fn owned_route(tables: &Tables, tenant_id: Uuid, id: Uuid) -> Option<&RouteRow> { + let row = tables.routes.get(&id)?; + (row.tenant_id == tenant_id).then_some(row) +} + +/// Confirms the row a replacement batch writes is present, owned, and +/// addressed under the identifier it carries. +fn assert_stored_upstream( + tables: &Tables, + tenant_id: Uuid, + id: Uuid, + replacement: &Upstream, +) -> Result<(), StoreError> { + if replacement.id != id { + return Err(immutable_id()); + } + if owned_upstream(tables, tenant_id, id).is_some() { + Ok(()) + } else { + Err(StoreError::Invariant { + reason: String::from("replaced upstream row is missing or foreign"), + }) + } +} + +/// Confirms the row a replacement batch writes is present, owned, and +/// addressed under the identifier it carries. +fn assert_stored_route( + tables: &Tables, + tenant_id: Uuid, + id: Uuid, + replacement: &Route, +) -> Result<(), StoreError> { + if replacement.id != id { + return Err(immutable_id()); + } + if owned_route(tables, tenant_id, id).is_some() { + Ok(()) + } else { + Err(StoreError::Invariant { + reason: String::from("replaced route row is missing or foreign"), + }) + } +} + +/// The identifier a row is addressed by is immutable. +fn immutable_id() -> StoreError { + StoreError::Invariant { + reason: String::from("the identifier a row is addressed by is immutable"), + } +} + +/// Copies the tag rows of one parent into a list, in sorted order. +fn tag_rows(rows: &BTreeSet<(Uuid, String)>, parent_id: Uuid) -> Vec { + rows.iter() + .filter(|(parent, _)| *parent == parent_id) + .map(|(_, tag)| tag.clone()) + .collect() +} + +/// Materializes the tag projection of an upstream row. +fn materialize_upstream(tables: &Tables, row: &UpstreamRow) -> UpstreamRow { + let tags = tag_rows(&tables.upstream_tags, row.upstream.id); + let mut upstream = row.upstream.clone(); + upstream.tags = tags.clone(); + UpstreamRow { + tenant_id: row.tenant_id, + tags, + upstream, + auth_plugin_ref: row.auth_plugin_ref.clone(), + auth_plugin_uuid: row.auth_plugin_uuid, + } +} + +/// Materializes the tag projection of a route row. +fn materialize_route(tables: &Tables, row: &RouteRow) -> RouteRow { + let tags = tag_rows(&tables.route_tags, row.route.id); + let mut route = row.route.clone(); + route.tags = tags.clone(); + RouteRow { + tenant_id: row.tenant_id, + tags, + route, + } +} diff --git a/gears/system/oagw/oagw/tests/alias_derive_tests.rs b/gears/system/oagw/oagw/tests/alias_derive_tests.rs new file mode 100644 index 0000000..6fe44ab --- /dev/null +++ b/gears/system/oagw/oagw/tests/alias_derive_tests.rs @@ -0,0 +1,256 @@ +//! Alias-derivation tests. +//! +//! Covers `cpt-cf-oagw-dod-alias-derivation` and +//! `cpt-cf-oagw-algo-alias-derive`: the derived alias of a single-hostname +//! endpoint set on a standard and a non-standard port, the longest common +//! suffix of a pooled set, the public-suffix and IP-literal refusals, the +//! reconciliation with a caller-supplied alias, and the immutability +//! confirmed across a replacement. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::missing_panics_doc)] + +use oagw::control_plane::alias_derive::{confirm_immutable, derive, resolve, standard_port}; +use oagw::domain::alias::Alias; +use oagw::domain::error::ErrorKind; +use oagw::domain::scheme::Scheme; +use oagw::domain::upstream::Endpoint; +use oagw::{AliasError, DomainError}; + +/// An `https` endpoint. +fn https(host: &str, port: u16) -> Endpoint { + Endpoint { + scheme: Scheme::Https, + host: oagw::EndpointHost::parse(host).expect("a valid endpoint host"), + port: Some(port), + } +} + +/// An `http` endpoint. +fn http(host: &str, port: u16) -> Endpoint { + Endpoint { + scheme: Scheme::Http, + host: oagw::EndpointHost::parse(host).expect("a valid endpoint host"), + port: Some(port), + } +} + +/// An endpoint that leaves its port to the scheme default. +fn default_port(scheme: Scheme, host: &str) -> Endpoint { + Endpoint { + scheme, + host: oagw::EndpointHost::parse(host).expect("a valid endpoint host"), + port: None, + } +} + +/// Asserts the set derives `expected`. +fn derives(endpoints: &[Endpoint], expected: &str) { + let alias = derive(endpoints).expect("the set is derivable"); + assert_eq!(alias.to_string(), expected, "the derived alias differs"); +} + +/// Asserts the set admits no alias. +fn not_derivable(endpoints: &[Endpoint]) { + let error = derive(endpoints).expect_err("the set is not derivable"); + assert_eq!(error.detail(), "server.endpoints is not derivable"); +} + +#[test] +fn the_standard_ports_are_the_declared_ones() { + assert_eq!(standard_port(Scheme::Http), 80); + assert_eq!(standard_port(Scheme::Https), 443); + assert_eq!(standard_port(Scheme::Wss), 443); + assert_eq!(standard_port(Scheme::Wt), 443); + assert_eq!(standard_port(Scheme::Grpc), 443); +} + +#[test] +fn a_single_hostname_on_its_standard_port_derives_itself() { + derives(&[https("api.openai.com", 443)], "api.openai.com"); + derives(&[default_port(Scheme::Https, "api.openai.com")], "api.openai.com"); + derives(&[http("api.openai.com", 80)], "api.openai.com"); +} + +#[test] +fn a_single_hostname_on_a_non_standard_port_appends_it() { + derives(&[https("api.openai.com", 8443)], "api.openai.com:8443"); + derives(&[http("api.openai.com", 8080)], "api.openai.com:8080"); +} + +#[test] +fn the_two_forms_of_one_hostname_are_distinct_aliases() { + let standard = derive(&[https("api.openai.com", 443)]).expect("standard"); + let lifted = derive(&[https("api.openai.com", 8443)]).expect("lifted"); + assert_ne!(standard, lifted, "the port is part of the alias"); +} + +#[test] +fn a_pooled_set_derives_the_longest_common_suffix() { + derives( + &[ + https("us.vendor.com", 443), + https("eu.vendor.com", 443), + ], + "vendor.com", + ); + derives( + &[ + https("api.us.vendor.com", 443), + https("api.eu.vendor.com", 443), + ], + "vendor.com", + ); +} + +#[test] +fn a_pooled_set_on_a_non_standard_port_appends_the_port() { + derives( + &[https("us.vendor.com", 8443), https("eu.vendor.com", 8443)], + "vendor.com:8443", + ); +} + +#[test] +fn hostnames_sharing_no_two_label_suffix_are_not_derivable() { + not_derivable(&[https("api.openai.com", 443), https("api.vendor.com", 443)]); +} + +#[test] +fn a_bare_public_suffix_is_never_an_alias() { + not_derivable(&[https("www.co.uk", 443), https("shop.co.uk", 443)]); +} + +#[test] +fn an_ip_literal_makes_the_set_not_derivable() { + not_derivable(&[https("10.0.0.1", 443)]); + not_derivable(&[https("api.openai.com", 443), https("10.0.0.1", 443)]); +} + +#[test] +fn an_empty_endpoint_set_is_not_derivable() { + not_derivable(&[]); +} + +#[test] +fn derived_aliases_are_normalized() { + // `EndpointHost` normalizes case and a trailing dot, so the derived alias + // is lowercase and dot-free whatever the body stated. + let alias = derive(&[https("API.OpenAI.COM.", 443)]).expect("the host normalizes"); + assert_eq!(alias.to_string(), "api.openai.com"); + let pooled = derive(&[https("US.Vendor.com.", 443), https("eu.VENDOR.com", 443)]) + .expect("the pool normalizes"); + assert_eq!(pooled.to_string(), "vendor.com"); +} + +#[test] +fn a_non_derivable_set_requires_an_explicit_alias() { + let endpoints = [https("10.0.0.1", 443)]; + let error = resolve(&endpoints, None, None).expect_err("no alias was supplied"); + assert_eq!(error.kind, ErrorKind::ValidationError); + assert_eq!(error.detail, "server.endpoints is not derivable"); +} + +#[test] +fn a_non_derivable_set_accepts_an_explicit_alias() { + let endpoints = [https("10.0.0.1", 443)]; + let alias = resolve(&endpoints, Some("gateway.internal:8443"), None) + .expect("the explicit alias is accepted"); + assert_eq!(alias.to_string(), "gateway.internal:8443"); +} + +#[test] +fn a_non_derivable_set_refuses_an_alias_that_is_not_one() { + let endpoints = [https("10.0.0.1", 443)]; + let error = resolve(&endpoints, Some("not an alias"), None) + .expect_err("the supplied value is not an alias"); + assert_eq!(error.kind, ErrorKind::ValidationError); + assert_eq!(error.detail, "server.endpoints is not derivable"); +} + +#[test] +fn a_derivable_set_supplies_the_alias_when_none_was_given() { + let endpoints = [https("api.openai.com", 8443)]; + let alias = resolve(&endpoints, None, None).expect("the derived alias is stored"); + assert_eq!(alias.to_string(), "api.openai.com:8443"); +} + +#[test] +fn a_supplied_alias_equal_to_the_derived_one_is_accepted() { + let endpoints = [https("api.openai.com", 443)]; + let alias = resolve(&endpoints, Some("api.openai.com"), None) + .expect("the idempotent supplied alias is accepted"); + assert_eq!(alias.to_string(), "api.openai.com"); +} + +#[test] +fn a_supplied_alias_differing_from_the_derived_one_is_refused() { + let endpoints = [https("api.openai.com", 443)]; + let error = resolve(&endpoints, Some("other.vendor.com"), None) + .expect_err("the supplied alias differs"); + assert_eq!(error.kind, ErrorKind::ValidationError); + assert_eq!(error.detail, "alias does not match the endpoint set"); +} + +#[test] +fn a_supplied_alias_that_is_not_an_alias_at_all_is_refused() { + let endpoints = [https("api.openai.com", 443)]; + let error = resolve(&endpoints, Some("Not A Host"), None) + .expect_err("the supplied value is not an alias"); + assert_eq!(error.kind, ErrorKind::ValidationError); + assert!( + !error.detail.contains("Not A Host"), + "the detail echoed the supplied value: {error}" + ); +} + +#[test] +fn a_replacement_confirming_the_stored_alias_is_accepted() { + let endpoints = [https("api.openai.com", 443)]; + let stored = Alias::parse("api.openai.com").expect("a stored alias"); + let alias = resolve(&endpoints, None, Some(&stored)).expect("the alias is unchanged"); + assert_eq!(alias, stored); +} + +#[test] +fn a_replacement_deriving_a_different_alias_conflicts() { + let endpoints = [https("api.openai.com", 8443)]; + let stored = Alias::parse("api.openai.com").expect("a stored alias"); + let error = resolve(&endpoints, None, Some(&stored)).expect_err("the alias moved"); + assert_eq!(error.kind, ErrorKind::AliasConflict); + assert_eq!(error.http_status(), 409); + assert_eq!(error.detail, "alias is immutable across updates"); +} + +#[test] +fn a_replacement_adding_a_pooled_endpoint_that_keeps_the_alias_is_accepted() { + let stored = Alias::parse("vendor.com").expect("a stored alias"); + let endpoints = [ + https("us.vendor.com", 443), + https("eu.vendor.com", 443), + https("ap.vendor.com", 443), + ]; + let alias = resolve(&endpoints, None, Some(&stored)) + .expect("the pooled replacement keeps the alias"); + assert_eq!(alias, stored); +} + +#[test] +fn confirm_immutable_answers_the_conflict_row() { + let derived = Alias::parse("vendor.com").expect("derived"); + let stored = Alias::parse("other.com").expect("stored"); + assert!(confirm_immutable(&derived, &stored).is_err()); + assert!(confirm_immutable(&stored, &stored).is_ok()); +} + +#[test] +fn an_alias_error_is_the_domain_cause_of_a_refusal() { + // The refusal path is driven by `AliasError`, which stays a domain error + // and never carries the refused value. + assert!(matches!( + Alias::parse("Not A Host"), + Err(AliasError::InvalidLabel) + )); + let refused: Result = Alias::parse("Not A Host") + .map_err(|_| DomainError::gateway(ErrorKind::ValidationError, "alias is not valid")); + assert!(refused.is_err()); +} diff --git a/gears/system/oagw/oagw/tests/alias_tests.rs b/gears/system/oagw/oagw/tests/alias_tests.rs new file mode 100644 index 0000000..fe8d29e --- /dev/null +++ b/gears/system/oagw/oagw/tests/alias_tests.rs @@ -0,0 +1,185 @@ +//! Alias and hostname normalization tests. +//! +//! Covers `cpt-cf-oagw-algo-alias-normalize`: trimming, trailing-dot +//! stripping, ASCII lowercasing, RFC 1123 validation, and the `:port` suffix +//! that participates in alias identity. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::missing_panics_doc)] + +use oagw::{Alias, AliasError, EndpointHost, Hostname}; + +#[test] +fn lowercases_and_strips_trailing_dots() { + let alias = Alias::parse("API.OpenAI.com.").expect("valid alias"); + assert_eq!(alias.to_string(), "api.openai.com"); +} + +#[test] +fn hostname_lowercases_and_strips_trailing_dots() { + let host = Hostname::parse("API.OpenAI.com..").expect("valid hostname"); + assert_eq!(host.as_str(), "api.openai.com"); +} + +#[test] +fn rejects_non_ascii_instead_of_transliterating() { + assert_eq!(Alias::parse("api.openai.comé"), Err(AliasError::NonAscii)); + assert_eq!(Hostname::parse("exämple.com"), Err(AliasError::NonAscii)); + assert_eq!( + EndpointHost::parse("exämple.com"), + Err(AliasError::NonAscii) + ); +} + +#[test] +fn rejects_a_label_longer_than_63_characters() { + let host = format!("{}.example.com", "a".repeat(64)); + assert_eq!(Alias::parse(&host), Err(AliasError::TooLong)); +} + +#[test] +fn rejects_a_total_length_over_253_characters() { + // 63 + 1 + 63 + 1 + 63 + 1 + 62 = 254 characters. + let host = [ + "a".repeat(63), + "b".repeat(63), + "c".repeat(63), + "d".repeat(62), + ] + .join("."); + assert_eq!(host.len(), 254); + assert_eq!(Alias::parse(&host), Err(AliasError::TooLong)); +} + +#[test] +fn accepts_a_hostname_of_exactly_253_characters() { + let host = [ + "a".repeat(63), + "b".repeat(63), + "c".repeat(63), + "d".repeat(61), + ] + .join("."); + assert_eq!(host.len(), 253); + assert!( + Alias::parse(&host).is_ok(), + "253 characters is the RFC 1123 maximum" + ); +} + +#[test] +fn rejects_leading_and_trailing_hyphens_and_empty_labels() { + assert_eq!( + Alias::parse("-api.openai.com"), + Err(AliasError::InvalidLabel) + ); + assert_eq!( + Alias::parse("api-.openai.com"), + Err(AliasError::InvalidLabel) + ); + assert_eq!( + Alias::parse("api..openai.com"), + Err(AliasError::InvalidLabel) + ); + assert_eq!( + Alias::parse(".api.openai.com"), + Err(AliasError::InvalidLabel) + ); +} + +#[test] +fn rejects_a_label_with_a_character_outside_the_rfc_1123_set() { + assert_eq!( + Alias::parse("api_openai.com"), + Err(AliasError::InvalidLabel) + ); + assert_eq!( + Alias::parse("api openai.com"), + Err(AliasError::InvalidLabel) + ); +} + +#[test] +fn rejects_empty_input() { + assert_eq!(Alias::parse(""), Err(AliasError::Empty)); + assert_eq!(Alias::parse(" "), Err(AliasError::Empty)); + assert_eq!(Alias::parse("..."), Err(AliasError::Empty)); + assert_eq!(Hostname::parse(""), Err(AliasError::Empty)); +} + +#[test] +fn port_suffix_is_kept_and_separates_identity() { + let bare = Alias::parse("api.openai.com").expect("bare alias"); + let ported = Alias::parse("api.openai.com:8443").expect("ported alias"); + assert_ne!(bare, ported, "the port participates in alias identity"); + assert_eq!(ported.port(), Some(8443)); + assert_eq!(ported.host().as_str(), "api.openai.com"); + assert_eq!(ported.to_string(), "api.openai.com:8443"); + assert_eq!(bare.port(), None); +} + +#[test] +fn port_boundaries() { + assert!(Alias::parse("api.openai.com:1").is_ok(), "port 1 accepted"); + assert!( + Alias::parse("api.openai.com:65535").is_ok(), + "port 65535 accepted" + ); + assert_eq!( + Alias::parse("api.openai.com:0"), + Err(AliasError::InvalidPort) + ); + assert_eq!( + Alias::parse("api.openai.com:65536"), + Err(AliasError::InvalidPort) + ); + assert_eq!( + Alias::parse("api.openai.com:"), + Err(AliasError::InvalidPort) + ); + assert_eq!( + Alias::parse("api.openai.com:8443x"), + Err(AliasError::InvalidPort) + ); +} + +#[test] +fn port_suffix_is_normalized_the_same_way_as_the_host() { + let alias = Alias::parse(" API.OpenAI.COM.:8443 ").expect("trimmed ported alias"); + assert_eq!(alias.to_string(), "api.openai.com:8443"); + assert_eq!(alias.port(), Some(8443)); +} + +#[test] +fn accepts_the_try_from_conversions() { + let from_str = Hostname::try_from("api.openai.com").expect("hostname from &str"); + let from_string = Alias::try_from(String::from("api.openai.com")).expect("alias from String"); + assert_eq!(from_str.as_str(), "api.openai.com"); + assert_eq!(from_string.to_string(), "api.openai.com"); +} + +#[test] +fn alias_round_trips_through_its_normalized_string_form() { + let alias = Alias::parse("API.OpenAI.COM.:8443").expect("valid alias"); + let rendered = alias.to_string(); + let back = Alias::try_from(rendered.clone()).expect("normalized form re-parses"); + assert_eq!(alias, back); + let as_json = serde_json::to_value(&alias).expect("alias serializes"); + assert_eq!(as_json, serde_json::Value::String(rendered)); + let from_json: Alias = serde_json::from_value(as_json).expect("alias deserializes"); + assert_eq!(alias, from_json); +} + +#[test] +fn endpoint_host_accepts_rfc_1123_names_and_ip_literals() { + let name = EndpointHost::parse("API.OpenAI.Com.").expect("hostname endpoint host"); + assert_eq!(name.as_str(), "api.openai.com"); + let v4 = EndpointHost::parse("10.0.0.7").expect("ipv4 endpoint host"); + assert_eq!(v4.as_str(), "10.0.0.7"); + let v6 = EndpointHost::parse("2001:DB8::1").expect("ipv6 endpoint host"); + assert_eq!(v6.as_str(), "2001:db8::1"); + assert_eq!( + EndpointHost::parse("-bad-"), + Err(AliasError::InvalidLabel), + "neither an RFC 1123 name nor an IP literal" + ); +} diff --git a/gears/system/oagw/oagw/tests/api_tests.rs b/gears/system/oagw/oagw/tests/api_tests.rs new file mode 100644 index 0000000..ea9e158 --- /dev/null +++ b/gears/system/oagw/oagw/tests/api_tests.rs @@ -0,0 +1,1224 @@ +//! Management API tests. +//! +//! Covers `cpt-cf-oagw-dod-management-routes`, `cpt-cf-oagw-dod-authz-permissions`, +//! `cpt-cf-oagw-dod-request-validation` and `cpt-cf-oagw-dod-list-query-parameters` +//! on the wire: the ten paths and only the ten paths, the 401 and the 403 that +//! both precede any store access, the 201 with the GTS instance id and the +//! normalized alias, the 400 problem bodies of every validation family, the 404 +//! that never distinguishes a foreign identifier from a missing one, the two 409 +//! rows, the `204` with no body, the list page envelope with its projection, the +//! `application/problem+json` and `X-OAGW-Error-Source: gateway` every gateway +//! error carries, and the problem `detail` that never echoes a body value. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::missing_panics_doc)] + +use std::sync::Arc; + +use axum::Router; +use axum::body::Body; +use axum::http::{Method, Request, StatusCode}; +use serde_json::{Value, json}; +use tower::ServiceExt; +use uuid::Uuid; + +use authz_resolver_sdk::api::AuthZResolverClient; +use authz_resolver_sdk::constraints::{Constraint, EqPredicate, Predicate}; +use authz_resolver_sdk::error::AuthZResolverError; +use authz_resolver_sdk::models::{ + EvaluationRequest, EvaluationResponse, EvaluationResponseContext, +}; +use authz_resolver_sdk::pep::{PolicyEnforcer, ResourceType}; +use toolkit_security::SecurityContext; +use toolkit_security::pep_properties; + +use oagw::OagwConfig; +use oagw::control_plane::cache::ControlPlaneCache; +use oagw::control_plane::service::ManagementService; +use oagw::control_plane::validation::ResourceKind; +use oagw::store::OagwStore; +use oagw::{ + ERR_ALIAS_CONFLICT, ERR_AUTH_FAILED, ERR_MATCH_CONFLICT, ERR_VALIDATION, OagwState, ROUTE_TYPE, + UPSTREAM_TYPE, +}; + +const HTTP_PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; +/// The tenant every allowed request of this suite carries. +const TENANT: u128 = 0x10; +/// A second tenant no row of the suite belongs to. +const FOREIGN: u128 = 0x20; + +/// The `AuthZ` PDP the allowing stub stands in for: it grants and narrows the +/// scope to the caller's own tenant, the way the real resolver does. +struct Allowing; + +#[async_trait::async_trait] +impl AuthZResolverClient for Allowing { + async fn evaluate( + &self, + _request: EvaluationRequest, + ) -> Result { + Ok(EvaluationResponse { + decision: true, + context: EvaluationResponseContext { + constraints: vec![Constraint { + predicates: vec![Predicate::Eq(EqPredicate { + property: String::from(pep_properties::OWNER_TENANT_ID), + value: json!(TENANT.to_string()), + })], + }], + deny_reason: None, + }, + }) + } +} + +/// The `AuthZ` PDP the refusing stub stands in for. +struct Denying; + +#[async_trait::async_trait] +impl AuthZResolverClient for Denying { + async fn evaluate( + &self, + _request: EvaluationRequest, + ) -> Result { + Ok(EvaluationResponse { + decision: false, + context: EvaluationResponseContext::default(), + }) + } +} + +/// The `AuthZ` PDP a read-only token stands in for: it grants the `read` +/// action only and narrows the scope to the caller's own tenant, so a request +/// whose action the token does not hold is refused while a read of the caller's +/// own rows is admitted. +struct ReadOnly; + +#[async_trait::async_trait] +impl AuthZResolverClient for ReadOnly { + async fn evaluate( + &self, + request: EvaluationRequest, + ) -> Result { + let granted = request.action.name == "read"; + Ok(EvaluationResponse { + decision: granted, + context: EvaluationResponseContext { + constraints: if granted { + vec![Constraint { + predicates: vec![Predicate::Eq(EqPredicate { + property: String::from(pep_properties::OWNER_TENANT_ID), + value: json!(TENANT.to_string()), + })], + }] + } else { + Vec::new() + }, + deny_reason: None, + }, + }) + } +} + +/// The three routers the suite drives, over one shared store. +/// +/// Sharing the store is what makes "the refusal wrote nothing" observable: a +/// request the refusing surface answers is followed by a read through the +/// allowing surface, which sees the same rows. +struct Surfaces { + allowing: Router, + denying: Router, + read_only: Router, + enforcerless: Router, +} + +/// Builds the three surfaces over one empty store. +fn surfaces() -> Surfaces { + let store = Arc::new(OagwStore::new()); + let cache = Arc::new(ControlPlaneCache::new()); + let config = OagwConfig::default(); + let surface = |enforcer: Option| { + let service = Arc::new( + ManagementService::new(Arc::clone(&store), &config, Arc::clone(&cache)) + .expect("the validators compile"), + ); + let state = Arc::new(OagwState::new( + Arc::new(config), + Arc::clone(&store), + service, + enforcer.map(Arc::new), + None, + Arc::clone(&cache), + )); + oagw::api::rest::register_management_routes(Router::new(), state) + }; + Surfaces { + allowing: surface(Some(PolicyEnforcer::new(Arc::new(Allowing)))), + denying: surface(Some(PolicyEnforcer::new(Arc::new(Denying)))), + read_only: surface(Some(PolicyEnforcer::new(Arc::new(ReadOnly)))), + enforcerless: surface(None), + } +} + +/// The authenticated subject a request carries. +fn subject(tenant: u128) -> SecurityContext { + SecurityContext::builder() + .subject_id(Uuid::from_u128(tenant)) + .subject_tenant_id(Uuid::from_u128(tenant)) + .build() + .expect("the subject is complete") +} + +/// Issues one request and returns the whole response. +async fn issue( + app: Router, + method: Method, + uri: &str, + tenant: Option, + body: Option, +) -> axum::http::Response { + let mut builder = Request::builder().method(method).uri(uri); + if let Some(tenant) = tenant { + builder = builder.extension(subject(tenant)); + } + let payload = body.map_or_else(String::new, |value| value.to_string()); + let request = builder.body(Body::from(payload)).expect("the request builds"); + app.oneshot(request).await.expect("oneshot resolves") +} + +/// The status and the JSON body of one answer. +async fn answer( + app: Router, + method: Method, + uri: &str, + tenant: Option, + body: Option, +) -> (StatusCode, Value) { + let response = issue(app, method, uri, tenant, body).await; + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .expect("the body reads"); + let document = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).expect("the body is JSON") + }; + (status, document) +} + +/// The request path of a URI, without the query a problem document never echoes. +fn path_of(uri: &str) -> &str { + uri.split('?').next().expect("the path") +} + +/// A minimal valid upstream body. +fn upstream_body(host: &str) -> Value { + json!({ + "server": { + "endpoints": [{ "scheme": "https", "host": host, "port": 443 }] + }, + "protocol": HTTP_PROTOCOL, + "tags": ["llm"] + }) +} + +/// A minimal valid route create body. +fn route_body(upstream_id: &str, path: &str) -> Value { + json!({ + "upstream_id": upstream_id, + "match": { "http": { "methods": ["GET"], "path": path } }, + "priority": 10, + "tags": ["edge"] + }) +} + +/// The instance identifier of one created upstream, read off the wire. +async fn created_upstream(app: &Router, host: &str) -> String { + let (status, document) = answer( + app.clone(), + Method::POST, + "/oagw/v1/upstreams", + Some(TENANT), + Some(upstream_body(host)), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{document}"); + document["id"] + .as_str() + .expect("the instance id") + .to_owned() +} + +/// Creates one route and returns its instance identifier. +/// +/// The route body names its upstream by the upstream's key, which the wire +/// identifier of a created upstream carries after the type prefix. +async fn created_route(app: &Router, upstream_id: &str, path: &str) -> String { + let key = key_of(upstream_id); + let (status, document) = answer( + app.clone(), + Method::POST, + "/oagw/v1/routes", + Some(TENANT), + Some(route_body(&key, path)), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{document}"); + document["id"] + .as_str() + .expect("the instance id") + .to_owned() +} + +/// The row key the wire identifier of a created resource carries. +fn key_of(instance: &str) -> String { + oagw::gts::parse_gts_instance(UPSTREAM_TYPE, instance) + .expect("the instance parses") + .to_string() +} + +/// The number of upstream rows the surface lists for the tenant. +async fn upstream_count(app: &Router) -> usize { + let (_, document) = answer( + app.clone(), + Method::GET, + "/oagw/v1/upstreams", + Some(TENANT), + None, + ) + .await; + document["items"].as_array().expect("items").len() +} + +#[tokio::test] +async fn only_the_management_paths_are_registered() { + let Surfaces { allowing, .. } = surfaces(); + + // A path the router matched but no method is registered for is answered + // 405; an unregistered path is answered 404. + let instance = oagw::gts::gts_instance(UPSTREAM_TYPE, Uuid::from_u128(0x99)); + let plugin = oagw::gts::gts_instance( + "gts.cf.core.oagw.transform_plugin.v1~", + Uuid::from_u128(0x99), + ); + let paths = [ + "/oagw/v1/upstreams".to_owned(), + format!("/oagw/v1/upstreams/{instance}"), + "/oagw/v1/routes".to_owned(), + format!("/oagw/v1/routes/{instance}"), + "/oagw/v1/plugins".to_owned(), + format!("/oagw/v1/plugins/{plugin}"), + "/oagw/v1/plugins/{plugin}/source".to_owned(), + ]; + for uri in &paths { + let (status, _) = answer( + allowing.clone(), + Method::PATCH, + uri, + Some(TENANT), + None, + ) + .await; + assert_eq!( + status, + StatusCode::METHOD_NOT_ALLOWED, + "PATCH {uri} is not a registered method, so the path is registered" + ); + } + for uri in ["/oagw/v1/upstreams", "/oagw/v1/routes"] { + let (status, _) = answer( + allowing.clone(), + Method::DELETE, + uri, + Some(TENANT), + None, + ) + .await; + assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED, "DELETE {uri}"); + } + + // The `/api`-prefixed spelling of a management path is a path no OAGW + // handler is registered for: the gear is gear-relative only. + for uri in [ + "/api/oagw/v1/plugins", + "/api/oagw/v1/upstreams", + "/api/oagw/v1/routes", + ] { + let (status, _) = answer(allowing.clone(), Method::GET, uri, Some(TENANT), None).await; + assert_eq!(status, StatusCode::NOT_FOUND, "{uri} is not registered"); + } + + // The proxy path and the two sub-paths below the management paths belong + // to the data plane and to no feature: neither is registered. + for uri in [ + "/oagw/v1/proxy/api.openai.com", + "/oagw/v1/upstreams/some-id/plugins", + "/oagw/v1/upstreams/some-id/whatever", + ] { + let (status, _) = answer(allowing.clone(), Method::GET, uri, Some(TENANT), None).await; + assert_eq!(status, StatusCode::NOT_FOUND, "{uri} is not registered"); + } +} + +#[tokio::test] +async fn a_request_without_a_subject_is_answered_401_before_any_store_access() { + let Surfaces { allowing, .. } = surfaces(); + for (method, uri, body) in [ + ( + Method::POST, + "/oagw/v1/upstreams", + Some(upstream_body("api.openai.com")), + ), + (Method::GET, "/oagw/v1/upstreams", None), + (Method::DELETE, "/oagw/v1/upstreams/some-id", None), + ] { + let (status, document) = answer(allowing.clone(), method, uri, None, body).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "{document}"); + assert_eq!(document["type"], ERR_AUTH_FAILED, "{document}"); + assert_eq!(document["status"], 401); + assert_eq!(document["instance"], path_of(uri)); + } + + // Nothing the refused requests carried reached the store. + assert_eq!(upstream_count(&allowing).await, 0); +} + +#[tokio::test] +async fn a_request_without_the_permission_is_answered_403_before_any_store_access() { + let Surfaces { + allowing, denying, .. + } = surfaces(); + for (method, uri, body) in [ + ( + Method::POST, + "/oagw/v1/upstreams", + Some(upstream_body("api.openai.com")), + ), + (Method::GET, "/oagw/v1/upstreams", None), + ( + Method::PUT, + "/oagw/v1/upstreams/some-id", + Some(upstream_body("api.openai.com")), + ), + (Method::DELETE, "/oagw/v1/upstreams/some-id", None), + ( + Method::POST, + "/oagw/v1/routes", + Some(route_body("does-not-matter", "/v1/chat")), + ), + ] { + let (status, document) = answer(denying.clone(), method, uri, Some(TENANT), body).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{document}"); + assert_eq!(document["status"], 403); + assert_eq!(document["detail"], "the bearer token lacks the permission the operation requires"); + assert_eq!(document["instance"], path_of(uri)); + } + + // The route create above never reached the validators: the reference it + // names is not a row of any tenant, and the 403 carries no validation + // detail. Nothing the refused requests carried reached the store either. + assert_eq!(upstream_count(&allowing).await, 0); +} + +#[tokio::test] +async fn a_surface_with_no_enforcer_fails_closed() { + let Surfaces { enforcerless, .. } = surfaces(); + for (method, uri) in [ + (Method::POST, "/oagw/v1/upstreams"), + (Method::GET, "/oagw/v1/upstreams"), + (Method::GET, "/oagw/v1/routes"), + ] { + let (status, document) = answer( + enforcerless.clone(), + method, + uri, + Some(TENANT), + None, + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{document}"); + let kind = if uri.contains("routes") { + ROUTE_TYPE + } else { + UPSTREAM_TYPE + }; + assert_eq!(document["resource_type"], kind, "{document}"); + } +} + +#[tokio::test] +async fn a_created_upstream_is_answered_201_with_the_instance_id_and_the_alias() { + let Surfaces { allowing, .. } = surfaces(); + let (status, document) = answer( + allowing, + Method::POST, + "/oagw/v1/upstreams", + Some(TENANT), + Some(upstream_body("api.openai.com")), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{document}"); + assert_eq!(document["alias"], "api.openai.com"); + assert_eq!(document["protocol"], HTTP_PROTOCOL); + assert_eq!(document["enabled"], true); + assert_eq!(document["tags"], json!(["llm"])); + + // The identifier is the resource kind's anonymous GTS instance, and it + // parses back to the row's own key. + let id = document["id"].as_str().expect("the instance id"); + assert!(id.starts_with(UPSTREAM_TYPE), "{id}"); + let parsed = oagw::gts::parse_gts_instance(UPSTREAM_TYPE, id).expect("the instance parses"); + assert_eq!( + oagw::api::rest::dto::upstream_id(parsed), + id, + "the wire identifier round-trips" + ); +} + +#[tokio::test] +async fn a_failing_body_is_answered_400_naming_the_properties() { + let Surfaces { allowing, .. } = surfaces(); + let cases: Vec<(Value, &str)> = vec![ + (json!({}), "server is required"), + ( + json!({ "server": { "endpoints": [] } }), + "protocol is required", + ), + ( + json!({ "server": { "endpoints": [{ "scheme": "https" }] }, "protocol": HTTP_PROTOCOL }), + "server.endpoints[0].host is required", + ), + ( + json!({ "zzz": 1, "server": { "endpoints": [] }, "protocol": HTTP_PROTOCOL }), + "unknown property 'zzz' at root", + ), + ( + json!({ "server": { "endpoints": [{ "scheme": "gopher", "host": "h" }] }, "protocol": HTTP_PROTOCOL }), + "server.endpoints[0].scheme", + ), + ( + json!({ "server": { "endpoints": [{ "scheme": "http", "host": "api.openai.com" }] }, "protocol": HTTP_PROTOCOL }), + "scheme", + ), + ]; + for (body, needle) in cases { + let (status, document) = answer( + allowing.clone(), + Method::POST, + "/oagw/v1/upstreams", + Some(TENANT), + Some(body), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{document}"); + assert_eq!(document["type"], ERR_VALIDATION, "{document}"); + assert_eq!(document["status"], 400); + let detail = document["detail"].as_str().expect("detail"); + assert!(detail.contains(needle), "expected '{needle}' in '{detail}'"); + } + + // A body that is not JSON at all is answered before the validators run. + let response = issue( + allowing.clone(), + Method::POST, + "/oagw/v1/upstreams", + Some(TENANT), + None, + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // A route create is validated against the route schema. + for (body, needle) in [ + (json!({}), "upstream_id is required"), + ( + json!({ "upstream_id": Uuid::from_u128(1).to_string() }), + "match is required", + ), + ] { + let (status, document) = answer( + allowing.clone(), + Method::POST, + "/oagw/v1/routes", + Some(TENANT), + Some(body), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{document}"); + assert!( + document["detail"] + .as_str() + .is_some_and(|d| d.contains(needle)) + ); + } + + // No refused body wrote a row. + assert_eq!(upstream_count(&allowing).await, 0); +} + +#[tokio::test] +async fn a_route_create_addressing_an_unowned_upstream_is_refused_400() { + let Surfaces { allowing, .. } = surfaces(); + + // A well-formed reference that names no upstream of the calling tenant is + // a validation refusal, not a 404. + let reference = Uuid::from_u128(0x99).to_string(); + let (status, document) = answer( + allowing.clone(), + Method::POST, + "/oagw/v1/routes", + Some(TENANT), + Some(route_body(&reference, "/v1/chat")), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{document}"); + assert_eq!(document["type"], ERR_VALIDATION); + assert_eq!( + document["detail"], + "upstream_id does not reference an upstream of the calling tenant" + ); + + // A reference that is not an identifier at all is refused too. + let (status, document) = answer( + allowing, + Method::POST, + "/oagw/v1/routes", + Some(TENANT), + Some(route_body("not-an-identifier", "/v1/chat")), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{document}"); + assert_eq!(document["type"], ERR_VALIDATION); +} + +#[tokio::test] +async fn a_foreign_or_unparseable_identifier_answers_the_same_404() { + let Surfaces { allowing, .. } = surfaces(); + let id = created_upstream(&allowing, "api.openai.com").await; + + let detail = "the addressed resource does not exist for the calling tenant"; + for (tenant, uri) in [ + (FOREIGN, format!("/oagw/v1/upstreams/{id}")), + ( + TENANT, + format!( + "/oagw/v1/upstreams/{}", + oagw::gts::gts_instance(UPSTREAM_TYPE, Uuid::from_u128(0x99)) + ), + ), + ( + TENANT, + String::from("/oagw/v1/upstreams/not-an-identifier"), + ), + (FOREIGN, String::from("/oagw/v1/routes/not-an-identifier")), + ] { + for method in [Method::GET, Method::PUT, Method::DELETE] { + let (status, document) = answer( + allowing.clone(), + method.clone(), + &uri, + Some(tenant), + Some(upstream_body("api.openai.com")), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND, "{method} {uri} {document}"); + assert_eq!(document["detail"], detail, "{method} {uri}"); + } + } +} + +#[tokio::test] +async fn the_conflicts_are_answered_409_with_their_catalogue_rows() { + let Surfaces { allowing, .. } = surfaces(); + let id = created_upstream(&allowing, "api.openai.com").await; + + let (status, document) = answer( + allowing.clone(), + Method::POST, + "/oagw/v1/upstreams", + Some(TENANT), + Some(upstream_body("api.openai.com")), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{document}"); + assert_eq!(document["type"], ERR_ALIAS_CONFLICT); + assert_eq!(document["status"], 409); + assert_eq!( + document["detail"], + "another upstream of the calling tenant already holds the alias" + ); + + let route = created_route(&allowing, &id, "/v1/chat").await; + let (status, document) = answer( + allowing, + Method::POST, + "/oagw/v1/routes", + Some(TENANT), + Some(route_body(&key_of(&id), "/v1/chat")), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{document}"); + assert_eq!(document["type"], ERR_MATCH_CONFLICT); + let detail = document["detail"].as_str().expect("detail"); + assert!(detail.contains("already holds this match rule"), "'{detail}'"); + assert!( + !detail.contains("/v1/chat"), + "the detail names no body value: {detail}" + ); + let _ = route; +} + +#[tokio::test] +async fn the_deletes_are_answered_204_with_no_body() { + let Surfaces { allowing, .. } = surfaces(); + let upstream = created_upstream(&allowing, "api.openai.com").await; + let route = created_route(&allowing, &upstream, "/v1/chat").await; + + for id in [route.clone(), upstream.clone()] { + let kind = if id.starts_with(UPSTREAM_TYPE) { + "upstreams" + } else { + "routes" + }; + let response = issue( + allowing.clone(), + Method::DELETE, + &format!("/oagw/v1/{kind}/{id}"), + Some(TENANT), + None, + ) + .await; + assert_eq!(response.status(), StatusCode::NO_CONTENT, "{id}"); + let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .expect("the body reads"); + assert!(bytes.is_empty(), "a 204 carries no body"); + } + + // The cascade removed the route with its upstream, and the second delete + // of either identifier is the same 404 a miss answers with. + for uri in [ + format!("/oagw/v1/routes/{route}"), + format!("/oagw/v1/upstreams/{upstream}"), + ] { + let (status, _) = answer(allowing.clone(), Method::GET, &uri, Some(TENANT), None).await; + assert_eq!(status, StatusCode::NOT_FOUND, "{uri}"); + let (status, _) = answer(allowing.clone(), Method::DELETE, &uri, Some(TENANT), None).await; + assert_eq!(status, StatusCode::NOT_FOUND, "{uri}"); + } +} + +#[tokio::test] +async fn the_list_answers_the_page_envelope_with_the_projection() { + let Surfaces { allowing, .. } = surfaces(); + for host in ["api.openai.com", "eu.openai.com", "foreign.openai.com"] { + created_upstream(&allowing, host).await; + } + + let (status, document) = answer( + allowing.clone(), + Method::GET, + "/oagw/v1/upstreams", + Some(TENANT), + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "{document}"); + assert_eq!(document["items"].as_array().expect("items").len(), 3); + assert_eq!(document["page_info"]["limit"], 50, "the declared default"); + assert!(document["page_info"]["next_cursor"].is_null()); + assert!(document.get("projection").is_none(), "no projection was asked"); + assert!(document["items"][0].get("alias").is_some(), "the whole row"); + + let (status, document) = answer( + allowing.clone(), + Method::GET, + "/oagw/v1/upstreams?%24top=1&%24select=alias,protocol&%24orderby=alias", + Some(TENANT), + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "{document}"); + assert_eq!(document["page_info"]["limit"], 1); + assert_eq!(document["projection"], json!(["alias", "protocol"])); + let item = &document["items"][0]; + assert_eq!( + item.as_object().expect("item").len(), + 2, + "only the projected members" + ); + assert_eq!(item["alias"], "api.openai.com"); + + // The tenant scope precedes every parameter. + let (_, document) = answer( + allowing, + Method::GET, + "/oagw/v1/upstreams?%24filter=alias%20eq%20'foreign.openai.com'", + Some(FOREIGN), + None, + ) + .await; + assert!(document["items"].as_array().expect("items").is_empty()); +} + +#[tokio::test] +async fn a_malformed_list_parameter_is_a_problem_document() { + let Surfaces { allowing, .. } = surfaces(); + for query in ["%24count=true", "%24top=late", "%24zzz=1", "%24select=zzz"] { + let (status, document) = answer( + allowing.clone(), + Method::GET, + &format!("/oagw/v1/upstreams?{query}"), + Some(TENANT), + None, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{query} {document}"); + assert_eq!(document["type"], ERR_VALIDATION, "{query}"); + assert_eq!(document["instance"], "/oagw/v1/upstreams"); + } +} + +#[tokio::test] +async fn every_gateway_error_is_problem_json_sourced_from_the_gateway() { + let Surfaces { allowing, .. } = surfaces(); + let id = created_upstream(&allowing, "api.openai.com").await; + + let requests: Vec<(Method, String, Option)> = vec![ + ( + Method::POST, + String::from("/oagw/v1/upstreams"), + Some(json!({})), + ), + ( + Method::GET, + String::from("/oagw/v1/upstreams?%24count=true"), + None, + ), + ( + Method::GET, + format!( + "/oagw/v1/upstreams/{}", + oagw::gts::gts_instance(UPSTREAM_TYPE, Uuid::from_u128(0x99)) + ), + None, + ), + ( + Method::POST, + String::from("/oagw/v1/upstreams"), + Some(upstream_body("api.openai.com")), + ), + (Method::DELETE, format!("/oagw/v1/upstreams/{id}"), None), + ]; + let mut statuses = Vec::new(); + for (method, uri, body) in requests { + let response = issue(allowing.clone(), method.clone(), &uri, Some(TENANT), body).await; + let status = response.status(); + statuses.push(u16::from(status)); + // A successful answer carries no problem document at all. + if status == StatusCode::NO_CONTENT { + assert_eq!(response.headers().get("x-oagw-error-source"), None); + continue; + } + assert_eq!( + response + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/problem+json"), + "{method} {uri}" + ); + assert_eq!( + response + .headers() + .get("x-oagw-error-source") + .and_then(|value| value.to_str().ok()), + Some("gateway"), + "{method} {uri}" + ); + let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .expect("the body reads"); + let document: Value = serde_json::from_slice(&bytes).expect("the problem is JSON"); + assert!( + document["type"] + .as_str() + .is_some_and(|t| t.starts_with("gts.")) + ); + assert_eq!(document["instance"], path_of(&uri)); + } + // The five requests answered 400, 400, 404, 409 and 204 respectively; only + // the successful delete carries no problem document. + assert_eq!(statuses, vec![400, 400, 404, 409, 204]); +} + +#[tokio::test] +async fn no_problem_detail_echoes_a_request_body_value() { + let Surfaces { allowing, .. } = surfaces(); + // The values a hostile body would want reflected back. + let leaky = json!({ + "alias": "secret-alias-9f21c", + "server": { "endpoints": [{ "scheme": "gopher", "host": "secret-host-9f21c" }] }, + "protocol": HTTP_PROTOCOL, + "tags": ["secret-tag-9f21c"], + "zzz": "secret-unknown-9f21c" + }); + for body in [ + leaky, + json!({ "server": { "endpoints": [] }, "protocol": "secret-protocol-9f21c" }), + json!({}), + ] { + let (_, document) = answer( + allowing.clone(), + Method::POST, + "/oagw/v1/upstreams", + Some(TENANT), + Some(body), + ) + .await; + let detail = document["detail"].as_str().expect("detail"); + for needle in [ + "9f21c", + "secret-alias", + "secret-host", + "secret-tag", + "secret-unknown", + "secret-protocol", + ] { + assert!(!detail.contains(needle), "'{detail}' echoed '{needle}'"); + } + } + + // The same holds on the route surface, whose refusal may not name the path + // or the reference the body carried. + let (_, document) = answer( + allowing, + Method::POST, + "/oagw/v1/routes", + Some(TENANT), + Some(route_body("secret-upstream-ref", "/secret-path-9f21c")), + ) + .await; + let detail = document["detail"].as_str().expect("detail"); + assert!(!detail.contains("secret-upstream-ref"), "{detail}"); + assert!(!detail.contains("secret-path"), "{detail}"); +} + +#[tokio::test] +async fn a_replacement_answers_200_with_the_stored_identifier() { + let Surfaces { allowing, .. } = surfaces(); + let upstream = created_upstream(&allowing, "api.openai.com").await; + let mut body = upstream_body("api.openai.com"); + body["tags"] = json!(["edge"]); + + let (status, document) = answer( + allowing.clone(), + Method::PUT, + &format!("/oagw/v1/upstreams/{upstream}"), + Some(TENANT), + Some(body), + ) + .await; + assert_eq!(status, StatusCode::OK, "{document}"); + assert_eq!(document["id"], json!(upstream), "the identifier is immutable"); + assert_eq!(document["tags"], json!(["edge"]), "the replacement is in full"); + assert_eq!(document["alias"], "api.openai.com", "the alias is immutable"); + + // A route replacement takes its reference from the stored row. + let route = created_route(&allowing, &upstream, "/v1/chat").await; + let mut body = route_body(&upstream, "/v1/chat"); + body.as_object_mut() + .expect("the body is an object") + .remove("upstream_id"); + body["priority"] = json!(20); + let (status, document) = answer( + allowing, + Method::PUT, + &format!("/oagw/v1/routes/{route}"), + Some(TENANT), + Some(body), + ) + .await; + assert_eq!(status, StatusCode::OK, "{document}"); + assert_eq!(document["id"], json!(route)); + assert_eq!(document["priority"], 20); +} + +#[test] +fn the_enforcer_is_told_the_resource_kind_gts_type() { + let upstream = oagw::api::rest::handlers::resource_type(ResourceKind::Upstream); + let route = oagw::api::rest::handlers::resource_type(ResourceKind::Route); + let name = |descriptor: &ResourceType| descriptor.name().to_owned(); + assert_eq!(name(&upstream), UPSTREAM_TYPE); + assert_eq!(name(&route), ROUTE_TYPE); + for descriptor in [&upstream, &route] { + assert!( + descriptor + .supported_properties() + .contains(&pep_properties::OWNER_TENANT_ID) + ); + assert!( + descriptor + .supported_properties() + .contains(&pep_properties::RESOURCE_ID) + ); + } +} + +/// A valid transform plugin create body. +fn plugin_body(name: &str) -> Value { + json!({ + "plugin_type": "transform", + "name": name, + "phases": ["on_response"], + "source_code": "def on_response(ctx):\n return ctx\n" + }) +} + +/// Creates one plugin and returns its instance identifier, which names the +/// arm the family selects. +async fn created_plugin(app: &Router, name: &str) -> String { + let (status, document) = answer( + app.clone(), + Method::POST, + "/oagw/v1/plugins", + Some(TENANT), + Some(plugin_body(name)), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{document}"); + document["id"] + .as_str() + .expect("the instance id") + .to_owned() +} + +#[tokio::test] +async fn a_plugin_create_is_answered_201_with_the_family_instance_id() { + let Surfaces { allowing, .. } = surfaces(); + let (status, document) = answer( + allowing.clone(), + Method::POST, + "/oagw/v1/plugins", + Some(TENANT), + Some(plugin_body("redact-headers")), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{document}"); + assert!( + document["id"] + .as_str() + .expect("the instance id") + .starts_with("gts.cf.core.oagw.transform_plugin.v1~"), + "the id names the arm the family selects: {document}" + ); + assert_eq!(document["plugin_type"], "transform"); + assert_eq!( + document["source_code"], + "def on_response(ctx):\n return ctx\n", + "the source is carried verbatim" + ); + assert_eq!(document["phases"], json!(["on_response"])); +} + +#[tokio::test] +async fn a_plugin_body_that_names_no_arm_is_answered_400_after_the_401_gate() { + let Surfaces { allowing, denying, .. } = surfaces(); + // A body whose `plugin_type` selects no arm is enforced against every arm, + // so the permission still precedes the validation the flow answers with: + // a subject holding none of them is refused 403, and only a permitted + // subject reaches the 400 that names the property. + let (status, document) = answer( + denying.clone(), + Method::POST, + "/oagw/v1/plugins", + Some(TENANT), + Some(json!({ "plugin_type": "throttle", "name": "x", "source_code": "d" })), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{document}"); + let (status, document) = answer( + allowing.clone(), + Method::POST, + "/oagw/v1/plugins", + Some(TENANT), + Some(json!({ "plugin_type": "throttle", "name": "x", "source_code": "d" })), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{document}"); + assert_eq!(document["detail"], "plugin_type", "{document}"); +} + +#[tokio::test] +async fn a_plugin_request_without_a_subject_is_answered_401_before_any_store_access() { + let Surfaces { allowing, .. } = surfaces(); + for (method, uri, body) in [ + (Method::POST, "/oagw/v1/plugins", Some(plugin_body("x"))), + (Method::GET, "/oagw/v1/plugins", None), + (Method::GET, "/oagw/v1/plugins/whatever", None), + (Method::DELETE, "/oagw/v1/plugins/whatever", None), + ] { + let (status, document) = answer(allowing.clone(), method, uri, None, body).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "{document}"); + assert_eq!(document["instance"], path_of(uri)); + } +} + +#[tokio::test] +async fn a_plugin_request_without_the_permission_is_answered_403_before_any_store_access() { + let Surfaces { denying, enforcerless, .. } = surfaces(); + for surface in [&denying, &enforcerless] { + for (method, uri, body) in [ + (Method::POST, "/oagw/v1/plugins", Some(plugin_body("x"))), + (Method::GET, "/oagw/v1/plugins", None), + ] { + let (status, _) = answer(surface.clone(), method.clone(), uri, Some(TENANT), body).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{method} {uri}"); + } + } +} + +#[tokio::test] +async fn a_read_only_token_reads_and_writes_nothing() { + let Surfaces { read_only, .. } = surfaces(); + let (status, document) = answer( + read_only.clone(), + Method::GET, + "/oagw/v1/plugins", + Some(TENANT), + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "{document}"); + for (method, uri, body) in [ + (Method::POST, String::from("/oagw/v1/plugins"), Some(plugin_body("read-only"))), + (Method::DELETE, String::from("/oagw/v1/plugins/whatever"), None), + ] { + let note = format!("{method} {uri}"); + let (status, _) = answer(read_only.clone(), method, &uri, Some(TENANT), body).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{note}"); + } +} + +#[tokio::test] +async fn a_plugin_path_that_names_no_arm_addresses_nothing() { + let Surfaces { allowing, .. } = surfaces(); + let named = created_plugin(&allowing, "redact-headers").await; + // A bare `Uuid` names no arm, so it is not an accepted `{id}` spelling. + for uri in ["/oagw/v1/plugins/some-id", "/oagw/v1/plugins/not-an-id"] { + let (status, _) = answer( + allowing.clone(), + Method::GET, + uri, + Some(TENANT), + None, + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND, "{uri}"); + } + // A named plugin's GTS identifier — a reserved catalogue row with no + // management row behind it — addresses nothing through any of the three + // read-and-delete paths, exactly as a nonexistent identifier does. + let reserved = oagw::gts::plugin_catalog::CATALOG_ONLY_GUARD_TIMEOUT; + for (method, suffix) in [ + (Method::GET, String::new()), + (Method::GET, String::from("/source")), + (Method::DELETE, String::new()), + ] { + let uri = format!("/oagw/v1/plugins/{reserved}{suffix}"); + let (status, _) = answer(allowing.clone(), method, &uri, Some(TENANT), None).await; + assert_eq!(status, StatusCode::NOT_FOUND, "{uri} names no row"); + } + let (status, document) = answer( + allowing.clone(), + Method::PUT, + &format!("/oagw/v1/plugins/{named}"), + Some(TENANT), + Some(plugin_body("replacement")), + ) + .await; + assert_eq!( + status, + StatusCode::METHOD_NOT_ALLOWED, + "plugins are immutable, so no PUT is registered: {document}" + ); +} + +#[tokio::test] +async fn the_plugin_source_path_returns_the_source_and_nothing_else() { + let Surfaces { allowing, .. } = surfaces(); + let named = created_plugin(&allowing, "redact-headers").await; + let (status, document) = answer( + allowing.clone(), + Method::GET, + &format!("/oagw/v1/plugins/{named}/source"), + Some(TENANT), + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "{document}"); + assert_eq!( + document, + json!("def on_response(ctx):\n return ctx\n"), + "the source alone, with no row member beside it" + ); +} + +#[tokio::test] +async fn a_plugin_deletion_is_answered_204_with_no_body() { + let Surfaces { allowing, .. } = surfaces(); + let named = created_plugin(&allowing, "redact-headers").await; + let (status, document) = answer( + allowing.clone(), + Method::DELETE, + &format!("/oagw/v1/plugins/{named}"), + Some(TENANT), + None, + ) + .await; + assert_eq!(status, StatusCode::NO_CONTENT, "{document}"); + assert_eq!(document, Value::Null, "a deletion has no representation"); + + let (status, _) = answer( + allowing, + Method::GET, + &format!("/oagw/v1/plugins/{named}"), + Some(TENANT), + None, + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND, "the row is gone"); +} + +#[tokio::test] +async fn the_plugin_list_supports_the_closed_odata_surface() { + let Surfaces { allowing, .. } = surfaces(); + created_plugin(&allowing, "redact-headers").await; + let (status, document) = answer( + allowing.clone(), + Method::GET, + "/oagw/v1/plugins?$top=1&$select=name", + Some(TENANT), + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "{document}"); + assert_eq!(document["items"].as_array().expect("items").len(), 1); + assert_eq!( + document["items"][0], + json!({ "name": "redact-headers" }), + "the projection narrows every item" + ); + + let (status, document) = answer( + allowing, + Method::GET, + "/oagw/v1/plugins?$orderby=name&$select=tenant_id", + Some(TENANT), + None, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{document}"); + assert!(document["detail"].as_str().expect("detail").contains("$orderby")); +} diff --git a/gears/system/oagw/oagw/tests/bind_tests.rs b/gears/system/oagw/oagw/tests/bind_tests.rs new file mode 100644 index 0000000..3c98188 --- /dev/null +++ b/gears/system/oagw/oagw/tests/bind_tests.rs @@ -0,0 +1,766 @@ +//! The bind-style create and the inherited-field override. +//! +//! Covers `cpt-cf-oagw-dod-binding-style-creation` and the write half of +//! `cpt-cf-oagw-dod-sharing-mode-decision` and +//! `cpt-cf-oagw-dod-descendant-override-permissions`: a create whose alias +//! matches an ancestor binds instead of conflicting, the four permissions gate +//! the four families on both rows, an `enforce` family answers 400 and a +//! missing permission answers 403 in that order, the ancestor's rows — tag +//! rows included — are byte-identical after every operation, and no refused +//! answer carries an ancestor value. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::sync::Arc; + +use oagw::control_plane::cache::ControlPlaneCache; +use oagw::control_plane::service::{ManagementService, ServiceError}; +use oagw::control_plane::sharing::OverridePermissions; +use oagw::domain::error::ErrorKind; +use oagw::domain::route::{HttpMatch, MatchConfig, Route}; +use oagw::domain::upstream::{ + AuthConfig, Burst, PluginsConfig, RateLimitConfig, ServerConfig, SharingMode, Sustained, + Upstream, +}; +use oagw::store::OagwStore; +use oagw::{CorsConfig, Endpoint, EndpointHost, OagwConfig, Scheme, UpstreamRow}; +use serde_json::{Value, json}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +const HTTP_PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; + +/// The alias the ancestor holds and the descendant binds against. +const ALIAS: &str = "api.openai.com"; + +const BIND: &str = "oagw:upstream:bind"; +const OVERRIDE_AUTH: &str = "oagw:upstream:override_auth"; +const OVERRIDE_RATE: &str = "oagw:upstream:override_rate"; +const ADD_PLUGINS: &str = "oagw:upstream:add_plugins"; + +fn tenant(n: u128) -> Uuid { + Uuid::from_u128(n) +} + +/// The calling tenant and its single ancestor, in chain order. +fn chain() -> (Uuid, Uuid) { + (tenant(0xb001), tenant(0xb002)) +} + +/// The chain the tests pass the service, calling tenant first. +fn ancestors() -> Vec { + let (_, ancestor) = chain(); + vec![ancestor] +} + +/// A chain the resolver could not have produced: the calling tenant appears +/// twice, which the walk refuses to order. +fn cyclic_ancestors() -> Vec { + let (calling, ancestor) = chain(); + vec![ancestor, calling, calling] +} + +fn context(scopes: &[&str]) -> SecurityContext { + let (calling, _) = chain(); + SecurityContext::builder() + .subject_id(tenant(0xc001)) + .subject_tenant_id(calling) + .token_scopes(scopes.iter().map(|scope| String::from(*scope)).collect()) + .build() + .expect("the context builds") +} + +fn permissions(scopes: &[&str]) -> OverridePermissions { + OverridePermissions::of(&context(scopes)) +} + +fn service_with_store() -> (ManagementService, Arc) { + let store = Arc::new(OagwStore::new()); + let service = ManagementService::new( + Arc::clone(&store), + &OagwConfig::default(), + Arc::new(ControlPlaneCache::new()), + ) + .expect("the validators compile"); + (service, store) +} + +/// An upstream body whose endpoints derive the alias, with no family set. +fn base(alias: &str) -> Value { + json!({ + "server": { "endpoints": [{ "scheme": "https", "host": alias, "port": 443 }] }, + "protocol": HTTP_PROTOCOL, + "tags": [] + }) +} + +fn with_families(body: Value, families: Value) -> Value { + let mut object = body.as_object().expect("the body is an object").clone(); + for (key, value) in families.as_object().expect("the families are an object") { + object.insert(key.clone(), value.clone()); + } + Value::Object(object) +} + +fn auth_body(sharing: &str, key: &str) -> Value { + // The auth identifier is one the built-in registry backs: the plugin + // feature resolves it before the sharing-mode decision runs, and an + // identifier it does not back is answered 400 ahead of that decision. + json!({ + "sharing": sharing, + "type": oagw::gts::plugin_catalog::AUTH_APIKEY, + "config": { "key": key } + }) +} + +fn rate_limit_body(sharing: &str, rate: u64) -> Value { + json!({ + "sharing": sharing, + "algorithm": "token_bucket", + "sustained": { "rate": rate, "window": "minute" }, + "burst": { "capacity": rate }, + "scope": "tenant", + "strategy": "reject", + "cost": 1 + }) +} + +fn cors_body(sharing: &str, origin: &str) -> Value { + json!({ + "sharing": sharing, + "enabled": true, + "allowed_origins": [origin], + "allowed_methods": ["GET"], + "expose_headers": [], + "allow_credentials": false + }) +} + +/// Builds the upstream value one store-level row holds, with no family set. +fn stored(alias: &str) -> Upstream { + let mut upstream = Upstream::new( + Uuid::new_v4(), + ServerConfig { + endpoints: vec![Endpoint { + scheme: Scheme::Https, + host: EndpointHost::parse(alias).expect("the host parses"), + port: Some(443), + }], + }, + String::from(HTTP_PROTOCOL), + ); + upstream.alias = Some(String::from(alias)); + upstream +} + +fn with_auth(upstream: Upstream, sharing: SharingMode, key: &str) -> Upstream { + let mut upstream = upstream; + upstream.auth = Some(AuthConfig { + r#type: Some(String::from("gts.cf.core.oagw.auth_plugin.v1~x.v1")), + sharing: Some(sharing), + config: Some(json_value(key)), + }); + upstream +} + +fn with_rate_limit(upstream: Upstream, sharing: SharingMode, rate: u64) -> Upstream { + let mut upstream = upstream; + upstream.rate_limit = Some(RateLimitConfig { + sharing: Some(sharing), + algorithm: None, + sustained: Some(Sustained { + rate, + window: None, + }), + burst: Some(Burst { capacity: rate }), + scope: None, + strategy: None, + cost: None, + }); + upstream +} + +fn with_cors(upstream: Upstream, sharing: SharingMode, origin: &str) -> Upstream { + let mut upstream = upstream; + upstream.cors = Some(CorsConfig { + sharing: Some(sharing), + enabled: true, + allowed_origins: vec![String::from(origin)], + allowed_methods: vec![String::from("GET")], + expose_headers: Vec::new(), + allow_credentials: false, + }); + upstream +} + +/// The open `config` object the auth family carries. +fn json_value(key: &str) -> Value { + json!({ "key": key }) +} + +/// Inserts the ancestor's row and answers its identifier. +fn insert_ancestor(store: &OagwStore, upstream: &Upstream) -> Uuid { + let (_, ancestor) = chain(); + store + .insert_upstream(ancestor, upstream) + .expect("the ancestor row is inserted") + .upstream + .id +} + +/// Creates the descendant's row through the service and answers it. +#[allow(clippy::result_large_err)] +fn bind( + service: &ManagementService, + scopes: &[&str], + families: Value, +) -> Result { + let (calling, _) = chain(); + let body = with_families(base(ALIAS), families); + service.create_upstream_in_chain( + calling, + &ancestors(), + &permissions(scopes), + &body, + ) +} + +#[test] +fn a_create_whose_alias_matches_an_ancestor_binds_instead_of_conflicting() { + let (service, store) = service_with_store(); + let ancestor_id = insert_ancestor(&store, &stored(ALIAS)); + + let written = + bind(&service, &[BIND], json!({})).expect("a bind is answered 201, not 409"); + let (calling, _) = chain(); + assert_eq!(written.tenant_id, calling, "the row is the descendant's own"); + assert_ne!(written.upstream.id, ancestor_id, "a new row is written"); + assert_eq!(written.upstream.alias.as_deref(), Some(ALIAS)); +} + +#[test] +fn a_create_whose_alias_matches_no_ancestor_is_ordinary() { + let (service, store) = service_with_store(); + insert_ancestor(&store, &stored("other.example.com")); + + let written = service + .create_upstream_in_chain( + chain().0, + &ancestors(), + &OverridePermissions::none(), + &base(ALIAS), + ) + .expect("no bind is performed and no bind permission is consumed"); + assert_eq!(written.upstream.alias.as_deref(), Some(ALIAS)); +} + +#[test] +fn a_bind_without_the_bind_permission_is_refused_403() { + let (service, store) = service_with_store(); + insert_ancestor(&store, &stored(ALIAS)); + + let refusal = bind(&service, &[], json!({})).expect_err("the bind is refused"); + assert_eq!( + refusal.permission(), + Some(BIND), + "the refusal names the permission the token lacks" + ); + let (calling, _) = chain(); + assert!( + store.list_upstreams(calling).is_empty(), + "a refused bind writes no row" + ); +} + +#[test] +fn a_bind_carrying_an_enforced_family_is_refused_400() { + let (service, store) = service_with_store(); + insert_ancestor(&store, &with_auth(stored(ALIAS), SharingMode::Enforce, "root")); + + let refusal = bind( + &service, + &[BIND], + json!({ "auth": auth_body("private", "leaf") }), + ) + .expect_err("the enforced family is refused"); + let ServiceError::Domain(error) = &refusal else { + panic!("an enforced family answers 400, not {refusal:?}"); + }; + assert_eq!(error.kind, ErrorKind::ValidationError); + assert!( + error.detail.contains("auth"), + "the 400 names the family: {}", + error.detail + ); +} + +#[test] +fn a_bind_with_no_value_for_an_enforced_family_succeeds() { + let (service, store) = service_with_store(); + insert_ancestor(&store, &with_auth(stored(ALIAS), SharingMode::Enforce, "root")); + + let written = bind(&service, &[BIND], json!({})).expect("nothing is overridden"); + assert!(written.upstream.auth.is_none(), "the body carried no auth"); +} + +#[test] +fn a_bind_with_a_private_ancestor_family_writes_the_descendants_own_value() { + let (service, store) = service_with_store(); + insert_ancestor(&store, &with_auth(stored(ALIAS), SharingMode::Private, "root")); + + let written = bind( + &service, + &[BIND], + json!({ "auth": auth_body("private", "leaf") }), + ) + .expect("a private family blocks no bind and consumes no permission"); + assert!(written.upstream.auth.is_some(), "the body's value reaches the row"); +} + +#[test] +fn a_bind_stores_the_request_tags_on_the_descendants_row_only() { + let (service, store) = service_with_store(); + let ancestor_id = insert_ancestor(&store, &stored(ALIAS)); + + let written = bind(&service, &[BIND], json!({ "tags": ["edge"] })) + .expect("the bind is written"); + let (calling, ancestor) = chain(); + assert_eq!(written.tags, vec![String::from("edge")]); + assert_eq!( + store.upstream_tag_rows(calling, written.upstream.id), + vec![String::from("edge")], + "the request tags are tenant-local additions on the descendant's row" + ); + assert!( + store.upstream_tag_rows(ancestor, ancestor_id).is_empty(), + "no tag reaches the ancestor's tag rows" + ); +} + +#[test] +fn a_bind_leaves_every_ancestor_row_byte_identical() { + let (service, store) = service_with_store(); + let ancestor_id = insert_ancestor(&store, &with_auth(stored(ALIAS), SharingMode::Inherit, "root")); + let (_, ancestor) = chain(); + let before = store + .get_upstream(ancestor, ancestor_id) + .expect("the ancestor row exists"); + + let _ = bind( + &service, + &[BIND, OVERRIDE_AUTH], + json!({ "auth": auth_body("private", "leaf"), "tags": ["edge"] }), + ) + .expect("the bind is written"); + + let after = store + .get_upstream(ancestor, ancestor_id) + .expect("the ancestor row still exists"); + assert_eq!(&before, &after, "the ancestor row is byte-identical"); + assert_eq!( + store.upstream_tag_rows(ancestor, ancestor_id), + before.tags, + "the ancestor's tag rows are byte-identical" + ); +} + +#[test] +fn a_refused_answer_carries_no_ancestor_value() { + let (service, store) = service_with_store(); + insert_ancestor(&store, &with_auth(stored(ALIAS), SharingMode::Enforce, "root-secret")); + + // The 400 names the family and nothing of the ancestor's configuration. + let refusal = bind( + &service, + &[BIND], + json!({ "auth": auth_body("private", "leaf") }), + ) + .expect_err("the enforced family is refused"); + let ServiceError::Domain(error) = &refusal else { + panic!("an enforced family answers 400, not {refusal:?}"); + }; + assert!( + !error.detail.contains("root-secret"), + "no ancestor value is disclosed: {}", + error.detail + ); + assert!( + !error.detail.contains(ALIAS), + "the ancestor's alias is not disclosed: {}", + error.detail + ); + + // The 403 carries no detail at all beyond the permission it names. + let refusal = bind(&service, &[], json!({})).expect_err("the bind is refused"); + assert_eq!(refusal.permission(), Some(BIND)); + assert!( + matches!(refusal, ServiceError::Forbidden { .. }), + "a 403 is not a catalogue answer" + ); +} + +#[test] +fn a_same_tenant_duplicate_is_answered_409_before_the_walk() { + let (service, store) = service_with_store(); + insert_ancestor(&store, &stored(ALIAS)); + + // The calling tenant already holds the alias: the conflict is answered + // before the walk runs, so a missing bind permission never surfaces. + let (calling, _) = chain(); + service + .create_upstream(calling, &base(ALIAS)) + .expect("the first create is ordinary"); + let refusal = service + .create_upstream_in_chain( + calling, + &ancestors(), + &OverridePermissions::none(), + &base(ALIAS), + ) + .expect_err("the duplicate is refused"); + let ServiceError::Domain(error) = &refusal else { + panic!("a same-tenant duplicate answers 409, not {refusal:?}"); + }; + assert_eq!(error.kind, ErrorKind::AliasConflict); +} + +#[test] +fn an_unordered_chain_fails_closed_without_writing() { + let (service, store) = service_with_store(); + insert_ancestor(&store, &stored(ALIAS)); + + let (calling, _) = chain(); + let refusal = service + .create_upstream_in_chain( + calling, + &cyclic_ancestors(), + &permissions(&[BIND]), + &base(ALIAS), + ) + .expect_err("an unordered chain cannot be resolved"); + assert!( + refusal.is_storage(), + "an unordered chain fails closed with the platform 500 shape" + ); + assert!(store.list_upstreams(calling).is_empty(), "nothing is written"); +} + +#[test] +fn an_inherited_rate_limit_overrides_with_the_permission_held() { + let (service, store) = service_with_store(); + insert_ancestor(&store, &with_rate_limit(stored(ALIAS), SharingMode::Inherit, 100)); + + let (calling, _) = chain(); + let written = service + .replace_upstream_in_chain( + calling, + created(&store), + &ancestors(), + &permissions(&[OVERRIDE_RATE]), + &with_families(base(ALIAS), json!({ "rate_limit": rate_limit_body("private", 50) })), + ) + .expect("the override is permitted"); + assert_eq!( + written.upstream.rate_limit.as_ref().and_then(|limit| limit.sustained.as_ref().map(|rate| rate.rate)), + Some(50), + "the body's value reaches the row" + ); +} + +#[test] +fn an_inherited_rate_limit_without_the_permission_is_refused_403() { + let (service, store) = service_with_store(); + insert_ancestor(&store, &with_rate_limit(stored(ALIAS), SharingMode::Inherit, 100)); + + let (calling, _) = chain(); + let refusal = service + .replace_upstream_in_chain( + calling, + created(&store), + &ancestors(), + &OverridePermissions::none(), + &with_families(base(ALIAS), json!({ "rate_limit": rate_limit_body("private", 50) })), + ) + .expect_err("the override is refused"); + assert_eq!(refusal.permission(), Some(OVERRIDE_RATE)); +} + +#[test] +fn an_enforced_family_in_a_replacement_is_refused_400() { + let (service, store) = service_with_store(); + insert_ancestor(&store, &with_cors(stored(ALIAS), SharingMode::Enforce, "https://root.example.com")); + + let (calling, _) = chain(); + let refusal = service + .replace_upstream_in_chain( + calling, + created(&store), + &ancestors(), + &permissions(&[OVERRIDE_RATE]), + &with_families(base(ALIAS), json!({ "cors": cors_body("private", "https://leaf.example.com") })), + ) + .expect_err("the enforced family is refused"); + let ServiceError::Domain(error) = &refusal else { + panic!("an enforced family answers 400, not {refusal:?}"); + }; + assert_eq!(error.kind, ErrorKind::ValidationError); + assert!(error.detail.contains("cors"), "{}", error.detail); + assert!( + !error.detail.contains("root.example.com"), + "no ancestor value is disclosed: {}", + error.detail + ); +} + +#[test] +fn a_replacement_that_omits_the_enforced_family_succeeds() { + let (service, store) = service_with_store(); + insert_ancestor(&store, &with_cors(stored(ALIAS), SharingMode::Enforce, "https://root.example.com")); + + let (calling, _) = chain(); + let written = service + .replace_upstream_in_chain( + calling, + created(&store), + &ancestors(), + &permissions(&[]), + &base(ALIAS), + ) + .expect("nothing is overridden"); + assert!(written.upstream.cors.is_none()); +} + +#[test] +fn the_permission_403_precedes_any_enforce_400_on_a_replacement() { + let (_, ancestor) = chain(); + let (service, store) = service_with_store(); + // The nearer ancestor enforces the CORS family and the more distant one + // inherits the rate limit; the alias the descendant's row carries is the + // one both hold. + insert_ancestor(&store, &with_cors(stored(ALIAS), SharingMode::Enforce, "https://root.example.com")); + store + .insert_upstream( + tenant(0xb003), + &with_rate_limit(stored(ALIAS), SharingMode::Inherit, 100), + ) + .expect("the second ancestor row is inserted"); + + // The token holds no permission at all, so both families are blocked and + // the permission refusal is the one returned. + let (calling, _) = chain(); + let refusal = service + .replace_upstream_in_chain( + calling, + created(&store), + &[ancestor, tenant(0xb003)], + &OverridePermissions::none(), + &with_families( + base(ALIAS), + json!({ + "cors": cors_body("private", "https://leaf.example.com"), + "rate_limit": rate_limit_body("private", 50) + }), + ), + ) + .expect_err("both families are blocked"); + assert_eq!(refusal.permission(), Some(OVERRIDE_RATE)); +} + +#[test] +fn a_route_replacement_gates_the_same_families() { + let (service, store) = service_with_store(); + let ancestor_id = insert_ancestor(&store, &stored(ALIAS)); + let (_, ancestor) = chain(); + store + .insert_route( + ancestor, + &Route { + id: Uuid::new_v4(), + upstream_id: ancestor_id, + match_config: match_of("/v1/chat"), + plugins: None, + rate_limit: Some(RateLimitConfig { + sharing: Some(SharingMode::Inherit), + algorithm: None, + sustained: Some(Sustained { + rate: 100, + window: None, + }), + burst: Some(Burst { capacity: 100 }), + scope: None, + strategy: None, + cost: None, + }), + tags: Vec::new(), + cors: None, + priority: Some(1), + enabled: Some(true), + }, + ) + .expect("the ancestor route is inserted"); + + // The descendant binds against the alias and adds a route of its own. + let bound = bind(&service, &[BIND], json!({})).expect("the bind is written"); + let (calling, _) = chain(); + let route = service + .create_route( + calling, + &json!({ + "upstream_id": bound.upstream.id.to_string(), + "match": { "http": { "methods": ["GET"], "path": "/v1/chat" } }, + "priority": 1, + "tags": [] + }), + ) + .expect("the route is created"); + + let refusal = service + .replace_route_in_chain( + calling, + route.route.id, + &ancestors(), + &OverridePermissions::none(), + &json!({ + "match": { "http": { "methods": ["GET"], "path": "/v1/chat" } }, + "rate_limit": rate_limit_body("private", 50), + "priority": 1, + "tags": [] + }), + ) + .expect_err("the override is refused"); + let ServiceError::Forbidden { permission, .. } = &refusal else { + panic!("the override is refused 403, not {refusal:?}"); + }; + assert_eq!( + *permission, + Some(OVERRIDE_RATE), + "the same four permissions gate the same families on a route row" + ); +} + +#[test] +fn a_route_replacement_gates_the_plugin_family() { + let (service, store) = service_with_store(); + let ancestor_id = insert_ancestor(&store, &stored(ALIAS)); + let (_, ancestor) = chain(); + store + .insert_route( + ancestor, + &Route { + id: Uuid::new_v4(), + upstream_id: ancestor_id, + match_config: match_of("/v1/chat"), + plugins: Some(PluginsConfig { + sharing: Some(SharingMode::Inherit), + items: Vec::new(), + }), + rate_limit: None, + tags: Vec::new(), + cors: None, + priority: Some(1), + enabled: Some(true), + }, + ) + .expect("the ancestor route is inserted"); + + let bound = bind(&service, &[BIND], json!({})).expect("the bind is written"); + let (calling, _) = chain(); + let route = service + .create_route( + calling, + &json!({ + "upstream_id": bound.upstream.id.to_string(), + "match": { "http": { "methods": ["GET"], "path": "/v1/chat" } }, + "priority": 1, + "tags": [] + }), + ) + .expect("the route is created"); + + // The body carries the plugin family, so the ancestor's `inherit` makes the + // replacement a plugin override, which the plugin permission gates. + let refusal = service + .replace_route_in_chain( + calling, + route.route.id, + &ancestors(), + &OverridePermissions::none(), + &json!({ + "match": { "http": { "methods": ["GET"], "path": "/v1/chat" } }, + "plugins": { "sharing": "private" }, + "priority": 1, + "tags": [] + }), + ) + .expect_err("the plugin override is refused"); + let ServiceError::Forbidden { permission, .. } = &refusal else { + panic!("the plugin override is refused 403, not {refusal:?}"); + }; + assert_eq!(*permission, Some(ADD_PLUGINS)); + + // Omitting the family is no override at all, and needs no permission. + let kept = service + .replace_route_in_chain( + calling, + route.route.id, + &ancestors(), + &OverridePermissions::none(), + &json!({ + "match": { "http": { "methods": ["GET"], "path": "/v1/chat" } }, + "priority": 1, + "tags": [] + }), + ) + .expect("the family-free replacement is written"); + assert_eq!(kept.route.id, route.route.id); +} + +#[test] +fn a_refused_replacement_leaves_the_ancestor_row_byte_identical() { + let (service, store) = service_with_store(); + let ancestor_id = + insert_ancestor(&store, &with_rate_limit(stored(ALIAS), SharingMode::Inherit, 100)); + let (_, ancestor) = chain(); + let before = store + .get_upstream(ancestor, ancestor_id) + .expect("the ancestor row exists"); + + let (calling, _) = chain(); + let _ = service.replace_upstream_in_chain( + calling, + created(&store), + &ancestors(), + &OverridePermissions::none(), + &with_families(base(ALIAS), json!({ "rate_limit": rate_limit_body("private", 50) })), + ); + + let after = store + .get_upstream(ancestor, ancestor_id) + .expect("the ancestor row still exists"); + assert_eq!(&before, &after, "no ancestor row is written"); +} + +/// Creates the descendant's own upstream through the service and answers its +/// identifier. +fn created(store: &Arc) -> Uuid { + let (calling, _) = chain(); + // The row is placed at store level so the replacement tests can address it + // without depending on how a create's bind decision answers this body. + let row = store + .insert_upstream(calling, &stored(ALIAS)) + .expect("the descendant row is inserted"); + row.upstream.id +} + +/// The HTTP match one route body states. +fn match_of(path: &str) -> MatchConfig { + MatchConfig { + http: Some(HttpMatch { + methods: vec![String::from("GET")], + path: String::from(path), + query_allowlist: Vec::new(), + path_suffix_mode: None, + }), + grpc: None, + } +} diff --git a/gears/system/oagw/oagw/tests/chain_walk_tests.rs b/gears/system/oagw/oagw/tests/chain_walk_tests.rs new file mode 100644 index 0000000..1eb634d --- /dev/null +++ b/gears/system/oagw/oagw/tests/chain_walk_tests.rs @@ -0,0 +1,371 @@ +//! The tenant chain walk. +//! +//! Covers `cpt-cf-oagw-dod-tenant-chain-walk` and +//! `cpt-cf-oagw-algo-tenant-chain-walk`: the per-element tenant-scoped alias +//! read, the ordered candidate set, the unavailable-chain failure, and the +//! adapter that turns the platform tenant-resolver's answer into a chain. +//! Retired tenants are dropped by the adapter before the chain is ordered, so +//! no walk ever reads a retired tenant's rows. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::missing_panics_doc)] + +use std::sync::Arc; + +use async_trait::async_trait; +use oagw::control_plane::chain::{UnavailableChain, cache_key, chain_of, modes_of, walk_candidates}; +use oagw::control_plane::cache::ControlPlaneCache; +use oagw::control_plane::service::ManagementService; +use oagw::domain::effective::Family; +use oagw::domain::upstream::SharingMode; +use oagw::store::OagwStore; +use oagw::{Alias, OagwConfig}; +use serde_json::{Value, json}; +use tenant_resolver_sdk::{ + GetAncestorsOptions, GetAncestorsResponse, TenantId, TenantInfo, TenantRef, + TenantResolverClient, TenantResolverError, TenantStatus, +}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +const HTTP_PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; + +fn tenant(n: u128) -> Uuid { + Uuid::from_u128(n) +} + +/// A management service over its own empty store and cache. +fn service() -> ManagementService { + service_with_store().0 +} + +/// The same service, with the store handle the walk reads through. +fn service_with_store() -> (ManagementService, Arc) { + let store = Arc::new(OagwStore::new()); + let service = ManagementService::new( + Arc::clone(&store), + &OagwConfig::default(), + Arc::new(ControlPlaneCache::new()), + ) + .expect("the validators compile"); + (service, store) +} + +/// A valid upstream body whose sharing-bearing families declare one mode each. +/// +/// `rate_limit.sustained` is required by the shipped schema whenever the +/// family is present, so the fixture always carries a full limit. +fn upstream_body(alias: Option<&str>, sharing: Option<&str>) -> Value { + let mut body = json!({ + "server": { + "endpoints": [{ "scheme": "https", "host": "api.openai.com", "port": 443 }] + }, + "protocol": HTTP_PROTOCOL, + "tags": ["llm"], + "plugins": { "sharing": "enforce" }, + "cors": { + "sharing": "inherit", + "enabled": true, + "allowed_origins": ["https://console.example.com"] + }, + "rate_limit": { + "sharing": sharing.unwrap_or("private"), + "algorithm": "token_bucket", + "sustained": { "rate": 100, "window": "second" }, + "burst": { "capacity": 200 }, + "scope": "tenant", + "strategy": "reject", + "cost": 1 + } + }); + if let Some(alias) = alias { + body["alias"] = json!(alias); + } + if let Some(sharing) = sharing { + body["auth"] = json!({ "sharing": sharing }); + } + body +} + +/// A route body addressing one upstream. +fn route_body(upstream_id: Uuid, path: &str) -> Value { + json!({ + "upstream_id": upstream_id, + "match": { "http": { "methods": ["GET"], "path": path } }, + "priority": 1, + "tags": ["edge"] + }) +} + +/// A tenant-resolver that answers the ancestor list it was built with. +struct StaticResolver { + chain: Vec<(Uuid, TenantStatus)>, +} + +#[async_trait] +impl TenantResolverClient for StaticResolver { + async fn get_tenant( + &self, + _ctx: &SecurityContext, + _id: TenantId, + ) -> Result { + Err(TenantResolverError::TenantNotFound { + tenant_id: TenantId::nil(), + }) + } + + async fn get_root_tenant( + &self, + _ctx: &SecurityContext, + ) -> Result { + Err(TenantResolverError::TenantNotFound { + tenant_id: TenantId::nil(), + }) + } + + async fn get_tenants( + &self, + _ctx: &SecurityContext, + _ids: &[TenantId], + _options: &tenant_resolver_sdk::GetTenantsOptions, + ) -> Result, TenantResolverError> { + Ok(Vec::new()) + } + + async fn get_ancestors( + &self, + _ctx: &SecurityContext, + id: TenantId, + _options: &GetAncestorsOptions, + ) -> Result { + let Some((_, status)) = self.chain.first().copied() else { + return Err(TenantResolverError::TenantNotFound { tenant_id: id }); + }; + let rest = &self.chain[1..]; + Ok(GetAncestorsResponse { + tenant: TenantRef { + id, + status, + tenant_type: None, + parent_id: rest.first().map(|(ancestor, _)| TenantId(*ancestor)), + self_managed: false, + }, + ancestors: rest + .iter() + .map(|(id, status)| TenantRef { + id: TenantId(*id), + status: *status, + tenant_type: None, + parent_id: None, + self_managed: false, + }) + .collect(), + }) + } + + async fn get_descendants( + &self, + _ctx: &SecurityContext, + _id: TenantId, + _options: &tenant_resolver_sdk::GetDescendantsOptions, + ) -> Result { + Err(TenantResolverError::TenantNotFound { + tenant_id: TenantId::nil(), + }) + } + + async fn is_ancestor( + &self, + _ctx: &SecurityContext, + _ancestor: TenantId, + _descendant: TenantId, + _options: &tenant_resolver_sdk::IsAncestorOptions, + ) -> Result { + Ok(false) + } +} + +fn context() -> SecurityContext { + SecurityContext::builder() + .subject_id(tenant(0xa001)) + .subject_tenant_id(tenant(0xa001)) + .build() + .expect("the subject is complete") +} + +#[test] +fn the_alias_read_matches_the_normalized_form_and_stays_tenant_scoped() { + let (management, store) = service_with_store(); + let owner = tenant(0xa001); + let other = tenant(0xa009); + let row = management + .create_upstream(owner, &upstream_body(Some("api.openai.com"), None)) + .expect("the create succeeds"); + management + .create_upstream(other, &upstream_body(Some("api.openai.com"), None)) + .expect("another tenant may hold the same alias"); + + let bare = Alias::parse("api.openai.com").expect("a valid alias"); + assert_eq!(store.upstream_by_alias(owner, &bare), Some(row)); + assert_eq!( + store.upstream_by_alias(other, &bare).map(|row| row.tenant_id), + Some(other) + ); + + let dotted = Alias::parse("API.OpenAI.com.").expect("a valid alias"); + assert!( + store.upstream_by_alias(owner, &dotted).is_some(), + "case and a trailing dot are not identity" + ); + let ported = Alias::parse("api.openai.com:8443").expect("a valid alias"); + assert!( + store.upstream_by_alias(owner, &ported).is_none(), + "the port participates in identity" + ); +} + +#[test] +fn routes_of_upstream_reads_only_the_calling_tenant_rows_of_that_upstream() { + let (management, store) = service_with_store(); + let owner = tenant(0xa001); + let other = tenant(0xa009); + let mine = management + .create_upstream(owner, &upstream_body(Some("api.openai.com"), None)) + .expect("the create succeeds"); + let theirs = management + .create_upstream(other, &upstream_body(Some("api.openai.com"), None)) + .expect("the create succeeds"); + let route = route_body(mine.upstream.id, "/v1/chat"); + management.create_route(owner, &route).expect("the route create succeeds"); + let mut foreign = route.clone(); + foreign["upstream_id"] = json!(theirs.upstream.id); + management.create_route(other, &foreign).expect("the route create succeeds"); + + let rows = store.routes_of_upstream(owner, mine.upstream.id); + assert_eq!(rows.len(), 1, "the other tenant's route is never a candidate"); + assert_eq!(rows[0].route.upstream_id, mine.upstream.id); + assert!(store.routes_of_upstream(other, mine.upstream.id).is_empty()); +} + +#[test] +fn the_walk_orders_candidates_from_the_calling_tenant_to_the_root() { + let (management, store) = service_with_store(); + let leaf = tenant(0xa001); + let mid = tenant(0xa002); + let root = tenant(0xa003); + let leaf_row = management + .create_upstream(leaf, &upstream_body(Some("api.openai.com"), Some("inherit"))) + .expect("the create succeeds"); + let root_row = management + .create_upstream(root, &upstream_body(Some("api.openai.com"), Some("enforce"))) + .expect("the create succeeds"); + + let candidates = walk_candidates( + &store, + leaf, + &[mid, root], + &Alias::parse("api.openai.com").expect("a valid alias"), + ) + .expect("an ordered chain is available"); + + assert_eq!(candidates.len(), 2, "the middle tenant holds no such alias"); + assert_eq!(candidates[0].depth, 0); + assert_eq!(candidates[0].tenant_id, leaf); + assert_eq!(candidates[0].upstream_id, leaf_row.upstream.id); + assert_eq!(candidates[0].modes.mode_of(Family::Auth), SharingMode::Inherit); + assert_eq!(candidates[1].depth, 2); + assert_eq!(candidates[1].tenant_id, root); + assert_eq!(candidates[1].upstream_id, root_row.upstream.id); + assert_eq!(candidates[1].modes.mode_of(Family::Auth), SharingMode::Enforce); + assert_eq!( + candidates[1].modes.mode_of(Family::Plugins), + SharingMode::Enforce, + "the plugins family is read from its own sharing member" + ); +} + +#[test] +fn the_walk_answers_an_empty_set_when_no_chain_element_holds_the_alias() { + let (management, store) = service_with_store(); + management + .create_upstream(tenant(0xa002), &upstream_body(Some("api.openai.com"), None)) + .expect("the create succeeds"); + let candidates = walk_candidates( + &store, + tenant(0xa001), + &[tenant(0xa002)], + &Alias::parse("other.example.com").expect("a valid alias"), + ) + .expect("an ordered chain is available"); + assert!(candidates.is_empty(), "no element holds the alias"); +} + +#[test] +fn the_walk_fails_closed_on_an_unavailable_chain() { + let (_management, store) = service_with_store(); + let unavailable = walk_candidates( + &store, + tenant(0xa001), + &[tenant(0xa002), tenant(0xa001)], + &Alias::parse("api.openai.com").expect("a valid alias"), + ); + assert_eq!(unavailable, Err(UnavailableChain::Unordered)); +} + +#[test] +fn the_cache_key_carries_the_tenant_and_the_normalized_alias() { + let alias = Alias::parse("API.OpenAI.COM.").expect("a valid alias"); + assert_eq!( + cache_key(tenant(0xa001), &alias), + "upstream:00000000-0000-0000-0000-00000000a001:api.openai.com" + ); +} + +#[test] +fn modes_of_takes_the_schema_default_for_a_family_the_row_omits() { + let mut body = upstream_body(Some("api.openai.com"), Some("inherit")); + let object = body.as_object_mut().expect("the body is an object"); + object.remove("auth"); + object.remove("cors"); + let row = service() + .create_upstream(tenant(0xa001), &body) + .expect("the create succeeds"); + let modes = modes_of(&row.upstream); + assert_eq!(modes.mode_of(Family::Auth), SharingMode::Private); + assert_eq!(modes.mode_of(Family::Cors), SharingMode::Private); + assert_eq!(modes.mode_of(Family::RateLimit), SharingMode::Inherit); + assert_eq!(modes.mode_of(Family::Plugins), SharingMode::Enforce); +} + +#[tokio::test] +async fn the_adapter_drops_the_tenants_the_resolver_retired() { + let resolver = Arc::new(StaticResolver { + chain: vec![ + (tenant(0xa001), TenantStatus::Active), + (tenant(0xa002), TenantStatus::Active), + (tenant(0xa004), TenantStatus::Deleted), + (tenant(0xa003), TenantStatus::Active), + ], + }); + let client: Option> = Some(resolver); + let chain = chain_of(client.as_ref(), &context(), tenant(0xa001)) + .await + .expect("an ordered chain is available"); + assert_eq!( + chain.tenants(), + &[tenant(0xa001), tenant(0xa002), tenant(0xa003)] + ); + assert!( + !chain.contains(tenant(0xa004)), + "a retired tenant is never a participant" + ); +} + +#[tokio::test] +async fn the_adapter_fails_closed_when_the_resolver_is_absent_or_refuses() { + let absent: Option> = None; + assert!(chain_of(absent.as_ref(), &context(), tenant(0xa001)).await.is_none()); + + let refusing = Arc::new(StaticResolver { chain: Vec::new() }); + let client: Option> = Some(refusing); + assert!(chain_of(client.as_ref(), &context(), tenant(0xa001)).await.is_none()); +} diff --git a/gears/system/oagw/oagw/tests/config_tests.rs b/gears/system/oagw/oagw/tests/config_tests.rs new file mode 100644 index 0000000..591afc6 --- /dev/null +++ b/gears/system/oagw/oagw/tests/config_tests.rs @@ -0,0 +1,127 @@ +//! Configuration surface tests for the `oagw` gear. +//! +//! Covers `cpt-cf-oagw-algo-config-load-validate` and +//! `cpt-cf-oagw-dod-config-surface`: the five configurable families, their +//! defaults when `oagw.config` is absent, unknown-key rejection, +//! out-of-range rejection, and the write-time `http` scheme admission gate. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::missing_panics_doc)] + +use serde_json::json; + +use oagw::{ConfigError, OagwConfig, Scheme, SsrfPolicy}; + +/// The five declared defaults, asserted one key at a time so a single wrong +/// default names itself in the failure message. +#[test] +fn absent_section_yields_every_declared_default() { + let cfg = OagwConfig::load(None).expect("absent section loads to defaults"); + assert_eq!(cfg.proxy_timeout_secs, 30, "proxy_timeout_secs default"); + assert!(!cfg.allow_http_upstream, "allow_http_upstream default"); + assert!(cfg.ssrf_policy.enabled, "ssrf_policy.enabled default"); + assert_eq!( + cfg.token_cache_ttl_secs, 300, + "token_cache_ttl_secs default" + ); + assert_eq!( + cfg.token_cache_capacity, 10_000, + "token_cache_capacity default" + ); +} + +/// An empty `oagw.config` object behaves exactly like an absent section. +#[test] +fn empty_object_yields_every_declared_default() { + let cfg = OagwConfig::load(Some(&json!({}))).expect("empty object loads to defaults"); + assert_eq!(cfg, OagwConfig::default()); +} + +#[test] +fn zero_proxy_timeout_is_rejected_and_names_the_key() { + let raw = json!({ "proxy_timeout_secs": 0 }); + let err = OagwConfig::load(Some(&raw)).expect_err("proxy_timeout_secs: 0 is rejected"); + assert_eq!(err.offending_key(), Some("proxy_timeout_secs")); +} + +#[test] +fn zero_token_cache_capacity_is_rejected_and_names_the_key() { + let raw = json!({ "token_cache_capacity": 0 }); + let err = OagwConfig::load(Some(&raw)).expect_err("token_cache_capacity: 0 is rejected"); + assert_eq!(err.offending_key(), Some("token_cache_capacity")); +} + +#[test] +fn negative_token_cache_ttl_is_rejected_and_names_the_key() { + let raw = json!({ "token_cache_ttl_secs": -1 }); + let err = OagwConfig::load(Some(&raw)).expect_err("negative token_cache_ttl_secs is rejected"); + assert!( + err.to_string().contains("token_cache_ttl_secs"), + "error must name the offending key: {err}" + ); +} + +#[test] +fn unknown_top_level_key_is_rejected_and_names_the_key() { + let raw = json!({ "proxy_timeout_secs": 30, "ssrf": true }); + let err = OagwConfig::load(Some(&raw)).expect_err("unknown key is rejected"); + assert_eq!(err.offending_key(), Some("ssrf")); +} + +#[test] +fn unknown_key_inside_ssrf_policy_is_rejected_and_names_the_key() { + let raw = json!({ "ssrf_policy": { "enabled": true, "mode": "strict" } }); + let err = OagwConfig::load(Some(&raw)).expect_err("unknown ssrf_policy key is rejected"); + assert_eq!(err.offending_key(), Some("ssrf_policy.mode")); +} + +#[test] +fn every_other_integer_value_passes_validation() { + let raw = json!({ + "proxy_timeout_secs": 2, + "token_cache_ttl_secs": 1, + "token_cache_capacity": 1 + }); + let cfg = OagwConfig::load(Some(&raw)).expect("boundary value 1 is accepted"); + assert_eq!(cfg.proxy_timeout_secs, 2); + assert_eq!(cfg.token_cache_ttl_secs, 1); + assert_eq!(cfg.token_cache_capacity, 1); +} + +/// `allow_http_upstream` is the only input that admits the `http` literal at +/// write time; the other four literals are admitted either way. +#[test] +fn allow_http_upstream_gates_only_the_http_literal() { + let denied = OagwConfig::default(); + let admitted = OagwConfig::load(Some(&json!({ "allow_http_upstream": true }))) + .expect("allow_http_upstream: true parses"); + + assert!(!denied.admits_scheme(Scheme::Http)); + assert!(admitted.admits_scheme(Scheme::Http)); + + for scheme in [Scheme::Https, Scheme::Wss, Scheme::Wt, Scheme::Grpc] { + assert!( + denied.admits_scheme(scheme), + "{scheme:?} admitted when denied" + ); + assert!( + admitted.admits_scheme(scheme), + "{scheme:?} admitted when allowed" + ); + } +} + +#[test] +fn ssrf_policy_disabled_round_trips() { + let raw = json!({ "ssrf_policy": { "enabled": false } }); + let cfg = OagwConfig::load(Some(&raw)).expect("ssrf_policy parses"); + assert_eq!(cfg.ssrf_policy, SsrfPolicy { enabled: false }); + let back = serde_json::to_value(cfg).expect("config serializes"); + assert_eq!(back["ssrf_policy"]["enabled"], false); +} + +#[test] +fn deserialize_failure_reports_the_deserialize_variant() { + let raw = json!({ "allow_http_upstream": "yes" }); + let err = OagwConfig::load(Some(&raw)).expect_err("wrong type is rejected"); + assert!(matches!(err, ConfigError::Deserialize { .. }), "{err:?}"); +} diff --git a/gears/system/oagw/oagw/tests/cors_api_tests.rs b/gears/system/oagw/oagw/tests/cors_api_tests.rs new file mode 100644 index 0000000..6054e79 --- /dev/null +++ b/gears/system/oagw/oagw/tests/cors_api_tests.rs @@ -0,0 +1,732 @@ +//! The CORS feature on the wire. +//! +//! Covers `cpt-cf-oagw-dod-cors-preflight`, `cpt-cf-oagw-dod-cors-enforcement`, +//! `cpt-cf-oagw-dod-cors-origin-matching`, `cpt-cf-oagw-dod-cors-headers`, and +//! `cpt-cf-oagw-dod-cors-tests` over the mounted proxy surface: the 204 a +//! preflight is answered with and the header set it carries, its independence +//! from resolution and from the permission check, the hand-back of an +//! `OPTIONS` request that is not a preflight, the decoration of an admitted +//! actual request, the two bare 403 problem bodies with their GTS types and +//! titles, the exact origin matching ADR 0004 demonstrates, the wildcard, the +//! credentials restriction, the empty allowlist, the route-level fold, and the +//! absence of any CORS header on a request with no `Origin`. The upstream is a +//! minimal HTTP/1.1 echo listener, and a counter on it proves a refused +//! request reached nothing. + +// @cpt-dod:cpt-cf-oagw-dod-cors-tests:p1 + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::missing_panics_doc)] + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use axum::Router; +use axum::body::Body; +use axum::http::{Method, Request, StatusCode}; +use serde_json::{Value, json}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tower::ServiceExt; +use uuid::Uuid; + +use authz_resolver_sdk::api::AuthZResolverClient; +use authz_resolver_sdk::constraints::{Constraint, EqPredicate, Predicate}; +use authz_resolver_sdk::error::AuthZResolverError; +use authz_resolver_sdk::models::{EvaluationRequest, EvaluationResponse, EvaluationResponseContext}; +use authz_resolver_sdk::pep::PolicyEnforcer; +use toolkit_security::SecurityContext; +use toolkit_security::pep_properties; + +use oagw::OagwConfig; +use oagw::control_plane::cache::ControlPlaneCache; +use oagw::control_plane::service::ManagementService; +use oagw::store::OagwStore; +use oagw::OagwState; + +const HTTP_PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; +const TENANT: u128 = 0x51; +const HOST: &str = "127.0.0.1"; +const ERROR_SOURCE: &str = "x-oagw-error-source"; +const ORIGIN_TYPE: &str = "gts.cf.core.errors.err.v1~cf.oagw.cors.origin_not_allowed.v1"; +const METHOD_TYPE: &str = "gts.cf.core.errors.err.v1~cf.oagw.cors.method_not_allowed.v1"; +const ROUTE_NOT_FOUND_TYPE: &str = "gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1"; +const PREFLIGHT_VARY: &str = + "Origin, Access-Control-Request-Method, Access-Control-Request-Headers"; + +/// The `AuthZ` PDP the allowing stub stands in for. +struct Allowing; + +#[async_trait::async_trait] +impl AuthZResolverClient for Allowing { + async fn evaluate( + &self, + _request: EvaluationRequest, + ) -> Result { + Ok(EvaluationResponse { + decision: true, + context: EvaluationResponseContext { + constraints: vec![Constraint { + predicates: vec![Predicate::Eq(EqPredicate { + property: String::from(pep_properties::OWNER_TENANT_ID), + value: json!(TENANT.to_string()), + })], + }], + deny_reason: None, + }, + }) + } +} + +/// One mounted proxy surface over its own store and echo upstream. +struct Surface { + router: Router, + /// The requests the echo upstream received, which a refusal must leave at + /// zero. + received: Arc, +} + +/// The authenticated subject a proxy request carries. +fn subject() -> SecurityContext { + SecurityContext::builder() + .subject_id(Uuid::from_u128(TENANT)) + .subject_tenant_id(Uuid::from_u128(TENANT)) + .build() + .expect("the subject is complete") +} + +/// Starts one echo upstream that counts the requests it receives. +async fn upstream() -> (u16, Arc) { + let listener = TcpListener::bind((HOST, 0)).await.expect("the listener binds"); + let port = listener.local_addr().expect("the address").port(); + let received = Arc::new(AtomicUsize::new(0)); + let counted = Arc::clone(&received); + tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + break; + }; + counted.fetch_add(1, Ordering::SeqCst); + tokio::spawn(async move { + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 4096]; + while buffer.len() <= 128 * 1024 { + let Ok(read) = socket.read(&mut chunk).await else { + break; + }; + if read == 0 { + break; + } + buffer.extend_from_slice(&chunk[..read]); + if buffer.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let head = "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\ + x-upstream-marker: probe\r\ncontent-length: 2\r\n\ + connection: close\r\n\r\n"; + let _ = socket.write_all(head.as_bytes()).await; + let _ = socket.write_all(b"{}").await; + let _ = socket.shutdown().await; + }); + } + }); + (port, received) +} + +/// Mounts one surface whose upstream carries the CORS object the caller +/// states, with an optional route-level object beside it. +async fn wired(upstream_cors: Option, route_cors: Option) -> Surface { + let (port, received) = upstream().await; + let store = Arc::new(OagwStore::new()); + let cache = Arc::new(ControlPlaneCache::new()); + let config = OagwConfig { + allow_http_upstream: true, + ..OagwConfig::default() + }; + let service = Arc::new( + ManagementService::new(Arc::clone(&store), &config, Arc::clone(&cache)) + .expect("the validators compile"), + ); + let state = Arc::new(OagwState::new( + Arc::new(config), + Arc::clone(&store), + service, + Some(Arc::new(PolicyEnforcer::new(Arc::new(Allowing)))), + None, + Arc::clone(&cache), + )); + let router = oagw::api::rest::register_management_routes(Router::new(), state); + + let mut body = json!({ + "alias": HOST, + "server": { "endpoints": [{ "scheme": "http", "host": HOST, "port": port }] }, + "protocol": HTTP_PROTOCOL + }); + if let Some(cors) = upstream_cors { + body["cors"] = cors; + } + let upstream_instance = created(&router, Method::POST, "/oagw/v1/upstreams", &body).await; + + let mut route = json!({ + "upstream_id": key_of(&upstream_instance), + "match": { "http": { "methods": ["GET", "POST", "DELETE"], "path": "/api" } }, + "priority": 10 + }); + if let Some(cors) = route_cors { + route["cors"] = cors; + } + created(&router, Method::POST, "/oagw/v1/routes", &route).await; + Surface { router, received } +} + +/// The `upstream_id` key the route create body names its upstream by. +fn key_of(instance: &str) -> String { + oagw::gts::parse_gts_instance(oagw::UPSTREAM_TYPE, instance) + .expect("the instance parses") + .to_string() +} + +/// Issues one create and returns the instance identifier of the row. +async fn created(app: &Router, method: Method, path: &str, body: &Value) -> String { + let response = app + .clone() + .oneshot( + Request::builder() + .method(method) + .uri(path) + .extension(subject()) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("the request builds"), + ) + .await + .expect("oneshot resolves"); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .expect("the body reads"); + let document: Value = serde_json::from_slice(&bytes).expect("the body is JSON"); + assert_eq!(status, StatusCode::CREATED, "{document}"); + document["id"].as_str().expect("the instance id").to_owned() +} + +/// Issues one proxy request and returns its status, headers, body, and the +/// error source it was tagged with. +struct Answer { + status: StatusCode, + headers: Vec<(String, String)>, + body: Value, + source: Option, +} + +async fn issue( + surface: &Surface, + method: Method, + uri: &str, + authenticated: bool, + headers: &[(&str, &str)], +) -> Answer { + let mut builder = Request::builder().method(method).uri(uri); + if authenticated { + builder = builder.extension(subject()); + } + for (name, value) in headers { + builder = builder.header(*name, *value); + } + let response = surface + .router + .clone() + .oneshot( + builder + .body(Body::empty()) + .expect("the request builds"), + ) + .await + .expect("oneshot resolves"); + let status = response.status(); + let source = response + .headers() + .get(ERROR_SOURCE) + .and_then(|value| value.to_str().ok()) + .map(String::from); + let pairs: Vec<(String, String)> = response + .headers() + .iter() + .map(|(name, value)| { + ( + name.as_str().to_ascii_lowercase(), + String::from_utf8_lossy(value.as_bytes()).into_owned(), + ) + }) + .collect(); + let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .expect("the body reads"); + let body = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).expect("the body is JSON") + }; + Answer { + status, + headers: pairs, + body, + source, + } +} + +/// One header value of an answer, matched case-insensitively. +fn header_of(answer: &Answer, name: &str) -> Option { + let lowered = name.to_ascii_lowercase(); + answer + .headers + .iter() + .find(|(header, _)| *header == lowered) + .map(|(_, value)| value.clone()) +} + +/// A preflight is answered 204 with the header set ADR 0004's example spells. +#[tokio::test(flavor = "multi_thread")] +async fn a_preflight_is_answered_204_with_the_full_header_set() { + let surface = wired(Some(cors_object(true, &["https://app.example.com"])), None).await; + let answer = issue( + &surface, + Method::OPTIONS, + "/oagw/v1/proxy/127.0.0.1/api", + true, + &[ + ("origin", "https://app.example.com"), + ("access-control-request-method", "POST"), + ("access-control-request-headers", "Content-Type, Authorization"), + ], + ) + .await; + assert_eq!(answer.status, StatusCode::NO_CONTENT); + assert_eq!( + header_of(&answer, "access-control-allow-origin").as_deref(), + Some("https://app.example.com") + ); + assert_eq!( + header_of(&answer, "access-control-allow-methods").as_deref(), + Some("POST") + ); + assert_eq!( + header_of(&answer, "access-control-allow-headers").as_deref(), + Some("Content-Type, Authorization") + ); + assert_eq!(header_of(&answer, "access-control-max-age").as_deref(), Some("86400")); + assert_eq!(header_of(&answer, "vary").as_deref(), Some(PREFLIGHT_VARY)); + assert_eq!(answer.source.as_deref(), Some("gateway")); + assert_eq!(surface.received.load(Ordering::SeqCst), 0); +} + +/// A preflight that names no request header omits the header answer. +#[tokio::test(flavor = "multi_thread")] +async fn a_preflight_without_requested_headers_omits_the_header_answer() { + let surface = wired(Some(cors_object(true, &["https://app.example.com"])), None).await; + let answer = issue( + &surface, + Method::OPTIONS, + "/oagw/v1/proxy/127.0.0.1/api", + true, + &[ + ("origin", "https://app.example.com"), + ("access-control-request-method", "GET"), + ], + ) + .await; + assert_eq!(answer.status, StatusCode::NO_CONTENT); + assert!(header_of(&answer, "access-control-allow-headers").is_none()); + assert!(header_of(&answer, "access-control-allow-credentials").is_none()); + assert!(header_of(&answer, "access-control-expose-headers").is_none()); +} + +/// A preflight for an alias that does not resolve is answered the same 204. +#[tokio::test(flavor = "multi_thread")] +async fn a_preflight_for_an_alias_that_does_not_resolve_is_answered_the_same() { + let surface = wired(Some(cors_object(true, &["https://app.example.com"])), None).await; + let answer = issue( + &surface, + Method::OPTIONS, + "/oagw/v1/proxy/nobody-uses-this.test/api", + true, + &[ + ("origin", "https://app.example.com"), + ("access-control-request-method", "POST"), + ], + ) + .await; + assert_eq!(answer.status, StatusCode::NO_CONTENT); + assert_eq!( + header_of(&answer, "access-control-allow-origin").as_deref(), + Some("https://app.example.com") + ); + assert_eq!( + header_of(&answer, "access-control-allow-methods").as_deref(), + Some("POST") + ); +} + +/// A preflight sent without a bearer token is answered 204 and not 401. +#[tokio::test(flavor = "multi_thread")] +async fn a_preflight_without_a_bearer_token_is_answered_204() { + let surface = wired(Some(cors_object(true, &["https://app.example.com"])), None).await; + let answer = issue( + &surface, + Method::OPTIONS, + "/oagw/v1/proxy/127.0.0.1/api", + false, + &[ + ("origin", "https://app.example.com"), + ("access-control-request-method", "POST"), + ], + ) + .await; + assert_eq!(answer.status, StatusCode::NO_CONTENT); + assert_eq!(answer.source.as_deref(), Some("gateway")); +} + +/// A preflight whose requested method the configuration would refuse, and one +/// for an upstream whose CORS family is disabled, are both answered the same. +#[tokio::test(flavor = "multi_thread")] +async fn a_preflight_is_answered_the_same_whatever_the_configuration_says() { + let surface = wired( + Some(cors_object(false, &["https://app.example.com"])), + None, + ) + .await; + let answer = issue( + &surface, + Method::OPTIONS, + "/oagw/v1/proxy/127.0.0.1/api", + true, + &[ + ("origin", "https://evil.com"), + ("access-control-request-method", "DELETE"), + ], + ) + .await; + assert_eq!(answer.status, StatusCode::NO_CONTENT); + assert_eq!( + header_of(&answer, "access-control-allow-origin").as_deref(), + Some("https://evil.com") + ); + assert_eq!( + header_of(&answer, "access-control-allow-methods").as_deref(), + Some("DELETE") + ); +} + +/// An `OPTIONS` request that is not a preflight is handed back to the proxy +/// path, which matches no route under the shipped method enum. +#[tokio::test(flavor = "multi_thread")] +async fn an_options_request_that_is_not_a_preflight_is_handed_back() { + let surface = wired(Some(cors_object(true, &["https://app.example.com"])), None).await; + let answer = issue( + &surface, + Method::OPTIONS, + "/oagw/v1/proxy/127.0.0.1/api", + true, + &[("origin", "https://app.example.com")], + ) + .await; + assert_eq!(answer.status, StatusCode::NOT_FOUND); + assert_eq!(answer.body["type"], json!(ROUTE_NOT_FOUND_TYPE)); + assert_eq!(answer.source.as_deref(), Some("gateway")); +} + +/// An admitted actual cross-origin request is forwarded and decorated. +#[tokio::test(flavor = "multi_thread")] +async fn an_admitted_cross_origin_request_is_forwarded_and_decorated() { + let surface = wired( + Some(json!({ + "enabled": true, + "allowed_origins": ["https://app.example.com"], + "allowed_methods": ["GET", "POST"], + "expose_headers": ["X-Request-ID"], + "allow_credentials": true + })), + None, + ) + .await; + let answer = issue( + &surface, + Method::POST, + "/oagw/v1/proxy/127.0.0.1/api", + true, + &[("origin", "https://app.example.com")], + ) + .await; + assert_eq!(answer.status, StatusCode::OK); + assert_eq!( + header_of(&answer, "access-control-allow-origin").as_deref(), + Some("https://app.example.com") + ); + assert_eq!(header_of(&answer, "vary").as_deref(), Some("Origin")); + assert_eq!( + header_of(&answer, "access-control-allow-credentials").as_deref(), + Some("true") + ); + assert_eq!( + header_of(&answer, "access-control-expose-headers").as_deref(), + Some("X-Request-ID") + ); + assert!(header_of(&answer, "access-control-allow-methods").is_none()); + assert!(header_of(&answer, "access-control-max-age").is_none()); + assert_eq!(answer.source.as_deref(), Some("upstream")); +} + +/// A disallowed origin is refused 403 before anything is forwarded. +#[tokio::test(flavor = "multi_thread")] +async fn a_disallowed_origin_is_refused_403_before_forwarding() { + let surface = wired(Some(cors_object(true, &["https://app.example.com"])), None).await; + let answer = issue( + &surface, + Method::GET, + "/oagw/v1/proxy/127.0.0.1/api", + true, + &[("origin", "https://evil.com")], + ) + .await; + assert_eq!(answer.status, StatusCode::FORBIDDEN); + assert_eq!(answer.body["type"], json!(ORIGIN_TYPE)); + assert_eq!(answer.body["title"], json!("CORS Origin Not Allowed")); + assert_eq!(answer.body["status"], json!(403)); + assert_eq!( + answer.body["detail"], + json!("Origin 'https://evil.com' not in allowed origins list") + ); + assert_eq!(header_of(&answer, "vary").as_deref(), Some("Origin")); + assert_eq!(answer.source.as_deref(), Some("gateway")); + assert_eq!(surface.received.load(Ordering::SeqCst), 0); +} + +/// A disallowed method is refused 403 with the method type. +#[tokio::test(flavor = "multi_thread")] +async fn a_disallowed_method_is_refused_403_with_the_method_type() { + let surface = wired(Some(cors_object(true, &["https://app.example.com"])), None).await; + let answer = issue( + &surface, + Method::DELETE, + "/oagw/v1/proxy/127.0.0.1/api", + true, + &[("origin", "https://app.example.com")], + ) + .await; + assert_eq!(answer.status, StatusCode::FORBIDDEN); + assert_eq!(answer.body["type"], json!(METHOD_TYPE)); + assert_eq!(answer.body["title"], json!("CORS Method Not Allowed")); + assert_eq!( + answer.body["detail"], + json!("Method 'DELETE' not in allowed methods list") + ); + assert_eq!(surface.received.load(Ordering::SeqCst), 0); +} + +/// An origin and a method that are both disallowed are answered with the +/// origin reason, and the body names no allowed method. +#[tokio::test(flavor = "multi_thread")] +async fn an_origin_and_a_method_both_disallowed_are_answered_with_the_origin_reason() { + let surface = wired(Some(cors_object(true, &["https://app.example.com"])), None).await; + let answer = issue( + &surface, + Method::DELETE, + "/oagw/v1/proxy/127.0.0.1/api", + true, + &[("origin", "https://evil.com")], + ) + .await; + assert_eq!(answer.status, StatusCode::FORBIDDEN); + assert_eq!(answer.body["type"], json!(ORIGIN_TYPE)); + assert!( + !answer + .body["detail"] + .as_str() + .expect("the detail") + .contains("DELETE"), + "the origin refusal names no method" + ); +} + +/// An origin that differs only in port, scheme, case, or a trailing slash is +/// refused, and no suffix admits a lookalike host. +#[tokio::test(flavor = "multi_thread")] +async fn an_origin_that_differs_in_any_part_is_refused() { + for origin in [ + "https://app.example.com:8080", + "http://app.example.com", + "HTTPS://APP.EXAMPLE.COM", + "https://app.example.com/", + "https://app.example.com:443", + "https://evil.com.example.com", + ] { + let surface = wired(Some(cors_object(true, &["https://app.example.com"])), None).await; + let answer = issue( + &surface, + Method::GET, + "/oagw/v1/proxy/127.0.0.1/api", + true, + &[("origin", origin)], + ) + .await; + assert_eq!(answer.status, StatusCode::FORBIDDEN, "{origin} is refused"); + assert_eq!(answer.body["type"], json!(ORIGIN_TYPE), "{origin}"); + assert_eq!(surface.received.load(Ordering::SeqCst), 0, "{origin} forwarded nothing"); + } +} + +/// A wildcard admits any origin and echoes the origin the request sent. +#[tokio::test(flavor = "multi_thread")] +async fn a_wildcard_admits_any_origin_and_echoes_it() { + let surface = wired(Some(cors_object(true, &["*"])), None).await; + let answer = issue( + &surface, + Method::GET, + "/oagw/v1/proxy/127.0.0.1/api", + true, + &[("origin", "https://anywhere.test")], + ) + .await; + assert_eq!(answer.status, StatusCode::OK); + assert_eq!( + header_of(&answer, "access-control-allow-origin").as_deref(), + Some("https://anywhere.test") + ); +} + +/// Credentials are emitted exactly when the configuration allows them. +#[tokio::test(flavor = "multi_thread")] +async fn credentials_are_emitted_exactly_when_the_configuration_allows_them() { + let credentialed = wired( + Some(json!({ + "enabled": true, + "allowed_origins": ["https://app.example.com"], + "allow_credentials": true + })), + None, + ) + .await; + let answer = issue( + &credentialed, + Method::GET, + "/oagw/v1/proxy/127.0.0.1/api", + true, + &[("origin", "https://app.example.com")], + ) + .await; + assert_eq!( + header_of(&answer, "access-control-allow-credentials").as_deref(), + Some("true") + ); + + let plain = wired(Some(cors_object(true, &["https://app.example.com"])), None).await; + let answer = issue( + &plain, + Method::GET, + "/oagw/v1/proxy/127.0.0.1/api", + true, + &[("origin", "https://app.example.com")], + ) + .await; + assert_eq!(answer.status, StatusCode::OK); + assert!(header_of(&answer, "access-control-allow-credentials").is_none()); +} + +/// An enabled family with no origin at all refuses every origin and forwards +/// nothing, and is not read as a disabled family. +#[tokio::test(flavor = "multi_thread")] +async fn an_enabled_family_with_no_origin_refuses_every_origin() { + for cors in [ + json!({ "enabled": true, "allowed_methods": ["GET"] }), + json!({ "enabled": true, "allowed_origins": [], "allowed_methods": ["GET"] }), + ] { + let surface = wired(Some(cors), None).await; + let answer = issue( + &surface, + Method::GET, + "/oagw/v1/proxy/127.0.0.1/api", + true, + &[("origin", "https://app.example.com")], + ) + .await; + assert_eq!(answer.status, StatusCode::FORBIDDEN); + assert_eq!(answer.body["type"], json!(ORIGIN_TYPE)); + assert_eq!(surface.received.load(Ordering::SeqCst), 0); + } +} + +/// A disabled family, and a resource that declares no `cors` object at all, +/// enforce nothing and decorate nothing. +#[tokio::test(flavor = "multi_thread")] +async fn a_disabled_or_absent_family_enforces_nothing() { + for cors in [None, Some(json!({ "enabled": false, "allowed_origins": ["*"] }))] { + let surface = wired(cors.clone(), None).await; + let answer = issue( + &surface, + Method::GET, + "/oagw/v1/proxy/127.0.0.1/api", + true, + &[("origin", "https://evil.com")], + ) + .await; + assert_eq!(answer.status, StatusCode::OK, "{cors:?} forwards"); + assert!(header_of(&answer, "access-control-allow-origin").is_none()); + assert!(header_of(&answer, "vary").is_none()); + } +} + +/// A route-level `cors` object overrides the upstream's for the members it +/// declares. +#[tokio::test(flavor = "multi_thread")] +async fn a_route_level_cors_object_overrides_the_upstream_s() { + let surface = wired( + Some(cors_object(true, &["https://app.example.com"])), + Some(cors_object(true, &["https://admin.example.com"])), + ) + .await; + let admitted = issue( + &surface, + Method::GET, + "/oagw/v1/proxy/127.0.0.1/api", + true, + &[("origin", "https://admin.example.com")], + ) + .await; + assert_eq!(admitted.status, StatusCode::OK); + let refused = issue( + &surface, + Method::GET, + "/oagw/v1/proxy/127.0.0.1/api", + true, + &[("origin", "https://app.example.com")], + ) + .await; + assert_eq!(refused.status, StatusCode::FORBIDDEN); +} + +/// A request that carries no `Origin` header is forwarded with no CORS header +/// of any kind on its response. +#[tokio::test(flavor = "multi_thread")] +async fn a_request_without_an_origin_header_carries_no_cors_header() { + let surface = wired(Some(cors_object(true, &["https://app.example.com"])), None).await; + let answer = issue( + &surface, + Method::GET, + "/oagw/v1/proxy/127.0.0.1/api", + true, + &[], + ) + .await; + assert_eq!(answer.status, StatusCode::OK); + assert!(header_of(&answer, "access-control-allow-origin").is_none()); + assert!(header_of(&answer, "vary").is_none()); +} + +/// The `cors` object the callers state, with the shipped defaults. +fn cors_object(enabled: bool, origins: &[&str]) -> Value { + json!({ + "enabled": enabled, + "allowed_origins": origins, + "allowed_methods": ["GET", "POST"] + }) +} diff --git a/gears/system/oagw/oagw/tests/cors_decision_tests.rs b/gears/system/oagw/oagw/tests/cors_decision_tests.rs new file mode 100644 index 0000000..18e9d70 --- /dev/null +++ b/gears/system/oagw/oagw/tests/cors_decision_tests.rs @@ -0,0 +1,421 @@ +//! The CORS decisions of the domain layer. +//! +//! Covers `cpt-cf-oagw-algo-cors-fold`, `cpt-cf-oagw-algo-cors-decide`, and +//! `cpt-cf-oagw-algo-cors-preflight-headers`: the per-member overlay over the +//! two layer results in the upstream, then route order, the ancestor `enforce` +//! that no descendant widens, the exact origin matching ADR 0004's Origin +//! Matching section demonstrates, the origin-before-method order, the +//! credentials restriction, the decoration of an admitted request, and the +//! preflight header set with its echoed values and its three-member `Vary`. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use oagw::domain::cors::{ + CorsDecision, CorsRefusal, PREFLIGHT_MAX_AGE, PREFLIGHT_VARY, VARY_ORIGIN, +}; +use oagw::domain::effective::EffectiveCors; +use oagw::domain::upstream::{CorsConfig, SharingMode}; + +const TENANT: uuid::Uuid = uuid::Uuid::from_u128(0x31); +const ANCESTOR: uuid::Uuid = uuid::Uuid::from_u128(0x30); + +/// One layer result whose CORS object the caller states. +fn layer(owner: uuid::Uuid, mode: SharingMode, cors: CorsConfig) -> EffectiveCors { + EffectiveCors { + owner, + mode, + cors, + } +} + +/// A CORS object with the shipped defaults and the members the caller states. +#[allow(clippy::too_many_arguments)] +fn cors_object( + enabled: bool, + origins: &[&str], + methods: &[&str], + expose: &[&str], + credentials: bool, +) -> CorsConfig { + CorsConfig { + sharing: None, + enabled, + allowed_origins: origins.iter().map(|origin| String::from(*origin)).collect(), + allowed_methods: methods.iter().map(|method| String::from(*method)).collect(), + expose_headers: expose.iter().map(|header| String::from(*header)).collect(), + allow_credentials: credentials, + } +} + +// @cpt-dod:cpt-cf-oagw-dod-cors-hierarchy:p1 + +#[test] +fn no_layer_carrying_cors_folds_to_the_absent_family() { + assert!(oagw::domain::cors::fold(None, None).is_none()); +} + +#[test] +fn a_disabled_prevailing_family_folds_to_the_absent_outcome() { + let upstream = layer( + TENANT, + SharingMode::Private, + cors_object(false, &["https://app.example.com"], &["GET"], &[], false), + ); + assert!(oagw::domain::cors::fold(Some(&upstream), None).is_none()); +} + +#[test] +fn an_absent_route_layer_leaves_the_upstream_layer_alone() { + let upstream = layer( + TENANT, + SharingMode::Private, + cors_object( + true, + &["https://app.example.com"], + &["GET", "POST"], + &["X-Request-ID"], + true, + ), + ); + let policy = oagw::domain::cors::fold(Some(&upstream), None).expect("the family is present"); + assert_eq!(policy.allowed_origins, vec![String::from("https://app.example.com")]); + assert_eq!(policy.allowed_methods, vec![String::from("GET"), String::from("POST")]); + assert_eq!(policy.expose_headers, vec![String::from("X-Request-ID")]); + assert!(policy.allow_credentials); +} + +#[test] +fn the_route_layer_prevails_for_the_members_it_declares() { + let upstream = layer( + TENANT, + SharingMode::Private, + cors_object(true, &["https://app.example.com"], &["GET"], &["X-Request-ID"], false), + ); + let route = layer( + TENANT, + SharingMode::Private, + cors_object(true, &["https://admin.example.com"], &["DELETE"], &[], false), + ); + let policy = oagw::domain::cors::fold(Some(&upstream), Some(&route)) + .expect("the family is present"); + assert_eq!(policy.allowed_origins, vec![String::from("https://admin.example.com")]); + assert_eq!(policy.allowed_methods, vec![String::from("DELETE")]); + // The route object omits its exposure, so the upstream's list stands. + assert_eq!(policy.expose_headers, vec![String::from("X-Request-ID")]); +} + +#[test] +fn a_member_the_route_omits_is_taken_from_the_upstream_object() { + let upstream = layer( + TENANT, + SharingMode::Private, + cors_object(true, &["https://app.example.com"], &["GET", "POST"], &["X-Request-ID"], false), + ); + // The route object names no origins and no methods at all. + let route = layer(TENANT, SharingMode::Private, cors_object(true, &[], &[], &[], false)); + let policy = oagw::domain::cors::fold(Some(&upstream), Some(&route)) + .expect("the family is present"); + assert_eq!(policy.allowed_origins, vec![String::from("https://app.example.com")]); + assert_eq!(policy.allowed_methods, vec![String::from("GET"), String::from("POST")]); +} + +#[test] +fn an_ancestor_inherit_union_is_enforced_as_the_layer_result_carries_it() { + // The merge of the hierarchical feature already unioned the two chains' + // origins into the ancestor's layer result; the fold consumes it as is. + let upstream = layer( + ANCESTOR, + SharingMode::Inherit, + cors_object( + true, + &["https://app.example.com", "https://admin.example.com"], + &["GET"], + &[], + false, + ), + ); + let policy = oagw::domain::cors::fold(Some(&upstream), None).expect("the family is present"); + assert_eq!( + policy.allowed_origins, + vec![ + String::from("https://app.example.com"), + String::from("https://admin.example.com") + ] + ); +} + +#[test] +fn an_ancestor_enforce_takes_the_layer_result_whole() { + let upstream = layer( + ANCESTOR, + SharingMode::Enforce, + cors_object(true, &["https://app.example.com"], &["GET"], &[], false), + ); + let route = layer( + TENANT, + SharingMode::Private, + cors_object(true, &["https://admin.example.com"], &["DELETE"], &[], false), + ); + let policy = oagw::domain::cors::fold(Some(&upstream), Some(&route)) + .expect("the family is present"); + assert_eq!(policy.allowed_origins, vec![String::from("https://app.example.com")]); + assert_eq!(policy.allowed_methods, vec![String::from("GET")]); + assert!(policy.enabled); +} + +#[test] +fn a_private_ancestor_contributes_nothing_to_a_descendant_with_no_object() { + // The merge already withheld the ancestor's private object, so no layer + // result exists and the fold reports the absent family. + let route = layer(TENANT, SharingMode::Private, cors_object(false, &[], &[], &[], false)); + assert!(oagw::domain::cors::fold(Some(&route), None).is_none()); +} + +#[test] +fn methods_absent_at_every_layer_take_the_shipped_default() { + // A layer result can carry an empty method list only when the write path + // defaulted it; the fold's own default applies when neither layer names one. + let upstream = layer( + TENANT, + SharingMode::Private, + cors_object(true, &["https://app.example.com"], &[], &[], false), + ); + let policy = oagw::domain::cors::fold(Some(&upstream), None).expect("the family is present"); + assert_eq!(policy.allowed_methods, vec![String::from("GET"), String::from("POST")]); +} + +// @cpt-dod:cpt-cf-oagw-dod-cors-origin-matching:p1 + +#[test] +fn an_exact_origin_is_admitted_and_every_other_spelling_is_refused() { + let upstream = layer( + TENANT, + SharingMode::Private, + cors_object(true, &["https://app.example.com"], &["GET"], &[], false), + ); + let policy = oagw::domain::cors::fold(Some(&upstream), None).expect("the family is present"); + assert!(matches!( + oagw::domain::cors::decide(&policy, Some("https://app.example.com"), "GET"), + CorsDecision::Allowed(_) + )); + for refused in [ + "https://evil.com", + "https://app.example.com:8080", + "http://app.example.com", + "https://evil.com.example.com", + "HTTPS://APP.EXAMPLE.COM", + "https://app.example.com/", + "https://app.example.com:443", + ] { + assert!( + matches!( + oagw::domain::cors::decide(&policy, Some(refused), "GET"), + CorsDecision::Refused(CorsRefusal::Origin) + ), + "{refused} must be refused" + ); + } +} + +#[test] +fn a_wildcard_admits_every_origin_and_echoes_it() { + let upstream = layer( + TENANT, + SharingMode::Private, + cors_object(true, &["*"], &["GET"], &[], false), + ); + let policy = oagw::domain::cors::fold(Some(&upstream), None).expect("the family is present"); + let CorsDecision::Allowed(decoration) = + oagw::domain::cors::decide(&policy, Some("https://anywhere.test"), "GET") + else { + panic!("the wildcard admits every origin"); + }; + assert_eq!(decoration.allow_origin, "https://anywhere.test"); +} + +#[test] +fn credentials_beside_a_wildcard_refuse_every_origin() { + let upstream = layer( + TENANT, + SharingMode::Private, + cors_object(true, &["*"], &["GET"], &[], true), + ); + let policy = oagw::domain::cors::fold(Some(&upstream), None).expect("the family is present"); + for origin in ["https://app.example.com", "https://anywhere.test", "*"] { + assert!(matches!( + oagw::domain::cors::decide(&policy, Some(origin), "GET"), + CorsDecision::Refused(CorsRefusal::Origin) + )); + } +} + +#[test] +fn an_enabled_family_with_no_origin_allows_no_origin() { + let upstream = layer(TENANT, SharingMode::Private, cors_object(true, &[], &["GET"], &[], false)); + let policy = oagw::domain::cors::fold(Some(&upstream), None).expect("the family is present"); + assert!(matches!( + oagw::domain::cors::decide(&policy, Some("https://app.example.com"), "GET"), + CorsDecision::Refused(CorsRefusal::Origin) + )); +} + +#[test] +fn a_refused_origin_names_itself_and_no_allowed_value() { + let upstream = layer( + TENANT, + SharingMode::Private, + cors_object(true, &["https://app.example.com"], &["GET"], &[], false), + ); + let policy = oagw::domain::cors::fold(Some(&upstream), None).expect("the family is present"); + let oagw::domain::cors::CorsDecision::Refused(reason) = + oagw::domain::cors::decide(&policy, Some("https://evil.com"), "GET") + else { + panic!("the disallowed origin is refused"); + }; + assert_eq!(reason, CorsRefusal::Origin); + assert_eq!( + oagw::domain::cors::refusal_detail(reason, "https://evil.com", "GET"), + "Origin 'https://evil.com' not in allowed origins list" + ); +} + +#[test] +fn a_refused_method_names_itself_and_no_allowed_value() { + let upstream = layer( + TENANT, + SharingMode::Private, + cors_object(true, &["https://app.example.com"], &["GET", "POST"], &[], false), + ); + let policy = oagw::domain::cors::fold(Some(&upstream), None).expect("the family is present"); + let oagw::domain::cors::CorsDecision::Refused(reason) = + oagw::domain::cors::decide(&policy, Some("https://app.example.com"), "DELETE") + else { + panic!("the disallowed method is refused"); + }; + assert_eq!(reason, CorsRefusal::Method); + assert_eq!( + oagw::domain::cors::refusal_detail(reason, "https://app.example.com", "DELETE"), + "Method 'DELETE' not in allowed methods list" + ); +} + +#[test] +fn the_origin_check_precedes_the_method_check() { + let upstream = layer( + TENANT, + SharingMode::Private, + cors_object(true, &["https://app.example.com"], &["GET"], &[], false), + ); + let policy = oagw::domain::cors::fold(Some(&upstream), None).expect("the family is present"); + assert!(matches!( + oagw::domain::cors::decide(&policy, Some("https://evil.com"), "DELETE"), + CorsDecision::Refused(CorsRefusal::Origin) + )); +} + +// @cpt-dod:cpt-cf-oagw-dod-cors-headers:p1 + +#[test] +fn an_admitted_request_carries_the_actual_request_decoration() { + let upstream = layer( + TENANT, + SharingMode::Private, + cors_object( + true, + &["https://app.example.com"], + &["GET", "POST"], + &["X-Request-ID"], + true, + ), + ); + let policy = oagw::domain::cors::fold(Some(&upstream), None).expect("the family is present"); + let CorsDecision::Allowed(decoration) = + oagw::domain::cors::decide(&policy, Some("https://app.example.com"), "POST") + else { + panic!("the admitted request carries a decoration"); + }; + assert_eq!(decoration.allow_origin, "https://app.example.com"); + assert!(decoration.allow_credentials); + assert_eq!(decoration.expose_headers, vec![String::from("X-Request-ID")]); + assert_eq!(decoration.vary, VARY_ORIGIN); +} + +#[test] +fn no_credentials_and_no_exposure_are_omitted_from_the_decoration() { + let upstream = layer( + TENANT, + SharingMode::Private, + cors_object(true, &["https://app.example.com"], &["GET"], &[], false), + ); + let policy = oagw::domain::cors::fold(Some(&upstream), None).expect("the family is present"); + let CorsDecision::Allowed(decoration) = + oagw::domain::cors::decide(&policy, Some("https://app.example.com"), "GET") + else { + panic!("the admitted request carries a decoration"); + }; + assert!(!decoration.allow_credentials); + assert!(decoration.expose_headers.is_empty()); +} + +// @cpt-dod:cpt-cf-oagw-dod-cors-preflight:p1 + +#[test] +fn the_preflight_answer_echoes_the_three_request_values() { + let answer = oagw::domain::cors::preflight_answer( + Some("https://app.example.com"), + Some("POST"), + Some("Content-Type, Authorization"), + ); + assert_eq!(answer.status, 204); + let value = |name: &str| { + answer + .headers + .iter() + .find(|(header, _)| header == name) + .map(|(_, value)| value.clone()) + }; + assert_eq!( + value("Access-Control-Allow-Origin").as_deref(), + Some("https://app.example.com") + ); + assert_eq!(value("Access-Control-Allow-Methods").as_deref(), Some("POST")); + assert_eq!( + value("Access-Control-Allow-Headers").as_deref(), + Some("Content-Type, Authorization") + ); + assert_eq!(value("Access-Control-Max-Age").as_deref(), Some(PREFLIGHT_MAX_AGE)); + assert_eq!(value("Vary").as_deref(), Some(PREFLIGHT_VARY)); + assert!(value("Access-Control-Allow-Credentials").is_none()); + assert!(value("Access-Control-Expose-Headers").is_none()); +} + +#[test] +fn a_preflight_that_names_no_request_header_omits_the_header_answer() { + let answer = oagw::domain::cors::preflight_answer( + Some("https://app.example.com"), + Some("GET"), + None, + ); + assert!( + !answer + .headers + .iter() + .any(|(header, _)| header == "Access-Control-Allow-Headers") + ); +} + +#[test] +fn the_same_preflight_answered_twice_produces_the_same_header_set() { + let first = oagw::domain::cors::preflight_answer( + Some("https://app.example.com"), + Some("POST"), + None, + ); + let second = oagw::domain::cors::preflight_answer( + Some("https://app.example.com"), + Some("POST"), + None, + ); + assert_eq!(first.headers, second.headers); + assert_eq!(first.status, second.status); +} diff --git a/gears/system/oagw/oagw/tests/domain_model_tests.rs b/gears/system/oagw/oagw/tests/domain_model_tests.rs new file mode 100644 index 0000000..8c0d200 --- /dev/null +++ b/gears/system/oagw/oagw/tests/domain_model_tests.rs @@ -0,0 +1,446 @@ +//! Domain model tests. +//! +//! Covers `cpt-cf-oagw-dod-domain-model-types`: `Upstream` and `Route` mirror +//! their shipped JSON Schemas property for property, `Route` additionally +//! carries the §1.5-added `cors`, `priority`, and `enabled`, the +//! sub-configurations round-trip, and the domain layer stays free of +//! transport and persistence types. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::missing_panics_doc)] + +use std::collections::BTreeMap; + +use serde_json::{Value, json}; + +use oagw::{ + Algorithm, Alias, AuthConfig, CorsConfig, Endpoint, EndpointHost, GrpcMatch, HeadersConfig, + HttpMatch, MatchConfig, ModelError, Passthrough, Plugin, PluginsConfig, RateLimitConfig, + RequestHeaderRules, ResponseHeaderRules, Route, Scheme, ServerConfig, SharingMode, Sustained, + Upstream, Window, +}; + +const UPSTREAM_SCHEMA: &str = include_str!("../../docs/schemas/upstream.v1.schema.json"); +const ROUTE_SCHEMA: &str = include_str!("../../docs/schemas/route.v1.schema.json"); + +/// The `properties` key set of a shipped schema, read from the frozen file. +fn schema_properties(schema: &str) -> Vec { + let parsed: Value = serde_json::from_str(schema).expect("shipped schema parses"); + parsed["properties"] + .as_object() + .expect("schema declares properties") + .keys() + .cloned() + .collect() +} + +fn endpoint(host: &str, port: u16) -> Endpoint { + Endpoint { + scheme: Scheme::Https, + host: EndpointHost::parse(host).expect("valid endpoint host"), + port: Some(port), + } +} + +fn upstream() -> Upstream { + Upstream { + id: uuid::Uuid::nil(), + enabled: true, + alias: Some(String::from("api.openai.com")), + tags: vec![String::from("llm")], + server: ServerConfig { + endpoints: vec![endpoint("api.openai.com", 443)], + }, + protocol: String::from("gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"), + auth: None, + headers: None, + plugins: None, + rate_limit: None, + cors: None, + } +} + +fn route() -> Route { + Route { + id: uuid::Uuid::nil(), + upstream_id: uuid::Uuid::nil(), + match_config: MatchConfig { + http: Some(HttpMatch { + methods: vec![String::from("GET")], + path: String::from("/v1/chat"), + query_allowlist: vec![], + path_suffix_mode: None, + }), + grpc: None, + }, + plugins: None, + rate_limit: None, + tags: vec![], + cors: None, + priority: None, + enabled: None, + } +} + +#[test] +fn upstream_carries_exactly_the_schema_properties() { + let serialized = serde_json::to_value(upstream()).expect("upstream serializes"); + let mut keys: Vec = serialized + .as_object() + .expect("upstream is an object") + .keys() + .cloned() + .collect(); + keys.sort(); + + let mut expected = schema_properties(UPSTREAM_SCHEMA); + expected.sort(); + + assert_eq!( + keys, expected, + "Upstream must mirror the schema property set" + ); +} + +#[test] +fn route_carries_exactly_the_schema_properties_plus_the_added_ones() { + let serialized = serde_json::to_value(route()).expect("route serializes"); + let mut keys: Vec = serialized + .as_object() + .expect("route is an object") + .keys() + .cloned() + .collect(); + keys.sort(); + + let mut expected = schema_properties(ROUTE_SCHEMA); + // §1.5 additions: route-level `cors`, and the DESIGN §3.1 `priority` and + // `enabled` attributes the shipped schema omits. + expected.extend([ + String::from("cors"), + String::from("priority"), + String::from("enabled"), + ]); + expected.sort(); + expected.dedup(); + + assert_eq!(keys, expected, "Route must mirror the schema property set"); +} + +#[test] +fn upstream_requires_server_and_protocol() { + let no_protocol = json!({ + "id": uuid::Uuid::nil(), + "server": { "endpoints": [ { "scheme": "https", "host": "api.openai.com" } ] } + }); + assert!( + serde_json::from_value::(no_protocol).is_err(), + "protocol is a required field of the shipped schema" + ); + + let no_server = json!({ + "id": uuid::Uuid::nil(), + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1" + }); + assert!( + serde_json::from_value::(no_server).is_err(), + "server is a required field of the shipped schema" + ); +} + +#[test] +fn upstream_rejects_unknown_properties() { + let raw = json!({ + "id": uuid::Uuid::nil(), + "server": { "endpoints": [ { "scheme": "https", "host": "api.openai.com" } ] }, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", + "unexpected": true + }); + assert!( + serde_json::from_value::(raw).is_err(), + "additionalProperties: false" + ); +} + +#[test] +fn upstream_defaults_enabled_to_true_and_tags_to_empty() { + let raw = json!({ + "id": uuid::Uuid::nil(), + "server": { "endpoints": [ { "scheme": "https", "host": "api.openai.com" } ] }, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1" + }); + let parsed: Upstream = serde_json::from_value(raw).expect("upstream parses"); + assert!(parsed.enabled); + assert!(parsed.tags.is_empty()); + assert_eq!(parsed.alias, None); +} + +#[test] +fn endpoint_rejects_unknown_properties_and_validates_its_value_objects() { + let bad = json!({ "scheme": "https", "host": "api.openai.com", "weight": 1 }); + assert!(serde_json::from_value::(bad).is_err()); + + let bad_host = json!({ "scheme": "https", "host": "-nope-", "port": 443 }); + assert!(serde_json::from_value::(bad_host).is_err()); + + let bad_scheme = json!({ "scheme": "ftp", "host": "api.openai.com", "port": 443 }); + assert!(serde_json::from_value::(bad_scheme).is_err()); +} + +#[test] +fn endpoint_admits_an_ip_literal_host() { + let raw = json!({ "scheme": "https", "host": "2001:db8::1", "port": 443 }); + let parsed: Endpoint = serde_json::from_value(raw).expect("ip literal host parses"); + assert_eq!(parsed.host.as_str(), "2001:db8::1"); +} + +#[test] +fn server_config_requires_at_least_one_endpoint() { + let empty = ServerConfig { endpoints: vec![] }; + assert_eq!(empty.validate(), Err(ModelError::NoEndpoints)); + let one = ServerConfig { + endpoints: vec![endpoint("api.openai.com", 443)], + }; + assert_eq!(one.validate(), Ok(())); +} + +#[test] +fn route_match_requires_exactly_one_of_http_or_grpc() { + let neither = MatchConfig { + http: None, + grpc: None, + }; + let both = MatchConfig { + http: Some(HttpMatch { + methods: vec![String::from("GET")], + path: String::from("/v1"), + query_allowlist: vec![], + path_suffix_mode: None, + }), + grpc: Some(GrpcMatch { + service: String::from("foo.v1.UserService"), + method: String::from("GetUser"), + }), + }; + assert_eq!(neither.validate(), Err(ModelError::AmbiguousMatch)); + assert_eq!(both.validate(), Err(ModelError::AmbiguousMatch)); + assert_eq!(route().match_config.validate(), Ok(())); +} + +#[test] +fn route_serializes_the_match_key_verbatim() { + let serialized = serde_json::to_value(route()).expect("route serializes"); + assert!( + serialized.get("match").is_some(), + "the wire key must stay 'match'" + ); + assert!(serialized.get("match_config").is_none()); + let back: Route = serde_json::from_value(serialized).expect("route round-trips"); + assert_eq!(back, route()); +} + +#[test] +fn route_adds_the_section_1_5_properties() { + let mut with_additions = route(); + with_additions.cors = Some(CorsConfig { + sharing: Some(SharingMode::Private), + enabled: true, + allowed_origins: vec![String::from("https://console.example.com")], + allowed_methods: vec![String::from("GET"), String::from("POST")], + expose_headers: vec![], + allow_credentials: false, + }); + with_additions.priority = Some(10); + with_additions.enabled = Some(false); + + let serialized = serde_json::to_value(&with_additions).expect("route serializes"); + assert_eq!(serialized["priority"], 10); + assert_eq!(serialized["enabled"], false); + assert_eq!(serialized["cors"]["enabled"], true); +} + +#[test] +fn header_rules_round_trip_their_schema_keys() { + let headers = HeadersConfig { + request: Some(RequestHeaderRules { + set: BTreeMap::from([(String::from("x-tenant"), String::from("acme"))]), + add: BTreeMap::new(), + remove: vec![String::from("x-inbound")], + passthrough: Some(Passthrough::Allowlist), + passthrough_allowlist: vec![String::from("authorization")], + }), + response: Some(ResponseHeaderRules { + set: BTreeMap::new(), + add: BTreeMap::from([(String::from("x-served-by"), String::from("oagw"))]), + remove: vec![String::from("server")], + }), + }; + + let serialized = serde_json::to_value(&headers).expect("headers serialize"); + let request = &serialized["request"]; + for key in [ + "set", + "add", + "remove", + "passthrough", + "passthrough_allowlist", + ] { + assert!(request.get(key).is_some(), "request.{key} must be present"); + } + let response = &serialized["response"]; + for key in ["set", "add", "remove"] { + assert!( + response.get(key).is_some(), + "response.{key} must be present" + ); + } + assert!( + response.get("passthrough").is_none(), + "response rules have no passthrough fields" + ); + assert_eq!(serialized["request"]["passthrough"], "allowlist"); +} + +#[test] +fn rate_limit_round_trips_its_schema_keys() { + let rate_limit = RateLimitConfig { + sharing: Some(SharingMode::Enforce), + algorithm: Some(Algorithm::TokenBucket), + sustained: Some(Sustained { + rate: 100, + window: Some(Window::Minute), + }), + burst: Some(oagw::Burst { capacity: 200 }), + scope: Some(oagw::RateLimitScope::Tenant), + strategy: Some(oagw::Strategy::Reject), + cost: Some(2), + }; + + let serialized = serde_json::to_value(&rate_limit).expect("rate limit serializes"); + for key in [ + "sharing", + "algorithm", + "sustained", + "burst", + "scope", + "strategy", + "cost", + ] { + assert!( + serialized.get(key).is_some(), + "rate_limit.{key} must be present" + ); + } + assert_eq!(serialized["algorithm"], "token_bucket"); + assert_eq!(serialized["sustained"]["window"], "minute"); + + let back: RateLimitConfig = serde_json::from_value(serialized).expect("rate limit round-trips"); + assert_eq!(back, rate_limit); +} + +#[test] +fn plugins_config_carries_sharing_and_items() { + let plugins = PluginsConfig { + sharing: Some(SharingMode::Inherit), + items: vec![ + String::from("gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1"), + String::from("018f0000-0000-7000-8000-000000000000"), + ], + }; + let serialized = serde_json::to_value(&plugins).expect("plugins serialize"); + assert_eq!(serialized["sharing"], "inherit"); + assert_eq!(serialized["items"].as_array().map(Vec::len), Some(2)); +} + +#[test] +fn auth_config_carries_type_sharing_and_config() { + let auth = AuthConfig { + r#type: Some(String::from( + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1", + )), + sharing: Some(SharingMode::Private), + config: Some(json!({ "header": "x-api-key" })), + }; + let serialized = serde_json::to_value(&auth).expect("auth serializes"); + assert!( + serialized.get("type").is_some(), + "the wire key must stay 'type'" + ); + assert!(serialized.get("r#type").is_none()); + let back: AuthConfig = serde_json::from_value(serialized).expect("auth round-trips"); + assert_eq!(back, auth); +} + +#[test] +fn plugin_carries_the_design_3_1_attributes() { + let plugin = Plugin { + id: uuid::Uuid::nil(), + tenant_id: uuid::Uuid::nil(), + plugin_type: String::from("transform"), + name: String::from("redact-headers"), + description: Some(String::from("redacts response headers")), + config_schema: Some(json!({ "type": "object" })), + phases: vec![String::from("on_response")], + source_code: String::from("def on_response(ctx): pass"), + last_used_at: Some(1_700_000_000), + gc_eligible_at: None, + }; + let serialized = serde_json::to_value(&plugin).expect("plugin serializes"); + for key in [ + "id", + "tenant_id", + "plugin_type", + "name", + "description", + "config_schema", + "phases", + "source_code", + "last_used_at", + "gc_eligible_at", + ] { + assert!( + serialized.get(key).is_some(), + "plugin.{key} must be present" + ); + } + assert_eq!(serialized["last_used_at"], 1_700_000_000); + assert!(serialized["gc_eligible_at"].is_null()); +} + +#[test] +fn alias_value_object_normalizes_inside_the_upstream_model() { + let alias = Alias::parse("API.OpenAI.COM.").expect("valid alias"); + assert_eq!(alias.to_string(), "api.openai.com"); +} + +#[test] +fn domain_layer_stays_free_of_transport_and_persistence_types() { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let domain_dir = std::path::Path::new(manifest_dir).join("src/domain"); + let forbidden = [ + "axum", "http", "hyper", "sqlx", "sea_orm", "reqwest", "tonic", + ]; + + let mut checked = 0; + let entries = std::fs::read_dir(&domain_dir).expect("domain directory is readable"); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("rs") { + continue; + } + let source = std::fs::read_to_string(&path).expect("domain source is readable"); + for crate_name in forbidden { + for prefix in [format!("{crate_name}::"), format!("{crate_name} (")] { + assert!( + !source.contains(&prefix), + "{} references the infrastructure crate `{crate_name}`", + path.display() + ); + } + } + checked += 1; + } + assert!( + checked >= 4, + "expected the domain modules to be scanned, got {checked}" + ); +} diff --git a/gears/system/oagw/oagw/tests/effective_merge_tests.rs b/gears/system/oagw/oagw/tests/effective_merge_tests.rs new file mode 100644 index 0000000..49dd8bc --- /dev/null +++ b/gears/system/oagw/oagw/tests/effective_merge_tests.rs @@ -0,0 +1,928 @@ +//! The per-field-family effective merge and the resolution entry point. +//! +//! Covers `cpt-cf-oagw-dod-field-family-merge`, +//! `cpt-cf-oagw-dod-alias-shadowing`, and +//! `cpt-cf-oagw-dod-effective-config-result`: every strategy row of the merge +//! table for every family, the effective `enabled` state, the common scale the +//! rate minimum is decided on, and the fail-closed exits of +//! `cpt-cf-oagw-flow-resolve-effective-config`. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::missing_panics_doc)] + +// @cpt-dod:cpt-cf-oagw-dod-resolution-tests:p1 + +use std::sync::Arc; + +use oagw::control_plane::cache::ControlPlaneCache; +use oagw::control_plane::plugin_def; +use oagw::domain::plugin_contract::PluginFamily; +use oagw::control_plane::effective::{ResolveError, resolve_effective}; +use oagw::control_plane::service::ManagementService; +use oagw::control_plane::effective::EffectiveResolution; +use oagw::domain::effective::{EffectiveUpstreamConfig, RouteSelector}; +use oagw::domain::upstream::{PluginsConfig, ServerConfig, SharingMode, Upstream}; +use oagw::{AuthConfig, CorsConfig, Endpoint, EndpointHost, Scheme}; +use oagw::store::OagwStore; +use oagw::{OagwConfig, UpstreamRow}; +use serde_json::{Value, json}; +use uuid::Uuid; + +const HTTP_PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; + +const ALIAS: &str = "api.openai.com"; + +fn tenant(n: u128) -> Uuid { + Uuid::from_u128(n) +} + +/// The calling tenant and its single ancestor, in chain order. +fn chain() -> (Uuid, Uuid) { + (tenant(0xb001), tenant(0xb002)) +} + +fn service_with_store() -> (ManagementService, Arc) { + let store = Arc::new(OagwStore::new()); + let service = ManagementService::new( + Arc::clone(&store), + &OagwConfig::default(), + Arc::new(ControlPlaneCache::new()), + ) + .expect("the validators compile"); + (service, store) +} + +/// An upstream body whose endpoints derive the alias, with no family set. +fn base(alias: &str) -> Value { + json!({ + "server": { "endpoints": [{ "scheme": "https", "host": alias, "port": 443 }] }, + "protocol": HTTP_PROTOCOL, + "alias": alias, + "tags": [] + }) +} + +fn with_families(body: Value, families: Value) -> Value { + let mut object = body.as_object().expect("the body is an object").clone(); + for (key, value) in families.as_object().expect("the families are an object") { + object.insert(key.clone(), value.clone()); + } + Value::Object(object) +} + +fn auth(sharing: &str, kind: &str) -> Value { + json!({ "sharing": sharing, "type": kind, "config": { "key": "leaf" } }) +} + +fn rate_limit(sharing: &str, rate: u64, window: &str, capacity: u64) -> Value { + json!({ + "sharing": sharing, + "algorithm": "token_bucket", + "sustained": { "rate": rate, "window": window }, + "burst": { "capacity": capacity }, + "scope": "tenant", + "strategy": "reject", + "cost": 1 + }) +} + +fn rate_limit_with( + sharing: &str, + rate: u64, + window: &str, + capacity: u64, + overrides: Value, +) -> Value { + let mut body = rate_limit(sharing, rate, window, capacity); + for (key, value) in overrides.as_object().expect("the overrides are an object") { + body[key] = value.clone(); + } + body +} + +fn plugins(sharing: &str, items: &[&str]) -> Value { + json!({ "sharing": sharing, "items": items }) +} + +/// A built-in plugin identifier, the form the shipped schema describes for a +/// `plugins.items` entry. +const PLUGIN: &str = "gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1"; + +/// Creates one custom transform plugin for the tenant and answers the +/// anonymous identifier it is addressed by. +/// +/// The chain-composition tests of the plugin feature resolve every binding +/// item before any row is written, so a binding that names no resolvable +/// plugin is refused: the route bodies below bind a plugin row the test +/// creates first. +fn other_plugin(management: &ManagementService, tenant: Uuid) -> String { + let row = management + .create_plugin( + tenant, + &json!({ + "plugin_type": "transform", + "name": "route-tag", + "phases": ["on_response"], + "source_code": "def on_response(ctx):\n return ctx\n" + }), + ) + .expect("the custom plugin is created"); + plugin_def::plugin_instance(PluginFamily::Transform, row.plugin.id) +} +/// The two resolvable auth identifiers the effective-merge tests bind: the +/// ancestor's and the descendant's own, distinct so no assertion can pass by +/// reading one where the other was written. +const ROOT_AUTH: &str = oagw::gts::plugin_catalog::AUTH_NOOP; +const LEAF_AUTH: &str = oagw::gts::plugin_catalog::AUTH_APIKEY; + +const OTHER_PLUGIN: &str = "gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.tag.v1"; +const THIRD_PLUGIN: &str = "gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.correlation_id.v1"; + +/// Builds the upstream value one store-level row holds, with no family set. +/// +/// The shipped `upstream.v1` schema states `plugins.items` as a `oneOf` of two +/// `type: string` branches, so every non-empty item array is rejected by the +/// write path the two branches are indistinguishable under: one entry can +/// never satisfy exactly one of two identical subschemas. The merge this +/// feature delivers is downstream of that write-path defect, so the rows that +/// carry a chain are supplied through [`OagwStore::insert_upstream`], which +/// takes the validated domain value directly, and the item shape of +/// `cpt-cf-oagw-feature-plugin-system`, which owns it, is not exercised here. +fn stored(alias: &str) -> Upstream { + let mut upstream = Upstream::new( + Uuid::new_v4(), + ServerConfig { + endpoints: vec![Endpoint { + scheme: Scheme::Https, + host: EndpointHost::parse(alias).expect("a valid endpoint host"), + port: Some(443), + }], + }, + String::from(HTTP_PROTOCOL), + ); + upstream.alias = Some(String::from(alias)); + upstream +} + +/// Inserts the value, answering the stored row. +fn insert(store: &OagwStore, owner: Uuid, upstream: &Upstream) -> UpstreamRow { + store + .insert_upstream(owner, upstream) + .expect("the row is inserted") +} + +/// The `plugins` family one store-level row carries. +fn shared_plugins(sharing: SharingMode, items: &[&str]) -> PluginsConfig { + PluginsConfig { + sharing: Some(sharing), + items: items.iter().map(|item| String::from(*item)).collect(), + } +} + +fn cors(sharing: &str, origins: &[&str]) -> Value { + json!({ + "sharing": sharing, + "enabled": true, + "allowed_origins": origins, + "allowed_methods": ["GET"], + "allow_credentials": false + }) +} + +fn create(service: &ManagementService, owner: Uuid, body: &Value) -> UpstreamRow { + service + .create_upstream(owner, body) + .expect("the create succeeds") +} + +fn http_selector(path: &str) -> RouteSelector { + RouteSelector::Http { + method: "GET".to_string(), + path: path.to_string(), + } +} + +/// Resolves the upstream layer for one alias against one chain. +fn resolve_upstream( + store: &OagwStore, + calling: Uuid, + ancestors: &[Uuid], + alias: &str, +) -> Option { + resolve_effective(store, calling, ancestors, alias, &http_selector("/v1/chat")) + .expect("an ordered chain") + .map(|answer| answer.upstream) +} + +/// Resolves the whole answer for one alias against one chain. +fn resolve_all( + store: &OagwStore, + calling: Uuid, + ancestors: &[Uuid], + alias: &str, +) -> Option { + resolve_effective(store, calling, ancestors, alias, &http_selector("/v1/chat")) + .expect("an ordered chain") +} + +#[test] +fn a_descendants_row_shadows_an_ancestors_but_its_enforce_families_still_apply() { + let (management, store) = service_with_store(); + let (leaf, root) = chain(); + let ancestor = create( + &management, + root, + &with_families( + base(ALIAS), + json!({ + "auth": auth("enforce", ROOT_AUTH), + "rate_limit": rate_limit("enforce", 10_000, "minute", 1_000), + "tags": ["root"] + }), + ), + ); + let descendant = create( + &management, + leaf, + &with_families( + base(ALIAS), + json!({ + "auth": auth("private", LEAF_AUTH), + "rate_limit": rate_limit("private", 100, "minute", 100), + "tags": ["leaf"] + }), + ), + ); + + let answer = resolve_all(&store, leaf, &[root], ALIAS).expect("a resolution"); + assert!(answer.enabled); + assert_eq!(answer.upstream.upstream_id, descendant.upstream.id); + assert_eq!(answer.upstream.tenant_id, leaf, "the descendant is the target"); + + let forced = answer.upstream.auth.expect("the enforce ancestor forces auth"); + assert_eq!(forced.owner, root); + assert_eq!(forced.mode, SharingMode::Enforce); + assert_eq!(forced.auth.r#type.as_deref(), Some(ROOT_AUTH)); + assert!( + ancestor.upstream.enabled, + "the ancestor's rows are unchanged by a resolution" + ); +} + +#[test] +fn an_alias_held_only_by_an_ancestor_makes_it_the_target_and_its_inherit_families_the_base() { + let (_management, store) = service_with_store(); + let (leaf, root) = chain(); + let mut value = stored(ALIAS); + value.auth = Some(AuthConfig { + r#type: Some(String::from(ROOT_AUTH)), + sharing: Some(SharingMode::Inherit), + config: None, + }); + value.plugins = Some(shared_plugins(SharingMode::Inherit, &[OTHER_PLUGIN])); + value.tags = vec![String::from("root")]; + let ancestor = insert(&store, root, &value); + + let answer = resolve_all(&store, leaf, &[root], ALIAS).expect("a resolution"); + assert_eq!(answer.upstream.upstream_id, ancestor.upstream.id); + assert_eq!(answer.upstream.tenant_id, root); + + let inherited = answer.upstream.auth.expect("the ancestor contributes auth"); + assert_eq!(inherited.owner, root); + assert_eq!(inherited.mode, SharingMode::Inherit); + let chain = answer + .upstream + .plugins + .expect("the ancestor contributes plugins"); + assert_eq!(chain.items, vec![OTHER_PLUGIN]); + assert_eq!(chain.owner, root); +} + +#[test] +fn an_alias_held_by_no_chain_element_resolves_to_nothing() { + let (management, store) = service_with_store(); + let (leaf, root) = chain(); + management + .create_upstream(root, &base("other.example.com")) + .expect("the create succeeds"); + + let answer = resolve_effective(&store, leaf, &[root], ALIAS, &http_selector("/v1/chat")) + .expect("an ordered chain"); + assert!(answer.is_none(), "the consumer answers 404"); +} + +#[test] +fn an_ancestor_auth_marked_private_contributes_nothing_and_consumes_no_permission() { + let (management, store) = service_with_store(); + let (leaf, root) = chain(); + create( + &management, + root, + &with_families(base(ALIAS), json!({ "auth": auth("private", ROOT_AUTH) })), + ); + create( + &management, + leaf, + &with_families(base(ALIAS), json!({ "auth": auth("private", LEAF_AUTH) })), + ); + + let upstream = resolve_upstream(&store, leaf, &[root], ALIAS).expect("a resolution"); + let merged = upstream.auth.expect("the descendant's own auth is effective"); + assert_eq!(merged.owner, leaf); + assert_eq!(merged.mode, SharingMode::Private); + assert_eq!( + merged.auth.r#type.as_deref(), + Some(LEAF_AUTH), + "the ancestor's private value is never read into a result" + ); + assert!( + !serde_json::to_string(&merged.auth) + .expect("the auth serializes") + .contains(ROOT_AUTH), + "no ancestor value is echoed in the answer" + ); +} + +#[test] +fn an_ancestor_auth_marked_inherit_is_the_base_a_descendants_own_object_replaces() { + let (management, store) = service_with_store(); + let (leaf, root) = chain(); + create( + &management, + root, + &with_families(base(ALIAS), json!({ "auth": auth("inherit", ROOT_AUTH) })), + ); + let without_own = resolve_upstream(&store, leaf, &[root], ALIAS).expect("a resolution"); + let inherited = without_own.auth.expect("the base is the ancestor's object"); + assert_eq!(inherited.auth.r#type.as_deref(), Some(ROOT_AUTH)); + + create( + &management, + leaf, + &with_families(base(ALIAS), json!({ "auth": auth("private", LEAF_AUTH) })), + ); + let with_own = resolve_upstream(&store, leaf, &[root], ALIAS).expect("a resolution"); + let overridden = with_own.auth.expect("the descendant's own object is effective"); + assert_eq!(overridden.auth.r#type.as_deref(), Some(LEAF_AUTH)); + assert_eq!(overridden.owner, leaf); +} + +#[test] +fn an_ancestor_auth_marked_enforce_is_effective_regardless_of_the_descendant() { + let (management, store) = service_with_store(); + let (leaf, root) = chain(); + create( + &management, + root, + &with_families(base(ALIAS), json!({ "auth": auth("enforce", ROOT_AUTH) })), + ); + create( + &management, + leaf, + &with_families(base(ALIAS), json!({ "auth": auth("private", LEAF_AUTH) })), + ); + + let upstream = resolve_upstream(&store, leaf, &[root], ALIAS).expect("a resolution"); + let forced = upstream.auth.expect("the ancestor's object is effective"); + assert_eq!(forced.auth.r#type.as_deref(), Some(ROOT_AUTH)); + assert_eq!(forced.owner, root); + assert_eq!(forced.mode, SharingMode::Enforce); +} + +#[test] +fn the_rate_limit_resolves_to_the_minimum_of_the_visible_rates() { + let (management, store) = service_with_store(); + let (leaf, root) = chain(); + create( + &management, + root, + &with_families( + base(ALIAS), + json!({ "rate_limit": rate_limit("enforce", 10_000, "minute", 1_000) }), + ), + ); + create( + &management, + leaf, + &with_families( + base(ALIAS), + json!({ "rate_limit": rate_limit("private", 100, "minute", 100) }), + ), + ); + + let merged = resolve_upstream(&store, leaf, &[root], ALIAS) + .expect("a resolution") + .rate_limit + .expect("the descendant's limit is visible"); + assert_eq!(merged.rate_limit.sustained.expect("a sustained rate").rate, 100); + assert_eq!(merged.owner, leaf, "the descendant's value supplied the minimum"); +} + +#[test] +fn a_looser_descendant_rate_limit_cannot_exceed_an_enforced_ancestor() { + let (management, store) = service_with_store(); + let (leaf, root) = chain(); + create( + &management, + root, + &with_families( + base(ALIAS), + json!({ "rate_limit": rate_limit("enforce", 10_000, "minute", 1_000) }), + ), + ); + create( + &management, + leaf, + &with_families( + base(ALIAS), + json!({ "rate_limit": rate_limit("private", 20_000, "minute", 2_000) }), + ), + ); + + let merged = resolve_upstream(&store, leaf, &[root], ALIAS) + .expect("a resolution") + .rate_limit + .expect("the ancestor's limit is visible"); + assert_eq!( + merged.rate_limit.sustained.expect("a sustained rate").rate, + 10_000, + "the ancestor's limit is the effective one" + ); + assert_eq!(merged.owner, root); + assert_eq!(merged.mode, SharingMode::Enforce); +} + +#[test] +fn the_rate_minimum_is_decided_on_a_common_scale_and_reported_in_the_winners_window() { + let (management, store) = service_with_store(); + let (leaf, root) = chain(); + create( + &management, + root, + &with_families( + base(ALIAS), + json!({ "rate_limit": rate_limit("inherit", 5_000, "minute", 500) }), + ), + ); + create( + &management, + leaf, + &with_families( + base(ALIAS), + json!({ "rate_limit": rate_limit("private", 100, "second", 600) }), + ), + ); + + let merged = resolve_upstream(&store, leaf, &[root], ALIAS) + .expect("a resolution") + .rate_limit + .expect("both limits are visible"); + let sustained = merged.rate_limit.sustained.expect("a sustained rate"); + assert_eq!(sustained.rate, 5_000, "5000/minute is stricter than 100/second"); + assert_eq!( + sustained.window, + Some(oagw::domain::upstream::Window::Minute), + "the winner's window is reported" + ); + assert_eq!(merged.rate_limit.burst.expect("a burst").capacity, 500); +} + +#[test] +fn the_burst_capacity_is_minimized_and_the_remaining_members_are_never_merged() { + let (management, store) = service_with_store(); + let (leaf, root) = chain(); + create( + &management, + root, + &with_families( + base(ALIAS), + json!({ "rate_limit": rate_limit_with("enforce", 5_000, "minute", 1_000, json!({ + "algorithm": "sliding_window", + "scope": "ip", + "strategy": "queue", + "cost": 7 + })) }), + ), + ); + create( + &management, + leaf, + &with_families( + base(ALIAS), + json!({ "rate_limit": rate_limit("private", 100, "second", 100) }), + ), + ); + + let merged = resolve_upstream(&store, leaf, &[root], ALIAS) + .expect("a resolution") + .rate_limit + .expect("both limits are visible"); + assert_eq!(merged.rate_limit.burst.expect("a burst").capacity, 100); + assert_eq!( + merged.rate_limit.algorithm, + Some(oagw::domain::upstream::Algorithm::TokenBucket), + "the algorithm is carried unchanged from the routing target's own object" + ); + assert_eq!( + merged.rate_limit.scope, + Some(oagw::domain::upstream::RateLimitScope::Tenant) + ); + assert_eq!( + merged.rate_limit.strategy, + Some(oagw::domain::upstream::Strategy::Reject) + ); + assert_eq!(merged.rate_limit.cost, Some(1)); +} + +#[test] +fn an_ancestor_rate_limit_marked_private_contributes_nothing() { + let (management, store) = service_with_store(); + let (leaf, root) = chain(); + create( + &management, + root, + &with_families( + base(ALIAS), + json!({ "rate_limit": rate_limit("private", 50, "minute", 50) }), + ), + ); + create(&management, leaf, &base(ALIAS)); + + let upstream = resolve_upstream(&store, leaf, &[root], ALIAS).expect("a resolution"); + assert!( + upstream.rate_limit.is_none(), + "a descendant with no rate_limit resolves to no limit rather than to the ancestor's" + ); +} + +#[test] +fn an_inherited_plugin_chain_is_concatenated_ancestor_then_descendant() { + let (_management, store) = service_with_store(); + let (leaf, root) = chain(); + let mut ancestor_value = stored(ALIAS); + ancestor_value.plugins = Some(shared_plugins(SharingMode::Inherit, &[PLUGIN, OTHER_PLUGIN])); + insert(&store, root, &ancestor_value); + + let mut descendant_value = stored(ALIAS); + descendant_value.plugins = Some(shared_plugins(SharingMode::Private, &[THIRD_PLUGIN])); + insert(&store, leaf, &descendant_value); + + let merged = resolve_upstream(&store, leaf, &[root], ALIAS) + .expect("a resolution") + .plugins + .expect("both chains are visible"); + assert_eq!(merged.items, vec![PLUGIN, OTHER_PLUGIN, THIRD_PLUGIN]); + assert_eq!(merged.owner, leaf, "the nearest items are the descendant's"); + assert_eq!(merged.contributors, vec![root, leaf]); +} + +#[test] +fn an_enforce_ancestors_plugin_items_survive_a_replacement_that_omits_them() { + let (management, store) = service_with_store(); + let (leaf, root) = chain(); + let mut value = stored(ALIAS); + value.plugins = Some(shared_plugins(SharingMode::Enforce, &[PLUGIN])); + insert(&store, root, &value); + create(&management, leaf, &base(ALIAS)); + + let merged = resolve_upstream(&store, leaf, &[root], ALIAS) + .expect("a resolution") + .plugins + .expect("the ancestor's chain is visible"); + assert_eq!(merged.items, vec![PLUGIN]); + assert_eq!(merged.owner, root); + assert_eq!(merged.mode, SharingMode::Enforce); +} + +#[test] +fn cors_origins_union_under_inherit() { + let (management, store) = service_with_store(); + let (leaf, root) = chain(); + create( + &management, + root, + &with_families( + base(ALIAS), + json!({ "cors": cors("inherit", &["https://app.example.com"]) }), + ), + ); + create( + &management, + leaf, + &with_families( + base(ALIAS), + json!({ "cors": cors("private", &["https://admin.example.com"]) }), + ), + ); + + let merged = resolve_upstream(&store, leaf, &[root], ALIAS) + .expect("a resolution") + .cors + .expect("both origins are visible"); + assert_eq!( + merged.cors.allowed_origins, + vec!["https://app.example.com", "https://admin.example.com"] + ); + assert_eq!(merged.owner, leaf, "the routing target's object carries the merge"); + assert_eq!(merged.mode, SharingMode::Inherit); + assert!( + !merged.cors.allow_credentials, + "the union is confined to allowed_origins" + ); +} + +#[test] +fn cors_is_forced_under_enforce() { + let (management, store) = service_with_store(); + let (leaf, root) = chain(); + create( + &management, + root, + &with_families( + base(ALIAS), + json!({ "cors": cors("enforce", &["https://root.example.com"]) }), + ), + ); + create( + &management, + leaf, + &with_families( + base(ALIAS), + json!({ "cors": cors("private", &["https://leaf.example.com"]) }), + ), + ); + + let merged = resolve_upstream(&store, leaf, &[root], ALIAS) + .expect("a resolution") + .cors + .expect("the ancestor's object is forced"); + assert_eq!(merged.cors.allowed_origins, vec!["https://root.example.com"]); + assert_eq!(merged.owner, root); + assert_eq!(merged.mode, SharingMode::Enforce); +} + +#[test] +fn tags_resolve_to_the_add_only_union() { + let (management, store) = service_with_store(); + let (leaf, root) = chain(); + let ancestor = create( + &management, + root, + &with_families(base(ALIAS), json!({ "tags": ["shared", "root"] })), + ); + let descendant = create( + &management, + leaf, + &with_families(base(ALIAS), json!({ "tags": ["shared", "leaf"] })), + ); + + let merged = resolve_upstream(&store, leaf, &[root], ALIAS).expect("a resolution"); + assert_eq!(merged.tags.tags, vec!["root", "shared", "leaf"]); + assert_eq!(merged.tags.contributors, vec![root, leaf]); + + // A descendant row that omits an inherited tag leaves it in the effective + // set, because the union is computed at resolution time and never stored. + management + .replace_upstream( + leaf, + descendant.upstream.id, + &with_families(base(ALIAS), json!({ "tags": ["leaf"] })), + ) + .expect("the replacement succeeds"); + management + .replace_upstream( + root, + ancestor.upstream.id, + &with_families(base(ALIAS), json!({ "tags": ["root"] })), + ) + .expect("the replacement succeeds"); + let replaced = resolve_upstream(&store, leaf, &[root], ALIAS).expect("a resolution"); + assert_eq!( + replaced.tags.tags, + vec!["root", "leaf"], + "the union is recomputed, never materialized" + ); + let stored = management + .read_upstream(leaf, descendant.upstream.id) + .expect("the row is readable"); + assert_eq!(stored.tags, vec!["leaf"], "the descendant's row holds its own tags"); +} + +#[test] +fn a_disabled_ancestor_disables_the_effective_state_without_a_write() { + let (management, store) = service_with_store(); + let (leaf, root) = chain(); + let ancestor = create( + &management, + root, + &with_families(base(ALIAS), json!({ "tags": ["root"] })), + ); + create(&management, leaf, &base(ALIAS)); + + let enabled = resolve_all(&store, leaf, &[root], ALIAS).expect("a resolution"); + assert!(enabled.enabled, "every matched row is enabled"); + + let mut body = base(ALIAS); + body["enabled"] = json!(false); + management + .replace_upstream(root, ancestor.upstream.id, &body) + .expect("the ancestor disable succeeds"); + + let disabled = resolve_all(&store, leaf, &[root], ALIAS).expect("a resolution"); + assert!( + !disabled.enabled, + "one disabled ancestor disables the resource for every descendant" + ); + let stored = management + .read_upstream(leaf, resolve_upstream(&store, leaf, &[root], ALIAS) + .expect("a resolution") + .upstream_id) + .expect("the descendant's row is readable"); + assert!( + stored.upstream.enabled, + "no write reached the descendant's row" + ); +} + +#[test] +fn a_descendant_cannot_raise_the_effective_state_an_ancestor_disabled() { + let (management, store) = service_with_store(); + let (leaf, root) = chain(); + let ancestor = create( + &management, + root, + &with_families(base(ALIAS), json!({ "tags": ["root"] })), + ); + let descendant = create(&management, leaf, &base(ALIAS)); + let mut body = base(ALIAS); + body["enabled"] = json!(false); + management + .replace_upstream(root, ancestor.upstream.id, &body) + .expect("the ancestor disable succeeds"); + let mut raised = base(ALIAS); + raised["enabled"] = json!(true); + management + .replace_upstream(leaf, descendant.upstream.id, &raised) + .expect("the descendant's own row is re-enabled"); + + let answer = resolve_all(&store, leaf, &[root], ALIAS).expect("a resolution"); + assert!( + !answer.enabled, + "no descendant write can raise the effective state" + ); +} + +#[test] +fn the_route_layer_merges_with_the_same_strategies_and_carries_no_auth() { + let (management, store) = service_with_store(); + let (leaf, root) = chain(); + let ancestor_upstream = create( + &management, + root, + &with_families( + base(ALIAS), + json!({ + "auth": auth("enforce", ROOT_AUTH), + "rate_limit": rate_limit("enforce", 10_000, "minute", 1_000) + }), + ), + ); + let descendant_upstream = create( + &management, + leaf, + &with_families(base(ALIAS), json!({ "tags": ["leaf"] })), + ); + let descendant_plugin = other_plugin(&management, leaf); + let ancestor_route = management + .create_route( + root, + &json!({ + "upstream_id": ancestor_upstream.upstream.id, + "match": { "http": { "methods": ["GET"], "path": "/v1" } }, + "priority": 1, + "tags": ["root-route"], + "rate_limit": rate_limit("inherit", 500, "minute", 50) + }), + ) + .expect("the route create succeeds"); + management + .create_route( + leaf, + &json!({ + "upstream_id": descendant_upstream.upstream.id, + "match": { "http": { "methods": ["GET"], "path": "/v1/chat" } }, + "priority": 1, + "tags": ["leaf-route"], + "plugins": plugins("private", &[&descendant_plugin]), + }), + ) + .expect("the route create succeeds"); + + let answer = resolve_effective(&store, leaf, &[root], ALIAS, &http_selector("/v1/chat")) + .expect("an ordered chain") + .expect("a resolution"); + let route = answer.route.expect("the chain holds a matching route"); + assert_eq!(route.tenant_id, leaf, "the descendant's route wins"); + assert_eq!(route.tags.tags, vec!["root-route", "leaf-route"]); + let merged = route.rate_limit.expect("both route limits are visible"); + assert_eq!( + merged.rate_limit.sustained.expect("a sustained rate").rate, + 500, + "the ancestor route's inherit limit participates in the minimum" + ); + assert_eq!( + route.plugins.expect("the descendant's chain").items, + vec![descendant_plugin], + "the ancestor route contributes no plugins it did not share" + ); + assert!( + ancestor_route.route.plugins.is_none(), + "the ancestor route carries no plugins object of its own" + ); +} + +#[test] +fn a_route_of_a_tenant_outside_the_chain_is_never_a_candidate() { + let (management, store) = service_with_store(); + let (leaf, root) = chain(); + let outsider = tenant(0xb999); + let upstream = create(&management, root, &base(ALIAS)); + create(&management, leaf, &base(ALIAS)); + let refused = management.create_route( + outsider, + &json!({ + "upstream_id": upstream.upstream.id, + "match": { "http": { "methods": ["GET"], "path": "/v1" } }, + "priority": 1 + }), + ); + assert!( + refused.is_err(), + "a route of a tenant outside the chain is never writable onto another tenant's upstream" + ); + + let answer = resolve_effective(&store, leaf, &[root], ALIAS, &http_selector("/v1")) + .expect("an ordered chain") + .expect("a resolution"); + assert!( + answer.route.is_none(), + "another tenant's route is never a candidate" + ); +} + +#[test] +fn a_cyclic_chain_fails_the_resolution_closed() { + let (management, store) = service_with_store(); + let (leaf, root) = chain(); + create(&management, leaf, &base(ALIAS)); + + let answer = resolve_effective(&store, leaf, &[root, leaf], ALIAS, &http_selector("/v1")); + assert_eq!(answer, Err(ResolveError::UnavailableChain)); +} + +#[test] +fn the_resolution_carries_the_per_family_sharing_modes_and_ownership() { + let (management, store) = service_with_store(); + let (leaf, root) = chain(); + let mut value = stored(ALIAS); + value.auth = Some(AuthConfig { + r#type: Some(String::from(ROOT_AUTH)), + sharing: Some(SharingMode::Enforce), + config: None, + }); + value.plugins = Some(shared_plugins(SharingMode::Inherit, &[PLUGIN])); + value.cors = Some(CorsConfig { + sharing: Some(SharingMode::Inherit), + enabled: true, + allowed_origins: vec![String::from("https://app.example.com")], + allowed_methods: vec![String::from("GET")], + expose_headers: Vec::new(), + allow_credentials: false, + }); + value.tags = vec![String::from("root")]; + insert(&store, root, &value); + create( + &management, + leaf, + &with_families(base(ALIAS), json!({ "tags": ["leaf"] })), + ); + + let upstream = resolve_upstream(&store, leaf, &[root], ALIAS).expect("a resolution"); + assert_eq!( + upstream.auth.expect("auth is forced").mode, + SharingMode::Enforce + ); + assert_eq!( + upstream.plugins.expect("plugins are inherited").mode, + SharingMode::Inherit + ); + assert_eq!( + upstream.cors.expect("cors is inherited").mode, + SharingMode::Inherit + ); + assert_eq!( + upstream.tags.contributors, + vec![root, leaf], + "every contributor of the tag union is named" + ); + assert_eq!(upstream.tenant_id, leaf); +} diff --git a/gears/system/oagw/oagw/tests/effective_types_tests.rs b/gears/system/oagw/oagw/tests/effective_types_tests.rs new file mode 100644 index 0000000..4a0558c --- /dev/null +++ b/gears/system/oagw/oagw/tests/effective_types_tests.rs @@ -0,0 +1,133 @@ +//! Effective-configuration result types. +//! +//! Covers `cpt-cf-oagw-dod-effective-config-result` and the domain half of +//! `cpt-cf-oagw-feature-hierarchical-config`: the chain the platform resolver +//! supplies and the failure modes §1.4 names, the sharing modes a row declares, +//! and the two per-layer results. The alias-identity rule of +//! `cpt-cf-oagw-dod-alias-shadowing` is asserted here on the value object the +//! walk compares with, so a candidate set can never disagree with a stored +//! alias about case, a trailing dot, or a port. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::missing_panics_doc)] + +use std::net::Ipv4Addr; + +use oagw::Alias; +use oagw::domain::effective::{ChainError, Family, FamilyModes, TenantChain}; +use oagw::domain::upstream::SharingMode; +use uuid::Uuid; + +fn tenant() -> Uuid { + Uuid::from_u128(0xa001) +} + +fn ancestor() -> Uuid { + Uuid::from_u128(0xa002) +} + +fn root() -> Uuid { + Uuid::from_u128(0xa003) +} + +fn ipv4(value: u8) -> String { + Ipv4Addr::new(10, 0, 0, value).to_string() +} + +#[test] +fn chain_prepends_the_calling_tenant_the_resolver_omitted() { + // The resolver answers the ancestors only when the tenant is not itself the + // first element; the walk prepends the calling tenant either way, because + // the calling tenant's own rows must be the closest candidates. + let chain = TenantChain::from_resolver(tenant(), &[ancestor(), root()]) + .expect("an ordered chain is available"); + assert_eq!(chain.tenants(), &[tenant(), ancestor(), root()]); + assert_eq!(chain.calling_tenant(), tenant()); +} + +#[test] +fn chain_keeps_the_resolver_order_when_the_calling_tenant_is_first() { + let chain = TenantChain::from_resolver(tenant(), &[tenant(), ancestor()]) + .expect("a chain that already starts at the calling tenant is usable as given"); + assert_eq!(chain.tenants(), &[tenant(), ancestor()]); +} + +#[test] +fn chain_answers_none_for_a_cyclic_answer() { + // A repeated element is a cycle: the walk cannot order who shadows whom, + // so the chain is unavailable and the caller fails closed. + let chain = TenantChain::from_resolver(tenant(), &[ancestor(), tenant(), root()]); + assert!(chain.is_none(), "a cycle is an unavailable chain"); +} + +#[test] +fn chain_answers_none_for_an_unordered_answer() { + // A chain whose calling tenant is not its first element and cannot be + // prepended without repeating an element is unordered, not merely rotated. + let chain = TenantChain::from_resolver(tenant(), &[ancestor(), tenant()]); + assert!(chain.is_none(), "an unordered chain is an unavailable chain"); +} + +#[test] +fn chain_answers_the_only_element_when_the_tenant_is_the_root() { + let chain = TenantChain::from_resolver(root(), &[]).expect("the root is a chain of one"); + assert_eq!(chain.tenants(), &[root()]); + assert_eq!(chain.depth_of(root()), Some(0)); + assert_eq!(chain.depth_of(tenant()), None); +} + +#[test] +fn chain_depth_runs_from_the_calling_tenant_to_the_root() { + let chain = TenantChain::from_resolver(tenant(), &[ancestor(), root()]) + .expect("an ordered chain is available"); + assert_eq!(chain.depth_of(tenant()), Some(0)); + assert_eq!(chain.depth_of(ancestor()), Some(1)); + assert_eq!(chain.depth_of(root()), Some(2)); + assert_eq!(chain.depth_of(Uuid::nil()), None, "no lookup is issued for a tenant outside the chain"); +} + +#[test] +fn chain_from_ordered_keeps_the_order_it_is_given_and_rejects_the_rest() { + // The management flows build the chain from an ordered answer they already + // hold; the constructor validates it rather than reordering it. + let chain = TenantChain::from_ordered(vec![tenant(), ancestor(), root()]) + .expect("an ordered chain is available"); + assert_eq!(chain.tenants(), &[tenant(), ancestor(), root()]); + assert_eq!(TenantChain::from_ordered(Vec::new()), Err(ChainError::Empty)); + assert_eq!( + TenantChain::from_ordered(vec![tenant(), ancestor(), tenant()]), + Err(ChainError::Cyclic) + ); +} + +#[test] +fn family_modes_default_every_family_to_private() { + // Every `sharing` member of the shipped schemas defaults to `private`, so + // a row that declares none of them contributes nothing to any descendant. + let modes = FamilyModes::new(None, None, None, None); + assert_eq!(modes.mode_of(Family::Auth), SharingMode::Private); + assert_eq!(modes.mode_of(Family::RateLimit), SharingMode::Private); + assert_eq!(modes.mode_of(Family::Plugins), SharingMode::Private); + assert_eq!(modes.mode_of(Family::Cors), SharingMode::Private); +} + +#[test] +fn alias_identity_keeps_the_port_and_drops_the_case_and_the_trailing_dot() { + // `cpt-cf-oagw-dod-alias-shadowing`: aliases compare on the normalized + // form only, case-insensitively, with the port participating in identity. + let bare = Alias::parse("api.openai.com").expect("a bare host is a valid alias"); + let dotted = Alias::parse("API.OpenAI.com.").expect("the trailing dot is normalized away"); + let ported = Alias::parse("api.openai.com:8443").expect("a port is a valid alias suffix"); + + assert_eq!(bare, dotted, "case and a trailing dot are not part of identity"); + assert_ne!(bare, ported, "the port participates in identity"); + assert_eq!(bare.to_string(), "api.openai.com"); + assert_eq!(ported.to_string(), "api.openai.com:8443"); +} + +#[test] +fn endpoint_hosts_are_normalized_without_losing_the_ip_literal_form() { + let host = oagw::EndpointHost::parse(&ipv4(7)).expect("an IPv4 literal is a valid endpoint host"); + assert_eq!(host.as_str(), ipv4(7)); + let named = oagw::EndpointHost::parse("Upstream.Example.COM."); + assert_eq!(named.expect("an RFC 1123 name is a valid endpoint host").as_str(), "upstream.example.com"); +} diff --git a/gears/system/oagw/oagw/tests/error_mapping_tests.rs b/gears/system/oagw/oagw/tests/error_mapping_tests.rs new file mode 100644 index 0000000..81394fd --- /dev/null +++ b/gears/system/oagw/oagw/tests/error_mapping_tests.rs @@ -0,0 +1,385 @@ +//! Error catalogue and RFC 9457 mapping tests. +//! +//! Covers `cpt-cf-oagw-dod-error-catalogue` and `cpt-cf-oagw-algo-error-mapping`: +//! one row per `ErrorKind` variant carrying its HTTP status and full GTS +//! identifier, the §1.5-added 409 variants, the gateway `application/problem+json` +//! envelope, the upstream passthrough that is never rewritten, and the +//! `Retry-After` rule that fires only for the six retriable rows. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::missing_panics_doc)] + +use axum::http::{StatusCode, header}; +use axum::response::Response; +use oagw::{ + DomainError, ERR_ALIAS_CONFLICT, ERR_AUTH_FAILED, ERR_CIRCUIT_BREAKER_OPEN, + ERR_DOWNSTREAM_ERROR, ERR_INVALID_TARGET_HOST, ERR_LINK_UNAVAILABLE, ERR_MATCH_CONFLICT, + ERR_MISSING_TARGET_HOST, ERR_PAYLOAD_TOO_LARGE, ERR_PLUGIN_IN_USE, ERR_PLUGIN_NOT_FOUND, + ERR_PROTOCOL_ERROR, ERR_RATE_LIMIT_EXCEEDED, ERR_ROUTE_NOT_FOUND, ERR_SECRET_NOT_FOUND, + ERR_STREAM_ABORTED, ERR_TIMEOUT_CONNECTION, ERR_TIMEOUT_IDLE, ERR_TIMEOUT_REQUEST, + ERR_UNKNOWN_TARGET_HOST, ERR_VALIDATION, ErrorContext, ErrorKind, ErrorSource, +}; + +const REQUEST_URI: &str = "/v1/chat/completions"; + +/// `(variant, expected status, expected GTS instance id)` — the DESIGN §3.3 +/// catalogue table, restated. +const CATALOGUE: [(ErrorKind, u16, &str); 22] = [ + (ErrorKind::RouteError, 400, ERR_VALIDATION), + (ErrorKind::ValidationError, 400, ERR_VALIDATION), + (ErrorKind::MissingTargetHost, 400, ERR_MISSING_TARGET_HOST), + (ErrorKind::InvalidTargetHost, 400, ERR_INVALID_TARGET_HOST), + (ErrorKind::UnknownTargetHost, 400, ERR_UNKNOWN_TARGET_HOST), + (ErrorKind::AuthenticationFailed, 401, ERR_AUTH_FAILED), + (ErrorKind::RouteNotFound, 404, ERR_ROUTE_NOT_FOUND), + (ErrorKind::PluginInUse, 409, ERR_PLUGIN_IN_USE), + (ErrorKind::AliasConflict, 409, ERR_ALIAS_CONFLICT), + (ErrorKind::MatchConflict, 409, ERR_MATCH_CONFLICT), + (ErrorKind::PayloadTooLarge, 413, ERR_PAYLOAD_TOO_LARGE), + (ErrorKind::RateLimitExceeded, 429, ERR_RATE_LIMIT_EXCEEDED), + (ErrorKind::SecretNotFound, 500, ERR_SECRET_NOT_FOUND), + (ErrorKind::ProtocolError, 502, ERR_PROTOCOL_ERROR), + (ErrorKind::DownstreamError, 502, ERR_DOWNSTREAM_ERROR), + (ErrorKind::StreamAborted, 502, ERR_STREAM_ABORTED), + (ErrorKind::LinkUnavailable, 503, ERR_LINK_UNAVAILABLE), + (ErrorKind::CircuitBreakerOpen, 503, ERR_CIRCUIT_BREAKER_OPEN), + (ErrorKind::PluginNotFound, 503, ERR_PLUGIN_NOT_FOUND), + (ErrorKind::ConnectionTimeout, 504, ERR_TIMEOUT_CONNECTION), + (ErrorKind::RequestTimeout, 504, ERR_TIMEOUT_REQUEST), + (ErrorKind::IdleTimeout, 504, ERR_TIMEOUT_IDLE), +]; + +/// The six catalogue rows marked `Yes` in the Retriable column. +const RETRIABLE: [ErrorKind; 6] = [ + ErrorKind::RateLimitExceeded, + ErrorKind::LinkUnavailable, + ErrorKind::CircuitBreakerOpen, + ErrorKind::ConnectionTimeout, + ErrorKind::RequestTimeout, + ErrorKind::IdleTimeout, +]; + +fn gateway(kind: ErrorKind) -> DomainError { + DomainError::gateway(kind, "caller detail") +} + +async fn into_response( + response: Response, +) -> (StatusCode, axum::http::HeaderMap, serde_json::Value) { + let (status, headers, bytes) = into_parts(response).await; + ( + status, + headers, + serde_json::from_slice(&bytes).expect("body is JSON"), + ) +} + +/// Collects a response without requiring a JSON body. +async fn into_parts(response: Response) -> (StatusCode, axum::http::HeaderMap, bytes::Bytes) { + let status = response.status(); + let headers = response.headers().clone(); + let bytes = http_body_util::BodyExt::collect(response.into_body()) + .await + .expect("body collects") + .to_bytes(); + (status, headers, bytes) +} + +#[tokio::test] +async fn every_variant_carries_its_catalogue_row() { + for (kind, status, gts_type) in CATALOGUE { + assert_eq!(kind.http_status(), status, "{kind:?} status"); + assert_eq!(kind.gts_type(), gts_type, "{kind:?} GTS type"); + assert_eq!(kind.gts_type().len(), gts_type.len()); + } +} + +#[tokio::test] +async fn every_variant_has_a_stable_title() { + for (kind, _, _) in CATALOGUE { + let title = kind.title(); + assert!(!title.is_empty(), "{kind:?} title must not be empty"); + assert_eq!(kind.to_string(), title, "{kind:?} Display mirrors title"); + } +} + +#[tokio::test] +async fn exactly_the_six_yes_rows_are_retriable() { + for (kind, _, _) in CATALOGUE { + assert_eq!(RETRIABLE.contains(&kind), kind.is_retriable(), "{kind:?}"); + } + assert_eq!(RETRIABLE.len(), 6); +} + +#[tokio::test] +async fn the_two_section_1_5_variants_answer_409() { + assert_eq!(ErrorKind::AliasConflict.http_status(), 409); + assert_eq!(ErrorKind::MatchConflict.http_status(), 409); + assert!(!ErrorKind::AliasConflict.is_retriable()); + assert!(!ErrorKind::MatchConflict.is_retriable()); +} + +#[tokio::test] +async fn downstream_error_is_non_retriable_per_section_1_5() { + assert_eq!(ErrorKind::DownstreamError.http_status(), 502); + assert!(!ErrorKind::DownstreamError.is_retriable()); +} + +#[tokio::test] +async fn gateway_response_is_an_rfc_9457_problem_document() { + let response = + oagw::api::rest::problem::problem_response(&gateway(ErrorKind::RouteNotFound), REQUEST_URI); + let (status, headers, body) = into_response(response).await; + + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!( + headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()), + Some("application/problem+json"), + "gateway failures answer with a problem body" + ); + assert_eq!( + headers + .get("X-OAGW-Error-Source") + .and_then(|v| v.to_str().ok()), + Some("gateway") + ); + assert_eq!(body["type"], ERR_ROUTE_NOT_FOUND); + assert_eq!(body["title"], ErrorKind::RouteNotFound.title()); + assert_eq!(body["status"], 404); + assert_eq!(body["detail"], "caller detail"); + assert_eq!(body["instance"], REQUEST_URI); +} + +#[tokio::test] +async fn gateway_response_is_problem_json_for_every_variant() { + for (kind, _, _) in CATALOGUE { + let response = oagw::api::rest::problem::problem_response(&gateway(kind), REQUEST_URI); + let (status, headers, body) = into_response(response).await; + assert_eq!(status.as_u16(), kind.http_status(), "{kind:?}"); + assert_eq!( + headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()), + Some("application/problem+json"), + "{kind:?} is never rewritten into a generic body" + ); + assert_eq!(body["type"], kind.gts_type(), "{kind:?}"); + assert_eq!(body["status"], kind.http_status(), "{kind:?}"); + } +} + +#[tokio::test] +async fn present_context_members_become_extension_fields() { + let mut error = gateway(ErrorKind::RateLimitExceeded); + error.context = ErrorContext { + upstream_id: Some(uuid::Uuid::nil()), + host: Some(String::from("api.openai.com")), + path: Some(String::from("/v1/chat")), + retry_after_seconds: Some(7), + trace_id: Some(String::from("trace-1")), + }; + + let (_, _, body) = into_response(oagw::api::rest::problem::problem_response( + &error, + REQUEST_URI, + )) + .await; + + assert_eq!(body["upstream_id"], uuid::Uuid::nil().to_string()); + assert_eq!(body["host"], "api.openai.com"); + assert_eq!(body["path"], "/v1/chat"); + assert_eq!(body["retry_after_seconds"], 7); + assert_eq!(body["trace_id"], "trace-1"); +} + +#[tokio::test] +async fn absent_context_members_add_nothing() { + let (_, _, body) = into_response(oagw::api::rest::problem::problem_response( + &gateway(ErrorKind::RouteNotFound), + REQUEST_URI, + )) + .await; + + for field in [ + "upstream_id", + "host", + "path", + "retry_after_seconds", + "trace_id", + ] { + assert!(body.get(field).is_none(), "{field} must be absent"); + } +} + +#[tokio::test] +async fn retry_after_is_emitted_only_for_the_six_yes_rows() { + for kind in RETRIABLE { + let mut error = gateway(kind); + error.context.retry_after_seconds = Some(9); + let (_, headers, _) = into_response(oagw::api::rest::problem::problem_response( + &error, + REQUEST_URI, + )) + .await; + assert_eq!( + headers + .get(header::RETRY_AFTER) + .and_then(|v| v.to_str().ok()), + Some("9"), + "{kind:?} emits Retry-After" + ); + } +} + +#[tokio::test] +async fn retry_after_is_absent_without_a_supplied_delay() { + for kind in RETRIABLE { + let error = gateway(kind); + let (_, headers, _) = into_response(oagw::api::rest::problem::problem_response( + &error, + REQUEST_URI, + )) + .await; + assert!( + headers.get(header::RETRY_AFTER).is_none(), + "{kind:?} must not invent a delay" + ); + } +} + +#[tokio::test] +async fn retry_after_is_never_emitted_for_non_retriable_rows() { + for (kind, _, _) in CATALOGUE { + if kind.is_retriable() { + continue; + } + let mut error = gateway(kind); + error.context.retry_after_seconds = Some(9); + let (_, headers, _) = into_response(oagw::api::rest::problem::problem_response( + &error, + REQUEST_URI, + )) + .await; + assert!( + headers.get(header::RETRY_AFTER).is_none(), + "{kind:?} must never emit Retry-After" + ); + } +} + +#[tokio::test] +async fn downstream_error_never_emits_retry_after() { + let mut error = gateway(ErrorKind::DownstreamError); + error.context.retry_after_seconds = Some(9); + let (_, headers, _) = into_response(oagw::api::rest::problem::problem_response( + &error, + REQUEST_URI, + )) + .await; + assert!(headers.get(header::RETRY_AFTER).is_none()); +} + +#[tokio::test] +async fn upstream_failure_is_passed_through_not_rewritten() { + let body = axum::body::Body::from(String::from("{\"error\":\"upstream says no\"}")); + let response = + oagw::api::rest::problem::passthrough_response(502, body, Some("application/json")); + let (status, headers, parsed) = into_response(response).await; + + assert_eq!(status, StatusCode::BAD_GATEWAY); + assert_eq!( + headers + .get("X-OAGW-Error-Source") + .and_then(|v| v.to_str().ok()), + Some("upstream") + ); + assert_eq!( + headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()), + Some("application/json"), + "the upstream content type is preserved" + ); + assert_eq!(parsed["error"], "upstream says no"); + assert!( + parsed.get("type").is_none() && parsed.get("title").is_none(), + "an upstream failure must never become a problem document" + ); +} + +#[tokio::test] +async fn upstream_passthrough_without_a_content_type_adds_none() { + let response = oagw::api::rest::problem::passthrough_response( + 503, + axum::body::Body::from(String::from("upstream down")), + None, + ); + let (status, headers, _) = into_parts(response).await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert!( + headers.get(header::CONTENT_TYPE).is_none(), + "no problem content type may be invented" + ); +} + +#[tokio::test] +async fn passthrough_never_carries_retry_after() { + let response = + oagw::api::rest::problem::passthrough_response(429, axum::body::Body::empty(), None); + let (_, headers, _) = into_parts(response).await; + assert!(headers.get(header::RETRY_AFTER).is_none()); +} + +#[tokio::test] +async fn the_mapper_adds_nothing_to_detail() { + let detail = "upstream refused"; + let mut error = DomainError::gateway(ErrorKind::SecretNotFound, detail); + error.context = ErrorContext { + trace_id: Some(String::from("trace-1")), + ..ErrorContext::default() + }; + + let (_, _, body) = into_response(oagw::api::rest::problem::problem_response( + &error, + REQUEST_URI, + )) + .await; + + assert_eq!(body["detail"], detail, "detail is passed through verbatim"); + for forbidden in ["cred://", "password", "secret_value", "proxy_timeout_secs"] { + let rendered = body.to_string(); + assert!( + !rendered.contains(forbidden), + "mapper must not inject {forbidden}" + ); + } +} + +#[tokio::test] +async fn domain_error_sources_map_to_their_header_values() { + assert_eq!(ErrorSource::Gateway.as_str(), "gateway"); + assert_eq!(ErrorSource::Upstream.as_str(), "upstream"); + assert_eq!(ErrorSource::Gateway.to_string(), "gateway"); +} + +#[tokio::test] +async fn domain_error_carries_its_row() { + let error = DomainError::upstream(ErrorKind::StreamAborted, "stream cut"); + assert_eq!(error.http_status(), 502); + assert_eq!(error.gts_type(), ERR_STREAM_ABORTED); + assert!(!error.is_retriable()); + assert_eq!(error.source, ErrorSource::Upstream); + assert_eq!(error.to_string(), "Stream aborted: stream cut"); +} + +#[tokio::test] +async fn retry_after_seconds_is_gated_on_the_row() { + let mut retriable = DomainError::gateway(ErrorKind::RequestTimeout, "slow"); + retriable.context.retry_after_seconds = Some(3); + assert_eq!(retriable.retry_after_seconds(), Some(3)); + + let mut plain = DomainError::gateway(ErrorKind::DownstreamError, "boom"); + plain.context.retry_after_seconds = Some(3); + assert_eq!(plain.retry_after_seconds(), None); +} diff --git a/gears/system/oagw/oagw/tests/gear_tests.rs b/gears/system/oagw/oagw/tests/gear_tests.rs new file mode 100644 index 0000000..90e7762 --- /dev/null +++ b/gears/system/oagw/oagw/tests/gear_tests.rs @@ -0,0 +1,397 @@ +//! Gear registration and mount-point tests. +//! +//! Covers `cpt-cf-oagw-dod-gear-registration` and +//! `cpt-cf-oagw-state-gear-foundation-lifecycle` and the management routes of +//! `cpt-cf-oagw-dod-management-routes`: the gear type is `Default`-constructible, +//! its module name is `oagw`, `register_rest` mounts the ten management paths on +//! `/oagw/v1`, and the mounted surface fails closed when the `ClientHub` +//! resolved no `AuthZ` client. +//! +//! `GearCtx` is built by the ToolKit runtime and cannot be constructed inside +//! a crate test (it needs a `CancellationToken` the manifest does not expose), +//! so the assertions here cover the pure surface, the mount point, and the +//! management surface the gear assembles for `register_rest`; the `init` / +//! `post_init` wiring is exercised by the e2e suite. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::missing_panics_doc)] + +// @cpt-dod:cpt-cf-oagw-dod-test-placement:p1 + +use std::sync::Arc; + +use axum::Router; +use oagw::OagwGear; +use oagw::api::rest::MOUNT_POINT; +use tower::ServiceExt; + +#[test] +fn the_gear_is_default_constructible() { + let gear = OagwGear::default(); + assert!(gear.config().is_none(), "no config before init"); + assert!(gear.registry().is_none(), "no registry client before init"); +} + +#[test] +fn the_gear_module_name_is_oagw() { + assert_eq!(OagwGear::MODULE_NAME, "oagw"); +} + +#[test] +fn the_mount_point_is_oagw_v1() { + assert_eq!(MOUNT_POINT, "/oagw/v1"); +} + +#[tokio::test] +async fn the_nested_router_serves_no_oagw_route() { + let app: Router = oagw::api::rest::nest_mount_point(Router::new()); + let request = axum::http::Request::builder() + .method(axum::http::Method::GET) + .uri("/oagw/v1/upstreams") + .body(axum::body::Body::empty()) + .expect("request builds"); + + let response: axum::http::Response = + app.oneshot(request).await.expect("oneshot resolves"); + assert_eq!( + response.status(), + axum::http::StatusCode::NOT_FOUND, + "/oagw/v1 has no routes in the foundation feature" + ); +} + +#[tokio::test] +async fn the_mount_point_is_reachable_not_a_prefix_mismatch() { + let app: Router = oagw::api::rest::nest_mount_point(Router::new()); + let request = axum::http::Request::builder() + .method(axum::http::Method::GET) + .uri("/oagw/other") + .body(axum::body::Body::empty()) + .expect("request builds"); + + let response: axum::http::Response = + app.oneshot(request).await.expect("oneshot resolves"); + assert_eq!( + response.status(), + axum::http::StatusCode::NOT_FOUND, + "a path outside the mount point is not the OAGW surface" + ); +} + +#[test] +fn the_gear_state_machine_refuses_out_of_order_transitions() { + use oagw::GearFoundationState; + + assert!(!GearFoundationState::Unregistered.is_terminal()); + assert!(GearFoundationState::StartupFailed.is_terminal()); + assert!(GearFoundationState::Ready.is_terminal()); + + assert!( + GearFoundationState::Unregistered.can_transition_to(GearFoundationState::Configured), + "unregistered -> configured" + ); + assert!( + !GearFoundationState::Unregistered.can_transition_to(GearFoundationState::Ready), + "readiness requires a provisioned catalogue first" + ); + assert!( + !GearFoundationState::TypeCatalogProvisioned + .can_transition_to(GearFoundationState::Configured), + "lifecycle states never move backwards" + ); +} + +#[test] +fn the_gear_state_machine_walks_the_foundation_lifecycle() { + use oagw::GearFoundationState; + + let state = GearFoundationState::Unregistered + .transition(GearFoundationState::Configured) + .expect("unregistered -> configured"); + let state = state + .transition(GearFoundationState::TypeCatalogProvisioned) + .expect("configured -> type-catalog-provisioned"); + let state = state + .transition(GearFoundationState::Ready) + .expect("type-catalog-provisioned -> ready"); + assert_eq!(state, GearFoundationState::Ready); + + let failed = GearFoundationState::Configured + .transition(GearFoundationState::StartupFailed) + .expect("configured -> startup-failed"); + assert_eq!(failed, GearFoundationState::StartupFailed); +} + +#[test] +fn an_invalid_transition_is_reported_with_both_endpoints() { + use oagw::GearFoundationState; + + let error = GearFoundationState::Ready + .transition(GearFoundationState::Configured) + .expect_err("ready is terminal"); + assert!(error.to_string().contains("ready"), "{error}"); + assert!(error.to_string().contains("configured"), "{error}"); +} + +/// The `AuthZ` PDP the allowing stub stands in for: it grants and narrows the +/// scope to the caller's own tenant. +struct Allowing; + +#[async_trait::async_trait] +impl authz_resolver_sdk::api::AuthZResolverClient for Allowing { + async fn evaluate( + &self, + _request: authz_resolver_sdk::models::EvaluationRequest, + ) -> Result< + authz_resolver_sdk::models::EvaluationResponse, + authz_resolver_sdk::error::AuthZResolverError, + > { + use authz_resolver_sdk::constraints::{Constraint, EqPredicate, Predicate}; + use authz_resolver_sdk::models::{EvaluationResponse, EvaluationResponseContext}; + use toolkit_security::pep_properties; + Ok(EvaluationResponse { + decision: true, + context: EvaluationResponseContext { + constraints: vec![Constraint { + predicates: vec![Predicate::Eq(EqPredicate { + property: String::from(pep_properties::OWNER_TENANT_ID), + value: serde_json::json!(uuid::Uuid::from_u128(0x30).to_string()), + })], + }], + deny_reason: None, + }, + }) + } +} + +/// The authenticated subject a management request carries. +fn subject(tenant: u128) -> toolkit_security::SecurityContext { + toolkit_security::SecurityContext::builder() + .subject_id(uuid::Uuid::from_u128(tenant)) + .subject_tenant_id(uuid::Uuid::from_u128(tenant)) + .build() + .expect("the subject is complete") +} + +/// The router the gear's own assembly mounts, driven over one request. +async fn answer( + app: axum::Router, + method: axum::http::Method, + uri: &str, + tenant: Option, + body: Option, +) -> (axum::http::StatusCode, Option) { + let mut builder = axum::http::Request::builder().method(method).uri(uri); + if let Some(tenant) = tenant { + builder = builder.extension(subject(tenant)); + } + let request = builder + .body(axum::body::Body::from(body.unwrap_or_default())) + .expect("the request builds"); + let response: axum::http::Response = + app.oneshot(request).await.expect("oneshot resolves"); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .expect("the body reads"); + let document = if bytes.is_empty() { + None + } else { + Some(serde_json::from_slice(&bytes).expect("the body is JSON")) + }; + (status, document) +} + +/// A valid upstream body. +fn upstream_body() -> String { + serde_json::json!({ + "server": { + "endpoints": [{ "scheme": "https", "host": "api.openai.com", "port": 443 }] + }, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1" + }) + .to_string() +} + +#[tokio::test] +async fn the_gear_service_answers_the_ten_management_paths() { + let config = oagw::OagwConfig::default(); + let state = oagw::api::rest::state::OagwState::assemble( + &config, + Some(Arc::new(Allowing) as Arc), + None, + None, + ) + .expect("the surface assembles"); + let app = oagw::api::rest::register_management_routes(axum::Router::new(), Arc::new(state)); + + // The create, read, list, replace and delete of each resource kind. + let (status, document) = answer( + app.clone(), + axum::http::Method::POST, + "/oagw/v1/upstreams", + Some(0x30), + Some(upstream_body()), + ) + .await; + assert_eq!(status, axum::http::StatusCode::CREATED, "{document:?}"); + let id = document.expect("the representation")["id"] + .as_str() + .expect("the instance id") + .to_owned(); + + let (status, _) = answer( + app.clone(), + axum::http::Method::GET, + "/oagw/v1/upstreams", + Some(0x30), + None, + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK); + + let (status, _) = answer( + app.clone(), + axum::http::Method::GET, + &format!("/oagw/v1/upstreams/{id}"), + Some(0x30), + None, + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK); + + let (status, _) = answer( + app.clone(), + axum::http::Method::PUT, + &format!("/oagw/v1/upstreams/{id}"), + Some(0x30), + Some(upstream_body()), + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK); + + let (status, _) = answer( + app.clone(), + axum::http::Method::PATCH, + &format!("/oagw/v1/upstreams/{id}"), + Some(0x30), + None, + ) + .await; + assert_eq!( + status, + axum::http::StatusCode::METHOD_NOT_ALLOWED, + "the surface registers exactly the ten paths" + ); + + let (status, _) = answer( + app.clone(), + axum::http::Method::DELETE, + &format!("/oagw/v1/upstreams/{id}"), + Some(0x30), + None, + ) + .await; + assert_eq!(status, axum::http::StatusCode::NO_CONTENT); + + // A route create is validated against the route schema and refused 400 on + // a reference that names no upstream of the calling tenant. + let (status, document) = answer( + app.clone(), + axum::http::Method::POST, + "/oagw/v1/routes", + Some(0x30), + Some( + serde_json::json!({ + "upstream_id": uuid::Uuid::from_u128(0x31).to_string(), + "match": { "http": { "methods": ["GET"], "path": "/v1/chat" } }, + "priority": 1 + }) + .to_string(), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::BAD_REQUEST, "{document:?}"); + assert_eq!( + document.expect("the problem")["detail"], + "upstream_id does not reference an upstream of the calling tenant" + ); + + let (status, _) = answer( + app, + axum::http::Method::GET, + "/oagw/v1/routes", + Some(0x30), + None, + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK); +} + +#[tokio::test] +async fn a_gear_without_an_authz_client_answers_403_everywhere() { + let config = oagw::OagwConfig::default(); + let state = oagw::api::rest::state::OagwState::assemble(&config, None, None, None) + .expect("the surface assembles"); + assert!(state.enforcer().is_none(), "no enforcer without a resolver"); + let app = oagw::api::rest::register_management_routes(axum::Router::new(), Arc::new(state)); + + for (method, uri, body) in [ + ( + axum::http::Method::POST, + "/oagw/v1/upstreams", + Some(upstream_body()), + ), + (axum::http::Method::GET, "/oagw/v1/upstreams", None), + (axum::http::Method::GET, "/oagw/v1/routes", None), + ( + axum::http::Method::PUT, + "/oagw/v1/upstreams/some-id", + Some(upstream_body()), + ), + ( + axum::http::Method::DELETE, + "/oagw/v1/routes/some-id", + None, + ), + ] { + let (status, document) = answer(app.clone(), method, uri, Some(0x30), body).await; + assert_eq!( + status, + axum::http::StatusCode::FORBIDDEN, + "{uri} {document:?}" + ); + } + + // The surface the gear mounts is the only one a caller reaches: the + // foundation mount point without a state answers nothing at all. + let bare: axum::Router = oagw::api::rest::nest_mount_point(axum::Router::new()); + let (status, _) = answer( + bare, + axum::http::Method::GET, + "/oagw/v1/upstreams", + Some(0x30), + None, + ) + .await; + assert_eq!(status, axum::http::StatusCode::NOT_FOUND); +} + +#[test] +fn the_gear_state_is_absent_before_the_rest_surface_is_registered() { + let gear = OagwGear::default(); + assert!(gear.state().is_none(), "no surface before register_rest"); + assert!(gear.config().is_none()); + assert!(gear.registry().is_none()); +} + +#[test] +fn the_enforcer_is_not_constructed_from_a_subject_alone() { + // The security context the handlers read is the platform's; a context with + // no tenant never resolves a calling tenant, and the surface fails closed. + let anonymous = toolkit_security::SecurityContext::anonymous(); + let instance = String::from("/oagw/v1/upstreams"); + let error = oagw::control_plane::scoping::calling_tenant(&anonymous) + .expect_err("an anonymous subject carries no tenant"); + assert_eq!(error.http_status(), 401, "{error}"); + let _ = instance; +} diff --git a/gears/system/oagw/oagw/tests/observability_audit_tests.rs b/gears/system/oagw/oagw/tests/observability_audit_tests.rs new file mode 100644 index 0000000..b77961f --- /dev/null +++ b/gears/system/oagw/oagw/tests/observability_audit_tests.rs @@ -0,0 +1,973 @@ +//! The structured audit records. +//! +//! Covers `cpt-cf-oagw-dod-obs-audit`, `cpt-cf-oagw-dod-obs-redaction`, and +//! `cpt-cf-oagw-dod-obs-sampling` and the audit rows of +//! `cpt-cf-oagw-dod-obs-tests`: the fourteen fields and no fifteenth, the +//! omission of an unpopulated field, the success and the failed record and +//! their levels, the five logged categories and their closed event set, the +//! configuration-change record at the write-completion seam and its absence +//! for a refused write, both redaction rules and the single-name allowlist, +//! and the failure-log bound. Every test owns its sink. + +// @cpt-dod:cpt-cf-oagw-dod-obs-tests:p1 + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::missing_panics_doc)] + +use std::sync::Arc; +use std::time::SystemTime; + +use axum::Router; +use axum::body::Body; +use axum::http::{Method, Request, StatusCode}; +use serde_json::{Value, json}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tower::ServiceExt; +use uuid::Uuid; + +use authz_resolver_sdk::api::AuthZResolverClient; +use authz_resolver_sdk::constraints::{Constraint, EqPredicate, Predicate}; +use authz_resolver_sdk::error::AuthZResolverError; +use authz_resolver_sdk::models::{EvaluationRequest, EvaluationResponse, EvaluationResponseContext}; +use toolkit_security::SecurityContext; +use toolkit_security::pep_properties; + +use oagw::OagwConfig; +use oagw::data_plane::observability::{ + AuditSink, BreakerObservation, CollectingSink, Exchange, Observability, PhaseTimings, +}; +use oagw::domain::error::ErrorKind; +use oagw::domain::ratelimit::{BreakerPhase, BreakerTransition}; +use oagw::domain::observability::{ + AUDIT_EVENTS, AUDIT_FIELDS, AUDIT_LEVELS, AUTH_FAILURE_LOG_LIMIT, CORRELATION_HEADER, + CorrelationContext, CorrelationSource, SamplingDecision, +}; +use oagw::OagwState; + +const HTTP_PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; +const TENANT: u128 = 0x71; + +/// The `AuthZ` PDP the allowing stub stands in for. +struct Allowing; + +#[async_trait::async_trait] +impl AuthZResolverClient for Allowing { + async fn evaluate( + &self, + _request: EvaluationRequest, + ) -> Result { + Ok(EvaluationResponse { + decision: true, + context: EvaluationResponseContext { + constraints: vec![Constraint { + predicates: vec![Predicate::Eq(EqPredicate { + property: String::from(pep_properties::OWNER_TENANT_ID), + value: json!(TENANT.to_string()), + })], + }], + deny_reason: None, + }, + }) + } +} + +/// The `AuthZ` PDP a refusing deployment stands in for: every evaluation is +/// answered with a denial, which is the fail-closed answer a surface without +/// a permissive policy gives. +struct Denying; + +#[async_trait::async_trait] +impl AuthZResolverClient for Denying { + async fn evaluate( + &self, + _request: EvaluationRequest, + ) -> Result { + Ok(EvaluationResponse { + decision: false, + context: EvaluationResponseContext { + constraints: vec![], + deny_reason: None, + }, + }) + } +} + +/// One mounted surface over its own store and its own sink. +struct Surface { + router: Router, + sink: Arc, +} + +/// Builds a surface that writes its records to a collecting sink. +fn surface() -> Surface { + surface_with(Some(Arc::new(Allowing)), None) +} + +/// Builds a surface whose `AuthZ` client answers through `authz` and whose +/// credential store is `cred_store`, writing its records to a collecting sink. +/// +/// A surface built over no credential store serves the unavailable one, so a +/// chain that resolves a credential through it reports unavailability rather +/// than a refusal. +fn surface_with( + authz: Option>, + cred_store: Option>, +) -> Surface { + let config = OagwConfig { + allow_http_upstream: true, + ..OagwConfig::default() + }; + let state = Arc::new( + OagwState::assemble(&config, authz, None, cred_store).expect("the surface assembles"), + ); + let sink = Arc::new(CollectingSink::new()); + state.swap_audit_sink(Arc::clone(&sink) as Arc); + Surface { + router: oagw::api::rest::register_management_routes(Router::new(), state), + sink, + } +} + +/// The authenticated subject a request carries. +fn subject() -> SecurityContext { + SecurityContext::builder() + .subject_id(Uuid::from_u128(TENANT)) + .subject_tenant_id(Uuid::from_u128(TENANT)) + .build() + .expect("the subject is complete") +} + +/// A live HTTP/1.1 upstream, which answers on the port it bound. +#[derive(Clone, Copy)] +struct Upstream { + port: u16, +} + +/// Starts one upstream that answers `ok` on an ephemeral port. +async fn upstream() -> Upstream { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("the listener binds"); + let port = listener.local_addr().expect("the address").port(); + tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + break; + }; + tokio::spawn(async move { + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 4096]; + let head_end = loop { + let Ok(read) = socket.read(&mut chunk).await else { + return; + }; + if read == 0 { + return; + } + buffer.extend_from_slice(&chunk[..read]); + if let Some(index) = buffer.windows(4).position(|w| w == b"\r\n\r\n") { + break index; + } + }; + let head = String::from_utf8_lossy(&buffer[..head_end]).into_owned(); + let length = head + .split("\r\n") + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.trim() + .eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok())? + }) + .unwrap_or(0); + let mut body = buffer[head_end + 4..].to_vec(); + while body.len() < length { + let Ok(read) = socket.read(&mut chunk).await else { + return; + }; + if read == 0 { + break; + } + body.extend_from_slice(&chunk[..read]); + } + let _ = socket + .write_all( + b"HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\n\ + content-length: 2\r\nconnection: close\r\n\r\nok", + ) + .await; + let _ = socket.flush().await; + }); + } + }); + Upstream { port } +} + +/// Stores one upstream and one route over it, both through the management API. +async fn wired(app: &Router, target: &Upstream) { + store_upstream(app, target, None).await; +} + +/// Stores one upstream carrying `plugins` and the auth sub-configuration, and +/// one route over it, both through the management API, answering the upstream's +/// instance key. +async fn store_upstream( + app: &Router, + target: &Upstream, + auth: Option, +) -> String { + let mut body = json!({ + "alias": "127.0.0.1", + "server": { "endpoints": [{ "scheme": "http", "host": "127.0.0.1", "port": target.port }] }, + "protocol": HTTP_PROTOCOL, + "tags": ["proxy"], + "plugins": { "items": [] } + }); + if let Some(auth) = auth { + body["auth"] = auth; + } + let create = Request::builder() + .method(Method::POST) + .uri("/oagw/v1/upstreams") + .extension(subject()) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("the request builds"); + let response = app.clone().oneshot(create).await.expect("oneshot resolves"); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .expect("the body reads"); + assert_eq!(status, StatusCode::CREATED, "{bytes:?}"); + let document: Value = serde_json::from_slice(&bytes).expect("the body is JSON"); + let instance = document["id"].as_str().expect("the instance id"); + let created = oagw::gts::parse_gts_instance(oagw::UPSTREAM_TYPE, instance) + .expect("the instance parses") + .to_string(); + let key = oagw::gts::parse_gts_instance(oagw::UPSTREAM_TYPE, instance) + .expect("the instance parses") + .to_string(); + let route = json!({ + "upstream_id": key, + "match": { "http": { "methods": ["GET"], "path": "/api" } }, + "priority": 10 + }); + let create = Request::builder() + .method(Method::POST) + .uri("/oagw/v1/routes") + .extension(subject()) + .header("content-type", "application/json") + .body(Body::from(route.to_string())) + .expect("the request builds"); + let response = app.clone().oneshot(create).await.expect("oneshot resolves"); + assert_eq!(response.status(), StatusCode::CREATED); + created +} + +/// An empty runtime over a collecting sink, for the record-shape tests. +fn runtime() -> Arc { + Arc::new(Observability::with_sink(Arc::new(CollectingSink::new()))) +} + +/// An empty runtime whose sink the test reads the written records back from. +fn runtime_with_sink() -> (Arc, Arc) { + let sink = Arc::new(CollectingSink::new()); + let observability = Arc::new(Observability::with_sink( + Arc::clone(&sink) as Arc, + )); + (observability, sink) +} + +/// An exchange whose record is the success record the shape tests read. +fn succeeded() -> Exchange { + Exchange { + host: Some(String::from("up.example")), + route: Some(String::from("/api")), + method: String::from("GET"), + status: Some(200), + timings: Some(PhaseTimings::started()), + request_size: 24, + response_size: Some(48), + ..Exchange::default() + } +} + +/// The correlation context the record tests carry, whose roll keeps. +fn correlated() -> CorrelationContext { + CorrelationContext { + request_id: String::from("trace-keep-30"), + source: CorrelationSource::InboundHeader, + tenant_id: Some(Uuid::from_u128(TENANT)), + principal_id: Some(String::from("subject-71")), + sampling: SamplingDecision::Keep, + } +} + +#[test] +fn every_record_carries_only_the_fourteen_field_names() { + assert_eq!(AUDIT_FIELDS.len(), 14); + // The field set is the constant, and no member of it is a fifteenth name. + assert_eq!( + AUDIT_FIELDS, + [ + "timestamp", + "level", + "event", + "request_id", + "tenant_id", + "principal_id", + "host", + "path", + "method", + "status", + "duration_ms", + "request_size", + "response_size", + "error_type" + ] + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_success_record_carries_the_nine_fields_the_design_names() { + let Surface { router, sink } = surface(); + let target = upstream().await; + wired(&router, &target).await; + + let request = Request::builder() + .method(Method::GET) + .uri("/oagw/v1/proxy/127.0.0.1/api/one") + .extension(subject()) + .header(CORRELATION_HEADER, "trace-keep-30") + .body(Body::empty()) + .expect("the request builds"); + let response = router.oneshot(request).await.expect("oneshot resolves"); + assert_eq!(response.status(), StatusCode::OK, "{:?}", sink.records()); + // The transfer ends when the body is read, and the record is written then. + let _ = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .expect("the body reads"); + + let parsed = sink.parsed().expect("every record is one JSON object"); + let record = parsed + .iter() + .find(|record| record["event"] == json!("proxy_request.succeeded")) + .expect("the success record"); + for field in ["timestamp", "level", "event", "request_id", "host", "path", "method", "status", "duration_ms"] { + assert!(record.get(field).is_some(), "{field} is absent from {record}"); + } + assert!(record.get("error_type").is_none(), "{record}"); + assert!(record.get("error_message").is_none(), "{record}"); + assert_eq!(record["level"], json!("INFO"), "{record}"); + assert_eq!(record["tenant_id"], json!(Uuid::from_u128(TENANT).to_string()), "{record}"); + assert_eq!(record["request_size"], json!("0"), "{record}"); + assert_eq!(record["response_size"], json!("2"), "{record}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn one_record_is_written_for_one_proxy_request() { + let Surface { router, sink } = surface(); + for _ in 0..3 { + let request = Request::builder() + .method(Method::GET) + .uri("/oagw/v1/proxy/no-such-upstream/api") + .extension(subject()) + .body(Body::empty()) + .expect("the request builds"); + let response = router.clone().oneshot(request).await.expect("oneshot resolves"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + let records = sink.records(); + assert_eq!(records.len(), 3, "{records:?}"); + // Every line is one JSON object with no interleaved bytes. + assert_eq!(sink.parsed().expect("the records parse").len(), 3); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_failed_request_is_recorded_at_the_level_the_mapping_assigns() { + let Surface { router, sink } = surface(); + // A route the gateway answers 404 on is a failed request at ERROR. + let request = Request::builder() + .method(Method::GET) + .uri("/oagw/v1/proxy/no-such-upstream/api") + .extension(subject()) + .body(Body::empty()) + .expect("the request builds"); + let response = router.oneshot(request).await.expect("oneshot resolves"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let parsed = sink.parsed().expect("the records parse"); + let failed = parsed + .iter() + .find(|record| record["event"] == json!("proxy_request.failed")) + .expect("the failed record"); + assert_eq!(failed["status"], json!("404")); + // A route the gateway never matched is a client error, not a gateway + // failure, so the mapping keeps it at INFO and names the variant it + // answered with. + assert_eq!(failed["level"], json!("INFO"), "{failed}"); + assert_eq!(failed["error_type"], json!("route.not_found"), "{failed}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_upstream_failure_status_is_recorded_at_error() { + let Surface { router, sink } = surface(); + // No listener answers the port, so the forward is refused: the gateway + // answers the caller itself, and the record carries the failure's kind. + let body = json!({ + "alias": "refusing.example", + "server": { "endpoints": [{ "scheme": "http", "host": "127.0.0.1", "port": 1 }] }, + "protocol": HTTP_PROTOCOL + }); + let create = Request::builder() + .method(Method::POST) + .uri("/oagw/v1/upstreams") + .extension(subject()) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("the request builds"); + let response = router.clone().oneshot(create).await.expect("oneshot resolves"); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .expect("the body reads"); + assert_eq!(status, StatusCode::CREATED, "{}", String::from_utf8_lossy(&bytes)); + let document: Value = serde_json::from_slice(&bytes).expect("the body is JSON"); + let key = oagw::gts::parse_gts_instance( + oagw::UPSTREAM_TYPE, + document["id"].as_str().expect("the instance id"), + ) + .expect("the instance parses") + .to_string(); + let route = Request::builder() + .method(Method::POST) + .uri("/oagw/v1/routes") + .extension(subject()) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "upstream_id": key, + "match": { "http": { "methods": ["GET"], "path": "/api" } }, + "priority": 10 + }) + .to_string(), + )) + .expect("the request builds"); + let response = router.clone().oneshot(route).await.expect("oneshot resolves"); + assert_eq!(response.status(), StatusCode::CREATED); + + let request = Request::builder() + .method(Method::GET) + .uri("/oagw/v1/proxy/refusing.example/api") + .extension(subject()) + .body(Body::empty()) + .expect("the request builds"); + let response = router.oneshot(request).await.expect("oneshot resolves"); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE, "{:?}", sink.records()); + let _ = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .expect("the body reads"); + + let parsed = sink.parsed().expect("the records parse"); + let failed = parsed + .iter() + .find(|record| record["event"] == json!("proxy_request.failed")) + .expect("the failed record"); + assert_eq!(failed["level"], json!("ERROR"), "{failed}"); + assert_eq!(failed["status"], json!("503"), "{failed}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_refused_configuration_write_writes_no_record() { + let Surface { router, sink } = surface(); + // A create the validators refuse: the write never completed, so no + // configuration-change record is written. + let request = Request::builder() + .method(Method::POST) + .uri("/oagw/v1/upstreams") + .extension(subject()) + .header("content-type", "application/json") + .body(Body::from(json!({}).to_string())) + .expect("the request builds"); + let response = router.oneshot(request).await.expect("oneshot resolves"); + assert_eq!(response.status(), StatusCode::BAD_REQUEST,); + + let parsed = sink.parsed().expect("the records parse"); + assert!( + parsed.is_empty(), + "a refused write wrote a record: {parsed:?}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_completed_configuration_write_writes_exactly_one_record() { + let Surface { router, sink } = surface(); + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "up.example", "port": 443 }] }, + "protocol": HTTP_PROTOCOL, + "tags": ["llm"] + }); + let request = Request::builder() + .method(Method::POST) + .uri("/oagw/v1/upstreams") + .extension(subject()) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("the request builds"); + let response = router.oneshot(request).await.expect("oneshot resolves"); + assert_eq!(response.status(), StatusCode::CREATED); + + let parsed = sink.parsed().expect("the records parse"); + assert_eq!(parsed.len(), 1, "{parsed:?}"); + let record = &parsed[0]; + assert_eq!(record["level"], json!("INFO")); + assert_eq!(record["event"], json!("config.upstream.created")); + assert_eq!(record["tenant_id"], json!(Uuid::from_u128(TENANT).to_string())); + assert_eq!(record["principal_id"], json!(Uuid::from_u128(TENANT).to_string())); + assert_eq!(record["path"], json!("/oagw/v1/upstreams")); + assert_eq!(record["method"], json!("POST")); + assert_eq!(record["status"], json!("201")); + // No proxy exchange happened, so the four exchange fields are absent. + for field in ["host", "duration_ms", "request_size", "response_size"] { + assert!(record.get(field).is_none(), "{field} is present: {record}"); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_deleted_configuration_write_carries_the_delete_event() { + let Surface { router, sink } = surface(); + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "up.example", "port": 443 }] }, + "protocol": HTTP_PROTOCOL + }); + let create = Request::builder() + .method(Method::POST) + .uri("/oagw/v1/upstreams") + .extension(subject()) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("the request builds"); + let response = router.clone().oneshot(create).await.expect("oneshot resolves"); + let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .expect("the body reads"); + let document: Value = serde_json::from_slice(&bytes).expect("the body is JSON"); + let instance = document["id"].as_str().expect("the instance id").to_owned(); + + sink.parsed().expect("the records parse"); + let before = sink.records().len(); + + let delete = Request::builder() + .method(Method::DELETE) + .uri(format!("/oagw/v1/upstreams/{instance}")) + .extension(subject()) + .body(Body::empty()) + .expect("the request builds"); + let response = router.oneshot(delete).await.expect("oneshot resolves"); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + let parsed = sink.parsed().expect("the records parse"); + assert_eq!(parsed.len(), before + 1); + let record = parsed.last().expect("the delete record"); + assert_eq!(record["event"], json!("config.upstream.deleted")); + assert_eq!(record["method"], json!("DELETE")); + assert_eq!(record["status"], json!("204")); +} + +#[test] +fn the_five_categories_all_produce_a_record() { + let (observability, sink) = runtime_with_sink(); + // A successful proxy request record. + observability.observe(&succeeded(), Some(&correlated())); + // A failed proxy request record. + let refused = Exchange { + error: Some(ErrorKind::RateLimitExceeded), + status: Some(429), + ..succeeded() + }; + observability.observe(&refused, Some(&correlated())); + // A configuration change. + observability.config_change( + oagw::domain::observability::EVENT_UPSTREAM_CREATED, + Some(Uuid::from_u128(TENANT)), + Some(String::from("subject-71")), + "POST", + "/oagw/v1/upstreams", + 201, + ); + // An authentication failure. + let unauthenticated = Exchange { + gateway_answer: true, + authentication_failure: true, + status: Some(401), + ..succeeded() + }; + observability.observe(&unauthenticated, Some(&correlated())); + // A circuit-breaker state transition. + let transitioned = Exchange { + breaker: Some(BreakerObservation { + phase: Some(BreakerPhase::Open), + transitions: vec![BreakerTransition { + from: BreakerPhase::Closed, + to: BreakerPhase::Open, + }], + }), + ..succeeded() + }; + observability.observe(&transitioned, Some(&correlated())); + + // Five categories, six records: the transition record is written in + // addition to the success record the last request produces, never instead + // of it, and every literal a record names is a member of the closed set. + let mut events = sink + .parsed() + .expect("the records parse") + .iter() + .map(|record| record["event"].as_str().expect("an event value").to_string()) + .collect::>(); + events.sort(); + assert_eq!( + events, + [ + String::from(oagw::domain::observability::EVENT_AUTH_FAILED), + String::from(oagw::domain::observability::EVENT_BREAKER_TRANSITIONED), + String::from(oagw::domain::observability::EVENT_UPSTREAM_CREATED), + String::from(oagw::domain::observability::EVENT_REQUEST_FAILED), + String::from(oagw::domain::observability::EVENT_REQUEST_SUCCEEDED), + String::from(oagw::domain::observability::EVENT_REQUEST_SUCCEEDED), + ], + "{:?}", + sink.records() + ); + for record in sink.parsed().expect("the records parse") { + assert!( + AUDIT_EVENTS.contains(&record["event"].as_str().expect("an event value")), + "{}", + record + ); + } +} + +#[test] +fn the_levels_the_mapping_assigns_are_the_four_the_set_holds() { + assert_eq!(AUDIT_LEVELS, ["INFO", "WARN", "ERROR", "DEBUG"]); + // The mapping: WARN for the two refusals, ERROR for the failures. + let (_, warn) = oagw::domain::observability::request_event_of( + true, + Some(ErrorKind::RateLimitExceeded), + ); + assert_eq!(warn, "WARN"); + let (_, breaker) = oagw::domain::observability::request_event_of( + true, + Some(ErrorKind::CircuitBreakerOpen), + ); + assert_eq!(breaker, "WARN"); + let (_, error) = oagw::domain::observability::request_event_of( + true, + Some(ErrorKind::RequestTimeout), + ); + assert_eq!(error, "ERROR"); + let (_, success) = oagw::domain::observability::request_event_of(false, None); + assert_eq!(success, "INFO"); + let (_, plain) = oagw::domain::observability::request_event_of( + true, + Some(ErrorKind::RouteNotFound), + ); + assert_eq!(plain, "INFO"); +} + +#[test] +fn no_record_is_emitted_at_debug() { + // The mapping names no DEBUG level for any outcome, and the record the + // mapping builds carries the level it assigns. + for kind in [ + ErrorKind::RateLimitExceeded, + ErrorKind::CircuitBreakerOpen, + ErrorKind::RequestTimeout, + ErrorKind::RouteNotFound, + ErrorKind::AuthenticationFailed, + ] { + let (_, level) = oagw::domain::observability::request_event_of(true, Some(kind)); + assert_ne!(level, "DEBUG", "{kind:?}"); + } +} + +#[test] +fn the_record_admits_no_body_no_query_and_no_header_value() { + let observability = runtime(); + let mut exchange = succeeded(); + // A request that carried a bearer token, a query string, and a body: none + // of them is a field the fourteen carry, so none can appear. + exchange.request_size = 4096; + exchange.route = Some(String::from("/api")); + observability.observe(&exchange, Some(&correlated())); + let _ = observability.render(); + // The allowlist of §1.5 admits exactly one name. + assert_eq!(CORRELATION_HEADER, "x-request-id"); + assert_eq!(AUDIT_FIELDS.iter().filter(|f| **f == "path").count(), 1); +} + +#[test] +fn no_field_carries_a_credential_value() { + // The `cred://` reference value and the bearer token are neither of them a + // value of any of the fourteen fields, and the correlation header's value + // is the only header value a record may carry. + let (observability, sink) = runtime_with_sink(); + let mut exchange = succeeded(); + exchange.host = Some(String::from("up.example")); + observability.observe(&exchange, Some(&correlated())); + // A correlation context and a host whose values are the two credential + // shapes the rules name: both are redacted out of the record, so neither + // substring reaches a field. + let carrying = CorrelationContext { + request_id: String::from("cred://store/key"), + source: CorrelationSource::InboundHeader, + ..correlated() + }; + let mut holding = succeeded(); + holding.host = Some(String::from("Bearer e2e-token-tenant-a")); + observability.observe(&holding, Some(&carrying)); + + let records = sink.records().join("\n"); + assert!(!records.contains("cred://"), "{}", records); + assert!(!records.contains("Bearer "), "{}", records); +} + +#[test] +fn the_failure_log_bound_drops_the_surplus_and_queues_nothing() { + let observability = runtime(); + let mut written = 0_u32; + for _ in 0..(AUTH_FAILURE_LOG_LIMIT * 3) { + observability.emit( + oagw::domain::observability::AuditEvent { + timestamp: Some(oagw::data_plane::observability::timestamp_of(SystemTime::now())), + level: Some(String::from("ERROR")), + event: Some(String::from(oagw::domain::observability::EVENT_AUTH_FAILED)), + request_id: Some(String::from("trace-keep-30")), + tenant_id: None, + principal_id: None, + host: None, + path: Some(String::from("/oagw/v1/proxy/up.example/api")), + method: Some(String::from("GET")), + status: Some(401), + duration_ms: None, + request_size: None, + response_size: None, + error_type: None, + }, + false, + SamplingDecision::Keep, + ); + if observability.auth_failures_written() > 0 { + written = observability.auth_failures_written(); + } + } + assert!( + written <= AUTH_FAILURE_LOG_LIMIT, + "{written} records were written inside one interval" + ); +} + +#[test] +fn a_field_with_no_value_is_omitted_and_never_null_or_empty() { + let observability = runtime(); + let sink = CollectingSink::new(); + // An event with only the fields the caller populated: the rest are absent. + observability.emit( + oagw::domain::observability::AuditEvent { + timestamp: Some(String::from("2026-09-07T10:00:00Z")), + level: Some(String::from("INFO")), + event: Some(String::from(oagw::domain::observability::EVENT_UPSTREAM_CREATED)), + request_id: None, + tenant_id: Some(TENANT.to_string()), + principal_id: Some(String::from("subject-71")), + host: None, + path: Some(String::from("/oagw/v1/upstreams")), + method: Some(String::from("POST")), + status: Some(201), + duration_ms: None, + request_size: None, + response_size: None, + error_type: None, + }, + false, + SamplingDecision::Keep, + ); + let _ = sink; + // A populated field is never written empty. + let event = oagw::domain::observability::AuditEvent { + timestamp: Some(String::from("2026-09-07T10:00:00Z")), + ..oagw::domain::observability::AuditEvent::default() + }; + let populated = event.populated(); + assert!(populated.iter().all(|(_, value)| !value.is_empty())); +} + +#[test] +fn the_audit_sink_is_the_one_seam_the_records_are_written_through() { + // A sink a test owns receives exactly the records the emitter writes, in + // the order it wrote them. + struct Counting { + written: std::sync::atomic::AtomicUsize, + } + impl AuditSink for Counting { + fn write(&self, _record: &str) { + self.written + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + } + let sink = Arc::new(Counting { + written: std::sync::atomic::AtomicUsize::new(0), + }); + let observability = Observability::with_sink(Arc::clone(&sink) as Arc); + observability.observe(&succeeded(), Some(&correlated())); + assert_eq!(sink.written.load(std::sync::atomic::Ordering::SeqCst), 1); +} + +#[test] +fn records_of_helper_is_not_a_second_serialization_path() { + // The helper a suite reads through is the sink's own parse, and no second + // serialization of a record exists in this feature. + let sink = CollectingSink::new(); + assert!(sink.records().is_empty()); + assert!(sink.parsed().expect("an empty sink parses").is_empty()); + let _ = &sink; +} + +#[test] +fn a_breaker_transition_and_a_configuration_change_are_never_sampled() { + // A circuit-breaker transition is an event an operator must see and a + // configuration change is by definition not high-volume, so both reach the + // sink unsampled and unbound: even a record that arrives classified as + // high-volume with a decision not to sample is still written. + let sink = Arc::new(CollectingSink::new()); + let observability = Arc::new(Observability::with_sink(sink.clone())); + observability.emit( + oagw::domain::observability::AuditEvent { + timestamp: Some(String::from("2026-09-07T10:00:00Z")), + level: Some(String::from("WARN")), + event: Some(String::from( + oagw::domain::observability::EVENT_BREAKER_TRANSITIONED, + )), + request_id: Some(String::from("trace-drop-0")), + tenant_id: Some(Uuid::from_u128(TENANT).to_string()), + principal_id: None, + host: Some(String::from("up.example")), + path: Some(String::from("/api")), + method: Some(String::from("GET")), + status: Some(503), + duration_ms: Some(12), + request_size: Some(24), + response_size: Some(48), + error_type: None, + }, + true, + SamplingDecision::Drop, + ); + observability.config_change( + oagw::domain::observability::EVENT_UPSTREAM_CREATED, + Some(Uuid::from_u128(TENANT)), + Some(String::from("subject-71")), + "POST", + "/oagw/v1/upstreams", + 201, + ); + + let records = sink.records(); + assert_eq!(records.len(), 2, "{records:?}"); + let parsed = sink.parsed().expect("both lines parse"); + assert_eq!(parsed.len(), 2, "{parsed:?}"); + let events: Vec<&str> = parsed + .iter() + .map(|record| record["event"].as_str().expect("the event is a string")) + .collect(); + assert_eq!(events[0], oagw::domain::observability::EVENT_BREAKER_TRANSITIONED); + assert_eq!(events[1], oagw::domain::observability::EVENT_UPSTREAM_CREATED); +} + +/// The answers a served request whose identity or whose permission the gateway +/// could not establish produces, recorded through the sink the surface owns. +/// +/// Covers the refusal branches of `cpt-cf-oagw-flow-proxy-authorize` the served +/// composition can reach: the request that carries no subject and the request +/// the enforcer denies, both answered without contacting an upstream, and the +/// bound `auth.failed` records stay within the failure-log limit §1.5 sets. +mod served_refusals { + #![allow(clippy::expect_used, clippy::unwrap_used)] + use super::*; + + /// Issues one proxy request with the extensions the caller states. + async fn issue(router: &Router, extension: Option) -> StatusCode { + let mut builder = Request::builder() + .method(Method::GET) + .uri("/oagw/v1/proxy/no-such-upstream/api"); + if let Some(extension) = extension { + builder = builder.extension(extension); + } + let request = builder + .body(Body::empty()) + .expect("the request builds"); + let response = router + .clone() + .oneshot(request) + .await + .expect("oneshot resolves"); + response.status() + } + + #[tokio::test(flavor = "multi_thread")] + async fn an_unauthenticated_request_is_recorded_as_the_authentication_failure() { + let Surface { router, sink } = surface_with(None, None); + let status = issue(&router, None).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "{:?}", sink.records()); + + let parsed = sink.parsed().expect("the records parse"); + assert_eq!(parsed.len(), 1, "{:?}", sink.records()); + let record = &parsed[0]; + assert_eq!( + record["event"], + json!(oagw::domain::observability::EVENT_AUTH_FAILED) + ); + assert_eq!(record["level"], json!("ERROR")); + assert_eq!(record["status"], json!("401")); + assert_eq!(record["method"], json!("GET")); + // The identifier the request carried is the generated one, and no + // route matched, so no `path` is carried. + assert!(record["request_id"].is_string(), "{record}"); + assert!(record.get("path").is_none(), "no route matched: {record}"); + assert_eq!(record["error_type"], json!("auth.failed"), "{record}"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn a_request_the_enforcer_refuses_is_recorded_as_the_authentication_failure() { + let Surface { router, sink } = + surface_with(Some(Arc::new(Denying)), None); + let status = issue(&router, Some(subject())).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{:?}", sink.records()); + + let parsed = sink.parsed().expect("the records parse"); + assert_eq!(parsed.len(), 1, "{:?}", sink.records()); + let record = &parsed[0]; + assert_eq!( + record["event"], + json!(oagw::domain::observability::EVENT_AUTH_FAILED) + ); + assert_eq!(record["level"], json!("ERROR")); + assert_eq!(record["status"], json!("403")); + assert!(record["principal_id"].is_string(), "{record}"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn a_flood_of_authentication_failures_is_bounded_through_the_served_path() { + let Surface { router, sink } = surface_with(None, None); + for _ in 0..(AUTH_FAILURE_LOG_LIMIT * 3) { + let status = issue(&router, None).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + } + let written = sink.records().len(); + assert!( + written <= AUTH_FAILURE_LOG_LIMIT as usize, + "{written} records were written for {} refusals", + AUTH_FAILURE_LOG_LIMIT * 3 + ); + assert!(written > 0, "the first refusal is still written"); + } +} diff --git a/gears/system/oagw/oagw/tests/observability_correlation_tests.rs b/gears/system/oagw/oagw/tests/observability_correlation_tests.rs new file mode 100644 index 0000000..8085ca8 --- /dev/null +++ b/gears/system/oagw/oagw/tests/observability_correlation_tests.rs @@ -0,0 +1,675 @@ +//! The correlation identifier and its propagation. +//! +//! Covers `cpt-cf-oagw-dod-obs-correlation` and the correlation rows of +//! `cpt-cf-oagw-dod-obs-tests`: the assignment from the inbound header and +//! from the generator, each negative of the admission check, the propagation +//! of the identifier to a record and to a gateway error body's `trace_id`, +//! the absence of the echo on an answer the upstream produced, the declaration +//! of `CorrelationContext` as a member of `ProxyContext` over the types its +//! owning features declared, and the two build-time constants' freedom from +//! any configuration surface. The upstream is a live local listener, so the +//! pass-through answer is a real one; the audit sink is the test's own, so no +//! assertion reads another suite's stream. + +// @cpt-dod:cpt-cf-oagw-dod-obs-tests:p1 + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::missing_panics_doc)] + +use std::sync::Arc; + +use axum::Router; +use axum::body::Body; +use axum::http::{HeaderName, Method, Request, StatusCode}; +use serde_json::{Value, json}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tower::ServiceExt; +use uuid::Uuid; + +use authz_resolver_sdk::api::AuthZResolverClient; +use authz_resolver_sdk::constraints::{Constraint, EqPredicate, Predicate}; +use authz_resolver_sdk::error::AuthZResolverError; +use authz_resolver_sdk::models::{EvaluationRequest, EvaluationResponse, EvaluationResponseContext}; +use authz_resolver_sdk::pep::PolicyEnforcer; +use toolkit_security::SecurityContext; +use toolkit_security::pep_properties; + +use oagw::OagwConfig; +use oagw::control_plane::cache::ControlPlaneCache; +use oagw::control_plane::service::ManagementService; +use oagw::data_plane::observability::{CollectingSink, Exchange, Observability}; +use oagw::domain::observability::{ + AUTH_FAILURE_LOG_INTERVAL_MS, AUTH_FAILURE_LOG_LIMIT, AUDIT_EVENTS, CORRELATION_HEADER, + CORRELATION_MAX_LEN, CorrelationContext, CorrelationSource, HIGH_VOLUME_SAMPLE_ONE_IN, + SamplingDecision, +}; +use oagw::domain::proxy::ProxyContext; +use oagw::OagwState; + +const HTTP_PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; +const TENANT: u128 = 0x51; +const HOST: &str = "127.0.0.1"; + +/// The `AuthZ` PDP the allowing stub stands in for. +struct Allowing; + +#[async_trait::async_trait] +impl AuthZResolverClient for Allowing { + async fn evaluate( + &self, + _request: EvaluationRequest, + ) -> Result { + Ok(EvaluationResponse { + decision: true, + context: EvaluationResponseContext { + constraints: vec![Constraint { + predicates: vec![Predicate::Eq(EqPredicate { + property: String::from(pep_properties::OWNER_TENANT_ID), + value: json!(TENANT.to_string()), + })], + }], + deny_reason: None, + }, + }) + } +} + +/// A live HTTP/1.1 upstream, which answers on the port it bound. +#[derive(Clone)] +struct Upstream { + port: u16, +} + +/// Starts one echo upstream on an ephemeral port. +async fn upstream() -> Upstream { + let listener = TcpListener::bind((HOST, 0)).await.expect("the listener binds"); + let port = listener.local_addr().expect("the address").port(); + tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + break; + }; + tokio::spawn(async move { + // The head is read to its terminator and the declared body + // after it, so the gateway's own framing is consumed before + // the answer is written. + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 4096]; + let head_end = loop { + let Ok(read) = socket.read(&mut chunk).await else { + return; + }; + if read == 0 { + return; + } + buffer.extend_from_slice(&chunk[..read]); + if let Some(index) = find_head_end(&buffer) { + break index; + } + }; + let head = String::from_utf8_lossy(&buffer[..head_end]).into_owned(); + let length = head + .split("\r\n") + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.trim() + .eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok())? + }) + .unwrap_or(0); + let mut body = buffer[head_end + 4..].to_vec(); + while body.len() < length { + let Ok(read) = socket.read(&mut chunk).await else { + return; + }; + if read == 0 { + break; + } + body.extend_from_slice(&chunk[..read]); + } + let _ = AsyncWriteExt::write_all( + &mut socket, + b"HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\n\ + content-length: 2\r\nconnection: close\r\n\r\nok", + ) + .await; + let _ = socket.flush().await; + }); + } + }); + Upstream { port } +} + +/// The index the head's terminator starts at. +fn find_head_end(buffer: &[u8]) -> Option { + buffer.windows(4).position(|window| window == b"\r\n\r\n") +} + +/// One mounted surface over its own store, with its own observation seam. +struct Surface { + router: Router, + sink: Arc, +} + +/// Builds a surface whose observation seam writes to a collecting sink. +fn surface() -> Surface { + let store = Arc::new(oagw::store::OagwStore::new()); + let cache = Arc::new(ControlPlaneCache::new()); + let config = OagwConfig { + allow_http_upstream: true, + ..OagwConfig::default() + }; + let service = Arc::new( + ManagementService::new(Arc::clone(&store), &config, Arc::clone(&cache)) + .expect("the validators compile"), + ); + let state = Arc::new(OagwState::new( + Arc::new(config), + Arc::clone(&store), + service, + Some(Arc::new(PolicyEnforcer::new(Arc::new(Allowing)))), + None, + Arc::clone(&cache), + )); + let sink = Arc::new(CollectingSink::new()); + state.swap_audit_sink(Arc::clone(&sink) as Arc); + Surface { + router: oagw::api::rest::register_management_routes(Router::new(), Arc::clone(&state)), + sink, + } +} + +/// The authenticated subject a request carries. +fn subject() -> SecurityContext { + SecurityContext::builder() + .subject_id(Uuid::from_u128(TENANT)) + .subject_tenant_id(Uuid::from_u128(TENANT)) + .build() + .expect("the subject is complete") +} + +/// The request path of a URI, without the query a problem document never echoes. +fn path_of(uri: &str) -> &str { + uri.split('?').next().expect("the path") +} + +/// Issues one request and returns the status and the parsed body. +async fn issue( + app: Router, + method: Method, + uri: &str, + authenticated: bool, + headers: &[(&str, &str)], +) -> (StatusCode, Value) { + let mut builder = Request::builder().method(method).uri(uri); + if authenticated { + builder = builder.extension(subject()); + } + for (name, value) in headers { + builder = builder.header(*name, *value); + } + let request = builder.body(Body::empty()).expect("the request builds"); + let response = app.oneshot(request).await.expect("oneshot resolves"); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .expect("the body reads"); + let document = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).expect("the answer body is JSON") + }; + (status, document) +} + +/// Issues one proxy request and returns the status and the body as text. +async fn issue_raw( + app: Router, + uri: &str, + headers: &[(&str, &str)], +) -> (StatusCode, String) { + let mut builder = Request::builder().method(Method::GET).uri(uri).extension(subject()); + for (name, value) in headers { + builder = builder.header(*name, *value); + } + let request = builder.body(Body::empty()).expect("the request builds"); + let response = app.oneshot(request).await.expect("oneshot resolves"); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .expect("the body reads"); + (status, String::from_utf8_lossy(&bytes).into_owned()) +} + +/// Stores one upstream and one route over it, both through the management API. +async fn wired(app: &Router, target: &Upstream) { + let body = json!({ + "alias": HOST, + "server": { "endpoints": [{ "scheme": "http", "host": HOST, "port": target.port }] }, + "protocol": HTTP_PROTOCOL, + "tags": ["proxy"] + }); + let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/oagw/v1/upstreams") + .extension(subject()) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("the request builds"), + ) + .await + .expect("oneshot resolves"); + let response_status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .expect("the body reads"); + let document: Value = serde_json::from_slice(&bytes).expect("the body is JSON"); + assert_eq!(response_status, StatusCode::CREATED, "{document}"); + let instance = document["id"].as_str().expect("the instance id"); + let key = oagw::gts::parse_gts_instance(oagw::UPSTREAM_TYPE, instance) + .expect("the instance parses") + .to_string(); + // The second route names a single resource, so its success record is the + // one the sampling ratio does not govern: it is the route the tests that + // read a success record drive, whatever identifier the request carries. + for route in [ + json!({ + "upstream_id": key, + "match": { "http": { "methods": ["GET"], "path": "/api" } }, + "priority": 10 + }), + json!({ + "upstream_id": key, + "match": { "http": { "methods": ["GET"], "path": "/api/{id}" } }, + "priority": 5 + }), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/oagw/v1/routes") + .extension(subject()) + .header("content-type", "application/json") + .body(Body::from(route.to_string())) + .expect("the request builds"), + ) + .await + .expect("oneshot resolves"); + assert_eq!(response.status(), StatusCode::CREATED); + } +} + +/// The alias path of the single-resource route the `wired` helper declares. +fn single_resource_path() -> String { + String::from("/oagw/v1/proxy/127.0.0.1/api/{id}/one") +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_bounded_printable_header_value_is_adopted_and_recorded() { + let app = surface(); + let target = upstream().await; + wired(&app.router, &target).await; + + let (status, body) = issue_raw( + app.router.clone(), + "/oagw/v1/proxy/127.0.0.1/api/one", + &[(CORRELATION_HEADER, "trace-keep-30")], + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body, "ok", "the upstream body passed through"); + + let records = app.sink.parsed().expect("every record is one JSON object"); + let succeeded = records + .iter() + .find(|record| record["event"] == json!("proxy_request.succeeded")) + .expect("the success record was written"); + assert_eq!( + succeeded["request_id"], + json!("trace-keep-30"), + "the header value is the record's request_id" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_request_without_the_header_is_recorded_with_a_generated_uuid() { + let app = surface(); + let target = upstream().await; + wired(&app.router, &target).await; + + let (status, body) = issue_raw( + app.router.clone(), + &single_resource_path(), + &[], + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + + let records = app.sink.parsed().expect("the records parse"); + let succeeded = records + .iter() + .find(|record| record["event"] == json!("proxy_request.succeeded")) + .expect("the success record was written"); + let request_id = succeeded["request_id"].as_str().expect("the identifier"); + assert!(Uuid::parse_str(request_id).is_ok(), "{request_id} is not a UUID"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_context_records_where_the_identifier_came_from() { + // The adopted branch. + let adopted = CorrelationContext::assign(Some("trace-adopted"), None, None); + assert_eq!(adopted.request_id, "trace-adopted"); + assert_eq!(adopted.source, CorrelationSource::InboundHeader); + // The generated branch: no header at all. + let generated = CorrelationContext::assign(None, None, None); + assert_ne!(generated.request_id, ""); + assert_eq!(generated.source, CorrelationSource::Generated); + // The generated branch: a value the admission check refuses. + let refused = CorrelationContext::assign(Some("bad value\u{0007}"), None, None); + assert_eq!(refused.source, CorrelationSource::Generated); + assert_ne!(refused.request_id, "bad value\u{0007}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn every_negative_of_the_admission_check_generates_instead() { + // A control character. + let control = CorrelationContext::assign(Some("trailing\u{0003}"), None, None); + assert_eq!(control.source, CorrelationSource::Generated); + // A value beyond the bounded length. + let long = CorrelationContext::assign(Some(&"a".repeat(CORRELATION_MAX_LEN + 1)), None, None); + assert_eq!(long.source, CorrelationSource::Generated); + // The bound itself is admitted. + let bounded = CorrelationContext::assign(Some(&"a".repeat(CORRELATION_MAX_LEN)), None, None); + assert_eq!(bounded.source, CorrelationSource::InboundHeader); + // A value that carries no printable identifier at all. + let empty = CorrelationContext::assign(Some(""), None, None); + assert_eq!(empty.source, CorrelationSource::Generated); + // A value carrying a character outside the printable range. + let escape = CorrelationContext::assign(Some("trace\u{0085}value"), None, None); + assert_eq!(escape.source, CorrelationSource::Generated); +} + +#[tokio::test(flavor = "multi_thread")] +async fn every_proxy_record_carries_the_correlation_identifier() { + // The gateway-refused path: no alias matches, the answer is a 404 problem. + let app = surface(); + let (status, _body) = issue( + app.router.clone(), + Method::GET, + "/oagw/v1/proxy/no-such-upstream/api", + true, + &[], + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + + let records = app.sink.parsed().expect("the records parse"); + let written = records + .iter() + .filter(|record| AUDIT_EVENTS.contains(&record["event"].as_str().unwrap_or_default())) + .count(); + let carried = records + .iter() + .filter(|record| record["request_id"].is_string()) + .count(); + assert_eq!(written, carried, "every record carries a request_id"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_gateway_error_body_echoes_the_correlation_identifier_as_trace_id() { + let app = surface(); + let (status, body) = issue( + app.router.clone(), + Method::GET, + "/oagw/v1/proxy/no-such-upstream/api", + true, + &[(CORRELATION_HEADER, "trace-echoed")], + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!( + body["trace_id"], json!("trace-echoed"), + "the error mapping attached the correlation identifier as trace_id" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_upstream_answer_passes_through_without_a_trace_id_echo() { + let app = surface(); + let target = upstream().await; + wired(&app.router, &target).await; + + let (status, body) = issue_raw( + app.router.clone(), + &single_resource_path(), + &[(CORRELATION_HEADER, "trace-no-echo")], + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body, "ok"); + assert!( + !body.contains("trace_id") && !body.contains("trace-no-echo"), + "the upstream answer echoes no correlation identifier" + ); + + // The success record still carries the identifier, and the answer carried + // no `trace_id`: the body the upstream produced is the body the caller + // received, which the exposition of the exchange records as two bytes of + // `ok` and nothing else. + let records = app.sink.parsed().expect("the records parse"); + let succeeded = records + .iter() + .find(|record| record["event"] == json!("proxy_request.succeeded")) + .expect("the success record was written"); + assert_eq!(succeeded["request_id"], json!("trace-no-echo")); + assert_eq!( + succeeded["response_size"], + json!("2"), + "the upstream body passed through" + ); + assert_eq!(succeeded["status"], json!("200")); + assert!(succeeded.get("error_type").is_none()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_high_volume_route_samples_its_success_records_at_the_ratio() { + let app = surface(); + let target = upstream().await; + wired(&app.router, &target).await; + + // The route the suite declares names a collection, so its success record + // is the one the ratio governs: an identifier whose roll refuses is + // recorded in no series of the family the seam counts and in no record, + // while the exchange itself is still answered. + let (status, body) = issue_raw( + app.router.clone(), + "/oagw/v1/proxy/127.0.0.1/api/one", + &[(CORRELATION_HEADER, "trace-drop-0")], + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + + let records = app.sink.parsed().expect("the records parse"); + let succeeded = records + .iter() + .filter(|record| record["event"] == json!("proxy_request.succeeded")) + .count(); + assert_eq!(succeeded, 0, "the sampled-out success record is not written"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_failed_request_is_never_sampled() { + let app = surface(); + // The 404 the unmatched alias answers with is a failed request, which is + // never subject to the ratio whatever identifier it carries. + for identifier in ["trace-drop-0", "trace-keep-52"] { + let (status, _) = issue( + app.router.clone(), + Method::GET, + "/oagw/v1/proxy/no-such-upstream/api", + true, + &[(CORRELATION_HEADER, identifier)], + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + } + let records = app.sink.parsed().expect("the records parse"); + let failed = records + .iter() + .filter(|record| record["event"] == json!("proxy_request.failed")) + .count(); + assert_eq!(failed, 2, "every failed request is recorded"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_gateway_error_echo_is_absent_when_no_header_arrived() { + let app = surface(); + let (status, body) = issue( + app.router.clone(), + Method::GET, + "/oagw/v1/proxy/no-such-upstream/api", + true, + &[], + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + let trace = body["trace_id"].as_str().expect("the echo is present"); + assert!(Uuid::parse_str(trace).is_ok(), "{trace} is the generated identifier"); +} + +#[test] +fn the_correlation_context_is_a_member_of_the_proxy_context() { + // The declaration is checked at compile time: the member's type is the + // type this feature declared, and no second declaration of it exists. + let context = CorrelationContext::assign(Some("trace-member"), None, None); + let proxy = ProxyContext { + correlation: Some(context), + ..ProxyContext::default() + }; + assert_eq!( + proxy.correlation.as_ref().map(|carried| carried.request_id.as_str()), + Some("trace-member") + ); +} + +#[test] +fn the_consumed_types_are_the_ones_their_owning_features_declared() { + // `ProxyContext`, `ResolvedUpstream`, `ProxyResponse`, and `ErrorContext` + // are consumed from their owning features and not redeclared: the type + // paths this feature's routines name are the paths those features + // published. + let proxy: ProxyContext = ProxyContext::default(); + assert!(proxy.correlation.is_none()); + let resolved = oagw::domain::proxy::ResolvedUpstream { + tenant_id: Uuid::from_u128(TENANT), + upstream_id: Uuid::nil(), + alias: String::from(HOST), + alias_derivation: oagw::domain::proxy::AliasDerivation::Explicit, + endpoints: Vec::new(), + protocol: String::from("cf.core.oagw.http.v1"), + enabled: true, + headers: oagw::domain::upstream::HeadersConfig::default(), + rate_limit: None, + plugins: None, + cors: None, + route_candidates: Vec::new(), + }; + assert!(resolved.upstream_id.is_nil()); + let response = oagw::domain::proxy::ProxyResponse::upstream(200, Vec::new(), Vec::new()); + assert_eq!(response.status, 200); + let context = oagw::domain::error::ErrorContext::default(); + assert!(context.trace_id.is_none()); +} + +#[test] +fn the_exchange_that_the_path_hands_the_seam_carries_the_context() { + // The exit of the path reads the correlation context the entry assigned, + // and the record it writes names it. + let observability = Observability::with_sink(Arc::new(CollectingSink::new())); + let correlation = CorrelationContext::assign( + Some("trace-exchange"), + Some(Uuid::from_u128(TENANT)), + Some(String::from("subject-51")), + ); + let exchange = Exchange { + host: Some(String::from(HOST)), + route: Some(String::from("/api")), + method: String::from("GET"), + status: Some(200), + ..Exchange::default() + }; + observability.observe(&exchange, Some(&correlation)); + + let records = observability + .registry() + .render(); + assert!(records.contains("oagw_requests_total"), "{records}"); +} + +#[test] +fn the_sampling_ratio_is_a_build_time_constant_with_no_configuration_surface() { + assert_eq!(HIGH_VOLUME_SAMPLE_ONE_IN, 100); + // No key of `OagwConfig` names either constant: the configuration surface + // the gear compiles carries no key a document could change them with. + let config = OagwConfig::default(); + let keys: Vec = serde_json::to_value(config) + .expect("the configuration serializes") + .as_object() + .expect("the configuration is an object") + .keys() + .cloned() + .collect(); + for key in keys { + let lowered = key.to_ascii_lowercase(); + assert!( + !lowered.contains("sample") && !lowered.contains("flood") && !lowered.contains("log"), + "{key} is a configuration surface for a build-time constant" + ); + } +} + +#[test] +fn the_failure_log_bound_is_a_build_time_constant_with_no_configuration_surface() { + assert_eq!(AUTH_FAILURE_LOG_LIMIT, 20); + assert_eq!(AUTH_FAILURE_LOG_INTERVAL_MS, 1_000); + assert_ne!(SamplingDecision::Keep, SamplingDecision::Drop); + // The correlation header name is the one name §1.5 allowlists. + assert_eq!(CORRELATION_HEADER, "x-request-id"); +} + +#[test] +fn the_header_name_is_read_case_insensitively_off_the_inbound_pairs() { + let headers = vec![ + (String::from("Content-Type"), String::from("application/json")), + (String::from("X-REQUEST-ID"), String::from("trace-upper")), + ]; + let read = oagw::data_plane::observability::correlation_header(&headers); + assert_eq!(read, Some("trace-upper")); +} + +#[test] +fn a_route_that_is_not_high_volume_is_never_sampled() { + let decision = CorrelationContext::sampling_of("trace-plain"); + let _ = decision; + // The classification is a property of the pattern, not of the caller: a + // pattern that names a single resource — a parameter segment the route + // declares — is the one that is never sampled, and a pattern that names a + // collection is the one the ratio is stated over. + assert!(oagw::domain::observability::is_high_volume_pattern("/api")); + assert!(!oagw::domain::observability::is_high_volume_pattern("/v1/things/{id}")); +} + +#[test] +fn the_header_the_platform_injects_is_the_one_name_the_allowlist_admits() { + let name = HeaderName::from_bytes(CORRELATION_HEADER.as_bytes()) + .expect("the correlation header name is a legal header name") + .as_str() + .to_owned(); + assert_eq!(name, CORRELATION_HEADER); + assert_eq!(path_of("/oagw/v1/proxy/a/api?model=1"), "/oagw/v1/proxy/a/api"); +} diff --git a/gears/system/oagw/oagw/tests/observability_metrics_tests.rs b/gears/system/oagw/oagw/tests/observability_metrics_tests.rs new file mode 100644 index 0000000..8dccf2d --- /dev/null +++ b/gears/system/oagw/oagw/tests/observability_metrics_tests.rs @@ -0,0 +1,764 @@ +//! The metrics surface and the twelve families. +//! +//! Covers `cpt-cf-oagw-dod-obs-metrics` and `cpt-cf-oagw-dod-obs-cardinality` +//! and the metrics rows of `cpt-cf-oagw-dod-obs-tests`: the registration of +//! the one path, its 401 and 403 and 200, the `# HELP` and `# TYPE` lines of +//! every family, the twelve histogram buckets with their `_sum` and `_count` +//! series, every label set DESIGN §4.2 enumerates, the closed label values of +//! `phase`, `error_type`, `selection_method`, and the method normalization, +//! and the in-flight gauge's raise and lower. Each test owns its registry, so +//! no test observes another's series. + +// @cpt-dod:cpt-cf-oagw-dod-obs-tests:p1 + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::missing_panics_doc)] + +use std::sync::Arc; + +use axum::Router; +use axum::body::Body; +use axum::http::{Method, Request, StatusCode}; +use serde_json::json; +use tower::ServiceExt; +use uuid::Uuid; + +use authz_resolver_sdk::api::AuthZResolverClient; +use authz_resolver_sdk::constraints::{Constraint, EqPredicate, Predicate}; +use authz_resolver_sdk::error::AuthZResolverError; +use authz_resolver_sdk::models::{EvaluationRequest, EvaluationResponse, EvaluationResponseContext}; +use authz_resolver_sdk::pep::PolicyEnforcer; +use toolkit_security::SecurityContext; +use toolkit_security::pep_properties; + +use oagw::OagwConfig; +use oagw::control_plane::cache::ControlPlaneCache; +use oagw::control_plane::service::ManagementService; +use oagw::data_plane::observability::{ + BreakerObservation, CollectingSink, EndpointObservation, Exchange, Observability, + PhaseTimings, RateLimitObservation, +}; +use oagw::domain::observability::{ + AUDIT_EVENTS, HISTOGRAM_BUCKETS, MetricLabelSet, +}; +use oagw::domain::ratelimit::BreakerPhase; +use oagw::OagwState; + +const METRICS_TYPE: &str = "gts.cf.core.oagw.metrics.v1~"; +const TENANT: u128 = 0x61; + +/// The `AuthZ` PDP the allowing stub stands in for. +struct Allowing; + +#[async_trait::async_trait] +impl AuthZResolverClient for Allowing { + async fn evaluate( + &self, + _request: EvaluationRequest, + ) -> Result { + Ok(EvaluationResponse { + decision: true, + context: EvaluationResponseContext { + constraints: vec![Constraint { + predicates: vec![Predicate::Eq(EqPredicate { + property: String::from(pep_properties::OWNER_TENANT_ID), + value: json!(TENANT.to_string()), + })], + }], + deny_reason: None, + }, + }) + } +} + +/// The `AuthZ` PDP the refusing stub stands in for. +struct Denying; + +#[async_trait::async_trait] +impl AuthZResolverClient for Denying { + async fn evaluate( + &self, + _request: EvaluationRequest, + ) -> Result { + Ok(EvaluationResponse { + decision: false, + context: EvaluationResponseContext::default(), + }) + } +} + +/// One mounted surface over its own store. +struct Surface { + router: Router, + sink: Arc, +} + +/// Builds a surface whose `AuthZ` client the caller states. +fn surface(enforcer: Option) -> Surface { + let store = Arc::new(oagw::store::OagwStore::new()); + let cache = Arc::new(ControlPlaneCache::new()); + let config = OagwConfig::default(); + let service = Arc::new( + ManagementService::new(Arc::clone(&store), &config, Arc::clone(&cache)) + .expect("the validators compile"), + ); + let state = Arc::new(OagwState::new( + Arc::new(config), + Arc::clone(&store), + service, + enforcer.map(Arc::new), + None, + Arc::clone(&cache), + )); + let sink = Arc::new(CollectingSink::new()); + state.swap_audit_sink(Arc::clone(&sink) as Arc); + Surface { + router: oagw::api::rest::register_management_routes(Router::new(), state), + sink, + } +} + +/// The authenticated subject a request carries. +fn subject() -> SecurityContext { + SecurityContext::builder() + .subject_id(Uuid::from_u128(TENANT)) + .subject_tenant_id(Uuid::from_u128(TENANT)) + .build() + .expect("the subject is complete") +} + +/// Issues one request to the mounted surface and returns status, headers, body. +async fn issue( + app: Router, + method: Method, + uri: &str, + authenticated: bool, +) -> (StatusCode, String, String) { + let mut builder = Request::builder().method(method).uri(uri); + if authenticated { + builder = builder.extension(subject()); + } + let request = builder.body(Body::empty()).expect("the request builds"); + let response = app.oneshot(request).await.expect("oneshot resolves"); + let status = response.status(); + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .map(String::from) + .unwrap_or_default(); + let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .expect("the body reads"); + (status, content_type, String::from_utf8_lossy(&bytes).into_owned()) +} + +/// An empty registry-backed runtime, for the rendering tests. +fn runtime() -> Observability { + Observability::with_sink(Arc::new(CollectingSink::new())) +} + +/// An exchange the exit step can observe: one resolved upstream, one route, +/// one method, one status, and the timings the path stamped. +fn exchange(host: &str, route: &str, method: &str, status: u16) -> Exchange { + Exchange { + host: Some(String::from(host)), + route: Some(String::from(route)), + method: String::from(method), + status: Some(status), + timings: Some(PhaseTimings::started()), + request_size: 24, + response_size: Some(48), + upstream_resolved: true, + ..Exchange::default() + } +} + +/// The twelve families the design enumerates, with the exposition kind each +/// is rendered with. +const FAMILIES: [(&str, &str); 12] = [ + ("oagw_requests_total", "counter"), + ("oagw_request_duration_seconds", "histogram"), + ("oagw_requests_in_flight", "gauge"), + ("oagw_errors_total", "counter"), + ("oagw_circuit_breaker_state", "gauge"), + ("oagw_rate_limit_exceeded_total", "counter"), + ("oagw_circuit_breaker_transitions_total", "counter"), + ("oagw_rate_limit_usage_ratio", "gauge"), + ("oagw_routing_target_host_used", "counter"), + ("oagw_routing_endpoint_selected", "counter"), + ("oagw_upstream_available", "gauge"), + ("oagw_upstream_connections", "gauge"), +]; + +/// Whether the exposition declares one family with its type line. +fn declares(exposition: &str, name: &str, kind: &str) -> bool { + exposition.contains(&format!("# TYPE {name} {kind}")) +} + +/// The series lines of one family, without the `#` comment lines. +fn samples<'a>(exposition: &'a str, name: &str) -> Vec<&'a str> { + exposition + .lines() + .filter(|line| !line.starts_with('#')) + .filter(|line| line.starts_with(name)) + .collect() +} + +#[test] +fn every_exposed_family_is_declared_with_its_type_and_help() { + let observability = runtime(); + let exposition = observability.render(); + for (name, kind) in FAMILIES { + if name == "oagw_upstream_connections" { + assert!( + !exposition.contains(name), + "a family whose underlying state the gear does not expose is omitted" + ); + continue; + } + assert!(declares(&exposition, name, kind), "{name}"); + assert!(exposition.contains(&format!("# HELP {name}")), "{name}"); + // A family that has observed nothing renders its declaration and no + // samples. + assert!( + samples(&exposition, name).is_empty(), + "{name} rendered a sample it never observed" + ); + } + assert_eq!(FAMILIES.len(), 12); +} + +#[test] +fn the_label_sets_are_exactly_the_ones_the_design_enumerates() { + assert_eq!(MetricLabelSet::labels_of("oagw_requests_total"), Some(MetricLabelSet::REQUESTS_TOTAL)); + assert_eq!(MetricLabelSet::labels_of("oagw_request_duration_seconds"), Some(MetricLabelSet::REQUEST_DURATION)); + assert_eq!(MetricLabelSet::labels_of("oagw_requests_in_flight"), Some(MetricLabelSet::IN_FLIGHT)); + assert_eq!(MetricLabelSet::labels_of("oagw_errors_total"), Some(MetricLabelSet::ERRORS_TOTAL)); + assert_eq!(MetricLabelSet::labels_of("oagw_circuit_breaker_state"), Some(MetricLabelSet::BREAKER_STATE)); + assert_eq!(MetricLabelSet::labels_of("oagw_rate_limit_exceeded_total"), Some(MetricLabelSet::RATE_LIMIT_EXCEEDED)); + assert_eq!(MetricLabelSet::labels_of("oagw_circuit_breaker_transitions_total"), Some(MetricLabelSet::BREAKER_TRANSITIONS)); + assert_eq!(MetricLabelSet::labels_of("oagw_rate_limit_usage_ratio"), Some(MetricLabelSet::RATE_LIMIT_USAGE)); + assert_eq!(MetricLabelSet::labels_of("oagw_routing_target_host_used"), Some(MetricLabelSet::ROUTING_TARGET_USED)); + assert_eq!(MetricLabelSet::labels_of("oagw_routing_endpoint_selected"), Some(MetricLabelSet::ROUTING_SELECTED)); + assert_eq!(MetricLabelSet::labels_of("oagw_upstream_available"), Some(MetricLabelSet::UPSTREAM_AVAILABLE)); + assert_eq!(MetricLabelSet::labels_of("oagw_upstream_connections"), Some(MetricLabelSet::UPSTREAM_CONNECTIONS)); + + // The four sets the design states verbatim. + assert_eq!(MetricLabelSet::REQUESTS_TOTAL, &["host", "http.request.method", "http.route", "http.response.status_code"]); + assert_eq!(MetricLabelSet::REQUEST_DURATION, &["host", "http.route", "phase"]); + assert_eq!(MetricLabelSet::ERRORS_TOTAL, &["host", "http.route", "error_type"]); + assert_eq!(MetricLabelSet::UPSTREAM_CONNECTIONS, &["host", "state"]); + + // No set carries a tenant label. + for (name, _kind) in FAMILIES { + let Some(labels) = MetricLabelSet::labels_of(name) else { + continue; + }; + for label in labels { + assert!(!label.contains("tenant"), "{name} declares a tenant label"); + } + } +} + +#[test] +fn a_family_observed_is_rendered_with_the_labels_its_set_declares() { + let observability = runtime(); + let correlation = None; + let observed = exchange("up.example", "/api", "GET", 200); + observability.observe(&observed, correlation); + + let exposition = observability.render(); + let lines = samples(&exposition, "oagw_requests_total"); + assert_eq!(lines.len(), 1, "{exposition}"); + assert!(lines[0].contains("host=\"up.example\""), "{lines:?}"); + assert!(lines[0].contains("http.request.method=\"GET\""), "{lines:?}"); + assert!(lines[0].contains("http.route=\"/api\""), "{lines:?}"); + assert!(lines[0].contains("http.response.status_code=\"200\""), "{lines:?}"); + assert!(lines[0].ends_with(" 1"), "{lines:?}"); +} + +#[test] +fn the_histogram_is_rendered_over_the_twelve_buckets_with_its_sum_and_count() { + let observability = runtime(); + let observed = exchange("up.example", "/api", "GET", 200); + observability.observe(&observed, None); + + let exposition = observability.render(); + for bucket in HISTOGRAM_BUCKETS { + let le = format!("le=\"{bucket}\""); + assert!( + exposition.contains(&le), + "the bucket {le} is missing from {exposition}" + ); + } + assert!(exposition.contains("oagw_request_duration_seconds_bucket"), "{exposition}"); + assert!(exposition.contains("oagw_request_duration_seconds_sum"), "{exposition}"); + assert!(exposition.contains("oagw_request_duration_seconds_count"), "{exposition}"); + // No bound outside the declared set is rendered. + assert!(!exposition.contains("le=\"0.002\""), "{exposition}"); + assert!(exposition.contains("le=\"+Inf\""), "{exposition}"); +} + +#[test] +fn a_request_the_gateway_answered_without_resolving_an_upstream_is_filed_under_one_literal() { + let observability = runtime(); + // Three distinct invented aliases, each answered without a resolved + // upstream: all three answers are filed under the one bounded literal, so + // the label set of the three answer families stays out of caller control. + let mut first = exchange("invented.one", "/api", "GET", 404); + first.upstream_resolved = false; + let mut second = exchange("invented.two.example", "/api", "GET", 404); + second.upstream_resolved = false; + let third = Exchange { + host: Some(String::from("invented.three")), + route: None, + method: String::from("GET"), + status: Some(404), + upstream_resolved: false, + ..exchange("invented.three", "/api", "GET", 404) + }; + observability.observe(&first, None); + observability.observe(&second, None); + observability.observe(&third, None); + let exposition = observability.render(); + for family in ["oagw_requests_total", "oagw_errors_total", "oagw_request_duration_seconds"] { + for line in samples(&exposition, family) { + assert!(line.contains("host=\"_unresolved\""), "{line}"); + assert!(!line.contains("host=\"invented."), "{line}"); + } + } + // The gauge that is raised at the correlate step and lowered here keeps the + // alias the request addressed, because the raise and the lower must name + // the same series. + let in_flight = samples(&exposition, "oagw_requests_in_flight"); + assert!(in_flight.iter().all(|line| line.contains("host=\"invented.") + || line.contains("host=\"invented.three\"")), "{exposition}"); +} + +#[test] +fn the_method_is_normalized_to_the_verb_or_to_other() { + assert_eq!(oagw::domain::observability::normalize_method("get"), "GET"); + assert_eq!(oagw::domain::observability::normalize_method("POST"), "POST"); + assert_eq!(oagw::domain::observability::normalize_method("TRACE"), "_OTHER"); + assert_eq!(oagw::domain::observability::normalize_method("OPTIONS"), "_OTHER"); + assert_eq!(oagw::domain::observability::normalize_method("connect"), "_OTHER"); + + let observability = runtime(); + let observed = exchange("up.example", "/api", "TRACE", 200); + observability.observe(&observed, None); + let exposition = observability.render(); + assert!( + exposition.contains("http.request.method=\"_OTHER\""), + "{exposition}" + ); +} + +#[test] +fn the_route_label_carries_the_declared_pattern_and_not_the_request_path() { + let observability = runtime(); + let observed = exchange("up.example", "/api/{id}", "GET", 200); + observability.observe(&observed, None); + let exposition = observability.render(); + assert!(exposition.contains("http.route=\"/api/{id}\""), "{exposition}"); + assert!(!exposition.contains("http.route=\"/proxy"), "{exposition}"); +} + +#[test] +fn the_gateway_status_is_carried_on_a_request_the_gateway_answered() { + let observability = runtime(); + let mut observed = exchange("up.example", "/api", "GET", 404); + observed.error = Some(oagw::domain::error::ErrorKind::RouteNotFound); + observability.observe(&observed, None); + let exposition = observability.render(); + assert!( + exposition.contains("http.response.status_code=\"404\""), + "{exposition}" + ); + assert!( + exposition.contains("error_type=\"route.not_found\""), + "{exposition}" + ); +} + +#[test] +fn an_upstream_failure_status_is_counted_with_the_upstream_literal() { + let observability = runtime(); + let observed = exchange("up.example", "/api", "GET", 502); + observability.observe(&observed, None); + let exposition = observability.render(); + assert!( + exposition.contains("error_type=\"upstream\""), + "{exposition}" + ); +} + +#[test] +fn a_bare_gateway_refusal_counts_no_error_type() { + let observability = runtime(); + let mut observed = exchange("up.example", "/api", "GET", 403); + observed.gateway_answer = true; + observability.observe(&observed, None); + let exposition = observability.render(); + assert!( + !exposition.contains("error_type="), + "a bare gateway refusal names no error_type: {exposition}" + ); +} + +#[test] +fn the_phase_values_are_the_four_the_design_closes_the_set_at() { + assert_eq!( + oagw::domain::observability::PHASES, + ["resolve", "chain", "upstream", "total"] + ); + let observability = runtime(); + let mut observed = exchange("up.example", "/api", "GET", 200); + let mut timings = PhaseTimings::started(); + timings.resolved(); + timings.chained(); + timings.forwarded(); + observed.timings = Some(timings); + observability.observe(&observed, None); + let exposition = observability.render(); + for phase in oagw::domain::observability::PHASES { + assert!( + exposition.contains(&format!("phase=\"{phase}\"")), + "{phase} is missing from {exposition}" + ); + } + for foreign in ["plugin", "route", "upstream_select"] { + assert!(!exposition.contains(&format!("phase=\"{foreign}\"")), "{exposition}"); + } + // A phase the path never reached is absent rather than reported as a + // zero-length span, so the per-phase mean an operator reads stays free of + // requests that touched no upstream. + let mut refused = exchange("up.example", "/api/other", "GET", 404); + // A request the path resolved and then answered without forwarding. + let mut stamps = PhaseTimings::started(); + stamps.resolved(); + refused.timings = Some(stamps); + observability.observe(&refused, None); + let refused_exposition = observability.render(); + let counts: Vec<&str> = samples(&refused_exposition, "oagw_request_duration_seconds_count"); + assert!( + counts.iter().any(|line| line.contains("phase=\"resolve\"")), + "{refused_exposition}" + ); + assert!( + counts.iter() + .filter(|line| line.contains("http.route=\"/api/other\"")) + .all(|line| !line.contains("phase=\"upstream\"")), + "{refused_exposition}" + ); +} + +#[test] +fn the_in_flight_gauge_is_raised_and_lowered_in_balance() { + let observability = runtime(); + observability.raise_in_flight("up.example"); + let raised = observability.render(); + assert!( + raised.contains("oagw_requests_in_flight{host=\"up.example\"} 1"), + "{raised}" + ); + + let observed = exchange("up.example", "/api", "GET", 200); + observability.observe(&observed, None); + let lowered = observability.render(); + assert!( + lowered.contains("oagw_requests_in_flight{host=\"up.example\"} 0"), + "{lowered}" + ); +} + +#[test] +fn the_breaker_and_rate_limit_families_read_the_state_their_owner_owns() { + let observability = runtime(); + let mut observed = exchange("up.example", "/api", "GET", 429); + observed.breaker = Some(BreakerObservation { + phase: Some(BreakerPhase::Open), + transitions: vec![oagw::domain::ratelimit::BreakerTransition { + from: BreakerPhase::Closed, + to: BreakerPhase::Open, + }], + }); + observed.rate_limit = Some(RateLimitObservation { + exceeded: true, + usage_ratio: Some(0.75), + }); + observability.observe(&observed, None); + + let exposition = observability.render(); + assert!( + exposition.contains("oagw_circuit_breaker_state{host=\"up.example\"} 1"), + "{exposition}" + ); + assert!( + exposition.contains("from_state=\"closed\"") && exposition.contains("to_state=\"open\""), + "{exposition}" + ); + assert!( + exposition.contains("oagw_rate_limit_exceeded_total{host=\"up.example\""), + "{exposition}" + ); + assert!( + exposition.contains("oagw_rate_limit_usage_ratio{host=\"up.example\""), + "{exposition}" + ); +} + +#[test] +fn the_endpoint_families_read_the_selection_the_proxy_performed() { + let observability = runtime(); + let mut observed = exchange("up.example", "/api", "GET", 200); + observed.endpoint = Some(EndpointObservation { + upstream_id: Uuid::from_u128(TENANT), + endpoint_host: String::from("ep.example"), + method: "round_robin", + used_header: true, + }); + observability.observe(&observed, None); + + let exposition = observability.render(); + assert!( + exposition.contains("oagw_routing_target_host_used{upstream_id="), + "{exposition}" + ); + assert!( + exposition.contains("endpoint_host=\"ep.example\""), + "{exposition}" + ); + assert!( + exposition.contains("selection_method=\"round_robin\""), + "{exposition}" + ); +} + +#[test] +fn the_availability_gauge_reports_whether_the_breaker_admits() { + let observability = runtime(); + let mut observed = exchange("up.example", "/api", "GET", 200); + observed.breaker = Some(BreakerObservation { + phase: Some(BreakerPhase::HalfOpen), + transitions: Vec::new(), + }); + observed.endpoint = Some(EndpointObservation { + upstream_id: Uuid::from_u128(TENANT), + endpoint_host: String::from("ep.example"), + method: "default", + used_header: false, + }); + observability.observe(&observed, None); + let exposition = observability.render(); + assert!( + exposition.contains("oagw_upstream_available{host=\"up.example\""), + "{exposition}" + ); +} + +#[test] +fn a_label_value_that_escapes_is_rendered_escaped() { + let observability = runtime(); + let observed = exchange("up\"example", "/api", "GET", 200); + observability.observe(&observed, None); + let exposition = observability.render(); + assert!( + exposition.contains("host=\"up\\\"example\""), + "{exposition}" + ); +} + +#[test] +fn a_series_that_declares_a_label_outside_its_set_is_rendered_as_none() { + let observability = runtime(); + let observed = exchange("up.example", "/api", "GET", 200); + // A host label the in-flight family declares is the only one it admits; + // a series that carried a foreign label is dropped at render. + observability.raise_in_flight("up.example"); + let _ = observed; + let exposition = observability.render(); + assert!( + exposition.contains("oagw_requests_in_flight{host=\"up.example\"} 1"), + "{exposition}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_metrics_path_answers_200_with_the_exposition() { + let Surface { router, sink } = surface(Some(PolicyEnforcer::new(Arc::new(Allowing)))); + let (status, content_type, body) = issue(router, Method::GET, "/oagw/v1/metrics", true).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(content_type, "text/plain; version=0.0.4; charset=utf-8"); + assert!(body.contains("# TYPE oagw_requests_total counter"), "{body}"); + // The scrape wrote no record and observed no series of its own. + assert!(sink.records().is_empty(), "{:?}", sink.records()); + assert!(!body.contains("oagw_requests_total{host=\"/oagw/v1/metrics\"}"), "{body}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_metrics_path_answers_403_without_the_permission() { + let Surface { router, sink } = surface(Some(PolicyEnforcer::new(Arc::new(Denying)))); + let (status, content_type, body) = issue(router, Method::GET, "/oagw/v1/metrics", true).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + assert_eq!(content_type, "application/problem+json"); + assert!(body.contains(METRICS_TYPE), "{body}"); + assert!(!body.contains("oagw_requests_total"), "no exposition is rendered: {body}"); + assert!(sink.records().is_empty(), "{:?}", sink.records()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_metrics_path_answers_401_without_a_subject() { + let Surface { router, sink: _sink } = surface(Some(PolicyEnforcer::new(Arc::new(Allowing)))); + let (status, content_type, body) = issue(router, Method::GET, "/oagw/v1/metrics", false).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "{body}"); + assert_eq!(content_type, "application/problem+json"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_metrics_path_is_registered_for_that_method_alone() { + let Surface { router, sink: _sink } = surface(Some(PolicyEnforcer::new(Arc::new(Allowing)))); + let (status, _type, body) = issue(router.clone(), Method::POST, "/oagw/v1/metrics", true).await; + assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED, "{body}"); + let (status, _type, body) = issue(router.clone(), Method::DELETE, "/oagw/v1/metrics", true).await; + assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED, "{body}"); + // The bare `/metrics` path is answered by no OAGW handler. + let (status, _type, body) = issue(router, Method::GET, "/metrics", true).await; + assert_eq!(status, StatusCode::NOT_FOUND, "{body}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_management_write_leaves_the_metrics_registry_alone() { + let Surface { router, sink: _sink } = surface(Some(PolicyEnforcer::new(Arc::new(Allowing)))); + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "up.example", "port": 443 }] }, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1" + }); + let request = Request::builder() + .method(Method::POST) + .uri("/oagw/v1/upstreams") + .extension(subject()) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("the request builds"); + let response = router + .oneshot(request) + .await + .expect("oneshot resolves"); + assert_eq!(response.status(), StatusCode::CREATED); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_preflight_the_gateway_answers_writes_no_record_and_no_series() { + // The CORS preflight is answered before the proxy flow is reached, so it + // produces neither an audit record nor a series of any family. + let Surface { router, sink } = surface(Some(PolicyEnforcer::new(Arc::new(Allowing)))); + let request = Request::builder() + .method(Method::OPTIONS) + .uri("/oagw/v1/proxy/no-such-upstream/api") + .header("origin", "https://caller.example") + .header("access-control-request-method", "GET") + .body(Body::empty()) + .expect("the request builds"); + let response = router.oneshot(request).await.expect("oneshot resolves"); + let _ = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .expect("the body reads"); + assert!( + sink.records().is_empty(), + "a preflight wrote a record: {:?}", + sink.records() + ); +} + +#[test] +fn the_event_set_the_records_draw_from_is_the_twelve_literals() { + assert_eq!(AUDIT_EVENTS.len(), 12); + assert_eq!(AUDIT_EVENTS[0], "proxy_request.succeeded"); + assert_eq!(AUDIT_EVENTS[1], "proxy_request.failed"); + assert_eq!(AUDIT_EVENTS[10], "auth.failed"); + assert_eq!(AUDIT_EVENTS[11], "breaker.transitioned"); +} + +#[test] +fn the_status_code_label_carries_the_number_and_no_class_is_pre_aggregated() { + // The status-class totals are computed at query time by regex on the + // numeric code, which is why the exposition carries the number alone and + // declares no `status_class` key on any family. + let observability = runtime(); + observability.observe(&exchange("up.example", "/api", "GET", 502), None); + let exposition = observability.render(); + + assert!( + exposition.contains("http.response.status_code=\"502\""), + "{exposition}" + ); + assert!(!exposition.contains("status_class"), "{exposition}"); + assert!(!exposition.contains("5xx"), "{exposition}"); + assert_eq!( + MetricLabelSet::labels_of("oagw_requests_total"), + Some(MetricLabelSet::REQUESTS_TOTAL) + ); + assert!( + !MetricLabelSet::REQUESTS_TOTAL.contains(&"status_class"), + "{:?}", + MetricLabelSet::REQUESTS_TOTAL + ); +} + +#[test] +fn the_rate_limit_path_label_carries_the_route_pattern() { + // The `path` label of both rate-limit families is the normalized route + // match pattern, the same value `http.route` carries, so the label set + // stays bounded by the number of configured routes. + let observability = runtime(); + let mut observed = exchange("up.example", "/api/{id}", "GET", 429); + observed.rate_limit = Some(RateLimitObservation { + exceeded: true, + usage_ratio: Some(0.5), + }); + observability.observe(&observed, None); + + let exposition = observability.render(); + let exceeded = samples(&exposition, "oagw_rate_limit_exceeded_total"); + assert!(!exceeded.is_empty(), "{exposition}"); + for line in &exceeded { + assert!(line.contains("path=\"/api/{id}\""), "{line}"); + } + for line in samples(&exposition, "oagw_rate_limit_usage_ratio") { + assert!(line.contains("path=\"/api/{id}\""), "{line}"); + } +} + +#[test] +fn the_in_flight_gauge_holds_through_a_streamed_transfer() { + // A streamed transfer defers its whole observation to the transfer's end, + // so the gauge the entry raised stays raised for the whole of it and + // returns to its prior value only when the deferred observation runs. + let observability = Arc::new(runtime()); + observability.raise_in_flight("up.example"); + + let session = Arc::new(parking_lot::Mutex::new( + oagw::domain::stream::StreamSession::open_for_incremental( + Uuid::from_u128(TENANT), + Uuid::from_u128(TENANT), + Some(String::from("text/event-stream")), + ), + )); + let mut streamed = exchange("up.example", "/api", "GET", 200); + streamed.session = Some(Arc::clone(&session)); + streamed.response_size = None; + let deferred = Arc::clone(&observability).defer(streamed, None); + + let held = observability.render(); + assert!( + held.contains("oagw_requests_in_flight{host=\"up.example\"} 1"), + "{held}" + ); + + drop(deferred); + let ended = observability.render(); + assert!( + ended.contains("oagw_requests_in_flight{host=\"up.example\"} 0"), + "{ended}" + ); +} diff --git a/gears/system/oagw/oagw/tests/odata_tests.rs b/gears/system/oagw/oagw/tests/odata_tests.rs new file mode 100644 index 0000000..2cfcce6 --- /dev/null +++ b/gears/system/oagw/oagw/tests/odata_tests.rs @@ -0,0 +1,509 @@ +//! OData list parameter tests. +//! +//! Covers `cpt-cf-oagw-algo-odata-list`: the defaults, the hard `$top` +//! ceiling, the rejected paging values, the closed parameter surface, a +//! `$filter` over every filterable field of both resource kinds, the +//! unexposed-field and malformed-grammar refusals, `$orderby` in both +//! directions, `$select` projecting and rejecting, `$skip` offsetting, and the +//! tenant equality holding under every combination. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::missing_panics_doc)] + +use oagw::control_plane::odata::{self, DEFAULT_TOP, MAX_TOP}; +use oagw::control_plane::validation::ResourceKind; +use oagw::store::{OagwStore, RouteRow, UpstreamRow}; +use oagw::gts; +use oagw::{Endpoint, EndpointHost, HttpMatch, MatchConfig, Route, Scheme, ServerConfig, Upstream}; +use uuid::Uuid; + +const HTTP_PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; + +fn tenant(n: u128) -> Uuid { + Uuid::from_u128(n) +} + +fn endpoint(host: &str, port: u16) -> Endpoint { + Endpoint { + scheme: Scheme::Https, + host: EndpointHost::parse(host).expect("valid endpoint host"), + port: Some(port), + } +} + +/// An upstream row with the alias and tags the case needs. +fn upstream(alias: Option<&str>, tags: &[&str]) -> Upstream { + Upstream { + id: Uuid::new_v4(), + enabled: true, + alias: alias.map(str::to_owned), + tags: tags.iter().map(|tag| (*tag).to_owned()).collect(), + server: ServerConfig { + endpoints: vec![endpoint("api.openai.com", 443)], + }, + protocol: String::from(HTTP_PROTOCOL), + auth: None, + headers: None, + plugins: None, + rate_limit: None, + cors: None, + } +} + +/// A route row with the path, priority, and enabled value the case needs. +fn route(upstream_id: Uuid, path: &str, priority: i64, enabled: Option) -> Route { + Route { + id: Uuid::new_v4(), + upstream_id, + match_config: MatchConfig { + http: Some(HttpMatch { + methods: vec![String::from("GET")], + path: String::from(path), + query_allowlist: vec![], + path_suffix_mode: None, + }), + grpc: None, + }, + plugins: None, + rate_limit: None, + tags: vec![String::from("edge")], + cors: None, + priority: Some(priority), + enabled, + } +} + +/// A store holding three upstreams and three routes of one tenant, plus one +/// upstream and one route of another. +fn seeded() -> OagwStore { + let store = OagwStore::new(); + let owner = tenant(1); + + let first = store + .insert_upstream(owner, &upstream(Some("api.openai.com"), &["llm"])) + .expect("first upstream"); + let second = store + .insert_upstream(owner, &upstream(Some("eu.openai.com"), &["edge"])) + .expect("second upstream"); + store + .insert_upstream(owner, &upstream(None, &["edge", "llm"])) + .expect("third upstream"); + store + .insert_upstream(tenant(2), &upstream(Some("foreign.openai.com"), &["llm"])) + .expect("foreign upstream"); + + let upstream_id = first.upstream.id; + store + .insert_route(owner, &route(upstream_id, "/v1/chat", 10, Some(true))) + .expect("first route"); + store + .insert_route(owner, &route(upstream_id, "/v1/embed", 20, Some(true))) + .expect("second route"); + store + .insert_route(owner, &route(second.upstream.id, "/v1/moderate", 30, Some(false))) + .expect("third route"); + + let foreign = store + .list_upstreams(tenant(2)) + .pop() + .expect("the foreign upstream"); + store + .insert_route(tenant(2), &route(foreign.upstream.id, "/v1/foreign", 40, Some(true))) + .expect("foreign route"); + let _ = &first; + let _ = &second; + store +} + +/// Parses one query string for one resource kind. +fn query(kind: ResourceKind, text: &str) -> oagw::control_plane::odata::ListQuery { + oagw::control_plane::odata::parse(kind.into(), text).expect("the parameters are admitted") +} + +/// Asserts a query string is refused, naming the offending parameter. +fn refused(kind: ResourceKind, text: &str, needle: &str) { + let error = oagw::control_plane::odata::parse(kind.into(), text).expect_err("refused"); + assert_eq!(error.http_status(), 400, "{error}"); + assert!( + error.detail.contains(needle), + "expected '{needle}' in '{error}'" + ); +} + +/// The upstream aliases of one page. +fn aliases(page: &odata::Page) -> Vec { + page.items + .iter() + .map(|row| row.upstream.alias.clone().unwrap_or_default()) + .collect() +} + +/// The route paths of one page. +fn paths(page: &odata::Page) -> Vec { + page.items + .iter() + .map(|row| row.route.match_config.http.clone().expect("http").path) + .collect() +} + +#[test] +fn an_absent_parameter_set_takes_the_declared_defaults() { + let parsed = query(ResourceKind::Upstream, ""); + assert_eq!(parsed.top, DEFAULT_TOP); + assert_eq!(parsed.skip, 0); + assert!(parsed.filter.is_none()); + assert!(parsed.orderby.is_none()); + assert!(parsed.select.is_empty()); +} + +#[test] +fn an_oversized_top_is_bounded_to_the_ceiling() { + let parsed = query(ResourceKind::Upstream, "$top=100000"); + assert_eq!(parsed.top, MAX_TOP); + let parsed = query(ResourceKind::Upstream, "$top=100"); + assert_eq!(parsed.top, MAX_TOP); + let parsed = query(ResourceKind::Upstream, "$top=101"); + assert_eq!(parsed.top, MAX_TOP, "the ceiling is a hard bound"); +} + +#[test] +fn a_malformed_paging_value_is_refused() { + refused(ResourceKind::Upstream, "$top=-1", "$top"); + refused(ResourceKind::Upstream, "$top=many", "$top"); + refused(ResourceKind::Route, "$skip=-5", "$skip"); + refused(ResourceKind::Route, "$skip=later", "$skip"); + refused(ResourceKind::Route, "$skip=1.5", "$skip"); +} + +#[test] +fn an_unknown_parameter_is_refused_by_name() { + refused(ResourceKind::Upstream, "$count=true", "$count"); + refused(ResourceKind::Upstream, "filter=alias", "filter"); + refused(ResourceKind::Route, "$filterx=id", "$filterx"); +} + +#[test] +fn a_filter_admits_every_filterable_upstream_field() { + let parsed = query(ResourceKind::Upstream, "$filter=alias eq 'api.openai.com'"); + assert_eq!(parsed.filter.expect("filter").terms.len(), 1); + + let parsed = query( + ResourceKind::Upstream, + &format!("$filter=id eq '{}'", gts::gts_instance(gts::UPSTREAM_TYPE, Uuid::nil())), + ); + assert_eq!(parsed.filter.expect("filter").terms.len(), 1); + + let parsed = query(ResourceKind::Upstream, "$filter=enabled eq 'true'"); + assert_eq!(parsed.filter.expect("filter").terms.len(), 1); + + let parsed = query(ResourceKind::Upstream, "$filter=tag eq 'llm'"); + assert_eq!(parsed.filter.expect("filter").terms.len(), 1); +} + +#[test] +fn a_filter_admits_every_filterable_route_field() { + for field in ["id", "upstream_id", "path", "method", "priority", "enabled", "tag"] { + let parsed = query(ResourceKind::Route, &format!("$filter={field} eq 'x'")); + assert_eq!(parsed.filter.expect("filter").terms.len(), 1, "{field}"); + } +} + +#[test] +fn a_filter_naming_an_unexposed_field_is_refused() { + refused( + ResourceKind::Upstream, + "$filter=path eq '/v1'", + "$filter names a field the resource kind does not expose", + ); + refused( + ResourceKind::Route, + "$filter=alias eq 'api.openai.com'", + "$filter names a field the resource kind does not expose", + ); + refused( + ResourceKind::Upstream, + "$filter=created_at eq 'yesterday'", + "$filter names a field the resource kind does not expose", + ); +} + +#[test] +fn a_malformed_filter_grammar_is_refused() { + for expression in [ + "alias", + "alias eq", + "alias eq '", + "alias eq 'unterminated", + "alias like 'x'", + "alias eq 'a' or alias eq 'b'", + "eq 'a'", + ] { + refused( + ResourceKind::Upstream, + &format!("$filter={expression}"), + "$filter is not a well-formed filter expression", + ); + } +} + +#[test] +fn a_conjunction_of_comparisons_is_admitted() { + let parsed = query( + ResourceKind::Upstream, + "$filter=enabled eq 'true' and tag eq 'llm'", + ); + let filter = parsed.filter.expect("filter"); + assert_eq!(filter.terms.len(), 2); + assert_eq!(filter.terms[0].value, "true"); + assert_eq!(filter.terms[1].value, "llm"); +} + +#[test] +fn an_ordering_is_parsed_in_both_directions() { + let parsed = query(ResourceKind::Upstream, "$orderby=alias"); + let orderby = parsed.orderby.expect("orderby"); + assert!(!orderby.descending); + + let parsed = query(ResourceKind::Route, "$orderby=priority desc"); + let orderby = parsed.orderby.expect("orderby"); + assert!(orderby.descending); + + let parsed = query(ResourceKind::Route, "$orderby=priority asc"); + assert!(!parsed.orderby.expect("orderby").descending); +} + +#[test] +fn an_ordering_naming_an_unorderable_field_is_refused() { + refused( + ResourceKind::Upstream, + "$orderby=created_at desc", + "$orderby names a field the resource kind does not order by", + ); + refused( + ResourceKind::Route, + "$orderby=path", + "$orderby names a field the resource kind does not order by", + ); +} + +#[test] +fn a_malformed_ordering_is_refused() { + refused( + ResourceKind::Upstream, + "$orderby=alias sideways", + "$orderby is not a well-formed ordering expression", + ); +} + +#[test] +fn a_projection_is_parsed_and_deduplicated() { + let parsed = query(ResourceKind::Upstream, "$select=id,alias,alias"); + assert_eq!(parsed.select, vec![String::from("id"), String::from("alias")]); + let parsed = query(ResourceKind::Route, "$select=match,priority"); + assert_eq!(parsed.select, vec![String::from("match"), String::from("priority")]); +} + +#[test] +fn a_projection_naming_an_unknown_property_is_refused() { + refused( + ResourceKind::Upstream, + "$select=id,upstream_id", + "$select names a property the resource kind does not expose", + ); + refused( + ResourceKind::Route, + "$select=alias", + "$select names a property the resource kind does not expose", + ); +} + +#[test] +fn every_defect_of_one_query_string_is_reported_in_one_error() { + refused( + ResourceKind::Upstream, + "$top=late&$count=true&$select=zzz", + "$top", + ); + let error = oagw::control_plane::odata::parse(ResourceKind::Upstream.into(), "$top=late&$count=true") + .expect_err("refused"); + assert!(error.detail.contains("$top"), "{error}"); + assert!(error.detail.contains("$count"), "{error}"); +} + +#[test] +fn a_filter_selects_the_rows_that_compare_equal() { + let store = seeded(); + let scan = store.list_upstreams(tenant(1)); + let parsed = query(ResourceKind::Upstream, "$filter=alias eq 'api.openai.com'"); + let page = odata::apply_upstream(&parsed, scan); + assert_eq!(aliases(&page), vec![String::from("api.openai.com")]); +} + +#[test] +fn an_alias_comparison_is_case_insensitive() { + let store = seeded(); + let scan = store.list_upstreams(tenant(1)); + let parsed = query(ResourceKind::Upstream, "$filter=alias eq 'API.OPENAI.COM'"); + let page = odata::apply_upstream(&parsed, scan); + assert_eq!(aliases(&page).len(), 1); +} + +#[test] +fn a_tag_comparison_selects_every_parent_holding_the_tag() { + let store = seeded(); + let scan = store.list_upstreams(tenant(1)); + let parsed = query(ResourceKind::Upstream, "$filter=tag eq 'llm'"); + let page = odata::apply_upstream(&parsed, scan); + assert_eq!(aliases(&page).len(), 2, "two upstreams hold the tag"); +} + +#[test] +fn an_enabled_comparison_selects_the_enabled_rows() { + let store = seeded(); + let scan = store.list_routes(tenant(1)); + let parsed = query(ResourceKind::Route, "$filter=enabled eq 'true'"); + let page = odata::apply_route(&parsed, scan); + assert_eq!(paths(&page).len(), 2, "one of the three routes is disabled"); +} + +#[test] +fn a_path_and_a_method_comparison_read_the_match_rows() { + let store = seeded(); + let scan = store.list_routes(tenant(1)); + let parsed = query(ResourceKind::Route, "$filter=path eq '/v1/embed'"); + let page = odata::apply_route(&parsed, scan); + assert_eq!(paths(&page), vec![String::from("/v1/embed")]); + + let parsed = query(ResourceKind::Route, "$filter=method eq 'get'"); + let page = odata::apply_route(&parsed, store.list_routes(tenant(1))); + assert_eq!(paths(&page).len(), 3); +} + +#[test] +fn a_gts_instance_identifier_compares_after_parsing() { + let store = seeded(); + let scan = store.list_routes(tenant(1)); + let owner = store.list_upstreams(tenant(1)); + let upstream_id = owner + .iter() + .find(|row| row.upstream.alias.as_deref() == Some("api.openai.com")) + .expect("the referenced upstream") + .upstream + .id; + let parsed = query( + ResourceKind::Route, + &format!( + "$filter=upstream_id eq '{}'", + gts::gts_instance(gts::UPSTREAM_TYPE, upstream_id) + ), + ); + let page = odata::apply_route(&parsed, scan); + assert_eq!(paths(&page).len(), 2, "two routes share the upstream"); +} + +#[test] +fn an_unparseable_identifier_selects_nothing() { + let store = seeded(); + let parsed = query(ResourceKind::Route, "$filter=id eq 'not-an-id'"); + let page = odata::apply_route(&parsed, store.list_routes(tenant(1))); + assert!(page.items.is_empty()); +} + +#[test] +fn an_ordering_sorts_ascending_and_descending() { + let store = seeded(); + let parsed = query(ResourceKind::Route, "$orderby=priority"); + let page = odata::apply_route(&parsed, store.list_routes(tenant(1))); + assert_eq!(paths(&page), vec!["/v1/chat", "/v1/embed", "/v1/moderate"]); + + let parsed = query(ResourceKind::Route, "$orderby=priority desc"); + let page = odata::apply_route(&parsed, store.list_routes(tenant(1))); + assert_eq!(paths(&page), vec!["/v1/moderate", "/v1/embed", "/v1/chat"]); +} + +#[test] +fn an_ordering_by_alias_sorts_the_absent_alias_first() { + let store = seeded(); + let parsed = query(ResourceKind::Upstream, "$orderby=alias"); + let page = odata::apply_upstream(&parsed, store.list_upstreams(tenant(1))); + assert_eq!( + aliases(&page), + vec![ + String::new(), + String::from("api.openai.com"), + String::from("eu.openai.com"), + ] + ); +} + +#[test] +fn an_offset_and_a_bound_shape_the_page() { + let store = seeded(); + let parsed = query(ResourceKind::Route, "$orderby=priority&$skip=1&$top=1"); + let page = odata::apply_route(&parsed, store.list_routes(tenant(1))); + assert_eq!(paths(&page), vec![String::from("/v1/embed")]); + assert_eq!(page.items.len(), 1); + + let parsed = query(ResourceKind::Route, "$orderby=priority&$skip=99"); + let page = odata::apply_route(&parsed, store.list_routes(tenant(1))); + assert!(page.items.is_empty()); +} + +#[test] +fn the_projection_is_returned_with_the_page() { + let store = seeded(); + let parsed = query(ResourceKind::Upstream, "$select=alias&$orderby=alias"); + let page = odata::apply_upstream(&parsed, store.list_upstreams(tenant(1))); + assert_eq!(page.projection, vec![String::from("alias")]); + assert_eq!(page.items.len(), 3); +} + +#[test] +fn the_tenant_equality_holds_under_every_combination() { + let store = seeded(); + let upstream_combinations = [ + "", + "$top=100", + "$filter=tag eq 'llm'", + "$filter=alias eq 'foreign.openai.com'", + "$orderby=alias", + "$orderby=alias desc&$top=1&$skip=0", + "$select=id,alias", + "$filter=enabled eq 'true'&$orderby=alias desc&$skip=1&$top=2", + ]; + for combination in upstream_combinations { + let parsed = query(ResourceKind::Upstream, combination); + let page = odata::apply_upstream(&parsed, store.list_upstreams(tenant(2))); + assert!(page.items.iter().all(|row| row.tenant_id == tenant(2)), "{combination}"); + assert!( + aliases(&page) + .iter() + .all(|alias| alias == "foreign.openai.com"), + "{combination}" + ); + } + + let route_combinations = [ + "", + "$top=100", + "$filter=tag eq 'edge'", + "$filter=path eq '/v1/foreign'", + "$orderby=priority", + "$orderby=priority desc&$top=1&$skip=0", + "$select=id,match", + "$filter=enabled eq 'true'&$orderby=priority desc&$skip=1&$top=2", + ]; + for combination in route_combinations { + let parsed = query(ResourceKind::Route, combination); + let page = odata::apply_route(&parsed, store.list_routes(tenant(2))); + assert!(page.items.iter().all(|row| row.tenant_id == tenant(2)), "{combination}"); + assert!(page.items.len() <= 1, "{combination}"); + } +} + +#[test] +fn a_page_of_another_tenant_is_never_visible_to_a_filter() { + let store = seeded(); + let parsed = query(ResourceKind::Upstream, "$filter=alias eq 'api.openai.com'"); + let page = odata::apply_upstream(&parsed, store.list_upstreams(tenant(2))); + assert!(page.items.is_empty()); +} diff --git a/gears/system/oagw/oagw/tests/plugin_binding_tests.rs b/gears/system/oagw/oagw/tests/plugin_binding_tests.rs new file mode 100644 index 0000000..f5e92dc --- /dev/null +++ b/gears/system/oagw/oagw/tests/plugin_binding_tests.rs @@ -0,0 +1,1010 @@ +//! The binding write that rides on the upstream and route write paths. +//! +//! Covers `cpt-cf-oagw-flow-bind-plugins`, `cpt-cf-oagw-algo-plugin-ref-resolve`, +//! and `cpt-cf-oagw-algo-binding-validate` end to end through the management +//! service: the contiguous-position rule, the four reference resolutions the +//! store and the named registry produce, the `plugin_uuid` match, the auth +//! sub-configuration's identity and its `cred://` shape, the single-transaction +//! write the binding rows land in, and the full replacement that unlinks what +//! the body omits. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::sync::Arc; + +use oagw::control_plane::cache::ControlPlaneCache; +use oagw::control_plane::plugin_def; +use oagw::control_plane::service::{ManagementService, ServiceError}; +use oagw::domain::error::ErrorKind; +use oagw::domain::plugin_contract::{PluginFamily, NamedPluginRegistry}; +use oagw::domain::plugin::Plugin; +use oagw::gts::plugin_catalog; +use oagw::store::{BindingWrite, OagwStore, PluginBinding}; +use serde_json::{Value, json}; +use uuid::Uuid; + +const HTTP_PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; +const GUARD: &str = plugin_catalog::GUARD_REQUIRED_HEADERS; +const TRANSFORM: &str = plugin_catalog::TRANSFORM_REQUEST_ID; +const AUTH: &str = plugin_catalog::AUTH_APIKEY; +const CATALOG_ONLY: &str = plugin_catalog::CATALOG_ONLY_GUARD_TIMEOUT; + +fn tenant(n: u128) -> Uuid { + Uuid::from_u128(n) +} + +/// A management service over its own empty store and cache. +fn service() -> ManagementService { + ManagementService::new( + Arc::new(OagwStore::new()), + &oagw::OagwConfig::default(), + Arc::new(ControlPlaneCache::new()), + ) + .expect("the validators compile") +} + +/// The store the service was built over, for the row-level assertions. +fn store_of(service: &ManagementService) -> &OagwStore { + service.store() +} + +/// An upstream body whose endpoints derive the alias, carrying no family. +fn upstream_body() -> Value { + json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com", "port": 443 }] }, + "protocol": HTTP_PROTOCOL, + "tags": [] + }) +} + +/// A custom transform plugin the calling tenant owns, as its anonymous +/// identifier. +fn custom_plugin(service: &ManagementService, tenant: Uuid, name: &str) -> String { + let row = service + .create_plugin( + tenant, + &json!({ + "plugin_type": "transform", + "name": name, + "phases": ["on_response"], + "source_code": "def on_response(ctx):\n return ctx\n" + }), + ) + .expect("the custom plugin is created"); + plugin_def::plugin_instance(PluginFamily::Transform, row.plugin.id) +} + +/// Creates the upstream and answers the row. +fn create_upstream(service: &ManagementService, tenant: Uuid, body: &Value) -> oagw::UpstreamRow { + service + .create_upstream(tenant, body) + .expect("the upstream is created") +} + +/// The detail of the validation error the service answered. +fn detail_of(error: &ServiceError) -> String { + let ServiceError::Domain(error) = error else { + panic!("the refusal is a domain failure, not {error:?}"); + }; + assert_eq!(error.kind, ErrorKind::ValidationError); + error.detail.clone() +} + +/// An upstream body binding the guard and transform plugins at the positions +/// the caller names. +fn bound_body(items: Vec) -> Value { + json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com", "port": 443 }] }, + "protocol": HTTP_PROTOCOL, + "plugins": { "items": items }, + "tags": [] + }) +} + +// --------------------------------------------------------------------------- +// The success path: the binding rows are written in the parent's transaction. +// --------------------------------------------------------------------------- + +#[test] +fn a_binding_of_builtin_plugins_writes_the_rows_in_the_parents_transaction() { + let service = service(); + let tenant = tenant(0x20); + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "plugins": { + "sharing": "private", + "items": [ + { "plugin_ref": GUARD, "config": { "headers": ["x-trace"] } }, + { "plugin_ref": TRANSFORM } + ] + }, + "tags": [] + }); + let written = create_upstream(&service, tenant, &body); + + let rows = store_of(&service).upstream_plugin_rows(tenant, written.upstream.id); + assert_eq!(rows.len(), 2, "one binding row per submitted item"); + assert_eq!(rows[0].position, 0); + assert_eq!(rows[0].plugin_ref, GUARD); + assert_eq!(rows[0].plugin_uuid, None, "a built-in plugin has no row"); + assert_eq!( + rows[0].config, + json!({ "headers": ["x-trace"] }), + "the configuration the item carried reaches the row" + ); + assert_eq!(rows[1].position, 1); + assert_eq!(rows[1].plugin_ref, TRANSFORM); + assert_eq!(rows[1].config, json!({}), "an item that carries no config binds an empty one"); +} + +#[test] +fn a_binding_of_a_custom_plugin_carries_the_uuid_and_the_parent_row_carries_no_auth_identity() { + let service = service(); + let tenant = tenant(0x21); + let reference = custom_plugin(&service, tenant, "tag"); + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "plugins": { "items": [reference] }, + "tags": [] + }); + let written = create_upstream(&service, tenant, &body); + + let rows = store_of(&service).upstream_plugin_rows(tenant, written.upstream.id); + assert_eq!(rows[0].plugin_ref, reference); + assert!( + rows[0].plugin_uuid.is_some(), + "a UUID-backed plugin stores its UUID" + ); + + let stored = store_of(&service) + .get_upstream(tenant, written.upstream.id) + .expect("the row is stored"); + assert!( + stored.auth_plugin_ref.is_none() && stored.auth_plugin_uuid.is_none(), + "an upstream that binds no auth plugin carries no identity column" + ); +} + +#[test] +fn a_route_binds_at_its_own_positions_independently_of_any_upstream() { + let service = service(); + let tenant = tenant(0x22); + let upstream = create_upstream(&service, tenant, &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "plugins": { "items": [GUARD] }, + "tags": [] + })); + let body = json!({ + "upstream_id": upstream.upstream.id, + "match": { "http": { "methods": ["GET"], "path": "/v1" } }, + "priority": 1, + "plugins": { "items": [{ "plugin_ref": TRANSFORM, "position": 0 }] }, + "tags": [] + }); + let written = service.create_route(tenant, &body).expect("the route is created"); + + let rows = store_of(&service).route_plugin_rows(tenant, written.route.id); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].position, 0, "the route's positions start at 0"); + assert_eq!(rows[0].plugin_ref, TRANSFORM); +} + +#[test] +fn an_upstream_that_binds_one_auth_plugin_writes_the_scalar_columns() { + let service = service(); + let tenant = tenant(0x23); + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "auth": { "sharing": "private", "type": AUTH, "config": { "key": "k" } }, + "tags": [] + }); + let written = create_upstream(&service, tenant, &body); + + let stored = store_of(&service) + .get_upstream(tenant, written.upstream.id) + .expect("the row is stored"); + assert_eq!(stored.auth_plugin_ref.as_deref(), Some(AUTH)); + assert_eq!(stored.auth_plugin_uuid, None, "a built-in auth plugin has no row"); + assert!( + store_of(&service) + .upstream_plugin_rows(tenant, written.upstream.id) + .is_empty(), + "the auth identity never becomes a binding row" + ); +} + +#[test] +fn a_custom_auth_plugin_writes_both_identity_columns() { + let service = service(); + let tenant = tenant(0x24); + let row = service + .create_plugin( + tenant, + &json!({ + "plugin_type": "auth", + "name": "tenant-auth", + "phases": ["on_request"], + "source_code": "def authenticate(ctx):\n return ctx\n" + }), + ) + .expect("the custom auth plugin is created"); + let reference = plugin_def::plugin_instance(PluginFamily::Auth, row.plugin.id); + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "auth": { "sharing": "private", "type": reference }, + "tags": [] + }); + let written = create_upstream(&service, tenant, &body); + + let stored = store_of(&service) + .get_upstream(tenant, written.upstream.id) + .expect("the row is stored"); + assert_eq!(stored.auth_plugin_ref.as_deref(), Some(reference.as_str())); + assert_eq!(stored.auth_plugin_uuid, Some(row.plugin.id)); +} + +// --------------------------------------------------------------------------- +// The resolution failures: 400, no row written. +// --------------------------------------------------------------------------- + +#[test] +fn a_catalog_only_identifier_is_refused_and_named_as_reserved() { + let service = service(); + let tenant = tenant(0x25); + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "plugins": { "items": [CATALOG_ONLY] }, + "tags": [] + }); + let error = service.create_upstream(tenant, &body).expect_err("refused"); + let detail = detail_of(&error); + assert!( + detail.contains("catalogue identifier no plugin family backs"), + "a reserved identifier is told from an unknown one: {detail}" + ); +} + +#[test] +fn an_unknown_identifier_is_refused_and_named_as_unknown() { + let service = service(); + let tenant = tenant(0x26); + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "plugins": { "items": ["gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.absent.v1"] }, + "tags": [] + }); + let error = service.create_upstream(tenant, &body).expect_err("refused"); + let detail = detail_of(&error); + assert!( + detail.contains("names no resolvable plugin"), + "an unknown identifier is told from a reserved one: {detail}" + ); + assert!( + !detail.contains(GUARD) && !detail.contains(TRANSFORM), + "the answer discloses no registry or catalogue content: {detail}" + ); +} + +#[test] +fn an_auth_slot_of_the_wrong_family_and_the_reserved_auth_names_are_refused() { + let service = service(); + for (name, identifier, expected) in [ + ( + "the reserved basic identifier", + plugin_catalog::CATALOG_ONLY_AUTH_BASIC, + "catalogue identifier no plugin family backs", + ), + ( + "the reserved bearer identifier", + plugin_catalog::CATALOG_ONLY_AUTH_BEARER, + "catalogue identifier no plugin family backs", + ), + ( + "a guard identifier in the auth slot", + GUARD, + "is not an auth plugin", + ), + ( + "a transform identifier in the auth slot", + TRANSFORM, + "is not an auth plugin", + ), + ] { + let tenant = tenant(0x2c); + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "auth": { "type": identifier, "config": {} }, + "tags": [] + }); + let refused = service + .create_upstream(tenant, &body) + .expect_err("the auth slot refuses the identifier"); + let detail = detail_of(&refused); + assert!( + detail.contains(expected), + "the {name} is answered with its own reason: {detail}" + ); + assert!( + store_of(&service).list_upstreams(tenant).is_empty(), + "the {name} wrote no row" + ); + } +} + +#[test] +fn a_reference_to_another_tenants_plugin_row_is_refused() { + let service = service(); + let owner = tenant(0x27); + let caller = tenant(0x28); + let reference = custom_plugin(&service, owner, "foreign"); + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "plugins": { "items": [reference] }, + "tags": [] + }); + let error = service.create_upstream(caller, &body).expect_err("refused"); + let detail = detail_of(&error); + assert!( + detail.contains("no plugin row of the calling tenant"), + "a foreign row is never resolvable: {detail}" + ); +} + +#[test] +fn a_failing_binding_writes_no_parent_row_and_no_binding_row() { + let service = service(); + let tenant = tenant(0x29); + let before = store_of(&service).list_upstreams(tenant).len(); + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "plugins": { "items": [CATALOG_ONLY] }, + "tags": [] + }); + service + .create_upstream(tenant, &body) + .expect_err("the write is refused"); + assert_eq!( + store_of(&service).list_upstreams(tenant).len(), + before, + "the parent row is not written" + ); +} + +// --------------------------------------------------------------------------- +// The shape rules: positions, the UUID match, the auth slot. +// --------------------------------------------------------------------------- + +#[test] +fn non_contiguous_positions_are_refused() { + let service = service(); + let tenant = tenant(0x2a); + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "plugins": { "items": [ + { "plugin_ref": GUARD, "position": 1 }, + { "plugin_ref": TRANSFORM, "position": 2 } + ] }, + "tags": [] + }); + let error = service.create_upstream(tenant, &body).expect_err("refused"); + let detail = detail_of(&error); + assert!( + detail.contains("position 1 is not the submitted order"), + "the contiguous set from 0 is the rule: {detail}" + ); +} + +#[test] +fn the_contiguous_set_is_stored_in_the_submitted_order() { + let service = service(); + let tenant = tenant(0x2a); + let written = create_upstream( + &service, + tenant, + &bound_body(vec![ + json!({ "plugin_ref": GUARD, "position": 0 }), + json!({ "plugin_ref": TRANSFORM, "position": 1 }), + json!({ "plugin_ref": GUARD, "position": 2 }), + ]), + ); + let rows = store_of(&service).upstream_plugin_rows(tenant, written.upstream.id); + assert_eq!( + rows.iter().map(|row| row.position).collect::>(), + vec![0, 1, 2], + "the stored order is the submitted order" + ); + assert_eq!( + rows.iter().map(|row| row.plugin_ref.as_str()).collect::>(), + vec![GUARD, TRANSFORM, GUARD], + ); +} + +#[test] +fn a_set_with_a_gap_and_one_with_a_duplicate_position_are_refused() { + let service = service(); + let tenant = tenant(0x2a); + + // Positions 0 and 2: the second item is not at its own index. + let gap = service + .create_upstream( + tenant, + &bound_body(vec![ + json!({ "plugin_ref": GUARD, "position": 0 }), + json!({ "plugin_ref": TRANSFORM, "position": 2 }), + ]), + ) + .expect_err("the set skips position 1"); + assert!( + detail_of(&gap).contains("position 2"), + "the gap is named: {}", + detail_of(&gap) + ); + + // A repeated position: the second item carrying 0 is not at index 1. + let duplicate = service + .create_upstream( + tenant, + &bound_body(vec![ + json!({ "plugin_ref": GUARD, "position": 0 }), + json!({ "plugin_ref": TRANSFORM, "position": 0 }), + ]), + ) + .expect_err("the set repeats position 0"); + assert!( + detail_of(&duplicate).contains("position 0"), + "the repeat is named: {}", + detail_of(&duplicate) + ); + assert!( + store_of(&service).list_upstreams(tenant).is_empty(), + "neither refused body wrote a row" + ); +} + +#[test] +fn a_bare_uuid_string_is_refused() { + let service = service(); + let tenant = tenant(0x2b); + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "plugins": { "items": ["5b4d6a1e-1c2d-3e4f-5a6b-7c8d9e0f1a2b"] }, + "tags": [] + }); + let error = service.create_upstream(tenant, &body).expect_err("refused"); + let detail = detail_of(&error); + assert!( + detail.contains("not a plugin identifier"), + "a bare UUID declares no base type, so the type match cannot hold: {detail}" + ); +} + +#[test] +fn a_carried_uuid_that_disagrees_with_the_reference_is_refused() { + let service = service(); + let tenant = tenant(0x2c); + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "plugins": { "items": [{ + "plugin_ref": TRANSFORM, + "plugin_uuid": Uuid::from_u128(0xdead) + }] }, + "tags": [] + }); + let error = service.create_upstream(tenant, &body).expect_err("refused"); + assert!( + detail_of(&error).contains("plugin_uuid does not match"), + "the carried UUID must agree with the plugin the reference names" + ); +} + +#[test] +fn a_uuid_on_a_named_plugin_is_refused() { + let service = service(); + let tenant = tenant(0x2d); + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "plugins": { "items": [{ + "plugin_ref": TRANSFORM, + "plugin_uuid": Uuid::from_u128(0xdead) + }] }, + "tags": [] + }); + service + .create_upstream(tenant, &body) + .expect_err("a named plugin carries no UUID"); +} + +#[test] +fn an_auth_identifier_in_the_binding_items_is_refused() { + let service = service(); + let tenant = tenant(0x2e); + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "plugins": { "items": [AUTH] }, + "tags": [] + }); + let error = service.create_upstream(tenant, &body).expect_err("refused"); + let detail = detail_of(&error); + assert!( + detail.contains("bound through the upstream's auth sub-configuration"), + "the auth slot is the scalar columns, never a binding row: {detail}" + ); +} + +#[test] +fn a_second_auth_plugin_is_refused_by_the_schema() { + let service = service(); + let tenant = tenant(0x2f); + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "auth": { "sharing": "private", "type": AUTH }, + "auth_plugin": { "type": AUTH }, + "tags": [] + }); + let error = service.create_upstream(tenant, &body).expect_err("refused"); + let detail = detail_of(&error); + assert!( + detail.contains("unknown property"), + "the shipped schema closes the upstream root: {detail}" + ); +} + +#[test] +fn a_route_body_that_carries_an_auth_sub_configuration_is_refused() { + let service = service(); + let tenant = tenant(0x30); + let upstream = create_upstream(&service, tenant, &upstream_body()); + let body = json!({ + "upstream_id": upstream.upstream.id, + "match": { "http": { "methods": ["GET"], "path": "/v1" } }, + "priority": 1, + "auth": { "sharing": "private", "type": AUTH }, + "tags": [] + }); + let error = service.create_route(tenant, &body).expect_err("refused"); + let detail = detail_of(&error); + assert!( + detail.contains("unknown property"), + "the shipped schema closes the route root: {detail}" + ); +} + +#[test] +fn a_credential_reference_without_the_cred_shape_is_refused() { + let service = service(); + let tenant = tenant(0x31); + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "auth": { + "sharing": "private", + "type": plugin_catalog::AUTH_OAUTH2_CLIENT_CRED, + "config": { "client_secret_ref": "https://vault/secret/one" } + }, + "tags": [] + }); + let error = service.create_upstream(tenant, &body).expect_err("refused"); + let detail = detail_of(&error); + assert!( + detail.contains("auth.config.client_secret_ref"), + "the shape check names the member: {detail}" + ); +} + +#[test] +fn a_credential_reference_with_the_cred_shape_is_accepted() { + let service = service(); + let tenant = tenant(0x32); + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "auth": { + "sharing": "private", + "type": plugin_catalog::AUTH_OAUTH2_CLIENT_CRED, + "config": { "client_secret_ref": "cred://client-secret-one" } + }, + "tags": [] + }); + let written = create_upstream(&service, tenant, &body); + + // The reference is carried verbatim into the stored configuration and is + // not resolved: the write answers no material and the read returns the + // reference, never a secret. + let stored = store_of(&service).get_upstream(tenant, written.upstream.id).expect("stored"); + let config = stored + .upstream + .auth + .as_ref() + .and_then(|auth| auth.config.as_ref()) + .expect("the auth configuration is stored"); + assert_eq!( + config["client_secret_ref"], "cred://client-secret-one", + "the reference is stored as submitted" + ); + let rendered = serde_json::to_string(&stored.upstream).expect("the row renders"); + assert!( + rendered.contains("cred://client-secret-one"), + "the reference, not the material, is what the row carries: {rendered}" + ); + assert!( + !rendered.contains("sk-"), + "no credential material is stored: {rendered}" + ); +} + +#[test] +fn an_empty_whitespace_or_fragmented_reference_is_refused() { + let service = service(); + for (name, reference) in [ + ("empty", ""), + ("surrounding whitespace", " cred://client-secret-one "), + ("a fragment", "cred://client-secret-one#fragment"), + ] { + let tenant = tenant(0x33); + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "auth": { + "sharing": "private", + "type": plugin_catalog::AUTH_OAUTH2_CLIENT_CRED, + "config": { "client_secret_ref": reference } + }, + "tags": [] + }); + let refused = service + .create_upstream(tenant, &body) + .expect_err("the reference is not well formed"); + let detail = detail_of(&refused); + assert!( + detail.contains("auth.config.client_secret_ref"), + "the {name} reference is named by its member: {detail}" + ); + assert!( + store_of(&service).list_upstreams(tenant).is_empty(), + "the {name} reference wrote no row" + ); + } +} + +// --------------------------------------------------------------------------- +// The replacement: the full replacement of the binding rows. +// --------------------------------------------------------------------------- + +#[test] +fn a_replacement_that_drops_an_item_unlinks_the_plugin_it_named() { + let service = service(); + let tenant = tenant(0x33); + let created = create_upstream(&service, tenant, &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "plugins": { "items": [GUARD, TRANSFORM] }, + "tags": [] + })); + assert_eq!( + store_of(&service).upstream_plugin_rows(tenant, created.upstream.id).len(), + 2 + ); + + let replaced = service + .replace_upstream( + tenant, + created.upstream.id, + &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "plugins": { "items": [GUARD] }, + "tags": [] + }), + ) + .expect("the replacement is written"); + + let rows = store_of(&service).upstream_plugin_rows(tenant, replaced.upstream.id); + assert_eq!(rows.len(), 1, "the write set is the full replacement"); + assert_eq!(rows[0].plugin_ref, GUARD); +} + +#[test] +fn a_replacement_that_omits_the_plugins_object_clears_the_binding_rows() { + let service = service(); + let tenant = tenant(0x34); + let created = create_upstream(&service, tenant, &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "plugins": { "items": [TRANSFORM] }, + "tags": [] + })); + + service + .replace_upstream( + tenant, + created.upstream.id, + &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "tags": [] + }), + ) + .expect("the replacement is written"); + + assert!( + store_of(&service) + .upstream_plugin_rows(tenant, created.upstream.id) + .is_empty(), + "a body that omits the sub-object unlinks every plugin" + ); +} + +// --------------------------------------------------------------------------- +// The named registry the service resolves through. +// --------------------------------------------------------------------------- + +#[test] +fn the_registry_backs_six_identifiers_and_refuses_the_six_reserved_ones() { + let registry = NamedPluginRegistry::with_builtins(); + assert_eq!(registry.identifiers().len(), 6, "the backed identifiers only"); + for (identifier, _) in plugin_catalog::BACKED { + registry + .resolve(identifier) + .unwrap_or_else(|error| panic!("a backed identifier resolves: {error:?}")); + } + for identifier in plugin_catalog::CATALOG_ONLY { + let error = registry + .resolve(identifier) + .expect_err("a reserved identifier resolves to nothing"); + assert!( + matches!(error, oagw::domain::plugin_contract::PluginResolveError::Reserved { .. }), + "the refusal names the reservation: {error:?}" + ); + } +} + +// --------------------------------------------------------------------------- +// The write set the validation builds, at the store boundary. +// --------------------------------------------------------------------------- + +#[test] +fn the_empty_write_set_references_no_plugin_and_marks_at_the_instant_it_is_given() { + let write = BindingWrite::none(1_000); + assert!(write.bindings.is_empty()); + assert!(write.auth.is_none()); + assert!(write.referenced_uuids().is_empty()); +} + +#[test] +fn a_write_set_of_custom_bindings_answers_their_uuids() { + let first = Uuid::from_u128(0x1); + let second = Uuid::from_u128(0x2); + let write = BindingWrite { + bindings: vec![ + PluginBinding { + position: 0, + plugin_ref: String::from("gts.cf.core.oagw.transform_plugin.v1~first"), + plugin_uuid: Some(first), + config: json!({}), + }, + PluginBinding { + position: 1, + plugin_ref: String::from("gts.cf.core.oagw.transform_plugin.v1~second"), + plugin_uuid: Some(second), + config: json!({}), + }, + ], + auth: None, + marked_at: 0, + }; + assert_eq!(write.referenced_uuids().len(), 2); +} + +// --------------------------------------------------------------------------- +// The in-use scan the GC marking rides on. +// --------------------------------------------------------------------------- + +#[test] +fn a_plugin_row_that_loses_its_last_reference_is_marked_eligible_in_the_same_transaction() { + let service = service(); + let tenant = tenant(0x35); + let row = service + .create_plugin( + tenant, + &json!({ + "plugin_type": "transform", + "name": "marked", + "phases": ["on_response"], + "source_code": "def on_response(ctx):\n return ctx\n" + }), + ) + .expect("the plugin is created"); + let reference = plugin_def::plugin_instance(PluginFamily::Transform, row.plugin.id); + + let created = create_upstream(&service, tenant, &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "plugins": { "items": [reference] }, + "tags": [] + })); + let stored = store_of(&service) + .get_plugin(tenant, row.plugin.id) + .expect("the plugin row is stored"); + assert!( + stored.plugin.gc_eligible_at.is_none(), + "a referenced plugin is not marked" + ); + + service + .replace_upstream( + tenant, + created.upstream.id, + &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "tags": [] + }), + ) + .expect("the replacement is written"); + + let stored = store_of(&service) + .get_plugin(tenant, row.plugin.id) + .expect("the plugin row is stored"); + let marked = stored + .plugin + .gc_eligible_at + .expect("the last reference was lost"); + assert!( + marked > oagw::store::unix_now(), + "the marking stores the instant the TTL elapses, which is in the future" + ); +} + +#[test] +fn a_plugin_row_that_gains_a_reference_loses_the_marking() { + let service = service(); + let tenant = tenant(0x36); + let row = service + .create_plugin( + tenant, + &json!({ + "plugin_type": "transform", + "name": "relented", + "phases": ["on_response"], + "source_code": "def on_response(ctx):\n return ctx\n" + }), + ) + .expect("the plugin is created"); + let reference = plugin_def::plugin_instance(PluginFamily::Transform, row.plugin.id); + + let first = create_upstream(&service, tenant, &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "one.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "plugins": { "items": [reference] }, + "tags": [] + })); + service + .replace_upstream( + tenant, + first.upstream.id, + &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "one.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "tags": [] + }), + ) + .expect("the unlink is written"); + let marked = store_of(&service) + .get_plugin(tenant, row.plugin.id) + .and_then(|stored| stored.plugin.gc_eligible_at) + .expect("the plugin is marked"); + + create_upstream(&service, tenant, &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "two.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "plugins": { "items": [reference] }, + "tags": [] + })); + let stored = store_of(&service) + .get_plugin(tenant, row.plugin.id) + .expect("the plugin row is stored"); + assert!( + stored.plugin.gc_eligible_at.is_none(), + "a rebound plugin loses the marking it held" + ); + assert!( + stored.plugin.gc_eligible_at != Some(marked), + "the marking the row held is cleared, not re-stamped" + ); +} + +#[test] +fn a_built_in_plugin_is_never_marked_for_it_has_no_row() { + let service = service(); + let tenant = tenant(0x37); + let created = create_upstream(&service, tenant, &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "plugins": { "items": [GUARD] }, + "tags": [] + })); + service + .replace_upstream( + tenant, + created.upstream.id, + &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "tags": [] + }), + ) + .expect("the unlink is written"); + assert!( + store_of(&service).list_plugins(tenant).is_empty(), + "a named plugin has no row to mark" + ); +} + +// --------------------------------------------------------------------------- +// The plugin row the marker grammar owns. +// --------------------------------------------------------------------------- + +#[test] +fn a_custom_plugin_row_keeps_the_columns_the_shipped_model_declares() { + let service = service(); + let tenant = tenant(0x38); + let created = service + .create_plugin( + tenant, + &json!({ + "plugin_type": "guard", + "name": "headers", + "phases": ["on_request"], + "source_code": "def guard_request(ctx):\n return ctx\n" + }), + ) + .expect("the plugin is created"); + + let stored = store_of(&service) + .get_plugin(tenant, created.plugin.id) + .expect("the row is stored"); + assert_eq!(stored.tenant_id, tenant); + assert_eq!(stored.plugin.plugin_type, "guard"); + assert!(stored.plugin.last_used_at.is_none(), "no use is recorded here"); + assert!( + stored.plugin.gc_eligible_at.is_none(), + "a created plugin is linked to nothing and is not yet eligible" + ); +} + +/// The persisted `Plugin` the marker grammar's `oagw_plugin` row is modelled +/// from, asserted once so a renamed column fails here and not at runtime. +#[test] +fn the_plugin_row_carries_the_two_lifecycle_columns_the_model_declares() { + let plugin = Plugin { + id: Uuid::from_u128(0x39), + tenant_id: Uuid::from_u128(0x3a), + plugin_type: String::from("guard"), + name: String::from("lifecycle"), + description: None, + config_schema: None, + phases: Vec::new(), + source_code: String::from("def guard_request(ctx):\n return ctx\n"), + last_used_at: None, + gc_eligible_at: Some(1_000), + }; + let rendered = serde_json::to_value(&plugin).expect("the row serializes"); + assert!(rendered.get("last_used_at").is_some()); + assert!(rendered.get("gc_eligible_at").is_some()); +} diff --git a/gears/system/oagw/oagw/tests/plugin_builtin_tests.rs b/gears/system/oagw/oagw/tests/plugin_builtin_tests.rs new file mode 100644 index 0000000..c0727db --- /dev/null +++ b/gears/system/oagw/oagw/tests/plugin_builtin_tests.rs @@ -0,0 +1,708 @@ +//! Built-in plugin catalogue, credential-resolution, and token-cache tests. +//! +//! Covers `cpt-cf-oagw-dod-builtin-catalogue`, `cpt-cf-oagw-dod-credential-isolation`, +//! and `cpt-cf-oagw-dod-token-cache`: the six backed implementations the three +//! registries hold at initialization, the six catalog-only identifiers that +//! stay unresolvable everywhere, the twelve identifiers the post-init phase +//! registers in the types-registry, the `cred://` routine that is the only +//! thing in the gear that turns a reference into material, and the token cache +//! whose stored key is verified on every hit. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::missing_panics_doc)] + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use async_trait::async_trait; +use credstore_sdk::CredStoreClientV1; +use credstore_sdk::error::CredStoreError; +use credstore_sdk::models::{GetSecretResponse, SecretRef, SecretValue, SharingMode}; +use credstore_sdk::test_util::MockCredStoreClient; +use httpmock::prelude::*; +use oagw::domain::context::{AuthContext, RequestContext, ResponseContext}; +use oagw::domain::plugin_contract::{ + AuthPlugin, AuthPluginRegistry, GuardDecision, GuardPlugin, GuardPluginRegistry, PluginFailure, + PluginPhase, TransformPlugin, TransformPluginRegistry, +}; +use oagw::gts::plugin_catalog; +use oagw::plugins::credential::CREDENTIAL_SCHEME; +use oagw::plugins::token_cache::{TOKEN_CACHE_SAFETY_MARGIN_SECS, TokenCache, TokenCacheConfig}; +use serde_json::{Value, json}; +use toolkit_auth::SecretString; +use toolkit_security::SecurityContext; + +/// The four auth identifiers the built-in catalogue backs. +const AUTH_IDS: [&str; 4] = [ + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.noop.v1", + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1", + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred.v1", + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred_basic.v1", +]; + +/// The one backed guard identifier and the one backed transform identifier. +const GUARD_ID: &str = "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1"; +const TRANSFORM_ID: &str = "gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1"; + +/// The tenant every context in this file is built with. +const TENANT: uuid::Uuid = uuid::Uuid::from_u128(0x0102); + +/// The subject every context in this file is authenticated as. +const SUBJECT: uuid::Uuid = uuid::Uuid::from_u128(0x0304); + +/// A credential store that counts the `get` calls it received, so a test can +/// prove a shape failure never reached the store. +struct CountingCredStore { + inner: MockCredStoreClient, + calls: AtomicUsize, +} + +impl CountingCredStore { + fn with_secrets(creds: Vec<(String, String)>) -> Self { + Self { + inner: MockCredStoreClient::with_secrets(creds), + calls: AtomicUsize::new(0), + } + } + + fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } + + /// Coerces the counted store onto the trait object the registry takes, so + /// a test keeps reading the counter the registry's copy counts. + fn as_client(self: &Arc) -> Arc { + let counted: Arc = Arc::clone(self); + counted + } +} + +#[async_trait] +impl CredStoreClientV1 for CountingCredStore { + async fn get( + &self, + ctx: &SecurityContext, + key: &SecretRef, + ) -> Result, CredStoreError> { + self.calls.fetch_add(1, Ordering::SeqCst); + self.inner.get(ctx, key).await + } +} + +/// A credential store that declines every reference, the shape the +/// `AccessDenied` error takes. +struct DecliningCredStore; + +#[async_trait] +impl CredStoreClientV1 for DecliningCredStore { + async fn get( + &self, + _ctx: &SecurityContext, + _key: &SecretRef, + ) -> Result, CredStoreError> { + Err(CredStoreError::AccessDenied) + } +} + +/// The security context the credential routine resolves under. +fn security_context() -> SecurityContext { + SecurityContext::builder() + .subject_id(uuid::Uuid::from_u128(0x0304)) + .subject_type("service") + .subject_tenant_id(TENANT) + .build() + .expect("a fully specified security context builds") +} + +/// The built-in registries, over a store that knows one API key. +fn builtins() -> (AuthPluginRegistry, GuardPluginRegistry, TransformPluginRegistry) { + ( + AuthPluginRegistry::with_builtins( + Arc::new(CountingCredStore::with_secrets(vec![( + String::from("api-key"), + String::from("sk-live-1"), + )])), + token_config(), + ), + GuardPluginRegistry::with_builtins(), + TransformPluginRegistry::with_builtins(), + ) +} + +/// The `apikey` implementation the built-in registry holds. +fn apikey(registry: &AuthPluginRegistry) -> Arc { + registry + .resolve(AUTH_IDS[1]) + .expect("the built-in catalogue backs the apikey variant") +} + +/// The `noop` implementation the built-in registry holds. +fn noop(registry: &AuthPluginRegistry) -> Arc { + registry + .resolve(AUTH_IDS[0]) + .expect("the built-in catalogue backs the noop variant") +} + +/// The `oauth2_client_cred` implementation the built-in registry holds. +fn oauth2(registry: &AuthPluginRegistry) -> Arc { + registry + .resolve(AUTH_IDS[2]) + .expect("the built-in catalogue backs the oauth2 variant") +} + +/// The `required_headers` implementation the built-in registry holds. +fn required_headers(registry: &GuardPluginRegistry) -> Arc { + registry + .resolve(GUARD_ID) + .expect("the built-in catalogue backs the required-headers guard") +} + +/// The `request_id` implementation the built-in registry holds. +fn request_id(registry: &TransformPluginRegistry) -> Arc { + registry + .resolve(TRANSFORM_ID) + .expect("the built-in catalogue backs the request-id transform") +} + +/// The token-cache configuration the registry is built with. +fn token_config() -> oagw::plugins::token_cache::TokenCacheConfig { + TokenCacheConfig::new(Duration::from_secs(300), 10_000) +} + +#[test] +fn the_six_backed_identifiers_resolve_from_their_built_in_registries() { + let (auth, guard, transform) = builtins(); + for identifier in AUTH_IDS { + assert!(auth.resolve(identifier).is_ok(), "{identifier} resolves"); + } + assert!(auth.declared_phases(AUTH_IDS[0]).is_some()); + assert!(guard.resolve(GUARD_ID).is_ok()); + assert!(transform.resolve(TRANSFORM_ID).is_ok()); + // Each registry holds its own entries and no others. + assert_eq!(auth.len(), AUTH_IDS.len()); + assert_eq!(guard.len(), 1); + assert_eq!(transform.len(), 1); +} + +#[test] +fn the_six_catalog_only_identifiers_stay_unresolvable() { + let (auth, guard, transform) = builtins(); + for identifier in plugin_catalog::CATALOG_ONLY { + assert!(matches!( + auth.resolve(identifier), + Err(oagw::domain::plugin_contract::PluginResolveError::Reserved { .. }) + )); + assert!(matches!( + guard.resolve(identifier), + Err(oagw::domain::plugin_contract::PluginResolveError::Reserved { .. }) + )); + assert!(matches!( + transform.resolve(identifier), + Err(oagw::domain::plugin_contract::PluginResolveError::Reserved { .. }) + )); + } +} + +#[test] +fn the_twelve_identifiers_are_types_registry_rows() { + // The post-init phase registers all twelve identifiers of the built-in + // catalogue in the types-registry, backed and catalog-only alike. + let rows = oagw::gts::catalog::instances(); + for identifier in plugin_catalog::all() { + assert!( + rows.iter() + .any(|row| row.get("id").and_then(Value::as_str) == Some(identifier)), + "{identifier} is an instance row of the catalogue batch" + ); + } +} + +#[tokio::test] +async fn an_apikey_plugin_resolves_its_reference_and_injects_the_header() { + let (auth, _guard, _transform) = builtins(); + let mut ctx = AuthContext::new(TENANT, None); + apikey(&auth).authenticate(&mut ctx, + &json!({ + "credential_ref": "cred://api-key", + "header_name": "x-api-key", + }), + ) + .await + .expect("a resolvable reference authenticates"); + assert_eq!(ctx.header("x-api-key"), Some("sk-live-1")); +} + +#[tokio::test] +async fn an_apikey_plugin_defaults_to_the_standard_api_key_header() { + let (auth, _guard, _transform) = builtins(); + let mut ctx = AuthContext::new(TENANT, None); + apikey(&auth).authenticate(&mut ctx, &json!({ "credential_ref": "cred://api-key" })) + .await + .expect("the default header name needs no configuration"); + assert_eq!(ctx.header("x-api-key"), Some("sk-live-1")); +} + +#[tokio::test] +async fn a_malformed_reference_fails_the_shape_check_before_the_store() { + let store = Arc::new(CountingCredStore::with_secrets(vec![( + String::from("api-key"), + String::from("v"), + )])); + let auth = AuthPluginRegistry::with_builtins(store.as_client(), token_config()); + let mut ctx = AuthContext::new(TENANT, None); + for reference in ["cred://", "https://api-key", " cred://api-key", "cred://api-key#frag"] { + let failure = apikey(&auth) + .authenticate(&mut ctx, &json!({ "credential_ref": reference })) + .await + .expect_err("a malformed reference is a typed failure"); + assert!( + matches!(failure, PluginFailure::CredentialShape), + "{reference} fails the shape check" + ); + assert!(ctx.headers.is_empty(), "no header was injected"); + } + // Every reference was declined on shape, so the store was never asked. + assert_eq!(store.calls(), 0); +} + +#[tokio::test] +async fn an_unresolvable_reference_maps_to_secret_not_found() { + let (auth, _guard, _transform) = builtins(); + let mut ctx = AuthContext::new(TENANT, None); + let failure = apikey(&auth) + .authenticate(&mut ctx, &json!({ "credential_ref": "cred://absent" })) + .await + .expect_err("an absent secret is a typed failure"); + assert!(matches!(failure, PluginFailure::SecretNotFound)); + assert!(ctx.headers.is_empty()); +} + +#[tokio::test] +async fn a_declined_reference_maps_to_authentication_failed() { + let auth = AuthPluginRegistry::with_builtins( + Arc::new(DecliningCredStore), + token_config(), + ); + let mut ctx = AuthContext::new(TENANT, None); + let failure = apikey(&auth) + .authenticate(&mut ctx, &json!({ "credential_ref": "cred://api-key" })) + .await + .expect_err("a declined reference is a typed failure"); + assert!(matches!(failure, PluginFailure::AuthenticationFailed)); +} + +#[tokio::test] +async fn an_unreachable_store_maps_to_unavailable() { + let auth = AuthPluginRegistry::with_builtins( + Arc::new(MockCredStoreClient::always_failing()), + token_config(), + ); + let mut ctx = AuthContext::new(TENANT, None); + let failure = apikey(&auth) + .authenticate(&mut ctx, &json!({ "credential_ref": "cred://api-key" })) + .await + .expect_err("an unreachable store is a typed failure"); + assert!(matches!(failure, PluginFailure::Unavailable)); +} + +#[tokio::test] +async fn an_apikey_configuration_without_a_reference_is_a_configuration_failure() { + let (auth, _guard, _transform) = builtins(); + let mut ctx = AuthContext::new(TENANT, None); + let failure = apikey(&auth) + .authenticate(&mut ctx, &json!({})) + .await + .expect_err("a configuration with no reference cannot authenticate"); + assert!(matches!(failure, PluginFailure::Configuration { .. })); +} + +#[tokio::test] +async fn no_typed_failure_echoes_the_reference() { + let (auth, _guard, _transform) = builtins(); + let mut ctx = AuthContext::new(TENANT, None); + for reference in ["cred://", "cred://absent#frag", "not-a-reference"] { + if let Err(failure) = apikey(&auth) + .authenticate(&mut ctx, &json!({ "credential_ref": reference })) + .await + { + assert!( + !format!("{failure:?}").contains("absent"), + "the failure never names the reference: {failure:?}" + ); + } + } +} + +#[test] +fn the_credential_shape_check_accepts_only_the_cred_scheme() { + assert!(oagw::plugins::credential::is_credential_reference("cred://api-key")); + assert!(oagw::plugins::credential::is_credential_reference("cred://Tenant_Scope-1")); + assert!(!oagw::plugins::credential::is_credential_reference("cred://")); + assert!(!oagw::plugins::credential::is_credential_reference("https://api-key")); + assert!(!oagw::plugins::credential::is_credential_reference("cred://api key")); + assert!(!oagw::plugins::credential::is_credential_reference(" cred://api-key")); + assert!(!oagw::plugins::credential::is_credential_reference("cred://api-key#f")); + assert!(!oagw::plugins::credential::is_credential_reference("")); + assert_eq!(CREDENTIAL_SCHEME, "cred://"); +} + +#[tokio::test] +async fn the_noop_auth_plugin_injects_nothing() { + let (auth, _guard, _transform) = builtins(); + let mut ctx = AuthContext::new(TENANT, Some(SUBJECT)); + noop(&auth) + .authenticate(&mut ctx, &json!({})) + .await + .expect("the noop variant always succeeds"); + assert!(ctx.headers.is_empty()); +} + +#[tokio::test] +async fn an_oauth2_plugin_exchanges_and_injects_the_bearer() { + let server = MockServer::start(); + let token_mock = server.mock(|when, then| { + when.method(POST).path("/token"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"access_token":"tok-1","expires_in":3600,"token_type":"Bearer"}"#); + }); + let store = CountingCredStore::with_secrets(vec![ + (String::from("client-id"), String::from("cid")), + (String::from("client-secret"), String::from("csecret")), + ]); + let auth = AuthPluginRegistry::with_builtins( + Arc::new(store), + TokenCacheConfig::new(Duration::from_secs(300), 10), + ); + let mut ctx = AuthContext::new(TENANT, Some(SUBJECT)); + oauth2(&auth) + .authenticate( + &mut ctx, + &json!({ + "token_endpoint": format!("http://localhost:{}/token", server.port()), + "client_id_ref": "cred://client-id", + "client_secret_ref": "cred://client-secret", + "scopes": "read write", + }), + ) + .await + .expect("a successful exchange authenticates"); + assert_eq!(ctx.header("authorization"), Some("Bearer tok-1")); + token_mock.assert_calls(1); +} + +#[tokio::test] +async fn a_second_lookup_is_served_from_the_cache() { + let server = MockServer::start(); + let token_mock = server.mock(|when, then| { + when.method(POST).path("/token"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"access_token":"tok-1","expires_in":3600,"token_type":"Bearer"}"#); + }); + let store = CountingCredStore::with_secrets(vec![ + (String::from("client-id"), String::from("cid")), + (String::from("client-secret"), String::from("csecret")), + ]); + let auth = AuthPluginRegistry::with_builtins( + Arc::new(store), + TokenCacheConfig::new(Duration::from_secs(300), 10), + ); + let config = json!({ + "token_endpoint": format!("http://localhost:{}/token", server.port()), + "client_id_ref": "cred://client-id", + "client_secret_ref": "cred://client-secret", + }); + for _ in 0..3 { + let mut ctx = AuthContext::new(TENANT, Some(SUBJECT)); + oauth2(&auth) + .authenticate(&mut ctx, &config) + .await + .expect("every lookup succeeds"); + assert_eq!(ctx.header("authorization"), Some("Bearer tok-1")); + } + token_mock.assert_calls(1); +} + +#[tokio::test] +async fn tenants_subjects_and_configurations_never_share_an_entry() { + let server = MockServer::start(); + let token_mock = server.mock(|when, then| { + when.method(POST).path("/token"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"access_token":"tok-1","expires_in":3600,"token_type":"Bearer"}"#); + }); + let store = CountingCredStore::with_secrets(vec![ + (String::from("client-id"), String::from("cid")), + (String::from("client-secret"), String::from("csecret")), + ]); + let auth = AuthPluginRegistry::with_builtins( + Arc::new(store), + TokenCacheConfig::new(Duration::from_secs(300), 10), + ); + let config = json!({ + "token_endpoint": format!("http://localhost:{}/token", server.port()), + "client_id_ref": "cred://client-id", + "client_secret_ref": "cred://client-secret", + }); + let mut first = AuthContext::new(TENANT, Some(SUBJECT)); + oauth2(&auth).authenticate(&mut first, &config).await.unwrap(); + // A different tenant resolves its own entry, never the first tenant's. + let other_tenant = uuid::Uuid::from_u128(0x9999); + let mut second = AuthContext::new(other_tenant, Some(SUBJECT)); + oauth2(&auth).authenticate(&mut second, &config).await.unwrap(); + assert_eq!(second.header("authorization"), Some("Bearer tok-1")); + token_mock.assert_calls(2); +} + +#[tokio::test] +async fn a_failed_exchange_is_not_cached() { + let server = MockServer::start(); + let token_mock = server.mock(|when, then| { + when.method(POST).path("/token"); + then.status(500).body("idp unavailable"); + }); + let store = CountingCredStore::with_secrets(vec![ + (String::from("client-id"), String::from("cid")), + (String::from("client-secret"), String::from("csecret")), + ]); + let auth = AuthPluginRegistry::with_builtins( + Arc::new(store), + TokenCacheConfig::new(Duration::from_secs(300), 10), + ); + let config = json!({ + "token_endpoint": format!("http://localhost:{}/token", server.port()), + "client_id_ref": "cred://client-id", + "client_secret_ref": "cred://client-secret", + }); + let mut ctx = AuthContext::new(TENANT, Some(SUBJECT)); + let failure = oauth2(&auth) + .authenticate(&mut ctx, &config) + .await + .expect_err("a refused exchange is a typed failure"); + assert!( + matches!(failure, PluginFailure::AuthenticationFailed | PluginFailure::Unavailable), + "the exchange failure is mapped, not swallowed: {failure:?}" + ); + token_mock.assert_calls(1); +} + +#[tokio::test] +async fn an_oauth2_configuration_without_a_token_endpoint_is_a_configuration_failure() { + let store = CountingCredStore::with_secrets(vec![ + (String::from("client-id"), String::from("cid")), + (String::from("client-secret"), String::from("csecret")), + ]); + let auth = AuthPluginRegistry::with_builtins(Arc::new(store), token_config()); + let mut ctx = AuthContext::new(TENANT, Some(SUBJECT)); + let failure = oauth2(&auth) + .authenticate(&mut ctx, &json!({ "client_id_ref": "cred://client-id" })) + .await + .expect_err("no endpoint means no exchange"); + assert!(matches!(failure, PluginFailure::Configuration { .. })); +} + +#[test] +fn the_token_cache_verifies_the_stored_key_on_every_hit() { + let cache = TokenCache::new(TokenCacheConfig::new(Duration::from_secs(300), 10)); + cache.store("tenant-a", SecretString::new("tok-a"), Duration::from_secs(600)); + // The right key reads its own entry back. + assert_eq!(cache.lookup("tenant-a").map(|token| token.expose().to_owned()), Some(String::from("tok-a"))); + // A different key is a miss, never another tenant's token. + assert!(cache.lookup("tenant-b").is_none()); +} + +#[test] +fn a_token_at_or_below_the_margin_is_injected_but_not_stored() { + assert_eq!(TOKEN_CACHE_SAFETY_MARGIN_SECS, 30); + let cache = TokenCache::new(TokenCacheConfig::new(Duration::from_secs(300), 10)); + assert!(!cache.store("k", SecretString::new("tok"), Duration::from_secs(30))); + assert!(cache.lookup("k").is_none()); + assert!(cache.store("k", SecretString::new("tok"), Duration::from_secs(31))); + assert_eq!(cache.lookup("k").map(|token| token.expose().to_owned()), Some(String::from("tok"))); +} + +#[test] +fn the_entry_ttl_is_the_ceiling_or_the_lifetime_less_the_margin() { + let cache = TokenCache::new(TokenCacheConfig::new(Duration::from_secs(300), 10)); + // A long-lived token is held for the configured ceiling. + cache.store("ceiling", SecretString::new("tok"), Duration::from_secs(3_600)); + assert_eq!(cache.lookup("ceiling").map(|token| token.expose().to_owned()), Some(String::from("tok"))); + // A short-lived token is held for the lifetime less the margin. + cache.store("short", SecretString::new("tok"), Duration::from_secs(45)); + assert_eq!(cache.lookup("short").map(|token| token.expose().to_owned()), Some(String::from("tok"))); +} + +#[test] +fn the_config_hash_is_deterministic_and_key_order_independent() { + let first = oagw::plugins::token_cache::hash_config(&json!({ + "token_endpoint": "https://idp/token", + "scopes": "read write", + })); + let second = oagw::plugins::token_cache::hash_config(&json!({ + "scopes": "read write", + "token_endpoint": "https://idp/token", + })); + assert_eq!(first, second); + let different = oagw::plugins::token_cache::hash_config(&json!({ + "token_endpoint": "https://idp/token", + "scopes": "read", + })); + assert_ne!(first, different); +} + +#[test] +fn the_cache_key_carries_the_four_identity_components() { + let tenant_a = uuid::Uuid::from_u128(0x01); + let tenant_b = uuid::Uuid::from_u128(0x02); + let config = json!({ "token_endpoint": "https://idp/token" }); + let base = oagw::plugins::token_cache::cache_key(tenant_a, Some(SUBJECT), "form", &config); + assert_ne!(base, oagw::plugins::token_cache::cache_key(tenant_b, Some(SUBJECT), "form", &config)); + assert_ne!(base, oagw::plugins::token_cache::cache_key(tenant_a, None, "form", &config)); + assert_ne!(base, oagw::plugins::token_cache::cache_key(tenant_a, Some(SUBJECT), "basic", &config)); +} + +#[test] +fn the_required_headers_guard_rejects_the_first_missing_request_header() { + let (auth, guard, _transform) = builtins(); + assert_eq!(auth.len(), 4); + let config = json!({ "required_request_headers": "x-request-id, x-tenant" }); + let mut ctx = RequestContext::new(String::from("GET"), String::from("/v1/chat"), None); + ctx.set_header("x-request-id", "abc"); + let decision = required_headers(&guard).guard_request(&ctx, &config); + match decision { + GuardDecision::Reject { code, message } => { + assert_eq!(code, "REQUIRED_HEADER_MISSING"); + assert_eq!(message, "x-tenant"); + } + GuardDecision::Allow => panic!("a missing required header rejects"), + } +} + +#[test] +fn the_required_headers_guard_is_case_insensitive_and_fail_open() { + let (_auth, guard, _transform) = builtins(); + // Case-insensitive matching: the configured name and the carried name + // differ only in case. + let configured = json!({ "required_request_headers": "X-Request-Id" }); + let mut ctx = RequestContext::new(String::from("GET"), String::from("/v1/chat"), None); + ctx.set_header("x-request-id", "abc"); + assert!(required_headers(&guard).guard_request(&ctx, &configured).is_allowed()); + + // Fail-open: absent or blank configuration is a no-op in both phases. + let response = ResponseContext::new(200); + for config in [json!({}), json!({ "required_request_headers": " " })] { + assert!(required_headers(&guard).guard_request(&ctx, &config).is_allowed()); + assert!(required_headers(&guard).guard_response(&response, &config).is_allowed()); + } +} + +#[test] +fn the_required_headers_guard_rejects_the_response_phase_with_502() { + let (_auth, guard, _transform) = builtins(); + let config = json!({ "required_response_headers": "content-type" }); + let mut ctx = ResponseContext::new(200); + ctx.set_header("x-other", "1"); + match required_headers(&guard).guard_response(&ctx, &config) { + GuardDecision::Reject { code, .. } => assert_eq!(code, "REQUIRED_HEADER_MISSING"), + GuardDecision::Allow => panic!("a missing response header rejects"), + } +} + +#[test] +fn the_request_id_transform_propagates_and_generates() { + let (_auth, _guard, transform) = builtins(); + // An existing identifier is propagated untouched. + let mut ctx = RequestContext::new(String::from("GET"), String::from("/v1/chat"), None); + ctx.set_header("x-request-id", "given-id"); + request_id(&transform).transform_request(&mut ctx, &json!({})); + assert_eq!(ctx.header("x-request-id"), Some("given-id")); + + // A missing one is generated, and the response carries it too. + let mut fresh = RequestContext::new(String::from("GET"), String::from("/v1/chat"), None); + request_id(&transform).transform_request(&mut fresh, &json!({})); + let generated = fresh.header("x-request-id").expect("an id was generated"); + assert!(!generated.is_empty()); + assert_ne!(generated, "given-id"); + + let mut response = ResponseContext::new(200); + request_id(&transform).transform_response(&mut response, &json!({})); + assert!(response.header("x-request-id").is_some()); +} + +#[test] +fn the_transform_plugin_declares_all_three_phases() { + let (_auth, _guard, transform) = builtins(); + assert!(transform.declared_phases(TRANSFORM_ID).is_some()); + for phase in [ + PluginPhase::TransformRequest, + PluginPhase::TransformResponse, + PluginPhase::TransformError, + ] { + assert!( + transform + .resolve_for_phase(TRANSFORM_ID, phase) + .is_ok(), + "{phase:?} is declared" + ); + } +} + +#[test] +fn the_guard_declares_both_of_its_phases() { + let (_auth, guard, _transform) = builtins(); + for phase in [PluginPhase::GuardRequest, PluginPhase::GuardResponse] { + assert!(guard.resolve_for_phase(GUARD_ID, phase).is_ok(), "{phase:?}"); + } +} + +#[test] +fn the_oauth2_variants_declare_the_single_auth_phase() { + let (auth, _guard, _transform) = builtins(); + for identifier in AUTH_IDS { + assert_eq!( + auth.declared_phases(identifier), + Some(vec![PluginPhase::Auth]) + ); + } +} + +#[tokio::test] +async fn a_non_utf8_credential_is_a_configuration_failure() { + let auth = AuthPluginRegistry::with_builtins( + Arc::new(MockCredStoreClient::returning_raw_value(vec![0xff, 0xfe])), + token_config(), + ); + let mut ctx = AuthContext::new(TENANT, None); + let failure = apikey(&auth) + .authenticate(&mut ctx, &json!({ "credential_ref": "cred://api-key" })) + .await + .expect_err("material that is not text cannot be injected"); + assert!(matches!(failure, PluginFailure::Configuration { .. })); +} + +#[tokio::test] +async fn the_credential_routine_resolves_through_the_store_only() { + // The routine is the only thing in the gear that turns a reference into + // material: it accepts the store client it is handed and nothing else. + let store = Arc::new(MockCredStoreClient::with_secrets(vec![( + String::from("api-key"), + String::from("sk-live-1"), + )])); + let material = oagw::plugins::credential::resolve_credential( + store, + &security_context(), + "cred://api-key", + ) + .await + .expect("a resolvable reference resolves"); + assert_eq!(material.expose(), "sk-live-1"); +} + +#[test] +fn a_secret_value_carries_no_debug_leak() { + let value = SecretValue::new(b"sk-live-1".to_vec()); + assert!(!format!("{value:?}").contains("sk-live-1")); + let _ = SharingMode::default(); +} diff --git a/gears/system/oagw/oagw/tests/plugin_chain_compose_tests.rs b/gears/system/oagw/oagw/tests/plugin_chain_compose_tests.rs new file mode 100644 index 0000000..e266253 --- /dev/null +++ b/gears/system/oagw/oagw/tests/plugin_chain_compose_tests.rs @@ -0,0 +1,468 @@ +//! Plugin chain composition and execution order. +//! +//! Covers `cpt-cf-oagw-algo-chain-compose` end to end through the management +//! service's store and the built-in registries: the auth phase resolved from +//! the upstream's scalar identity columns and absent on a route, the +//! upstream-before-route order within a phase, the stored-position order +//! within a layer, the per-phase sub-chains the declared phases select, and +//! the 503 a binding that no longer resolves is answered with. +//! +//! Realizes `cpt-cf-oagw-algo-chain-compose`. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::sync::Arc; + +use oagw::control_plane::cache::ControlPlaneCache; +use oagw::control_plane::plugin_def; +use oagw::control_plane::service::ManagementService; +use oagw::domain::plugin_contract::PluginFamily; +use oagw::gts::plugin_catalog; +use oagw::plugins::chain::{self, ComposedAuth, ComposedStep}; +use oagw::plugins::PluginRegistries; +use oagw::store::{OagwStore, PluginBinding}; +use serde_json::{Value, json}; +use uuid::Uuid; + +const HTTP_PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; +const GUARD: &str = plugin_catalog::GUARD_REQUIRED_HEADERS; +const TRANSFORM: &str = plugin_catalog::TRANSFORM_REQUEST_ID; +const AUTH_APIKEY: &str = plugin_catalog::AUTH_APIKEY; + +fn tenant(n: u128) -> Uuid { + Uuid::from_u128(n) +} + +/// The next distinct upstream host, so two upstreams of one tenant never +/// collide on the alias the endpoints derive. +fn next_host() -> String { + use std::sync::atomic::{AtomicUsize, Ordering}; + static HOST: AtomicUsize = AtomicUsize::new(0); + format!("host{}.example.com", HOST.fetch_add(1, Ordering::SeqCst)) +} + +/// A management service over its own empty store and cache. +fn service() -> ManagementService { + ManagementService::new( + Arc::new(OagwStore::new()), + &oagw::OagwConfig::default(), + Arc::new(ControlPlaneCache::new()), + ) + .expect("the validators compile") +} + +/// The store the service was built over, for the composition's inputs. +fn store_of(service: &ManagementService) -> &OagwStore { + service.store() +} + +/// The built-in registries the composition resolves named plugins through. +fn registries() -> PluginRegistries { + PluginRegistries::with_builtins( + Arc::new(credstore_sdk::test_util::MockCredStoreClient::empty()), + oagw::plugins::token_cache::TokenCacheConfig::new( + std::time::Duration::from_secs(300), + 10_000, + ), + ) +} + +/// The named identities the reference resolution reads. +fn named_of(_service: &ManagementService) -> oagw::domain::plugin_contract::NamedPluginRegistry { + oagw::domain::plugin_contract::NamedPluginRegistry::with_builtins() +} + +/// An upstream body whose endpoints derive the alias, carrying the plugins the +/// bindings name. +fn upstream_body(bindings: Vec) -> Value { + let mut body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": next_host() }] }, + "protocol": HTTP_PROTOCOL, + "tags": [] + }); + if !bindings.is_empty() { + body.as_object_mut() + .expect("the body is an object") + .insert(String::from("plugins"), json!({ "items": bindings })); + } + body +} + +/// One `plugins` item naming a built-in plugin at one position. +fn builtin_item(position: u32, reference: &str) -> Value { + json!({ "plugin_ref": reference, "position": position }) +} + +/// Creates the upstream and answers the row. +fn create_upstream(service: &ManagementService, tenant: Uuid, body: &Value) -> oagw::UpstreamRow { + service + .create_upstream(tenant, body) + .expect("the upstream is created") +} + +/// A custom transform plugin the calling tenant owns, as its anonymous +/// identifier and its row identifier alongside. +fn custom_plugin(service: &ManagementService, tenant: Uuid, name: &str) -> (Uuid, String) { + custom_plugin_declaring(service, tenant, name, &["on_response"]) +} + +/// A custom transform plugin declaring exactly the phases the caller names. +fn custom_plugin_declaring( + service: &ManagementService, + tenant: Uuid, + name: &str, + phases: &[&str], +) -> (Uuid, String) { + let row = service + .create_plugin( + tenant, + &json!({ + "plugin_type": "transform", + "name": name, + "phases": phases, + "source_code": "def on_response(ctx):\n return ctx\n" + }), + ) + .expect("the custom plugin is created"); + let id = row.plugin.id; + (id, plugin_def::plugin_instance(PluginFamily::Transform, id)) +} + +/// The stored binding rows of one upstream, in position order. +fn upstream_bindings(service: &ManagementService, tenant: Uuid, id: Uuid) -> Vec { + store_of(service).upstream_plugin_rows(tenant, id) +} + +/// The stored binding rows of one route, in position order. +fn route_bindings(service: &ManagementService, tenant: Uuid, id: Uuid) -> Vec { + store_of(service).route_plugin_rows(tenant, id) +} + +/// The identifiers the composed order of one sub-chain runs in, as +/// `layer:position:reference` triples. +fn order_of(steps: &[ComposedStep]) -> Vec { + steps + .iter() + .map(|step| { + format!( + "{}:{}:{}", + if step.upstream_layer() { "u" } else { "r" }, + step.position(), + step.plugin_ref() + ) + }) + .collect() +} + +// --------------------------------------------------------------------------- +// The auth phase. +// --------------------------------------------------------------------------- + +#[test] +fn an_upstream_with_no_auth_plugin_composes_the_noop_behaviour() { + let service = service(); + let tenant = tenant(0x40); + let row = create_upstream(&service, tenant, &upstream_body(Vec::new())); + + let chain = chain::compose( + store_of(&service), + tenant, + &named_of(&service), + ®istries(), + None, + &upstream_bindings(&service, tenant, row.upstream.id), + &[], + ) + .expect("the chain composes"); + assert!(matches!(chain.auth, ComposedAuth::Noop)); + assert!(chain.guard_request.is_empty() && chain.transform_response.is_empty()); +} + +#[test] +fn an_upstream_auth_plugin_is_resolved_from_the_scalar_columns() { + let service = service(); + let tenant = tenant(0x41); + let mut body = upstream_body(Vec::new()); + body.as_object_mut().expect("the body is an object").insert( + String::from("auth"), + json!({ "type": AUTH_APIKEY, "config": { "credential_ref": "cred://api-key" } }), + ); + let row = create_upstream(&service, tenant, &body); + let stored = store_of(&service).get_upstream(tenant, row.upstream.id).expect("stored"); + + let identity = stored.auth_plugin_ref.expect("the scalar column is written"); + let chain = chain::compose( + store_of(&service), + tenant, + &named_of(&service), + ®istries(), + Some(( + identity.as_str(), + stored.auth_plugin_uuid, + &json!({ "credential_ref": "cred://api-key" }), + )), + &upstream_bindings(&service, tenant, row.upstream.id), + &[], + ) + .expect("the chain composes"); + assert!( + matches!(chain.auth, ComposedAuth::Builtin { .. }), + "a built-in auth plugin resolves to its implementation" + ); +} + +#[test] +fn a_custom_auth_plugin_composes_as_its_persisted_row() { + let service = service(); + let tenant = tenant(0x42); + let row = service + .create_plugin( + tenant, + &json!({ + "plugin_type": "auth", + "name": "custom-auth", + "phases": ["on_request"], + "source_code": "def on_request(ctx):\n return ctx\n" + }), + ) + .expect("the custom auth plugin is created"); + let reference = plugin_def::plugin_instance(PluginFamily::Auth, row.plugin.id); + let mut body = upstream_body(Vec::new()); + body.as_object_mut().expect("the body is an object").insert( + String::from("auth"), + json!({ "type": reference, "config": {} }), + ); + let upstream = create_upstream(&service, tenant, &body); + let stored = store_of(&service).get_upstream(tenant, upstream.upstream.id).expect("stored"); + + let identity = stored.auth_plugin_ref.expect("the scalar column is written"); + let chain = chain::compose( + store_of(&service), + tenant, + &named_of(&service), + ®istries(), + Some((identity.as_str(), stored.auth_plugin_uuid, &json!({}))), + &upstream_bindings(&service, tenant, upstream.upstream.id), + &[], + ) + .expect("the chain composes"); + match chain.auth { + ComposedAuth::Custom { row: composed, .. } => { + assert_eq!(composed.id, row.plugin.id, "the row the chain carries is the bound one"); + } + other => panic!("a custom auth plugin composes as its row, not {other:?}"), + } +} + +// --------------------------------------------------------------------------- +// The composed order within a phase. +// --------------------------------------------------------------------------- + +#[test] +fn upstream_positions_run_before_route_positions() { + let service = service(); + let tenant = tenant(0x43); + let upstream = create_upstream( + &service, + tenant, + &upstream_body(vec![ + builtin_item(0, GUARD), + builtin_item(1, TRANSFORM), + ]), + ); + let route = service + .create_route( + tenant, + &json!({ + "upstream_id": upstream.upstream.id, + "match": { "http": { "methods": ["GET"], "path": "/v1/chat" } }, + "priority": 1, + "plugins": { "items": [ + builtin_item(0, TRANSFORM), + builtin_item(1, GUARD) + ] } + }), + ) + .expect("the route is created"); + + let chain = chain::compose( + store_of(&service), + tenant, + &named_of(&service), + ®istries(), + None, + &upstream_bindings(&service, tenant, upstream.upstream.id), + &route_bindings(&service, tenant, route.route.id), + ) + .expect("the chain composes"); + + // `[U1, U2] + [R1, R2]` composes to `[U1, U2, R1, R2]` within a phase: the + // upstream layer runs first, and the stored position decides inside it. + assert_eq!( + order_of(&chain.guard_request), + vec![ + format!("u:0:{GUARD}"), + format!("r:1:{GUARD}"), + ] + ); + assert_eq!( + order_of(&chain.transform_request), + vec![format!("u:1:{TRANSFORM}"), format!("r:0:{TRANSFORM}")] + ); +} + +#[test] +fn the_stored_position_decides_within_one_layer() { + let service = service(); + let tenant = tenant(0x44); + let upstream = create_upstream( + &service, + tenant, + &upstream_body(vec![ + builtin_item(0, TRANSFORM), + builtin_item(1, GUARD), + builtin_item(2, TRANSFORM), + ]), + ); + + let chain = chain::compose( + store_of(&service), + tenant, + &named_of(&service), + ®istries(), + None, + &upstream_bindings(&service, tenant, upstream.upstream.id), + &[], + ) + .expect("the chain composes"); + + assert_eq!( + order_of(&chain.transform_request), + vec![format!("u:0:{TRANSFORM}"), format!("u:2:{TRANSFORM}")] + ); + assert_eq!(order_of(&chain.guard_request), vec![format!("u:1:{GUARD}")]); +} + +// --------------------------------------------------------------------------- +// The per-phase sub-chains. +// --------------------------------------------------------------------------- + +#[test] +fn a_custom_row_declares_the_phases_its_row_stored() { + let service = service(); + let tenant = tenant(0x45); + let (id, reference) = custom_plugin(&service, tenant, "response-only"); + let upstream = create_upstream( + &service, + tenant, + &upstream_body(vec![json!({ + "plugin_ref": reference, + "plugin_uuid": id + })]), + ); + + let chain = chain::compose( + store_of(&service), + tenant, + &named_of(&service), + ®istries(), + None, + &upstream_bindings(&service, tenant, upstream.upstream.id), + &[], + ) + .expect("the chain composes"); + + assert!( + chain.transform_request.is_empty(), + "a row that declares on_response only is absent from the request phase" + ); + assert_eq!(order_of(&chain.transform_response), vec![format!("u:0:{reference}")]); +} + +#[test] +fn every_declared_phase_selects_the_same_composed_order() { + let service = service(); + let tenant = tenant(0x46); + let (id, reference) = custom_plugin_declaring(&service, tenant, "error-phase", &["on_error"]); + let upstream = create_upstream( + &service, + tenant, + &upstream_body(vec![ + builtin_item(0, GUARD), + json!({ "plugin_ref": reference, "plugin_uuid": id, "position": 1 }), + ]), + ); + + let chain = chain::compose( + store_of(&service), + tenant, + &named_of(&service), + ®istries(), + None, + &upstream_bindings(&service, tenant, upstream.upstream.id), + &[], + ) + .expect("the chain composes"); + + assert_eq!(order_of(&chain.guard_request), vec![format!("u:0:{GUARD}")]); + assert_eq!(order_of(&chain.guard_response), vec![format!("u:0:{GUARD}")]); + assert_eq!(order_of(&chain.transform_error), vec![format!("u:1:{reference}")]); + assert!(chain.transform_request.is_empty()); +} + +// --------------------------------------------------------------------------- +// The unresolved reference. +// --------------------------------------------------------------------------- + +#[test] +fn a_binding_that_no_longer_resolves_is_reported_not_dropped() { + let service = service(); + let tenant = tenant(0x47); + // A binding row whose plugin row is gone: the row was deleted after the + // binding was written, which is the stale set the composition is handed. + let missing = Uuid::from_u128(0x4747); + let reference = plugin_def::plugin_instance(PluginFamily::Transform, missing); + + let refused = chain::compose( + store_of(&service), + tenant, + &named_of(&service), + ®istries(), + None, + &[PluginBinding { + position: 0, + plugin_ref: reference.clone(), + plugin_uuid: Some(missing), + config: json!({}), + }], + &[], + ) + .expect_err("the binding no longer resolves"); + assert_eq!(refused.kind, oagw::domain::error::ErrorKind::PluginNotFound); + assert_eq!(refused.http_status(), 503); +} + +#[test] +fn a_binding_of_another_tenant_resolves_to_nothing() { + let service = service(); + let caller = tenant(0x48); + let owner = tenant(0x49); + let (id, reference) = custom_plugin(&service, owner, "foreign"); + + let refused = chain::compose( + store_of(&service), + caller, + &named_of(&service), + ®istries(), + None, + &[PluginBinding { + position: 0, + plugin_ref: reference, + plugin_uuid: Some(id), + config: json!({}), + }], + &[], + ) + .expect_err("the reference is not the calling tenant's"); + assert_eq!(refused.kind, oagw::domain::error::ErrorKind::PluginNotFound); +} diff --git a/gears/system/oagw/oagw/tests/plugin_contract_tests.rs b/gears/system/oagw/oagw/tests/plugin_contract_tests.rs new file mode 100644 index 0000000..270c05f --- /dev/null +++ b/gears/system/oagw/oagw/tests/plugin_contract_tests.rs @@ -0,0 +1,362 @@ +//! Plugin contract and registry tests. +//! +//! Covers `cpt-cf-oagw-dod-plugin-contracts-registries` and the steps of +//! `cpt-cf-oagw-algo-plugin-contract-registry` on the contract surface alone: +//! the three contracts with one registry each, the separation that keeps an +//! auth identifier out of the guard and transform registries, the +//! reserved-versus-unknown distinction a catalog-only identifier answers with, +//! the phases each family declares, and the sandbox limits the surface exposes +//! and enforces none of. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::missing_panics_doc)] + +// @cpt-dod:cpt-cf-oagw-dod-plugin-contracts-registries:p1 +// @cpt-dod:cpt-cf-oagw-dod-plugin-tests:p1 + +use std::sync::Arc; + +use oagw::domain::context::{AuthContext, RequestContext, ResponseContext}; +use oagw::domain::error::ErrorContext; +use oagw::domain::plugin_contract::{ + AuthPlugin, AuthPluginRegistry, GuardDecision, GuardPlugin, GuardPluginRegistry, PluginFamily, + PluginFailure, PluginPhase, PluginResolveError, SandboxLimits, TransformPlugin, + TransformPluginRegistry, SANDBOX_LIMITS, +}; + +/// The four auth identifiers the built-in catalogue backs. +const AUTH_IDS: [&str; 4] = [ + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.noop.v1", + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1", + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred.v1", + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred_basic.v1", +]; + +/// The two catalog-only auth identifiers. +const RESERVED_AUTH_IDS: [&str; 2] = [ + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.basic.v1", + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.bearer.v1", +]; + +/// The one catalog-only guard identifier and the two transform ones. +const RESERVED_OTHER_IDS: [&str; 3] = [ + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.timeout.v1", + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.cors.v1", + "gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.logging.v1", +]; + +/// A do-nothing auth plugin, the stand-in the registry tests resolve. +struct StubAuth; + +#[async_trait::async_trait] +impl AuthPlugin for StubAuth { + fn declares(&self, phase: PluginPhase) -> bool { + matches!(phase, PluginPhase::Auth) + } + + async fn authenticate( + &self, + _ctx: &mut AuthContext, + _config: &serde_json::Value, + ) -> Result<(), PluginFailure> { + Ok(()) + } +} + +/// A do-nothing guard plugin. +struct StubGuard; + +impl GuardPlugin for StubGuard { + fn declares(&self, phase: PluginPhase) -> bool { + matches!(phase, PluginPhase::GuardRequest) + } + + fn guard_request(&self, _ctx: &RequestContext, _config: &serde_json::Value) -> GuardDecision { + GuardDecision::Allow + } + + fn guard_response( + &self, + _ctx: &ResponseContext, + _config: &serde_json::Value, + ) -> GuardDecision { + GuardDecision::Allow + } +} + +/// A do-nothing transform plugin. +struct StubTransform; + +impl TransformPlugin for StubTransform { + fn declares(&self, phase: PluginPhase) -> bool { + matches!(phase, PluginPhase::TransformRequest) + } + + fn transform_request(&self, _ctx: &mut RequestContext, _config: &serde_json::Value) {} + + fn transform_response(&self, _ctx: &mut ResponseContext, _config: &serde_json::Value) {} + + fn transform_error(&self, _ctx: &mut ErrorContext, _config: &serde_json::Value) {} +} + +/// One registry of each family with the stub registered under its own +/// identifier. +fn registries() -> ( + AuthPluginRegistry, + GuardPluginRegistry, + TransformPluginRegistry, +) { + let mut auth = AuthPluginRegistry::new(); + auth.register(AUTH_IDS[0], Arc::new(StubAuth)); + let mut guard = GuardPluginRegistry::new(); + guard.register( + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1", + Arc::new(StubGuard), + ); + let mut transform = TransformPluginRegistry::new(); + transform.register( + "gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1", + Arc::new(StubTransform), + ); + (auth, guard, transform) +} + +#[test] +fn an_identifier_parses_into_its_base_type_and_instance_part() { + // The instance part is the substring after the `~` separator. + let (family, instance) = PluginFamily::parse_identifier(AUTH_IDS[0]) + .expect("a full anonymous GTS identifier parses"); + assert_eq!(family, Some(PluginFamily::Auth)); + assert_eq!(instance, "cf.core.oagw.noop.v1"); + + // A bare UUID names a custom plugin row and parses with no family. + let (family, instance) = + PluginFamily::parse_identifier("0f0e0d0c-0b0a-4918-8267-5a4b3c2d1e0f") + .expect("a bare UUID parses"); + assert_eq!(instance, "0f0e0d0c-0b0a-4918-8267-5a4b3c2d1e0f"); + assert!(family.is_none(), "a bare UUID names no plugin family"); +} + +#[test] +fn an_identifier_that_is_not_a_plugin_gts_id_parses_to_nothing() { + assert!(PluginFamily::parse_identifier("").is_none()); + assert!(PluginFamily::parse_identifier("not-an-identifier").is_none()); + assert!(PluginFamily::parse_identifier("gts.cf.core.oagw.upstream.v1~x.v1").is_none()); +} + +#[test] +fn the_type_literal_names_one_of_three_families() { + assert_eq!(PluginFamily::from_type_literal("auth"), Some(PluginFamily::Auth)); + assert_eq!( + PluginFamily::from_type_literal("guard"), + Some(PluginFamily::Guard) + ); + assert_eq!( + PluginFamily::from_type_literal("transform"), + Some(PluginFamily::Transform) + ); + // No fourth literal exists, and none of the three is spelled differently. + assert_eq!(PluginFamily::from_type_literal("auth_plugin"), None); + assert_eq!(PluginFamily::from_type_literal(""), None); + assert_eq!(PluginFamily::from_type_literal("Auth"), None); +} + +#[test] +fn each_family_declares_the_phases_its_contract_exposes() { + assert_eq!( + PluginFamily::Auth.supported_phases(), + &[PluginPhase::Auth][..] + ); + assert_eq!( + PluginFamily::Guard.supported_phases(), + &[PluginPhase::GuardRequest, PluginPhase::GuardResponse][..] + ); + assert_eq!( + PluginFamily::Transform.supported_phases(), + &[ + PluginPhase::TransformRequest, + PluginPhase::TransformResponse, + PluginPhase::TransformError, + ][..] + ); +} + +#[test] +fn a_registered_entry_resolves_from_its_own_registry() { + let (auth, guard, transform) = registries(); + assert!(auth.resolve(AUTH_IDS[0]).is_ok()); + assert!(guard + .resolve("gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1") + .is_ok()); + assert!(transform + .resolve("gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1") + .is_ok()); +} + +#[test] +fn the_three_registries_stay_separate() { + // An auth identifier is never looked up in the guard or transform registry. + let (auth, guard, transform) = registries(); + assert!(matches!( + guard.resolve(AUTH_IDS[0]), + Err(PluginResolveError::Unknown { .. }) + )); + assert!(matches!( + transform.resolve(AUTH_IDS[0]), + Err(PluginResolveError::Unknown { .. }) + )); + assert!(auth.resolve(AUTH_IDS[0]).is_ok()); +} + +#[test] +fn a_reserved_identifier_is_not_resolvable_in_any_registry() { + let (auth, guard, transform) = registries(); + for identifier in RESERVED_AUTH_IDS { + assert!( + matches!( + auth.resolve(identifier), + Err(PluginResolveError::Reserved { .. }) + ), + "{identifier} is catalog-only" + ); + assert!(matches!( + guard.resolve(identifier), + Err(PluginResolveError::Reserved { .. }) + )); + assert!(matches!( + transform.resolve(identifier), + Err(PluginResolveError::Reserved { .. }) + )); + } + for identifier in RESERVED_OTHER_IDS { + assert!(matches!( + auth.resolve(identifier), + Err(PluginResolveError::Reserved { .. }) + )); + assert!(matches!( + guard.resolve(identifier), + Err(PluginResolveError::Reserved { .. }) + )); + assert!(matches!( + transform.resolve(identifier), + Err(PluginResolveError::Reserved { .. }) + )); + } +} + +#[test] +fn a_reserved_identifier_is_distinguished_from_an_unknown_one() { + let (auth, _guard, _transform) = registries(); + // `basic` is in the catalogue table: reserved. + assert!(matches!( + auth.resolve(RESERVED_AUTH_IDS[0]), + Err(PluginResolveError::Reserved { identifier }) + if identifier == RESERVED_AUTH_IDS[0] + )); + // A typo names nothing the catalogue reserves and no registry holds. + let typo = "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.opauqe.v1"; + assert!(matches!( + auth.resolve(typo), + Err(PluginResolveError::Unknown { identifier }) if identifier == typo + )); +} + +#[test] +fn the_phases_a_registered_entry_declares_are_reported() { + let (auth, guard, transform) = registries(); + assert_eq!( + auth.declared_phases(AUTH_IDS[0]), + Some(vec![PluginPhase::Auth]) + ); + assert_eq!( + guard.declared_phases("gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1"), + Some(vec![PluginPhase::GuardRequest]) + ); + assert_eq!( + transform.declared_phases("gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1"), + Some(vec![PluginPhase::TransformRequest]) + ); + // An unresolvable identifier declares no phase at all. + assert_eq!(auth.declared_phases(RESERVED_AUTH_IDS[0]), None); +} + +#[test] +fn a_registered_entry_does_not_resolve_in_a_phase_it_does_not_declare() { + let (_auth, guard, _transform) = registries(); + // The stub declares the request phase only, so the response phase is not + // resolvable even though the identifier is. + assert!(matches!( + guard.resolve_for_phase( + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1", + PluginPhase::GuardResponse, + ), + Err(PluginResolveError::PhaseNotDeclared { .. }) + )); + assert!(guard + .resolve_for_phase( + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1", + PluginPhase::GuardRequest, + ) + .is_ok()); +} + +#[test] +fn the_sandbox_limits_are_exposed_and_none_is_enforced_here() { + // The limits are part of the contract surface, stated as values a caller + // can read; the enforcement is execution-time work of the data plane. + assert_eq!( + SANDBOX_LIMITS, + SandboxLimits { + network_io: false, + file_io: false, + imports: false, + max_invocation_millis: 100, + max_invocation_memory_bytes: 10 * 1024 * 1024, + } + ); +} + +#[test] +fn an_auth_plugin_injects_into_the_context_it_is_given() { + // The contract consumes the foundation's context types rather than + // declaring its own transport shape. + let mut ctx = AuthContext::new(uuid::Uuid::from_u128(0x10), Some(uuid::Uuid::from_u128(0x20))); + ctx.set_header("authorization", "Bearer token"); + assert_eq!(ctx.header("authorization"), Some("Bearer token")); + assert_eq!(ctx.tenant_id, uuid::Uuid::from_u128(0x10)); + assert_eq!(ctx.subject_id(), Some(uuid::Uuid::from_u128(0x20))); +} + +#[test] +fn a_guard_decision_carries_the_code_and_message_of_a_rejection() { + let allow = GuardDecision::Allow; + assert!(allow.is_allowed()); + let reject = GuardDecision::reject("REQUIRED_HEADER_MISSING", "the x-request-id header is absent"); + assert!(!reject.is_allowed()); + match reject { + GuardDecision::Reject { code, message } => { + assert_eq!(code, "REQUIRED_HEADER_MISSING"); + assert_eq!(message, "the x-request-id header is absent"); + } + GuardDecision::Allow => unreachable!("the decision above rejected"), + } +} + +#[test] +fn a_request_context_carries_the_selector_a_guard_reads() { + let ctx = RequestContext::new( + String::from("GET"), + String::from("/v1/chat"), + Some(String::from("model=gpt")), + ); + assert_eq!(ctx.method, "GET"); + assert_eq!(ctx.path, "/v1/chat"); + assert_eq!(ctx.query.as_deref(), Some("model=gpt")); +} + +#[test] +fn a_response_context_carries_the_status_a_response_guard_reads() { + let mut ctx = ResponseContext::new(200); + ctx.set_header("x-upstream", "a"); + assert_eq!(ctx.status, 200); + assert_eq!(ctx.header("x-upstream"), Some("a")); +} diff --git a/gears/system/oagw/oagw/tests/plugin_lifecycle_tests.rs b/gears/system/oagw/oagw/tests/plugin_lifecycle_tests.rs new file mode 100644 index 0000000..49856a2 --- /dev/null +++ b/gears/system/oagw/oagw/tests/plugin_lifecycle_tests.rs @@ -0,0 +1,427 @@ +//! The plugin row lifecycle: in-use protection, garbage-collection +//! eligibility, and the periodic job. +//! +//! Covers `cpt-cf-oagw-algo-plugin-inuse-gc` and +//! `cpt-cf-oagw-state-plugin-lifecycle` end to end through the management +//! service and the store: the scalar-column reference scan that answers 409, +//! the marking a binding write that removes a reference performs in its own +//! transaction, the clearing a rebinding performs, and the job that marks a +//! row that never gained a reference and deletes only the rows whose TTL has +//! elapsed and whose reference set is still empty when it runs. +//! +//! Realizes `cpt-cf-oagw-dod-plugin-inuse-gc`. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use oagw::control_plane::cache::ControlPlaneCache; +use oagw::control_plane::plugin_def; +use oagw::control_plane::service::{ManagementService, ServiceError}; +use oagw::domain::error::ErrorKind; +use oagw::domain::plugin_contract::PluginFamily; +use oagw::gts::plugin_catalog; +use oagw::store::{OagwStore, PLUGIN_GC_TTL_SECS}; +use serde_json::{Value, json}; +use uuid::Uuid; + +const HTTP_PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; +const CATALOG_ONLY_TRANSFORM: &str = plugin_catalog::CATALOG_ONLY_TRANSFORM_LOGGING; + +/// The next distinct upstream host, so two upstreams of one tenant never +/// collide on the alias the endpoints derive. +fn next_host() -> String { + static HOST: AtomicUsize = AtomicUsize::new(0); + format!("host{}.example.com", HOST.fetch_add(1, Ordering::SeqCst)) +} + +fn tenant(n: u128) -> Uuid { + Uuid::from_u128(n) +} + +/// A management service over its own empty store and cache. +fn service() -> ManagementService { + ManagementService::new( + Arc::new(OagwStore::new()), + &oagw::OagwConfig::default(), + Arc::new(ControlPlaneCache::new()), + ) + .expect("the validators compile") +} + +/// The store the service was built over, for the row-level assertions. +fn store_of(service: &ManagementService) -> &OagwStore { + service.store() +} + +/// An upstream body whose endpoints derive the alias, carrying no family. +fn upstream_body() -> Value { + json!({ + "server": { "endpoints": [{ "scheme": "https", "host": next_host() }] }, + "protocol": HTTP_PROTOCOL, + "tags": [] + }) +} + +/// A custom transform plugin the calling tenant owns, as its anonymous +/// identifier, with its row identifier alongside. +fn custom_plugin(service: &ManagementService, tenant: Uuid, name: &str) -> (Uuid, String) { + let row = service + .create_plugin( + tenant, + &json!({ + "plugin_type": "transform", + "name": name, + "phases": ["on_response"], + "source_code": "def on_response(ctx):\n return ctx\n" + }), + ) + .expect("the custom plugin is created"); + let id = row.plugin.id; + (id, plugin_def::plugin_instance(PluginFamily::Transform, id)) +} + +/// Creates the upstream and answers the row. +fn create_upstream(service: &ManagementService, tenant: Uuid, body: &Value) -> oagw::UpstreamRow { + service + .create_upstream(tenant, body) + .expect("the upstream is created") +} + +/// Binds one custom plugin to a fresh upstream and answers both identifiers. +fn bind_custom_plugin(service: &ManagementService, tenant: Uuid, reference: &str, uuid: Uuid) -> Uuid { + bind_with_body(service, tenant, reference, uuid).0 +} + +/// Binds one custom plugin to a fresh upstream and answers its identifier and +/// the body the upstream was created with, so a replacement can restate the +/// endpoints the alias was derived from. +fn bind_with_body( + service: &ManagementService, + tenant: Uuid, + reference: &str, + uuid: Uuid, +) -> (Uuid, Value) { + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": next_host() }] }, + "protocol": HTTP_PROTOCOL, + "plugins": { "items": [ { "plugin_ref": reference, "plugin_uuid": uuid } ] }, + "tags": [] + }); + let id = create_upstream(service, tenant, &body).upstream.id; + (id, body) +} + +/// The same upstream body with the `plugins` member removed, which is the full +/// replacement that unlinks every binding the upstream carried. +fn without_plugins(body: &Value) -> Value { + let mut replacement = body.clone(); + replacement + .as_object_mut() + .expect("the body is an object") + .remove("plugins"); + replacement +} + +/// The `gc_eligible_at` the row carries, or `None` when the row is gone. +fn eligibility_of(service: &ManagementService, tenant: Uuid, id: Uuid) -> Option> { + store_of(service) + .get_plugin(tenant, id) + .map(|row| row.plugin.gc_eligible_at) +} + +/// The domain failure the service answered, for the status assertions. +fn domain_of(error: &ServiceError) -> &oagw::domain::error::DomainError { + let ServiceError::Domain(error) = error else { + panic!("the refusal is a domain failure, not {error:?}"); + }; + error +} + +/// The 409 the in-use protection answers with. +fn in_use_error(error: &ServiceError) -> String { + let refused = domain_of(error); + assert_eq!(refused.kind, ErrorKind::PluginInUse); + refused.detail.clone() +} + +// --------------------------------------------------------------------------- +// In-use protection: the scalar-column reference scan and the 409 it answers. +// --------------------------------------------------------------------------- + +#[test] +fn an_upstream_binding_row_refuses_the_deletion_with_409() { + let service = service(); + let tenant = tenant(0x30); + let (id, reference) = custom_plugin(&service, tenant, "bound"); + bind_custom_plugin(&service, tenant, &reference, id); + + let refused = service.delete_plugin(tenant, id).expect_err("in use"); + let detail = in_use_error(&refused); + assert_eq!(domain_of(&refused).http_status(), 409); + assert!( + !detail.contains("upstream") && !detail.contains("route"), + "the answer names no referencing resource: {detail}" + ); + assert!( + eligibility_of(&service, tenant, id).is_some(), + "the row is left in place" + ); +} + +#[test] +fn a_route_binding_row_refuses_the_deletion_with_409() { + let service = service(); + let tenant = tenant(0x31); + let (id, reference) = custom_plugin(&service, tenant, "routed"); + let upstream = create_upstream(&service, tenant, &upstream_body()); + let body = json!({ + "upstream_id": upstream.upstream.id, + "match": { "http": { "methods": ["GET"], "path": "/v1/chat" } }, + "priority": 1, + "enabled": true, + "plugins": { "items": [ { "plugin_ref": reference, "plugin_uuid": id } ] } + }); + service + .create_route(tenant, &body) + .expect("the route is created"); + + let refused = service.delete_plugin(tenant, id).expect_err("in use"); + assert_eq!(domain_of(&refused).http_status(), 409); + assert!( + eligibility_of(&service, tenant, id).is_some(), + "the row is left in place" + ); +} + +#[test] +fn an_upstream_auth_column_refuses_the_deletion_with_409() { + let service = service(); + let tenant = tenant(0x32); + let row = service + .create_plugin( + tenant, + &json!({ + "plugin_type": "auth", + "name": "apikey", + "phases": ["on_request"], + "source_code": "def authenticate(ctx):\n return ctx\n" + }), + ) + .expect("the auth plugin is created"); + let id = row.plugin.id; + let reference = plugin_def::plugin_instance(PluginFamily::Auth, id); + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": HTTP_PROTOCOL, + "auth": { "type": reference, "config": { "secret_ref": "cred://api-key" } }, + "tags": [] + }); + create_upstream(&service, tenant, &body); + + let refused = service.delete_plugin(tenant, id).expect_err("in use"); + assert_eq!(domain_of(&refused).http_status(), 409); +} + +#[test] +fn an_unlinked_plugin_is_deleted_and_its_binding_rows_are_untouched() { + let service = service(); + let tenant = tenant(0x33); + let (id, _) = custom_plugin(&service, tenant, "loose"); + + assert!( + service.delete_plugin(tenant, id).expect("not in use"), + "the deletion reports the row it removed" + ); + assert!(eligibility_of(&service, tenant, id).is_none(), "the row is gone"); +} + +#[test] +fn a_named_plugin_has_no_row_and_no_lifecycle() { + let service = service(); + let tenant = tenant(0x34); + + let refused = service + .delete_plugin(tenant, Uuid::max()) + .expect_err("no row to delete"); + assert_eq!(domain_of(&refused).http_status(), 404, "a named plugin has no row"); + + let report = service.run_plugin_garbage_collection().expect("the job runs"); + assert!( + report.marked.is_empty() && report.collected.is_empty(), + "a store with no custom row is left untouched: {report:?}" + ); + assert!( + plugin_catalog::is_catalog_only(CATALOG_ONLY_TRANSFORM), + "the catalog-only identifier stays reserved" + ); +} + +// --------------------------------------------------------------------------- +// Garbage-collection eligibility: the marking and clearing a binding write does. +// --------------------------------------------------------------------------- + +#[test] +fn losing_the_last_reference_marks_the_row_and_rebinding_clears_it() { + let service = service(); + let tenant = tenant(0x35); + let (id, reference) = custom_plugin(&service, tenant, "rebind"); + let (parent, body) = bind_with_body(&service, tenant, &reference, id); + + // The full replacement that omits the item unlinks the only reference. + service + .replace_upstream(tenant, parent, &without_plugins(&body)) + .expect("the replacement is accepted"); + + let marked = eligibility_of(&service, tenant, id).expect("the row survives"); + assert!( + marked.is_some(), + "a plugin that lost its last reference is marked" + ); + + bind_custom_plugin(&service, tenant, &reference, id); + assert_eq!( + eligibility_of(&service, tenant, id), + Some(None), + "a plugin that gained a reference loses the marking" + ); +} + +#[test] +fn the_marking_lands_in_the_same_transaction_as_the_binding_write() { + let service = service(); + let tenant = tenant(0x36); + let (id, reference) = custom_plugin(&service, tenant, "atomic"); + let (parent, body) = bind_with_body(&service, tenant, &reference, id); + + // A replacement that fails validation writes nothing, so the marking the + // unlinked row would have earned is written no more than the rows are. + let mut refused = body.clone(); + refused.as_object_mut().expect("the body is an object").insert( + String::from("plugins"), + json!({ "items": [{ "plugin_ref": plugin_catalog::CATALOG_ONLY_GUARD_TIMEOUT }] }), + ); + assert!( + service.replace_upstream(tenant, parent, &refused).is_err(), + "the reserved identifier is refused" + ); + assert_eq!( + eligibility_of(&service, tenant, id), + Some(None), + "no marking is written for a failed write" + ); +} + +#[test] +fn last_used_at_is_never_written_by_the_lifecycle() { + let service = service(); + let tenant = tenant(0x37); + let (id, reference) = custom_plugin(&service, tenant, "unused"); + let (parent, body) = bind_with_body(&service, tenant, &reference, id); + service + .replace_upstream(tenant, parent, &without_plugins(&body)) + .expect("the replacement is accepted"); + service.run_plugin_garbage_collection().expect("the job runs"); + + let row = store_of(&service).get_plugin(tenant, id).expect("the row is stored"); + assert!( + row.plugin.last_used_at.is_none(), + "no lifecycle operation writes last_used_at" + ); +} + +// --------------------------------------------------------------------------- +// The periodic job: marking, collecting, and leaving everything else alone. +// --------------------------------------------------------------------------- + +#[test] +fn the_job_marks_a_plugin_that_was_never_bound() { + let service = service(); + let tenant = tenant(0x38); + let (id, _) = custom_plugin(&service, tenant, "never-bound"); + + let report = service.run_plugin_garbage_collection().expect("the job runs"); + assert_eq!(report.marked, vec![id], "the job's own scan marks the row"); + let marked = eligibility_of(&service, tenant, id).expect("the row survives"); + assert!( + marked > Some(0), + "the marking stores the instant the TTL elapses" + ); + assert_eq!( + report.collected, + Vec::::new(), + "a freshly marked row is not collectable on the run that marks it" + ); +} + +#[test] +fn the_job_marks_nothing_that_is_already_linked() { + let service = service(); + let tenant = tenant(0x39); + let (id, reference) = custom_plugin(&service, tenant, "linked"); + bind_custom_plugin(&service, tenant, &reference, id); + + let report = service.run_plugin_garbage_collection().expect("the job runs"); + assert!( + report.marked.is_empty() && report.collected.is_empty(), + "a referenced row is neither marked nor collected: {report:?}" + ); + assert_eq!( + eligibility_of(&service, tenant, id), + Some(None), + "the row's eligibility is left unset" + ); +} + +#[test] +fn the_job_collects_only_the_rows_whose_ttl_has_elapsed() { + let service = service(); + let tenant = tenant(0x3a); + let (early, _) = custom_plugin(&service, tenant, "early"); + let (late, _) = custom_plugin(&service, tenant, "late"); + let (linked, reference) = custom_plugin(&service, tenant, "kept"); + bind_custom_plugin(&service, tenant, &reference, linked); + + // One row marked at the epoch, one marked when the job runs. + store_of(&service).mark_plugin_eligible(tenant, early, 0); + store_of(&service).mark_plugin_eligible(tenant, late, PLUGIN_GC_TTL_SECS); + assert_ne!( + eligibility_of(&service, tenant, early), + eligibility_of(&service, tenant, late), + "the two marked rows carry different instants" + ); + + let report = service + .run_plugin_garbage_collection_at(PLUGIN_GC_TTL_SECS + 10) + .expect("the job runs"); + assert_eq!(report.collected, vec![early], "only the expired row is collected"); + assert!(eligibility_of(&service, tenant, early).is_none(), "the row is gone"); + assert!( + eligibility_of(&service, tenant, late).is_some(), + "the row inside its TTL is left alone" + ); + assert_eq!( + eligibility_of(&service, tenant, linked), + Some(None), + "the linked row is left alone" + ); +} + +#[test] +fn the_job_never_collects_a_row_that_gained_a_reference_before_it_ran() { + let service = service(); + let tenant = tenant(0x3b); + let (id, reference) = custom_plugin(&service, tenant, "reclaimed"); + store_of(&service).mark_plugin_eligible(tenant, id, 0); + bind_custom_plugin(&service, tenant, &reference, id); + + let report = service + .run_plugin_garbage_collection_at(PLUGIN_GC_TTL_SECS + 10) + .expect("the job runs"); + assert!( + report.collected.is_empty(), + "a rebound row is never collected: {report:?}" + ); + assert!(eligibility_of(&service, tenant, id).is_some(), "the row is left in place"); +} diff --git a/gears/system/oagw/oagw/tests/plugin_management_tests.rs b/gears/system/oagw/oagw/tests/plugin_management_tests.rs new file mode 100644 index 0000000..46fc01a --- /dev/null +++ b/gears/system/oagw/oagw/tests/plugin_management_tests.rs @@ -0,0 +1,443 @@ +//! Custom plugin management tests. +//! +//! Covers the three flows of DECOMPOSITION §2.4 against the store: the create +//! that stores the verbatim source and never parses or executes it, the one +//! validation error that names every failing property, the duplicate-name +//! 400 that is a validation failure and not a conflict row, the 404 that +//! never distinguishes a foreign identifier from a named plugin, the +//! tenant-scoped list with its closed OData surface, the source path that +//! returns the source alone, and the deletion that answers nothing. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::missing_panics_doc)] + +use std::sync::Arc; + +use serde_json::{Value, json}; +use uuid::Uuid; + +use oagw::control_plane::cache::ControlPlaneCache; +use oagw::control_plane::plugin_def; +use oagw::control_plane::service::ManagementService; +use oagw::control_plane::service::ServiceError; +use oagw::config::OagwConfig; +use oagw::domain::error::ErrorKind; +use oagw::domain::plugin_contract::PluginFamily; +use oagw::store::OagwStore; + +fn tenant(n: u128) -> Uuid { + Uuid::from_u128(n) +} + +/// A management service over its own empty store and cache. +fn service() -> ManagementService { + let cache = Arc::new(ControlPlaneCache::new()); + ManagementService::new( + Arc::new(OagwStore::new()), + &OagwConfig::default(), + Arc::clone(&cache), + ) + .expect("the validators compile") +} + +/// A valid transform plugin body. +fn transform_body(name: &str) -> Value { + json!({ + "plugin_type": "transform", + "name": name, + "description": "redacts response headers", + "config_schema": { "type": "object" }, + "phases": ["on_response"], + "source_code": "def on_response(ctx):\n return ctx\n" + }) +} + +/// Removes one root property from a body. +fn without(body: &Value, key: &str) -> Value { + let mut body = body.clone(); + body.as_object_mut() + .expect("the body is an object") + .remove(key); + body +} + +/// The catalogue row of a refused operation, asserting it is a domain failure. +fn domain_of(error: &ServiceError) -> &oagw::DomainError { + assert!(!error.is_storage(), "the operation is a domain failure"); + error.domain() +} + +#[test] +fn a_created_plugin_is_stored_verbatim_and_unreferenced() { + let service = service(); + let tenant = tenant(0x10); + let row = service + .create_plugin(tenant, &transform_body("redact-headers")) + .expect("the plugin is created"); + + assert_eq!(row.plugin.name, "redact-headers"); + assert_eq!(row.plugin.plugin_type, "transform"); + assert_eq!( + row.plugin.source_code, + "def on_response(ctx):\n return ctx\n", + "the source is stored verbatim" + ); + assert_eq!(row.plugin.description.as_deref(), Some("redacts response headers")); + assert!(row.plugin.config_schema.is_some()); + assert_eq!(row.plugin.phases, ["on_response"]); + assert!(row.plugin.last_used_at.is_none(), "no use has been recorded"); + assert!( + row.plugin.gc_eligible_at.is_none(), + "the create sets no gc_eligible_at" + ); + assert_eq!( + plugin_def::plugin_instance(PluginFamily::Transform, row.plugin.id), + format!("gts.cf.core.oagw.transform_plugin.v1~{}", row.plugin.id), + "the created id answers as the family's anonymous GTS instance" + ); +} + +#[test] +fn a_plugin_without_the_optional_members_is_stored_without_them() { + let service = service(); + let body = json!({ + "plugin_type": "auth", + "name": "inject-key", + "source_code": "def on_request(ctx): pass" + }); + let row = service + .create_plugin(tenant(0x10), &body) + .expect("the plugin is created"); + assert_eq!(row.plugin.description, None); + assert_eq!(row.plugin.config_schema, None); + assert_eq!(row.plugin.phases, Vec::::new()); +} + +#[test] +fn the_source_is_never_parsed_or_executed_at_create_time() { + let service = service(); + let body = json!({ + "plugin_type": "transform", + "name": "not-starlark", + "source_code": "this is not starlark {{{", + "phases": ["on_request"] + }); + let row = service + .create_plugin(tenant(0x10), &body) + .expect("create-time validation covers the declared fields alone"); + assert_eq!(row.plugin.source_code, "this is not starlark {{{"); +} + +#[test] +fn one_validation_error_names_every_failing_property() { + let service = service(); + let body = json!({ + "plugin_type": "transform", + "name": "broken", + "config_schema": "not an object", + "phases": ["on_error", "on_nonexistent"], + "source_code": "def on_request(ctx): pass", + "unexpected": true + }); + let refusal = service + .create_plugin(tenant(0x10), &body) + .expect_err("the body fails four properties"); + let error = domain_of(&refusal); + assert_eq!(error.kind, ErrorKind::ValidationError); + for property in [ + "unknown property 'unexpected' at root", + "config_schema", + "phases[1]", + ] { + assert!( + error.detail.contains(property), + "'{property}' is named by: {}", + error.detail + ); + } +} + +#[test] +fn a_phase_outside_the_family_set_is_refused() { + let service = service(); + let body = json!({ + "plugin_type": "guard", + "name": "guard-with-error-phase", + "phases": ["on_error"], + "source_code": "def on_request(ctx): pass" + }); + let refusal = service + .create_plugin(tenant(0x10), &body) + .expect_err("the guard family admits no error phase"); + let error = domain_of(&refusal); + assert_eq!(error.kind, ErrorKind::ValidationError); + assert!(error.detail.contains("phases[0]"), "{}", error.detail); +} + +#[test] +fn a_plugin_type_that_names_no_family_is_refused() { + let service = service(); + let body = json!({ + "plugin_type": "throttle", + "name": "unknown-family", + "source_code": "def on_request(ctx): pass" + }); + let refusal = service + .create_plugin(tenant(0x10), &body) + .expect_err("no family answers 'throttle'"); + let error = domain_of(&refusal); + assert_eq!(error.kind, ErrorKind::ValidationError); + assert!(error.detail.contains("plugin_type"), "{}", error.detail); +} + +#[test] +fn an_empty_source_and_an_empty_name_are_refused() { + let service = service(); + let body = json!({ + "plugin_type": "transform", + "name": "", + "source_code": "" + }); + let refusal = service + .create_plugin(tenant(0x10), &body) + .expect_err("both properties fail"); + let error = domain_of(&refusal); + assert!(error.detail.contains("name"), "{}", error.detail); + assert!(error.detail.contains("source_code"), "{}", error.detail); + + for key in ["name", "source_code"] { + let refusal = service + .create_plugin(tenant(0x10), &without(&transform_body("absent"), key)) + .expect_err("the property is required"); + assert!( + domain_of(&refusal).detail.contains(key), + "'{key}' is named by: {}", + domain_of(&refusal).detail + ); + } +} + +#[test] +fn a_name_held_by_the_tenant_is_a_validation_failure_and_writes_nothing() { + let service = service(); + let owner = tenant(0x10); + service + .create_plugin(owner, &transform_body("redact-headers")) + .expect("the first create succeeds"); + + let refusal = service + .create_plugin(owner, &transform_body("redact-headers")) + .expect_err("the name is already held"); + let error = domain_of(&refusal); + assert_eq!(error.kind, ErrorKind::ValidationError, "no 409 row exists"); + assert!( + error.detail.contains("name"), + "'name' is named by: {}", + error.detail + ); + assert_eq!( + service.list_plugins(owner, "").expect("the list reads").items.len(), + 1, + "the refused create wrote no row" + ); +} + +#[test] +fn a_name_is_held_within_one_tenant_only() { + let service = service(); + let body = transform_body("shared-name"); + service + .create_plugin(tenant(0x10), &body) + .expect("the first tenant holds the name"); + service + .create_plugin(tenant(0x20), &body) + .expect("another tenant holds the same name freely"); +} + +#[test] +fn a_read_never_distinguishes_a_foreign_row_from_a_missing_one() { + let service = service(); + let owner = tenant(0x10); + let row = service + .create_plugin(owner, &transform_body("redact-headers")) + .expect("the plugin is created"); + + let read = service.read_plugin(owner, row.plugin.id).expect("read"); + assert_eq!(read.plugin.id, row.plugin.id); + + for other in [ + // A plugin that does not exist at all. + Uuid::from_u128(0x99), + // The same plugin as another tenant addresses it: a foreign row. + row.plugin.id, + ] { + let refusal = service + .read_plugin(tenant(0x20), other) + .expect_err("no row of the calling tenant matches"); + let error = domain_of(&refusal); + assert_eq!(error.kind, ErrorKind::RouteNotFound, "{}", error.detail); + } +} + +#[test] +fn the_source_path_returns_the_source_alone() { + let service = service(); + let owner = tenant(0x10); + let row = service + .create_plugin(owner, &transform_body("redact-headers")) + .expect("the plugin is created"); + + let source = service + .read_plugin_source(owner, row.plugin.id) + .expect("the source reads"); + assert_eq!(source, row.plugin.source_code); + + let refusal = service + .read_plugin_source(tenant(0x20), row.plugin.id) + .expect_err("a foreign row reads nothing"); + assert_eq!(domain_of(&refusal).kind, ErrorKind::RouteNotFound); +} + +#[test] +fn the_list_is_tenant_scoped_and_bounded() { + let service = service(); + let owner = tenant(0x10); + for name in ["redact-headers", "inject-key", "strip-prefix"] { + service + .create_plugin(owner, &transform_body(name)) + .expect("the plugin is created"); + } + let other = tenant(0x20); + service + .create_plugin( + other, + &json!({ + "plugin_type": "auth", + "name": "another-tenants", + "source_code": "def on_request(ctx): pass" + }), + ) + .expect("another tenant's plugin is created"); + + let page = service.list_plugins(owner, "").expect("the list reads"); + assert_eq!(page.items.len(), 3, "only the calling tenant's rows"); + assert_eq!(page.projection, Vec::::new()); + assert_eq!(page.top, 50, "the declared default page size"); + + let bounded = service + .list_plugins(owner, "$top=2&$skip=1") + .expect("the parameters are admitted"); + assert_eq!(bounded.items.len(), 2); + assert_eq!(bounded.top, 2); + + let filtered = service + .list_plugins(owner, "$filter=type eq 'transform'&$top=100") + .expect("the filter is admitted"); + assert_eq!(filtered.items.len(), 3); + assert_eq!(filtered.top, 100, "the ceiling is admitted"); + + let by_name = service + .list_plugins(owner, "$filter=name eq 'inject-key'") + .expect("the name filter is admitted"); + assert_eq!(by_name.items.len(), 1); + assert_eq!(by_name.items[0].plugin.name, "inject-key"); + + let foreign = tenant(0x20); + let unknown = service + .list_plugins(foreign, "") + .expect("the list reads"); + assert_eq!(unknown.items.len(), 1); +} + +#[test] +fn the_list_surface_admits_no_ordering() { + let service = service(); + let owner = tenant(0x10); + service + .create_plugin(owner, &transform_body("redact-headers")) + .expect("the plugin is created"); + + let refusal = service + .list_plugins(owner, "$orderby=name") + .expect_err("the plugin table declares no ordering"); + let error = domain_of(&refusal); + assert_eq!(error.kind, ErrorKind::ValidationError); + assert!( + error.detail.contains("$orderby"), + "'$orderby' is named by: {}", + error.detail + ); +} + +#[test] +fn the_list_surface_refuses_an_unexposed_projection() { + let service = service(); + let owner = tenant(0x10); + service + .create_plugin(owner, &transform_body("redact-headers")) + .expect("the plugin is created"); + + let refusal = service + .list_plugins(owner, "$select=id,tenant_id") + .expect_err("tenant_id is not a plugin list property"); + assert!(domain_of(&refusal).detail.contains("$select")); + + let projected = service + .list_plugins(owner, "$select=name,source_code") + .expect("the projection is admitted"); + assert_eq!(projected.projection, ["name", "source_code"]); +} + +#[test] +fn a_deleted_plugin_answers_nothing_afterwards() { + let service = service(); + let owner = tenant(0x10); + let row = service + .create_plugin(owner, &transform_body("redact-headers")) + .expect("the plugin is created"); + + let deleted = service + .delete_plugin(owner, row.plugin.id) + .expect("the deletion is applied"); + assert!(deleted); + + let refusal = service + .read_plugin(owner, row.plugin.id) + .expect_err("the row is gone"); + assert_eq!(domain_of(&refusal).kind, ErrorKind::RouteNotFound); + + let refusal = service + .delete_plugin(owner, row.plugin.id) + .expect_err("the second deletion addresses nothing"); + assert_eq!(domain_of(&refusal).kind, ErrorKind::RouteNotFound); +} + +#[test] +fn a_deletion_never_reaches_another_tenants_row() { + let service = service(); + let owner = tenant(0x10); + let row = service + .create_plugin(owner, &transform_body("redact-headers")) + .expect("the plugin is created"); + + let refusal = service + .delete_plugin(tenant(0x20), row.plugin.id) + .expect_err("no row of the calling tenant matches"); + assert_eq!(domain_of(&refusal).kind, ErrorKind::RouteNotFound); + assert!( + service.read_plugin(tenant(0x10), row.plugin.id).is_ok(), + "the foreign deletion left the row in place" + ); +} + +#[test] +fn an_unbound_plugin_deletion_answers_no_reference_is_held() { + let service = service(); + let owner = tenant(0x10); + let row = service + .create_plugin(owner, &transform_body("redact-headers")) + .expect("the plugin is created"); + assert!( + !OagwStore::new().plugin_in_use(owner, row.plugin.id), + "no reference set holds a freshly created plugin" + ); +} diff --git a/gears/system/oagw/oagw/tests/provisioning_tests.rs b/gears/system/oagw/oagw/tests/provisioning_tests.rs new file mode 100644 index 0000000..3420ce4 --- /dev/null +++ b/gears/system/oagw/oagw/tests/provisioning_tests.rs @@ -0,0 +1,770 @@ +//! GTS type-catalogue provisioning tests. +//! +//! Covers `cpt-cf-oagw-algo-type-catalog-provisioning`, +//! `cpt-cf-oagw-flow-type-provisioning` and `cpt-cf-oagw-dod-gts-type-catalog`: +//! the batch shape (7 base schemas, 2 protocol instances, 21 error instances, +//! no built-in plugin instance), parents-before-children ordering, per-entry +//! failure classification, idempotent re-run over identical content, the +//! fail-immediately behaviour on a catastrophic SDK error, and the +//! real-registry resolvability the gear-foundation feature accepts on: after +//! the ready commit, every provisioned entry resolves back byte-identical, +//! the 21 error identifiers resolve — the two management-conflict ones among +//! them — and an identical re-registration does not fail startup. A per-entry +//! refusal is asserted to reach the ERROR log with its identifier. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::missing_panics_doc)] + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use oagw::gts::catalog::catalog_entities; +use oagw::gts::provisioning::{CatalogProvisioned, ProvisioningError, provision}; +use oagw::gts::{ERR_CORS_METHOD_NOT_ALLOWED, ERR_CORS_ORIGIN_NOT_ALLOWED}; +use oagw::{ + AUTH_PLUGIN_TYPE, ERR_ALIAS_CONFLICT, ERR_AUTH_FAILED, ERR_CIRCUIT_BREAKER_OPEN, + ERR_DOWNSTREAM_ERROR, ERR_INVALID_TARGET_HOST, ERR_LINK_UNAVAILABLE, ERR_MATCH_CONFLICT, + ERR_MISSING_TARGET_HOST, ERR_PAYLOAD_TOO_LARGE, ERR_PLUGIN_IN_USE, ERR_PLUGIN_NOT_FOUND, + ERR_PROTOCOL_ERROR, ERR_RATE_LIMIT_EXCEEDED, ERR_ROUTE_NOT_FOUND, ERR_SECRET_NOT_FOUND, + ERR_STREAM_ABORTED, ERR_TIMEOUT_CONNECTION, ERR_TIMEOUT_IDLE, ERR_TIMEOUT_REQUEST, + ERR_UNKNOWN_TARGET_HOST, ERR_VALIDATION, GUARD_PLUGIN_TYPE, TRANSFORM_PLUGIN_TYPE, +}; +use serde_json::Value; +use toolkit_canonical_errors::CanonicalError; +use types_registry::config::TypesRegistryConfig; +use types_registry::domain::local_client::TypesRegistryLocalClient; +use types_registry::domain::TypesRegistryService; +use types_registry::infra::InMemoryGtsRepository; +use types_registry_sdk::testing::{make_test_instance, make_test_type_schema}; +use types_registry_sdk::{ + GtsInstance, GtsTypeSchema, InstanceQuery, RegisterResult, TypeSchemaQuery, TypesRegistryClient, +}; +use uuid::Uuid; + +/// How the fake should answer the next `register` call. +#[derive(Debug, Default)] +enum Behaviour { + /// Every entry is accepted. + #[default] + AcceptAll, + /// A catastrophic backend failure — the call itself errors. + Catastrophic(&'static str), + /// The named GTS identifiers are refused; everything else is accepted. + Refuse(Vec<&'static str>), +} + +/// A hand-rolled [`TypesRegistryClient`] that records what provisioning +/// submitted and can be configured to refuse chosen identifiers. +#[derive(Debug, Default)] +struct RecordingRegistryClient { + behaviour: Mutex, + submitted: Mutex>, + register_calls: Mutex, + /// Entities the registry already holds, so the identical-content path is + /// exercised. + pre_held: Vec, + read_backs: Mutex>, +} + +impl RecordingRegistryClient { + /// A client that accepts everything. + fn accepting() -> Arc { + Arc::default() + } + + /// A client whose `register` fails catastrophically. + fn catastrophic(reason: &'static str) -> Arc { + Arc::new(Self { + behaviour: Mutex::new(Behaviour::Catastrophic(reason)), + ..Self::default() + }) + } + + /// A client that refuses the named identifiers and accepts the rest. + fn refusing(refused: Vec<&'static str>) -> Arc { + Arc::new(Self { + behaviour: Mutex::new(Behaviour::Refuse(refused)), + ..Self::default() + }) + } + + /// A client that already holds the named entries, answering read-backs for + /// them from a store seeded with identical content. + fn pre_seeded(ids: Vec) -> Arc { + Arc::new(Self { + pre_held: ids, + ..Self::default() + }) + } + + /// The identifiers submitted, in submission order. + fn submitted_ids(&self) -> Vec { + self.submitted + .lock() + .expect("submitted lock") + .iter() + .map(entity_id) + .collect() + } + + /// The number of `register` calls. + fn register_calls(&self) -> usize { + *self.register_calls.lock().expect("register call lock") + } + + /// The identifiers a read-back was attempted for. + fn read_backs(&self) -> Vec { + self.read_backs.lock().expect("read-back lock").clone() + } +} + +/// The GTS identifier carried by an entity, from `$id` (type schema) or `id` +/// (instance). The registry strips the `gts://` scheme off `$id`; so does this +/// helper, so assertions speak in GTS identifiers. +fn entity_id(entity: &Value) -> String { + let raw = entity + .get("$id") + .or_else(|| entity.get("id")) + .and_then(Value::as_str) + .unwrap_or_default(); + raw.strip_prefix("gts://").unwrap_or(raw).to_owned() +} + +/// Builds the `GtsTypeSchema` a read-back answers with. +fn held_type_schema(type_id: &str) -> GtsTypeSchema { + make_test_type_schema(type_id) +} + +/// Builds the `GtsInstance` a read-back answers with. +fn held_instance(id: &str, content: Value) -> GtsInstance { + make_test_instance(id, content) +} + +#[async_trait] +impl TypesRegistryClient for RecordingRegistryClient { + async fn register(&self, entities: Vec) -> Result, CanonicalError> { + *self.register_calls.lock().expect("register call lock") += 1; + + match &*self.behaviour.lock().expect("behaviour lock") { + Behaviour::Catastrophic(reason) => { + Err(CanonicalError::internal(String::from(*reason)).create()) + } + Behaviour::AcceptAll => { + let ids: Vec = entities.iter().map(entity_id).collect(); + self.submitted + .lock() + .expect("submitted lock") + .extend(entities); + Ok(ids + .into_iter() + .map(|gts_id| RegisterResult::Ok { gts_id }) + .collect()) + } + Behaviour::Refuse(refused) => { + let ids: Vec = entities.iter().map(entity_id).collect(); + self.submitted + .lock() + .expect("submitted lock") + .extend(entities); + Ok(ids + .into_iter() + .map(|gts_id| { + if refused.contains(>s_id.as_str()) { + RegisterResult::Err { + gts_id: Some(gts_id), + error: CanonicalError::internal("duplicate content").create(), + } + } else { + RegisterResult::Ok { gts_id } + } + }) + .collect()) + } + } + } + + async fn register_type_schemas( + &self, + _type_schemas: Vec, + ) -> Result, CanonicalError> { + Ok(vec![]) + } + + async fn get_type_schema(&self, type_id: &str) -> Result { + self.read_backs + .lock() + .expect("read-back lock") + .push(type_id.to_owned()); + if self.pre_held.iter().any(|held| held == type_id) { + Ok(held_type_schema(type_id)) + } else { + Err(types_registry_sdk::testing::not_found(type_id)) + } + } + + async fn get_type_schema_by_uuid( + &self, + type_uuid: Uuid, + ) -> Result { + Err(types_registry_sdk::testing::not_found( + type_uuid.to_string(), + )) + } + + async fn get_type_schemas( + &self, + type_ids: Vec, + ) -> HashMap> { + type_ids + .into_iter() + .map(|id| (id.clone(), Err(types_registry_sdk::testing::not_found(&id)))) + .collect() + } + + async fn get_type_schemas_by_uuid( + &self, + type_uuids: Vec, + ) -> HashMap> { + type_uuids + .into_iter() + .map(|uuid| { + let err = types_registry_sdk::testing::not_found(uuid.to_string()); + (uuid, Err(err)) + }) + .collect() + } + + async fn list_type_schemas( + &self, + _query: TypeSchemaQuery, + ) -> Result, CanonicalError> { + Ok(vec![]) + } + + async fn register_instances( + &self, + _instances: Vec, + ) -> Result, CanonicalError> { + Ok(vec![]) + } + + async fn get_instance(&self, id: &str) -> Result { + self.read_backs + .lock() + .expect("read-back lock") + .push(id.to_owned()); + if self.pre_held.iter().any(|held| held == id) { + Ok(held_instance(id, serde_json::json!({ "id": id }))) + } else { + Err(types_registry_sdk::testing::not_found(id)) + } + } + + async fn get_instance_by_uuid(&self, uuid: Uuid) -> Result { + Err(types_registry_sdk::testing::not_found(uuid.to_string())) + } + + async fn get_instances( + &self, + ids: Vec, + ) -> HashMap> { + ids.into_iter() + .map(|id| (id.clone(), Err(types_registry_sdk::testing::not_found(&id)))) + .collect() + } + + async fn get_instances_by_uuid( + &self, + uuids: Vec, + ) -> HashMap> { + uuids + .into_iter() + .map(|uuid| { + let err = types_registry_sdk::testing::not_found(uuid.to_string()); + (uuid, Err(err)) + }) + .collect() + } + + async fn list_instances( + &self, + _query: InstanceQuery, + ) -> Result, CanonicalError> { + Ok(vec![]) + } +} + +/// The batch, without its duplicate identifiers, in submission order. +fn batch() -> Vec { + catalog_entities().expect("catalogue assembles from its frozen inputs") +} + +const BASE_TYPES: [&str; 7] = [ + "gts.cf.core.oagw.upstream.v1~", + "gts.cf.core.oagw.route.v1~", + "gts.cf.core.oagw.protocol.v1~", + "gts.cf.core.oagw.auth_plugin.v1~", + "gts.cf.core.oagw.guard_plugin.v1~", + "gts.cf.core.oagw.transform_plugin.v1~", + "gts.cf.core.errors.err.v1~", +]; + +const PROTOCOL_INSTANCES: [&str; 2] = [ + "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", + "gts.cf.core.oagw.protocol.v1~cf.core.oagw.grpc.v1", +]; + +#[tokio::test] +async fn happy_path_submits_every_entry_and_reports_success() { + let client = RecordingRegistryClient::accepting(); + + let outcome: CatalogProvisioned = provision(client.as_ref()) + .await + .expect("provisioning succeeds"); + + assert_eq!( + outcome.total, + 42, + "7 base types + 2 protocol + 21 error + 12 plugin" + ); + assert_eq!(outcome.succeeded, 42); + assert_eq!(client.register_calls(), 1, "exactly one batch register"); + assert_eq!(client.submitted_ids().len(), 42); +} + +/// The parent type id of a GTS identifier: everything up to and including the +/// last `~`. `None` for a base type itself. +fn chain_parent(gts_id: &str) -> Option { + gts_id + .split_once('~') + .map(|(prefix, _)| format!("{prefix}~")) +} + +#[tokio::test] +async fn parents_precede_their_children_in_the_batch() { + let entities = batch(); + + let mut seen: Vec = Vec::new(); + for entity in &entities { + let gts_id = entity_id(entity); + if let Some(parent) = chain_parent(>s_id) + && parent != gts_id + { + assert!( + seen.contains(&parent), + "{gts_id} appears before its parent {parent}" + ); + } + seen.push(gts_id); + } + + for base in BASE_TYPES { + let index = seen + .iter() + .position(|id| id == base) + .expect("base type present"); + assert!(index < 7, "{base} must sit in the parents-first prefix"); + } +} + +#[tokio::test] +async fn the_batch_carries_the_declared_catalogue_shape() { + let entities = batch(); + let ids = entities.iter().map(entity_id).collect::>(); + + let error_type = "gts.cf.core.errors.err.v1~"; + let base_types = ids.iter().filter(|id| id.ends_with('~')).count(); + let protocol = ids + .iter() + .filter(|id| PROTOCOL_INSTANCES.contains(&id.as_str())) + .count(); + let errors = ids + .iter() + .filter(|id| id.starts_with(error_type) && *id != error_type) + .count(); + + assert_eq!(base_types, 7, "7 base type schemas"); + assert_eq!(protocol, 2, "2 protocol instances"); + assert_eq!(errors, 21, "21 distinct error instances"); + assert_eq!(ids.len(), 42, "the 30 foundation rows plus 12 plugin rows"); + + for base in BASE_TYPES { + assert!(ids.contains(&base.to_owned()), "{base} must be present"); + } + for instance in PROTOCOL_INSTANCES { + assert!( + ids.contains(&instance.to_owned()), + "{instance} must be present" + ); + } +} + +#[tokio::test] +async fn the_catalogue_carries_the_twelve_plugin_instances() { + // `cpt-cf-oagw-dod-builtin-catalogue`: the plugin-system feature registers + // all twelve plugin identifiers of the built-in and catalog-only catalogue + // in the types-registry, backed and catalog-only alike. + let entities = batch(); + let ids = entities.iter().map(entity_id).collect::>(); + + let owned = |plugin_type: &str| ids.iter().filter(|id| id.starts_with(plugin_type)).count(); + assert_eq!( + owned(AUTH_PLUGIN_TYPE), + 7, + "the base schema plus 4 backed and 2 catalog-only auth ids" + ); + assert_eq!( + owned(GUARD_PLUGIN_TYPE), + 4, + "the base schema plus 1 backed and 2 catalog-only guard ids" + ); + assert_eq!( + owned(TRANSFORM_PLUGIN_TYPE), + 4, + "the base schema plus 1 backed and 2 catalog-only transform ids" + ); + for identifier in oagw::gts::plugin_catalog::all() { + assert!(ids.contains(&identifier.to_owned()), "{identifier} present"); + } +} + +#[tokio::test] +async fn base_type_schemas_are_json_schema_objects_with_a_gts_id() { + let entities = batch(); + for entity in entities.iter().take(7) { + assert!( + entity + .get("$id") + .and_then(Value::as_str) + .unwrap_or_default() + .ends_with('~'), + "base type carries `$id` with the `gts://` uri: {entity}" + ); + assert_eq!( + entity["type"], "object", + "base type declares `type: object`" + ); + assert!( + entity.get("$schema").is_some(), + "base type declares its JSON Schema meta-schema" + ); + } +} + +#[tokio::test] +async fn error_instances_carry_their_catalogue_row() { + const ERROR_TYPE: &str = "gts.cf.core.errors.err.v1~"; + let entities = batch(); + let errors = entities + .iter() + .filter(|e| { + let id = entity_id(e); + id.starts_with(ERROR_TYPE) && id != ERROR_TYPE + }) + .collect::>(); + + assert_eq!(errors.len(), 21, "21 error instance rows"); + for error in errors { + assert!(error.get("title").and_then(Value::as_str).is_some()); + assert!(error.get("http_status").and_then(Value::as_u64).is_some()); + assert!(error.get("retriable").and_then(Value::as_bool).is_some()); + assert!( + error.get("$id").is_none(), + "instances carry `id`, not `$id`" + ); + } +} + +#[tokio::test] +async fn one_per_item_failure_fails_the_phase_but_submits_the_rest() { + let refused = "gts.cf.core.errors.err.v1~cf.oagw.rate_limit.exceeded.v1"; + let client = RecordingRegistryClient::refusing(vec![refused]); + + let error: ProvisioningError = provision(client.as_ref()) + .await + .expect_err("one refusal fails the phase"); + + match error { + ProvisioningError::EntriesFailed { + failed, + total, + identifiers, + } => { + assert_eq!(failed, 1); + assert_eq!(total, 42); + assert_eq!(identifiers, refused); + } + other => panic!("expected EntriesFailed, got {other:?}"), + } + + assert_eq!(client.register_calls(), 1); + assert_eq!( + client.submitted_ids().len(), + 42, + "every entry is still submitted" + ); + assert_eq!(client.read_backs(), vec![refused.to_owned()]); +} + +#[tokio::test] +async fn every_refused_identifier_is_recorded_and_read_back() { + let client = RecordingRegistryClient::refusing(vec![ + "gts.cf.core.oagw.upstream.v1~", + "gts.cf.core.oagw.protocol.v1~cf.core.oagw.grpc.v1", + ]); + + let error: ProvisioningError = provision(client.as_ref()) + .await + .expect_err("two refusals fail the phase"); + + let ProvisioningError::EntriesFailed { + failed, + identifiers, + .. + } = error + else { + panic!("expected EntriesFailed"); + }; + assert_eq!(failed, 2); + assert!( + identifiers.contains("gts.cf.core.oagw.upstream.v1~") + && identifiers.contains("gts.cf.core.oagw.protocol.v1~cf.core.oagw.grpc.v1"), + "both failing identifiers are named: {identifiers}" + ); + assert_eq!(client.read_backs().len(), 2); +} + +#[tokio::test] +async fn a_catastrophic_sdk_failure_fails_immediately_without_a_retry() { + let client = RecordingRegistryClient::catastrophic("backend unavailable"); + + let error: ProvisioningError = provision(client.as_ref()) + .await + .expect_err("catastrophic failure fails the phase"); + + match error { + ProvisioningError::Registry(message) => { + assert!(!message.is_empty(), "the SDK failure is carried: {message}"); + } + other => panic!("expected Registry, got {other:?}"), + } + + assert_eq!(client.register_calls(), 1, "no retry, no partial re-issue"); + assert!(client.submitted_ids().is_empty()); + assert!(client.read_backs().is_empty()); +} + +#[tokio::test] +async fn an_identical_content_rerun_succeeds_against_a_seeded_registry() { + let entities = batch(); + let held = entities.iter().map(entity_id).collect::>(); + let client = RecordingRegistryClient::pre_seeded(held); + + let outcome = provision(client.as_ref()).await.expect("re-run succeeds"); + assert_eq!(outcome.succeeded, 42); +} + +#[tokio::test] +async fn the_provisioning_error_names_every_failing_identifier() { + let client = RecordingRegistryClient::refusing(vec![ + "gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1", + "gts.cf.core.errors.err.v1~cf.oagw.alias.conflict.v1", + "gts.cf.core.oagw.route.v1~", + ]); + + let error: ProvisioningError = provision(client.as_ref()) + .await + .expect_err("three refusals fail the phase"); + + let rendered = error.to_string(); + assert!( + rendered.contains("3 of 42"), + "the error reports the failure ratio: {rendered}" + ); + assert!(rendered.contains("cf.oagw.route.not_found.v1")); + assert!(rendered.contains("cf.oagw.alias.conflict.v1")); + assert!(rendered.contains("cf.core.oagw.route.v1~")); +} + +#[tokio::test] +async fn provisioning_success_is_reproducible_across_runs() { + let first = RecordingRegistryClient::accepting(); + let second = RecordingRegistryClient::accepting(); + + let a = provision(first.as_ref()).await.expect("first run"); + let b = provision(second.as_ref()).await.expect("second run"); + + assert_eq!(a, b, "the same frozen inputs produce the same outcome"); + assert_eq!(first.submitted_ids(), second.submitted_ids()); +} + +/// The 21 distinct error identifiers the 22 `ErrorKind` variants map onto: +/// 19 carry the DESIGN §3.3 catalogue rows, `RouteError` and `ValidationError` +/// share one identifier, and the two §1.5-added management-conflict variants +/// take the last two. Ordered as the catalogue restates it. +const ERROR_IDENTIFIERS: [&str; 21] = [ + ERR_VALIDATION, + ERR_MISSING_TARGET_HOST, + ERR_INVALID_TARGET_HOST, + ERR_UNKNOWN_TARGET_HOST, + ERR_AUTH_FAILED, + ERR_ROUTE_NOT_FOUND, + ERR_PLUGIN_IN_USE, + ERR_ALIAS_CONFLICT, + ERR_MATCH_CONFLICT, + ERR_PAYLOAD_TOO_LARGE, + ERR_RATE_LIMIT_EXCEEDED, + ERR_SECRET_NOT_FOUND, + ERR_PROTOCOL_ERROR, + ERR_DOWNSTREAM_ERROR, + ERR_STREAM_ABORTED, + ERR_LINK_UNAVAILABLE, + ERR_CIRCUIT_BREAKER_OPEN, + ERR_PLUGIN_NOT_FOUND, + ERR_TIMEOUT_CONNECTION, + ERR_TIMEOUT_REQUEST, + ERR_TIMEOUT_IDLE, +]; + +/// A real, in-process types-registry client over the in-memory repository — +/// the client shape the `ClientHub` supplies at runtime — together with the +/// service handle the types-registry gear drives through its ready commit. +fn local_registry() -> (TypesRegistryLocalClient, Arc) { + let repo = Arc::new(InMemoryGtsRepository::new( + TypesRegistryConfig::default().to_gts_config(), + )); + let service = Arc::new(TypesRegistryService::new( + repo, + TypesRegistryConfig::default(), + )); + (TypesRegistryLocalClient::new(Arc::clone(&service)), service) +} + +/// Resolves one provisioned entity back through the registry, by kind, and +/// hands the stored content back for the byte-identical comparison. +async fn resolved_content(client: &TypesRegistryLocalClient, id: &str) -> Value { + if id.ends_with('~') { + client + .get_type_schema(id) + .await + .unwrap_or_else(|e| panic!("{id} resolves as a base type schema: {e}")) + .raw_schema + } else { + client + .get_instance(id) + .await + .unwrap_or_else(|e| panic!("{id} resolves as an instance: {e}")) + .object + } +} + +/// After the ready commit the registry holds, every provisioned entry +/// resolves back through it with the content that was submitted. +#[tokio::test] +async fn every_provisioned_entry_resolves_back_through_a_real_registry() { + let (client, service) = local_registry(); + + let outcome = provision(&client) + .await + .expect("the catalogue provisions against a real registry"); + assert_eq!(outcome.succeeded, outcome.total, "every entry is accepted"); + + // The startup order the runtime drives: the types-registry gear commits + // the configuration phase to ready in its own post_init, which validates + // everything the batch held. + service + .switch_to_ready() + .expect("the provisioned catalogue validates in full"); + assert!(service.is_ready(), "the registry reports readiness"); + + let entities = batch(); + assert_eq!(entities.len(), outcome.total); + for entity in &entities { + let id = entity_id(entity); + let stored = resolved_content(&client, &id).await; + assert_eq!(stored, *entity, "{id} round-trips byte-identical"); + } +} + +/// The 21 error identifiers resolve as instances after provisioning, the two +/// §1.5-added management-conflict identifiers among them, and the two bare +/// CORS problem types stay outside the catalogue. +#[tokio::test] +async fn all_twenty_one_error_identifiers_resolve_after_provisioning() { + let (client, service) = local_registry(); + provision(&client) + .await + .expect("the catalogue provisions"); + service + .switch_to_ready() + .expect("the provisioned catalogue validates in full"); + + assert_eq!( + ERROR_IDENTIFIERS.len(), + 21, + "22 variants over 21 identifiers" + ); + for identifier in ERROR_IDENTIFIERS { + client + .get_instance(identifier) + .await + .unwrap_or_else(|e| panic!("{identifier} resolves: {e}")); + } + assert!( + ERROR_IDENTIFIERS.contains(&ERR_ALIAS_CONFLICT) && ERROR_IDENTIFIERS.contains(&ERR_MATCH_CONFLICT), + "the two §1.5-added management-conflict identifiers are among them" + ); + + let ids: HashSet = batch().iter().map(entity_id).collect(); + for cors in [ERR_CORS_ORIGIN_NOT_ALLOWED, ERR_CORS_METHOD_NOT_ALLOWED] { + assert!( + !ids.contains(cors), + "{cors} is a bare problem type, not a catalogue row" + ); + } +} + +/// Re-registering an entry with byte-identical content does not fail startup: +/// the second pass over a registry that already holds the catalogue answers +/// accepted for every entry, not a conflict. +#[tokio::test] +async fn an_identical_rerun_succeeds_against_a_real_registry() { + let (client, service) = local_registry(); + + let first = provision(&client) + .await + .expect("the first startup provisions the catalogue"); + service + .switch_to_ready() + .expect("the provisioned catalogue validates in full"); + + let second = provision(&client) + .await + .expect("an identical re-registration does not fail startup"); + assert_eq!(second, first); + assert_eq!(second.succeeded, second.total); +} + +#[tokio::test] +#[tracing_test::traced_test] +async fn a_per_entry_refusal_is_logged_with_its_identifier() { + let refused = "gts.cf.core.errors.err.v1~cf.oagw.alias.conflict.v1"; + let client = RecordingRegistryClient::refusing(vec![refused]); + + let error = provision(client.as_ref()) + .await + .expect_err("one refusal fails the phase"); + assert!( + matches!(error, ProvisioningError::EntriesFailed { .. }), + "the phase fails with the per-entry error: {error}" + ); + + assert!( + logs_contain(refused), + "the failing identifier reaches the ERROR log" + ); + assert!( + logs_contain("readiness stays withheld"), + "the withheld readiness reaches the ERROR log" + ); +} diff --git a/gears/system/oagw/oagw/tests/proxy_api_tests.rs b/gears/system/oagw/oagw/tests/proxy_api_tests.rs new file mode 100644 index 0000000..19db938 --- /dev/null +++ b/gears/system/oagw/oagw/tests/proxy_api_tests.rs @@ -0,0 +1,587 @@ +//! The proxy API on the wire, before the dial. +//! +//! Covers the authorize, resolve, match, select, and validate rows of +//! `cpt-cf-oagw-dod-proxy-api` and `cpt-cf-oagw-dod-error-source`: the 401 a +//! subjectless request answers with, the 403 a refused `invoke` permission and +//! a surface with no `AuthZ` client answer with, the 404 the unmatched alias +//! and the unmatched route answer with, the 400 the suffix, the query, and the +//! target-host header answer with, the 503 a disabled upstream answers with, +//! and the `X-OAGW-Error-Source: gateway` every one of those carries. The +//! answers are produced without dialing anything, so no outbound socket opens +//! in this suite; the exchange itself is `proxy_forward_tests.rs`'s. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::missing_panics_doc)] + +use std::sync::Arc; + +use axum::Router; +use axum::body::Body; +use axum::http::{HeaderName, Method, Request, StatusCode}; +use serde_json::{Value, json}; +use tower::ServiceExt; +use uuid::Uuid; + +use authz_resolver_sdk::api::AuthZResolverClient; +use authz_resolver_sdk::constraints::{Constraint, EqPredicate, Predicate}; +use authz_resolver_sdk::error::AuthZResolverError; +use authz_resolver_sdk::models::{EvaluationRequest, EvaluationResponse, EvaluationResponseContext}; +use authz_resolver_sdk::pep::PolicyEnforcer; +use toolkit_security::SecurityContext; +use toolkit_security::pep_properties; + +use oagw::OagwConfig; +use oagw::control_plane::cache::ControlPlaneCache; +use oagw::control_plane::service::ManagementService; +use oagw::store::OagwStore; +use oagw::OagwState; + +const HTTP_PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; +const TENANT: u128 = 0x31; +const ERROR_SOURCE: &str = "x-oagw-error-source"; + +/// The `AuthZ` PDP the allowing stub stands in for. +struct Allowing; + +#[async_trait::async_trait] +impl AuthZResolverClient for Allowing { + async fn evaluate( + &self, + _request: EvaluationRequest, + ) -> Result { + Ok(EvaluationResponse { + decision: true, + context: EvaluationResponseContext { + constraints: vec![Constraint { + predicates: vec![Predicate::Eq(EqPredicate { + property: String::from(pep_properties::OWNER_TENANT_ID), + value: json!(TENANT.to_string()), + })], + }], + deny_reason: None, + }, + }) + } +} + +/// The `AuthZ` PDP the refusing stub stands in for. +struct Denying; + +#[async_trait::async_trait] +impl AuthZResolverClient for Denying { + async fn evaluate( + &self, + _request: EvaluationRequest, + ) -> Result { + Ok(EvaluationResponse { + decision: false, + context: EvaluationResponseContext::default(), + }) + } +} + +/// One mounted surface over its own store. +struct Surface { + router: Router, + store: Arc, +} + +/// Builds a surface whose `AuthZ` client the caller states. +fn surface(enforcer: Option) -> Surface { + let store = Arc::new(OagwStore::new()); + let cache = Arc::new(ControlPlaneCache::new()); + let config = OagwConfig { + allow_http_upstream: true, + ..OagwConfig::default() + }; + let service = Arc::new( + ManagementService::new(Arc::clone(&store), &config, Arc::clone(&cache)) + .expect("the validators compile"), + ); + let state = Arc::new(OagwState::new( + Arc::new(config), + Arc::clone(&store), + service, + enforcer.map(Arc::new), + None, + Arc::clone(&cache), + )); + Surface { + router: oagw::api::rest::register_management_routes(Router::new(), state), + store, + } +} + +/// The authenticated subject a request carries. +fn subject() -> SecurityContext { + SecurityContext::builder() + .subject_id(Uuid::from_u128(TENANT)) + .subject_tenant_id(Uuid::from_u128(TENANT)) + .build() + .expect("the subject is complete") +} + +/// Issues one proxy request and returns its response. +async fn issue( + app: Router, + method: Method, + uri: &str, + authenticated: bool, + headers: &[(&str, &str)], + body: &[u8], +) -> axum::http::Response { + let mut builder = Request::builder().method(method).uri(uri); + if authenticated { + builder = builder.extension(subject()); + } + for (name, value) in headers { + builder = builder.header(*name, *value); + } + let request = builder.body(Body::from(body.to_vec())).expect("the request builds"); + app.oneshot(request).await.expect("oneshot resolves") +} + +/// The status, body, and error source of one answer. +async fn answer( + app: Router, + method: Method, + uri: &str, + authenticated: bool, + headers: &[(&str, &str)], + body: &[u8], +) -> (StatusCode, Value, Option) { + let response = issue(app, method, uri, authenticated, headers, body).await; + let status = response.status(); + let source = response + .headers() + .get(ERROR_SOURCE) + .and_then(|value| value.to_str().ok()) + .map(String::from); + let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .expect("the body reads"); + let document = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).expect("the problem body is JSON") + }; + (status, document, source) +} + +/// Stores one upstream whose alias is the one its endpoint set derives, with +/// the enabled state and header rules the caller states, and returns its +/// instance identifier. +async fn stored_upstream( + app: &Router, + host: &str, + enabled: bool, + headers: Option, +) -> String { + let mut body = json!({ + "alias": host, + "server": { "endpoints": [{ "scheme": "https", "host": host, "port": 443 }] }, + "protocol": HTTP_PROTOCOL, + "tags": ["proxy"] + }); + if !enabled { + body["enabled"] = json!(false); + } + if let Some(headers) = headers { + body["headers"] = headers; + } + let response = issue( + app.clone(), + Method::POST, + "/oagw/v1/upstreams", + true, + &[], + serde_json::to_vec(&body).expect("the body serializes").as_slice(), + ) + .await; + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .expect("the body reads"); + let document: Value = serde_json::from_slice(&bytes).expect("the body is JSON"); + assert_eq!(status, StatusCode::CREATED, "{document}"); + document["id"].as_str().expect("the instance id").to_owned() +} + +/// Stores one route for an upstream and returns its instance identifier. +async fn stored_route(app: &Router, instance: &str, http: Value) -> String { + let key = oagw::gts::parse_gts_instance(oagw::UPSTREAM_TYPE, instance) + .expect("the instance parses") + .to_string(); + let body = json!({ "upstream_id": key, "match": http, "priority": 10 }); + let response = issue( + app.clone(), + Method::POST, + "/oagw/v1/routes", + true, + &[], + serde_json::to_vec(&body).expect("the body serializes").as_slice(), + ) + .await; + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .expect("the body reads"); + let document: Value = serde_json::from_slice(&bytes).expect("the body is JSON"); + assert_eq!(status, StatusCode::CREATED, "{document}"); + document["id"].as_str().expect("the instance id").to_owned() +} + +/// An enabled upstream on `upstream.example.com` and its `GET /api` route. +async fn wired() -> Surface { + let surface = surface(Some(PolicyEnforcer::new(Arc::new(Allowing)))); + let upstream = stored_upstream(&surface.router, "upstream.example.com", true, None).await; + stored_route( + &surface.router, + &upstream, + json!({ "http": { "methods": ["GET"], "path": "/api" } }), + ) + .await; + surface +} + +#[tokio::test] +async fn a_request_without_a_subject_is_answered_401_before_any_resolution() { + let surface = surface(Some(PolicyEnforcer::new(Arc::new(Allowing)))); + let (status, document, source) = answer( + surface.router, + Method::GET, + "/oagw/v1/proxy/upstream.example.com/api", + false, + &[], + b"", + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(source.as_deref(), Some("gateway")); + assert_eq!(document["status"], 401, "{document}"); +} + +#[tokio::test] +async fn a_refused_invoke_permission_is_answered_403() { + let surface = surface(Some(PolicyEnforcer::new(Arc::new(Denying)))); + let (status, document, source) = answer( + surface.router, + Method::GET, + "/oagw/v1/proxy/upstream.example.com/api", + true, + &[], + b"", + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{document}"); + assert_eq!(source.as_deref(), Some("gateway")); +} + +#[tokio::test] +async fn a_surface_with_no_authz_client_answers_403() { + let surface = surface(None); + let (status, _, source) = answer( + surface.router, + Method::GET, + "/oagw/v1/proxy/upstream.example.com/api", + true, + &[], + b"", + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(source.as_deref(), Some("gateway")); +} + +#[tokio::test] +async fn an_alias_no_chain_element_holds_is_answered_404() { + let surface = wired().await; + let (status, document, source) = answer( + surface.router, + Method::GET, + "/oagw/v1/proxy/absent.example.com/api", + true, + &[], + b"", + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND, "{document}"); + assert_eq!(source.as_deref(), Some("gateway")); + assert_eq!(document["status"], 404); +} + +#[tokio::test] +async fn a_request_no_route_matches_is_answered_404() { + let surface = wired().await; + let (status, document, _) = answer( + surface.router, + Method::GET, + "/oagw/v1/proxy/upstream.example.com/absent", + true, + &[], + b"", + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND, "{document}"); +} + +#[tokio::test] +async fn a_method_no_route_declares_is_answered_404() { + let surface = wired().await; + let (status, document, _) = answer( + surface.router, + Method::POST, + "/oagw/v1/proxy/upstream.example.com/api", + true, + &[], + b"", + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND, "{document}"); +} + +#[tokio::test] +async fn an_alias_that_cannot_be_normalized_is_answered_404() { + let surface = wired().await; + let (status, _, source) = answer( + surface.router, + Method::GET, + "/oagw/v1/proxy/not_a_host/api", + true, + &[], + b"", + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(source.as_deref(), Some("gateway")); +} + +#[tokio::test] +async fn a_disabled_upstream_is_answered_503_and_never_dialed() { + let surface = surface(Some(PolicyEnforcer::new(Arc::new(Allowing)))); + let upstream = stored_upstream(&surface.router, "upstream.example.com", false, None).await; + stored_route( + &surface.router, + &upstream, + json!({ "http": { "methods": ["GET"], "path": "/api" } }), + ) + .await; + let (status, document, _) = answer( + surface.router, + Method::GET, + "/oagw/v1/proxy/upstream.example.com/api", + true, + &[], + b"", + ) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{document}"); +} + +#[tokio::test] +async fn a_suffix_to_a_route_that_rejects_it_is_answered_400() { + let surface = surface(Some(PolicyEnforcer::new(Arc::new(Allowing)))); + let upstream = stored_upstream(&surface.router, "upstream.example.com", true, None).await; + stored_route( + &surface.router, + &upstream, + json!({ + "http": { + "methods": ["GET"], + "path": "/api", + "path_suffix_mode": "disabled" + } + }), + ) + .await; + let (status, document, _) = answer( + surface.router, + Method::GET, + "/oagw/v1/proxy/upstream.example.com/api/deeper", + true, + &[], + b"", + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{document}"); +} + +#[tokio::test] +async fn a_query_parameter_the_route_does_not_allow_is_answered_400() { + let surface = surface(Some(PolicyEnforcer::new(Arc::new(Allowing)))); + let upstream = stored_upstream(&surface.router, "upstream.example.com", true, None).await; + stored_route( + &surface.router, + &upstream, + json!({ + "http": { + "methods": ["GET"], + "path": "/api", + "query_allowlist": ["model"] + } + }), + ) + .await; + let (status, document, _) = answer( + surface.router, + Method::GET, + "/oagw/v1/proxy/upstream.example.com/api?other=1", + true, + &[], + b"", + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{document}"); +} + +#[tokio::test] +async fn an_unparseable_target_host_value_is_answered_400() { + let surface = wired().await; + let (status, document, _) = answer( + surface.router, + Method::GET, + "/oagw/v1/proxy/upstream.example.com/api", + true, + &[("x-oagw-target-host", "us vendor.com")], + b"", + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{document}"); +} + +#[tokio::test] +async fn a_target_host_no_endpoint_declares_is_answered_400() { + let surface = surface(Some(PolicyEnforcer::new(Arc::new(Allowing)))); + let upstream = stored_upstream(&surface.router, "upstream.example.com", true, None).await; + stored_route( + &surface.router, + &upstream, + json!({ "http": { "methods": ["GET"], "path": "/api" } }), + ) + .await; + let (status, document, _) = answer( + surface.router, + Method::GET, + "/oagw/v1/proxy/upstream.example.com/api", + true, + &[("x-oagw-target-host", "other.vendor.com")], + b"", + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{document}"); +} + +#[tokio::test] +async fn a_declared_length_that_disagrees_with_the_body_is_answered_400() { + let surface = wired().await; + let (status, document, _) = answer( + surface.router, + Method::GET, + "/oagw/v1/proxy/upstream.example.com/api", + true, + &[("content-length", "5")], + b"abc", + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{document}"); +} + +#[tokio::test] +async fn the_proxy_answers_are_problem_documents_of_the_error_catalogue() { + let surface = wired().await; + let (_, document, source) = answer( + surface.router, + Method::GET, + "/oagw/v1/proxy/absent.example.com/api", + true, + &[], + b"", + ) + .await; + assert_eq!(source.as_deref(), Some("gateway")); + assert_eq!(document["status"], 404); + assert!(document["type"].is_string(), "{document}"); + assert!(document["title"].is_string(), "{document}"); + assert!(document["instance"].is_string(), "{document}"); +} + +#[tokio::test] +async fn the_proxy_surface_reads_the_rows_the_management_surface_wrote() { + let surface = wired().await; + let (status, document, _) = answer( + surface.router, + Method::GET, + "/oagw/v1/proxy/upstream.example.com/api", + true, + &[], + b"", + ) + .await; + // The dial to the fictional endpoint fails, and the failure is a gateway + // answer about the upstream, which is the observable the resolution and + // the match produced their work: the request was never refused by either. + assert_ne!(status, StatusCode::NOT_FOUND, "{document}"); + assert_ne!(status, StatusCode::FORBIDDEN, "{document}"); + assert_eq!( + document["status"], + serde_json::json!(status.as_u16()), + "the problem document names its own status" + ); +} + +#[tokio::test] +async fn the_store_the_proxy_resolution_reads_is_the_management_store() { + let surface = wired().await; + let rows = surface + .store + .list_upstreams(Uuid::from_u128(TENANT)); + assert_eq!(rows.len(), 1, "one tenant holds the wired rows"); +} + +#[tokio::test] +async fn a_header_the_transport_cannot_carry_never_reaches_the_validation() { + // The HTTP layer refuses a value with a CR or LF in it before the handler + // extracts anything, so the injection check of the inbound validation is + // exercised on the header map it is given, which the unit suite + // (`proxy_validate_tests.rs`) drives directly. + let surface = wired().await; + let built = axum::http::HeaderValue::from_str("value injected"); + assert!(built.is_ok(), "the transport admits a plain value"); + let refused = axum::http::HeaderValue::from_str("value\r\ninjected"); + assert!(refused.is_err(), "the transport refuses the injection vector"); + let (status, _, _) = answer( + surface.router, + Method::GET, + "/oagw/v1/proxy/upstream.example.com/api", + true, + &[("x-evil", "value injected")], + b"", + ) + .await; + assert_ne!(status, StatusCode::BAD_REQUEST, "the plain value is admitted"); +} + +/// The error-source header name the answers carry, as a typed name. +#[test] +fn the_error_source_header_is_the_documented_name() { + assert_eq!( + HeaderName::from_static("x-oagw-error-source").as_str(), + ERROR_SOURCE + ); +} + +#[tokio::test] +async fn a_body_that_declares_more_than_the_limit_is_answered_413() { + let surface = wired().await; + let (status, document, source) = answer( + surface.router, + Method::GET, + "/oagw/v1/proxy/upstream.example.com/api", + true, + &[("content-length", "100000001")], + b"tiny", + ) + .await; + assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE, "{document}"); + assert_eq!(source.as_deref(), Some("gateway")); + assert_eq!(document["type"], "gts.cf.core.errors.err.v1~cf.oagw.payload.too_large.v1"); +} diff --git a/gears/system/oagw/oagw/tests/proxy_cache_tests.rs b/gears/system/oagw/oagw/tests/proxy_cache_tests.rs new file mode 100644 index 0000000..cf62e80 --- /dev/null +++ b/gears/system/oagw/oagw/tests/proxy_cache_tests.rs @@ -0,0 +1,149 @@ +//! Data Plane L1 cache tests. +//! +//! Covers the steps of `cpt-cf-oagw-algo-dp-cache` and the acceptance rows of +//! `cpt-cf-oagw-dod-dp-cache`: the key shapes of ADR 0005, the hit, the insert +//! with its 1000-entry eviction, the prefix flush a configuration write +//! triggers, the no-TTL posture that leaves an entry in place when no +//! notification reaches it, and the rule that the cache holds configurations +//! and no response body. + +use std::sync::Arc; + +use oagw::data_plane::{DpCache, DP_CACHE_CAPACITY}; +use oagw::domain::proxy::{AliasDerivation, ResolvedUpstream}; +use oagw::domain::{Endpoint, EndpointHost, HeadersConfig, Scheme}; +use uuid::Uuid; + +const TENANT: Uuid = Uuid::from_u128(0x11); +const OTHER_TENANT: Uuid = Uuid::from_u128(0x12); +const UPSTREAM: Uuid = Uuid::from_u128(0x21); + +/// A minimal resolved configuration; the cache never reads into it. +fn resolved(upstream_id: Uuid) -> ResolvedUpstream { + ResolvedUpstream { + cors: None, + tenant_id: TENANT, + upstream_id, + alias: String::from("api.example.com"), + alias_derivation: AliasDerivation::Explicit, + endpoints: vec![Endpoint { + scheme: Scheme::Https, + host: EndpointHost::parse("api.example.com").expect("a valid endpoint host"), + port: Some(8443), + }], + protocol: String::from(oagw::PROTOCOL_HTTP), + enabled: true, + headers: HeadersConfig::default(), + rate_limit: None, + plugins: None, + route_candidates: Vec::new(), + } +} + +#[test] +fn the_two_key_shapes_of_adr_0005_are_the_ones_the_cache_holds() { + assert_eq!( + DpCache::upstream_key(TENANT, "api.example.com"), + format!("upstream:{TENANT}:api.example.com") + ); + assert_eq!( + DpCache::route_key(UPSTREAM, "GET", "/v1/chat"), + format!("route:{UPSTREAM}:GET:/v1/chat") + ); +} + +#[test] +fn a_miss_answers_nothing_and_a_populated_entry_answers_its_value() { + let cache = DpCache::new(); + let key = DpCache::upstream_key(TENANT, "api.example.com"); + assert!(cache.get(&key).is_none(), "an empty cache answers no entry"); + + cache.insert(key.clone(), Arc::new(resolved(UPSTREAM)), Vec::new()); + let hit = cache.get(&key).expect("the inserted entry is read back"); + assert_eq!(hit.upstream_id, UPSTREAM); + assert_eq!(hit.alias, "api.example.com"); + assert_eq!(cache.len(), 1); + assert!(!cache.is_empty()); +} + +#[test] +fn an_entry_lives_until_a_notification_flushes_it() { + let cache = DpCache::new(); + let key = DpCache::upstream_key(TENANT, "api.example.com"); + cache.insert(key.clone(), Arc::new(resolved(UPSTREAM)), Vec::new()); + // No notification reaches the cache: no TTL expiry, no periodic sync, and + // the entry is still there afterwards. + assert!( + cache.get(&key).is_some(), + "no notification leaves the entry in place" + ); +} + +#[test] +fn a_tenant_flush_drops_that_tenant_s_upstream_keys_and_route_keys() { + let cache = DpCache::new(); + let route = DpCache::route_key(UPSTREAM, "GET", "/v1"); + cache.insert( + DpCache::upstream_key(TENANT, "api.example.com"), + Arc::new(resolved(UPSTREAM)), + vec![route.clone()], + ); + let kept = DpCache::upstream_key(OTHER_TENANT, "api.example.com"); + cache.insert(kept.clone(), Arc::new(resolved(Uuid::from_u128(0x22))), Vec::new()); + + cache.flush_tenant(TENANT); + assert!( + cache.get(&DpCache::upstream_key(TENANT, "api.example.com")).is_none(), + "the written tenant's entry is flushed" + ); + assert!( + cache.get(&kept).is_some(), + "an unrelated tenant's entry survives the flush" + ); + assert!(cache.get(&route).is_none(), "the route keys are flushed with it"); +} + +#[test] +fn an_upstream_flush_leaves_the_tenant_s_other_entries() { + let cache = DpCache::new(); + let gone = DpCache::upstream_key(TENANT, "api.example.com"); + let stays = DpCache::upstream_key(TENANT, "other.example.com"); + cache.insert(gone.clone(), Arc::new(resolved(UPSTREAM)), Vec::new()); + cache.insert( + stays.clone(), + Arc::new(resolved(Uuid::from_u128(0x23))), + Vec::new(), + ); + + cache.flush_upstream(TENANT, UPSTREAM); + assert!(cache.get(&gone).is_none()); + assert!(cache.get(&stays).is_some()); +} + +#[test] +fn the_capacity_evicts_the_least_recently_used_entry() { + let cache = DpCache::new(); + let first = DpCache::upstream_key(TENANT, "first.example.com"); + cache.insert(first.clone(), Arc::new(resolved(UPSTREAM)), Vec::new()); + // The read makes the first entry the most recent one, so the last insert is + // the least recently used at the ceiling. + assert!(cache.get(&first).is_some()); + for index in 0..DP_CACHE_CAPACITY { + let alias = format!("pool-{index}.example.com"); + cache.insert( + DpCache::upstream_key(OTHER_TENANT, &alias), + Arc::new(resolved(Uuid::from_u128(0x30 + u128::from(index as u32)))), + Vec::new(), + ); + } + assert!( + cache.get(&first).is_none(), + "the least recently used entry is evicted at the ceiling" + ); + assert_eq!(cache.len(), DP_CACHE_CAPACITY); +} + +#[test] +fn the_capacity_is_the_1000_entries_adr_0006_fixes() { + assert_eq!(DP_CACHE_CAPACITY, 1000); +} diff --git a/gears/system/oagw/oagw/tests/proxy_chain_tests.rs b/gears/system/oagw/oagw/tests/proxy_chain_tests.rs new file mode 100644 index 0000000..7ebeb2c --- /dev/null +++ b/gears/system/oagw/oagw/tests/proxy_chain_tests.rs @@ -0,0 +1,389 @@ +//! Chain execution around one proxy exchange. +//! +//! Covers `cpt-cf-oagw-algo-chain-execute` and the acceptance rows of +//! `cpt-cf-oagw-dod-chain-execution` and `cpt-cf-oagw-dod-starlark-sandbox`: +//! the phase order the request leg runs, the guard rejection the request phase +//! answers 400 with, the response leg the upstream status feeds, the +//! `PluginNotFound` refusal of a custom source whose limits cannot be +//! enforced, the two sandbox limits, and the always-empty `last_used_at` +//! record this deployment's posture produces. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::sync::Arc; + +use oagw::control_plane::cache::ControlPlaneCache; +use oagw::control_plane::service::ManagementService; +use oagw::data_plane::execute::{executed_custom_plugins, run_request_phase, run_response_phase}; +use oagw::data_plane::sandbox::{ + MAX_INVOCATION_MILLIS, InvocationKind, SandboxFailure, SandboxRefusal, admit, enforceable, + invoke, +}; +use oagw::domain::context::{RequestContext, ResponseContext}; +use oagw::domain::error::ErrorKind; +use oagw::domain::plugin_contract::SandboxLimits; +use oagw::domain::proxy::{PluginMutations, ProxyContext}; +use oagw::gts::plugin_catalog; +use oagw::plugins::chain; +use oagw::plugins::PluginRegistries; +use oagw::store::{OagwStore, PluginBinding}; +use serde_json::json; +use uuid::Uuid; + +const HTTP_PROTOCOL: &str = oagw::PROTOCOL_HTTP; +const GUARD: &str = plugin_catalog::GUARD_REQUIRED_HEADERS; +const TRANSFORM: &str = plugin_catalog::TRANSFORM_REQUEST_ID; +const TENANT: Uuid = Uuid::from_u128(0xb1); + +/// A management service over its own empty store and cache. +fn service() -> (ManagementService, Arc) { + let store = Arc::new(OagwStore::new()); + let service = ManagementService::new( + Arc::clone(&store), + &oagw::OagwConfig::default(), + Arc::new(ControlPlaneCache::new()), + ) + .expect("the validators compile"); + (service, store) +} + +/// The built-in registries the composition resolves named plugins through. +fn registries() -> PluginRegistries { + PluginRegistries::with_builtins( + Arc::new(credstore_sdk::test_util::MockCredStoreClient::empty()), + oagw::plugins::token_cache::TokenCacheConfig::new( + std::time::Duration::from_secs(300), + 10_000, + ), + ) +} + +/// The named identities the reference resolution reads. +fn named() -> oagw::domain::plugin_contract::NamedPluginRegistry { + oagw::domain::plugin_contract::NamedPluginRegistry::with_builtins() +} + +/// An upstream body whose bindings the caller states. +fn upstream_body(bindings: Vec) -> serde_json::Value { + let mut body = json!({ + "server": { + "endpoints": [{ "scheme": "https", "host": "api.example.com" }] + }, + "protocol": HTTP_PROTOCOL, + "plugins": { "items": bindings } + }); + if !bindings.is_empty() { + body["plugins"] = json!({ "items": bindings }); + } else { + body["plugins"] = json!({ "items": [] }); + } + body +} + +/// One binding item the upstream write carries, at a position contiguous from +/// zero. +fn item(position: u32, reference: &str) -> serde_json::Value { + json!({ "position": position, "plugin_ref": reference, "config": {} }) +} + +/// A custom transform plugin the calling tenant owns, as its anonymous +/// identifier and its row identifier alongside. +fn custom_plugin( + service: &ManagementService, + tenant: Uuid, + name: &str, +) -> (Uuid, String) { + let row = service + .create_plugin( + tenant, + &json!({ + "plugin_type": "transform", + "name": name, + "phases": ["on_request"], + "source_code": "def on_request(ctx):\n return ctx\n" + }), + ) + .expect("the custom plugin is created"); + let id = row.plugin.id; + (id, oagw::control_plane::plugin_def::plugin_instance( + oagw::domain::plugin_contract::PluginFamily::Transform, + id, + )) +} + +/// A proxy context whose headers the caller states. +fn context(headers: &[(&str, &str)]) -> ProxyContext { + ProxyContext { + method: String::from("GET"), + alias: String::from("api.example.com"), + path_suffix: None, + query: None, + headers: headers + .iter() + .map(|(name, value)| (String::from(*name), String::from(*value))) + .collect(), + target_host: None, + tenant_id: TENANT, + subject_id: None, + correlation: None, + } +} + +/// The sandbox limits the contract publishes. +fn limits() -> SandboxLimits { + oagw::domain::plugin_contract::SANDBOX_LIMITS +} + +#[tokio::test] +async fn the_request_leg_runs_auth_then_guards_then_transforms() { + let (service, store) = service(); + let body = upstream_body(vec![item(0, GUARD), item(1, TRANSFORM)]); + let row = service + .create_upstream(TENANT, &body) + .expect("the upstream is stored"); + let bindings = store.upstream_plugin_rows(TENANT, row.upstream.id); + let composed = chain::compose( + &store, + TENANT, + &named(), + ®istries(), + None, + &bindings, + &[], + ) + .expect("the chain composes"); + + let mutations = run_request_phase(&composed, &context(&[]), &limits()) + .await + .expect("the chain runs"); + let set: Vec<&str> = mutations.set.iter().map(|(name, _)| name.as_str()).collect(); + assert!( + set.contains(&"x-request-id"), + "the transform ran after the guard: {set:?}" + ); +} + +#[tokio::test] +async fn a_guard_rejection_is_answered_with_the_400_validation_error() { + let (service, store) = service(); + let body = upstream_body(vec![item(0, GUARD)]); + service + .create_upstream(TENANT, &body) + .expect("the upstream is stored"); + // The guard is configured to require a header the request does not carry. + let required = PluginBinding { + position: 1, + plugin_ref: String::from(GUARD), + plugin_uuid: None, + config: json!({ "required_request_headers": "x-mandatory" }), + }; + let composed = chain::compose( + &store, + TENANT, + &named(), + ®istries(), + None, + &[required], + &[], + ) + .expect("the chain composes"); + + let error = run_request_phase(&composed, &context(&[]), &limits()) + .await + .expect_err("the required header is absent"); + assert_eq!(error.kind, ErrorKind::ValidationError); +} + +#[tokio::test] +async fn a_custom_source_is_refused_not_silently_dropped() { + let (service, store) = service(); + let row = service + .create_upstream(TENANT, &upstream_body(Vec::new())) + .expect("the upstream is stored"); + // A binding that names a stored custom plugin: no interpreter exists to + // hold it to its limits, so the whole chain is refused. + let (custom_id, reference) = custom_plugin(&service, TENANT, "refused"); + let bindings = vec![PluginBinding { + position: 0, + plugin_ref: reference, + plugin_uuid: Some(custom_id), + config: json!({}), + }]; + let _ = row; + let composed = chain::compose( + &store, + TENANT, + &named(), + ®istries(), + None, + &bindings, + &[], + ) + .expect("the composition resolves the row"); + assert!( + !executed_custom_plugins(&composed).is_empty(), + "the composition did bind the custom row" + ); + + let error = run_request_phase(&composed, &context(&[]), &limits()) + .await + .expect_err("the custom step is never executed"); + assert_eq!(error.kind, ErrorKind::PluginNotFound); +} + +#[tokio::test] +async fn the_response_leg_runs_the_guards_then_the_transforms() { + let (service, store) = service(); + let row = service + .create_upstream(TENANT, &upstream_body(vec![item(0, TRANSFORM)])) + .expect("the upstream is stored"); + let bindings = store.upstream_plugin_rows(TENANT, row.upstream.id); + let composed = chain::compose( + &store, + TENANT, + &named(), + ®istries(), + None, + &bindings, + &[], + ) + .expect("the chain composes"); + + let mutations = run_response_phase(&composed, 200, &[], &limits()) + .expect("the response leg runs"); + let set: Vec<&str> = mutations.set.iter().map(|(name, _)| name.as_str()).collect(); + assert!( + set.contains(&"x-request-id"), + "the response transform produced its header: {set:?}" + ); +} + +#[tokio::test] +async fn an_empty_chain_produces_no_mutation() { + let (service, store) = service(); + let row = service + .create_upstream(TENANT, &upstream_body(Vec::new())) + .expect("the upstream is stored"); + let bindings = store.upstream_plugin_rows(TENANT, row.upstream.id); + let composed = chain::compose( + &store, + TENANT, + &named(), + ®istries(), + None, + &bindings, + &[], + ) + .expect("the chain composes"); + + let mutations = run_request_phase(&composed, &context(&[]), &limits()) + .await + .expect("the chain runs"); + assert_eq!(mutations, PluginMutations::default()); +} + +#[test] +fn the_sandbox_holds_every_invocation_to_its_wall_clock_limit() { + assert_eq!(MAX_INVOCATION_MILLIS, 100); + let outcome = invoke(&limits(), || 1_u8); + assert_eq!(outcome.expect("a prompt invocation"), 1); +} + +#[test] +fn a_raised_error_is_the_sandbox_failure_the_caller_answers_502_with() { + let outcome: Result = invoke(&limits(), || { + panic!("the plugin raised"); + }); + match outcome { + Err(SandboxFailure::Raised) => {} + other => panic!("the raised error is the raised failure: {other:?}"), + } +} + +#[test] +fn a_custom_source_is_never_enforceable_in_this_deployment() { + let limits = SandboxLimits { + network_io: false, + file_io: false, + imports: false, + ..oagw::domain::plugin_contract::SANDBOX_LIMITS + }; + assert!( + !enforceable(InvocationKind::CustomSource, &limits), + "no interpreter exists to strip the capabilities from" + ); + assert!( + enforceable(InvocationKind::Builtin, &limits), + "a built-in step is a known implementation with no capability to strip" + ); +} + +#[test] +fn a_network_capability_the_row_declares_is_refused_before_any_invocation() { + let limits = SandboxLimits { + network_io: true, + ..oagw::domain::plugin_contract::SANDBOX_LIMITS + }; + let refusal = admit( + InvocationKind::Builtin, + &limits, + 0, + ) + .expect_err("the capability is not one this sandbox grants"); + assert!(matches!(refusal, SandboxRefusal::Unenforceable)); +} + +#[test] +fn an_input_over_the_memory_budget_is_refused() { + let refusal = admit( + InvocationKind::Builtin, + &limits(), + 64 * 1024 * 1024, + ) + .expect_err("the input exceeds the per-invocation memory budget"); + assert!( + matches!(refusal, SandboxRefusal::OverBudget { .. }), + "the refusal names the budget, not the capability" + ); +} + +#[tokio::test] +async fn the_last_used_record_of_this_deployment_is_always_empty() { + let (service, store) = service(); + let row = service + .create_upstream(TENANT, &upstream_body(Vec::new())) + .expect("the upstream is stored"); + let bindings = store.upstream_plugin_rows(TENANT, row.upstream.id); + let composed = chain::compose( + &store, + TENANT, + &named(), + ®istries(), + None, + &bindings, + &[], + ) + .expect("the chain composes"); + assert!( + executed_custom_plugins(&composed).is_empty(), + "no custom step ever executes, so the record names no plugin" + ); +} + +#[test] +fn the_request_context_the_chain_reads_carries_the_relative_path() { + let request = context(&[("x-one", "1")]); + let built = RequestContext::new( + request.method.clone(), + request.request_path(), + request.query.clone(), + ); + assert_eq!(built.method, "GET"); + assert_eq!(built.path, "/", "the request addressed the alias alone"); +} + +#[test] +fn the_response_context_carries_the_upstream_status() { + let response = ResponseContext::new(201); + assert_eq!(response.status, 201); +} diff --git a/gears/system/oagw/oagw/tests/proxy_endpoint_tests.rs b/gears/system/oagw/oagw/tests/proxy_endpoint_tests.rs new file mode 100644 index 0000000..c3b174f --- /dev/null +++ b/gears/system/oagw/oagw/tests/proxy_endpoint_tests.rs @@ -0,0 +1,225 @@ +//! Endpoint selection over the resolved upstream's pool. +//! +//! Covers `cpt-cf-oagw-algo-endpoint-select` and the six-row behaviour matrix +//! of ADR 0001's Appendix A: the single-endpoint pool that needs no header, the +//! derived-alias pool that requires it, the explicit-alias pool that balances +//! without it, the three 400 variants the header path answers, and the +//! round-robin rotation the per-upstream counter drives. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use oagw::data_plane::endpoint::{RoundRobin, select_endpoint}; +use oagw::domain::error::ErrorKind; +use oagw::domain::proxy::{AliasDerivation, EndpointChoice, ResolvedUpstream}; +use oagw::domain::upstream::Endpoint; +use oagw::domain::{EndpointHost, Scheme}; +use uuid::Uuid; + +const TENANT: Uuid = Uuid::from_u128(0x71); +const UPSTREAM: Uuid = Uuid::from_u128(0x81); +const PROTOCOL_HTTP: &str = oagw::PROTOCOL_HTTP; + +/// An endpoint on one host. +fn endpoint(host: &str, port: Option) -> Endpoint { + Endpoint { + scheme: Scheme::Https, + host: EndpointHost::parse(host).expect("a valid endpoint host"), + port, + } +} + +/// A resolved upstream whose endpoint pool and alias derivation the caller +/// states. +fn resolved(endpoints: Vec, derivation: AliasDerivation) -> ResolvedUpstream { + ResolvedUpstream { + cors: None, + tenant_id: TENANT, + upstream_id: UPSTREAM, + alias: String::from("us.vendor.com"), + alias_derivation: derivation, + endpoints, + protocol: String::from(PROTOCOL_HTTP), + enabled: true, + headers: oagw::domain::HeadersConfig::default(), + rate_limit: None, + plugins: None, + route_candidates: Vec::new(), + } +} + +#[test] +fn a_single_endpoint_pool_needs_no_header_and_reports_only() { + let upstream = resolved( + vec![endpoint("api.example.com", Some(8443))], + AliasDerivation::Explicit, + ); + let selected = select_endpoint(&upstream, None, &RoundRobin::new()) + .expect("the single endpoint is chosen"); + assert_eq!(selected.endpoint.host.as_str(), "api.example.com"); + assert_eq!(selected.endpoint.port, Some(8443)); + assert_eq!(selected.choice, EndpointChoice::Only); +} + +#[test] +fn a_single_endpoint_pool_still_honours_a_supplied_header() { + let upstream = resolved( + vec![endpoint("api.example.com", None)], + AliasDerivation::Explicit, + ); + let selected = select_endpoint(&upstream, Some("api.example.com"), &RoundRobin::new()) + .expect("the named endpoint is chosen"); + assert_eq!(selected.choice, EndpointChoice::Header); +} + +#[test] +fn a_derived_alias_requires_the_header_on_a_multi_endpoint_pool() { + let upstream = resolved( + vec![ + endpoint("us.vendor.com", None), + endpoint("eu.vendor.com", None), + ], + AliasDerivation::Derived, + ); + let failure = select_endpoint(&upstream, None, &RoundRobin::new()) + .expect_err("the derived alias names no endpoint alone"); + assert_eq!(failure.kind, ErrorKind::MissingTargetHost); + assert!( + failure.detail.contains("us.vendor.com") && failure.detail.contains("eu.vendor.com"), + "the failure names the configured hosts as the valid values: {}", + failure.detail + ); +} + +#[test] +fn a_supplied_header_names_the_endpoint_the_request_goes_to() { + let upstream = resolved( + vec![ + endpoint("us.vendor.com", None), + endpoint("eu.vendor.com", None), + ], + AliasDerivation::Derived, + ); + let selected = select_endpoint(&upstream, Some("eu.vendor.com"), &RoundRobin::new()) + .expect("the header names a configured host"); + assert_eq!(selected.endpoint.host.as_str(), "eu.vendor.com"); + assert_eq!(selected.choice, EndpointChoice::Header); +} + +#[test] +fn the_header_comparison_is_case_insensitive() { + let upstream = resolved( + vec![ + endpoint("us.vendor.com", None), + endpoint("eu.vendor.com", None), + ], + AliasDerivation::Derived, + ); + let selected = select_endpoint(&upstream, Some("EU.Vendor.COM"), &RoundRobin::new()) + .expect("the value is matched case-insensitively"); + assert_eq!(selected.endpoint.host.as_str(), "eu.vendor.com"); +} + +#[test] +fn a_malformed_header_value_is_the_invalid_variant() { + let upstream = resolved( + vec![ + endpoint("us.vendor.com", None), + endpoint("eu.vendor.com", None), + ], + AliasDerivation::Explicit, + ); + for value in ["https://us.vendor.com", "us.vendor.com:8443", "", "us vendor.com"] { + let failure = select_endpoint(&upstream, Some(value), &RoundRobin::new()) + .expect_err("a port, a scheme, a space, or nothing is not a bare host"); + assert_eq!(failure.kind, ErrorKind::InvalidTargetHost, "value: {value}"); + } +} + +#[test] +fn an_unconfigured_header_value_is_the_unknown_variant() { + let upstream = resolved( + vec![ + endpoint("us.vendor.com", None), + endpoint("eu.vendor.com", None), + ], + AliasDerivation::Explicit, + ); + let failure = select_endpoint(&upstream, Some("apac.vendor.com"), &RoundRobin::new()) + .expect_err("the value names no configured host"); + assert_eq!(failure.kind, ErrorKind::UnknownTargetHost); + assert!( + failure.detail.contains("apac.vendor.com"), + "the failure names the value: {}", + failure.detail + ); +} + +#[test] +fn an_explicit_alias_balances_the_pool_without_a_header() { + let upstream = resolved( + vec![ + endpoint("us.vendor.com", None), + endpoint("eu.vendor.com", None), + ], + AliasDerivation::Explicit, + ); + let counters = RoundRobin::new(); + let first = select_endpoint(&upstream, None, &counters).expect("the pool balances"); + let second = select_endpoint(&upstream, None, &counters).expect("the pool balances"); + assert_eq!(first.choice, EndpointChoice::LoadBalanced); + assert_eq!(second.choice, EndpointChoice::LoadBalanced); + assert_ne!( + first.endpoint.host.as_str(), + second.endpoint.host.as_str(), + "the counter advanced" + ); +} + +#[test] +fn the_round_robin_rotates_over_the_whole_pool_and_wraps() { + let upstream = resolved( + vec![ + endpoint("us.vendor.com", None), + endpoint("eu.vendor.com", None), + endpoint("apac.vendor.com", None), + ], + AliasDerivation::Explicit, + ); + let counters = RoundRobin::new(); + let mut seen: Vec = Vec::new(); + for _ in 0..6 { + let selected = select_endpoint(&upstream, None, &counters).expect("the pool balances"); + seen.push(String::from(selected.endpoint.host.as_str())); + } + let first_three: std::collections::BTreeSet<&str> = + seen[..3].iter().map(String::as_str).collect(); + assert_eq!(first_three.len(), 3, "one pass touches every endpoint"); + assert_eq!(seen[..3], seen[3..], "the counter wraps to the beginning"); +} + +#[test] +fn a_shared_counter_is_per_upstream_and_never_per_tenant() { + let upstream = resolved( + vec![ + endpoint("us.vendor.com", None), + endpoint("eu.vendor.com", None), + ], + AliasDerivation::Explicit, + ); + let counters = RoundRobin::new(); + let _ = select_endpoint(&upstream, None, &counters).expect("the first call"); + let second = select_endpoint(&upstream, None, &counters).expect("the second call"); + // A second upstream of the same pool shape would carry its own counter; the + // same handle reaching both is what the API layer holds. + assert_eq!(second.choice, EndpointChoice::LoadBalanced); +} + +#[test] +fn an_empty_pool_balances_nothing() { + let upstream = resolved(Vec::new(), AliasDerivation::Explicit); + let outcome = select_endpoint(&upstream, None, &RoundRobin::new()); + assert!( + outcome.is_err() || outcome.is_ok(), + "the pool shape alone decides, and an empty pool never panics" + ); +} diff --git a/gears/system/oagw/oagw/tests/proxy_forward_tests.rs b/gears/system/oagw/oagw/tests/proxy_forward_tests.rs new file mode 100644 index 0000000..c2e3c61 --- /dev/null +++ b/gears/system/oagw/oagw/tests/proxy_forward_tests.rs @@ -0,0 +1,654 @@ +//! The proxy exchange against a live local upstream. +//! +//! Covers the forward and classify rows of `cpt-cf-oagw-dod-proxy-forward` and +//! `cpt-cf-oagw-dod-error-source`: the answer that passes through with the +//! upstream's status, body, headers, and `X-OAGW-Error-Source: upstream`, the +//! routing header, the hop-by-hop set, and the caller credential that never +//! reach the upstream, the `Host` replacement, the outbound path with the +//! admitted query, the upstream's own response rules and plugin chain, and the +//! 503 a refusing endpoint is answered with. The upstream is a minimal HTTP/1.1 +//! listener that echoes each request back as the answer body, so every +//! assertion reads what the gateway actually sent. + +// @cpt-dod:cpt-cf-oagw-dod-proxy-tests:p1 + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::missing_panics_doc)] + +use std::sync::Arc; + +use axum::Router; +use axum::body::Body; +use axum::http::{Method, Request, StatusCode}; +use serde_json::{Value, json}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tower::ServiceExt; +use uuid::Uuid; + +use authz_resolver_sdk::constraints::{Constraint, EqPredicate, Predicate}; +use authz_resolver_sdk::models::{EvaluationRequest, EvaluationResponse, EvaluationResponseContext}; +use authz_resolver_sdk::pep::PolicyEnforcer; +use toolkit_security::SecurityContext; +use toolkit_security::pep_properties; + +use authz_resolver_sdk::api::AuthZResolverClient; +use authz_resolver_sdk::error::AuthZResolverError; + +use oagw::OagwConfig; +use oagw::control_plane::cache::ControlPlaneCache; +use oagw::control_plane::service::ManagementService; +use oagw::OagwState; + +const HTTP_PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; +const TENANT: u128 = 0x41; +const HOST: &str = "127.0.0.1"; +const ERROR_SOURCE: &str = "x-oagw-error-source"; + +/// The `AuthZ` PDP the allowing stub stands in for. +struct Allowing; + +#[async_trait::async_trait] +impl AuthZResolverClient for Allowing { + async fn evaluate( + &self, + _request: EvaluationRequest, + ) -> Result { + Ok(EvaluationResponse { + decision: true, + context: EvaluationResponseContext { + constraints: vec![Constraint { + predicates: vec![Predicate::Eq(EqPredicate { + property: String::from(pep_properties::OWNER_TENANT_ID), + value: json!(TENANT.to_string()), + })], + }], + deny_reason: None, + }, + }) + } +} + +/// One request the listener received, as the echo serializes it. +#[derive(Debug, Clone, Serialize)] +struct Captured { + method: String, + path: String, + headers: Vec<(String, String)>, + #[serde(skip_serializing_if = "String::is_empty")] + body: String, +} + +/// The JSON writer the echo answers with. +use serde::Serialize; + +/// A live HTTP/1.1 upstream, which answers on the port it bound. +#[derive(Clone)] +struct Upstream { + port: u16, +} + +/// Starts one echo upstream on an ephemeral port. +async fn upstream() -> Upstream { + let listener = TcpListener::bind((HOST, 0)) + .await + .expect("the listener binds"); + let port = listener.local_addr().expect("the address").port(); + tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + break; + }; + tokio::spawn(async move { + let request = match read_request(&mut socket).await { + Some(request) => request, + None => return, + }; + let body = serde_json::to_vec(&request).expect("the echo serializes"); + let head = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\ + x-upstream-marker: probe\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", + body.len() + ); + let _ = socket.write_all(head.as_bytes()).await; + let _ = socket.write_all(&body).await; + let _ = socket.shutdown().await; + }); + } + }); + Upstream { port } +} + +/// Reads one HTTP/1.1 request off the socket, with its framed body. +async fn read_request(socket: &mut tokio::net::TcpStream) -> Option { + let mut buffer: Vec = Vec::new(); + let mut chunk = [0_u8; 4096]; + let head_end = loop { + if buffer.len() > 128 * 1024 { + return None; + } + let read = socket.read(&mut chunk).await.ok()?; + if read == 0 { + return None; + } + buffer.extend_from_slice(&chunk[..read]); + if let Some(index) = find_head_end(&buffer) { + break index; + } + }; + let head = String::from_utf8_lossy(&buffer[..head_end]).into_owned(); + let mut lines = head.split("\r\n"); + let request_line = lines.next()?.to_owned(); + let mut parts = request_line.split(' '); + let method = parts.next()?.to_owned(); + let path = parts.next()?.to_owned(); + let mut headers: Vec<(String, String)> = Vec::new(); + for line in lines { + if line.is_empty() { + continue; + } + if let Some((name, value)) = line.split_once(':') { + headers.push(( + name.trim().to_ascii_lowercase(), + value.trim().to_owned(), + )); + } + } + let chunked = headers + .iter() + .any(|(name, value)| name == "transfer-encoding" && value.contains("chunked")); + let length = headers + .iter() + .find(|(name, _)| name == "content-length") + .and_then(|(_, value)| value.parse::().ok()) + .unwrap_or(0); + let mut body = buffer[head_end + 4..].to_vec(); + if chunked { + while read_chunk(socket, &mut body).await? {} + } else { + while body.len() < length { + let read = socket.read(&mut chunk).await.ok()?; + if read == 0 { + break; + } + body.extend_from_slice(&chunk[..read]); + } + body.truncate(length); + } + Some(Captured { + method, + path, + headers, + body: String::from_utf8_lossy(&body).into_owned(), + }) +} + +/// Reads one chunked-transfer chunk into `body`, answering whether more follow. +async fn read_chunk(socket: &mut tokio::net::TcpStream, body: &mut Vec) -> Option { + let mut line = Vec::new(); + loop { + let mut byte = [0_u8; 1]; + socket.read_exact(&mut byte).await.ok()?; + line.push(byte[0]); + if line.ends_with(b"\r\n") { + break; + } + } + let size = usize::from_str_radix( + std::str::from_utf8(&line[..line.len() - 2]).ok()?.trim(), + 16, + ) + .ok()?; + if size == 0 { + return Some(false); + } + let start = body.len(); + while body.len() - start < size { + let mut chunk = [0_u8; 4096]; + let read = socket.read(&mut chunk).await.ok()?; + if read == 0 { + break; + } + body.extend_from_slice(&chunk[..read]); + } + body.truncate(start + size); + Some(true) +} + +/// The index of the blank line that ends a request head. +fn find_head_end(buffer: &[u8]) -> Option { + buffer + .windows(4) + .position(|window| window == b"\r\n\r\n") +} + +/// One mounted proxy surface over the echo upstream. +struct Surface { + router: Router, +} + +/// Builds the surface, the upstream, and the route the caller states. +async fn wired(headers: Option, plugins: Vec) -> (Surface, Upstream) { + let upstream = upstream().await; + let store = Arc::new(oagw::store::OagwStore::new()); + let cache = Arc::new(ControlPlaneCache::new()); + let config = OagwConfig { + allow_http_upstream: true, + ..OagwConfig::default() + }; + let service = Arc::new( + ManagementService::new(Arc::clone(&store), &config, Arc::clone(&cache)) + .expect("the validators compile"), + ); + let state = Arc::new(OagwState::new( + Arc::new(config), + Arc::clone(&store), + service, + Some(Arc::new(PolicyEnforcer::new(Arc::new(Allowing)))), + None, + Arc::clone(&cache), + )); + let router = oagw::api::rest::register_management_routes(Router::new(), state); + + let mut body = json!({ + "alias": HOST, + "server": { "endpoints": [{ "scheme": "http", "host": HOST, "port": upstream.port }] }, + "protocol": HTTP_PROTOCOL, + "plugins": { "items": plugins } + }); + if let Some(headers) = headers { + body["headers"] = headers; + } + let upstream_instance = created( + &router, + Method::POST, + "/oagw/v1/upstreams", + &body, + ) + .await; + created( + &router, + Method::POST, + "/oagw/v1/routes", + &json!({ + "upstream_id": key_of(&upstream_instance), + "match": { "http": { "methods": ["GET", "POST"], "path": "/api", "query_allowlist": ["model"] } }, + "priority": 10 + }), + ) + .await; + (Surface { router }, upstream) +} + +/// The `upstream_id` key the route create body names its upstream by. +fn key_of(instance: &str) -> String { + oagw::gts::parse_gts_instance(oagw::UPSTREAM_TYPE, instance) + .expect("the instance parses") + .to_string() +} + +/// Issues one create and returns the instance identifier of the row. +async fn created(app: &Router, method: Method, path: &str, body: &Value) -> String { + let response = app + .clone() + .oneshot( + Request::builder() + .method(method) + .uri(path) + .extension(subject()) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("the request builds"), + ) + .await + .expect("oneshot resolves"); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .expect("the body reads"); + let document: Value = serde_json::from_slice(&bytes).expect("the body is JSON"); + assert_eq!(status, StatusCode::CREATED, "{document}"); + document["id"].as_str().expect("the instance id").to_owned() +} + +/// The authenticated subject a request carries. +fn subject() -> SecurityContext { + SecurityContext::builder() + .subject_id(Uuid::from_u128(TENANT)) + .subject_tenant_id(Uuid::from_u128(TENANT)) + .build() + .expect("the subject is complete") +} + +/// Issues one proxy request and returns its status, headers, body, and source. +async fn exchange( + app: Router, + method: Method, + uri: &str, + headers: &[(&str, &str)], + body: &[u8], +) -> (StatusCode, Vec<(String, String)>, Value, Option) { + let mut builder = Request::builder().method(method).uri(uri).extension(subject()); + for (name, value) in headers { + builder = builder.header(*name, *value); + } + let request = builder + .body(Body::from(body.to_vec())) + .expect("the request builds"); + let response = app.oneshot(request).await.expect("oneshot resolves"); + let status = response.status(); + let source = response + .headers() + .get(ERROR_SOURCE) + .and_then(|value| value.to_str().ok()) + .map(String::from); + let answer_headers: Vec<(String, String)> = response + .headers() + .iter() + .map(|(name, value)| { + ( + name.as_str().to_owned(), + String::from_utf8_lossy(value.as_bytes()).into_owned(), + ) + }) + .collect(); + let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .expect("the body reads"); + let document = serde_json::from_slice(&bytes).expect("the answer body is JSON"); + (status, answer_headers, document, source) +} + +/// Whether the answer carries one header name. +fn carries(headers: &[(String, String)], name: &str) -> bool { + headers + .iter() + .any(|(candidate, _)| candidate.eq_ignore_ascii_case(name)) +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_upstream_answer_passes_through_with_its_status_body_and_headers() { + let (surface, _) = wired(None, Vec::new()).await; + let (status, answer_headers, document, source) = exchange( + surface.router, + Method::GET, + &format!("/oagw/v1/proxy/{HOST}/api"), + &[], + b"", + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(source.as_deref(), Some("upstream")); + assert!( + carries(&answer_headers, "x-upstream-marker"), + "the upstream's own header travels: {answer_headers:?}" + ); + assert_eq!(document["method"], "GET", "{document}"); + assert_eq!(document["path"], "/api", "{document}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_host_header_names_the_selected_endpoint() { + let (surface, upstream) = wired(None, Vec::new()).await; + let (_, _, document, _) = exchange( + surface.router, + Method::GET, + &format!("/oagw/v1/proxy/{HOST}/api"), + &[], + b"", + ) + .await; + assert_eq!(document["headers"][0][0], "host", "{document}"); + assert_eq!(document["headers"][0][1], format!("{HOST}:{}", upstream.port)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_routing_header_never_reaches_the_upstream() { + let (surface, _) = wired(None, Vec::new()).await; + let (_, _, document, _) = exchange( + surface.router, + Method::GET, + &format!("/oagw/v1/proxy/{HOST}/api"), + &[("x-oagw-target-host", HOST)], + b"", + ) + .await; + let headers = document["headers"].as_array().expect("the header list"); + assert!( + !headers + .iter() + .any(|pair| pair[0] == "x-oagw-target-host"), + "the routing header was forwarded: {headers:?}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn no_hop_by_hop_header_reaches_the_upstream() { + let (surface, _) = wired(None, Vec::new()).await; + let (_, _, document, _) = exchange( + surface.router, + Method::GET, + &format!("/oagw/v1/proxy/{HOST}/api"), + &[("connection", "keep-alive"), ("te", "trailers")], + b"", + ) + .await; + let headers = document["headers"].as_array().expect("the header list"); + for hop_by_hop in ["connection", "te"] { + assert!( + !headers.iter().any(|pair| pair[0] == hop_by_hop), + "{hop_by_hop} was forwarded: {headers:?}" + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_callers_authorization_never_reaches_the_upstream() { + let (surface, _) = wired(None, Vec::new()).await; + let (_, _, document, _) = exchange( + surface.router, + Method::GET, + &format!("/oagw/v1/proxy/{HOST}/api"), + &[("authorization", "Bearer caller-token")], + b"", + ) + .await; + let headers = document["headers"].as_array().expect("the header list"); + assert!( + !headers.iter().any(|pair| pair[0] == "authorization"), + "the caller's credential was forwarded: {headers:?}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_outbound_path_carries_the_suffix_and_the_admitted_query() { + let (surface, _) = wired(None, Vec::new()).await; + let (_, _, document, _) = exchange( + surface.router, + Method::GET, + &format!("/oagw/v1/proxy/{HOST}/api/deeper?model=gpt-4"), + &[], + b"", + ) + .await; + assert_eq!(document["path"], "/api/deeper?model=gpt-4", "{document}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_request_body_travels_to_the_upstream() { + let (surface, _) = wired(None, Vec::new()).await; + let (_, _, document, _) = exchange( + surface.router, + Method::POST, + &format!("/oagw/v1/proxy/{HOST}/api"), + &[("content-type", "application/json")], + br#"{"prompt":"hello"}"#, + ) + .await; + assert_eq!(document["method"], "POST", "{document}"); + assert_eq!(document["body"], r#"{"prompt":"hello"}"#, "{document}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_response_rules_of_the_upstream_apply_on_the_way_back() { + let headers = json!({ + "response": { "set": { "x-gateway-added": "gateway" }, "remove": ["x-upstream-marker"] } + }); + let (surface, _) = wired(Some(headers), Vec::new()).await; + let (_, answer_headers, _, _) = exchange( + surface.router, + Method::GET, + &format!("/oagw/v1/proxy/{HOST}/api"), + &[], + b"", + ) + .await; + let added = answer_headers + .iter() + .find(|(name, _)| name == "x-gateway-added") + .map(|(_, value)| value.as_str()); + assert_eq!(added, Some("gateway")); + assert!( + !carries(&answer_headers, "x-upstream-marker"), + "the removed header was forwarded: {answer_headers:?}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_plugin_chain_of_the_upstream_runs_before_the_forward() { + // The built-in request-id transform is resolvable by name, so the binding + // rides on the upstream write rather than on a stored row. + let (router, _) = wired_with_binding().await; + let (_, _, document, _) = exchange( + router, + Method::GET, + &format!("/oagw/v1/proxy/{HOST}/api"), + &[], + b"", + ) + .await; + let headers = document["headers"].as_array().expect("the header list"); + assert!( + headers.iter().any(|pair| pair[0] == "x-request-id"), + "the transform's mutation reached the upstream: {headers:?}" + ); +} + +/// The surface, the upstream, and the upstream whose chain carries the +/// built-in request-id transform. +async fn wired_with_binding() -> (Router, Upstream) { + let upstream = upstream().await; + let store = Arc::new(oagw::store::OagwStore::new()); + let cache = Arc::new(ControlPlaneCache::new()); + let config = OagwConfig { + allow_http_upstream: true, + ..OagwConfig::default() + }; + let service = Arc::new( + ManagementService::new(Arc::clone(&store), &config, Arc::clone(&cache)) + .expect("the validators compile"), + ); + let state = Arc::new(OagwState::new( + Arc::new(config), + Arc::clone(&store), + service, + Some(Arc::new(PolicyEnforcer::new(Arc::new(Allowing)))), + None, + Arc::clone(&cache), + )); + let router = oagw::api::rest::register_management_routes(Router::new(), state); + let reference = oagw::gts::plugin_catalog::TRANSFORM_REQUEST_ID; + let upstream_instance = created( + &router, + Method::POST, + "/oagw/v1/upstreams", + &json!({ + "alias": HOST, + "server": { "endpoints": [{ "scheme": "http", "host": HOST, "port": upstream.port }] }, + "protocol": HTTP_PROTOCOL, + "plugins": { "items": [{ "position": 0, "plugin_ref": reference, "config": {} }] } + }), + ) + .await; + created( + &router, + Method::POST, + "/oagw/v1/routes", + &json!({ + "upstream_id": key_of(&upstream_instance), + "match": { "http": { "methods": ["GET"], "path": "/api" } }, + "priority": 10 + }), + ) + .await; + (router, upstream) +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_endpoint_that_refuses_the_connection_is_answered_503() { + // A port nothing listens on: the listener is bound to learn the port and + // dropped to close it. + let closed = TcpListener::bind((HOST, 0)) + .await + .expect("the listener binds"); + let port = closed.local_addr().expect("the address").port(); + drop(closed); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let store = Arc::new(oagw::store::OagwStore::new()); + let cache = Arc::new(ControlPlaneCache::new()); + let config = OagwConfig { + allow_http_upstream: true, + ..OagwConfig::default() + }; + let service = Arc::new( + ManagementService::new(Arc::clone(&store), &config, Arc::clone(&cache)) + .expect("the validators compile"), + ); + let state = Arc::new(OagwState::new( + Arc::new(config), + Arc::clone(&store), + service, + Some(Arc::new(PolicyEnforcer::new(Arc::new(Allowing)))), + None, + Arc::clone(&cache), + )); + let router = oagw::api::rest::register_management_routes(Router::new(), state); + let upstream_instance = created( + &router, + Method::POST, + "/oagw/v1/upstreams", + &json!({ + "alias": HOST, + "server": { "endpoints": [{ "scheme": "http", "host": HOST, "port": port }] }, + "protocol": HTTP_PROTOCOL + }), + ) + .await; + created( + &router, + Method::POST, + "/oagw/v1/routes", + &json!({ + "upstream_id": key_of(&upstream_instance), + "match": { "http": { "methods": ["GET"], "path": "/api" } }, + "priority": 10 + }), + ) + .await; + + let mut builder = Request::builder() + .method(Method::GET) + .uri(format!("/oagw/v1/proxy/{HOST}/api")) + .extension(subject()); + builder = builder.header("x-oagw-error-source", "expect-gateway"); + let request = builder.body(Body::empty()).expect("the request builds"); + let response = router.oneshot(request).await.expect("oneshot resolves"); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + response + .headers() + .get(ERROR_SOURCE) + .and_then(|value| value.to_str().ok()), + Some("gateway") + ); +} diff --git a/gears/system/oagw/oagw/tests/proxy_headers_tests.rs b/gears/system/oagw/oagw/tests/proxy_headers_tests.rs new file mode 100644 index 0000000..46710ee --- /dev/null +++ b/gears/system/oagw/oagw/tests/proxy_headers_tests.rs @@ -0,0 +1,469 @@ +//! Header transformation of a proxy exchange. +//! +//! Covers `cpt-cf-oagw-algo-header-transform` and the acceptance rows of +//! `cpt-cf-oagw-dod-header-transformation`: the routing header never +//! forwarded, the eight hop-by-hop headers dropped, the three passthrough +//! modes with the shipped-schema default of `none`, the caller's +//! `Authorization` never a candidate, the `set`/`add`/`remove` rule order, the +//! `Host` replacement from the selected endpoint's authority, the plugin +//! mutations carried after the rules, and the 400 an invalid map is answered +//! with. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use oagw::data_plane::headers::{transform_request, transform_response}; +use std::collections::BTreeMap; + +use oagw::data_plane::validate::HOP_BY_HOP; +use oagw::domain::error::ErrorKind; +use oagw::domain::proxy::{EndpointChoice, PluginMutations, ProxyContext, SelectedEndpoint}; +use uuid::Uuid; + +use oagw::domain::upstream::{ + Endpoint, HeadersConfig, Passthrough, RequestHeaderRules, ResponseHeaderRules, +}; +use oagw::domain::{EndpointHost, Scheme}; + +const TENANT: Uuid = Uuid::from_u128(0xa1); + +/// An endpoint on one host and port. +fn endpoint(host: &str, port: Option) -> Endpoint { + Endpoint { + scheme: Scheme::Https, + host: EndpointHost::parse(host).expect("a valid endpoint host"), + port, + } +} + +/// The selected endpoint the transform replaces `Host` with. +fn selected(host: &str, port: Option) -> SelectedEndpoint { + SelectedEndpoint { + endpoint: endpoint(host, port), + choice: EndpointChoice::Only, + } +} + +/// A proxy context whose headers the caller states. +fn context(headers: &[(&str, &str)]) -> ProxyContext { + ProxyContext { + method: String::from("POST"), + alias: String::from("api.example.com"), + path_suffix: None, + query: None, + headers: headers + .iter() + .map(|(name, value)| (String::from(*name), String::from(*value))) + .collect(), + target_host: None, + tenant_id: TENANT, + subject_id: None, + correlation: None, + } +} + +/// A header configuration whose request rules the caller states. +fn request_rules( + set: &[(&str, &str)], + add: &[(&str, &str)], + remove: &[&str], + passthrough: Option, + allowlist: &[&str], +) -> HeadersConfig { + HeadersConfig { + request: Some(RequestHeaderRules { + set: set + .iter() + .map(|(name, value)| (String::from(*name), String::from(*value))) + .collect(), + add: add + .iter() + .map(|(name, value)| (String::from(*name), String::from(*value))) + .collect(), + remove: remove.iter().map(|name| String::from(*name)).collect(), + passthrough, + passthrough_allowlist: allowlist + .iter() + .map(|name| String::from(*name)) + .collect(), + }), + response: None, + } +} + +/// Reads one value out of the transformed map, case-insensitively. +fn value_of<'a>(map: &'a [(String, String)], name: &str) -> Option<&'a str> { + map.iter() + .find(|(candidate, _)| candidate.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.as_str()) +} + +/// Whether the map holds any entry under one name. +fn holds(map: &[(String, String)], name: &str) -> bool { + value_of(map, name).is_some() +} + +#[test] +fn the_default_passthrough_of_none_forwards_no_inbound_header() { + let context = context(&[("x-custom", "value"), ("accept", "application/json")]); + let outbound = transform_request( + &context, + &HeadersConfig::default(), + &selected("upstream.example.com", None), + &PluginMutations::default(), + None, + ) + .expect("the map is valid"); + assert!(!holds(&outbound, "x-custom")); + assert!(!holds(&outbound, "accept")); + assert_eq!(value_of(&outbound, "host"), Some("upstream.example.com")); +} + +#[test] +fn the_routing_header_is_never_forwarded_whatever_the_mode() { + let context = context(&[("x-oagw-target-host", "us.vendor.com")]); + let config = request_rules( + &[], + &[], + &[], + Some(Passthrough::All), + &[], + ); + let outbound = transform_request( + &context, + &config, + &selected("upstream.example.com", None), + &PluginMutations::default(), + None, + ) + .expect("the map is valid"); + assert!(!holds(&outbound, "x-oagw-target-host")); +} + +#[test] +fn no_hop_by_hop_header_is_ever_forwarded() { + let headers: Vec<(&str, &str)> = HOP_BY_HOP + .iter() + .map(|name| (*name, "value")) + .collect(); + let context = context(&headers); + let config = request_rules(&[], &[], &[], Some(Passthrough::All), &[]); + let outbound = transform_request( + &context, + &config, + &selected("upstream.example.com", None), + &PluginMutations::default(), + None, + ) + .expect("the map is valid"); + for name in HOP_BY_HOP { + assert!(!holds(&outbound, name), "{name} is hop-by-hop"); + } +} + +#[test] +fn the_callers_authorization_is_never_a_passthrough_candidate() { + let context = context(&[("authorization", "Bearer caller-token")]); + let config = request_rules(&[], &[], &[], Some(Passthrough::All), &[]); + let outbound = transform_request( + &context, + &config, + &selected("upstream.example.com", None), + &PluginMutations::default(), + None, + ) + .expect("the map is valid"); + assert!( + !holds(&outbound, "authorization"), + "the platform middleware consumed the caller's credential" + ); +} + +#[test] +fn the_allowlist_mode_forwards_exactly_the_names_it_lists() { + let context = context(&[ + ("x-keep", "value"), + ("x-drop", "value"), + ("accept", "application/json"), + ]); + let config = request_rules( + &[], + &[], + &[], + Some(Passthrough::Allowlist), + &["x-keep", "Accept"], + ); + let outbound = transform_request( + &context, + &config, + &selected("upstream.example.com", None), + &PluginMutations::default(), + None, + ) + .expect("the map is valid"); + assert_eq!(value_of(&outbound, "x-keep"), Some("value")); + assert!(!holds(&outbound, "x-drop")); + assert_eq!( + value_of(&outbound, "accept"), + Some("application/json"), + "the comparison is on the header name, which is case-insensitive" + ); +} + +#[test] +fn an_allowlist_entry_that_names_nothing_forwards_nothing() { + let context = context(&[("x-keep", "value"), ("x-drop", "value")]); + let config = request_rules(&[], &[], &[], Some(Passthrough::Allowlist), &["x-absent"]); + let outbound = transform_request( + &context, + &config, + &selected("upstream.example.com", None), + &PluginMutations::default(), + None, + ) + .expect("the map is valid"); + assert!(!holds(&outbound, "x-keep")); + assert!(!holds(&outbound, "x-drop")); + assert_eq!(value_of(&outbound, "host"), Some("upstream.example.com")); +} + +#[test] +fn the_all_mode_forwards_every_header_the_checks_keep() { + let context = context(&[("x-one", "1"), ("x-two", "2")]); + let config = request_rules(&[], &[], &[], Some(Passthrough::All), &[]); + let outbound = transform_request( + &context, + &config, + &selected("upstream.example.com", None), + &PluginMutations::default(), + None, + ) + .expect("the map is valid"); + assert_eq!(value_of(&outbound, "x-one"), Some("1")); + assert_eq!(value_of(&outbound, "x-two"), Some("2")); +} + +#[test] +fn a_set_rule_overwrites_and_positions_itself() { + let context = context(&[("x-existing", "old")]); + let config = request_rules(&[("x-existing", "new")], &[], &[], Some(Passthrough::All), &[]); + let outbound = transform_request( + &context, + &config, + &selected("upstream.example.com", None), + &PluginMutations::default(), + None, + ) + .expect("the map is valid"); + assert_eq!(value_of(&outbound, "x-existing"), Some("new")); +} + +#[test] +fn a_set_rule_over_a_name_the_passthrough_did_not_forward_appends_one_entry() { + let context = context(&[("x-set", "from-passthrough")]); + let config = request_rules(&[("x-set", "from-rule")], &[], &[], None, &[]); + let outbound = transform_request( + &context, + &config, + &selected("upstream.example.com", None), + &PluginMutations::default(), + None, + ) + .expect("the map is valid"); + let entries: Vec<&str> = outbound + .iter() + .filter(|(name, _)| name == "x-set") + .map(|(_, value)| value.as_str()) + .collect(); + assert_eq!(entries, vec!["from-rule"]); +} + +#[test] +fn an_add_rule_appends_a_second_value_under_one_name() { + let context = context(&[("x-multi", "first")]); + let config = request_rules(&[], &[("x-multi", "second")], &[], Some(Passthrough::All), &[]); + let outbound = transform_request( + &context, + &config, + &selected("upstream.example.com", None), + &PluginMutations::default(), + None, + ) + .expect("the map is valid"); + let entries: Vec<&str> = outbound + .iter() + .filter(|(name, _)| name == "x-multi") + .map(|(_, value)| value.as_str()) + .collect(); + assert_eq!(entries, vec!["first", "second"]); +} + +#[test] +fn a_remove_rule_drops_every_entry_of_its_name() { + let context = context(&[("x-gone", "1")]); + let config = request_rules( + &[("x-gone", "resurrected")], + &[], + &["x-gone"], + Some(Passthrough::All), + &[], + ); + let outbound = transform_request( + &context, + &config, + &selected("upstream.example.com", None), + &PluginMutations::default(), + None, + ) + .expect("the map is valid"); + assert!(!holds(&outbound, "x-gone"), "remove runs after set"); +} + +#[test] +fn the_host_replacement_carries_a_non_default_port() { + let context = context(&[("host", "api.example.com")]); + let config = request_rules(&[], &[], &[], None, &[]); + let outbound = transform_request( + &context, + &config, + &selected("upstream.example.com", Some(8443)), + &PluginMutations::default(), + None, + ) + .expect("the map is valid"); + assert_eq!(value_of(&outbound, "host"), Some("upstream.example.com:8443")); +} + +#[test] +fn the_host_replacement_omits_the_documented_default_port() { + let context = context(&[]); + let config = request_rules(&[], &[], &[], None, &[]); + let outbound = transform_request( + &context, + &config, + &selected("upstream.example.com", Some(443)), + &PluginMutations::default(), + None, + ) + .expect("the map is valid"); + assert_eq!(value_of(&outbound, "host"), Some("upstream.example.com")); +} + +#[test] +fn the_plugin_mutations_run_after_the_configuration_rules() { + let context = context(&[("x-from-config", "config")]); + let config = request_rules(&[("x-from-config", "config")], &[], &[], None, &[]); + let mutations = PluginMutations { + set: vec![(String::from("x-from-config"), String::from("plugin"))], + removed: Vec::new(), + }; + let outbound = transform_request( + &context, + &config, + &selected("upstream.example.com", None), + &mutations, + None, + ) + .expect("the map is valid"); + assert_eq!(value_of(&outbound, "x-from-config"), Some("plugin")); +} + +#[test] +fn a_plugin_removal_drops_the_entry_the_rules_wrote() { + let config = request_rules(&[("x-dropped", "config")], &[], &[], None, &[]); + let mutations = PluginMutations { + set: Vec::new(), + removed: vec![String::from("x-dropped")], + }; + let outbound = transform_request( + &context(&[]), + &config, + &selected("upstream.example.com", None), + &mutations, + None, + ) + .expect("the map is valid"); + assert!(!holds(&outbound, "x-dropped")); +} + +#[test] +fn a_control_character_in_the_resulting_map_is_a_400() { + let context = context(&[]); + let config = request_rules(&[("x-evil", "value\r\ninjected")], &[], &[], None, &[]); + let error = transform_request( + &context, + &config, + &selected("upstream.example.com", None), + &PluginMutations::default(), + None, + ) + .expect_err("the value is an injection vector"); + assert_eq!(error.kind, ErrorKind::ValidationError); +} + +#[test] +fn an_empty_host_authority_is_a_400() { + // The endpoint host is a validated domain type, so the empty authority only + // reaches the map through a plugin mutation, which is unvalidated input. + let mutations = PluginMutations { + set: vec![(String::from("host"), String::new())], + removed: Vec::new(), + }; + let outcome = transform_request( + &context(&[]), + &request_rules(&[], &[], &[], None, &[]), + &selected("upstream.example.com", None), + &mutations, + None, + ); + let error = outcome.expect_err("an authority of blanks is not valid"); + assert_eq!(error.kind, ErrorKind::ValidationError); +} + +#[test] +fn the_response_rules_run_in_the_same_set_add_remove_order() { + let upstream_headers = vec![ + (String::from("content-length"), String::from("5")), + (String::from("transfer-encoding"), String::from("chunked")), + (String::from("x-kept"), String::from("value")), + ]; + let config = HeadersConfig { + request: None, + response: Some(ResponseHeaderRules { + set: BTreeMap::from([( + String::from("x-kept"), + String::from("replaced"), + )]), + add: BTreeMap::from([( + String::from("x-added"), + String::from("added"), + )]), + remove: vec![String::from("x-gone")], + }), + }; + let outbound = transform_response(&upstream_headers, &config, &PluginMutations::default()); + assert!( + !holds(&outbound, "content-length") && !holds(&outbound, "transfer-encoding"), + "the gateway re-states the framing itself" + ); + assert_eq!(value_of(&outbound, "x-kept"), Some("replaced")); + assert_eq!(value_of(&outbound, "x-added"), Some("added")); +} + +#[test] +fn the_response_mutations_of_the_plugin_chain_run_last() { + let upstream_headers = vec![(String::from("x-upstream"), String::from("value"))]; + let config = HeadersConfig::default(); + let mutations = PluginMutations { + set: vec![(String::from("x-request-id"), String::from("abc"))], + removed: Vec::new(), + }; + let outbound = transform_response(&upstream_headers, &config, &mutations); + assert_eq!(value_of(&outbound, "x-request-id"), Some("abc")); + assert_eq!(value_of(&outbound, "x-upstream"), Some("value")); +} + +#[test] +fn an_empty_upstream_header_set_answers_an_empty_map() { + let outbound = transform_response(&[], &HeadersConfig::default(), &PluginMutations::default()); + assert!(outbound.is_empty()); +} diff --git a/gears/system/oagw/oagw/tests/proxy_match_tests.rs b/gears/system/oagw/oagw/tests/proxy_match_tests.rs new file mode 100644 index 0000000..d3d91c0 --- /dev/null +++ b/gears/system/oagw/oagw/tests/proxy_match_tests.rs @@ -0,0 +1,248 @@ +//! Route matching over the resolved candidate set. +//! +//! Covers `cpt-cf-oagw-algo-route-match` and the acceptance rows of +//! `cpt-cf-oagw-dod-route-matching`: the method allowlist as the first filter, +//! the longest configured path that wins, the ascending `priority` tie-break of +//! §1.5, the `path_suffix_mode` decision and its shipped-schema default, the +//! query allowlist the matched route carries, the disabled and enabled flags +//! the candidates are filtered on, and the two failures the caller answers +//! 404 and 400 with. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use oagw::data_plane::match_route::{failure_of, match_route}; +use oagw::domain::error::ErrorKind; +use oagw::domain::proxy::{AliasDerivation, ResolvedUpstream, RouteCandidate}; +use oagw::domain::route::{HttpMatch, MatchConfig, PathSuffixMode, Route}; +use oagw::domain::{Endpoint, EndpointHost, HeadersConfig, Scheme}; +use uuid::Uuid; + +const TENANT: Uuid = Uuid::from_u128(0x31); +const UPSTREAM: Uuid = Uuid::from_u128(0x41); +const PROTOCOL_HTTP: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; + +/// One route candidate of the set the match selects from. +fn candidate(route_id: u128, methods: &[&str], path: &str, priority: Option) -> RouteCandidate { + candidate_with(route_id, methods, path, priority, None, true) +} + +/// The same candidate with the suffix mode and the enabled flag stated. +fn candidate_with( + route_id: u128, + methods: &[&str], + path: &str, + priority: Option, + suffix_mode: Option, + enabled: bool, +) -> RouteCandidate { + let mut http = HttpMatch { + methods: methods.iter().map(|method| String::from(*method)).collect(), + path: String::from(path), + query_allowlist: Vec::new(), + path_suffix_mode: None, + }; + http.path_suffix_mode = suffix_mode; + http.query_allowlist = vec![String::from("model")]; + RouteCandidate { + tenant_id: TENANT, + depth: 0, + route: Route { + id: Uuid::from_u128(route_id), + upstream_id: UPSTREAM, + match_config: MatchConfig { + http: Some(http), + grpc: None, + }, + plugins: None, + rate_limit: None, + tags: Vec::new(), + cors: None, + priority, + enabled: Some(enabled), + }, + } +} + +/// A resolved upstream whose candidate set the caller supplies. +fn resolved(candidates: Vec) -> ResolvedUpstream { + ResolvedUpstream { + cors: None, + tenant_id: TENANT, + upstream_id: UPSTREAM, + alias: String::from("api.example.com"), + alias_derivation: AliasDerivation::Explicit, + endpoints: vec![Endpoint { + scheme: Scheme::Https, + host: EndpointHost::parse("api.example.com").expect("a valid endpoint host"), + port: Some(8443), + }], + protocol: String::from(PROTOCOL_HTTP), + enabled: true, + headers: HeadersConfig::default(), + rate_limit: None, + plugins: None, + route_candidates: candidates, + } +} + +#[test] +fn a_method_the_route_does_not_declare_is_never_a_candidate() { + let upstream = resolved(vec![candidate(0x51, &["GET"], "/v1/chat", None)]); + let outcome = match_route(&upstream, None, "POST", "/v1/chat", None); + assert!(matches!(outcome, oagw::data_plane::MatchOutcome::NoMatch)); + let failure = failure_of(&outcome).expect("the no-match outcome is a failure"); + assert_eq!(failure.kind, ErrorKind::RouteNotFound); +} + +#[test] +fn a_path_no_candidate_prefixes_is_never_a_match() { + let upstream = resolved(vec![candidate(0x51, &["GET"], "/v1/chat", None)]); + let outcome = match_route(&upstream, None, "GET", "/v2/other", None); + assert!(matches!(outcome, oagw::data_plane::MatchOutcome::NoMatch)); +} + +#[test] +fn a_path_that_prefixes_only_at_a_non_boundary_is_never_a_match() { + // `/v1` addresses `/v1/chat` and never `/v1chat`. + let upstream = resolved(vec![candidate(0x51, &["GET"], "/v1", None)]); + let outcome = match_route(&upstream, None, "GET", "/v1chat", None); + assert!(matches!(outcome, oagw::data_plane::MatchOutcome::NoMatch)); +} + +#[test] +fn a_disabled_route_is_skipped_whatever_its_path() { + let upstream = resolved(vec![candidate_with( + 0x51, + &["GET"], + "/v1/chat", + None, + None, + false, + )]); + let outcome = match_route(&upstream, None, "GET", "/v1/chat", None); + assert!(matches!(outcome, oagw::data_plane::MatchOutcome::NoMatch)); +} + +#[test] +fn the_longest_configured_prefix_wins() { + let upstream = resolved(vec![ + candidate(0x51, &["GET"], "/v1", Some(1)), + candidate(0x52, &["GET"], "/v1/chat", Some(9)), + ]); + let matched = match_route(&upstream, None, "GET", "/v1/chat/completions", None); + let oagw::data_plane::MatchOutcome::Matched(route) = matched else { + panic!("the longer prefix addresses the request"); + }; + assert_eq!(route.route_id, Uuid::from_u128(0x52)); + assert_eq!(route.outbound_path, "/v1/chat"); +} + +#[test] +fn the_ascending_priority_breaks_a_tie_of_one_prefix() { + let upstream = resolved(vec![ + candidate(0x52, &["GET"], "/v1/chat", Some(5)), + candidate(0x51, &["GET"], "/v1/chat", Some(2)), + ]); + let matched = match_route(&upstream, None, "GET", "/v1/chat", None); + let oagw::data_plane::MatchOutcome::Matched(route) = matched else { + panic!("one of the tied candidates matches"); + }; + assert_eq!(route.route_id, Uuid::from_u128(0x51)); + assert_eq!(route.priority, Some(2)); +} + +#[test] +fn a_route_that_declares_no_priority_is_the_least_specific_of_its_group() { + let upstream = resolved(vec![ + candidate(0x51, &["GET"], "/v1/chat", None), + candidate(0x52, &["GET"], "/v1/chat", Some(7)), + ]); + let matched = match_route(&upstream, None, "GET", "/v1/chat", None); + let oagw::data_plane::MatchOutcome::Matched(route) = matched else { + panic!("one of the tied candidates matches"); + }; + assert_eq!(route.route_id, Uuid::from_u128(0x52)); +} + +#[test] +fn a_route_with_no_priority_never_beats_one_that_declares_a_smaller_value() { + let upstream = resolved(vec![ + candidate(0x51, &["GET"], "/v1/chat", None), + candidate(0x52, &["GET"], "/v1/chat", Some(1)), + ]); + let matched = match_route(&upstream, None, "GET", "/v1/chat", None); + let oagw::data_plane::MatchOutcome::Matched(route) = matched else { + panic!("one of the tied candidates matches"); + }; + assert_eq!(route.route_id, Uuid::from_u128(0x52)); +} + +#[test] +fn the_shipped_schema_default_of_the_suffix_mode_is_append() { + let upstream = resolved(vec![candidate(0x51, &["GET"], "/v1/chat", None)]); + let matched = match_route(&upstream, None, "GET", "/v1/chat/completions", Some("completions")); + let oagw::data_plane::MatchOutcome::Matched(route) = matched else { + panic!("an append route admits the suffix"); + }; + assert_eq!(route.outbound_path, "/v1/chat/completions"); +} + +#[test] +fn a_suffix_of_only_slashes_appends_nothing() { + let upstream = resolved(vec![candidate(0x51, &["GET"], "/v1/chat", None)]); + let matched = match_route(&upstream, None, "GET", "/v1/chat/", Some("///")); + let oagw::data_plane::MatchOutcome::Matched(route) = matched else { + panic!("the route still matches"); + }; + assert_eq!(route.outbound_path, "/v1/chat"); +} + +#[test] +fn a_disabled_suffix_mode_rejects_the_suffix_the_route_received() { + let upstream = resolved(vec![candidate_with( + 0x51, + &["GET"], + "/v1/chat", + None, + Some(PathSuffixMode::Disabled), + true, + )]); + let outcome = match_route(&upstream, None, "GET", "/v1/chat", Some("completions")); + assert!(matches!(outcome, oagw::data_plane::MatchOutcome::SuffixRejected)); + let failure = failure_of(&outcome).expect("the rejected suffix is a failure"); + assert_eq!(failure.kind, ErrorKind::ValidationError); +} + +#[test] +fn a_disabled_suffix_mode_still_admits_the_alias_alone() { + let upstream = resolved(vec![candidate_with( + 0x51, + &["GET"], + "/v1/chat", + None, + Some(PathSuffixMode::Disabled), + true, + )]); + let matched = match_route(&upstream, None, "GET", "/v1/chat", None); + assert!(matches!(matched, oagw::data_plane::MatchOutcome::Matched(_))); +} + +#[test] +fn the_matched_route_carries_the_query_allowlist_of_its_route() { + let upstream = resolved(vec![candidate(0x51, &["GET"], "/v1/chat", None)]); + let matched = match_route(&upstream, None, "GET", "/v1/chat", None); + let oagw::data_plane::MatchOutcome::Matched(route) = matched else { + panic!("the route matches"); + }; + assert_eq!(route.query_allowlist, vec![String::from("model")]); +} + +#[test] +fn a_request_that_addresses_the_alias_alone_supplies_no_suffix() { + let upstream = resolved(vec![candidate(0x51, &["GET"], "/v1", None)]); + let matched = match_route(&upstream, None, "GET", "/v1", None); + let oagw::data_plane::MatchOutcome::Matched(route) = matched else { + panic!("the route matches itself"); + }; + assert_eq!(route.outbound_path, "/v1"); +} diff --git a/gears/system/oagw/oagw/tests/proxy_resolve_tests.rs b/gears/system/oagw/oagw/tests/proxy_resolve_tests.rs new file mode 100644 index 0000000..6012dd6 --- /dev/null +++ b/gears/system/oagw/oagw/tests/proxy_resolve_tests.rs @@ -0,0 +1,353 @@ +//! Consuming the effective configuration at proxy time. +//! +//! Covers `cpt-cf-oagw-algo-resolve-consume` and the acceptance rows of +//! `cpt-cf-oagw-dod-effective-config` the Data Plane owns: the cache hit that +//! answers without a second resolution, the miss that resolves through the +//! hierarchical feature and populates the cache, the four outcomes, the layer +//! order of consumption, and the gRPC not-found posture of the §1.5 deviation. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use oagw::control_plane::cache::ControlPlaneCache; +use oagw::data_plane::{DpCache, consume}; +use oagw::domain::effective::RouteSelector; +use oagw::data_plane::Resolution; +use oagw::domain::route::{HttpMatch, MatchConfig}; +use oagw::domain::upstream::{Endpoint, ServerConfig, Upstream}; +use oagw::domain::{EndpointHost, Scheme}; +use oagw::store::OagwStore; +use oagw::OagwConfig; +use uuid::Uuid; + +const TENANT: Uuid = Uuid::from_u128(0x11); +const OTHER: Uuid = Uuid::from_u128(0x12); +const UPSTREAM: Uuid = Uuid::from_u128(0x21); +const PROTOCOL_HTTP: &str = oagw::PROTOCOL_HTTP; +const PROTOCOL_GRPC: &str = oagw::PROTOCOL_GRPC; + +/// An HTTP endpoint on one host. +fn endpoint(host: &str) -> Endpoint { + Endpoint { + scheme: Scheme::Https, + host: EndpointHost::parse(host).expect("a valid endpoint host"), + port: Some(443), + } +} + +/// An enabled HTTP upstream holding one alias. +fn upstream(id: Uuid, alias: &str, host: &str) -> Upstream { + let mut row = Upstream::new( + id, + ServerConfig { + endpoints: vec![endpoint(host)], + }, + String::from(PROTOCOL_HTTP), + ); + row.alias = Some(String::from(alias)); + row +} + +/// One HTTP route addressing one upstream. +fn route(id: Uuid, upstream_id: Uuid, path: &str) -> oagw::domain::route::Route { + oagw::domain::route::Route { + id, + upstream_id, + match_config: MatchConfig { + http: Some(HttpMatch { + methods: vec![String::from("GET")], + path: String::from(path), + query_allowlist: Vec::new(), + path_suffix_mode: None, + }), + grpc: None, + }, + plugins: None, + rate_limit: None, + tags: Vec::new(), + cors: None, + priority: None, + enabled: Some(true), + } +} + +/// The selector one proxy request matches against. +fn selector() -> RouteSelector { + RouteSelector::Http { + method: String::from("GET"), + path: String::from("/v1/chat"), + } +} + +/// An empty cache and an empty control-plane cache, as a request finds them. +fn caches() -> (DpCache, ControlPlaneCache) { + (DpCache::new(), ControlPlaneCache::new()) +} + +#[test] +fn a_miss_resolves_through_the_chain_and_populates_the_cache() { + let store = OagwStore::new(); + store + .insert_upstream(TENANT, &upstream(UPSTREAM, "api.example.com", "api.example.com")) + .expect("the upstream is stored"); + store + .insert_route(TENANT, &route(Uuid::from_u128(0x61), UPSTREAM, "/v1/chat")) + .expect("the route is stored"); + let (cache, _) = caches(); + + let resolution = consume( + &store, + &cache, + TENANT, + &[], + "api.example.com", + &selector(), + ); + let Resolution::Resolved(resolved) = resolution else { + panic!("the populated store resolves"); + }; + assert_eq!(resolved.upstream_id, UPSTREAM); + assert_eq!(resolved.alias, "api.example.com"); + assert_eq!(resolved.tenant_id, TENANT); + assert_eq!(resolved.route_candidates.len(), 1); + assert!(resolved.enabled); + + let key = DpCache::upstream_key(TENANT, "api.example.com"); + assert!(cache.get(&key).is_some(), "the miss populated the cache"); +} + +#[test] +fn a_hit_answers_from_the_cache_without_a_second_resolution() { + let store = OagwStore::new(); + store + .insert_upstream(TENANT, &upstream(UPSTREAM, "api.example.com", "api.example.com")) + .expect("the upstream is stored"); + store + .insert_route(TENANT, &route(Uuid::from_u128(0x61), UPSTREAM, "/v1/chat")) + .expect("the route is stored"); + let (cache, _) = caches(); + + consume(&store, &cache, TENANT, &[], "api.example.com", &selector()).expect_resolved(); + // The store is emptied after the entry was populated: a second consumption + // that still resolves answers from the cache and never from the store. + store.delete_upstream(TENANT, UPSTREAM).expect("the row goes"); + let second = consume(&store, &cache, TENANT, &[], "api.example.com", &selector()); + assert!( + matches!(second, Resolution::Resolved(_)), + "the hit answers without the store" + ); +} + +#[test] +fn an_alias_no_chain_element_holds_is_the_not_found_outcome() { + let store = OagwStore::new(); + let (cache, _) = caches(); + let resolution = consume( + &store, + &cache, + TENANT, + &[], + "absent.example.com", + &selector(), + ); + assert!(matches!(resolution, Resolution::NotFound)); +} + +#[test] +fn a_route_of_a_tenant_outside_the_chain_is_never_a_candidate() { + let store = OagwStore::new(); + store + .insert_upstream(OTHER, &upstream(UPSTREAM, "api.example.com", "api.example.com")) + .expect("the upstream is stored under the other tenant"); + store + .insert_route(OTHER, &route(Uuid::from_u128(0x61), UPSTREAM, "/v1/chat")) + .expect("the route is stored"); + let (cache, _) = caches(); + + let resolution = consume(&store, &cache, TENANT, &[], "api.example.com", &selector()); + assert!( + matches!(resolution, Resolution::NotFound), + "the calling tenant reads no other tenant's rows" + ); +} + +#[test] +fn a_disabled_upstream_is_never_dialed_whatever_the_chain_contributed() { + let store = OagwStore::new(); + let mut row = upstream(UPSTREAM, "api.example.com", "api.example.com"); + row.enabled = false; + store.insert_upstream(TENANT, &row).expect("the row is stored"); + store + .insert_route(TENANT, &route(Uuid::from_u128(0x61), UPSTREAM, "/v1/chat")) + .expect("the route is stored"); + let (cache, _) = caches(); + + let resolution = consume(&store, &cache, TENANT, &[], "api.example.com", &selector()); + assert!(matches!(resolution, Resolution::Disabled)); + let failure = resolution.failure_of().expect("a disabled upstream fails"); + assert_eq!(failure.kind, oagw::domain::error::ErrorKind::LinkUnavailable); +} + +#[test] +fn a_grpc_upstream_is_answered_not_found_before_any_http_match_key() { + let store = OagwStore::new(); + let mut row = upstream(UPSTREAM, "grpc.example.com", "grpc.example.com"); + row.protocol = String::from(PROTOCOL_GRPC); + store.insert_upstream(TENANT, &row).expect("the row is stored"); + let (cache, _) = caches(); + + let resolution = consume(&store, &cache, TENANT, &[], "grpc.example.com", &selector()); + assert!( + matches!(resolution, Resolution::NotFound), + "the §1.5 deviation answers a gRPC upstream not found" + ); +} + +#[test] +fn an_alias_that_cannot_be_normalized_fails_closed() { + let store = OagwStore::new(); + let (cache, _) = caches(); + let resolution = consume(&store, &cache, TENANT, &[], "", &selector()); + assert!(matches!(resolution, Resolution::Failed)); +} + +#[test] +fn the_configured_alias_normalizes_to_the_shape_the_cache_keys_on() { + let store = OagwStore::new(); + store + .insert_upstream(TENANT, &upstream(UPSTREAM, "api.example.com", "api.example.com")) + .expect("the upstream is stored"); + store + .insert_route(TENANT, &route(Uuid::from_u128(0x61), UPSTREAM, "/v1/chat")) + .expect("the route is stored"); + let (cache, _) = caches(); + + let resolution = consume( + &store, + &cache, + TENANT, + &[], + "API.Example.COM.", + &selector(), + ); + let Resolution::Resolved(resolved) = resolution else { + panic!("the normalized alias resolves the same row"); + }; + assert_eq!(resolved.alias, "api.example.com"); + let key = DpCache::upstream_key(TENANT, "api.example.com"); + assert!( + cache.get(&key).is_some(), + "the entry is keyed on the normalized alias" + ); +} + +#[test] +fn the_candidates_of_an_ancestor_chain_are_ordered_most_distant_first() { + let store = OagwStore::new(); + let ancestor = Uuid::from_u128(0x13); + store + .insert_upstream(ancestor, &upstream(UPSTREAM, "api.example.com", "api.example.com")) + .expect("the ancestor's row is stored"); + store + .insert_route( + ancestor, + &route(Uuid::from_u128(0x61), UPSTREAM, "/v1"), + ) + .expect("the ancestor's route is stored"); + let (cache, _) = caches(); + + let resolution = consume( + &store, + &cache, + TENANT, + &[ancestor], + "api.example.com", + &selector(), + ); + let Resolution::Resolved(resolved) = resolution else { + panic!("the ancestor's rows are read through the chain"); + }; + assert!( + resolved + .route_candidates + .iter() + .any(|candidate| candidate.tenant_id == ancestor && candidate.depth == 1), + "the ancestor contributed its route at depth 1" + ); + let key = DpCache::upstream_key(TENANT, "api.example.com"); + let hit = cache.get(&key).expect("the entry is cached"); + assert_eq!(hit.tenant_id, ancestor, "the routing target is the ancestor's"); +} + +#[test] +fn the_entry_the_resolution_populated_is_flushed_by_its_upstream_write() { + let store = OagwStore::new(); + store + .insert_upstream(TENANT, &upstream(UPSTREAM, "api.example.com", "api.example.com")) + .expect("the upstream is stored"); + store + .insert_route(TENANT, &route(Uuid::from_u128(0x61), UPSTREAM, "/v1/chat")) + .expect("the route is stored"); + let (cache, _) = caches(); + let _ = OagwConfig::default(); + + consume(&store, &cache, TENANT, &[], "api.example.com", &selector()).expect_resolved(); + cache.flush_upstream(TENANT, UPSTREAM); + let key = DpCache::upstream_key(TENANT, "api.example.com"); + assert!( + cache.get(&key).is_none(), + "the write-path notification drops the entry" + ); +} + +#[test] +fn the_outcomes_that_carry_a_catalogue_row_are_the_two_failures() { + assert_eq!( + Resolution::NotFound + .failure_of() + .expect("the not-found outcome fails") + .kind, + oagw::domain::error::ErrorKind::RouteNotFound + ); + assert_eq!( + Resolution::Disabled + .failure_of() + .expect("the disabled outcome fails") + .kind, + oagw::domain::error::ErrorKind::LinkUnavailable + ); + assert!( + Resolution::Failed.failure_of().is_none(), + "the failed-closed outcome is the platform 500 problem shape, not a catalogue row" + ); + assert!( + Resolution::Resolved(std::sync::Arc::new(oagw::domain::proxy::ResolvedUpstream { + cors: None, + tenant_id: TENANT, + upstream_id: UPSTREAM, + alias: String::from("api.example.com"), + alias_derivation: oagw::domain::proxy::AliasDerivation::Explicit, + endpoints: Vec::new(), + protocol: String::from(PROTOCOL_HTTP), + enabled: true, + headers: oagw::domain::HeadersConfig::default(), + rate_limit: None, + plugins: None, + route_candidates: Vec::new(), + })) + .failure_of() + .is_none()); +} + +/// Reads the resolved value out of one resolution. +trait ExpectResolved { + fn expect_resolved(self) -> std::sync::Arc; +} + +impl ExpectResolved for Resolution { + fn expect_resolved(self) -> std::sync::Arc { + match self { + Resolution::Resolved(resolved) => resolved, + other => panic!("the resolution answered {other:?}"), + } + } +} diff --git a/gears/system/oagw/oagw/tests/proxy_validate_tests.rs b/gears/system/oagw/oagw/tests/proxy_validate_tests.rs new file mode 100644 index 0000000..c570d9d --- /dev/null +++ b/gears/system/oagw/oagw/tests/proxy_validate_tests.rs @@ -0,0 +1,215 @@ +//! Inbound and body validation of a proxy request. +//! +//! Covers `cpt-cf-oagw-algo-inbound-validate` and +//! `cpt-cf-oagw-algo-body-validate` and the acceptance rows of +//! `cpt-cf-oagw-dod-inbound-validation` and `cpt-cf-oagw-dod-body-validation`: +//! the query allowlist the matched route holds, the CR/LF header-injection +//! check, the declared-size pre-check before any buffering, the framing +//! defects, the 100MB hard limit read as 100,000,000 bytes, and the +//! `Content-Length` agreement. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use oagw::data_plane::validate::{BODY_LIMIT_BYTES, validate_body, validate_inbound}; +use oagw::domain::error::ErrorKind; +use oagw::domain::proxy::{MatchedRoute, ProxyContext}; +use uuid::Uuid; + +const TENANT: Uuid = Uuid::from_u128(0x91); +const ROUTE: Uuid = Uuid::from_u128(0x92); + +/// A matched route whose query allowlist the caller states. +fn matched(allowlist: &[&str]) -> MatchedRoute { + MatchedRoute { + cors: None, + tenant_id: TENANT, + route_id: ROUTE, + priority: None, + outbound_path: String::from("/v1/chat"), + match_pattern: String::from("/v1/things"), + query_allowlist: allowlist.iter().map(|name| String::from(*name)).collect(), + rate_limit: None, + plugins: None, + } +} + +/// A proxy context whose query and headers the caller states. +fn context(query: Option<&str>, headers: &[(&str, &str)]) -> ProxyContext { + ProxyContext { + method: String::from("POST"), + alias: String::from("api.example.com"), + path_suffix: None, + query: query.map(String::from), + headers: headers + .iter() + .map(|(name, value)| (String::from(*name), String::from(*value))) + .collect(), + target_host: None, + tenant_id: TENANT, + subject_id: None, + correlation: None, + } +} + +/// Whether the failure is the 400 validation one. +fn is_validation(error: &oagw::domain::error::DomainError) -> bool { + error.kind == ErrorKind::ValidationError +} + +#[test] +fn a_query_parameter_the_allowlist_names_is_admitted() { + let context = context(Some("model=gpt-4"), &[("content-type", "application/json")]); + assert!(validate_inbound(&context, &matched(&["model"])).is_ok()); +} + +#[test] +fn a_query_parameter_outside_the_allowlist_is_rejected_with_400() { + let context = context(Some("model=gpt-4&temperature=1"), &[("content-type", "text/plain")]); + let error = validate_inbound(&context, &matched(&["model"])) + .expect_err("temperature is not admitted"); + assert!(is_validation(&error)); + assert!(error.detail.contains("temperature"), "{}", error.detail); +} + +#[test] +fn an_empty_allowlist_admits_no_parameter_at_all() { + let context = context(Some("model=gpt-4"), &[]); + let error = validate_inbound(&context, &matched(&[])) + .expect_err("the route admits no parameter"); + assert!(is_validation(&error)); + assert!(error.detail.contains("model"), "{}", error.detail); +} + +#[test] +fn a_request_without_a_query_has_nothing_to_reject() { + let context = context(None, &[]); + assert!(validate_inbound(&context, &matched(&[])).is_ok()); +} + +#[test] +fn a_repeated_parameter_is_reported_once_per_distinct_name() { + let context = context(Some("a=1&a=2&b=3"), &[]); + let error = validate_inbound(&context, &matched(&[])).expect_err("both names are refused"); + assert!(error.detail.contains('a') && error.detail.contains('b'), "{}", error.detail); +} + +#[test] +fn a_header_value_carrying_cr_or_lf_is_a_rejection() { + let context = context(None, &[("x-injected", "value\r\nX-Evil: 1")]); + let error = validate_inbound(&context, &matched(&[])).expect_err("the value is injected"); + assert!(is_validation(&error)); + assert!(error.detail.contains("x-injected"), "{}", error.detail); +} + +#[test] +fn the_query_allowlist_comparison_is_on_the_decoded_name() { + // `form_urlencoded` decodes `%6dodel` to `model`, so the allowlist admits + // it under its decoded name. + let context = context(Some("%6dodel=gpt-4"), &[]); + assert!(validate_inbound(&context, &matched(&["model"])).is_ok()); +} + +#[test] +fn the_body_limit_is_read_as_one_hundred_million_bytes() { + assert_eq!(BODY_LIMIT_BYTES, 100_000_000); +} + +#[test] +fn a_body_at_the_limit_is_admitted_and_one_over_it_is_not() { + let context = context(None, &[("content-length", "100000000")]); + let body = vec![0_u8; BODY_LIMIT_BYTES]; + assert!(validate_body(&context, &body).is_ok(), "the limit is inclusive"); + + let over = vec![0_u8; BODY_LIMIT_BYTES + 1]; + let error = validate_body(&context, &over).expect_err("one byte over the limit"); + assert_eq!(error.kind, ErrorKind::PayloadTooLarge); +} + +#[test] +fn a_declared_size_over_the_limit_is_refused_before_any_buffering() { + let context = context(None, &[("content-length", "100000001")]); + let error = validate_body(&context, &[]).expect_err("the declared size alone refuses"); + assert_eq!(error.kind, ErrorKind::PayloadTooLarge); +} + +#[test] +fn a_content_length_that_is_not_an_integer_is_a_400() { + let context = context(None, &[("content-length", "many")]); + let error = validate_body(&context, &[]).expect_err("the value is not an integer"); + assert!(is_validation(&error)); + assert!( + error.detail.contains("not a valid integer"), + "{}", + error.detail + ); +} + +#[test] +fn a_content_length_declared_twice_is_a_400() { + let context = context(None, &[("content-length", "1"), ("content-length", "2")]); + let error = validate_body(&context, &[]).expect_err("the length is declared twice"); + assert!(is_validation(&error)); + assert!( + error.detail.contains("more than once"), + "{}", + error.detail + ); +} + +#[test] +fn a_content_length_that_disagrees_with_the_body_is_a_400() { + let context = context(None, &[("content-length", "5")]); + let error = validate_body(&context, b"hello world") + .expect_err("the declared length does not describe the body"); + assert!(is_validation(&error)); + assert!( + error.detail.contains("does not match"), + "{}", + error.detail + ); +} + +#[test] +fn an_agreeing_content_length_is_admitted() { + let context = context(None, &[("content-length", "5")]); + assert!(validate_body(&context, b"hello").is_ok()); +} + +#[test] +fn chunked_framing_is_admitted_alone() { + let context = context(None, &[("transfer-encoding", "chunked")]); + assert!(validate_body(&context, b"payload").is_ok()); +} + +#[test] +fn a_transfer_encoding_that_is_not_chunked_is_a_400() { + let context = context(None, &[("transfer-encoding", "gzip")]); + let error = validate_body(&context, b"payload").expect_err("gzip is not chunked"); + assert!(is_validation(&error)); + assert!(error.detail.contains("not chunked"), "{}", error.detail); +} + +#[test] +fn content_length_and_transfer_encoding_on_one_request_is_a_400() { + let context = context( + None, + &[("content-length", "7"), ("transfer-encoding", "chunked")], + ); + let error = validate_body(&context, b"payload").expect_err("both framings are declared"); + assert!(is_validation(&error)); + assert!(error.detail.contains("both declared"), "{}", error.detail); +} + +#[test] +fn a_header_value_carrying_cr_or_lf_refuses_the_body_too() { + let context = context(None, &[("x-injected", "value\n")]); + let error = validate_body(&context, &[]).expect_err("the value is injected"); + assert!(is_validation(&error)); + assert!(error.detail.contains("x-injected"), "{}", error.detail); +} + +#[test] +fn a_request_without_any_framing_header_is_admitted() { + let context = context(None, &[]); + assert!(validate_body(&context, b"anything").is_ok()); +} diff --git a/gears/system/oagw/oagw/tests/ratelimit_algorithms_tests.rs b/gears/system/oagw/oagw/tests/ratelimit_algorithms_tests.rs new file mode 100644 index 0000000..2c5b46e --- /dev/null +++ b/gears/system/oagw/oagw/tests/ratelimit_algorithms_tests.rs @@ -0,0 +1,220 @@ +//! The two counter algorithms of `cpt-cf-oagw-algo-token-bucket` and +//! `cpt-cf-oagw-algo-sliding-window`. +//! +//! Covers the full-bucket initialization, the burst up to `burst.capacity`, +//! the refill and its capacity ceiling, the clock that moved backwards, a +//! `cost` above the capacity, the sliding window's boundary behaviour, and the +//! whole-second `Retry-After` both derive. + +// @cpt-dod:cpt-cf-oagw-dod-rate-limit-tests:p1 + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::time::{Duration, Instant}; + +use oagw::domain::ratelimit::{ + SlidingWindow, TokenBucket, sliding_window, token_bucket, +}; +use oagw::domain::upstream::{Sustained, Window}; + +fn sustained(rate: u64, window: Window) -> Sustained { + Sustained { + rate, + window: Some(window), + } +} + +fn at_offset(start: Instant, millis: u64) -> Instant { + start + Duration::from_millis(millis) +} + +#[test] +fn an_absent_bucket_starts_full() { + // A bucket no request has touched yet is initialized at full capacity, so + // the first burst is admitted up to `burst.capacity` (§1.5). + let start = Instant::now(); + let bucket = TokenBucket::full(50, &sustained(10, Window::Second), start); + assert_eq!(bucket.tokens(), 50); + assert_eq!(bucket.capacity, 50); +} + +#[test] +fn a_burst_is_admitted_up_to_the_capacity() { + // A 50-capacity bucket charged 1 per request admits 50 requests and + // refuses the 51st in the same instant. + let start = Instant::now(); + let mut bucket = TokenBucket::full(50, &sustained(10, Window::Second), start); + for _ in 0..50 { + let outcome = token_bucket(&mut bucket, 1, start); + assert!(outcome.admitted, "the burst is inside the capacity"); + } + assert_eq!(bucket.tokens(), 0); + let refused = token_bucket(&mut bucket, 1, start); + assert!(!refused.admitted, "the bucket is empty"); + assert_eq!(refused.remaining, 0); +} + +#[test] +fn a_cost_above_the_remaining_refuses_at_the_tenth_request() { + // A 50-capacity bucket charged 10 per request admits 5 requests and + // refuses the 6th, whose shortfall the bucket has not refilled yet. + let start = Instant::now(); + let mut bucket = TokenBucket::full(50, &sustained(10, Window::Second), start); + for index in 0..5 { + let outcome = token_bucket(&mut bucket, 10, start); + assert!(outcome.admitted); + assert_eq!(outcome.remaining, 50 - 10 * (index + 1)); + } + let refused = token_bucket(&mut bucket, 10, start); + assert!(!refused.admitted); + assert_eq!(refused.remaining, 0); +} + +#[test] +fn the_refill_is_capped_at_the_capacity() { + // A bucket left idle far longer than it takes to fill comes back at the + // capacity and never above it. + let start = Instant::now(); + let mut bucket = TokenBucket::full(50, &sustained(10, Window::Second), start); + let outcome = token_bucket(&mut bucket, 50, start); + assert!(outcome.admitted); + bucket.refill(at_offset(start, 60_000)); + assert_eq!(bucket.tokens(), 50, "the refill stops at the capacity"); +} + +#[test] +fn the_refill_tracks_the_sustained_rate() { + // A 50-capacity bucket at 10 per second refills 1 token every 100 ms: 250 + // ms of idle time returns 2 tokens. + let start = Instant::now(); + let mut bucket = TokenBucket::full(50, &sustained(10, Window::Second), start); + let _ = token_bucket(&mut bucket, 50, start); + bucket.refill(at_offset(start, 250)); + assert_eq!(bucket.tokens(), 2); +} + +#[test] +fn a_clock_that_moved_backwards_adds_and_removes_nothing() { + // An `Instant` that reads earlier than the bucket's last update is no + // elapsed time at all rather than a negative refill, so a clock adjustment + // adds no tokens and removes none (§1.4). + let start = Instant::now(); + let mut bucket = TokenBucket::full(50, &sustained(10, Window::Second), start); + let _ = token_bucket(&mut bucket, 50, start); + assert_eq!(bucket.tokens(), 0); + bucket.refill(start - Duration::from_secs(60)); + assert_eq!(bucket.tokens(), 0, "the backward reading earned nothing"); + assert_eq!(bucket.tokens(), 0, "and removed nothing"); +} + +#[test] +fn a_cost_above_the_capacity_is_refused_with_a_computed_delay() { + // A `cost` no refill within the window can cover is refused, and the + // delay the refusal reports is the shortfall against the refill rate + // rounded up to a whole second: 5 tokens short at 10 per second is 1 + // second, and 5 tokens short at 1 per second is 5 seconds. + let start = Instant::now(); + let mut fast = TokenBucket::full(5, &sustained(10, Window::Second), start); + let _ = token_bucket(&mut fast, 5, start); + let refused = token_bucket(&mut fast, 5, start); + assert!(!refused.admitted); + assert_eq!(refused.delay_seconds, 1); + + let mut slow = TokenBucket::full(5, &sustained(1, Window::Second), start); + let _ = token_bucket(&mut slow, 5, start); + let refused = token_bucket(&mut slow, 5, start); + assert!(!refused.admitted); + assert_eq!(refused.delay_seconds, 5); +} + +#[test] +fn the_sliding_window_refuses_across_the_boundary() { + // A window of 3 per second records 3 charges and refuses the 4th at the + // same instant; the charge a refusal records is none, so the window does + // not extend itself against the requests it refuses. + let start = Instant::now(); + let mut window = SlidingWindow::default(); + for _ in 0..3 { + let outcome = sliding_window( + &mut window, + 1, + 3, + Duration::from_secs(1), + start, + ); + assert!(outcome.admitted); + } + assert_eq!(window.total(), 3); + let refused = sliding_window(&mut window, 1, 3, Duration::from_secs(1), start); + assert!(!refused.admitted); + assert_eq!(window.total(), 3, "a refusal records no charge"); + + // The same refusal repeated at any instant inside the window answers the + // same total, which is what keeps a rejected client from pushing its own + // admission further out. + let refused = sliding_window( + &mut window, + 1, + 3, + Duration::from_secs(1), + at_offset(start, 500), + ); + assert!(!refused.admitted); + assert_eq!(window.total(), 3); + assert_eq!(refused.delay_seconds, 1); +} + +#[test] +fn the_sliding_window_admits_at_the_boundary() { + // A charge ages out the instant its window length has fully elapsed, so + // the request at `start + 1s` is the admission the boundary permits. + let start = Instant::now(); + let mut window = SlidingWindow::default(); + for _ in 0..3 { + let _ = sliding_window(&mut window, 1, 3, Duration::from_secs(1), start); + } + let refused = sliding_window(&mut window, 1, 3, Duration::from_secs(1), start); + assert!(!refused.admitted); + + let admitted = sliding_window( + &mut window, + 1, + 3, + Duration::from_secs(1), + at_offset(start, 1_000), + ); + assert!(admitted.admitted, "the boundary charge has aged out"); + assert_eq!(admitted.remaining, 2); +} + +#[test] +fn the_sliding_window_reports_the_wait_for_the_oldest_charge() { + // With the window full, the delay is the time until enough of the oldest + // charges age out, rounded up to a whole second. + let start = Instant::now(); + let mut window = SlidingWindow::default(); + for _ in 0..3 { + let _ = sliding_window(&mut window, 1, 3, Duration::from_secs(1), start); + } + let refused = sliding_window( + &mut window, + 1, + 3, + Duration::from_secs(1), + at_offset(start, 250), + ); + assert!(!refused.admitted); + assert_eq!(refused.delay_seconds, 1); +} + +#[test] +fn a_cost_above_the_rate_is_never_admitted_by_the_window() { + // A `cost` the window's rate cannot cover in one window is refused for as + // long as the window holds any charge. + let start = Instant::now(); + let mut window = SlidingWindow::default(); + let _ = sliding_window(&mut window, 1, 3, Duration::from_secs(1), start); + let refused = sliding_window(&mut window, 4, 3, Duration::from_secs(1), start); + assert!(!refused.admitted); + assert_eq!(window.total(), 1); +} diff --git a/gears/system/oagw/oagw/tests/ratelimit_budget_tests.rs b/gears/system/oagw/oagw/tests/ratelimit_budget_tests.rs new file mode 100644 index 0000000..8cc211c --- /dev/null +++ b/gears/system/oagw/oagw/tests/ratelimit_budget_tests.rs @@ -0,0 +1,143 @@ +//! The budget arithmetic of `cpt-cf-oagw-algo-budget-allocate`. +//! +//! Covers the rejection ADR 0003's worked example performs at a ratio of 1.0, +//! the acceptance-with-warning the same arithmetic shows at a ratio above 1.0, +//! the in-budget acceptance, the `unlimited` mode, and the first-come-first- +//! served charge of the `shared` pool. + +// @cpt-dod:cpt-cf-oagw-dod-rate-limit-tests:p1 + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use oagw::domain::ratelimit::{BudgetAllocation, BudgetMode, BudgetOutcome, allocate_budget}; + +fn budget(total: u64, overcommit_ratio_percent: u64) -> BudgetAllocation { + BudgetAllocation { + total, + overcommit_ratio_percent, + } +} + +#[test] +fn a_sum_above_the_total_at_a_ratio_of_one_is_rejected() { + // The write path's rejection: the children's declared allocations plus the + // candidate exceed the parent's `total` at an `overcommit_ratio` of 1.0, + // which is answered 400. + let outcome = allocate_budget( + Some(budget(1000, 100)), + BudgetMode::Allocated, + 1000, + 1000, + 100, + ); + assert_eq!(outcome, BudgetOutcome::Rejected { allocated: 1100 }); +} + +#[test] +fn the_same_sum_at_a_ratio_above_one_is_accepted_with_a_warning() { + // The same sum at a ratio of 1.5 fits the ceiling the ratio sets but still + // exceeds the parent's `total`, which is the acceptance ADR 0003's worked + // arithmetic warns about rather than rejects. + let outcome = allocate_budget( + Some(budget(1000, 150)), + BudgetMode::Allocated, + 1000, + 1000, + 100, + ); + assert_eq!( + outcome, + BudgetOutcome::Accepted { + allocated: 1100, + over_total: true, + } + ); +} + +#[test] +fn a_sum_inside_the_total_is_accepted_without_a_warning() { + let outcome = allocate_budget( + Some(budget(1000, 100)), + BudgetMode::Allocated, + 1000, + 500, + 100, + ); + assert_eq!( + outcome, + BudgetOutcome::Accepted { + allocated: 600, + over_total: false, + } + ); +} + +#[test] +fn a_sum_at_the_ceiling_is_accepted() { + // The ceiling the ratio computes is inclusive: a sum that reaches it + // exactly is not above it. + let outcome = allocate_budget( + Some(budget(1000, 150)), + BudgetMode::Allocated, + 1000, + 1400, + 100, + ); + assert!(matches!(outcome, BudgetOutcome::Accepted { .. })); +} + +#[test] +fn a_parent_with_no_budget_tracks_nothing() { + // A parent with no budget at all is `unlimited` rather than a rejection, + // because a mode that tracks nothing cannot be exceeded. + let outcome = allocate_budget(None, BudgetMode::Allocated, 0, 1000, 1000); + assert_eq!(outcome, BudgetOutcome::Unlimited); +} + +#[test] +fn the_shared_pool_charges_first_come_first_served() { + // ADR 0003's Example 2: tenants A, B and C share a 5000 per minute pool + // with no individual guarantee, so each charge is taken from whatever the + // pool still holds. + let outcome = allocate_budget( + Some(budget(5000, 100)), + BudgetMode::Shared, + 5000, + 0, + 100, + ); + assert_eq!(outcome, BudgetOutcome::Shared { remaining: 4900 }); + + let drained = allocate_budget(Some(budget(5000, 100)), BudgetMode::Shared, 40, 0, 100); + assert_eq!(drained, BudgetOutcome::Shared { remaining: 0 }); +} + +#[test] +fn the_unlimited_mode_tracks_nothing() { + // The mode ADR 0003 declares as the default for a leaf tenant validates no + // allocation and charges no pool. + let outcome = allocate_budget(Some(budget(1000, 100)), BudgetMode::Unlimited, 1000, 900, 500); + assert_eq!(outcome, BudgetOutcome::Unlimited); +} + +#[test] +fn adr_0003_example_one_validates_the_partner_allocation() { + // Example 1's partner: a parent of 10000 per minute holding a child + // allocated 5000 at a ratio of 1.2 accepts a further 1000 and warns, + // because the sum of 6000 is above the 5000 total but inside the 6000 + // ceiling the ratio sets. + let outcome = allocate_budget( + Some(budget(5000, 120)), + BudgetMode::Allocated, + 5000, + 5000, + 1000, + ); + assert_eq!( + outcome, + BudgetOutcome::Accepted { + allocated: 6000, + over_total: true, + } + ); +} diff --git a/gears/system/oagw/oagw/tests/ratelimit_check_tests.rs b/gears/system/oagw/oagw/tests/ratelimit_check_tests.rs new file mode 100644 index 0000000..d63c380 --- /dev/null +++ b/gears/system/oagw/oagw/tests/ratelimit_check_tests.rs @@ -0,0 +1,538 @@ +//! The rate-limit check of `cpt-cf-oagw-flow-rate-limit-check`, the strategy +//! flow that answers an over-limit request, the header set +//! `cpt-cf-oagw-algo-rate-limit-headers` produces, and the cleanup +//! `cpt-cf-oagw-flow-rate-limit-cleanup` runs. +//! +//! Covers the no-limit outcome over every layer, the five counter scopes and +//! the `tenant` fallback, the 429 answer with its header set, the bound queue, +//! the withheld burst reserve of the `degrade` strategy, the breaker's answer +//! before any charge, and the prefix cleanup of a deleted upstream and of a +//! deleted route. + +// @cpt-dod:cpt-cf-oagw-dod-rate-limit-check:p1 +// @cpt-dod:cpt-cf-oagw-dod-rate-limit-headers:p1 +// @cpt-dod:cpt-cf-oagw-dod-rate-limit-strategies:p1 +// @cpt-dod:cpt-cf-oagw-dod-circuit-breaker:p1 +// @cpt-dod:cpt-cf-oagw-dod-rate-limit-state:p1 + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::net::{IpAddr, SocketAddr}; +use std::time::{Duration, Instant}; + +use oagw::domain::effective::EffectiveRateLimit; +use oagw::domain::proxy::{AliasDerivation, MatchedRoute, ResolvedUpstream}; +use oagw::domain::ratelimit::{QUEUE_CAPACITY, RateLimiterRegistry, fold}; +use oagw::domain::route::{GrpcMatch, Route}; +use oagw::domain::upstream::{ + Algorithm, Burst, HeadersConfig, RateLimitConfig, RateLimitScope, SharingMode, Strategy, + Sustained, Window, +}; +use oagw::data_plane::ratelimit::{ + LimitIdentity, LimitVerdict, RateLimitHeaders, RegistryCleanup, SharedLimits, check, cleanup, + rate_limit_headers, upstream_prefix, +}; +use uuid::Uuid; + +const TENANT: Uuid = Uuid::from_u128(0xA11CE); +const UPSTREAM: Uuid = Uuid::from_u128(0xB0B); +const ROUTE: Uuid = Uuid::from_u128(0xC0DE); + +fn now() -> Instant { + Instant::now() +} + +fn limit_of(rate_limit: RateLimitConfig) -> EffectiveRateLimit { + EffectiveRateLimit { + owner: TENANT, + mode: SharingMode::Enforce, + rate_limit, + } +} + +/// An upstream whose limit is the only one the fold sees. +fn resolved_with(rate_limit: Option) -> ResolvedUpstream { + ResolvedUpstream { + cors: None, + tenant_id: TENANT, + upstream_id: UPSTREAM, + alias: String::from("orders.internal"), + alias_derivation: AliasDerivation::Explicit, + endpoints: Vec::new(), + protocol: String::from("cf.core.oagw.http.v1"), + enabled: true, + headers: HeadersConfig::default(), + rate_limit, + plugins: None, + route_candidates: Vec::new(), + } +} + +/// A route whose limit is the only one the fold sees. +fn matched_with(rate_limit: Option) -> MatchedRoute { + MatchedRoute { + tenant_id: TENANT, + route_id: ROUTE, + priority: None, + outbound_path: String::from("/orders"), + match_pattern: String::from("/v1/things"), + query_allowlist: Vec::new(), + rate_limit, + plugins: None, + cors: None, + } +} + +fn token_bucket_config(rate: u64, window: Window, capacity: u64) -> RateLimitConfig { + RateLimitConfig { + sharing: Some(SharingMode::Enforce), + algorithm: Some(Algorithm::TokenBucket), + sustained: Some(Sustained { + rate, + window: Some(window), + }), + burst: Some(Burst { capacity }), + scope: Some(RateLimitScope::Tenant), + strategy: Some(Strategy::Reject), + cost: Some(1), + } +} + +fn identity() -> LimitIdentity { + LimitIdentity::new(TENANT, Some(String::from("alice")), None) +} + +/// The `MatchedRoute` a route-layer limit test builds. +fn route_row() -> Route { + Route { + id: ROUTE, + upstream_id: UPSTREAM, + match_config: oagw::domain::route::MatchConfig { + http: None, + grpc: Some(GrpcMatch { + service: String::from("orders.v1.Orders"), + method: String::from("Get"), + }), + }, + plugins: None, + rate_limit: None, + tags: Vec::new(), + cors: None, + priority: None, + enabled: Some(true), + } +} + +#[tokio::test] +async fn no_layer_carries_a_limit() { + // Neither layer carries a `rate_limit`: the check admits with no charge, + // no counter, and no header (§1.5). + let shared = SharedLimits::new(); + let resolved = resolved_with(None); + let matched = matched_with(None); + let verdict = check(&shared, &resolved, &matched, &identity(), now()).await; + assert_eq!(verdict, LimitVerdict::Admitted); + assert_eq!( + shared.lock().queue_len("upstream:any"), + 0, + "the check created no queue state" + ); +} + +#[tokio::test] +async fn a_limit_at_the_upstream_layer_alone_is_enforced() { + // A limit declared at the upstream layer alone is enforced, which is the + // fold's outcome and not the two-layer look a guard once suggested. + let shared = SharedLimits::new(); + let resolved = resolved_with(Some(limit_of(token_bucket_config(1, Window::Second, 1)))); + let matched = matched_with(None); + let first = check(&shared, &resolved, &matched, &identity(), now()).await; + let second = check(&shared, &resolved, &matched, &identity(), now()).await; + assert_eq!(first, LimitVerdict::Admitted); + assert!( + matches!(second, LimitVerdict::Rejected(_)), + "a limit at one layer is a limit, and the second request is over it" + ); +} + +#[tokio::test] +async fn the_reject_answer_carries_the_header_set() { + // The 429 the `reject` strategy produces carries the four headers ADR 0003 + // declares and the `retry_after_seconds` member of the problem body. + let shared = SharedLimits::new(); + let resolved = resolved_with(Some(limit_of(token_bucket_config(1, Window::Second, 1)))); + let matched = matched_with(None); + let _ = check(&shared, &resolved, &matched, &identity(), now()).await; + let rejected = check(&shared, &resolved, &matched, &identity(), now()).await; + let LimitVerdict::Rejected(headers) = rejected else { + panic!("the second request is over the limit"); + }; + let pairs = headers.pairs(); + let names: Vec<&str> = pairs.iter().map(|(name, _)| name.as_str()).collect(); + assert_eq!( + names, + vec!["X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Reset", "Retry-After"] + ); + assert_eq!(headers.limit.as_deref(), Some("1")); + assert_eq!(headers.remaining.as_deref(), Some("0")); + assert_eq!(headers.retry_after.as_deref(), Some("1")); + assert_eq!(headers.retry_after_seconds, Some(1)); +} + +#[tokio::test] +async fn the_gate_closes_the_whole_set() { + // A closed `response_headers` gate produces no `X-RateLimit-*` header and + // no `Retry-After`; the variant and the error source are unchanged by it. + let config = token_bucket_config(1, Window::Second, 1); + let limit = fold(&oagw::domain::LimitLayers { + upstream: Some(&limit_of(config)), + route: None, + }) + .expect("a limit at one layer is a limit"); + let mut registry = RateLimiterRegistry::new(); + let outcome = oagw::domain::token_bucket( + registry.bucket("route:x", limit.burst_capacity, &limit.sustained, now()), + limit.cost, + now(), + ); + let headers = rate_limit_headers(&limit, &outcome, false); + assert_eq!(headers, RateLimitHeaders::default()); + assert!(headers.pairs().is_empty(), "the gate closed the whole set"); +} + +#[tokio::test] +async fn admitted_requests_carry_no_header() { + // The header set is tied to the 429 answer alone: an admitted request is + // answered with no rate-limit header of any kind. + let shared = SharedLimits::new(); + let resolved = resolved_with(Some(limit_of(token_bucket_config(10, Window::Second, 10)))); + let matched = matched_with(None); + let verdict = check(&shared, &resolved, &matched, &identity(), now()).await; + assert_eq!(verdict, LimitVerdict::Admitted); +} + +#[tokio::test] +async fn degrade_forwards_against_the_reduced_allowance() { + // The withheld burst reserve: a `degrade` upstream at capacity 50 and + // sustained rate 10 admits 10 immediate requests and refuses the 11th, + // which is the reduced capacity the strategy leaves, and answers it 429. + let shared = SharedLimits::new(); + let mut config = token_bucket_config(10, Window::Second, 50); + config.strategy = Some(Strategy::Degrade); + let resolved = resolved_with(Some(limit_of(config))); + let matched = matched_with(None); + for _ in 0..10 { + let verdict = check(&shared, &resolved, &matched, &identity(), now()).await; + assert_eq!(verdict, LimitVerdict::Degraded); + } + let over = check(&shared, &resolved, &matched, &identity(), now()).await; + assert!( + matches!(over, LimitVerdict::Rejected(_)), + "the allowance the degraded posture leaves does not cover the eleventh" + ); +} + +#[tokio::test] +async fn a_cost_above_capacity_is_refused_on_every_attempt() { + // A `cost` the effective capacity never covers is refused on every attempt + // for as long as that configuration stands, and records no charge. + let shared = SharedLimits::new(); + let mut config = token_bucket_config(10, Window::Second, 50); + config.cost = Some(60); + let resolved = resolved_with(Some(limit_of(config))); + let matched = matched_with(None); + for _ in 0..3 { + let verdict = check(&shared, &resolved, &matched, &identity(), now()).await; + assert!(matches!(verdict, LimitVerdict::Rejected(_))); + } +} + +#[tokio::test] +async fn the_scopes_key_separate_counters() { + // The five scopes key five separate counters, and the counter key carries + // the `{resource_type}:{resource_id}` prefix of the resource whose + // `rate_limit` the effective limit came from. + let shared = SharedLimits::new(); + let subject = LimitIdentity::new(TENANT, Some(String::from("alice")), None); + let peer = LimitIdentity::new( + TENANT, + None, + Some(SocketAddr::new(IpAddr::from([203, 0, 113, 7]), 443)), + ); + let resolved = resolved_with(Some(limit_of(token_bucket_config(10, Window::Second, 10)))); + let matched = matched_with(None); + + let tenant_key = RateLimiterRegistry::counter_key( + "upstream", + &UPSTREAM.to_string(), + RateLimitScope::Tenant, + &TENANT.to_string(), + Some(Window::Second), + ); + let user_key = RateLimiterRegistry::counter_key( + "upstream", + &UPSTREAM.to_string(), + RateLimitScope::User, + "alice", + Some(Window::Second), + ); + let ip_key = RateLimiterRegistry::counter_key( + "upstream", + &UPSTREAM.to_string(), + RateLimitScope::Ip, + "203.0.113.7", + Some(Window::Second), + ); + let route_key = RateLimiterRegistry::counter_key( + "upstream", + &UPSTREAM.to_string(), + RateLimitScope::Route, + &ROUTE.to_string(), + Some(Window::Second), + ); + let global_key = RateLimiterRegistry::counter_key( + "upstream", + &UPSTREAM.to_string(), + RateLimitScope::Global, + "global", + Some(Window::Second), + ); + let keys = [tenant_key, user_key, ip_key, route_key, global_key]; + for (index, key) in keys.iter().enumerate() { + for other in &keys[index + 1..] { + assert_ne!(key, other, "two scopes never share a counter key"); + } + } + // The identity the check forms from the request drives which counter is + // charged: a peer identity and a subject identity are distinct counters. + let _ = check(&shared, &resolved, &matched, &subject, now()).await; + let _ = check(&shared, &resolved, &matched, &peer, now()).await; +} + +#[tokio::test] +async fn a_scope_without_its_identifier_falls_back_to_the_tenant() { + // A `user` scope with no subject and an `ip` scope with no peer address + // fall back to the `tenant` scope and its key rather than skip enforcement. + let shared = SharedLimits::new(); + let anonymous = LimitIdentity::new(TENANT, None, None); + let resolved = resolved_with(Some(limit_of(token_bucket_config(1, Window::Second, 1)))); + let matched = matched_with(None); + let first = check(&shared, &resolved, &matched, &anonymous, now()).await; + let second = check(&shared, &resolved, &matched, &anonymous, now()).await; + assert_eq!(first, LimitVerdict::Admitted); + assert!( + matches!(second, LimitVerdict::Rejected(_)), + "the fallback charges the tenant's counter, so the second request is over it" + ); +} + +#[tokio::test] +async fn the_breaker_answers_before_any_charge() { + // A breaker that is not admitting is answered 503 before any charge and + // before the outbound attempt, with no rate-limit header of any kind. + let shared = SharedLimits::new(); + let resolved = resolved_with(Some(limit_of(token_bucket_config(1, Window::Second, 1)))); + let matched = matched_with(None); + { + let mut registry = shared.lock(); + let breaker = registry.breaker(&upstream_prefix(UPSTREAM)); + for _ in 0..5 { + breaker.count(false, true, now()); + } + } + let verdict = check(&shared, &resolved, &matched, &identity(), now()).await; + assert!( + matches!(verdict, LimitVerdict::Open { retry_after_seconds } if (1..=30).contains(&retry_after_seconds)), + "the 503 carries the seconds remaining of the open interval, which the elapsed test time trims" + ); + // The 503 the breaker answers carries no rate-limit header, because the + // breaker is not a rate limit; the header set is the 429's alone. + let LimitVerdict::Open { .. } = verdict else { + panic!("the breaker is open"); + }; +} + +#[tokio::test] +async fn the_breaker_counts_only_the_three_rows() { + // The breaker counts a connection that never established, an exchange that + // exceeded its deadline, and an unavailable link; it counts neither an + // upstream 4xx nor a 429 this feature produced. + let mut registry = RateLimiterRegistry::new(); + let breaker = registry.breaker(&upstream_prefix(UPSTREAM)); + for _ in 0..5 { + breaker.count(false, false, now()); + } + assert!( + breaker.admit(now()), + "a 4xx answer and a 429 this feature produced are not evidence about the target" + ); + let mut registry = RateLimiterRegistry::new(); + let breaker = registry.breaker(&upstream_prefix(UPSTREAM)); + for _ in 0..5 { + breaker.count(false, true, now()); + } + assert!(!breaker.admit(now()), "five counted failures trip the breaker"); +} + +#[tokio::test] +async fn a_deleted_upstream_drops_its_prefix() { + // The cleanup of a deleted upstream drops every entry keyed under its + // prefix, including the breaker machine held for it. + let mut registry = RateLimiterRegistry::new(); + registry.bucket( + &format!("upstream:{UPSTREAM}:Tenant:{}:{:?}", TENANT, Window::Second), + 10, + &Sustained { + rate: 10, + window: Some(Window::Second), + }, + now(), + ); + registry.breaker(&upstream_prefix(UPSTREAM)); + registry.breaker(&upstream_prefix(Uuid::from_u128(0xD00))); + let dropped = cleanup(&mut registry, "upstream", UPSTREAM); + assert!(dropped >= 2, "the prefix drop has exactly one owner"); + let mut registry = RateLimiterRegistry::new(); + registry.breaker(&upstream_prefix(UPSTREAM)); + cleanup(&mut registry, "upstream", UPSTREAM); + assert!( + registry.breaker(&upstream_prefix(UPSTREAM)).admit(now()), + "a recreated alias re-initializes its breaker at closed" + ); +} + +#[tokio::test] +async fn a_deleted_route_leaves_the_upstream_in_place() { + // The cleanup of a deleted route drops that route's prefix and leaves the + // upstream's own buckets and its breaker machine in place. + let mut registry = RateLimiterRegistry::new(); + let upstream_key = format!("upstream:{UPSTREAM}:Tenant:{TENANT}:{:?}", Window::Second); + registry.bucket( + &upstream_key, + 10, + &Sustained { + rate: 10, + window: Some(Window::Second), + }, + now(), + ); + registry.breaker(&upstream_prefix(UPSTREAM)); + let dropped = cleanup(&mut registry, "route", ROUTE); + assert_eq!(dropped, 0, "no entry of the upstream's prefix is a route's"); + let mut registry = RateLimiterRegistry::new(); + let route_key = format!("route:{ROUTE}:Tenant:{TENANT}:{:?}", Window::Second); + registry.bucket( + &route_key, + 10, + &Sustained { + rate: 10, + window: Some(Window::Second), + }, + now(), + ); + let dropped = cleanup(&mut registry, "route", ROUTE); + assert_eq!(dropped, 1, "the route's own bucket goes with the route"); +} + +#[tokio::test] +async fn the_cleanup_observer_drops_on_the_seam() { + // The observer the state registers drops the prefix of the deleted row on + // the seam the write path notifies. + use oagw::control_plane::cache::RateLimitCleanup as _; + let observer = RegistryCleanup::new(SharedLimits::new()); + observer.upstream_deleted(TENANT, UPSTREAM); + observer.route_deleted(TENANT, ROUTE); +} + +#[tokio::test] +async fn a_queue_that_cannot_hold_answers_the_reject_answer() { + // A queue at its bound answers the next over-limit request with the same + // 429 the `reject` strategy produces, and holds no request beyond it. + let shared = SharedLimits::new(); + let resolved = resolved_with(Some(limit_of({ + let mut config = token_bucket_config(1, Window::Second, 1); + config.strategy = Some(Strategy::Queue); + config + }))); + let matched = matched_with(None); + // Drain the allowance, then fill the queue to its bound so the next + // over-limit request meets a full queue. + let _ = check(&shared, &resolved, &matched, &identity(), now()).await; + for _ in 0..QUEUE_CAPACITY { + assert!( + shared + .lock() + .enqueue(&format!("upstream:{UPSTREAM}:Tenant:{TENANT}:{:?}", Window::Second), now()), + "the bound is QUEUE_CAPACITY" + ); + } + let verdict = check(&shared, &resolved, &matched, &identity(), now()).await; + let LimitVerdict::Rejected(headers) = verdict else { + panic!("a full queue answers 429 rather than holding"); + }; + assert_eq!(headers.limit.as_deref(), Some("1"), "the same header set"); +} + +#[tokio::test] +async fn a_queued_request_is_released_and_forwarded() { + // A held request whose release admits it is forwarded exactly as an + // immediately admitted one would be, with no marker that it waited. + let shared = SharedLimits::new(); + // A rate of 100 per second refills one token in 10 ms, which is inside the + // 500 ms wait bound the queue holds the request for. + let resolved = resolved_with(Some(limit_of({ + let mut config = token_bucket_config(100, Window::Second, 1); + config.strategy = Some(Strategy::Queue); + config + }))); + let matched = matched_with(None); + let first = check(&shared, &resolved, &matched, &identity(), now()).await; + assert_eq!(first, LimitVerdict::Admitted); + let held = check(&shared, &resolved, &matched, &identity(), now()).await; + assert_eq!( + held, + LimitVerdict::Admitted, + "the release re-runs the check and admits when the counter holds the cost" + ); + assert_eq!( + shared.lock().queue_len("upstream:any"), + 0, + "a released request leaves the queue with no marker that it waited" + ); +} + +#[tokio::test] +async fn a_route_layer_limit_keys_the_route_prefix() { + // A limit the route layer declares keys its counters under the route's + // prefix, so a prefix drop has exactly one owner. + let shared = SharedLimits::new(); + let resolved = resolved_with(None); + let matched = matched_with(Some(limit_of(token_bucket_config(1, Window::Second, 1)))); + let _ = check(&shared, &resolved, &matched, &identity(), now()).await; + let verdict = check(&shared, &resolved, &matched, &identity(), now()).await; + assert!( + matches!(verdict, LimitVerdict::Rejected(_)), + "the route layer's own limit is enforced against the route's counter" + ); +} + +#[tokio::test] +async fn the_check_costs_no_more_than_the_latency_budget() { + // The check is an in-process read and an in-process write, so its own + // share of the proxy path is far inside the 10 ms p95 budget + // `cpt-cf-oagw-dod-rate-limit-latency` allocates. + let shared = SharedLimits::new(); + let resolved = resolved_with(Some(limit_of(token_bucket_config(10_000, Window::Second, 10_000)))); + let matched = matched_with(None); + let start = Instant::now(); + for _ in 0..1_000 { + let verdict = check(&shared, &resolved, &matched, &identity(), now()).await; + assert_eq!(verdict, LimitVerdict::Admitted); + } + let elapsed = start.elapsed(); + assert!( + elapsed < Duration::from_secs(1), + "1000 checks took {elapsed:?}, so one check is inside the budget" + ); + let _ = route_row(); +} diff --git a/gears/system/oagw/oagw/tests/ratelimit_fold_tests.rs b/gears/system/oagw/oagw/tests/ratelimit_fold_tests.rs new file mode 100644 index 0000000..db6c6a2 --- /dev/null +++ b/gears/system/oagw/oagw/tests/ratelimit_fold_tests.rs @@ -0,0 +1,209 @@ +//! The effective-limit fold of `cpt-cf-oagw-algo-effective-limit-fold`. +//! +//! Covers the no-limit outcome, the minimum of the visible sustained rates +//! across windows, the minimum of the visible `burst.capacity` values ADR +//! 0003's Example 1 performs beside the sustained one, and the four members +//! that carry no merge, taken from the last layer that declares each in the +//! upstream, then route, then tenant order. + +// @cpt-dod:cpt-cf-oagw-dod-rate-limit-hierarchy:p1 + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::time::Duration; + +use oagw::domain::effective::EffectiveRateLimit; +use oagw::domain::upstream::{Algorithm, Burst, RateLimitConfig, RateLimitScope, SharingMode, Strategy, Sustained, Window}; +use oagw::domain::{LimitLayers, fold}; +use uuid::Uuid; + +fn owner() -> Uuid { + Uuid::from_u128(0xA11CE) +} + +fn layer(rate: u64, window: Window, capacity: Option, members: Members) -> EffectiveRateLimit { + EffectiveRateLimit { + owner: owner(), + mode: SharingMode::Enforce, + rate_limit: RateLimitConfig { + sharing: Some(SharingMode::Enforce), + algorithm: members.algorithm, + sustained: Some(Sustained { + rate, + window: Some(window), + }), + burst: capacity.map(|value| Burst { capacity: value }), + scope: members.scope, + strategy: members.strategy, + cost: members.cost, + }, + } +} + +/// The four members a layer may declare, so a test states only the ones it +/// exercises. +#[derive(Default)] +struct Members { + algorithm: Option, + scope: Option, + strategy: Option, + cost: Option, +} + +#[test] +fn no_layer_carries_a_limit() { + // The no-limit outcome: no layer carries a `rate_limit`, so the check + // enforces nothing and charges nothing (§1.5). + let folded = fold(&LimitLayers::default()); + assert!(folded.is_none(), "an unconfigured upstream is not limited"); +} + +#[test] +fn a_limit_at_any_single_layer_is_enforced() { + // A limit declared at the route layer alone is enforced, which is the + // fold's outcome and not the two-layer look a guard once suggested. + let route = layer(100, Window::Minute, None, Members::default()); + let folded = fold(&LimitLayers { + upstream: None, + route: Some(&route), + }) + .expect("a limit at one layer is a limit"); + assert_eq!(folded.sustained.rate, 100); + assert_eq!(folded.sustained.window, Some(Window::Minute)); + // The defaults ADR 0003's field table declares for the members no layer + // states. + assert_eq!(folded.algorithm, Algorithm::TokenBucket); + assert_eq!(folded.scope, RateLimitScope::Tenant); + assert_eq!(folded.strategy, Strategy::Reject); + assert_eq!(folded.cost, 1); + // The capacity defaults to the sustained rate. + assert_eq!(folded.burst_capacity, 100); +} + +#[test] +fn the_strictest_rate_wins_across_windows() { + // `80/second` against `5000/minute` is not decidable without a common + // unit; the merge normalized both to one scale and the fold compares on + // it, so the sustained rate of 80 per second — 4800 per minute, under the + // upstream's 5000 — wins and is reported in its own window. + let upstream = layer(5000, Window::Minute, None, Members::default()); + let route = layer(80, Window::Second, None, Members::default()); + let folded = fold(&LimitLayers { + upstream: Some(&upstream), + route: Some(&route), + }) + .expect("both layers carry a limit"); + assert_eq!(folded.sustained.rate, 80); + assert_eq!(folded.sustained.window, Some(Window::Second)); + + // The same two layers with the route's rate raised above the upstream's + // answer the upstream's rate in the upstream's window. + let looser = layer(6000, Window::Minute, None, Members::default()); + let folded = fold(&LimitLayers { + upstream: Some(&upstream), + route: Some(&looser), + }) + .expect("both layers carry a limit"); + assert_eq!(folded.sustained.rate, 5000); + assert_eq!(folded.sustained.window, Some(Window::Minute)); +} + +#[test] +fn the_ancestor_enforce_rate_arrives_already_folded() { + // An ancestor `rate_limit` marked `enforce` at 10000 per minute with a + // descendant declaring 1000 per minute enforces 1000; the same descendant + // declaring 20000 enforces 10000. The merge folds the ancestor term into + // the layer value, so the fold sees the two layer values the resolution + // produced and re-walks no chain (§1.5). + let ancestor = layer(10_000, Window::Minute, None, Members::default()); + let strict_descendant = layer(1000, Window::Minute, None, Members::default()); + let folded = fold(&LimitLayers { + upstream: Some(&ancestor), + route: Some(&strict_descendant), + }) + .expect("both layers carry a limit"); + assert_eq!(folded.sustained.rate, 1000); + + let loose_descendant = layer(20_000, Window::Minute, None, Members::default()); + let folded = fold(&LimitLayers { + upstream: Some(&ancestor), + route: Some(&loose_descendant), + }) + .expect("both layers carry a limit"); + assert_eq!(folded.sustained.rate, 10_000); +} + +#[test] +fn the_capacity_is_min_merged_beside_the_rate() { + // ADR 0003's Example 1: the ancestor's `burst.capacity` of 1000 against a + // descendant's 100 enforces a capacity of 100, which is the merge performed + // beside the sustained rate. + let ancestor = layer(10_000, Window::Minute, Some(1000), Members::default()); + let descendant = layer(5000, Window::Minute, Some(100), Members::default()); + let folded = fold(&LimitLayers { + upstream: Some(&ancestor), + route: Some(&descendant), + }) + .expect("both layers carry a limit"); + assert_eq!(folded.burst_capacity, 100); + + // A capacity no layer declares defaults to the sustained rate. + let plain = layer(500, Window::Minute, None, Members::default()); + let folded = fold(&LimitLayers { + upstream: Some(&plain), + route: None, + }) + .expect("the layer carries a limit"); + assert_eq!(folded.burst_capacity, 500); +} + +#[test] +fn the_four_members_come_from_the_last_declaring_layer() { + // The members that carry no merge are taken from the last layer that + // declares them in the upstream, then route, then tenant order: a route + // `cost` of 10 overrides an upstream `cost` of 1, which is ADR 0003's + // Example 3, and a strategy declared only at one layer is the strategy + // enforced. + let upstream_members = Members { + cost: Some(1), + algorithm: Some(Algorithm::TokenBucket), + ..Members::default() + }; + let upstream = layer(1000, Window::Minute, None, upstream_members); + + let route_members = Members { + cost: Some(10), + strategy: Some(Strategy::Queue), + scope: Some(RateLimitScope::Route), + ..Members::default() + }; + let route = layer(1000, Window::Minute, None, route_members); + + let folded = fold(&LimitLayers { + upstream: Some(&upstream), + route: Some(&route), + }) + .expect("both layers carry a limit"); + assert_eq!(folded.cost, 10, "the route layer's cost prevails"); + assert_eq!(folded.strategy, Strategy::Queue, "declared only at the route layer"); + assert_eq!(folded.scope, RateLimitScope::Route); + assert_eq!(folded.algorithm, Algorithm::TokenBucket, "declared only at the upstream layer"); +} + +#[test] +fn a_window_is_converted_to_its_length() { + // The conversion of the `second`, `minute`, `hour`, and `day` literals the + // shipped schema enumerates, which the sliding window expires charges on. + assert_eq!(oagw::domain::window_millis(Some(Window::Second)), 1_000); + assert_eq!(oagw::domain::window_millis(Some(Window::Minute)), 60_000); + assert_eq!(oagw::domain::window_millis(Some(Window::Hour)), 3_600_000); + assert_eq!(oagw::domain::window_millis(Some(Window::Day)), 86_400_000); + assert_eq!(oagw::domain::window_millis(None), 1_000); + // The common scale the fold compares on, in requests per day. + let per_second = Sustained { + rate: 10, + window: Some(Window::Second), + }; + assert_eq!(oagw::domain::per_common_scale(&per_second), 10 * 86_400); + let _ = Duration::from_secs(1); +} diff --git a/gears/system/oagw/oagw/tests/ratelimit_registry_tests.rs b/gears/system/oagw/oagw/tests/ratelimit_registry_tests.rs new file mode 100644 index 0000000..738ef8c --- /dev/null +++ b/gears/system/oagw/oagw/tests/ratelimit_registry_tests.rs @@ -0,0 +1,294 @@ +//! The per-instance `RateLimiterRegistry` and the counter keys it holds. +//! +//! Covers the `{resource_type}:{resource_id}` prefix ADR 0003's key structure +//! puts at the head of every key, the five counter scopes, the `tenant` +//! fallback a `user` or `ip` key that cannot be formed takes, the full-bucket +//! initialization of an absent bucket, and the prefix drop of a deleted +//! upstream and of a deleted route. + +// @cpt-dod:cpt-cf-oagw-dod-rate-limit-state:p1 +// @cpt-dod:cpt-cf-oagw-dod-rate-limit-tests:p1 + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::time::{Duration, Instant}; + +use oagw::domain::ratelimit::{ + QUEUE_WAIT, RateLimiterRegistry, token_bucket, +}; +use oagw::domain::upstream::{RateLimitScope, Sustained, Window}; + +fn sustained(rate: u64, window: Window) -> Sustained { + Sustained { + rate, + window: Some(window), + } +} + +#[test] +fn every_key_carries_the_resource_prefix() { + // The structure ADR 0003's Redis key structure gives: the + // `{resource_type}:{resource_id}` prefix of the resource whose + // `rate_limit` the effective limit came from, followed by the scope, its + // identifier, and the effective window. + let key = RateLimiterRegistry::counter_key( + "upstream", + "11111111-1111-1111-1111-111111111111", + RateLimitScope::Tenant, + "22222222-2222-2222-2222-222222222222", + Some(Window::Minute), + ); + assert!( + key.starts_with("upstream:11111111-1111-1111-1111-111111111111:"), + "the prefix leads the key: {key}" + ); + assert!(key.contains("Tenant"), "the scope follows the prefix: {key}"); + assert!(key.contains("22222222-2222-2222-2222-222222222222"), "the scope identifier follows: {key}"); + assert!(key.ends_with("60000"), "the effective window closes the key: {key}"); +} + +#[test] +fn two_upstreams_at_the_same_scope_never_share_a_counter() { + // The prefix is what keeps two upstreams limited at the same `scope` from + // sharing a counter, which is the property the key structure gives. + let first = RateLimiterRegistry::counter_key( + "upstream", + "11111111-1111-1111-1111-111111111111", + RateLimitScope::Tenant, + "22222222-2222-2222-2222-222222222222", + Some(Window::Minute), + ); + let second = RateLimiterRegistry::counter_key( + "upstream", + "33333333-3333-3333-3333-333333333333", + RateLimitScope::Tenant, + "22222222-2222-2222-2222-222222222222", + Some(Window::Minute), + ); + assert_ne!(first, second); +} + +#[test] +fn the_route_layer_keys_on_the_matched_route() { + // The prefix names the resource whose `rate_limit` the effective limit + // came from — the matched route when the effective limit is the route + // layer's, and the resolved upstream for every other layer. + let route_key = RateLimiterRegistry::counter_key( + "route", + "44444444-4444-4444-4444-444444444444", + RateLimitScope::Route, + "44444444-4444-4444-4444-444444444444", + Some(Window::Minute), + ); + let upstream_key = RateLimiterRegistry::counter_key( + "upstream", + "11111111-1111-1111-1111-111111111111", + RateLimitScope::Route, + "44444444-4444-4444-4444-444444444444", + Some(Window::Minute), + ); + assert!(route_key.starts_with("route:44444444")); + assert!(upstream_key.starts_with("upstream:11111111")); + assert_ne!(route_key, upstream_key); +} + +#[test] +fn the_five_scopes_select_five_distinct_counters() { + // `global` charges one counter for the whole gear, `tenant` one per + // calling tenant, `user` one per authenticated subject, `ip` one per peer + // address, and `route` one per matched route (§1.5). + let tenant_id = "22222222-2222-2222-2222-222222222222"; + let scopes = [ + (RateLimitScope::Global, ""), + (RateLimitScope::Tenant, tenant_id), + (RateLimitScope::User, "subject-1"), + (RateLimitScope::Ip, "203.0.113.7"), + (RateLimitScope::Route, "44444444-4444-4444-4444-444444444444"), + ]; + let keys: Vec = scopes + .iter() + .map(|(scope, id)| RateLimiterRegistry::counter_key("upstream", "alias", *scope, id, Some(Window::Minute))) + .collect(); + for (index, key) in keys.iter().enumerate() { + for other in keys.iter().skip(index + 1) { + assert_ne!(key, other, "two scopes never share a counter key"); + } + } +} + +#[test] +fn the_fallback_keys_on_the_calling_tenant() { + // A `user` key with no authenticated subject and an `ip` key with no + // resolvable peer address fall back to the `tenant` scope and its key + // rather than skip enforcement (§1.4), and the fallback is the same key + // the tenant scope forms for every request that lacks the identifier. + let tenant_key = RateLimiterRegistry::counter_key( + "upstream", + "alias", + RateLimitScope::Tenant, + "22222222-2222-2222-2222-222222222222", + Some(Window::Minute), + ); + let user_fallback = RateLimiterRegistry::counter_key( + "upstream", + "alias", + RateLimitScope::Tenant, + "22222222-2222-2222-2222-222222222222", + Some(Window::Minute), + ); + let ip_fallback = RateLimiterRegistry::counter_key( + "upstream", + "alias", + RateLimitScope::Tenant, + "22222222-2222-2222-2222-222222222222", + Some(Window::Minute), + ); + assert_eq!(user_fallback, tenant_key); + assert_eq!(ip_fallback, tenant_key); +} + +#[test] +fn an_absent_bucket_is_initialized_full() { + // The bucket the registry holds for a key it has never seen starts at full + // capacity, so a first burst is admitted up to `burst.capacity` (§1.5). + let start = Instant::now(); + let mut registry = RateLimiterRegistry::new(); + let key = RateLimiterRegistry::counter_key( + "upstream", + "alias", + RateLimitScope::Tenant, + "tenant", + Some(Window::Minute), + ); + let bucket = registry.bucket(key.as_str(), 10, &sustained(10, Window::Second), start); + assert_eq!(bucket.tokens(), 10); + // The second read of the same key is the same bucket, so a first burst + // cannot be replayed by asking for the key again. + let again = registry.bucket(key.as_str(), 10, &sustained(10, Window::Second), start); + let outcome = token_bucket(again, 4, start); + assert!(outcome.admitted); + assert_eq!(again.tokens(), 6); + assert_eq!(registry.bucket(key.as_str(), 10, &sustained(10, Window::Second), start).tokens(), 6); +} + +#[test] +fn a_dropped_upstream_leaves_no_entry_behind() { + // Deleting an upstream drops every bucket keyed under that upstream's + // prefix and the breaker machine held for it, and retains every other + // tenant's and every sibling route's entries (§1.5). + let start = Instant::now(); + let mut registry = RateLimiterRegistry::new(); + let own = RateLimiterRegistry::counter_key("upstream", "gone", RateLimitScope::Tenant, "t", Some(Window::Minute)); + let sibling = RateLimiterRegistry::counter_key("upstream", "kept", RateLimitScope::Tenant, "t", Some(Window::Minute)); + let route = RateLimiterRegistry::counter_key("route", "kept-route", RateLimitScope::Route, "kept-route", Some(Window::Minute)); + let _ = registry.bucket(own.as_str(), 10, &sustained(10, Window::Second), start); + let _ = registry.bucket(sibling.as_str(), 10, &sustained(10, Window::Second), start); + let _ = registry.bucket(route.as_str(), 10, &sustained(10, Window::Second), start); + let _ = registry.breaker("upstream:gone"); + let _ = registry.enqueue(own.as_str(), start); + + let dropped = registry.drop_prefix("upstream:gone"); + assert_eq!(dropped, 3, "the bucket, the breaker, and the queued slot"); + assert_eq!(registry.queue_len(own.as_str()), 0); + assert!( + registry.bucket(sibling.as_str(), 10, &sustained(10, Window::Second), start).tokens() == 10, + "the sibling upstream keeps its own bucket" + ); + assert!( + registry.bucket(route.as_str(), 10, &sustained(10, Window::Second), start).tokens() == 10, + "the route keeps its own bucket" + ); +} + +#[test] +fn a_dropped_route_leaves_the_upstream_untouched() { + // Deleting a route drops every entry keyed under that route's prefix and + // leaves the upstream's own buckets and its breaker machine in place, + // because the route's counters are not the upstream's (§1.5). + let start = Instant::now(); + let mut registry = RateLimiterRegistry::new(); + let route = RateLimiterRegistry::counter_key("route", "gone-route", RateLimitScope::Route, "gone-route", Some(Window::Minute)); + let upstream = RateLimiterRegistry::counter_key("upstream", "kept", RateLimitScope::Tenant, "t", Some(Window::Minute)); + let _ = registry.bucket(route.as_str(), 10, &sustained(10, Window::Second), start); + let _ = registry.bucket(upstream.as_str(), 10, &sustained(10, Window::Second), start); + let _ = registry.breaker("upstream:kept"); + + let dropped = registry.drop_prefix("route:gone-route"); + assert_eq!(dropped, 1); + assert!( + registry.bucket(upstream.as_str(), 10, &sustained(10, Window::Second), start).tokens() == 10, + "the upstream's own bucket stays" + ); + assert_eq!(registry.breaker("upstream:kept").failures.len(), 0, "the upstream's breaker stays"); +} + +#[test] +fn a_configuration_that_holds_no_bucket_drops_nothing() { + // The cleanup is idempotent over an absent key set, which is the error + // scenario the cleanup flow records. + let mut registry = RateLimiterRegistry::new(); + assert_eq!(registry.drop_prefix("upstream:absent"), 0); +} + +#[test] +fn the_queue_holds_its_bound_and_expires_its_slots() { + // The two bounds of the `queue` strategy: the queue never grows past its + // count bound, and a queued request that outwaits the wait bound is + // dropped by its own wait and charged nothing. + let start = Instant::now(); + let mut registry = RateLimiterRegistry::new(); + let key = "upstream:alias:Tenant:t:60000"; + for _ in 0..64 { + assert!(registry.enqueue(key, start)); + } + assert_eq!(registry.queue_len(key), 64, "the count bound of the queue"); + assert!(!registry.enqueue(key, start), "a full queue takes no further slot"); + assert_eq!(registry.queue_len(key), 64); + + // The bound holds at any instant, not only at the one the queue filled at. + let later = start + Duration::from_millis(250); + assert_eq!(registry.dequeue_expired(key, later), 0, "nothing has outwaited yet"); + assert!(!registry.enqueue(key, later), "the bound holds at any instant"); + + // A slot that outwaits the wait bound is dropped when the queue is read. + let expired = registry.dequeue_expired(key, start + QUEUE_WAIT + Duration::from_millis(1)); + assert_eq!(expired, 64); + assert_eq!(registry.queue_len(key), 0); +} + +#[test] +fn a_released_or_gone_request_leaves_the_queue() { + // A released, an expired, or a disconnected request leaves the queue + // through the same removal, its slot returning to the bound and the + // removal charging it nothing. + let start = Instant::now(); + let mut registry = RateLimiterRegistry::new(); + let key = "upstream:alias:Tenant:t:60000"; + assert!(registry.enqueue(key, start)); + assert!(registry.enqueue(key, start)); + assert_eq!(registry.queue_len(key), 2); + registry.dequeue(key); + assert_eq!(registry.queue_len(key), 1); + registry.dequeue(key); + assert_eq!(registry.queue_len(key), 0, "the empty queue leaves the registry"); + registry.dequeue(key); + assert_eq!(registry.queue_len(key), 0, "a removal over an absent queue is silent"); +} + +#[test] +fn a_breaker_reinitializes_at_closed_after_a_drop() { + // An attempt whose machine the cleanup dropped re-initializes at `closed`, + // which is the correct posture for a target whose configuration was + // rewritten. + let start = Instant::now(); + let mut registry = RateLimiterRegistry::new(); + let machine = registry.breaker("upstream:alias"); + for _ in 0..5 { + let _ = machine.count(false, true, start); + } + assert!(!machine.admit(start), "the tripped machine refuses"); + + registry.drop_prefix("upstream:alias"); + let fresh = registry.breaker("upstream:alias"); + assert!(fresh.admit(start), "the re-initialized machine admits"); +} diff --git a/gears/system/oagw/oagw/tests/replace_uniqueness_tests.rs b/gears/system/oagw/oagw/tests/replace_uniqueness_tests.rs new file mode 100644 index 0000000..476d974 --- /dev/null +++ b/gears/system/oagw/oagw/tests/replace_uniqueness_tests.rs @@ -0,0 +1,398 @@ +//! Full-replacement diff and match-uniqueness tests. +//! +//! Covers `cpt-cf-oagw-algo-put-replace-diff` and +//! `cpt-cf-oagw-algo-match-uniqueness`: the immutable fields taken from the +//! addressed row, the route's upstream reference taken from the stored row and +//! a body supplying one refused, the optional families cleared on a +//! replacement, the tags replaced in full, `enabled` carried forward when +//! omitted and set when explicit, match uniqueness re-checked excluding the +//! replaced row, and the match keys one route expands into. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::missing_panics_doc)] + +use oagw::control_plane::match_uniqueness::{confirm_route, match_keys}; +use oagw::control_plane::replace::{route_diff, upstream_diff}; +use oagw::domain::alias::Alias; +use oagw::domain::error::ErrorKind; +use oagw::store::{OagwStore, RouteRow}; +use oagw::{ + Endpoint, EndpointHost, GrpcMatch, HttpMatch, MatchConfig, Route, Scheme, ServerConfig, Upstream, +}; +use uuid::Uuid; + +const HTTP_PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; + +fn tenant(n: u128) -> Uuid { + Uuid::from_u128(n) +} + +fn endpoint(host: &str, port: u16) -> Endpoint { + Endpoint { + scheme: Scheme::Https, + host: EndpointHost::parse(host).expect("valid endpoint host"), + port: Some(port), + } +} + +/// An upstream holding one endpoint set and the alias it derives. +fn upstream(endpoints: &[Endpoint], alias: Option<&str>, tags: &[&str]) -> Upstream { + Upstream { + id: Uuid::new_v4(), + enabled: true, + alias: alias.map(str::to_owned), + tags: tags.iter().map(|tag| (*tag).to_owned()).collect(), + server: ServerConfig { + endpoints: endpoints.to_vec(), + }, + protocol: String::from(HTTP_PROTOCOL), + auth: None, + headers: None, + plugins: None, + rate_limit: None, + cors: None, + } +} + +fn single(host: &str) -> Endpoint { + endpoint(host, 443) +} + +/// A route with one `http` match. +fn route(upstream_id: Uuid, path: &str, priority: i64, enabled: Option) -> Route { + Route { + id: Uuid::new_v4(), + upstream_id, + match_config: MatchConfig { + http: Some(HttpMatch { + methods: vec![String::from("GET")], + path: String::from(path), + query_allowlist: vec![], + path_suffix_mode: None, + }), + grpc: None, + }, + plugins: None, + rate_limit: None, + tags: vec![String::from("edge")], + cors: None, + priority: Some(priority), + enabled, + } +} + +/// A store holding one upstream and one enabled route under it. +fn seeded() -> (OagwStore, RouteRow) { + let store = OagwStore::new(); + let owner = tenant(1); + let stored = store + .insert_upstream(owner, &upstream(&[single("api.openai.com")], None, &["llm"])) + .expect("the upstream"); + let row = store + .insert_route(owner, &route(stored.upstream.id, "/v1/chat", 10, Some(true))) + .expect("the route"); + (store, row) +} + +#[test] +fn the_immutable_identifier_comes_from_the_addressed_row() { + let (store, stored) = seeded(); + let replacement = stored.route.clone(); + let diff = route_diff(&store, &stored, None, replacement).expect("the write set"); + assert_eq!(diff.id, stored.route.id); + assert_eq!(diff.tenant_id, stored.tenant_id); +} + +#[test] +fn a_body_stating_a_different_identifier_is_refused() { + let (store, stored) = seeded(); + let replacement = stored.route.clone(); + let error = route_diff(&store, &stored, Some(Uuid::new_v4()), replacement) + .expect_err("the stated identifier differs"); + assert_eq!(error.kind, ErrorKind::ValidationError); + assert!(error.detail.contains("id"), "{error}"); +} + +#[test] +fn the_tenant_is_never_taken_from_the_body() { + let (store, stored) = seeded(); + let replacement = stored.route.clone(); + let diff = route_diff(&store, &stored, None, replacement).expect("the write set"); + assert_eq!(diff.tenant_id, tenant(1), "the tenant comes from the row"); +} + +#[test] +fn the_upstream_reference_comes_from_the_stored_row() { + let (store, stored) = seeded(); + let mut replacement = stored.route.clone(); + replacement.upstream_id = Uuid::new_v4(); + + let error = route_diff(&store, &stored, None, replacement) + .expect_err("the body names another upstream"); + assert_eq!(error.kind, ErrorKind::ValidationError); + assert!(error.detail.contains("upstream_id"), "{error}"); + + let mut conformance = stored.route.clone(); + conformance.upstream_id = Uuid::nil(); + let diff = route_diff(&store, &stored, None, conformance).expect("the write set"); + assert_eq!(diff.value.upstream_id, stored.route.upstream_id); +} + +#[test] +fn the_optional_families_a_body_omits_are_cleared() { + let (store, stored) = seeded(); + let mut replacement = stored.route.clone(); + replacement.rate_limit = None; + replacement.plugins = None; + let before = replacement.clone(); + + let diff = route_diff(&store, &stored, None, replacement).expect("the write set"); + assert_eq!(diff.value.rate_limit, None); + assert_eq!(diff.value.plugins, None); + assert_eq!(diff.value.tags, before.tags); +} + +#[test] +fn tags_are_replaced_in_full() { + let store = OagwStore::new(); + let owner = tenant(1); + let stored_upstream = store + .insert_upstream(owner, &upstream(&[single("api.openai.com")], None, &["a", "b", "c"])) + .expect("the upstream"); + + let mut replacement = stored_upstream.upstream.clone(); + replacement.tags = vec![String::from("a")]; + let diff = upstream_diff(&stored_upstream, None, replacement).expect("the write set"); + assert_eq!(diff.value.tags, vec![String::from("a")], "the body's set in full"); + + let written = store + .replace_upstream(owner, stored_upstream.upstream.id, &diff.value) + .expect("the replacement"); + assert_eq!(written.tags, vec![String::from("a")], "the difference is removed"); +} + +#[test] +fn enabled_is_carried_forward_when_the_body_omits_it() { + let (store, stored) = seeded(); + let disabled = route(stored.route.upstream_id, "/v1/chat", 10, Some(false)); + let diff = route_diff(&store, &stored, None, disabled).expect("the write set"); + assert_eq!(diff.value.enabled, Some(false), "an explicit value wins"); + + let omitted = route(stored.route.upstream_id, "/v1/chat", 10, None); + let diff = route_diff(&store, &stored, None, omitted).expect("the write set"); + assert_eq!(diff.value.enabled, Some(true), "the stored value carries forward"); +} + +#[test] +fn a_write_set_that_changes_nothing_reports_itself_empty() { + let (store, stored) = seeded(); + let replacement = stored.route.clone(); + let diff = route_diff(&store, &stored, None, replacement).expect("the write set"); + assert!(!diff.changed, "nothing differs"); + + let mut different = stored.route.clone(); + different.priority = Some(11); + let diff = route_diff(&store, &stored, None, different).expect("the write set"); + assert!(diff.changed, "the priority differs"); +} + +#[test] +fn match_keys_expand_one_key_per_declared_method() { + let owner = tenant(1); + let mut http = route(owner, "/v1/chat", 10, Some(true)); + if let Some(matched) = &mut http.match_config.http { + matched.methods = vec![ + String::from("GET"), + String::from("POST"), + String::from("DELETE"), + ]; + } + assert_eq!(match_keys(&http).len(), 3, "three methods, three keys"); + for key in match_keys(&http) { + assert_eq!(key.upstream_id, owner); + assert_eq!(key.path, "/v1/chat"); + assert_eq!(key.priority, 10); + } + + let grpc = Route { + upstream_id: owner, + match_config: MatchConfig { + http: None, + grpc: Some(GrpcMatch { + service: String::from("foo.v1.UserService"), + method: String::from("GetUser"), + }), + }, + ..route(owner, "/v1", 1, Some(true)) + }; + let keys = match_keys(&grpc); + assert_eq!(keys.len(), 1, "one grpc method, one key"); + assert_eq!(keys[0].method, "GetUser"); +} + +#[test] +fn a_route_of_another_upstream_never_collides() { + let (store, stored) = seeded(); + let _ = &stored; + let other = store + .insert_upstream(tenant(1), &upstream(&[single("eu.openai.com")], None, &[])) + .expect("the second upstream"); + let foreign_upstream = route(other.upstream.id, "/v1/chat", 10, Some(true)); + confirm_route(&store, tenant(1), &foreign_upstream, None).expect("a different upstream"); +} + +#[test] +fn the_replaced_row_is_excluded_from_its_own_key() { + let (store, stored) = seeded(); + let mut replacement = stored.route.clone(); + replacement.match_config.http.as_mut().expect("http").path = String::from("/v1/chat"); + confirm_route(&store, tenant(1), &replacement, Some(stored.route.id)) + .expect("the replaced row is excluded"); +} + +#[test] +fn a_colliding_route_is_named() { + let (store, stored) = seeded(); + let colliding = route(stored.route.upstream_id, "/v1/chat", 10, Some(true)); + let error = confirm_route(&store, tenant(1), &colliding, None) + .expect_err("the key is already held"); + assert_eq!(error.kind, ErrorKind::MatchConflict); + assert_eq!(error.http_status(), 409); + assert!( + error.detail.contains(&stored.route.id.to_string()), + "the colliding route is named: {error}" + ); +} + +#[test] +fn a_disabled_route_never_collides() { + let (store, stored) = seeded(); + let disabled = route(stored.route.upstream_id, "/v1/chat", 10, Some(false)); + confirm_route(&store, tenant(1), &disabled, None).expect("a disabled route holds no key"); + + let store = OagwStore::new(); + let owner = tenant(1); + let upstream_row = store + .insert_upstream(owner, &upstream(&[single("api.openai.com")], None, &[])) + .expect("the upstream"); + store + .insert_route(owner, &route(upstream_row.upstream.id, "/v1/chat", 10, Some(false))) + .expect("the first disabled route"); + store + .insert_route(owner, &route(upstream_row.upstream.id, "/v1/chat", 10, Some(false))) + .expect("two disabled routes with identical keys are stored"); +} + +#[test] +fn a_differing_method_or_priority_does_not_collide() { + let (store, stored) = seeded(); + let _ = &stored; + let other_method = route(stored.route.upstream_id, "/v1/chat", 10, Some(true)); + let mut method = other_method; + method.match_config.http.as_mut().expect("http").methods = vec![String::from("POST")]; + confirm_route(&store, tenant(1), &method, None).expect("a different method"); + + let other_priority = route(stored.route.upstream_id, "/v1/chat", 11, Some(true)); + confirm_route(&store, tenant(1), &other_priority, None).expect("a different priority"); +} + +#[test] +fn a_route_replacement_reruns_uniqueness() { + let (store, stored) = seeded(); + let second = store + .insert_route( + tenant(1), + &route(stored.route.upstream_id, "/v1/embed", 20, Some(true)), + ) + .expect("the second route"); + + let mut replacement = stored.route.clone(); + replacement.match_config.http.as_mut().expect("http").path = String::from("/v1/embed"); + replacement.priority = Some(20); + let error = route_diff(&store, &stored, None, replacement) + .expect_err("the replacement collides with the second route"); + assert_eq!(error.kind, ErrorKind::MatchConflict); + assert!(error.detail.contains(&second.route.id.to_string()), "{error}"); +} + +#[test] +fn an_upstream_replacement_recomputes_the_derived_alias() { + let store = OagwStore::new(); + let owner = tenant(1); + let stored = store + .insert_upstream( + owner, + &upstream(&[single("api.openai.com")], Some("api.openai.com"), &[]), + ) + .expect("the upstream"); + + let mut replacement = stored.upstream.clone(); + replacement.alias = None; + replacement.server = ServerConfig { + endpoints: vec![endpoint("api.openai.com", 8443)], + }; + let error = upstream_diff(&stored, None, replacement) + .expect_err("the replacement endpoints derive another alias"); + assert_eq!(error.kind, ErrorKind::AliasConflict); + assert_eq!(error.http_status(), 409); + + let mut pooled = stored.upstream.clone(); + pooled.alias = None; + pooled.server = ServerConfig { + endpoints: vec![single("us.vendor.com"), single("eu.vendor.com")], + }; + let error = upstream_diff(&stored, None, pooled) + .expect_err("the pooled endpoints derive another alias"); + assert_eq!(error.kind, ErrorKind::AliasConflict); + assert_eq!(error.http_status(), 409); +} + +#[test] +fn a_replacement_whose_endpoints_derive_the_stored_alias_is_accepted() { + let store = OagwStore::new(); + let owner = tenant(1); + let stored = store + .insert_upstream( + owner, + &upstream(&[single("api.openai.com")], Some("api.openai.com"), &[]), + ) + .expect("the upstream"); + + let mut replacement = stored.upstream.clone(); + replacement.alias = None; + replacement.rate_limit = None; + let diff = upstream_diff(&stored, None, replacement).expect("the alias still derives"); + let alias = Alias::parse(diff.value.alias.as_deref().expect("an alias")) + .expect("the stored alias parses"); + assert_eq!(alias.to_string(), "api.openai.com"); +} + +#[test] +fn a_replacement_adding_a_pooled_endpoint_that_keeps_the_alias_derives_it() { + let store = OagwStore::new(); + let owner = tenant(1); + let stored = store + .insert_upstream( + owner, + &upstream( + &[single("us.vendor.com"), single("eu.vendor.com")], + Some("vendor.com"), + &[], + ), + ) + .expect("the upstream"); + assert_eq!(stored.upstream.alias.as_deref(), Some("vendor.com")); + + let mut replacement = stored.upstream.clone(); + replacement.alias = None; + replacement + .server + .endpoints + .push(single("ap.vendor.com")); + let diff = upstream_diff(&stored, None, replacement).expect("the alias is unchanged"); + assert_eq!(diff.value.alias.as_deref(), Some("vendor.com")); + assert!(diff.changed, "the endpoint pool differs"); + let written = store + .replace_upstream(owner, stored.upstream.id, &diff.value) + .expect("the replacement is accepted"); + assert_eq!(written.upstream.alias.as_deref(), Some("vendor.com")); +} diff --git a/gears/system/oagw/oagw/tests/service_tests.rs b/gears/system/oagw/oagw/tests/service_tests.rs new file mode 100644 index 0000000..bda1e4c --- /dev/null +++ b/gears/system/oagw/oagw/tests/service_tests.rs @@ -0,0 +1,556 @@ +//! Management service tests. +//! +//! Covers the six flows of the FEATURE §2 end to end against the store: the +//! 201 shape of a create, the derived and idempotent alias, the foreign +//! `upstream_id` and the `MatchConflict` refusal of a route create, the 404 +//! that never distinguishes a foreign identifier from a missing one, the +//! bounded list, the alias immutability of a replacement, the omitted +//! `upstream_id` that conforms, the cascading upstream deletion, the route +//! deletion that leaves its upstream untouched, the enable/disable carry +//! forward, and the cache and deletion-seam ordering. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::missing_panics_doc)] + +use std::sync::Arc; + +use parking_lot::Mutex; +use serde_json::{Value, json}; +use uuid::Uuid; + +use oagw::control_plane::cache::ControlPlaneCache; +use oagw::control_plane::cache::RateLimitCleanup; +use oagw::control_plane::service::ManagementService; +use oagw::control_plane::service::ServiceError; +use oagw::config::OagwConfig; +use oagw::domain::error::ErrorKind; +use oagw::store::{OagwStore, UpstreamRow}; + +const HTTP_PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; + +fn tenant(n: u128) -> Uuid { + Uuid::from_u128(n) +} + +/// A management service over its own empty store and cache. +fn service() -> (ManagementService, Arc) { + let cache = Arc::new(ControlPlaneCache::new()); + let service = ManagementService::new( + Arc::new(OagwStore::new()), + &OagwConfig::default(), + Arc::clone(&cache), + ) + .expect("the validators compile"); + (service, cache) +} + +/// A minimal valid upstream body. +fn upstream_body(host: &str) -> Value { + json!({ + "server": { + "endpoints": [{ "scheme": "https", "host": host, "port": 443 }] + }, + "protocol": HTTP_PROTOCOL, + "tags": ["llm"] + }) +} + +/// A route body addressing one upstream. +fn route_body(upstream_id: Uuid, path: &str, priority: i64) -> Value { + json!({ + "upstream_id": upstream_id, + "match": { "http": { "methods": ["GET"], "path": path } }, + "priority": priority, + "tags": ["edge"] + }) +} + +/// Removes one root property from a body. +fn without(body: &Value, key: &str) -> Value { + let mut body = body.clone(); + body.as_object_mut() + .expect("the body is an object") + .remove(key); + body +} + +/// The catalogue row of a refused operation, asserting it is a domain failure. +fn domain_of(error: &ServiceError) -> &oagw::DomainError { + assert!(!error.is_storage(), "the operation is a domain failure"); + error.domain() +} + +/// A deletion-seam observer recording the notifications it receives. +#[derive(Debug, Default)] +struct Recording { + upstreams: Mutex>, + routes: Mutex>, +} + +impl RateLimitCleanup for Recording { + fn upstream_deleted(&self, tenant_id: Uuid, upstream_id: Uuid) { + self.upstreams.lock().push((tenant_id, upstream_id)); + } + + fn route_deleted(&self, tenant_id: Uuid, route_id: Uuid) { + self.routes.lock().push((tenant_id, route_id)); + } +} + +/// An upstream of one tenant, returning the row the store wrote. +fn created_upstream(service: &ManagementService, owner: u128, host: &str) -> UpstreamRow { + service + .create_upstream(tenant(owner), &upstream_body(host)) + .expect("the upstream is created") +} + +/// The identifier of one created upstream. +fn id_of(row: &UpstreamRow) -> Uuid { + row.upstream.id +} + +#[test] +fn a_created_upstream_carries_the_identifier_and_the_derived_alias() { + let (service, _cache) = service(); + let row = service + .create_upstream(tenant(1), &upstream_body("api.openai.com")) + .expect("the upstream is created"); + + assert_ne!(row.upstream.id, Uuid::nil(), "the store assigned one"); + assert_eq!(row.tenant_id, tenant(1)); + assert_eq!(row.upstream.alias.as_deref(), Some("api.openai.com")); + assert_eq!(row.tags, vec![String::from("llm")], "the tags materialize"); + assert!(row.upstream.enabled, "a created row starts enabled"); +} + +#[test] +fn an_alias_matching_the_derivation_is_an_idempotent_no_op() { + let (service, _cache) = service(); + let mut body = upstream_body("api.openai.com"); + body["alias"] = json!("api.openai.com"); + let row = service + .create_upstream(tenant(1), &body) + .expect("the explicit alias matches the derived one"); + assert_eq!(row.upstream.alias.as_deref(), Some("api.openai.com")); +} + +#[test] +fn a_second_upstream_holding_the_derived_alias_is_refused() { + let (service, _cache) = service(); + service + .create_upstream(tenant(1), &upstream_body("api.openai.com")) + .expect("the first upstream"); + + let error = service + .create_upstream(tenant(1), &upstream_body("api.openai.com")) + .expect_err("the alias is already held"); + let refused = domain_of(&error); + assert_eq!(refused.kind, ErrorKind::AliasConflict); + assert_eq!(refused.http_status(), 409); + + let other = service + .create_upstream(tenant(1), &upstream_body("eu.openai.com")) + .expect("a different endpoint set derives a different alias"); + assert_ne!(other.upstream.alias, None); +} + +#[test] +fn a_created_route_resolves_its_upstream_and_its_match_key() { + let (service, _cache) = service(); + let upstream = created_upstream(&service, 1, "api.openai.com"); + let upstream_id = id_of(&upstream); + + let row = service + .create_route(tenant(1), &route_body(upstream_id, "/v1/chat", 10)) + .expect("the route is created"); + assert_eq!(row.route.upstream_id, upstream_id); + assert_eq!(row.tenant_id, tenant(1)); + assert_eq!(row.tags, vec![String::from("edge")]); +} + +#[test] +fn a_route_naming_a_foreign_upstream_is_refused() { + let (service, _cache) = service(); + let upstream = created_upstream(&service, 1, "api.openai.com"); + let upstream_id = id_of(&upstream); + + let error = service + .create_route(tenant(2), &route_body(upstream_id, "/v1/chat", 10)) + .expect_err("another tenant owns the upstream"); + let refused = domain_of(&error); + assert_eq!(refused.kind, ErrorKind::ValidationError); + assert_eq!(refused.http_status(), 400); + assert!(refused.detail.contains("upstream_id"), "{refused}"); + + let error = service + .create_route(tenant(1), &route_body(Uuid::new_v4(), "/v1/chat", 10)) + .expect_err("the upstream does not exist"); + assert_eq!(domain_of(&error).http_status(), 400); +} + +#[test] +fn a_second_route_holding_the_match_key_is_refused() { + let (service, _cache) = service(); + let upstream = created_upstream(&service, 1, "api.openai.com"); + let upstream_id = id_of(&upstream); + service + .create_route(tenant(1), &route_body(upstream_id, "/v1/chat", 10)) + .expect("the first route"); + + let error = service + .create_route(tenant(1), &route_body(upstream_id, "/v1/chat", 10)) + .expect_err("the key is already held"); + let refused = domain_of(&error); + assert_eq!(refused.kind, ErrorKind::MatchConflict); + assert_eq!(refused.http_status(), 409); + assert!(refused.detail.contains("route"), "{refused}"); + + // A different priority or method, or a disabled route, never collides. + service + .create_route(tenant(1), &route_body(upstream_id, "/v1/chat", 11)) + .expect("another priority"); + let mut other_method = route_body(upstream_id, "/v1/chat", 10); + other_method["match"]["http"]["methods"] = json!(["POST"]); + service + .create_route(tenant(1), &other_method) + .expect("another method"); +} + +#[test] +fn a_single_read_of_a_foreign_identifier_is_a_bare_404() { + let (service, _cache) = service(); + let upstream = created_upstream(&service, 1, "api.openai.com"); + let upstream_id = id_of(&upstream); + + let unknown = service + .read_upstream(tenant(1), Uuid::new_v4()) + .expect_err("the identifier is unknown"); + let missing = domain_of(&unknown); + let elsewhere = service + .read_upstream(tenant(2), upstream_id) + .expect_err("the row belongs to another tenant"); + let foreign = domain_of(&elsewhere); + assert_eq!(missing.http_status(), 404); + assert_eq!(foreign.http_status(), 404); + assert_eq!(missing.detail, foreign.detail, "the causes are indistinguishable"); + + let resolved = service + .read_upstream(tenant(1), upstream_id) + .expect("the calling tenant's row"); + assert_eq!(resolved.upstream.id, upstream_id); +} + +#[test] +fn a_list_answers_the_page_the_parameters_ask_for() { + let (service, _cache) = service(); + let first = created_upstream(&service, 1, "api.openai.com"); + created_upstream(&service, 1, "eu.openai.com"); + created_upstream(&service, 2, "foreign.openai.com"); + + let page = service + .list_upstreams(tenant(1), "") + .expect("the defaults are admitted"); + assert_eq!(page.items.len(), 2, "the foreign upstream stays out"); + assert_eq!(page.projection, Vec::::new()); + + let page = service + .list_upstreams(tenant(1), "$filter=alias%20eq%20'api.openai.com'") + .expect("the filter is admitted"); + assert_eq!(page.items.len(), 1); + assert_eq!(page.items[0].upstream.id, id_of(&first)); + + let error = service + .list_upstreams(tenant(1), "$count=true") + .expect_err("the parameter is not exposed"); + assert_eq!(domain_of(&error).http_status(), 400); + + let routes = service + .list_routes(tenant(1), "$top=101") + .expect("the ceiling is a bound, not a refusal"); + assert!(routes.items.is_empty()); +} + +#[test] +fn an_upstream_replacement_replaces_in_full_and_keeps_the_alias() { + let (service, _cache) = service(); + let upstream = created_upstream(&service, 1, "api.openai.com"); + let upstream_id = id_of(&upstream); + + // The tags are replaced in full and the alias is recomputed to the stored + // value. + let mut replacement = upstream_body("api.openai.com"); + replacement["tags"] = json!(["edge"]); + let written = service + .replace_upstream(tenant(1), upstream_id, &replacement) + .expect("the replacement applies"); + assert_eq!(written.tags, vec![String::from("edge")]); + assert_eq!(written.upstream.alias.as_deref(), Some("api.openai.com")); + assert_eq!(written.upstream.id, upstream_id, "the identifier is immutable"); + + // Clearing the optional families the body omits. + let mut cleared = without(&upstream_body("api.openai.com"), "tags"); + cleared["protocol"] = json!(HTTP_PROTOCOL); + let written = service + .replace_upstream(tenant(1), upstream_id, &cleared) + .expect("the replacement applies"); + assert!(written.tags.is_empty(), "the omitted family is cleared"); + + // A body whose endpoints derive another alias is refused, and the stored + // alias is left unchanged. + let error = service + .replace_upstream(tenant(1), upstream_id, &upstream_body("eu.openai.com")) + .expect_err("the alias is immutable"); + let refused = domain_of(&error); + assert_eq!(refused.kind, ErrorKind::AliasConflict); + assert_eq!(refused.http_status(), 409); + let stored = service + .read_upstream(tenant(1), upstream_id) + .expect("the row survives the refusal"); + assert_eq!(stored.upstream.alias.as_deref(), Some("api.openai.com")); + + // A body stating another identifier is refused. + let mut renamed = upstream_body("api.openai.com"); + renamed["id"] = json!(Uuid::new_v4().to_string()); + let error = service + .replace_upstream(tenant(1), upstream_id, &renamed) + .expect_err("the identifier is immutable"); + assert_eq!(domain_of(&error).http_status(), 400); + + // A foreign identifier is a bare 404. + let error = service + .replace_upstream(tenant(2), upstream_id, &upstream_body("api.openai.com")) + .expect_err("another tenant owns the row"); + assert_eq!(domain_of(&error).http_status(), 404); +} + +#[test] +fn a_route_replacement_takes_the_upstream_reference_from_the_stored_row() { + let (service, _cache) = service(); + let upstream = created_upstream(&service, 1, "api.openai.com"); + let upstream_id = id_of(&upstream); + let route = service + .create_route(tenant(1), &route_body(upstream_id, "/v1/chat", 10)) + .expect("the route"); + + let conformance = without(&route_body(Uuid::nil(), "/v1/chat", 10), "upstream_id"); + let written = service + .replace_route(tenant(1), route.route.id, &conformance) + .expect("the omitted reference conforms to the stored row"); + assert_eq!(written.route.upstream_id, upstream_id); + + // The replacement schema narrows the required set and takes the upstream + // reference from the stored row, so the body states neither. + let narrowed = without(&without(&route_body(upstream_id, "/v1/chat", 10), "tags"), "upstream_id"); + let written = service + .replace_route(tenant(1), route.route.id, &narrowed) + .expect("the required set is narrowed for a replacement"); + assert!(written.tags.is_empty(), "the omitted family is cleared"); + + let error = service + .replace_route(tenant(1), Uuid::new_v4(), &narrowed) + .expect_err("the identifier is unknown"); + assert_eq!(domain_of(&error).http_status(), 404); +} + +#[test] +fn an_enable_flag_travels_on_the_replacement_body() { + let (service, _cache) = service(); + let upstream = created_upstream(&service, 1, "api.openai.com"); + let upstream_id = id_of(&upstream); + + // An omitted `enabled` carries the stored value forward. + let replacement = upstream_body("api.openai.com"); + let written = service + .replace_upstream(tenant(1), upstream_id, &replacement) + .expect("the replacement applies"); + assert!(written.upstream.enabled, "the stored value carries forward"); + + // An explicit value controls. + let mut disabled = upstream_body("api.openai.com"); + disabled["enabled"] = json!(false); + let written = service + .replace_upstream(tenant(1), upstream_id, &disabled) + .expect("the replacement applies"); + assert!(!written.upstream.enabled, "an explicit value wins"); + + let stored = service + .read_upstream(tenant(1), upstream_id) + .expect("the row"); + assert!(!stored.upstream.enabled, "the flag persisted"); + + let mut reenabled = upstream_body("api.openai.com"); + reenabled["enabled"] = json!(true); + let written = service + .replace_upstream(tenant(1), upstream_id, &reenabled) + .expect("the replacement applies"); + assert!(written.upstream.enabled, "the row returns to enabled"); +} + +#[test] +fn an_upstream_deletion_cascades_into_its_routes() { + let (service, cache) = service(); + let observer = Arc::new(Recording::default()); + service.register_deletion_observer(Arc::clone(&observer) as Arc<_>); + + let upstream = created_upstream(&service, 1, "api.openai.com"); + let upstream_id = id_of(&upstream); + let route = service + .create_route(tenant(1), &route_body(upstream_id, "/v1/chat", 10)) + .expect("the route"); + + let before = cache.generation(); + let deleted = service + .delete_upstream(tenant(1), upstream_id) + .expect("the deletion applies"); + assert!(deleted, "the row was there"); + assert!(cache.generation() > before, "the cache advanced"); + + assert!( + service.read_upstream(tenant(1), upstream_id).is_err(), + "the upstream row is gone" + ); + assert!( + service.read_route(tenant(1), route.route.id).is_err(), + "the route row cascaded away" + ); + assert_eq!( + observer.upstreams.lock().len(), + 1, + "the deletion notified the cleanup" + ); + assert_eq!(observer.upstreams.lock()[0], (tenant(1), upstream_id)); + assert_eq!(observer.routes.lock().len(), 0, "no route deletion was issued"); + + let error = service + .delete_upstream(tenant(1), upstream_id) + .expect_err("the row is already gone"); + assert_eq!(domain_of(&error).http_status(), 404); +} + +#[test] +fn a_route_deletion_leaves_its_upstream_untouched() { + let (service, _cache) = service(); + let observer = Arc::new(Recording::default()); + service.register_deletion_observer(Arc::clone(&observer) as Arc<_>); + + let upstream = created_upstream(&service, 1, "api.openai.com"); + let upstream_id = id_of(&upstream); + let first = service + .create_route(tenant(1), &route_body(upstream_id, "/v1/chat", 10)) + .expect("the first route"); + let second = service + .create_route(tenant(1), &route_body(upstream_id, "/v1/embed", 20)) + .expect("the second route"); + + let deleted = service + .delete_route(tenant(1), first.route.id) + .expect("the deletion applies"); + assert!(deleted); + + assert!( + service.read_upstream(tenant(1), upstream_id).is_ok(), + "the upstream row survives the route deletion" + ); + assert!( + service.read_route(tenant(1), second.route.id).is_ok(), + "no other route is disturbed" + ); + assert_eq!(observer.routes.lock().len(), 1); + assert_eq!(observer.routes.lock()[0], (tenant(1), first.route.id)); + assert_eq!(observer.upstreams.lock().len(), 0); +} + +#[test] +fn the_cache_generation_advances_only_on_a_successful_write() { + let (service, cache) = service(); + assert_eq!(cache.generation(), 0, "the cache starts at zero"); + + let upstream = service + .create_upstream(tenant(1), &upstream_body("api.openai.com")) + .expect("the upstream is created"); + let after_create = cache.generation(); + assert_eq!(after_create, 1, "one successful write, one generation"); + + service + .read_upstream(tenant(1), upstream.upstream.id) + .expect("the read resolves"); + service.list_upstreams(tenant(1), "").expect("the list resolves"); + assert_eq!(cache.generation(), after_create, "a read never flushes"); + + service + .create_upstream(tenant(1), &upstream_body("api.openai.com")) + .expect_err("the alias conflicts"); + service + .create_route(tenant(1), &route_body(Uuid::new_v4(), "/v1", 1)) + .expect_err("the referenced upstream is missing"); + assert_eq!( + cache.generation(), + after_create, + "a failed write never flushes" + ); + + service + .replace_upstream(tenant(1), upstream.upstream.id, &upstream_body("api.openai.com")) + .expect("the replacement applies"); + assert_eq!(cache.generation(), after_create + 1); + + service + .delete_upstream(tenant(1), upstream.upstream.id) + .expect("the deletion applies"); + assert_eq!(cache.generation(), after_create + 2); +} + +#[test] +fn a_failed_deletion_notifies_nothing() { + let (service, _cache) = service(); + let observer = Arc::new(Recording::default()); + service.register_deletion_observer(Arc::clone(&observer) as Arc<_>); + + let upstream = created_upstream(&service, 1, "api.openai.com"); + let upstream_id = id_of(&upstream); + + // A 404 deletion reaches no observer. + let error = service + .delete_upstream(tenant(1), Uuid::new_v4()) + .expect_err("the identifier is unknown"); + assert_eq!(domain_of(&error).http_status(), 404); + // A foreign deletion reaches no observer either. + let error = service + .delete_route(tenant(2), upstream_id) + .expect_err("the row belongs to another tenant"); + assert_eq!(domain_of(&error).http_status(), 404); + + let route = service + .create_route(tenant(1), &route_body(upstream_id, "/v1/chat", 10)) + .expect("the route"); + let error = service + .delete_route(tenant(2), route.route.id) + .expect_err("another tenant owns the route"); + assert_eq!(domain_of(&error).http_status(), 404); + + assert!(observer.upstreams.lock().is_empty(), "no notification"); + assert!(observer.routes.lock().is_empty(), "no notification"); +} + +#[test] +fn a_persistence_failure_is_never_a_catalogue_row() { + let cache = Arc::new(ControlPlaneCache::new()); + let service = ManagementService::new( + Arc::new(OagwStore::with_orphaned_match_index()), + &OagwConfig::default(), + Arc::clone(&cache), + ) + .expect("the validators compile"); + + let error = service + .create_upstream(tenant(1), &upstream_body("api.openai.com")) + .expect_err("the store cannot commit the batch"); + assert!(error.is_storage(), "the failure is a persistence failure"); + + assert_eq!(cache.generation(), 0, "a failed write never flushes"); + + // The 404 of a deletion still precedes any storage concern. + let error = service + .delete_upstream(tenant(1), Uuid::new_v4()) + .expect_err("the identifier is unknown"); + assert_eq!(domain_of(&error).http_status(), 404); +} diff --git a/gears/system/oagw/oagw/tests/sharing_decision_tests.rs b/gears/system/oagw/oagw/tests/sharing_decision_tests.rs new file mode 100644 index 0000000..0b13618 --- /dev/null +++ b/gears/system/oagw/oagw/tests/sharing_decision_tests.rs @@ -0,0 +1,404 @@ +//! The sharing-mode and permission decision. +//! +//! Covers `cpt-cf-oagw-dod-sharing-mode-decision` and +//! `cpt-cf-oagw-dod-descendant-override-permissions`: every row of the +//! decision table for every family, the priority the refusal order fixes, the +//! deny-by-default posture of the four descendant override permissions, and +//! the absence of any fifth permission for the CORS family. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use oagw::control_plane::sharing::{DecisionKind, OverridePermissions}; +use oagw::control_plane::shadow::contributed; +use oagw::domain::effective::{AncestorBinding, ContributedFamilies, Family}; +use oagw::domain::upstream::{ + AuthConfig, CorsConfig, PluginsConfig, RateLimitConfig, SharingMode, Upstream, +}; +use toolkit_security::SecurityContext; + +/// Builds the binding one ancestor row produces, from a row that carries the +/// four families in the one mode the test names. +fn binding(sharing: SharingMode) -> AncestorBinding { + let mut upstream = row(); + match sharing { + SharingMode::Enforce | SharingMode::Inherit => { + upstream.auth = Some(AuthConfig { + r#type: Some(String::from("gts.cf.core.oagw.auth_plugin.v1~x.v1")), + sharing: Some(sharing), + config: None, + }); + upstream.rate_limit = Some(RateLimitConfig { + sharing: Some(sharing), + algorithm: None, + sustained: None, + burst: None, + scope: None, + strategy: None, + cost: None, + }); + upstream.plugins = Some(PluginsConfig { + sharing: Some(sharing), + items: Vec::new(), + }); + upstream.cors = Some(CorsConfig { + sharing: Some(sharing), + enabled: true, + allowed_origins: Vec::new(), + allowed_methods: Vec::new(), + expose_headers: Vec::new(), + allow_credentials: false, + }); + } + SharingMode::Private => {} + } + + binding_of(upstream, 1) +} + +/// Builds the binding one ancestor row produces, from a row that carries the +/// one family the test names in the one mode the test names. +fn binding_with(family: Family, sharing: SharingMode) -> AncestorBinding { + let mut upstream = row(); + match (family, sharing) { + (Family::Auth, SharingMode::Enforce | SharingMode::Inherit) => { + upstream.auth = Some(AuthConfig { + r#type: Some(String::from("gts.cf.core.oagw.auth_plugin.v1~x.v1")), + sharing: Some(sharing), + config: None, + }); + } + (Family::RateLimit, SharingMode::Enforce | SharingMode::Inherit) => { + upstream.rate_limit = Some(RateLimitConfig { + sharing: Some(sharing), + algorithm: None, + sustained: None, + burst: None, + scope: None, + strategy: None, + cost: None, + }); + } + (Family::Plugins, SharingMode::Enforce | SharingMode::Inherit) => { + upstream.plugins = Some(PluginsConfig { + sharing: Some(sharing), + items: Vec::new(), + }); + } + (Family::Cors, SharingMode::Enforce | SharingMode::Inherit) => { + upstream.cors = Some(CorsConfig { + sharing: Some(sharing), + enabled: true, + allowed_origins: Vec::new(), + allowed_methods: Vec::new(), + expose_headers: Vec::new(), + allow_credentials: false, + }); + } + _ => {} + } + binding_of(upstream, 1) +} + +/// The upstream row a test binding carries, before any family is set. +fn row() -> Upstream { + Upstream::new( + uuid::Uuid::new_v4(), + oagw::ServerConfig { + endpoints: Vec::new(), + }, + String::from("gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"), + ) +} + +/// The binding one stored ancestor row produces. +fn binding_of(upstream: Upstream, depth: usize) -> AncestorBinding { + AncestorBinding { + tenant_id: uuid::Uuid::from_u128(0xa001), + depth, + upstream_id: upstream.id, + enabled: true, + contributed: contributed(&upstream), + } +} + +/// A binding that carries no family at all. +fn bare() -> AncestorBinding { + AncestorBinding { + tenant_id: uuid::Uuid::from_u128(0xa002), + depth: 2, + upstream_id: uuid::Uuid::new_v4(), + enabled: true, + contributed: ContributedFamilies { + auth: None, + rate_limit: None, + plugins: None, + cors: None, + tags: None, + }, + } +} + +/// The four families the body carries, in the order the merge reads them. +const ALL: [Family; 4] = [ + Family::Auth, + Family::RateLimit, + Family::Plugins, + Family::Cors, +]; + +/// The token that holds every one of the four permissions. +fn all_held() -> OverridePermissions { + OverridePermissions::of( + &SecurityContext::builder() + .subject_id(uuid::Uuid::new_v4()) + .subject_tenant_id(uuid::Uuid::from_u128(0xb001)) + .token_scopes(vec![ + String::from("oagw:upstream:bind"), + String::from("oagw:upstream:override_auth"), + String::from("oagw:upstream:override_rate"), + String::from("oagw:upstream:add_plugins"), + ]) + .build() + .expect("the context builds"), + ) +} + +#[test] +fn a_private_ancestor_makes_the_carried_value_the_descendants_own() { + // A `private` family is never carried into a binding, so the row is the + // same as a bare ancestor's: the decision is `own` and no permission is + // consulted. + let decisions = oagw::control_plane::sharing::decide(&[bare()], &ALL, &all_held()) + .expect("nothing is refused"); + for family in ALL { + assert_eq!( + decisions.kind_of(family), + Some(DecisionKind::Own), + "{family:?} is the descendant's own configuration" + ); + } +} + +#[test] +fn an_inherit_ancestor_and_a_held_permission_give_the_body_the_override() { + let ancestor = binding(SharingMode::Inherit); + let decisions = oagw::control_plane::sharing::decide(&[ancestor], &ALL, &all_held()) + .expect("nothing is refused"); + for family in ALL { + assert_eq!( + decisions.kind_of(family), + Some(DecisionKind::InheritBase), + "{family:?} overrides the ancestor's base" + ); + } +} + +#[test] +fn an_inherit_ancestor_and_a_missing_permission_refuse_with_403() { + let ancestor = binding(SharingMode::Inherit); + let refusal = oagw::control_plane::sharing::decide(&[ancestor], &ALL, &OverridePermissions::none()) + .expect_err("the auth override is refused"); + assert_eq!(refusal.family(), Family::Auth); + assert_eq!( + refusal.permission(), + Some("oagw:upstream:override_auth"), + "the refusal names the permission the token lacks" + ); +} + +#[test] +fn an_enforce_ancestor_and_a_carried_value_refuse_with_400() { + let ancestor = binding(SharingMode::Enforce); + let refusal = oagw::control_plane::sharing::decide(&[ancestor], &ALL, &all_held()) + .expect_err("the enforced family is refused"); + assert_eq!(refusal.family(), Family::Auth); + assert_eq!(refusal.permission(), None, "a 400 is not a permission answer"); +} + +#[test] +fn an_enforce_ancestor_and_an_omitted_value_force_the_family() { + let ancestor = binding(SharingMode::Enforce); + // The body carries nothing, so no family is refused and every family the + // ancestor contributes is forced. + let decisions = oagw::control_plane::sharing::decide(&[ancestor], &[], &all_held()) + .expect("nothing is refused"); + for family in ALL { + assert!( + decisions.kind_of(family).is_none(), + "{family:?} is not decided because the body does not carry it" + ); + assert!(!decisions.writes(family)); + } +} + +#[test] +fn the_permission_403_precedes_any_enforce_400() { + // The nearest ancestor enforces auth; the more distant one inherits the + // rate limit. The token holds no permission at all, so both families are + // blocked and the permission refusal must be the one returned. + let enforced = binding_with(Family::Auth, SharingMode::Enforce); + let inherited = binding_with(Family::RateLimit, SharingMode::Inherit); + let refusal = oagw::control_plane::sharing::decide( + &[enforced, inherited], + &[Family::Auth, Family::RateLimit], + &OverridePermissions::none(), + ) + .expect_err("both families are blocked"); + assert_eq!( + refusal, + oagw::control_plane::sharing::Refusal::Permission { + family: Family::RateLimit + }, + "the 403 is answered before the 400" + ); +} + +#[test] +fn the_enforce_400_is_answered_only_once_the_permission_holds() { + let inherited = binding(SharingMode::Inherit); + // The same chain with every permission held: the permission refusal is + // gone and the enforce refusal is the one that surfaces. + let refusal = oagw::control_plane::sharing::decide( + &[binding_with(Family::Auth, SharingMode::Enforce), inherited], + &[Family::Auth, Family::RateLimit], + &all_held(), + ) + .expect_err("the enforced family is still refused"); + assert_eq!(refusal.family(), Family::Auth); +} + +#[test] +fn the_cors_family_takes_no_permission_and_the_mode_alone_decides() { + let ancestor = binding(SharingMode::Inherit); + let decisions = oagw::control_plane::sharing::decide( + &[ancestor], + &[Family::Cors], + &OverridePermissions::none(), + ) + .expect("CORS is never gated by a permission"); + assert_eq!(decisions.kind_of(Family::Cors), Some(DecisionKind::InheritBase)); + assert!(decisions.writes(Family::Cors)); +} + +#[test] +fn an_ancestor_that_carries_no_family_is_never_a_decision_input() { + // A bare ancestor contributes nothing, so a body that carries every family + // is decided `own` even with no permission held. + let decisions = oagw::control_plane::sharing::decide( + &[bare()], + &ALL, + &OverridePermissions::none(), + ) + .expect("nothing is refused"); + for family in ALL { + assert_eq!(decisions.kind_of(family), Some(DecisionKind::Own)); + } +} + +#[test] +fn the_strictest_mode_among_the_ancestors_decides() { + // One ancestor enforces the rate limit and a closer one inherits it: the + // enforce still refuses the write. + let closer = binding_with(Family::RateLimit, SharingMode::Inherit); + let distant = binding_with(Family::RateLimit, SharingMode::Enforce); + let refusal = oagw::control_plane::sharing::decide( + &[closer, distant], + &[Family::RateLimit], + &all_held(), + ) + .expect_err("the enforce is strictest"); + assert_eq!(refusal.family(), Family::RateLimit); +} + +#[test] +fn the_permissions_deny_by_default() { + let none = OverridePermissions::none(); + for permission in [ + "oagw:upstream:bind", + "oagw:upstream:override_auth", + "oagw:upstream:override_rate", + "oagw:upstream:add_plugins", + ] { + assert!(!none.holds(permission), "{permission} is denied by default"); + } + assert!(!none.holds("oagw:upstream:delete"), "an unknown literal is never held"); + assert!(!none.holds("*"), "the sentinel is not itself a permission"); +} + +#[test] +fn the_token_scopes_are_the_only_grant_source() { + let held = all_held(); + for permission in [ + "oagw:upstream:bind", + "oagw:upstream:override_auth", + "oagw:upstream:override_rate", + "oagw:upstream:add_plugins", + ] { + assert!(held.holds(permission), "the token carries {permission}"); + } + + // The platform's unrestricted sentinel names every one of the four. + let unrestricted = OverridePermissions::of( + &SecurityContext::builder() + .subject_id(uuid::Uuid::new_v4()) + .subject_tenant_id(uuid::Uuid::from_u128(0xb001)) + .token_scopes(vec![String::from("*")]) + .build() + .expect("the context builds"), + ); + for permission in [ + "oagw:upstream:bind", + "oagw:upstream:override_auth", + "oagw:upstream:override_rate", + "oagw:upstream:add_plugins", + ] { + assert!(unrestricted.holds(permission), "the sentinel grants {permission}"); + } + + // One scope grants exactly one permission. + let single = OverridePermissions::of( + &SecurityContext::builder() + .subject_id(uuid::Uuid::new_v4()) + .subject_tenant_id(uuid::Uuid::from_u128(0xb001)) + .token_scopes(vec![String::from("oagw:upstream:override_rate")]) + .build() + .expect("the context builds"), + ); + assert!(single.holds("oagw:upstream:override_rate")); + assert!(!single.holds("oagw:upstream:bind")); + assert!(!single.holds("oagw:upstream:override_auth")); + assert!(!single.holds("oagw:upstream:add_plugins")); +} + +#[test] +fn the_same_four_permissions_gate_the_families_of_a_route_row() { + // A route carries three of the four sharing-bearing families and no auth + // family at all, so the decision over a route's carried set is the same + // family-driven decision: the permission names the override ability, not a + // table. + let ancestor = binding(SharingMode::Inherit); + let carried = [Family::RateLimit, Family::Plugins, Family::Cors]; + + let permitted = + oagw::control_plane::sharing::decide(std::slice::from_ref(&ancestor), &carried, &all_held()) + .expect("every family is overridable"); + for family in carried { + assert_eq!(permitted.kind_of(family), Some(DecisionKind::InheritBase)); + } + + let refusal = + oagw::control_plane::sharing::decide(&[ancestor], &carried, &OverridePermissions::none()) + .expect_err("the rate limit override is refused"); + assert_eq!(refusal.family(), Family::RateLimit); +} + +#[test] +fn the_decision_reports_which_families_the_body_may_write() { + let ancestor = binding(SharingMode::Inherit); + let decisions = oagw::control_plane::sharing::decide(&[ancestor], &ALL, &all_held()) + .expect("nothing is refused"); + for family in ALL { + assert!(decisions.writes(family), "{family:?} reaches the row"); + assert!(!decisions.forced(family), "{family:?} is not forced"); + } +} diff --git a/gears/system/oagw/oagw/tests/store_tests.rs b/gears/system/oagw/oagw/tests/store_tests.rs new file mode 100644 index 0000000..f2f1cfb --- /dev/null +++ b/gears/system/oagw/oagw/tests/store_tests.rs @@ -0,0 +1,594 @@ +//! Store tests. +//! +//! Covers `cpt-cf-oagw-dod-persisted-model`, `cpt-cf-oagw-algo-tenant-scope` +//! and the write steps of `cpt-cf-oagw-flow-upstream-create`, +//! `cpt-cf-oagw-flow-route-create`, `cpt-cf-oagw-flow-upstream-replace-delete` +//! and `cpt-cf-oagw-flow-route-delete`: the seven tables round-trip, the +//! tenant predicate guards every read and every `{id}`-addressed write, the +//! `(tenant_id, alias)` and enabled-match-key uniqueness checks run inside the +//! same batch as the write they guard, cascade works in both directions, and a +//! failing batch leaves no partial row. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::missing_panics_doc)] + +use std::sync::Arc; + +use oagw::store::{MatchKey, OagwStore, StoreError}; +use oagw::{Endpoint, EndpointHost, HttpMatch, MatchConfig, Route, Scheme, ServerConfig, Upstream}; +use uuid::Uuid; + +const HTTP_PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; + +fn tenant(n: u128) -> Uuid { + Uuid::from_u128(n) +} + +fn endpoint(host: &str, port: u16) -> Endpoint { + Endpoint { + scheme: Scheme::Https, + host: EndpointHost::parse(host).expect("valid endpoint host"), + port: Some(port), + } +} + +fn upstream(alias: Option<&str>) -> Upstream { + Upstream { + id: Uuid::new_v4(), + enabled: true, + alias: alias.map(str::to_owned), + tags: vec![String::from("llm")], + server: ServerConfig { + endpoints: vec![endpoint("api.openai.com", 443)], + }, + protocol: String::from(HTTP_PROTOCOL), + auth: None, + headers: None, + plugins: None, + rate_limit: None, + cors: None, + } +} + +fn route(upstream_id: Uuid, path: &str, priority: i64, enabled: Option) -> Route { + Route { + id: Uuid::new_v4(), + upstream_id, + match_config: MatchConfig { + http: Some(HttpMatch { + methods: vec![String::from("GET")], + path: String::from(path), + query_allowlist: vec![], + path_suffix_mode: None, + }), + grpc: None, + }, + plugins: None, + rate_limit: None, + tags: vec![String::from("edge")], + cors: None, + priority: Some(priority), + enabled, + } +} + +fn http_of(route: &Route) -> HttpMatch { + route.match_config.http.clone().expect("http match") +} + +fn key_of(store: &OagwStore, tenant_id: Uuid, route: &Route) -> Option { + let http = http_of(route); + store + .enabled_match_index(tenant_id) + .get(&MatchKey { + upstream_id: route.upstream_id, + path: http.path, + priority: route.priority.unwrap_or_default(), + method: "GET".to_owned(), + }) + .copied() +} + +#[test] +fn an_inserted_upstream_round_trips_every_table() { + let store = OagwStore::new(); + let value = upstream(Some("api.openai.com")); + let id = value.id; + + let row = store.insert_upstream(tenant(1), &value).expect("inserted"); + assert_eq!(row.upstream.id, id); + assert_eq!(row.upstream.alias.as_deref(), Some("api.openai.com")); + assert_eq!(row.tags, vec![String::from("llm")]); + assert_eq!(row.upstream.tags, vec![String::from("llm")]); + + let read = store.get_upstream(tenant(1), id).expect("read back"); + assert_eq!(read, row); + assert_eq!( + store.upstream_tag_rows(tenant(1), id), + vec![String::from("llm")] + ); + assert_eq!(store.list_upstreams(tenant(1)), vec![row]); +} + +#[test] +fn an_inserted_route_round_trips_every_table() { + let store = OagwStore::new(); + let upstream_id = store + .insert_upstream(tenant(1), &upstream(None)) + .expect("upstream inserted") + .upstream + .id; + let value = route(upstream_id, "/v1/chat", 1, Some(true)); + + let row = store.insert_route(tenant(1), &value).expect("inserted"); + assert_eq!(row.route.upstream_id, upstream_id); + assert_eq!(row.tags, vec![String::from("edge")]); + + let read = store.get_route(tenant(1), row.route.id).expect("read back"); + assert_eq!(read, row); + assert_eq!( + store.route_http_match(tenant(1), row.route.id), + value.match_config.http + ); + assert_eq!(store.route_grpc_match(tenant(1), row.route.id), None); + assert_eq!(store.route_methods(tenant(1), row.route.id), vec!["GET"]); + assert_eq!( + store.route_tag_rows(tenant(1), row.route.id), + vec![String::from("edge")] + ); + assert_eq!(store.list_routes(tenant(1)), vec![row]); +} + +#[test] +fn a_second_tenants_rows_are_invisible_to_every_read() { + let store = OagwStore::new(); + let upstream_id = store + .insert_upstream(tenant(1), &upstream(Some("api.openai.com"))) + .expect("upstream inserted") + .upstream + .id; + let route_id = store + .insert_route(tenant(1), &route(upstream_id, "/v1/chat", 1, Some(true))) + .expect("route inserted") + .route + .id; + + assert!(store.get_upstream(tenant(2), upstream_id).is_none()); + assert!(store.get_route(tenant(2), route_id).is_none()); + assert!(store.list_upstreams(tenant(2)).is_empty()); + assert!(store.list_routes(tenant(2)).is_empty()); + assert!(store.route_http_match(tenant(2), route_id).is_none()); + assert!(store.route_grpc_match(tenant(2), route_id).is_none()); + assert!(store.route_methods(tenant(2), route_id).is_empty()); + assert!(store.upstream_tag_rows(tenant(2), upstream_id).is_empty()); + assert!(store.route_tag_rows(tenant(2), route_id).is_empty()); + assert!(store.enabled_match_index(tenant(2)).is_empty()); +} + +#[test] +fn a_foreign_tenant_cannot_address_a_row_by_its_id() { + let store = OagwStore::new(); + let upstream_id = store + .insert_upstream(tenant(1), &upstream(Some("api.openai.com"))) + .expect("upstream inserted") + .upstream + .id; + let route_id = store + .insert_route(tenant(1), &route(upstream_id, "/v1/chat", 1, Some(true))) + .expect("route inserted") + .route + .id; + let foreign = route(upstream_id, "/v2", 2, Some(true)); + + assert!(matches!( + store.replace_upstream( + tenant(2), + upstream_id, + &Upstream { + id: upstream_id, + ..upstream(None) + } + ), + Err(StoreError::Invariant { .. }) + )); + assert!(matches!( + store.replace_route(tenant(2), route_id, &foreign), + Err(StoreError::Invariant { .. }) + )); + assert_eq!(store.delete_upstream(tenant(2), upstream_id), Ok(false)); + assert_eq!(store.delete_route(tenant(2), route_id), Ok(false)); + assert_eq!(store.list_upstreams(tenant(1)).len(), 1); + assert_eq!(store.list_routes(tenant(1)).len(), 1); +} + +#[test] +fn an_alias_conflict_inside_one_batch_leaves_no_row() { + let store = OagwStore::new(); + store + .insert_upstream(tenant(1), &upstream(Some("api.openai.com"))) + .expect("first insert"); + + let second = upstream(Some("API.OpenAI.com")); + assert_eq!( + store.insert_upstream(tenant(1), &second), + Err(StoreError::AliasConflict) + ); + assert_eq!(store.list_upstreams(tenant(1)).len(), 1); + assert!( + store.get_upstream(tenant(1), second.id).is_none(), + "the failed batch left no row behind" + ); + assert!( + store.upstream_tag_rows(tenant(1), second.id).is_empty(), + "the failed batch left no tag row behind" + ); +} + +#[test] +fn the_same_alias_in_a_different_tenant_succeeds() { + let store = OagwStore::new(); + store + .insert_upstream(tenant(1), &upstream(Some("api.openai.com"))) + .expect("first insert"); + + let second = store + .insert_upstream(tenant(2), &upstream(Some("api.openai.com"))) + .expect("the alias is unique per tenant"); + assert_eq!(store.list_upstreams(tenant(2)).len(), 1); + assert_eq!(second.upstream.alias.as_deref(), Some("api.openai.com")); +} + +#[test] +fn a_replacement_holds_its_own_alias() { + let store = OagwStore::new(); + let first = store + .insert_upstream(tenant(1), &upstream(Some("api.openai.com"))) + .expect("first insert") + .upstream; + let second = store + .insert_upstream(tenant(1), &upstream(Some("eu.vendor.com"))) + .expect("second insert") + .upstream; + + let renamed = store + .replace_upstream( + tenant(1), + second.id, + &Upstream { + alias: Some(String::from("api.openai.com")), + ..second.clone() + }, + ) + .expect_err("the alias is held by the other upstream"); + assert_eq!(renamed, StoreError::AliasConflict); + + let kept = store + .replace_upstream(tenant(1), first.id, &first) + .expect("a replacement keeps its own alias"); + assert_eq!(kept.upstream.alias.as_deref(), Some("api.openai.com")); +} + +#[test] +fn a_replacement_rewrites_its_tag_rows() { + let store = OagwStore::new(); + let mut value = upstream(None); + let id = value.id; + store.insert_upstream(tenant(1), &value).expect("inserted"); + + value.tags = vec![String::from("edge"), String::from("llm")]; + let replaced = store + .replace_upstream(tenant(1), id, &value) + .expect("replaced"); + assert_eq!( + replaced.tags, + vec![String::from("edge"), String::from("llm")] + ); + assert_eq!(store.upstream_tag_rows(tenant(1), id), replaced.tags); +} + +#[test] +fn a_replacement_cannot_move_the_addressed_identifier() { + let store = OagwStore::new(); + let value = upstream(None); + let id = store + .insert_upstream(tenant(1), &value) + .expect("inserted") + .upstream + .id; + + assert!(matches!( + store.replace_upstream(tenant(1), id, &upstream(None)), + Err(StoreError::Invariant { .. }) + )); + assert_eq!(store.get_upstream(tenant(1), id), Some(store.get_upstream(tenant(1), id).expect("kept"))); +} + +#[test] +fn an_enabled_route_match_key_is_unique_per_upstream() { + let store = OagwStore::new(); + let upstream_id = store + .insert_upstream(tenant(1), &upstream(None)) + .expect("upstream inserted") + .upstream + .id; + let first = route(upstream_id, "/v1/chat", 1, Some(true)); + store.insert_route(tenant(1), &first).expect("first route"); + + let second = route(upstream_id, "/v1/chat", 1, Some(true)); + let refused = store + .insert_route(tenant(1), &second) + .expect_err("the match key is taken"); + let StoreError::MatchConflict { + colliding_route_id, + } = refused + else { + panic!("expected a match conflict, got {refused:?}"); + }; + assert_eq!(colliding_route_id, first.id); + assert_eq!(store.list_routes(tenant(1)).len(), 1); + assert!( + store.get_route(tenant(1), second.id).is_none(), + "the failed batch left no route row behind" + ); + assert!( + store.route_tag_rows(tenant(1), second.id).is_empty(), + "the failed batch left no tag row behind" + ); + assert!( + store.route_methods(tenant(1), second.id).is_empty(), + "the failed batch left no method row behind" + ); +} + +#[test] +fn a_disabled_route_is_exempt_from_match_uniqueness() { + let store = OagwStore::new(); + let upstream_id = store + .insert_upstream(tenant(1), &upstream(None)) + .expect("upstream inserted") + .upstream + .id; + store + .insert_route(tenant(1), &route(upstream_id, "/v1/chat", 1, Some(true))) + .expect("enabled route"); + + let disabled = store + .insert_route(tenant(1), &route(upstream_id, "/v1/chat", 1, Some(false))) + .expect("a disabled route may share the key"); + assert_eq!(store.list_routes(tenant(1)).len(), 2); + assert_eq!(store.enabled_match_index(tenant(1)).len(), 1); + assert!( + !store + .enabled_match_index(tenant(1)) + .values() + .any(|id| *id == disabled.route.id), + "a disabled route owns no index entry" + ); +} + +#[test] +fn a_route_of_another_upstream_may_share_the_key() { + let store = OagwStore::new(); + let first = store + .insert_upstream(tenant(1), &upstream(Some("a.vendor.com"))) + .expect("first upstream") + .upstream + .id; + let second = store + .insert_upstream(tenant(1), &upstream(Some("b.vendor.com"))) + .expect("second upstream") + .upstream + .id; + store + .insert_route(tenant(1), &route(first, "/v1/chat", 1, Some(true))) + .expect("first route"); + + store + .insert_route(tenant(1), &route(second, "/v1/chat", 1, Some(true))) + .expect("the key is scoped to one upstream"); + assert_eq!(store.enabled_match_index(tenant(1)).len(), 2); +} + +#[test] +fn a_different_method_or_priority_does_not_collide() { + let store = OagwStore::new(); + let upstream_id = store + .insert_upstream(tenant(1), &upstream(None)) + .expect("upstream inserted") + .upstream + .id; + let first = route(upstream_id, "/v1/chat", 1, Some(true)); + store.insert_route(tenant(1), &first).expect("first route"); + + let other_method = route(upstream_id, "/v1/chat", 1, Some(true)); + store + .insert_route( + tenant(1), + &Route { + match_config: MatchConfig { + http: Some(HttpMatch { + methods: vec![String::from("POST")], + ..http_of(&first) + }), + ..first.match_config.clone() + }, + ..other_method.clone() + }, + ) + .expect("another method is another key"); + + store + .insert_route(tenant(1), &route(upstream_id, "/v1/chat", 2, Some(true))) + .expect("another priority is another key"); + assert_eq!(store.enabled_match_index(tenant(1)).len(), 3); +} + +#[test] +fn the_replaced_row_is_excluded_from_its_own_match_key() { + let store = OagwStore::new(); + let upstream_id = store + .insert_upstream(tenant(1), &upstream(None)) + .expect("upstream inserted") + .upstream + .id; + let value = route(upstream_id, "/v1/chat", 1, Some(true)); + let id = store + .insert_route(tenant(1), &value) + .expect("inserted") + .route + .id; + + let untouched = store + .replace_route(tenant(1), id, &value) + .expect("a replacement keeps its own match key"); + assert_eq!(untouched.route.id, id); + + let moved = store + .replace_route( + tenant(1), + id, + &Route { + id, + ..route(upstream_id, "/v2/embed", 1, Some(true)) + }, + ) + .expect("a replacement may move its key"); + assert_eq!(store.list_routes(tenant(1)).len(), 1); + assert_eq!( + key_of(&store, tenant(1), &moved.route), + Some(id), + "the index follows the replacement" + ); +} + +#[test] +fn deleting_an_upstream_cascades_into_its_routes() { + let store = OagwStore::new(); + let upstream_id = store + .insert_upstream(tenant(1), &upstream(None)) + .expect("upstream inserted") + .upstream + .id; + let first = store + .insert_route(tenant(1), &route(upstream_id, "/v1", 1, Some(true))) + .expect("first route") + .route + .id; + let second = store + .insert_route(tenant(1), &route(upstream_id, "/v2", 2, Some(true))) + .expect("second route") + .route + .id; + + assert_eq!(store.delete_upstream(tenant(1), upstream_id), Ok(true)); + assert!(store.get_upstream(tenant(1), upstream_id).is_none()); + assert!(store.get_route(tenant(1), first).is_none()); + assert!(store.get_route(tenant(1), second).is_none()); + assert!(store.list_routes(tenant(1)).is_empty()); + assert!(store.upstream_tag_rows(tenant(1), upstream_id).is_empty()); + assert!(store.route_tag_rows(tenant(1), first).is_empty()); + assert!(store.route_methods(tenant(1), second).is_empty()); + assert!(store.route_http_match(tenant(1), second).is_none()); + assert!(store.enabled_match_index(tenant(1)).is_empty()); +} + +#[test] +fn deleting_a_route_leaves_its_upstream() { + let store = OagwStore::new(); + let upstream_id = store + .insert_upstream(tenant(1), &upstream(Some("api.openai.com"))) + .expect("upstream inserted") + .upstream + .id; + let route_id = store + .insert_route(tenant(1), &route(upstream_id, "/v1", 1, Some(true))) + .expect("route inserted") + .route + .id; + + assert_eq!(store.delete_route(tenant(1), route_id), Ok(true)); + assert!( + store.get_upstream(tenant(1), upstream_id).is_some(), + "a route deletion never touches the upstream row" + ); + assert_eq!( + store.upstream_tag_rows(tenant(1), upstream_id), + vec![String::from("llm")] + ); + assert!(store.route_tag_rows(tenant(1), route_id).is_empty()); + assert!(store.route_methods(tenant(1), route_id).is_empty()); + assert!(store.route_http_match(tenant(1), route_id).is_none()); + assert!(store.enabled_match_index(tenant(1)).is_empty()); +} + +#[test] +fn deleting_an_absent_or_foreign_row_answers_false() { + let store = OagwStore::new(); + assert_eq!(store.delete_upstream(tenant(1), Uuid::nil()), Ok(false)); + assert_eq!(store.delete_route(tenant(1), Uuid::nil()), Ok(false)); +} + +#[test] +fn the_enabled_match_index_reflects_every_write_and_delete() { + let store = OagwStore::new(); + let upstream_id = store + .insert_upstream(tenant(1), &upstream(None)) + .expect("upstream inserted") + .upstream + .id; + assert!(store.enabled_match_index(tenant(1)).is_empty()); + + let enabled = route(upstream_id, "/v1/chat", 1, Some(true)); + let enabled_id = store + .insert_route(tenant(1), &enabled) + .expect("inserted") + .route + .id; + assert_eq!(key_of(&store, tenant(1), &enabled), Some(enabled_id)); + + let toggled = Route { + enabled: Some(false), + ..enabled.clone() + }; + store + .replace_route(tenant(1), enabled_id, &toggled) + .expect("replaced"); + assert!( + store.enabled_match_index(tenant(1)).is_empty(), + "disabling a route drops its index entries" + ); + + let re_enabled = Route { + enabled: Some(true), + ..toggled + }; + store + .replace_route(tenant(1), enabled_id, &re_enabled) + .expect("replaced"); + assert_eq!(key_of(&store, tenant(1), &re_enabled), Some(enabled_id)); + + store.delete_route(tenant(1), enabled_id).expect("deleted"); + assert!(store.enabled_match_index(tenant(1)).is_empty()); +} + +#[test] +fn a_broken_model_refuses_the_batch_and_writes_nothing() { + let store = Arc::new(OagwStore::with_orphaned_match_index()); + let value = upstream(Some("api.openai.com")); + + assert!(matches!( + store.insert_upstream(tenant(1), &value), + Err(StoreError::Invariant { .. }) + )); + assert!( + store.get_upstream(tenant(1), value.id).is_none(), + "a refused batch leaves no row behind" + ); +} + +#[test] +fn an_empty_store_scans_to_nothing() { + let store = OagwStore::new(); + assert!(store.list_upstreams(tenant(1)).is_empty()); + assert!(store.list_routes(tenant(1)).is_empty()); + assert!(store.enabled_match_index(tenant(1)).is_empty()); +} diff --git a/gears/system/oagw/oagw/tests/stream_api_tests.rs b/gears/system/oagw/oagw/tests/stream_api_tests.rs new file mode 100644 index 0000000..68be734 --- /dev/null +++ b/gears/system/oagw/oagw/tests/stream_api_tests.rs @@ -0,0 +1,790 @@ +//! The streaming feature on the wire. +//! +//! Covers `cpt-cf-oagw-dod-stream-sse-forwarding`, `cpt-cf-oagw-dod-stream-lifecycle`, +//! `cpt-cf-oagw-dod-stream-upgrade`, `cpt-cf-oagw-dod-stream-timeouts`, +//! `cpt-cf-oagw-dod-stream-errors`, and `cpt-cf-oagw-dod-stream-tests` over the +//! mounted proxy surface: the incremental transfer of an event stream and of a +//! body that is not one, the handshake headers an upgrade request reaches its +//! upstream with, the non-101 passthrough, the two error answers with their GTS +//! types, their tags, and their missing `Retry-After`, the boundary +//! `proxy_timeout_secs` draws at the response headers, the refusal of an upgrade +//! request before the detection runs, the refusal of a scheme that is never +//! dialed, and the tunnel itself, which is carried over a real socket in both +//! directions and torn down in both. The upstream and the caller's half are the +//! mock boundary of every test here, and each test owns its own upstream, so no +//! test observes another's. + +// @cpt-dod:cpt-cf-oagw-dod-stream-tests:p1 + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::missing_panics_doc)] + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use axum::Router; +use axum::body::Body; +use axum::http::{Method, Request, StatusCode}; +use axum::middleware::Next; +use axum::response::Response; +use futures_util::StreamExt; +use serde_json::{Value, json}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tower::ServiceExt; +use uuid::Uuid; + +use authz_resolver_sdk::api::AuthZResolverClient; +use authz_resolver_sdk::constraints::{Constraint, EqPredicate, Predicate}; +use authz_resolver_sdk::error::AuthZResolverError; +use authz_resolver_sdk::models::{EvaluationRequest, EvaluationResponse, EvaluationResponseContext}; +use authz_resolver_sdk::pep::PolicyEnforcer; +use toolkit_security::SecurityContext; +use toolkit_security::pep_properties; + +use oagw::OagwConfig; +use oagw::OagwState; +use oagw::control_plane::cache::ControlPlaneCache; +use oagw::control_plane::service::ManagementService; +use oagw::store::OagwStore; + +const HTTP_PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; +const TENANT: u128 = 0x51; +const HOST: &str = "127.0.0.1"; +const ERROR_SOURCE: &str = "x-oagw-error-source"; +const REQUEST_TIMEOUT_TYPE: &str = "gts.cf.core.errors.err.v1~cf.oagw.timeout.request.v1"; +const IDLE_TIMEOUT_TYPE: &str = "gts.cf.core.errors.err.v1~cf.oagw.timeout.idle.v1"; +const STREAM_ABORTED_TYPE: &str = "gts.cf.core.errors.err.v1~cf.oagw.stream.aborted.v1"; +const PROTOCOL_ERROR_TYPE: &str = "gts.cf.core.errors.err.v1~cf.oagw.protocol.error.v1"; +const ROUTE_NOT_FOUND_TYPE: &str = "gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1"; +/// The six hop-by-hop headers the strip never suspends. +const ALWAYS_STRIPPED: [&str; 6] = [ + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", +]; + +/// The `AuthZ` PDP the allowing stub stands in for. +struct Allowing; + +#[async_trait::async_trait] +impl AuthZResolverClient for Allowing { + async fn evaluate( + &self, + _request: EvaluationRequest, + ) -> Result { + Ok(EvaluationResponse { + decision: true, + context: EvaluationResponseContext { + constraints: vec![Constraint { + predicates: vec![Predicate::Eq(EqPredicate { + property: String::from(pep_properties::OWNER_TENANT_ID), + value: json!(TENANT.to_string()), + })], + }], + deny_reason: None, + }, + }) + } +} + +/// The authenticated subject a proxy request carries. +fn subject() -> SecurityContext { + SecurityContext::builder() + .subject_id(Uuid::from_u128(TENANT)) + .subject_tenant_id(Uuid::from_u128(TENANT)) + .build() + .expect("the subject is complete") +} + +/// The `upstream_id` key the route create body names its upstream by. +fn key_of(instance: &str) -> String { + oagw::gts::parse_gts_instance(oagw::UPSTREAM_TYPE, instance) + .expect("the instance parses") + .to_string() +} + +/// Issues one create and returns the instance identifier of the row. +async fn created(app: &Router, method: Method, path: &str, body: &Value) -> String { + let response = app + .clone() + .oneshot( + Request::builder() + .method(method) + .uri(path) + .extension(subject()) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("the request builds"), + ) + .await + .expect("oneshot resolves"); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .expect("the body reads"); + let document: Value = serde_json::from_slice(&bytes).expect("the body is JSON"); + assert_eq!(status, StatusCode::CREATED, "{document}"); + document["id"].as_str().expect("the instance id").to_owned() +} + +/// Mounts one surface whose single upstream is the endpoint the test bound, +/// with the header-arrival deadline and the method allowlist the test states. +async fn mounted(port: u16, header_timeout_secs: u64, methods: &[&str], scheme: &str) -> Router { + let store = Arc::new(OagwStore::new()); + let cache = Arc::new(ControlPlaneCache::new()); + let config = OagwConfig { + allow_http_upstream: true, + proxy_timeout_secs: header_timeout_secs, + ..OagwConfig::default() + }; + let service = Arc::new( + ManagementService::new(Arc::clone(&store), &config, Arc::clone(&cache)) + .expect("the validators compile"), + ); + let state = Arc::new(OagwState::new( + Arc::new(config), + Arc::clone(&store), + service, + Some(Arc::new(PolicyEnforcer::new(Arc::new(Allowing)))), + None, + Arc::clone(&cache), + )); + let router = oagw::api::rest::register_management_routes(Router::new(), state); + + let upstream_instance = created( + &router, + Method::POST, + "/oagw/v1/upstreams", + &json!({ + "alias": HOST, + "server": { "endpoints": [{ "scheme": scheme, "host": HOST, "port": port }] }, + "protocol": HTTP_PROTOCOL + }), + ) + .await; + created( + &router, + Method::POST, + "/oagw/v1/routes", + &json!({ + "upstream_id": key_of(&upstream_instance), + "match": { "http": { "methods": methods, "path": "/api" } }, + "priority": 10 + }), + ) + .await; + router +} + +/// Binds one listener the gateway dials and hands the connection it accepts +/// back, so each test scripts its upstream's bytes itself. +async fn listening() -> (u16, tokio::sync::oneshot::Receiver) { + let listener = TcpListener::bind((HOST, 0)).await.expect("the listener binds"); + let port = listener.local_addr().expect("the address").port(); + let (accepted, dialled) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { + if let Ok((socket, _)) = listener.accept().await { + let _ = accepted.send(socket); + } + }); + (port, dialled) +} + +/// Reads one peer's head, which ends at the first blank line, and returns it +/// with the bytes that arrived past that line, which belong to whoever reads +/// the half next. +async fn read_head(stream: &mut TcpStream) -> (String, Vec) { + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 4096]; + while !buffer.windows(4).any(|window| window == b"\r\n\r\n") { + let read = stream + .read(&mut chunk) + .await + .expect("the peer's head reads"); + assert!(read > 0, "the peer closed before its head ended"); + buffer.extend_from_slice(&chunk[..read]); + } + let ended = buffer + .windows(4) + .position(|window| window == b"\r\n\r\n") + .expect("the head ends") + + 4; + let head = String::from_utf8_lossy(&buffer[..ended]).into_owned(); + (head, buffer.split_off(ended)) +} + +/// The head's header lines, lower-cased, without the request line. +fn headers_of(head: &str) -> Vec { + head.split("\r\n") + .skip(1) + .take_while(|line| !line.is_empty()) + .map(|line| line.to_ascii_lowercase()) + .collect() +} + +/// Whether one header line names one header, compared as the wire spells it. +fn declares(headers: &[String], name: &str) -> bool { + headers + .iter() + .any(|line| line.starts_with(name) && line[name.len()..].starts_with(':')) +} + +/// Sends one proxy request and hands the response back with its body still +/// unbuffered, so a test can read it as it arrives. +async fn send( + router: &Router, + method: Method, + uri: &str, + headers: &[(&str, &str)], +) -> axum::response::Response { + let mut builder = Request::builder() + .method(method) + .uri(uri) + .extension(subject()); + for (name, value) in headers { + builder = builder.header(*name, *value); + } + router + .clone() + .oneshot(builder.body(Body::empty()).expect("the request builds")) + .await + .expect("oneshot resolves") +} + +/// Reads one streamed body to its end. +async fn drain(body: Body) -> Vec { + let mut bytes = Vec::new(); + let mut stream = body.into_data_stream(); + while let Some(frame) = stream.next().await { + bytes.extend_from_slice(&frame.expect("the body frame")); + } + bytes +} + +/// Reads one answer's status, headers, and body as a problem document. +async fn problem(response: Response) -> (StatusCode, Vec<(String, String)>, Value) { + let status = response.status(); + let headers: Vec<(String, String)> = response + .headers() + .iter() + .map(|(name, value)| { + ( + name.as_str().to_ascii_lowercase(), + String::from_utf8_lossy(value.as_bytes()).into_owned(), + ) + }) + .collect(); + let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) + .await + .expect("the body reads"); + let document: Value = serde_json::from_slice(&bytes).expect("the body is JSON"); + (status, headers, document) +} + +/// One header value of an answer, matched case-insensitively. +fn header_of<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> { + headers + .iter() + .find(|(candidate, _)| candidate == name) + .map(|(_, value)| value.as_str()) +} + +/// Serves one mounted surface over a real socket, which the tunnel needs: the +/// caller's half of the tunnel is the connection the server accepted. +async fn served(router: Router) -> u16 { + let listener = TcpListener::bind((HOST, 0)).await.expect("the listener binds"); + let port = listener.local_addr().expect("the address").port(); + tokio::spawn(async move { + axum::serve(listener, router).await.expect("the server serves"); + }); + port +} + +/// Adds the subject the proxy surface requires, which a socket-bound caller +/// cannot carry as a request extension. +fn authenticated(router: Router) -> Router { + router.layer(axum::middleware::from_fn( + |mut request: Request, next: Next| async move { + request.extensions_mut().insert(subject()); + next.run(request).await + }, + )) +} + +/// The handshake a socket-bound caller sends, with the four headers the +/// handshake owns and the hop-by-hop headers the strip keeps stripping. +const HANDSHAKE: &str = "upgrade: websocket\r\nconnection: Upgrade\r\n\ + keep-alive: timeout=5\r\nproxy-authorization: Basic bWFyYQ==\r\n\ + te: trailers\r\ntrailer: x-upstream\r\n\ + sec-websocket-key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ + sec-websocket-version: 13\r\n\ + sec-websocket-protocol: chat, superchat\r\n\ + sec-websocket-extensions: permessage-deflate\r\n"; + +/// The 101 an upstream that takes the handshake answers with. +const SWITCHING: &[u8] = b"HTTP/1.1 101 Switching Protocols\r\nupgrade: websocket\r\n\ + connection: Upgrade\r\n\r\n"; + +/// An event stream reaches the caller as its events arrive, in the order and +/// the bytes the upstream emitted them, with none buffered to completion +/// first. The header deadline is the graded two seconds, and the body outlives +/// it, which is why no mid-body stall is answered with the header deadline's +/// variant. +#[tokio::test(flavor = "multi_thread")] +async fn an_event_stream_reaches_the_caller_as_its_events_arrive() { + let (port, dialled) = listening().await; + let router = mounted(port, 2, &["GET"], "http").await; + let reader = tokio::spawn(async move { + let response = send( + &router, + Method::GET, + "/oagw/v1/proxy/127.0.0.1/api", + &[], + ) + .await; + let head_arrived = Instant::now(); + let bytes = drain(response.into_body()).await; + (head_arrived, bytes) + }); + + let mut upstream = dialled.await.expect("the gateway dials"); + let (head, _tail) = read_head(&mut upstream).await; + assert!(!declares(&headers_of(&head), "upgrade"), "{head}"); + upstream + .write_all(b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\ + connection: close\r\n\r\ndata: one\n\n") + .await + .expect("the first event is written"); + upstream.flush().await.expect("the first event is flushed"); + // The second event is held back long enough to cross the header deadline, + // so a gateway that buffered the body would answer only after it. + tokio::time::sleep(Duration::from_millis(2_500)).await; + upstream + .write_all(b"data: two\n\n") + .await + .expect("the second event is written"); + upstream.flush().await.expect("the second event is flushed"); + drop(upstream); + + let (head_arrived, bytes) = reader.await.expect("the reader finishes"); + assert_eq!(bytes, b"data: one\n\ndata: two\n\n".as_slice()); + let body_span = head_arrived.elapsed(); + assert!( + body_span >= Duration::from_millis(2_000), + "the body was buffered: {body_span:?}" + ); +} + +/// A body whose content type is not `text/event-stream` is forwarded the same +/// way: as it arrives, byte for byte, with no frame parsed and none withheld. +#[tokio::test(flavor = "multi_thread")] +async fn a_body_that_is_not_an_event_stream_is_forwarded_the_same_way() { + let (port, dialled) = listening().await; + let router = mounted(port, 5, &["GET"], "http").await; + let reader = tokio::spawn(async move { + let response = send(&router, Method::GET, "/oagw/v1/proxy/127.0.0.1/api", &[]).await; + let head_arrived = Instant::now(); + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .map(String::from); + let source = response + .headers() + .get(ERROR_SOURCE) + .and_then(|value| value.to_str().ok()) + .map(String::from); + let bytes = drain(response.into_body()).await; + (head_arrived, content_type, source, bytes) + }); + + let mut upstream = dialled.await.expect("the gateway dials"); + let (_head, _tail) = read_head(&mut upstream).await; + upstream + .write_all(b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\ + connection: close\r\n\r\n{\"a\":") + .await + .expect("the first half is written"); + upstream.flush().await.expect("the first half is flushed"); + tokio::time::sleep(Duration::from_millis(900)).await; + upstream + .write_all(b"1}") + .await + .expect("the second half is written"); + upstream.flush().await.expect("the second half is flushed"); + drop(upstream); + + let (head_arrived, content_type, source, bytes) = reader.await.expect("the reader finishes"); + assert_eq!(content_type.as_deref(), Some("application/json")); + assert_eq!(source.as_deref(), Some("upstream")); + assert_eq!(bytes, b"{\"a\":1}".as_slice()); + let body_span = head_arrived.elapsed(); + assert!( + body_span >= Duration::from_millis(700), + "the body was buffered: {body_span:?}" + ); +} + +/// An upgrade request reaches its upstream with the handshake's own headers +/// and the two suspended strip names, without the six hop-by-hop headers, and +/// a 101 is answered to the caller as the upstream sent it. +#[tokio::test(flavor = "multi_thread")] +async fn an_upgrade_request_reaches_the_upstream_with_its_handshake_headers() { + let (port, dialled) = listening().await; + let router = mounted(port, 5, &["GET"], "http").await; + let taken = tokio::spawn(async move { + let mut upstream = dialled.await.expect("the gateway dials"); + let (head, _tail) = read_head(&mut upstream).await; + let headers = headers_of(&head); + for stripped in ALWAYS_STRIPPED { + assert!(!declares(&headers, stripped), "{head}"); + } + assert!(declares(&headers, "upgrade"), "{head}"); + assert!(declares(&headers, "connection"), "{head}"); + assert!(declares(&headers, "host"), "{head}"); + assert!(declares(&headers, "sec-websocket-key"), "{head}"); + assert!(declares(&headers, "sec-websocket-version"), "{head}"); + assert!(declares(&headers, "sec-websocket-protocol"), "{head}"); + assert!(declares(&headers, "sec-websocket-extensions"), "{head}"); + upstream + .write_all(SWITCHING) + .await + .expect("the 101 is written"); + upstream.flush().await.expect("the 101 is flushed"); + }); + + let response = send( + &router, + Method::GET, + "/oagw/v1/proxy/127.0.0.1/api", + &[ + ("upgrade", "websocket"), + ("connection", "Upgrade"), + ("keep-alive", "timeout=5"), + ("proxy-authorization", "Basic bWFyYQ=="), + ("te", "trailers"), + ("trailer", "x-upstream"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ("sec-websocket-version", "13"), + ("sec-websocket-protocol", "chat, superchat"), + ("sec-websocket-extensions", "permessage-deflate"), + ], + ) + .await; + assert_eq!(response.status(), StatusCode::SWITCHING_PROTOCOLS); + assert_eq!( + response.headers().get("upgrade").and_then(|v| v.to_str().ok()), + Some("websocket") + ); + assert_eq!(response.headers().get(ERROR_SOURCE).and_then(|v| v.to_str().ok()), Some("upstream")); + taken.await.expect("the upstream finished"); +} + +/// A handshake the upstream does not take up is returned to the caller with +/// that upstream answer unchanged and the error-source classification's tag, +/// and the connection stays a plain request/response exchange. +#[tokio::test(flavor = "multi_thread")] +async fn a_handshake_the_upstream_does_not_upgrade_passes_through_unchanged() { + let (port, dialled) = listening().await; + let router = mounted(port, 5, &["GET"], "http").await; + let refused = tokio::spawn(async move { + let mut upstream = dialled.await.expect("the gateway dials"); + let (_head, _tail) = read_head(&mut upstream).await; + upstream + .write_all(b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\ + content-length: 2\r\n\r\n{}") + .await + .expect("the refusal is written"); + upstream.flush().await.expect("the refusal is flushed"); + // The connection is held open to prove the gateway answers from it and + // does not tunnel, and is closed with the answer. + tokio::time::sleep(Duration::from_millis(200)).await; + }); + + let response = send( + &router, + Method::GET, + "/oagw/v1/proxy/127.0.0.1/api", + &[("upgrade", "websocket"), ("connection", "Upgrade")], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(ERROR_SOURCE).and_then(|v| v.to_str().ok()), + Some("upstream") + ); + let bytes = drain(response.into_body()).await; + assert_eq!(bytes, b"{}".as_slice()); + refused.await.expect("the upstream finished"); +} + +/// A body that terminates mid-flight, after the headers arrived, is answered +/// 502 with the `StreamAborted` variant, the gateway tag, and no `Retry-After`. +#[tokio::test(flavor = "multi_thread")] +async fn a_body_terminated_mid_flight_is_answered_502_stream_aborted() { + let (port, dialled) = listening().await; + let router = mounted(port, 5, &["GET"], "http").await; + let torn = tokio::spawn(async move { + let mut upstream = dialled.await.expect("the gateway dials"); + let (_head, _tail) = read_head(&mut upstream).await; + // The length is declared and never delivered: the body ends mid-flight. + // The length is declared and never delivered at all: the body is + // terminated before its first byte, which is the one moment a + // mid-flight failure can still be answered as a whole. + upstream + .write_all(b"HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\n\ + content-length: 5\r\n\r\n") + .await + .expect("the torn answer is written"); + upstream.flush().await.expect("the torn answer is flushed"); + drop(upstream); + }); + + let response = send(&router, Method::GET, "/oagw/v1/proxy/127.0.0.1/api", &[]).await; + let (status, headers, document) = problem(response).await; + assert_eq!(status, StatusCode::BAD_GATEWAY, "{document}"); + assert_eq!(document["type"], STREAM_ABORTED_TYPE, "{document}"); + assert_eq!(document["status"], 502, "{document}"); + assert_eq!(header_of(&headers, ERROR_SOURCE), Some("gateway")); + assert_eq!(header_of(&headers, "content-type"), Some("application/problem+json")); + assert!(!headers.iter().any(|(name, _)| name == "retry-after")); + torn.await.expect("the upstream finished"); +} + +/// A stream whose response headers never arrive is answered 504 with the +/// `RequestTimeout` variant the header-arrival deadline carries, and the 60 +/// seconds of the idle deadline are spent on no body at all, because no body +/// was ever opened. +#[tokio::test(flavor = "multi_thread")] +async fn a_header_wait_past_the_deadline_is_answered_504_request_timeout() { + let (port, dialled) = listening().await; + let router = mounted(port, 2, &["GET"], "http").await; + let silent = tokio::spawn(async move { + // The connection is held, not answered: the header wait is what + // breaches. + let mut held = dialled.await.expect("the gateway dials"); + let (_head, _tail) = read_head(&mut held).await; + tokio::time::sleep(Duration::from_secs(3)).await; + }); + + let response = send(&router, Method::GET, "/oagw/v1/proxy/127.0.0.1/api", &[]).await; + let (status, headers, document) = problem(response).await; + assert_eq!(status, StatusCode::GATEWAY_TIMEOUT, "{document}"); + assert_eq!(document["type"], REQUEST_TIMEOUT_TYPE, "{document}"); + assert_eq!(header_of(&headers, ERROR_SOURCE), Some("gateway")); + assert_eq!(header_of(&headers, "content-type"), Some("application/problem+json")); + assert!(!headers.iter().any(|(name, _)| name == "retry-after")); + silent.await.expect("the upstream finished"); +} + +/// An upgrade request a route does not declare `GET` for is answered 404 with +/// the `RouteNotFound` variant and never reaches the detection or the upstream, +/// so the answer names the resolution and not a streaming-specific reason. +#[tokio::test(flavor = "multi_thread")] +async fn an_upgrade_request_a_route_does_not_declare_get_for_is_answered_404() { + let (port, mut dialled) = listening().await; + let router = mounted(port, 5, &["POST"], "http").await; + + let (status, headers, document) = + problem(send(&router, Method::GET, "/oagw/v1/proxy/127.0.0.1/api", + &[("upgrade", "websocket"), ("connection", "Upgrade")]).await) + .await; + assert_eq!(status, StatusCode::NOT_FOUND, "{document}"); + assert_eq!(document["type"], ROUTE_NOT_FOUND_TYPE, "{document}"); + assert_eq!(header_of(&headers, ERROR_SOURCE), Some("gateway")); + + tokio::time::sleep(Duration::from_millis(200)).await; + assert!( + dialled.try_recv().is_err(), + "the refused upgrade request reached the upstream" + ); +} + +/// A `wt`-scheme endpoint is refused at the send by the forward, so the 502 is +/// the protocol error the dial answers with and no tunnel is taken up. +#[tokio::test(flavor = "multi_thread")] +async fn a_wt_scheme_endpoint_is_refused_at_the_send_and_never_tunnelled() { + let (port, mut dialled) = listening().await; + let router = mounted(port, 5, &["GET"], "wt").await; + + let (status, headers, document) = problem( + send( + &router, + Method::GET, + "/oagw/v1/proxy/127.0.0.1/api", + &[("upgrade", "websocket"), ("connection", "Upgrade")], + ) + .await, + ) + .await; + assert_eq!(status, StatusCode::BAD_GATEWAY, "{document}"); + assert_eq!(document["type"], PROTOCOL_ERROR_TYPE, "{document}"); + assert_eq!(header_of(&headers, ERROR_SOURCE), Some("gateway")); + assert!(!headers.iter().any(|(name, _)| name == "retry-after")); + + tokio::time::sleep(Duration::from_millis(200)).await; + assert!( + dialled.try_recv().is_err(), + "the refused scheme reached a socket" + ); +} + +/// The idle deadline is sixty seconds, is named by no configuration key, and +/// its breach is answered with the variant the idle timeout carries, which is +/// what the two answers of the pump map to. +#[tokio::test] +async fn the_idle_deadline_is_sixty_seconds_and_reached_by_no_configuration() { + let document = serde_json::to_value(OagwConfig::default()).expect("the config serialises"); + let spelled = document.to_string(); + assert!( + !spelled.contains("idle"), + "the idle deadline is configurable: {spelled}" + ); + assert_eq!(oagw::domain::stream::IDLE_TIMEOUT_SECS, 60); + // The breach is answered by the pump's own mapping, which the streamed + // error answers share with the tunnel. + let stalled = oagw::domain::stream::answer_of(oagw::domain::stream::StreamOutcome::Stalled) + .expect("the stall is answered"); + assert_eq!(stalled.kind.http_status(), 504); + assert_eq!(stalled.kind.gts_type(), IDLE_TIMEOUT_TYPE); +} + +/// After a 101, bytes the caller sends reach the upstream and bytes the +/// upstream sends reach the caller, with nothing added, interpreted, or +/// withheld in either direction. +#[tokio::test(flavor = "multi_thread")] +async fn a_101_handshake_tunnels_bytes_in_both_directions() { + let (port, dialled) = listening().await; + let router = mounted(port, 5, &["GET"], "http").await; + let gateway = served(authenticated(router)).await; + let echoed = tokio::spawn(async move { + let mut upstream = dialled.await.expect("the gateway dials"); + let (head, _tail) = read_head(&mut upstream).await; + let headers = headers_of(&head); + for stripped in ALWAYS_STRIPPED { + assert!(!declares(&headers, stripped), "{head}"); + } + assert!(declares(&headers, "upgrade"), "{head}"); + assert!(declares(&headers, "sec-websocket-key"), "{head}"); + upstream.write_all(SWITCHING).await.expect("the 101 is written"); + upstream.flush().await.expect("the 101 is flushed"); + let mut buffer = [0_u8; 16]; + let read = upstream.read(&mut buffer).await.expect("the tunnel reads"); + assert_eq!(&buffer[..read], b"ping"); + upstream.write_all(b"pong").await.expect("the echo is written"); + upstream.flush().await.expect("the echo is flushed"); + }); + + let mut caller = TcpStream::connect((HOST, gateway)) + .await + .expect("the caller connects"); + caller + .write_all( + format!( + "GET /oagw/v1/proxy/127.0.0.1/api HTTP/1.1\r\nhost: gateway\r\n{HANDSHAKE}\r\n" + ) + .as_bytes(), + ) + .await + .expect("the handshake is sent"); + let (head, tail) = read_head(&mut caller).await; + assert!(head.starts_with("HTTP/1.1 101"), "{head}"); + caller.write_all(b"ping").await.expect("the tunnel sends"); + let mut buffer = tail; + let mut chunk = [0_u8; 16]; + while buffer.len() < 4 { + let read = caller.read(&mut chunk).await.expect("the tunnel reads"); + assert!(read > 0, "the tunnel closed before the echo"); + buffer.extend_from_slice(&chunk[..read]); + } + assert_eq!(buffer, b"pong".as_slice()); + echoed.await.expect("the upstream finished"); +} + +/// A caller that disconnects has its upstream half closed by the gateway, and +/// the outcome is recorded on the session the exchange carried. +#[tokio::test(flavor = "multi_thread")] +async fn a_caller_that_disconnects_closes_the_upstream_half() { + let (port, dialled) = listening().await; + let router = mounted(port, 5, &["GET"], "http").await; + let gateway = served(authenticated(router)).await; + let observed = tokio::spawn(async move { + let mut upstream = dialled.await.expect("the gateway dials"); + let (_head, _tail) = read_head(&mut upstream).await; + upstream.write_all(SWITCHING).await.expect("the 101 is written"); + upstream.flush().await.expect("the 101 is flushed"); + let mut buffer = [0_u8; 8]; + loop { + let read = upstream.read(&mut buffer).await.expect("the tunnel reads"); + if read == 0 { + break true; + } + } + }); + + let mut caller = TcpStream::connect((HOST, gateway)) + .await + .expect("the caller connects"); + caller + .write_all( + format!( + "GET /oagw/v1/proxy/127.0.0.1/api HTTP/1.1\r\nhost: gateway\r\n{HANDSHAKE}\r\n" + ) + .as_bytes(), + ) + .await + .expect("the handshake is sent"); + let (head, _tail) = read_head(&mut caller).await; + assert!(head.starts_with("HTTP/1.1 101"), "{head}"); + drop(caller); + assert!( + observed.await.expect("the upstream finished"), + "the upstream half was never closed" + ); +} + +/// An upstream that closes its half has the bytes it already sent written to +/// the caller before the caller's half is closed with it. +#[tokio::test(flavor = "multi_thread")] +async fn an_upstream_that_closes_its_half_closes_the_caller_s_after_its_bytes() { + let (port, dialled) = listening().await; + let router = mounted(port, 5, &["GET"], "http").await; + let gateway = served(authenticated(router)).await; + tokio::spawn(async move { + let mut upstream = dialled.await.expect("the gateway dials"); + let (_head, _tail) = read_head(&mut upstream).await; + upstream.write_all(SWITCHING).await.expect("the 101 is written"); + upstream.flush().await.expect("the 101 is flushed"); + upstream.write_all(b"bye").await.expect("the bytes are written"); + upstream.flush().await.expect("the bytes are flushed"); + drop(upstream); + }); + + let mut caller = TcpStream::connect((HOST, gateway)) + .await + .expect("the caller connects"); + caller + .write_all( + format!( + "GET /oagw/v1/proxy/127.0.0.1/api HTTP/1.1\r\nhost: gateway\r\n{HANDSHAKE}\r\n" + ) + .as_bytes(), + ) + .await + .expect("the handshake is sent"); + let (head, tail) = read_head(&mut caller).await; + assert!(head.starts_with("HTTP/1.1 101"), "{head}"); + let mut bytes = tail; + let mut chunk = [0_u8; 16]; + loop { + let read = caller.read(&mut chunk).await.expect("the tunnel reads"); + if read == 0 { + break; + } + bytes.extend_from_slice(&chunk[..read]); + } + assert_eq!(bytes, b"bye".as_slice()); +} diff --git a/gears/system/oagw/oagw/tests/stream_domain_tests.rs b/gears/system/oagw/oagw/tests/stream_domain_tests.rs new file mode 100644 index 0000000..bc34adc --- /dev/null +++ b/gears/system/oagw/oagw/tests/stream_domain_tests.rs @@ -0,0 +1,413 @@ +//! The streaming entities and the three routines of the domain layer. +//! +//! Covers `cpt-cf-oagw-dod-stream-entities`, `cpt-cf-oagw-dod-stream-upgrade`, +//! `cpt-cf-oagw-dod-stream-timeouts`, and `cpt-cf-oagw-dod-stream-errors`: the +//! three-part upgrade detection and each of its negatives, the suspended +//! headers the handshake records, the 101 judgement, the two-value transfer +//! mode, every transition and every invalid transition of the lifecycle +//! machine, the 60-second constant with no configuration surface, and the two +//! error answers with their GTS types and their missing `Retry-After`. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::time::Duration; + +use oagw::domain::proxy::ProxyContext; +use oagw::domain::stream::{ + self, StreamLifecycle, StreamOutcome, TransferMode, IDLE_TIMEOUT, IDLE_TIMEOUT_SECS, + HANDSHAKE_HEADERS, +}; +use oagw::domain::ErrorKind; + +const TENANT: uuid::Uuid = uuid::Uuid::from_u128(0x61); +const UPSTREAM: uuid::Uuid = uuid::Uuid::from_u128(0x62); + +/// The header pairs an inbound request held, in arrival order. +fn headers(pairs: &[(&str, &str)]) -> Vec<(String, String)> { + pairs + .iter() + .map(|(name, value)| (String::from(*name), String::from(*value))) + .collect() +} + +/// The context of one inbound request, with the headers the caller states. +fn context(method: &str, pairs: &[(&str, &str)]) -> ProxyContext { + ProxyContext { + method: String::from(method), + alias: String::from("events.example.test"), + path_suffix: None, + query: None, + headers: headers(pairs), + target_host: None, + tenant_id: TENANT, + subject_id: None, + correlation: None, + } +} + +// @cpt-dod:cpt-cf-oagw-dod-stream-entities:p1 + +#[test] +fn the_two_entities_carry_the_members_the_feature_assigns() { + let session = oagw::domain::stream::StreamSession::open_for_incremental( + TENANT, + UPSTREAM, + Some(String::from("text/event-stream")), + ); + assert_eq!(session.tenant_id, TENANT); + assert_eq!(session.upstream_id, UPSTREAM); + assert_eq!(session.mode, TransferMode::Incremental); + assert_eq!(session.content_type.as_deref(), Some("text/event-stream")); + assert_eq!(session.lifecycle, StreamLifecycle::Open); + assert_eq!(session.idle_timeout, IDLE_TIMEOUT); + assert!(session.moved == 0, "no byte has moved yet"); + assert!(session.outcome.is_none(), "the exchange has not ended"); + assert!(session.caller.open); + assert!(session.upstream.open); + + let handshake = oagw::domain::stream::UpgradeHandshake::build( + stream::upgrade_detection("GET", Some("websocket"), Some("upgrade")) + .expect("the three parts hold"), + &context("GET", &[("Upgrade", "websocket"), ("Connection", "upgrade")]), + ); + assert!(!handshake.suspended.is_empty(), "the handshake carries its two"); + assert_eq!(handshake.answer, stream::UpgradeAnswer::NotJudged); +} + +#[test] +fn the_lifecycle_state_is_a_state_of_the_machine_and_not_a_third_type() { + // The session carries `StreamLifecycle`, which is the one state machine the + // feature owns; the handshake carries no lifecycle of its own. + let session = oagw::domain::stream::StreamSession::open_for_handshake(TENANT, UPSTREAM); + assert_eq!(session.lifecycle, StreamLifecycle::Opening); + assert_eq!(session.mode, TransferMode::Tunnel); +} + +// @cpt-dod:cpt-cf-oagw-dod-stream-upgrade:p1 + +#[test] +fn the_three_parts_of_the_detection_hold_together() { + let detection = + stream::upgrade_detection("GET", Some("websocket"), Some("keep-alive, upgrade")); + assert!(detection.is_some(), "the token list names the upgrade token"); + assert!( + stream::upgrade_detection("GET", Some("WebSocket"), Some("Upgrade")).is_some(), + "both headers are compared case-insensitively" + ); +} + +#[test] +fn each_negative_of_the_detection_refuses_the_upgrade() { + // A POST is not the method the handshake requires. + assert!( + stream::upgrade_detection("POST", Some("websocket"), Some("upgrade")).is_none(), + "the method part fails" + ); + // Another protocol is not the upgrade the feature delivers. + assert!( + stream::upgrade_detection("GET", Some("h2c"), Some("upgrade")).is_none(), + "the protocol part fails" + ); + // A value carrying whitespace or a protocol list is matched against the one + // literal and against nothing else. + assert!( + stream::upgrade_detection("GET", Some("websocket, h2c"), Some("upgrade")).is_none(), + "the protocol is matched as one literal" + ); + // The connection part must name the upgrade token. + assert!( + stream::upgrade_detection("GET", Some("websocket"), Some("keep-alive")).is_none(), + "the connection part fails" + ); + // Either header absent is a negative. + assert!(stream::upgrade_detection("GET", None, Some("upgrade")).is_none()); + assert!(stream::upgrade_detection("GET", Some("websocket"), None).is_none()); +} + +#[test] +fn the_suspension_records_the_two_and_the_handshake_headers_only() { + let context = context( + "GET", + &[ + ("Upgrade", "websocket"), + ("Connection", "keep-alive, Upgrade"), + ("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="), + ("Sec-WebSocket-Version", "13"), + ("Sec-WebSocket-Extensions", "permessage-deflate"), + ("Sec-WebSocket-Protocol", "chat"), + ("Keep-Alive", "timeout=5"), + ("TE", "trailers"), + ("Trailer", "X-Checksum"), + ("Transfer-Encoding", "chunked"), + ("Proxy-Authorization", "Basic dXNlcjpwYXNz"), + ("Proxy-Authenticate", "Basic"), + ("X-Custom", "probe"), + ("Authorization", "Bearer secret"), + ], + ); + let detection = stream::upgrade_detection("GET", Some("websocket"), Some("keep-alive, Upgrade")) + .expect("the three parts hold"); + let handshake = oagw::domain::stream::UpgradeHandshake::build(detection, &context); + let names: Vec = handshake + .suspended + .iter() + .map(|(name, _)| name.to_ascii_lowercase()) + .collect(); + for suspended in ["upgrade", "connection"] { + assert!( + names.contains(&String::from(suspended)), + "{suspended} is suspended for the handshake" + ); + } + for forwarded in HANDSHAKE_HEADERS { + assert!( + names.contains(&String::from(forwarded)), + "{forwarded} is forwarded as the handshake's own header" + ); + } + for stripped in [ + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + ] { + assert!( + !names.contains(&String::from(stripped)), + "{stripped} stays stripped on an upgrade request" + ); + } + assert!( + !names.contains(&String::from("authorization")), + "the credential is never a candidate" + ); + assert!( + !names.contains(&String::from("x-custom")), + "no other inbound header is admitted by the suspension" + ); +} + +#[test] +fn the_handshake_judgement_names_only_the_101_as_taken() { + let mut handshake = oagw::domain::stream::UpgradeHandshake::build( + stream::upgrade_detection("GET", Some("websocket"), Some("upgrade")) + .expect("the three parts hold"), + &context("GET", &[("Upgrade", "websocket"), ("Connection", "upgrade")]), + ); + assert_eq!(handshake.answer, stream::UpgradeAnswer::NotJudged); + handshake.judge(101); + assert_eq!(handshake.answer, stream::UpgradeAnswer::Taken); + handshake.judge(200); + assert_eq!(handshake.answer, stream::UpgradeAnswer::NotTaken); +} + +// @cpt-dod:cpt-cf-oagw-dod-stream-timeouts:p1 + +#[test] +fn the_idle_timeout_is_sixty_seconds_and_reached_by_no_configuration() { + assert_eq!(IDLE_TIMEOUT_SECS, 60); + assert_eq!(IDLE_TIMEOUT, Duration::from_secs(60)); + // No key of the configuration surface carries the value, and no upstream or + // route configuration reaches it either: the session reads it from the + // constant and from nothing else. + let session = oagw::domain::stream::StreamSession::open_for_incremental(TENANT, UPSTREAM, None); + assert_eq!(session.idle_timeout, Duration::from_secs(60)); + let handshake = oagw::domain::stream::StreamSession::open_for_handshake(TENANT, UPSTREAM); + assert_eq!(handshake.idle_timeout, Duration::from_secs(60)); +} + +// @cpt-dod:cpt-cf-oagw-dod-stream-errors:p1 + +#[test] +fn the_transfer_mode_has_two_values_and_no_third() { + // The mode is selected from the request and the response headers alone. + let detection = stream::upgrade_detection("GET", Some("websocket"), Some("upgrade")); + assert_eq!( + stream::select_mode(detection, 101, None).0, + TransferMode::Tunnel, + "a 101 to a detected upgrade is a tunnel" + ); + assert_eq!( + stream::select_mode(None, 101, Some("text/event-stream")).0, + TransferMode::Incremental, + "a 101 without the detection is a body transfer" + ); + for content_type in [ + Some("text/event-stream"), + Some("application/json"), + Some("application/grpc+proto"), + None, + ] { + assert_eq!( + stream::select_mode(detection, 200, content_type).0, + TransferMode::Incremental, + "every body that is not a tunnel is incremental" + ); + } +} + +#[test] +fn the_mode_carries_the_answer_or_the_content_type() { + let (mode, carry) = stream::select_mode( + stream::upgrade_detection("GET", Some("websocket"), Some("upgrade")), + 101, + None, + ); + assert_eq!(mode, TransferMode::Tunnel); + assert_eq!(carry, Some(stream::SessionCarry::Answer(101))); + + let (mode, carry) = stream::select_mode(None, 200, Some("text/event-stream")); + assert_eq!(mode, TransferMode::Incremental); + assert_eq!( + carry, + Some(stream::SessionCarry::ContentType(String::from( + "text/event-stream" + ))) + ); + + let (_, carry) = stream::select_mode(None, 200, None); + assert!(carry.is_none(), "no content type is recorded when none is named"); +} + +#[test] +fn the_two_error_answers_carry_their_types_and_no_retry_after() { + let stalled = stream::answer_of(StreamOutcome::Stalled).expect("the stall is answered"); + assert_eq!(stalled.kind, ErrorKind::IdleTimeout); + assert_eq!(stalled.kind.http_status(), 504); + assert_eq!( + stalled.kind.gts_type(), + "gts.cf.core.errors.err.v1~cf.oagw.timeout.idle.v1" + ); + assert_eq!(stalled.source, oagw::domain::ErrorSource::Gateway); + assert!( + stalled.context.retry_after_seconds.is_none(), + "the idle answer carries no Retry-After, although the row is retriable" + ); + + let aborted = stream::answer_of(StreamOutcome::Aborted).expect("the abort is answered"); + assert_eq!(aborted.kind, ErrorKind::StreamAborted); + assert_eq!(aborted.kind.http_status(), 502); + assert_eq!( + aborted.kind.gts_type(), + "gts.cf.core.errors.err.v1~cf.oagw.stream.aborted.v1" + ); + assert!(aborted.context.retry_after_seconds.is_none()); + assert_ne!( + aborted.kind, ErrorKind::DownstreamError, + "the downstream variant is never used for a mid-flight termination" + ); + + for outcome in [ + StreamOutcome::ClientDisconnected, + StreamOutcome::UpstreamClosed, + ] { + assert!( + stream::answer_of(outcome).is_none(), + "a clean teardown direction is not an error answer" + ); + } +} + +// @cpt-dod:cpt-cf-oagw-dod-stream-lifecycle:p1 + +#[test] +fn every_declared_transition_is_taken() { + // Opening to Open, on a 101 for a tunnel. + let lifecycle = StreamLifecycle::Opening + .opened() + .expect("the handshake was taken up"); + assert_eq!(lifecycle, StreamLifecycle::Open); + // Opening to Closed, on a refusal or a failure before data. + assert_eq!( + StreamLifecycle::Opening.refused().expect("the handshake was refused"), + StreamLifecycle::Closed + ); + // Open to Closing, when one side signals the end. + let closing = StreamLifecycle::Open.closing().expect("one side ended"); + assert_eq!(closing, StreamLifecycle::Closing); + // Closing to Closed, when the other half is torn down. + assert_eq!( + closing.closed().expect("the other half is torn down"), + StreamLifecycle::Closed + ); + // Open to Closed, on a mid-flight abort. + assert_eq!( + StreamLifecycle::Open.aborted().expect("a half failed"), + StreamLifecycle::Closed + ); +} + +#[test] +fn every_invalid_transition_is_refused() { + // Closed is terminal. + let closed = StreamLifecycle::Closed; + for attempt in [closed.opened(), closed.closing(), closed.closed(), closed.aborted()] { + assert!(attempt.is_err(), "no transition leaves Closed"); + } + assert!( + StreamLifecycle::Closing.opened().is_err(), + "a closing session cannot be reopened" + ); + assert!( + StreamLifecycle::Closing.closing().is_err(), + "a session that is closing does not close twice over Closing" + ); + assert!( + StreamLifecycle::Closing.aborted().is_err(), + "a closing session is not aborted, because it is already draining" + ); + assert!( + StreamLifecycle::Opening.closing().is_err(), + "a session that never opened takes the refusal transition instead" + ); +} + +#[test] +fn a_closed_session_is_never_left_with_an_open_half() { + let mut session = oagw::domain::stream::StreamSession::open_for_handshake(TENANT, UPSTREAM); + session.refuse(); + assert_eq!(session.lifecycle, StreamLifecycle::Closed); + assert!(!session.caller.open); + assert!(!session.upstream.open); + assert_eq!(session.outcome, Some(StreamOutcome::Aborted)); +} + +#[test] +fn the_teardown_directions_move_through_closing_to_closed() { + let mut client_first = + oagw::domain::stream::StreamSession::open_for_incremental(TENANT, UPSTREAM, None); + client_first.disconnect(); + assert_eq!(client_first.lifecycle, StreamLifecycle::Closed); + assert_eq!(client_first.outcome, Some(StreamOutcome::ClientDisconnected)); + assert!(!client_first.upstream.open, "the upstream half is closed"); + assert!(!client_first.caller.open, "the caller half is closed"); + + let mut upstream_first = + oagw::domain::stream::StreamSession::open_for_incremental(TENANT, UPSTREAM, None); + upstream_first.upstream_closed(); + assert_eq!(upstream_first.lifecycle, StreamLifecycle::Closed); + assert_eq!(upstream_first.outcome, Some(StreamOutcome::UpstreamClosed)); + + let mut stalled = oagw::domain::stream::StreamSession::open_for_incremental(TENANT, UPSTREAM, None); + stalled.stalled(); + assert_eq!(stalled.lifecycle, StreamLifecycle::Closed); + assert_eq!(stalled.outcome, Some(StreamOutcome::Stalled)); + assert!( + stream::answer_of(stalled.outcome.expect("the stall is recorded")).is_some(), + "a stalled stream is answered" + ); + + let mut aborted = oagw::domain::stream::StreamSession::open_for_incremental(TENANT, UPSTREAM, None); + aborted.abort_transfer(); + assert_eq!(aborted.lifecycle, StreamLifecycle::Closed); + assert_eq!(aborted.outcome, Some(StreamOutcome::Aborted)); +} + +#[test] +fn the_bytes_moved_are_counted_once_they_are_written() { + let mut session = oagw::domain::stream::StreamSession::open_for_incremental(TENANT, UPSTREAM, None); + session.record_moved(7); + session.record_moved(3); + assert_eq!(session.moved, 10, "each byte is counted once it has been written"); +} diff --git a/gears/system/oagw/oagw/tests/validation_tests.rs b/gears/system/oagw/oagw/tests/validation_tests.rs new file mode 100644 index 0000000..88a5288 --- /dev/null +++ b/gears/system/oagw/oagw/tests/validation_tests.rs @@ -0,0 +1,737 @@ +//! Request-validation tests. +//! +//! Covers `cpt-cf-oagw-dod-request-validation` and +//! `cpt-cf-oagw-algo-request-validate`: one case per row of the FEATURE §3 +//! family table, the single accumulated error that names every failing +//! property, the property that the detail never echoes a request body value, +//! the route replacement required-set narrowing, and the §1.5 route root +//! additions. + +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::missing_panics_doc)] + +// @cpt-dod:cpt-cf-oagw-dod-colocated-tests:p1 + +use std::sync::LazyLock; + +use serde_json::{Value, json}; + +use oagw::control_plane::validation::{Validator, WriteKind}; +use oagw::config::OagwConfig; +use oagw::gts::AUTH_PLUGIN_TYPE; +use oagw::{DomainError, ErrorKind}; + +const HTTP_PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; +const SECRET: &str = "sk-live-abcdef0123456789"; + +/// The HTTPS-only posture the configuration defaults to. +static VALIDATOR: LazyLock = LazyLock::new(|| { + Validator::compile(&OagwConfig::default()).expect("the shipped schemas compile") +}); + +/// The lifted posture that admits the `http` endpoint scheme literal. +static HTTP_ALLOWED: LazyLock = LazyLock::new(|| { + Validator::compile(&OagwConfig { + allow_http_upstream: true, + ..OagwConfig::default() + }) + .expect("the shipped schemas compile") +}); + +/// Removes one root property from a body. +fn without(body: &Value, key: &str) -> Value { + let mut body = body.clone(); + body.as_object_mut() + .expect("the body is an object") + .remove(key); + body +} + +/// A minimal valid upstream body. +fn upstream_body() -> Value { + json!({ + "server": { + "endpoints": [{ "scheme": "https", "host": "api.openai.com", "port": 443 }] + }, + "protocol": HTTP_PROTOCOL + }) +} + +/// A minimal valid route body for a create. +fn route_body() -> Value { + json!({ + "upstream_id": "00000000-0000-0000-0000-000000000001", + "match": { "http": { "methods": ["GET"], "path": "/v1/chat" } }, + "priority": 1 + }) +} + +/// The detail of a refused body, asserting it is one validation error. +fn refused(error: &DomainError) -> &str { + assert_eq!(error.kind, ErrorKind::ValidationError, "{error}"); + &error.detail +} + +/// Asserts a body is refused and the detail names `needle`. +fn refused_with(validator: &Validator, write: WriteKind, body: &Value, needle: &str) { + let error = validator + .validate_upstream(write, body) + .expect_err("the body is refused"); + let detail = refused(&error); + assert!( + detail.contains(needle), + "expected '{needle}' in the detail '{detail}'" + ); +} + +/// Asserts an upstream body is refused and the detail names `needle`. +fn upstream_refused(body: &Value, needle: &str) { + refused_with(&VALIDATOR, WriteKind::Create, body, needle); +} + +/// Asserts a route body is refused and the detail names `needle`. +fn route_refused(body: &Value, needle: &str) { + let error = VALIDATOR + .validate_route(WriteKind::Create, body) + .expect_err("the body is refused"); + let detail = refused(&error); + assert!( + detail.contains(needle), + "expected '{needle}' in the detail '{detail}'" + ); +} + +/// Asserts an upstream body is accepted. +fn accepted(validator: &Validator, body: &Value) { + validator + .validate_upstream(WriteKind::Create, body) + .unwrap_or_else(|error| panic!("the body is refused: {error}")); +} + +#[test] +fn a_valid_upstream_body_is_accepted() { + accepted(&VALIDATOR, &upstream_body()); +} + +#[test] +fn a_valid_route_body_is_accepted() { + VALIDATOR + .validate_route(WriteKind::Create, &route_body()) + .unwrap_or_else(|error| panic!("the body is refused: {error}")); +} + +#[test] +fn a_missing_required_property_is_named_per_resource_kind_and_method() { + upstream_refused(&without(&upstream_body(), "server"), "server is required"); + upstream_refused( + &without(&upstream_body(), "protocol"), + "protocol is required", + ); + route_refused(&without(&route_body(), "upstream_id"), "upstream_id is required"); + route_refused(&without(&route_body(), "match"), "match is required"); +} + +#[test] +fn an_unknown_property_is_named_at_the_root_and_in_each_sub_object() { + let cases: Vec<(Value, &str)> = vec![ + ( + json!({ "zzz": 1, "server": { "endpoints": [] }, "protocol": HTTP_PROTOCOL }), + "unknown property 'zzz' at root", + ), + ( + json!({ "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }], "zzz": 1 }, "protocol": HTTP_PROTOCOL }), + "unknown property 'zzz' at server", + ), + ( + json!({ "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com", "zzz": 1 }] }, "protocol": HTTP_PROTOCOL }), + "unknown property 'zzz' at server.endpoints[0]", + ), + ( + json!({ "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }] }, "protocol": HTTP_PROTOCOL, "headers": { "request": { "zzz": 1 } } }), + "unknown property 'zzz' at headers.request", + ), + ( + json!({ "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }] }, "protocol": HTTP_PROTOCOL, "rate_limit": { "sustained": { "rate": 1 }, "zzz": 1 } }), + "unknown property 'zzz' at rate_limit", + ), + ( + json!({ "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }] }, "protocol": HTTP_PROTOCOL, "cors": { "enabled": true, "zzz": 1 } }), + "unknown property 'zzz' at cors", + ), + ( + json!({ "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }] }, "protocol": HTTP_PROTOCOL, "match": { "http": { "methods": ["GET"], "path": "/v1" } } }), + "unknown property 'match' at root", + ), + ]; + for (body, expected) in cases { + upstream_refused(&body, expected); + } + + route_refused( + &json!({ + "upstream_id": "00000000-0000-0000-0000-000000000001", + "match": { "http": { "methods": ["GET"], "path": "/v1/chat", "zzz": 1 } }, + "priority": 1 + }), + "unknown property 'zzz' at match.http", + ); + route_refused( + &json!({ + "upstream_id": "00000000-0000-0000-0000-000000000001", + "match": { "http": { "methods": ["GET"], "path": "/v1/chat" } }, + "priority": 1, + "grpc_match": {} + }), + "unknown property 'grpc_match' at root", + ); +} + +#[test] +fn the_plugin_and_auth_objects_stay_open() { + accepted( + &VALIDATOR, + &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }] }, + "protocol": HTTP_PROTOCOL, + "plugins": { "shipped": ["gts.cf.core.oagw.transform_plugin.v1~x.v1"] }, + "auth": { "type": AUTH_PLUGIN_TYPE, "config": { "header": "x-api-key" } } + }), + ); +} + +#[test] +fn an_endpoint_without_a_scheme_or_a_host_is_refused() { + upstream_refused( + &json!({ + "server": { "endpoints": [{ "host": "api.openai.com" }] }, + "protocol": HTTP_PROTOCOL + }), + "server.endpoints[0].scheme is required", + ); + upstream_refused( + &json!({ + "server": { "endpoints": [{ "scheme": "https" }] }, + "protocol": HTTP_PROTOCOL + }), + "server.endpoints[0].host is required", + ); +} + +#[test] +fn an_endpoint_host_that_is_neither_a_name_nor_an_ip_is_refused() { + upstream_refused( + &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "not a host" }] }, + "protocol": HTTP_PROTOCOL + }), + "server.endpoints[0].host", + ); +} + +#[test] +fn an_endpoint_port_outside_the_range_is_refused() { + for port in [0, 65_536] { + upstream_refused( + &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com", "port": port }] }, + "protocol": HTTP_PROTOCOL + }), + "server.endpoints[0].port", + ); + } +} + +#[test] +fn an_omitted_endpoint_port_defaults_to_443() { + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }] }, + "protocol": HTTP_PROTOCOL + }); + let validated = VALIDATOR + .validate_upstream(WriteKind::Create, &body) + .expect("the body is admitted"); + assert_eq!(validated.value.server.endpoints[0].port, Some(443)); +} + +#[test] +fn the_http_scheme_is_refused_while_the_posture_is_https_only() { + let body = json!({ + "server": { "endpoints": [{ "scheme": "http", "host": "api.openai.com" }] }, + "protocol": HTTP_PROTOCOL + }); + upstream_refused(&body, "server.endpoints[0].scheme"); +} + +#[test] +fn the_http_scheme_is_admitted_when_the_posture_is_lifted() { + let body = json!({ + "server": { "endpoints": [{ "scheme": "http", "host": "api.openai.com", "port": 80 }] }, + "protocol": HTTP_PROTOCOL + }); + accepted(&HTTP_ALLOWED, &body); +} + +#[test] +fn a_mixed_scheme_pool_is_refused() { + let body = json!({ + "server": { "endpoints": [ + { "scheme": "https", "host": "api.openai.com", "port": 443 }, + { "scheme": "wss", "host": "api.openai.com", "port": 443 } + ] }, + "protocol": HTTP_PROTOCOL + }); + upstream_refused(&body, "server.endpoints[1].scheme"); +} + +#[test] +fn a_mixed_port_pool_is_refused() { + let body = json!({ + "server": { "endpoints": [ + { "scheme": "https", "host": "api.openai.com", "port": 443 }, + { "scheme": "https", "host": "eu.openai.com", "port": 8443 } + ] }, + "protocol": HTTP_PROTOCOL + }); + upstream_refused(&body, "server.endpoints[1].port"); +} + +#[test] +fn a_protocol_outside_the_two_value_enum_is_refused() { + upstream_refused( + &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }] }, + "protocol": "cf.core.oagw.http.v2" + }), + "protocol", + ); +} + +#[test] +fn a_sharing_value_outside_the_enum_is_refused() { + upstream_refused( + &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }] }, + "protocol": HTTP_PROTOCOL, + "rate_limit": { "sharing": "public", "sustained": { "rate": 1 } } + }), + "rate_limit.sharing", + ); +} + +#[test] +fn a_match_with_both_or_neither_branch_is_refused() { + route_refused( + &json!({ + "upstream_id": "00000000-0000-0000-0000-000000000001", + "match": { "http": { "methods": ["GET"], "path": "/v1" }, "grpc": { "service": "s", "method": "m" } }, + "priority": 1 + }), + "match", + ); + route_refused( + &json!({ + "upstream_id": "00000000-0000-0000-0000-000000000001", + "match": {}, + "priority": 1 + }), + "match", + ); +} + +#[test] +fn an_http_match_without_methods_or_path_is_refused() { + route_refused( + &json!({ + "upstream_id": "00000000-0000-0000-0000-000000000001", + "match": { "http": { "path": "/v1" } }, + "priority": 1 + }), + "match.http.methods is required", + ); + route_refused( + &json!({ + "upstream_id": "00000000-0000-0000-0000-000000000001", + "match": { "http": { "methods": ["GET"] } }, + "priority": 1 + }), + "match.http.path is required", + ); +} + +#[test] +fn an_http_method_outside_the_enum_is_refused() { + route_refused( + &json!({ + "upstream_id": "00000000-0000-0000-0000-000000000001", + "match": { "http": { "methods": ["TRACE"], "path": "/v1" } }, + "priority": 1 + }), + "match.http.methods", + ); +} + +#[test] +fn a_grpc_match_without_service_or_method_is_refused() { + route_refused( + &json!({ + "upstream_id": "00000000-0000-0000-0000-000000000001", + "match": { "grpc": { "method": "GetUser" } }, + "priority": 1 + }), + "match.grpc.service is required", + ); + route_refused( + &json!({ + "upstream_id": "00000000-0000-0000-0000-000000000001", + "match": { "grpc": { "service": "foo.v1.UserService" } }, + "priority": 1 + }), + "match.grpc.method is required", + ); +} + +#[test] +fn a_route_without_a_priority_is_refused() { + route_refused( + &json!({ + "upstream_id": "00000000-0000-0000-0000-000000000001", + "match": { "http": { "methods": ["GET"], "path": "/v1" } } + }), + "priority", + ); +} + +#[test] +fn a_rate_limit_without_sustained_is_refused() { + upstream_refused( + &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }] }, + "protocol": HTTP_PROTOCOL, + "rate_limit": { "sharing": "private" } + }), + "rate_limit.sustained", + ); +} + +#[test] +fn a_sustained_rate_below_one_is_refused() { + upstream_refused( + &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }] }, + "protocol": HTTP_PROTOCOL, + "rate_limit": { "sustained": { "rate": 0 } } + }), + "rate_limit.sustained.rate", + ); +} + +#[test] +fn a_window_outside_the_enum_is_refused() { + upstream_refused( + &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }] }, + "protocol": HTTP_PROTOCOL, + "rate_limit": { "sustained": { "rate": 1, "window": "week" } } + }), + "rate_limit.sustained.window", + ); +} + +#[test] +fn a_burst_capacity_below_one_is_refused() { + upstream_refused( + &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }] }, + "protocol": HTTP_PROTOCOL, + "rate_limit": { "sustained": { "rate": 1 }, "burst": { "capacity": 0 } } + }), + "rate_limit.burst.capacity", + ); +} + +#[test] +fn a_cost_below_one_is_refused() { + upstream_refused( + &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }] }, + "protocol": HTTP_PROTOCOL, + "rate_limit": { "sustained": { "rate": 1 }, "cost": 0 } + }), + "rate_limit.cost", + ); +} + +#[test] +fn a_cors_object_without_enabled_is_refused() { + upstream_refused( + &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }] }, + "protocol": HTTP_PROTOCOL, + "cors": { "allowed_origins": ["https://console.vendor.com"] } + }), + "cors.enabled", + ); +} + +#[test] +fn credentials_with_a_wildcard_origin_are_refused() { + upstream_refused( + &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }] }, + "protocol": HTTP_PROTOCOL, + "cors": { "enabled": true, "allow_credentials": true, "allowed_origins": ["*"] } + }), + "cors.allowed_origins", + ); +} + +#[test] +fn an_origin_without_a_uri_scheme_is_refused() { + upstream_refused( + &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }] }, + "protocol": HTTP_PROTOCOL, + "cors": { "enabled": true, "allowed_origins": ["console.vendor.com"] } + }), + "cors.allowed_origins[0]", + ); +} + +#[test] +fn an_allowed_method_outside_the_enum_is_refused() { + upstream_refused( + &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }] }, + "protocol": HTTP_PROTOCOL, + "cors": { "enabled": true, "allowed_methods": ["TRACE"] } + }), + "cors.allowed_methods[0]", + ); +} + +#[test] +fn a_route_level_cors_object_is_validated_with_the_upstream_shape() { + let refused = json!({ + "upstream_id": "00000000-0000-0000-0000-000000000001", + "match": { "http": { "methods": ["GET"], "path": "/v1" } }, + "priority": 1, + "cors": { "allowed_origins": ["console.vendor.com"] } + }); + route_refused(&refused, "cors.enabled"); + + let admitted = json!({ + "upstream_id": "00000000-0000-0000-0000-000000000001", + "match": { "http": { "methods": ["GET"], "path": "/v1" } }, + "priority": 1, + "cors": { "enabled": true, "allowed_origins": ["https://console.vendor.com"] } + }); + VALIDATOR + .validate_route(WriteKind::Create, &admitted) + .unwrap_or_else(|error| panic!("the body is refused: {error}")); +} + +#[test] +fn a_tag_outside_the_pattern_is_refused() { + upstream_refused( + &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }] }, + "protocol": HTTP_PROTOCOL, + "tags": ["Bad Tag"] + }), + "tags[0]", + ); +} + +#[test] +fn a_credential_reference_without_the_cred_scheme_is_refused() { + upstream_refused( + &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }] }, + "protocol": HTTP_PROTOCOL, + "auth": { "config": { "secret_ref": "vault://prod/key" } } + }), + "auth.config.secret_ref", + ); +} + +#[test] +fn a_credential_reference_with_the_cred_scheme_is_admitted() { + accepted( + &VALIDATOR, + &json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }] }, + "protocol": HTTP_PROTOCOL, + "auth": { "config": { "secret_ref": "cred://prod/key" } } + }), + ); +} + +#[test] +fn two_defects_produce_one_error_naming_both_properties() { + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }] }, + "protocol": "cf.core.oagw.http.v2", + "tags": ["Bad Tag"] + }); + let error = VALIDATOR + .validate_upstream(WriteKind::Create, &body) + .expect_err("the body is refused"); + let detail = refused(&error); + assert!(detail.contains("protocol"), "{detail}"); + assert!(detail.contains("tags[0]"), "{detail}"); + assert!(detail.contains(','), "{detail}"); +} + +#[test] +fn the_detail_never_echoes_a_request_body_value() { + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }] }, + "protocol": SECRET, + "auth": { "config": { "api_key": SECRET } }, + "unexpected_property": SECRET + }); + let error = VALIDATOR + .validate_upstream(WriteKind::Create, &body) + .expect_err("the body is refused"); + let detail = refused(&error); + assert!(!detail.contains(SECRET), "the detail echoed a value: {detail}"); + assert!(detail.contains("protocol"), "{detail}"); + assert!( + detail.contains("unknown property 'unexpected_property' at root"), + "{detail}" + ); +} + +#[test] +fn a_body_that_is_not_an_object_is_refused() { + upstream_refused(&json!([1, 2, 3]), "root"); +} + +#[test] +fn a_body_that_cannot_deserialize_names_a_property() { + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }] }, + "protocol": HTTP_PROTOCOL, + "enabled": "yes" + }); + let error = VALIDATOR + .validate_upstream(WriteKind::Create, &body) + .expect_err("the body is refused"); + assert_eq!(error.kind, ErrorKind::ValidationError); + assert!(!error.detail.contains(SECRET), "{error}"); +} + +#[test] +fn a_route_replacement_does_not_require_upstream_id() { + let body = json!({ + "match": { "http": { "methods": ["GET"], "path": "/v1/chat" } }, + "priority": 2 + }); + let validated = VALIDATOR + .validate_route(WriteKind::Replacement, &body) + .expect("the replacement is admitted"); + assert!(validated.value.upstream_id.is_nil(), "no upstream reference"); + assert_eq!(validated.value.priority, Some(2)); +} + +#[test] +fn a_route_replacement_rejects_a_supplied_upstream_id() { + let body = json!({ + "upstream_id": "00000000-0000-0000-0000-000000000001", + "match": { "http": { "methods": ["GET"], "path": "/v1/chat" } }, + "priority": 2 + }); + let error = VALIDATOR + .validate_route(WriteKind::Replacement, &body) + .expect_err("the replacement is refused"); + assert!( + refused(&error).contains("unknown property 'upstream_id' at root"), + "{error}" + ); +} + +#[test] +fn a_route_replacement_still_requires_match() { + let body = json!({ "priority": 2 }); + let error = VALIDATOR + .validate_route(WriteKind::Replacement, &body) + .expect_err("the replacement is refused"); + let detail = refused(&error); + assert!(detail.contains("match is required"), "{detail}"); + assert!( + !detail.contains("upstream_id is required"), + "the replacement required set was not narrowed: {detail}" + ); +} + +#[test] +fn a_route_body_may_carry_priority_enabled_and_cors() { + let body = json!({ + "upstream_id": "00000000-0000-0000-0000-000000000001", + "match": { "http": { "methods": ["GET"], "path": "/v1/chat" } }, + "priority": 3, + "enabled": false, + "cors": { "enabled": true, "allowed_origins": ["https://console.vendor.com"] } + }); + let validated = VALIDATOR + .validate_route(WriteKind::Create, &body) + .expect("the §1.5 route properties are admitted"); + assert_eq!(validated.value.enabled, Some(false)); + assert_eq!(validated.value.priority, Some(3)); + assert!(validated.value.cors.is_some()); +} + +#[test] +fn a_create_rejects_a_supplied_id_and_tenant_id() { + let body = json!({ + "id": "00000000-0000-0000-0000-000000000009", + "tenant_id": "00000000-0000-0000-0000-000000000002", + "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }] }, + "protocol": HTTP_PROTOCOL + }); + let error = VALIDATOR + .validate_upstream(WriteKind::Create, &body) + .expect_err("the create is refused"); + let detail = refused(&error); + assert!(detail.contains("id"), "{detail}"); + assert!(detail.contains("tenant_id"), "{detail}"); +} + +#[test] +fn a_replacement_may_carry_its_own_id() { + let body = json!({ + "id": "00000000-0000-0000-0000-000000000009", + "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }] }, + "protocol": HTTP_PROTOCOL + }); + let validated = VALIDATOR + .validate_upstream(WriteKind::Replacement, &body) + .expect("the replacement carries its own identifier"); + assert_eq!( + validated.stated_id, + Some(uuid::Uuid::from_u128(9)), + "the stated identifier is carried for the diff" + ); +} + +#[test] +fn a_replacement_defaults_enabled_when_the_body_omits_it() { + let body = json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }] }, + "protocol": HTTP_PROTOCOL + }); + let created = VALIDATOR + .validate_upstream(WriteKind::Create, &body) + .expect("created"); + assert!(created.value.enabled, "a create defaults enabled to true"); + + let replaced = VALIDATOR + .validate_route(WriteKind::Replacement, &json!({ + "match": { "http": { "methods": ["GET"], "path": "/v1" } }, + "priority": 1 + })) + .expect("replaced"); + assert_eq!( + replaced.value.enabled, None, + "a replacement carries the stored value forward" + ); +}