diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md new file mode 100644 index 000000000..d1f1277c4 --- /dev/null +++ b/.superpowers/sdd/progress.md @@ -0,0 +1,177 @@ +# Phase 1 (EdgeZero store-registry migration) — progress ledger + +Plan: docs/superpowers/plans/2026-07-02-edgezero-store-registry-migration.md +Pin: edgezero @ ff530286 (PR #306 branch head, --locked). D6-a confirmed (keep composite write path). +Branch: worktree-edgezero-migration-spec + +## 2026-07-08 merge iabtechlab/main (commit 76662d364) +- Synced main into branch. Conflicts: Cargo.toml/Cargo.lock only (config.rs clean — + main #744 didn't touch it, `fn secret_fields()` compat fix intact). +- Resolution: KEEP PR #306 edgezero pin (branch, not main's tag=v0.0.4 — v0.0.4 lacks + State/nested secrets). Branch head advanced d8f71a4a → ff530286. +- Verified green: core + axum + cloudflare(wasm) + spin + fastly(wasm) + cli, --locked. +- MATERIAL: main #744 REMOVED legacy_main/route_request. Phase-5 legacy delete already + done upstream. Invalidates Task 8's Phase 1/5 boundary justification (see BLOCKERS H3). + +## Blocking plan amendments before Task 2 resumes (from 2-agent deep review) +- H1 (config store id): store *id* is `trusted_server_config` (fastly main.rs:45, + fastly.toml:65/67, generate-viceroy-config.rs:157/159/250/253, viceroy-template.toml:71, + cloudflare.toml:15) — NOT `app_config`. Blob *key* is app_config. "Keep app_config" + decision requires reconciling these store-id sites (rename → app_config, OR reverse + core DEFAULT_CONFIG_STORE_ID → trusted_server_config). DECISION NEEDED. +- H2 (phantom defaults): edgezero.toml kv default `trusted_server_kv` + secret default + `trusted_server_secrets` are unreferenced/unprovisioned → from_parts returns None → + whole KV/secret registry dropped on Fastly → named-KV never works. Pick real defaults + (kv=ec_identity_store, secret=signing_keys) or provision them. +- H2b (eager open): build_kv_registry opens EVERY declared id; creative_store is + deprecated/never-opened; converts lazy-fail-closed → eager-fail-all. Make per-store + open non-fatal, or exclude never-read ids. +- H(ec): EC identity graph bypasses registry (FastlyEcKvStore direct open, main.rs:1378/ + 1449). Plan's "ec/* already use kv_handle()" is FALSE. Migrate to kv_handle_named or + explicitly scope out. +- H3 (legacy gone): re-ground Task 8 on real Fastly read callers + (load_settings_from_config_store boot, build_per_request_services fallback, mgmt-API + writes) — drop legacy_main/platform.rs:578 references. +- Med: CF config side-channel var vs KV-namespace contradiction; consent fail-closed now + fires all 4 adapters (only Fastly tested); pervasive line/symbol drift (locate by + symbol not line); clippy-cloudflare-wasm missing from plan CI gate. +- Review outputs: tasks/a33b466a7f03d14e3.output (executability), a392eb23beb2ec22b.output + (coverage). + +## Tasks +- Task 1: COMPLETE — preflight green, D6-a confirmed, D5 map recorded. +- Task 2: COMPLETE. + - part 1 (0513377e9): H1 store-name/blob-key decouple + request-signing ids. + - part 2 (7da58df8c): edgezero.toml full id lists; provision trusted_server_kv/ + trusted_server_secrets + s3-auth in fastly.toml/viceroy-template.toml/spin.toml; + shared STORES_METADATA const in core/src/stores.rs feeding all 4 Hooks::stores(); + anti-drift test (RED verified). CF: decision C (cloudflare.toml left vestigial; + per-id wrangler.toml bindings deferred to Phase 2). All adapters + parity green. +- Plan amended for post-merge state + 2-agent review (c22fcd045). +- Task 3: COMPLETE (bfde7780a) — write-only PlatformConfigWriter/PlatformSecretWriter + split; CompositeConfigStore/CompositeSecretStore (Option reader, strict: + absent registry AND unknown id both hard-error; writes delegate preserving StoreId); + StoreName doc → logical read id + read-site audit. Additive to core/platform only. + 4 composite tests RED→GREEN; all 4 adapters check + fmt green. +- Task 4: COMPLETE (11373ea89) — get_settings_from_config_store re-typed to + (&ConfigStoreHandle, key); Fastly reuses open_trusted_server_config_store(); + Axum uses path-env-pointer (TRUSTED_SERVER_AXUM_CONFIG_PATH → tempdir file, else + from_local_file("trusted_server_config")); axum.rs harness updated; parity 13/0. + CF/Spin untouched (don't call the loader). +- Defect fixes (45d590c7b), both pre-existing (Task 2/3), caught by Task 4: + * EdgeZero manifest validator REJECTS hyphenated store ids ([A-Za-z0-9_] only, + for EDGEZERO__STORES__ env exportability). s3-auth + datadome-ip-bypass removed + from edgezero.toml + STORES_METADATA (Spin manifest test was failing). They are + NOT yet routed through the registry, so removal is safe now. + * doc_markdown clippy -D warnings (EdgeZero/DataDome un-backticked) in + composite/traits/stores — fixed. + +## 2026-07-09 operator decision — s3-auth/datadome-ip-bypass (RESOLVED, was deferred below) +- CONVERGE on underscore logical ids (Option A). Both reads route through RuntimeServices + (proxy.rs:829 secret_store; datadome protection_scope.rs:347 config_store) → the composite + in Task 5b, and EdgeZero forbids hyphenated registry ids. So: s3-auth → s3_auth, + datadome-ip-bypass → datadome_ip_bypass EVERYWHERE (code defaults, manifests' physical + store names, example/fixtures, user docs, tests) + re-declare as registry ids. Under D7 + logical id == physical store name; operators with hyphenated physical stores use + EDGEZERO__STORES______NAME (documented, not implemented here). +- Task 5 SPLIT: 5a = the convergence rename; 5b = composite wiring + named-KV. +- Task 5a: COMPLETE (33ff05884) — s3-auth→s3_auth, datadome-ip-bypass→datadome_ip_bypass + everywhere (code, manifests' physical store names, example/fixtures, 3 user guides w/ + __NAME contract note); re-declared in edgezero.toml + STORES_METADATA. Zero stray hyphens. + core 1630, spin manifest PASS, parity 13/0, clippy/fmt clean. +## Task 6: COMPLETE (1be632f5b) — ALL FOUR ADAPTERS NOW ON THE EDGEZERO REGISTRY +- Fastly registries.rs (KV/config/secret builders, H2b non-fatal per-store open), injected into the + custom oneshot path; Fastly writer impls + composite-backed build_per_request_services + kv_registry. +- Core `resolve_consent_kv` fail-closed guard replaces the Fastly-only + `runtime_services_for_consent_route` (deleted, with open_kv_store + its 3 call sites). Guard applied to + auction + publisher + page-bids (the wrapper covered all three; the 503 gate asserts the publisher + fallback). BOTH mandatory 503 gates still pass. Now fail-closed on all four adapters. +- Fastly 106/106; parity 13/13; all six clippy targets + wasm + fmt clean. +- KEY FIX (Option B): edgezero's Fastly `ConfigStore::get` is ENVELOPE-ONLY (rejects non-BlobEnvelope + values), while Axum/CF are plain passthroughs. TS's non-default config stores hold PLAIN values + (jwks_store, datadome_ip_bypass) → binding them to it would 500 discovery/verify/rotation/DataDome. + So: default id → edgezero chunk-aware store; non-default ids → local plain store over + fastly::ConfigStore::try_get. Upstream asymmetry FILED: https://github.com/stackpop/edgezero/issues/324 +- KEY FIX: Fastly runs the whole request inside futures::executor::block_on, so the composite's inner + block_on aborted (EnterError). `resolve_store_future` now polls once with a noop waker, falling back to + block_on. Load-bearing assumption documented (Fastly stores are sync under an async signature). + +## Task 8 premise is STALE (do not trust the plan there) +- Plan says "keep Fastly read impls until Phase 5 because legacy_main is live". main #744 DELETED + legacy_main; there is no legacy_main / build_runtime_services in adapter-fastly. Fastly IS in scope + for the write-only conversion. KNOWN exception to preserve: management_api.rs:130 reads the Fastly + api-keys secret (a management store deliberately outside the registry; hyphenated id anyway). + +## Open follow-ups (explicitly deferred, NOT bugs introduced here) +- Consent KV persistence is DORMANT (ec/mod.rs passes kv_store: None) — enabling it is a FEATURE + decision, deliberately not made during a behavior-preserving migration. Plumbing now exists. +- EC identity graph still opens FastlyEcKvStore directly (bypasses the registry) — scoped out. +- Remove the consent TTL clamp when edgezero#323 ships. +- Doc note owed: Axum dev secrets now resolve via EnvSecretStore (bare KEY env var) rather than + TRUSTED_SERVER_SECRET_{STORE}_{KEY}. + +## 2026-07-13 Task 5b findings (both VERIFIED) + operator decision +- BLOCKER (found by 5b implementer): edgezero `KvHandle::put_bytes_with_ttl` validates against + `MAX_TTL = 365d` (key_value_store.rs:378), but TS `MAX_CONSENT_AGE_DAYS = 395` (consent_config.rs:10, + the IAB 13-month norm). Consent MUST go through KvHandle (KvRegistry::named only yields KvHandle), + so migrating consent → KvHandle would fail TTL validation, and save_consent_to_kv SWALLOWS KV + failures → consent silently never persists. On Task 6's critical path too; cannot be dodged. + DECISION: clamp consent KV TTL to edgezero's public MAX_TTL + warn log (interim, fail-safe: + consent expires ~1mo earlier → earlier re-prompt, still IAB-compliant). MAX_CONSENT_AGE_DAYS + stays 395. Upstream ask FILED: https://github.com/stackpop/edgezero/issues/323 (raise or + parameterize MAX_TTL; at minimum make an over-cap write a typed error that can't be swallowed). + REMOVE the clamp once #323 ships and the pin is bumped. +- FINDING for Task 6: the plan's `publisher.rs:626` consent call site DOES NOT EXIST. The only + production `build_consent_context` caller is `ec/mod.rs:216`, passing `kv_store: None` — consent + KV persistence is DORMANT in the shared path. Task 6 must RE-ESTABLISH the consent KV call site, + not "flip" it. (All other build_consent_context refs are tests/docs.) + +- Task 5b: dispatched — Axum registries via PUBLIC edgezero constructors (AxumConfigStore + + PersistentKvStore::new, TS-chosen redb paths, no private replication/parity test since TS + supplies the whole registry via with_kv_registry → authoritative); keep AxumDevServer:: + with_config for PORT. Core kv_handle_named + kv_registry field; consent → KvHandle + (ConsentPipelineInput + persistence fns + all consumers atomically); writer trait impls for + Axum/CF/Spin. Fastly NOT touched (publisher consent stays on kv_handle() until Task 6). + +## (superseded) DEFERRED decision — config-contract +- s3-auth (secret; settings.rs:655 default_s3_secret_store) and datadome-ip-bypass + (config; datadome/protection_scope.rs:165) are REAL store-name defaults with hyphens. + When their reads move onto the composite (registry.named(config_value)), the value + must be a valid underscore logical id. Options: (a) change operator-facing defaults + to s3_auth / datadome_ip_bypass (+ physical-name mapping via EDGEZERO__STORES__…__NAME), + or (b) keep these reads off the strict registry. Decide when Task 5/6 wires them. + Default-path tests won't trip it (DataDome IP-CIDR sources + S3 default-empty/disabled). + +## 2026-07-07 operator decision (mid-Task-2) — SUPERSEDED 2026-07-08 +- (Was: KEEP app_config store id.) Rested on false premise that manifests/generator + already use app_config. Deep review proved store *id* is trusted_server_config in + Fastly/manifests; only core default + blob key are app_config. See below. + +## 2026-07-08 operator decision (supersedes above) — H1 resolution +- UNIFY config-store id on `trusted_server_config`. Manifests/generator already use it, + so they stay UNTOUCHED (generate-viceroy-config.rs, fastly.toml, viceroy-template.toml, + cloudflare.toml unchanged). Change is: core DEFAULT_CONFIG_STORE_ID (settings_data.rs) + app_config → trusted_server_config, and edgezero.toml [stores.config] default = + trusted_server_config. Blob KEY (config_payload.rs CONFIG_BLOB_KEY) is orthogonal + (key within the store, not a store id) — verify interaction before touching. +- Spec's D5/Phase1/R9 `trusted_server_config` wording is CORRECT (not residue); keep. +- Request-signing store ids: fix example+fixture to jwks_store/signing_keys (2 lines). + +## 2026-07-08 operator decision — H2 resolution (KV/secret defaults) +- PROVISION real default stores: add `trusted_server_kv` + `trusted_server_secrets` + as real stores in EVERY adapter manifest (fastly.toml, viceroy-template.toml, + wrangler.toml, spin.toml). trusted_server_kv = general-purpose TS KV (plan intent). + No edgezero change. edgezero.toml already declares them as defaults (no change there). +- Still owed in Task 6: make build_kv_registry per-store open non-fatal (H2b) so a + single unprovisioned/deprecated id (creative_store) can't fail all traffic. + +## Finalized Task 2 scope (supersedes plan Task 2 where they differ) +1. settings_data.rs: DECOUPLE store-name from blob-key. default_config_store_name() + → "trusted_server_config"; default_config_key() must stay "app_config" + (= CONFIG_BLOB_KEY). Update the 4 test StoreName::from("app_config") sites to match + whatever the read path now asserts. Do NOT touch CONFIG_BLOB_KEY, manifests, generator. +2. Provision trusted_server_kv + trusted_server_secrets in all 4 adapter manifests. +3. Hooks::stores() per adapter returns the declared StoresMetadata (const literals). +4. request-signing example+fixture → jwks_store/signing_keys (2 lines). +GUARDRAIL: this is NOT a broad rename. Only settings_data.rs store-NAME changes to +trusted_server_config. The blob KEY and all config_payload/CONFIG_BLOB_KEY stay app_config. diff --git a/Cargo.lock b/Cargo.lock index 311597aae..cafe2a601 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5519,6 +5519,7 @@ dependencies = [ name = "trusted-server-integration-tests" version = "0.1.0" dependencies = [ + "async-trait", "axum", "bytes", "derive_more", @@ -5526,6 +5527,7 @@ dependencies = [ "edgezero-core", "env_logger", "error-stack", + "futures", "http", "http-body-util", "libc", diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 38776eb95..5570ef8e9 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -1,7 +1,7 @@ use core::future::Future; use std::sync::Arc; -use edgezero_core::app::Hooks; +use edgezero_core::app::{Hooks, StoresMetadata}; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{ @@ -14,6 +14,7 @@ use trusted_server_core::auction::{ AuctionOrchestrator, build_orchestrator_with_plan, compile_auction_plan, }; use trusted_server_core::cache_policy::EdgeCacheHeader; +use trusted_server_core::config_payload::CONFIG_BLOB_KEY; use trusted_server_core::ec::EcContext; use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, @@ -33,14 +34,23 @@ use trusted_server_core::request_signing::{ handle_trusted_server_discovery, handle_verify_signature, }; use trusted_server_core::settings::Settings; -use trusted_server_core::settings_data::{ - default_config_key, default_config_store_name, get_settings_from_config_store, -}; +use trusted_server_core::settings_data::get_settings_from_config_store; +use edgezero_adapter_axum::config_store::AxumConfigStore; +use edgezero_core::config_store::ConfigStoreHandle; use trusted_server_core::platform::RuntimeServices; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, SanitizeRequestMiddleware}; -use crate::platform::{AxumPlatformConfigStore, AxumPlatformSecretStore, build_runtime_services}; +use crate::platform::build_runtime_services; + +/// Logical id of the `EdgeZero` config store holding the Trusted Server +/// app-config blob. Resolves to `.edgezero/local-config-trusted_server_config.json`. +const AXUM_CONFIG_STORE_ID: &str = "trusted_server_config"; + +/// Environment variable naming an explicit JSON config-store file to load +/// instead of the default `.edgezero/local-config-.json`. This is a +/// file-location pointer only — it does not carry any config value. +const AXUM_CONFIG_PATH_ENV: &str = "TRUSTED_SERVER_AXUM_CONFIG_PATH"; // --------------------------------------------------------------------------- // AppState @@ -60,18 +70,52 @@ pub struct AppState { /// Returns an error when settings, the auction orchestrator, or the integration /// registry fail to initialise. fn build_state() -> Result, Report> { - let store_name = default_config_store_name(); - let config_key = default_config_key(); - let settings = get_settings_from_config_store( - &AxumPlatformConfigStore, - &AxumPlatformSecretStore, - &store_name, - &config_key, + let config_store = open_config_store()?; + // Boot-time secret resolution reads through the same EdgeZero secret + // registry as request-time reads: locally, secrets are environment + // variables named exactly after the secret key. + let secret_store = trusted_server_core::platform::CompositeSecretStore::new( + crate::registries::build_secret_registry_axum( + &trusted_server_core::stores::STORES_METADATA, + ), + std::sync::Arc::new(crate::platform::AxumPlatformSecretStore), + ); + // Startup-only read: `routes()` runs during dev-server setup, outside any + // request handler, so driving the async boot read with a top-level + // `block_on` here never nests inside a request executor. + let settings = futures::executor::block_on(get_settings_from_config_store( + &config_store, + CONFIG_BLOB_KEY, + &secret_store, &trusted_server_core::settings_data::default_secret_store_name(), - )?; + ))?; build_state_with_settings(settings) } +/// Opens the `EdgeZero` Axum config store for the app-config blob. +/// +/// When [`AXUM_CONFIG_PATH_ENV`] is set, the store is read from that explicit +/// JSON file; otherwise it reads the default +/// `.edgezero/local-config-trusted_server_config.json`. The env var is a +/// file-location pointer only and never carries a config value. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::Configuration`] when the backing file exists +/// but cannot be read or parsed. +fn open_config_store() -> Result> { + let store = match std::env::var(AXUM_CONFIG_PATH_ENV) { + Ok(path) => AxumConfigStore::from_path(std::path::Path::new(&path)), + Err(_) => AxumConfigStore::from_local_file(AXUM_CONFIG_STORE_ID), + } + .map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!("failed to open Trusted Server config store: {error}"), + }) + })?; + Ok(ConfigStoreHandle::new(Arc::new(store))) +} + /// Build the application state from explicit settings. /// /// # Errors @@ -168,7 +212,11 @@ where /// jurisdiction stays Unknown there unless the request carries TCF consent). A /// malformed consent string is logged and falls back to the default /// (fail-closed) context rather than being silently swallowed. -fn build_ec_context(state: &AppState, services: &RuntimeServices, req: &Request) -> EcContext { +async fn build_ec_context( + state: &AppState, + services: &RuntimeServices, + req: &Request, +) -> EcContext { let geo_info = services .geo() .lookup(services.client_info().client_ip) @@ -177,6 +225,7 @@ fn build_ec_context(state: &AppState, services: &RuntimeServices, req: &Request) None }); EcContext::read_from_request_with_geo(&state.settings, req, services, geo_info.as_ref()) + .await .unwrap_or_else(|e| { log::warn!("EC context read failed: {e:?}"); EcContext::default() @@ -227,7 +276,7 @@ async fn dispatch_fallback( // Run the server-side auction with the configured creative-opportunity // slots; `handle_publisher_request` matches them against the request path. - let mut ec_context = build_ec_context(state, services, &req); + let mut ec_context = build_ec_context(state, services, &req).await; let auction = AuctionDispatch { orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), @@ -427,10 +476,10 @@ fn named_route_handler( move |state, services, req| async move { match handler { NamedRouteHandler::TrustedServerDiscovery => { - handle_trusted_server_discovery(&state.settings, &services, req) + handle_trusted_server_discovery(&state.settings, &services, req).await } NamedRouteHandler::VerifySignature => { - handle_verify_signature(&state.settings, &services, req) + handle_verify_signature(&state.settings, &services, req).await } NamedRouteHandler::AdminNotSupported => { // Config/secret-store writes are backed by read-only env vars on the @@ -463,7 +512,7 @@ fn named_route_handler( // Build the geo-aware EC context so the auction consent // gate sees the caller's jurisdiction — `EcContext::default()` // fails it closed for consented users. - let mut ec_context = build_ec_context(&state, &services, &req); + let mut ec_context = build_ec_context(&state, &services, &req).await; handle_auction( &state.settings, &state.orchestrator, @@ -482,7 +531,7 @@ fn named_route_handler( if req.method() == Method::OPTIONS { Ok(page_bids_preflight_denied()) } else { - let mut ec_context = build_ec_context(&state, &services, &req); + let mut ec_context = build_ec_context(&state, &services, &req).await; let auction = AuctionDispatch { orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), @@ -584,6 +633,10 @@ impl Hooks for TrustedServerApp { build_router(&state) } + + fn stores() -> StoresMetadata { + trusted_server_core::stores::STORES_METADATA + } } impl TrustedServerApp { diff --git a/crates/trusted-server-adapter-axum/src/lib.rs b/crates/trusted-server-adapter-axum/src/lib.rs index 2f15e566d..12ceb1fd2 100644 --- a/crates/trusted-server-adapter-axum/src/lib.rs +++ b/crates/trusted-server-adapter-axum/src/lib.rs @@ -10,3 +10,5 @@ pub mod app; pub mod middleware; /// Platform-trait implementations backed by env vars and `reqwest`. pub mod platform; +/// Per-request store registry builders for the dev server. +pub mod registries; diff --git a/crates/trusted-server-adapter-axum/src/main.rs b/crates/trusted-server-adapter-axum/src/main.rs index 960982176..081ecbcf4 100644 --- a/crates/trusted-server-adapter-axum/src/main.rs +++ b/crates/trusted-server-adapter-axum/src/main.rs @@ -1,6 +1,10 @@ use edgezero_adapter_axum::dev_server::{AxumDevServer, AxumDevServerConfig}; use edgezero_core::app::Hooks as _; use trusted_server_adapter_axum::app::TrustedServerApp; +use trusted_server_adapter_axum::registries::{ + build_config_registry_axum, build_kv_registry_axum, build_secret_registry_axum, +}; +use trusted_server_core::stores::STORES_METADATA; #[allow(clippy::print_stderr)] fn main() { @@ -21,7 +25,22 @@ fn main() { log::info!("Listening on http://{}", config.addr); let router = TrustedServerApp::routes(); - if let Err(err) = AxumDevServer::with_config(router, config).run() { + + // Keep AxumDevServer::with_config (preserves PORT/axum.toml) and attach the + // Trusted Server store registries so request-time reads resolve non-default + // logical store ids through the registry-backed composite stores. + let mut server = AxumDevServer::with_config(router, config); + if let Some(registry) = build_config_registry_axum(&STORES_METADATA) { + server = server.with_config_registry(registry); + } + if let Some(registry) = build_kv_registry_axum(&STORES_METADATA) { + server = server.with_kv_registry(registry); + } + if let Some(registry) = build_secret_registry_axum(&STORES_METADATA) { + server = server.with_secret_registry(registry); + } + + if let Err(err) = server.run() { log::error!("trusted-server-adapter-axum failed: {err}"); std::process::exit(1); } diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index 7dcdd53d8..572bfbd72 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -7,61 +7,27 @@ use std::time::Duration; use async_trait::async_trait; use edgezero_core::http::{HeaderMap, HeaderName, HeaderValue, header}; +use edgezero_core::store_registry::{ConfigRegistry, KvRegistry, SecretRegistry}; use error_stack::{Report, ResultExt as _}; use trusted_server_core::platform::{ - BackendNamingPolicy, ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, - PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, - PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, - RuntimeServices, StoreId, StoreName, + BackendNamingPolicy, ClientInfo, CompositeConfigStore, CompositeSecretStore, GeoInfo, + PlatformBackend, PlatformBackendSpec, PlatformConfigWriter, PlatformError, PlatformGeo, + PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, + PlatformSecretWriter, PlatformSelectResult, RuntimeServices, StoreId, }; // --------------------------------------------------------------------------- -// Env-var naming helpers +// PlatformConfigWriter // --------------------------------------------------------------------------- -/// Normalize a store name or key for use as an environment-variable segment. +/// Write-only config store for the Axum dev server. /// -/// Uppercases and replaces hyphens, dots, and spaces with underscores. -fn normalize_env_segment(s: &str) -> String { - s.to_uppercase().replace(['-', '.', ' '], "_") -} - -fn config_env_var(store_name: &str, key: &str) -> String { - format!( - "TRUSTED_SERVER_CONFIG_{}_{}", - normalize_env_segment(store_name), - normalize_env_segment(key), - ) -} - -fn secret_env_var(store_name: &str, key: &str) -> String { - format!( - "TRUSTED_SERVER_SECRET_{}_{}", - normalize_env_segment(store_name), - normalize_env_segment(key), - ) -} - -// --------------------------------------------------------------------------- -// PlatformConfigStore -// --------------------------------------------------------------------------- - -/// Environment-variable–backed config store for the Axum dev server. -/// -/// Reads from `TRUSTED_SERVER_CONFIG_{STORE}_{KEY}` (uppercased, hyphens→underscores). -/// Write operations are unsupported in local development. +/// Config reads resolve through the `EdgeZero` config registry behind +/// [`CompositeConfigStore`], so this type only supplies the composite's write +/// delegate. Writes are unsupported in local development and always error. pub struct AxumPlatformConfigStore; -impl PlatformConfigStore for AxumPlatformConfigStore { - fn get(&self, store_name: &StoreName, key: &str) -> Result> { - let var_name = config_env_var(store_name.as_ref(), key); - std::env::var(&var_name).map_err(|_| { - Report::new(PlatformError::ConfigStore).attach(format!( - "env var '{var_name}' not set — export it to supply this config value" - )) - }) - } - +impl PlatformConfigWriter for AxumPlatformConfigStore { fn put( &self, store_id: &StoreId, @@ -91,31 +57,17 @@ impl PlatformConfigStore for AxumPlatformConfigStore { } // --------------------------------------------------------------------------- -// PlatformSecretStore +// PlatformSecretWriter // --------------------------------------------------------------------------- -/// Environment-variable–backed secret store for the Axum dev server. +/// Write-only secret store for the Axum dev server. /// -/// Reads from `TRUSTED_SERVER_SECRET_{STORE}_{KEY}` as raw UTF-8 bytes. -/// Write operations are unsupported in local development. +/// Secret reads resolve through the `EdgeZero` secret registry behind +/// [`CompositeSecretStore`]. Writes are unsupported in local development and +/// always error. pub struct AxumPlatformSecretStore; -impl PlatformSecretStore for AxumPlatformSecretStore { - fn get_bytes( - &self, - store_name: &StoreName, - key: &str, - ) -> Result, Report> { - let var_name = secret_env_var(store_name.as_ref(), key); - std::env::var(&var_name) - .map(String::into_bytes) - .map_err(|_| { - Report::new(PlatformError::SecretStore).attach(format!( - "env var '{var_name}' not set — export it to supply this secret value" - )) - }) - } - +impl PlatformSecretWriter for AxumPlatformSecretStore { fn create( &self, store_id: &StoreId, @@ -539,29 +491,49 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R .map(|addr| addr.ip()); use trusted_server_core::platform::{ - PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformKvStore, PlatformSecretStore, + PlatformBackend, PlatformConfigWriter, PlatformGeo, PlatformKvStore, PlatformSecretWriter, }; // Stateless shims are promoted to process-wide statics so callers clone // an existing Arc instead of allocating a new one per request. - static CONFIG_STORE: std::sync::OnceLock> = + static CONFIG_WRITER: std::sync::OnceLock> = std::sync::OnceLock::new(); - static SECRET_STORE: std::sync::OnceLock> = + static SECRET_WRITER: std::sync::OnceLock> = std::sync::OnceLock::new(); static KV_STORE: std::sync::OnceLock> = std::sync::OnceLock::new(); static BACKEND: std::sync::OnceLock> = std::sync::OnceLock::new(); static GEO: std::sync::OnceLock> = std::sync::OnceLock::new(); + // Config/secret reads resolve through the whole registry (from request + // extensions, inserted by the AxumDevServer registry setters) so non-default + // logical ids resolve; writes delegate to the env-backed dev-server stores + // (which reject writes). An absent registry makes composite reads error + // rather than silently reading a default store. + let config_reader = ctx.request().extensions().get::().cloned(); + let secret_reader = ctx.request().extensions().get::().cloned(); + let kv_registry = ctx.request().extensions().get::().cloned(); + let config_writer = Arc::clone( + CONFIG_WRITER + .get_or_init(|| Arc::new(AxumPlatformConfigStore) as Arc), + ); + let secret_writer = Arc::clone( + SECRET_WRITER + .get_or_init(|| Arc::new(AxumPlatformSecretStore) as Arc), + ); + RuntimeServices::builder() - .config_store(Arc::clone(CONFIG_STORE.get_or_init(|| { - Arc::new(AxumPlatformConfigStore) as Arc - }))) - .secret_store(Arc::clone(SECRET_STORE.get_or_init(|| { - Arc::new(AxumPlatformSecretStore) as Arc - }))) + .config_store(Arc::new(CompositeConfigStore::new( + config_reader, + config_writer, + ))) + .secret_store(Arc::new(CompositeSecretStore::new( + secret_reader, + secret_writer, + ))) .kv_store(Arc::clone(KV_STORE.get_or_init(|| { Arc::new(trusted_server_core::platform::UnavailableKvStore) as Arc }))) + .kv_registry(kv_registry) .backend(Arc::clone(BACKEND.get_or_init(|| { Arc::new(AxumPlatformBackend) as Arc }))) @@ -586,12 +558,204 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R // Tests // --------------------------------------------------------------------------- +#[cfg(test)] +mod registry_test_support { + //! In-memory store doubles and a `RequestContext` builder that seeds the + //! three `EdgeZero` registries into request extensions, so adapter tests can + //! exercise non-default logical store resolution through the composite. + + use std::collections::{BTreeMap, HashMap}; + use std::sync::{Arc, Mutex}; + + use async_trait::async_trait; + // `bytes` is not a direct dependency of this crate; use the re-export from the + // `axum` dev-dependency so no new dependency edge (and Cargo.lock churn) is + // introduced for test-only code. + use axum::body::Bytes; + use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use edgezero_core::context::RequestContext; + use edgezero_core::http::request_builder; + use edgezero_core::key_value_store::{KvError, KvHandle, KvPage, KvStore}; + use edgezero_core::params::PathParams; + use edgezero_core::secret_store::{SecretError, SecretHandle, SecretStore}; + use edgezero_core::store_registry::{ + BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, + StoreRegistry, + }; + + /// In-memory [`ConfigStore`] double keyed by lookup key. + struct MemConfigStore { + data: HashMap, + } + + #[async_trait(?Send)] + impl ConfigStore for MemConfigStore { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + Ok(self.data.get(key).cloned()) + } + } + + /// In-memory [`SecretStore`] double keyed by `"{store_name}/{key}"`. + struct MemSecretStore { + data: HashMap, + } + + #[async_trait(?Send)] + impl SecretStore for MemSecretStore { + async fn get_bytes( + &self, + store_name: &str, + key: &str, + ) -> Result, SecretError> { + Ok(self.data.get(&format!("{store_name}/{key}")).cloned()) + } + } + + /// In-memory [`KvStore`] double. + #[derive(Default)] + struct MemKvStore { + data: Mutex>, + } + + #[async_trait(?Send)] + impl KvStore for MemKvStore { + async fn get_bytes(&self, key: &str) -> Result, KvError> { + Ok(self.data.lock().expect("should lock").get(key).cloned()) + } + + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + self.data + .lock() + .expect("should lock") + .insert(key.to_owned(), value); + Ok(()) + } + + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + _ttl: std::time::Duration, + ) -> Result<(), KvError> { + self.put_bytes(key, value).await + } + + async fn delete(&self, key: &str) -> Result<(), KvError> { + self.data.lock().expect("should lock").remove(key); + Ok(()) + } + + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Ok(KvPage::default()) + } + } + + /// Build a [`ConfigRegistry`] from `(store_id, key, value)` entries. + pub(super) fn config_registry(entries: &[(&str, &str, &str)], default: &str) -> ConfigRegistry { + let mut by_store: BTreeMap> = BTreeMap::new(); + for (id, key, value) in entries { + by_store + .entry((*id).to_owned()) + .or_default() + .insert((*key).to_owned(), (*value).to_owned()); + } + let by_id: BTreeMap = by_store + .into_iter() + .map(|(id, data)| { + let binding = ConfigStoreBinding { + default_key: id.clone(), + handle: ConfigStoreHandle::new(Arc::new(MemConfigStore { data })), + }; + (id, binding) + }) + .collect(); + StoreRegistry::from_parts(by_id, default.to_owned()) + .expect("should build a non-empty config registry") + } + + /// Build a [`SecretRegistry`] from `(store_id, key, value)` entries. + pub(super) fn secret_registry( + entries: &[(&str, &str, &[u8])], + default: &str, + ) -> SecretRegistry { + let mut data: HashMap = HashMap::new(); + let mut ids: BTreeMap = BTreeMap::new(); + for (id, key, value) in entries { + data.insert(format!("{id}/{key}"), Bytes::copy_from_slice(value)); + ids.insert((*id).to_owned(), ()); + } + let handle = SecretHandle::new(Arc::new(MemSecretStore { data })); + let by_id: BTreeMap = ids + .into_keys() + .map(|id| { + let bound = BoundSecretStore::new(handle.clone(), id.clone()); + (id, bound) + }) + .collect(); + StoreRegistry::from_parts(by_id, default.to_owned()) + .expect("should build a non-empty secret registry") + } + + /// Build a [`KvRegistry`] from `(store_id, key, value)` entries; each id maps + /// to its own in-memory store so distinct ids are observably distinct. + pub(super) fn kv_registry(entries: &[(&str, &str, &[u8])], default: &str) -> KvRegistry { + let mut by_store: BTreeMap> = BTreeMap::new(); + for (id, key, value) in entries { + let store = by_store.entry((*id).to_owned()).or_default(); + store + .data + .lock() + .expect("should lock") + .insert((*key).to_owned(), Bytes::copy_from_slice(value)); + } + let by_id: BTreeMap = by_store + .into_iter() + .map(|(id, store)| (id, KvHandle::new(store))) + .collect(); + StoreRegistry::from_parts(by_id, default.to_owned()) + .expect("should build a non-empty kv registry") + } + + /// Build a [`RequestContext`] with the three registries inserted into request + /// extensions, mirroring the dev server's registry wiring. + pub(super) fn test_context_with_registries( + config: Option, + kv: Option, + secrets: Option, + ) -> RequestContext { + let mut builder = request_builder().method("GET").uri("https://example.com/"); + if let Some(config) = config { + builder = builder.extension(config); + } + if let Some(kv) = kv { + builder = builder.extension(kv); + } + if let Some(secrets) = secrets { + builder = builder.extension(secrets); + } + let req = builder + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + RequestContext::new(req, PathParams::default()) + } +} + #[cfg(test)] mod tests { use super::*; use edgezero_core::body::Body as EdgeBody; use std::time::Duration; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + use trusted_server_core::platform::StoreName; + + use super::registry_test_support::{ + config_registry, kv_registry, secret_registry, test_context_with_registries, + }; #[test] fn auction_http_capabilities_are_explicit() { @@ -609,42 +773,159 @@ mod tests { } #[test] - fn config_store_reads_from_env_var() { - temp_env::with_var( - "TRUSTED_SERVER_CONFIG_MY_STORE_MY_KEY", - Some("test-value"), - || { - let store = AxumPlatformConfigStore; - let result = store - .get(&StoreName::from("my-store"), "my-key") - .expect("should read env var"); - assert_eq!(result, "test-value", "should return env var value"); - }, + fn config_store_resolves_nondefault_jwks_store() { + // Arrange: registry with the default config store plus a non-default + // `jwks_store` (D5: default config id is `trusted_server_config`). + let config = config_registry( + &[ + ("trusted_server_config", "current-kid", "kid-1"), + ("jwks_store", "kid-1", "{\"kty\":\"OKP\"}"), + ], + "trusted_server_config", + ); + let ctx = test_context_with_registries(Some(config), None, None); + let services = build_runtime_services(&ctx); + + // Act + Assert: the non-default config id resolves through the composite. + let jwk = futures::executor::block_on( + services + .config_store() + .get(&StoreName::from("jwks_store"), "kid-1"), + ) + .expect("should resolve the non-default jwks_store through the composite"); + assert_eq!( + jwk, "{\"kty\":\"OKP\"}", + "should read the seeded value from the non-default config store" + ); + + // Unknown id is a strict error, never a silent fallback. + assert!( + futures::executor::block_on( + services + .config_store() + .get(&StoreName::from("no_such_store"), "kid-1") + ) + .is_err(), + "unknown config id should error, not fall back to the default store" + ); + } + + #[test] + fn secret_store_resolves_nondefault_ts_secrets_and_s3_auth() { + // Arrange: registry with the default secret store plus non-default + // `ts_secrets` (DataDome) and `s3_auth` (S3 SigV4) ids. + let secrets = secret_registry( + &[ + ("trusted_server_secrets", "API_KEY", b"default-key"), + ("ts_secrets", "server-side-key", b"dd-secret"), + ("s3_auth", "aws-secret-access-key", b"s3-secret"), + ], + "trusted_server_secrets", + ); + let ctx = test_context_with_registries(None, None, Some(secrets)); + let services = build_runtime_services(&ctx); + + let dd = futures::executor::block_on( + services + .secret_store() + .get_bytes(&StoreName::from("ts_secrets"), "server-side-key"), + ) + .expect("should resolve ts_secrets through the composite"); + assert_eq!(dd, b"dd-secret", "should read the seeded DataDome secret"); + + let s3 = futures::executor::block_on( + services + .secret_store() + .get_bytes(&StoreName::from("s3_auth"), "aws-secret-access-key"), + ) + .expect("should resolve s3_auth through the composite"); + assert_eq!(s3, b"s3-secret", "should read the seeded S3 secret"); + + assert!( + futures::executor::block_on( + services + .secret_store() + .get_bytes(&StoreName::from("no_such_store"), "x") + ) + .is_err(), + "unknown secret id should error, not fall back to the default store" + ); + } + + #[tokio::test] + async fn kv_handle_named_resolves_consent_store() { + // Arrange: registry with the default KV store plus a non-default + // `consent_store`, each carrying a different value for the same key. + let kv = kv_registry( + &[ + ("trusted_server_kv", "marker", b"default-value"), + ("consent_store", "marker", b"consent-value"), + ], + "trusted_server_kv", + ); + let ctx = test_context_with_registries(None, Some(kv), None); + let services = build_runtime_services(&ctx); + + // The named store resolves and is distinct from the default request-path + // KV store (which is the dev server's unavailable store). + let handle = services + .kv_handle_named("consent_store") + .expect("should resolve the consent_store handle"); + let value = handle + .get_bytes("marker") + .await + .expect("should read from consent_store") + .expect("should find the seeded key"); + assert_eq!( + value.as_ref(), + b"consent-value", + "named lookup should read the consent_store value, not the default" + ); + assert!( + services.kv_handle().get_bytes("marker").await.is_err(), + "the default request-path KV store is distinct from consent_store" + ); + + // Unknown id yields None. + assert!( + services.kv_handle_named("no_such_store").is_none(), + "unknown KV id should resolve to None" ); } #[test] - fn config_store_returns_error_for_missing_env_var() { - let store = AxumPlatformConfigStore; - let result = store.get( - &StoreName::from("nonexistent-store-zzz"), - "nonexistent-key-zzz", + fn config_writer_rejects_writes_on_the_dev_server() { + let writer = AxumPlatformConfigStore; + + assert!( + writer + .put(&StoreId::from("jwks_store"), "current-kid", "kid-1") + .is_err(), + "the Axum dev server should reject config writes" + ); + assert!( + writer + .delete(&StoreId::from("jwks_store"), "kid-1") + .is_err(), + "the Axum dev server should reject config deletes" ); - assert!(result.is_err(), "should error for missing env var"); } #[test] - fn secret_store_reads_bytes_from_env_var() { - temp_env::with_var( - "TRUSTED_SERVER_SECRET_MY_SECRETS_MY_SECRET", - Some("hello"), - || { - let store = AxumPlatformSecretStore; - let result = store - .get_bytes(&StoreName::from("my-secrets"), "my-secret") - .expect("should read env var as bytes"); - assert_eq!(result, b"hello", "should return raw bytes"); - }, + fn secret_writer_rejects_writes_on_the_dev_server() { + let writer = AxumPlatformSecretStore; + + assert!( + writer + .create(&StoreId::from("signing_keys"), "kid-1", "value") + .is_err(), + "the Axum dev server should reject secret writes" + ); + assert!( + writer + .delete(&StoreId::from("signing_keys"), "kid-1") + .is_err(), + "the Axum dev server should reject secret deletes" ); } diff --git a/crates/trusted-server-adapter-axum/src/registries.rs b/crates/trusted-server-adapter-axum/src/registries.rs new file mode 100644 index 000000000..8e1db03ee --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/registries.rs @@ -0,0 +1,153 @@ +//! Per-request store registries for the Axum dev server. +//! +//! These builders construct the `EdgeZero` [`ConfigRegistry`], [`KvRegistry`], +//! and [`SecretRegistry`] from the shared +//! [`StoresMetadata`](edgezero_core::app::StoresMetadata) so that +//! [`build_runtime_services`](crate::platform::build_runtime_services) can +//! resolve non-default logical store ids (e.g. `jwks_store`, `consent_store`) +//! through the registry-backed composite stores. +//! +//! The registries are attached to the dev server via +//! [`AxumDevServer::with_config_registry`](edgezero_adapter_axum::dev_server::AxumDevServer::with_config_registry) +//! and its `with_kv_registry`/`with_secret_registry` siblings, which insert them +//! into each request's extensions. +//! +//! **KV redb path.** Each KV id opens a deterministic +//! `.edgezero/kv-.redb` database via the public +//! [`PersistentKvStore::new`] constructor. Because Trusted Server supplies the +//! whole registry to its own dev server (the dev server does not build one of +//! its own), this registry is authoritative for local KV — so the file name need +//! not match `EdgeZero`'s private `.edgezero/kv--.redb` scheme, and +//! no path-parity test is required. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use edgezero_adapter_axum::config_store::AxumConfigStore; +use edgezero_adapter_axum::key_value_store::PersistentKvStore; +use edgezero_adapter_axum::secret_store::EnvSecretStore; +use edgezero_core::app::StoresMetadata; +use edgezero_core::config_store::{ConfigStoreError, ConfigStoreHandle}; +use edgezero_core::key_value_store::KvHandle; +use edgezero_core::secret_store::SecretHandle; +use edgezero_core::store_registry::{ + BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, StoreRegistry, +}; +use trusted_server_core::stores::RUNTIME_ONLY_SECRET_IDS; + +/// Environment variable naming an explicit JSON config-store file for the +/// default app-config store, mirroring the boot-time override in +/// [`crate::app`]. It is a file-location pointer only; it never carries a config +/// value and applies only to the default config id. +const AXUM_CONFIG_PATH_ENV: &str = "TRUSTED_SERVER_AXUM_CONFIG_PATH"; + +/// Directory holding the dev server's local redb KV databases. +const KV_DIR: &str = ".edgezero"; + +/// Build the config registry from `stores`, one file-backed store per declared +/// config id. +/// +/// Each id reads its `.edgezero/local-config-.json` file; the default +/// app-config id additionally honors [`AXUM_CONFIG_PATH_ENV`]. A store that +/// fails to open is logged and dropped from the registry rather than aborting +/// startup. Returns `None` when no config stores are declared or the default id +/// could not be opened. +#[must_use] +pub fn build_config_registry_axum(stores: &StoresMetadata) -> Option { + let meta = stores.config?; + let mut by_id: BTreeMap = BTreeMap::new(); + for id in meta.ids { + let store = match open_config_store(id, meta.default) { + Ok(store) => store, + Err(error) => { + log::warn!( + "Axum config store `{id}` could not be opened: {error}; \ + dropping it from the registry" + ); + continue; + } + }; + by_id.insert( + (*id).to_owned(), + ConfigStoreBinding { + handle: ConfigStoreHandle::new(Arc::new(store)), + default_key: (*id).to_owned(), + }, + ); + } + StoreRegistry::from_parts(by_id, meta.default.to_owned()) +} + +/// Open the file-backed config store for `id`. +/// +/// Only the default app-config store honors [`AXUM_CONFIG_PATH_ENV`], matching +/// the boot-time read in [`crate::app`]; every other id reads its own +/// `.edgezero/local-config-.json` file. +fn open_config_store(id: &str, default_id: &str) -> Result { + if id == default_id + && let Ok(path) = std::env::var(AXUM_CONFIG_PATH_ENV) + { + return AxumConfigStore::from_path(Path::new(&path)); + } + AxumConfigStore::from_local_file(id) +} + +/// Build the KV registry from `stores`, one redb-backed store per declared KV +/// id. +/// +/// Each id opens a deterministic `.edgezero/kv-.redb` database. A store that +/// fails to open is logged and dropped rather than aborting startup. Returns +/// `None` when no KV stores are declared or the default id could not be opened. +#[must_use] +pub fn build_kv_registry_axum(stores: &StoresMetadata) -> Option { + let meta = stores.kv?; + if let Err(error) = std::fs::create_dir_all(KV_DIR) { + log::warn!("could not create `{KV_DIR}` directory for Axum KV stores: {error}"); + } + let mut by_id: BTreeMap = BTreeMap::new(); + for id in meta.ids { + let path = kv_path(id); + match PersistentKvStore::new(&path) { + Ok(store) => { + by_id.insert((*id).to_owned(), KvHandle::new(Arc::new(store))); + } + Err(error) => { + log::warn!( + "Axum KV store `{id}` could not be opened at {}: {error}; \ + dropping it from the registry", + path.display() + ); + } + } + } + StoreRegistry::from_parts(by_id, meta.default.to_owned()) +} + +/// Deterministic redb path for KV store `id`. +fn kv_path(id: &str) -> PathBuf { + Path::new(KV_DIR).join(format!("kv-{id}.redb")) +} + +/// Build the secret registry from `stores`. +/// +/// Axum reads secrets from environment variables via the public +/// [`EnvSecretStore`], so every declared id binds to the same env-backed store; +/// the store ignores the platform name on lookup. Returns `None` when no secret +/// stores are declared. +#[must_use] +pub fn build_secret_registry_axum(stores: &StoresMetadata) -> Option { + let meta = stores.secrets?; + let handle = SecretHandle::new(Arc::new(EnvSecretStore::new())); + let mut by_id: BTreeMap = BTreeMap::new(); + // The management-provisioned RUNTIME_ONLY_SECRET_IDS bind on top of the + // declared ids; every binding aliases the same flat env-var namespace, so + // the extra ids cost nothing locally. + for id in meta.ids.iter().chain(RUNTIME_ONLY_SECRET_IDS) { + by_id.insert( + (*id).to_owned(), + BoundSecretStore::new(handle.clone(), (*id).to_owned()), + ); + } + StoreRegistry::from_parts(by_id, meta.default.to_owned()) +} diff --git a/crates/trusted-server-adapter-cloudflare/cloudflare.toml b/crates/trusted-server-adapter-cloudflare/cloudflare.toml index f505e6b2f..5d4130283 100644 --- a/crates/trusted-server-adapter-cloudflare/cloudflare.toml +++ b/crates/trusted-server-adapter-cloudflare/cloudflare.toml @@ -9,7 +9,7 @@ kind = "http" name = "trusted_server_kv" [stores.kv.adapters.cloudflare] -name = "TRUSTED_SERVER_KV" +name = "trusted_server_kv" [stores.config] name = "trusted_server_config" diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 7ea582ee8..dbb58926a 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -2,7 +2,7 @@ use core::future::Future; use core::pin::Pin; use std::sync::Arc; -use edgezero_core::app::Hooks; +use edgezero_core::app::{Hooks, StoresMetadata}; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderValue, Method, Request, Response, StatusCode, header}; @@ -105,7 +105,9 @@ fn settings_from_cloudflare_config_json() -> Result Result Result RuntimeServices { /// users. Geo comes from the Workers `cf` object when deployed. A malformed /// consent string is logged and falls back to the default (fail-closed) context /// rather than being silently swallowed. -fn build_ec_context(settings: &Settings, services: &RuntimeServices, req: &Request) -> EcContext { +async fn build_ec_context( + settings: &Settings, + services: &RuntimeServices, + req: &Request, +) -> EcContext { let geo_info = services .geo() .lookup(services.client_info().client_ip) @@ -179,6 +193,7 @@ fn build_ec_context(settings: &Settings, services: &RuntimeServices, req: &Reque None }); EcContext::read_from_request_with_geo(settings, req, services, geo_info.as_ref()) + .await .unwrap_or_else(|e| { log::warn!("EC context read failed: {e:?}"); EcContext::default() @@ -377,6 +392,10 @@ impl Hooks for TrustedServerApp { build_router(&state) } + + fn stores() -> StoresMetadata { + trusted_server_core::stores::STORES_METADATA + } } impl TrustedServerApp { @@ -449,7 +468,7 @@ fn build_router(state: &Arc) -> RouterService { })) }) } else { - let mut ec_context = build_ec_context(&state.settings, &services, &req); + let mut ec_context = build_ec_context(&state.settings, &services, &req).await; let auction = AuctionDispatch { orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), @@ -503,13 +522,13 @@ fn build_router(state: &Arc) -> RouterService { .get( "/.well-known/trusted-server.json", make_handler(Arc::clone(&state), |s, services, req| async move { - handle_trusted_server_discovery(&s.settings, &services, req) + handle_trusted_server_discovery(&s.settings, &services, req).await }), ) .post( "/verify-signature", make_handler(Arc::clone(&state), |s, services, req| async move { - handle_verify_signature(&s.settings, &services, req) + handle_verify_signature(&s.settings, &services, req).await }), ) // Canonical admin key routes. These match `Settings::ADMIN_ENDPOINTS` @@ -554,7 +573,7 @@ fn build_router(state: &Arc) -> RouterService { // Build the geo-aware EC context so the auction consent gate // sees the caller's jurisdiction — `EcContext::default()` // fails it closed for consented users. - let mut ec_context = build_ec_context(&s.settings, &services, &req); + let mut ec_context = build_ec_context(&s.settings, &services, &req).await; handle_auction( &s.settings, &s.orchestrator, @@ -618,7 +637,7 @@ fn build_router(state: &Arc) -> RouterService { // preflight fall through to a permissive origin would reopen exactly // the cross-site hole the canonical path closes. let page_bids = make_handler(Arc::clone(&state), |s, services, req| async move { - let mut ec_context = build_ec_context(&s.settings, &services, &req); + let mut ec_context = build_ec_context(&s.settings, &services, &req).await; let auction = AuctionDispatch { orchestrator: &s.orchestrator, slots: s.settings.creative_opportunity_slots(), diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index cded42a0d..35c854e7f 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -3,13 +3,16 @@ use std::sync::Arc; use std::time::Duration; use bytes::Bytes; -use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::key_value_store::{KvHandle, KvPage, KvStore}; +use edgezero_core::store_registry::{ConfigRegistry, KvRegistry, SecretRegistry}; use error_stack::{Report, ResultExt as _}; +#[cfg(target_arch = "wasm32")] +use trusted_server_core::platform::StoreName; use trusted_server_core::platform::{ - BackendNamingPolicy, ClientInfo, GeoInfo, KvError, PlatformBackend, PlatformBackendSpec, - PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformKvStore, - PlatformSecretStore, RuntimeServices, StoreId, StoreName, UnavailableKvStore, + BackendNamingPolicy, ClientInfo, CompositeConfigStore, CompositeSecretStore, GeoInfo, KvError, + PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformConfigWriter, PlatformError, + PlatformGeo, PlatformHttpClient, PlatformKvStore, PlatformSecretStore, PlatformSecretWriter, + RuntimeServices, StoreId, UnavailableKvStore, }; #[cfg(not(target_arch = "wasm32"))] @@ -26,11 +29,7 @@ use trusted_server_core::platform::{ struct NoopConfigStore; -impl PlatformConfigStore for NoopConfigStore { - fn get(&self, _: &StoreName, _: &str) -> Result> { - Err(Report::new(PlatformError::ConfigStore).attach("config store not available")) - } - +impl PlatformConfigWriter for NoopConfigStore { fn put(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { Err(Report::new(PlatformError::ConfigStore).attach("config store not available")) } @@ -42,11 +41,7 @@ impl PlatformConfigStore for NoopConfigStore { struct NoopSecretStore; -impl PlatformSecretStore for NoopSecretStore { - fn get_bytes(&self, _: &StoreName, _: &str) -> Result, Report> { - Err(Report::new(PlatformError::SecretStore).attach("secret store not available")) - } - +impl PlatformSecretWriter for NoopSecretStore { fn create(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { Err(Report::new(PlatformError::SecretStore).attach("secret store not available")) } @@ -80,40 +75,8 @@ impl PlatformBackend for NoopBackend { // construction is handled by edgezero's run_app before we receive the ctx. // --------------------------------------------------------------------------- -/// Bridges edgezero's [`ConfigStoreHandle`] (injected by `run_app` from the -/// `TRUSTED_SERVER_CONFIG` env-var binding) to [`PlatformConfigStore`]. -/// -/// Reads delegate through the handle. Writes are unsupported on all current -/// adapter targets and return errors. -/// -/// Note: Cloudflare config is a single flat JSON env-var binding — all keys -/// live in one namespace. The `store_name` argument is intentionally ignored; -/// callers cannot route to a different store by passing a different name. -struct ConfigStoreHandleAdapter(ConfigStoreHandle); - -impl PlatformConfigStore for ConfigStoreHandleAdapter { - fn get(&self, _store_name: &StoreName, key: &str) -> Result> { - futures::executor::block_on(self.0.get(key)) - .map_err(|e| { - Report::new(PlatformError::ConfigStore) - .attach(format!("config store lookup failed: {e}")) - })? - .ok_or_else(|| { - Report::new(PlatformError::ConfigStore).attach(format!("key not found: {key}")) - }) - } - - fn put(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { - Err(Report::new(PlatformError::ConfigStore).attach("config store writes are not supported")) - } - - fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { - Err(Report::new(PlatformError::ConfigStore).attach("config store writes are not supported")) - } -} - /// Bridges edgezero's [`KvHandle`] (injected by `run_app` from the -/// `TRUSTED_SERVER_KV` KV namespace binding) to [`PlatformKvStore`]. +/// `trusted_server_kv` KV namespace binding) to [`PlatformKvStore`]. /// /// Delegates all operations through `KvHandle`'s raw-bytes API, which includes /// key/value validation before forwarding to the underlying store. @@ -544,8 +507,9 @@ pub(crate) struct CloudflareSecretStoreAdapter { } #[cfg(target_arch = "wasm32")] +#[async_trait::async_trait(?Send)] impl PlatformSecretStore for CloudflareSecretStoreAdapter { - fn get_bytes( + async fn get_bytes( &self, _store_name: &StoreName, key: &str, @@ -578,14 +542,10 @@ impl PlatformSecretStore for CloudflareSecretStoreAdapter { /// Construct [`RuntimeServices`] for an incoming Cloudflare Workers request. /// -/// Config and KV are sourced from the edgezero handles that `run_app` injects -/// before routing — via the `TRUSTED_SERVER_CONFIG` env-var binding and the -/// `TRUSTED_SERVER_KV` KV namespace declared in `cloudflare.toml`. No -/// platform-specific `#[cfg]` is required for these two stores. -/// -/// Secrets still require direct `worker::Env` access because -/// `SecretHandle::get_bytes` is async while `PlatformSecretStore::get_bytes` -/// is sync; the underlying `env.secret()` call is synchronous at the JS level. +/// Config, secret, and KV reads resolve through the `EdgeZero` store registries +/// that `run_app` injects into the request extensions before routing. Cloudflare +/// exposes no config- or secret-store write API, so both write delegates handed +/// to the composites reject writes. /// /// Geo information is read from Cloudflare's injected request headers /// (`cf-ipcountry`, etc.) which are present on all plans; headers absent on @@ -598,30 +558,31 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R #[cfg(not(target_arch = "wasm32"))] let http_client: Arc = Arc::new(UnavailableHttpClient); - // Config: use the ConfigStoreHandle injected by run_app — no #[cfg] needed. - let config_store: Arc = ctx - .config_store_default() - .map(|h| Arc::new(ConfigStoreHandleAdapter(h)) as Arc) - .unwrap_or_else(|| Arc::new(NoopConfigStore)); - - // KV: use the KvHandle injected by run_app — no #[cfg] needed. + // Config reads resolve through the whole ConfigRegistry (from request + // extensions) so non-default logical ids (e.g. `jwks_store`) resolve. An + // absent registry makes composite reads error rather than silently reading a + // default store. Cloudflare has no config-store write API (config arrives as + // a `[vars]` binding), so the write delegate always errors. + let config_reader = ctx.request().extensions().get::().cloned(); + let config_writer: Arc = Arc::new(NoopConfigStore); + let config_store: Arc = + Arc::new(CompositeConfigStore::new(config_reader, config_writer)); + + // KV: the default request-path store stays the run_app-injected handle; the + // full KvRegistry is carried separately for `kv_handle_named` lookups. let kv_store: Arc = ctx .kv_store_default() .map(|h| Arc::new(KvHandleAdapter(h)) as Arc) .unwrap_or_else(|| Arc::new(UnavailableKvStore)); + let kv_registry = ctx.request().extensions().get::().cloned(); - // Secrets: still requires wasm32-specific env.secret() (async/sync mismatch). - #[cfg(target_arch = "wasm32")] + // Secret reads resolve through the whole SecretRegistry (from request + // extensions). Cloudflare secrets are deploy-time bindings with no runtime + // write API, so the write delegate rejects writes. + let secret_reader = ctx.request().extensions().get::().cloned(); + let secret_writer: Arc = Arc::new(NoopSecretStore); let secret_store: Arc = - edgezero_adapter_cloudflare::context::CloudflareRequestContext::get(ctx.request()) - .map(|cf_ctx| { - Arc::new(CloudflareSecretStoreAdapter { - env: cf_ctx.env().clone(), - }) as Arc - }) - .unwrap_or_else(|| Arc::new(NoopSecretStore)); - #[cfg(not(target_arch = "wasm32"))] - let secret_store: Arc = Arc::new(NoopSecretStore); + Arc::new(CompositeSecretStore::new(secret_reader, secret_writer)); // Geo: read Cloudflare-injected headers — no #[cfg] needed; headers are // simply absent on the native host target, producing Ok(None) from lookup(). @@ -631,6 +592,7 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R .config_store(config_store) .secret_store(secret_store) .kv_store(kv_store) + .kv_registry(kv_registry) .backend(Arc::new(NoopBackend)) .http_client(http_client) .geo(Arc::new(geo)) @@ -750,6 +712,190 @@ fn reject_multi_provider_fanout(len: usize) -> Result<(), Report> Ok(()) } +#[cfg(test)] +mod registry_test_support { + //! In-memory store doubles and a `RequestContext` builder that seeds the + //! three `EdgeZero` registries into request extensions, so adapter tests can + //! exercise non-default logical store resolution through the composite. + + use std::collections::{BTreeMap, HashMap}; + use std::sync::{Arc, Mutex}; + + use async_trait::async_trait; + use bytes::Bytes; + use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use edgezero_core::context::RequestContext; + use edgezero_core::http::request_builder; + use edgezero_core::key_value_store::{KvError, KvHandle, KvPage, KvStore}; + use edgezero_core::params::PathParams; + use edgezero_core::secret_store::{SecretError, SecretHandle, SecretStore}; + use edgezero_core::store_registry::{ + BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, + StoreRegistry, + }; + + /// In-memory [`ConfigStore`] double keyed by lookup key. + struct MemConfigStore { + data: HashMap, + } + + #[async_trait(?Send)] + impl ConfigStore for MemConfigStore { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + Ok(self.data.get(key).cloned()) + } + } + + /// In-memory [`SecretStore`] double keyed by `"{store_name}/{key}"`. + struct MemSecretStore { + data: HashMap, + } + + #[async_trait(?Send)] + impl SecretStore for MemSecretStore { + async fn get_bytes( + &self, + store_name: &str, + key: &str, + ) -> Result, SecretError> { + Ok(self.data.get(&format!("{store_name}/{key}")).cloned()) + } + } + + /// In-memory [`KvStore`] double. + #[derive(Default)] + struct MemKvStore { + data: Mutex>, + } + + #[async_trait(?Send)] + impl KvStore for MemKvStore { + async fn get_bytes(&self, key: &str) -> Result, KvError> { + Ok(self.data.lock().expect("should lock").get(key).cloned()) + } + + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + self.data + .lock() + .expect("should lock") + .insert(key.to_owned(), value); + Ok(()) + } + + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + _ttl: std::time::Duration, + ) -> Result<(), KvError> { + self.put_bytes(key, value).await + } + + async fn delete(&self, key: &str) -> Result<(), KvError> { + self.data.lock().expect("should lock").remove(key); + Ok(()) + } + + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Ok(KvPage::default()) + } + } + + /// Build a [`ConfigRegistry`] from `(store_id, key, value)` entries. + pub(super) fn config_registry(entries: &[(&str, &str, &str)], default: &str) -> ConfigRegistry { + let mut by_store: BTreeMap> = BTreeMap::new(); + for (id, key, value) in entries { + by_store + .entry((*id).to_owned()) + .or_default() + .insert((*key).to_owned(), (*value).to_owned()); + } + let by_id: BTreeMap = by_store + .into_iter() + .map(|(id, data)| { + let binding = ConfigStoreBinding { + default_key: id.clone(), + handle: ConfigStoreHandle::new(Arc::new(MemConfigStore { data })), + }; + (id, binding) + }) + .collect(); + StoreRegistry::from_parts(by_id, default.to_owned()) + .expect("should build a non-empty config registry") + } + + /// Build a [`SecretRegistry`] from `(store_id, key, value)` entries. + pub(super) fn secret_registry( + entries: &[(&str, &str, &[u8])], + default: &str, + ) -> SecretRegistry { + let mut data: HashMap = HashMap::new(); + let mut ids: BTreeMap = BTreeMap::new(); + for (id, key, value) in entries { + data.insert(format!("{id}/{key}"), Bytes::copy_from_slice(value)); + ids.insert((*id).to_owned(), ()); + } + let handle = SecretHandle::new(Arc::new(MemSecretStore { data })); + let by_id: BTreeMap = ids + .into_keys() + .map(|id| { + let bound = BoundSecretStore::new(handle.clone(), id.clone()); + (id, bound) + }) + .collect(); + StoreRegistry::from_parts(by_id, default.to_owned()) + .expect("should build a non-empty secret registry") + } + + /// Build a [`KvRegistry`] from `(store_id, key, value)` entries; each id maps + /// to its own in-memory store so distinct ids are observably distinct. + pub(super) fn kv_registry(entries: &[(&str, &str, &[u8])], default: &str) -> KvRegistry { + let mut by_store: BTreeMap> = BTreeMap::new(); + for (id, key, value) in entries { + let store = by_store.entry((*id).to_owned()).or_default(); + store + .data + .lock() + .expect("should lock") + .insert((*key).to_owned(), Bytes::copy_from_slice(value)); + } + let by_id: BTreeMap = by_store + .into_iter() + .map(|(id, store)| (id, KvHandle::new(store))) + .collect(); + StoreRegistry::from_parts(by_id, default.to_owned()) + .expect("should build a non-empty kv registry") + } + + /// Build a [`RequestContext`] with the three registries inserted into request + /// extensions, mirroring what `run_app` does before routing. + pub(super) fn test_context_with_registries( + config: Option, + kv: Option, + secrets: Option, + ) -> RequestContext { + let mut builder = request_builder().method("GET").uri("https://example.com/"); + if let Some(config) = config { + builder = builder.extension(config); + } + if let Some(kv) = kv { + builder = builder.extension(kv); + } + if let Some(secrets) = secrets { + builder = builder.extension(secrets); + } + let req = builder + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + RequestContext::new(req, PathParams::default()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -775,6 +921,133 @@ mod tests { } use edgezero_core::http::{HeaderValue, request_builder}; use edgezero_core::params::PathParams; + use trusted_server_core::platform::StoreName; + + use super::registry_test_support::{ + config_registry, kv_registry, secret_registry, test_context_with_registries, + }; + + #[test] + fn config_store_resolves_nondefault_jwks_store() { + // Arrange: registry with the default config store plus a non-default + // `jwks_store` (D5: default config id is `trusted_server_config`). + let config = config_registry( + &[ + ("trusted_server_config", "current-kid", "kid-1"), + ("jwks_store", "kid-1", "{\"kty\":\"OKP\"}"), + ], + "trusted_server_config", + ); + let ctx = test_context_with_registries(Some(config), None, None); + let services = build_runtime_services(&ctx); + + let jwk = futures::executor::block_on( + services + .config_store() + .get(&StoreName::from("jwks_store"), "kid-1"), + ) + .expect("should resolve the non-default jwks_store through the composite"); + assert_eq!( + jwk, "{\"kty\":\"OKP\"}", + "should read the seeded value from the non-default config store" + ); + + assert!( + futures::executor::block_on( + services + .config_store() + .get(&StoreName::from("no_such_store"), "kid-1") + ) + .is_err(), + "unknown config id should error, not fall back to the default store" + ); + } + + #[test] + fn secret_store_resolves_nondefault_ts_secrets_and_s3_auth() { + let secrets = secret_registry( + &[ + ("trusted_server_secrets", "API_KEY", b"default-key"), + ("ts_secrets", "server-side-key", b"dd-secret"), + ("s3_auth", "aws-secret-access-key", b"s3-secret"), + ], + "trusted_server_secrets", + ); + let ctx = test_context_with_registries(None, None, Some(secrets)); + let services = build_runtime_services(&ctx); + + let dd = futures::executor::block_on( + services + .secret_store() + .get_bytes(&StoreName::from("ts_secrets"), "server-side-key"), + ) + .expect("should resolve ts_secrets through the composite"); + assert_eq!(dd, b"dd-secret", "should read the seeded DataDome secret"); + + let s3 = futures::executor::block_on( + services + .secret_store() + .get_bytes(&StoreName::from("s3_auth"), "aws-secret-access-key"), + ) + .expect("should resolve s3_auth through the composite"); + assert_eq!(s3, b"s3-secret", "should read the seeded S3 secret"); + + assert!( + futures::executor::block_on( + services + .secret_store() + .get_bytes(&StoreName::from("no_such_store"), "x") + ) + .is_err(), + "unknown secret id should error, not fall back to the default store" + ); + } + + #[tokio::test] + async fn kv_handle_named_resolves_consent_store() { + // Both ids carry the same key with different values, so a named lookup + // that resolved to the default store would be caught. + let kv = kv_registry( + &[ + ("trusted_server_kv", "marker", b"default-value"), + ("consent_store", "marker", b"consent-value"), + ], + "trusted_server_kv", + ); + let ctx = test_context_with_registries(None, Some(kv), None); + let services = build_runtime_services(&ctx); + + let named = services + .kv_handle_named("consent_store") + .expect("should resolve the consent_store handle"); + let named_value = named + .get_bytes("marker") + .await + .expect("should read from consent_store") + .expect("should find the seeded key"); + assert_eq!( + named_value.as_ref(), + b"consent-value", + "named lookup should read consent_store, not the default store" + ); + + let default_value = services + .kv_handle() + .get_bytes("marker") + .await + .expect("should read from the default KV store") + .expect("should find the seeded key"); + assert_eq!( + default_value.as_ref(), + b"default-value", + "the default request-path KV store must stay distinct from consent_store" + ); + + assert!( + services.kv_handle_named("no_such_store").is_none(), + "unknown KV id should resolve to None" + ); + } fn make_ctx_with_header(name: &str, value: &str) -> RequestContext { let req = request_builder() diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml b/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml index 9992db712..31cc05a0a 100644 --- a/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml +++ b/crates/trusted-server-adapter-cloudflare/wrangler.ci.toml @@ -6,10 +6,24 @@ compatibility_date = "2024-09-23" compatibility_flags = ["nodejs_compat", "cache_option_enabled"] # No [build] section — bundle is pre-built in CI; wrangler dev must not rebuild. +# One binding per logical KV store id in edgezero.toml [stores.kv].ids — the +# EdgeZero dispatcher requires every declared id to open. [[kv_namespaces]] -binding = "TRUSTED_SERVER_KV" +binding = "trusted_server_kv" id = "ci-local-kv" +[[kv_namespaces]] +binding = "ec_identity_store" +id = "ci-local-kv-ec" + +[[kv_namespaces]] +binding = "consent_store" +id = "ci-local-kv-consent" + +[[kv_namespaces]] +binding = "creative_store" +id = "ci-local-kv-creative" + [vars] # Placeholder replaced by the integration test harness with a JSON object that # contains the runtime Trusted Server app-config blob envelope. diff --git a/crates/trusted-server-adapter-cloudflare/wrangler.toml b/crates/trusted-server-adapter-cloudflare/wrangler.toml index 48eb2db8d..a23f0f0d9 100644 --- a/crates/trusted-server-adapter-cloudflare/wrangler.toml +++ b/crates/trusted-server-adapter-cloudflare/wrangler.toml @@ -14,18 +14,33 @@ compatibility_flags = ["nodejs_compat", "cache_option_enabled"] [build] command = "bash build.sh" +# One KV namespace binding per logical KV store id declared in edgezero.toml +# ([stores.kv].ids). The EdgeZero dispatcher opens every declared id at request +# time and fails the request when a binding is missing, so all four must exist. +# Run `wrangler kv:namespace create ` for each and paste the returned +# ids here before `wrangler deploy` or `wrangler dev --remote`. +# `wrangler dev --local` (Miniflare default) tolerates these placeholders. [[kv_namespaces]] -binding = "TRUSTED_SERVER_KV" -# Run `wrangler kv:namespace create TRUSTED_SERVER_KV` and paste the returned -# id here before `wrangler deploy` or `wrangler dev --remote`. -# `wrangler dev --local` (Miniflare default) tolerates this placeholder. +binding = "trusted_server_kv" id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID" +[[kv_namespaces]] +binding = "ec_identity_store" +id = "REPLACE_WITH_YOUR_EC_KV_NAMESPACE_ID" + +[[kv_namespaces]] +binding = "consent_store" +id = "REPLACE_WITH_YOUR_CONSENT_KV_NAMESPACE_ID" + +[[kv_namespaces]] +binding = "creative_store" +id = "REPLACE_WITH_YOUR_CREATIVE_KV_NAMESPACE_ID" + [vars] # TRUSTED_SERVER_CONFIG is required at startup. Replace this intentionally # invalid placeholder with JSON containing an `app_config` blob envelope before # deploying or running `wrangler dev` against real traffic. -TRUSTED_SERVER_CONFIG = '{"app_config":""}' +TRUSTED_SERVER_CONFIG = '{"trusted_server_config":""}' # App-config secret values are provisioned as Worker secrets with # `wrangler secret put `. The pushed blob contains only those key diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 190be505c..37938aeb2 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -91,7 +91,7 @@ use std::sync::Arc; use crate::rate_limiter::{FastlyRateLimiter, RATE_COUNTER_NAME}; use edgezero_adapter_fastly::context::FastlyRequestContext; use edgezero_adapter_fastly::runtime_env_config; -use edgezero_core::app::{App, Hooks, StoreMetadata, StoresMetadata}; +use edgezero_core::app::{App, Hooks, StoresMetadata}; use edgezero_core::context::RequestContext; use edgezero_core::env_config::EnvConfig; use edgezero_core::error::EdgeError; @@ -99,6 +99,7 @@ use edgezero_core::http::{ HandlerFuture, HeaderValue, Method, Request, Response, StatusCode, header, }; use edgezero_core::router::RouterService; +use edgezero_core::store_registry::{ConfigRegistry, KvRegistry, SecretRegistry}; use error_stack::Report; use trusted_server_core::auction::AuctionTelemetrySink; use trusted_server_core::auction::endpoints::handle_auction; @@ -125,7 +126,8 @@ use trusted_server_core::integrations::{ RequestFilterRegistryOutcome, }; use trusted_server_core::platform::{ - ClientInfo, GeoInfo, PlatformKvStore, RuntimeServices, StoreName, + ClientInfo, CompositeConfigStore, CompositeSecretStore, GeoInfo, PlatformKvStore, + RuntimeServices, StoreName, }; use trusted_server_core::proxy::{ AssetProxyCachePolicy, handle_asset_proxy_request, handle_first_party_click, @@ -147,7 +149,7 @@ use trusted_server_core::tester_cookie::{handle_clear_tester, handle_set_tester} use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; use crate::platform::{ FastlyPlatformBackend, FastlyPlatformConfigStore, FastlyPlatformGeo, FastlyPlatformHttpClient, - FastlyPlatformSecretStore, UnavailableKvStore, open_kv_store, + FastlyPlatformSecretStore, UnavailableKvStore, }; // --------------------------------------------------------------------------- @@ -158,7 +160,6 @@ use crate::platform::{ pub(crate) struct RuntimeStoreConfig { pub(crate) config_store_name: StoreName, pub(crate) config_key: String, - pub(crate) secret_store_name: StoreName, } impl RuntimeStoreConfig { @@ -166,7 +167,6 @@ impl RuntimeStoreConfig { Self { config_store_name: StoreName::from(env.store_name("config", DEFAULT_CONFIG_STORE_ID)), config_key: env.store_key("config", DEFAULT_CONFIG_STORE_ID), - secret_store_name: StoreName::from(env.store_name("secrets", DEFAULT_SECRET_STORE_ID)), } } } @@ -198,13 +198,30 @@ pub(crate) fn build_state( pub(crate) fn load_settings_from_config_store( stores: &RuntimeStoreConfig, ) -> Result> { - get_settings_from_config_store( - &FastlyPlatformConfigStore, - &FastlyPlatformSecretStore, - &stores.config_store_name, + let config_store = crate::open_trusted_server_config_store(stores.config_store_name.as_ref()) + .map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: format!("failed to open Trusted Server config store: {error}"), + }) + })?; + // Boot-time secret resolution reads through the same EdgeZero secret + // registry as request-time reads. The registry binds the logical id to its + // physical store name, so a `trusted_server_secrets` selector mapped to a + // differently named physical store (e.g. `ts_secrets`) resolves identically + // at startup — the lookup key here is always the logical id. + let secret_store = CompositeSecretStore::new( + crate::registries::build_secret_registry(&trusted_server_core::stores::STORES_METADATA), + Arc::new(FastlyPlatformSecretStore), + ); + // Startup-only read: this runs at process/app construction (and post-dispatch + // response finalize), never inside the request router executor, so driving the + // async boot read with a top-level `block_on` here cannot nest executors. + futures::executor::block_on(get_settings_from_config_store( + &config_store, &stores.config_key, - &stores.secret_store_name, - ) + &secret_store, + &StoreName::from(DEFAULT_SECRET_STORE_ID), + )) } pub(crate) fn build_state_from_settings( @@ -237,36 +254,6 @@ fn warn_if_certificate_check_disabled(settings: &Settings) { } } -/// Resolves per-request consent KV store services for routes that read consent data. -/// -/// When `settings.consent.consent_store` is configured and the named KV store cannot -/// be opened, returns `Err` so the caller can respond with 503 (fail-closed). This is -/// intentional hardening over the legacy `route_request` path, which builds -/// `runtime_services` with `UnavailableKvStore` and never opens the named consent -/// store, so it never fails closed — the `EdgeZero` path instead makes consent-dependent -/// routes unavailable rather than proceeding without consent. -/// -/// # Errors -/// -/// Returns an error when the configured consent store cannot be opened. -pub(crate) fn runtime_services_for_consent_route( - settings: &Settings, - runtime_services: &RuntimeServices, -) -> Result> { - let Some(store_name) = settings.consent.consent_store.as_deref() else { - return Ok(runtime_services.clone()); - }; - - open_kv_store(store_name) - .map(|store| runtime_services.clone().with_kv_store(store)) - .map_err(|e| { - Report::new(TrustedServerError::KvStore { - store_name: store_name.to_string(), - message: e.to_string(), - }) - }) -} - // --------------------------------------------------------------------------- // Per-request RuntimeServices // --------------------------------------------------------------------------- @@ -282,6 +269,16 @@ pub(crate) fn runtime_services_for_consent_route( /// absent (e.g. tests that dispatch without the entry point). Scheme detection /// continues to rely on the trusted `fastly-ssl` header injected by /// `edgezero_main` after sanitization. +/// +/// Config and secret reads resolve through the `EdgeZero` registries that +/// `edgezero_main` inserts into the request extensions (see +/// [`crate::registries`]), wrapped in [`CompositeConfigStore`] / +/// [`CompositeSecretStore`] so that non-default logical ids (`jwks_store`, +/// `ts_secrets`, …) resolve by name; writes still delegate to the Fastly +/// management-API impls. An absent registry makes reads error rather than +/// silently falling back to the default store. The [`KvRegistry`] is handed to +/// [`RuntimeServices`] so `kv_handle_named` can resolve non-default KV stores +/// such as the consent store. fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> RuntimeServices { let client_info = ctx .request() @@ -293,10 +290,21 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime ..ClientInfo::default() }); + let config_reader = ctx.request().extensions().get::().cloned(); + let secret_reader = ctx.request().extensions().get::().cloned(); + let kv_registry = ctx.request().extensions().get::().cloned(); + RuntimeServices::builder() - .config_store(Arc::new(FastlyPlatformConfigStore)) - .secret_store(Arc::new(FastlyPlatformSecretStore)) + .config_store(Arc::new(CompositeConfigStore::new( + config_reader, + Arc::new(FastlyPlatformConfigStore), + ))) + .secret_store(Arc::new(CompositeSecretStore::new( + secret_reader, + Arc::new(FastlyPlatformSecretStore), + ))) .kv_store(Arc::clone(&state.default_kv_store)) + .kv_registry(kv_registry) // Spike-only (#1009). Constructed unconditionally, but only read when the // assembly mode is a shared-template one — which defaults to Inline, so this // is inert until an operator opts in. @@ -423,7 +431,7 @@ fn device_signals_for(req: &Request) -> DeviceSignals { /// Builds the per-request EC state, mirroring the pre-routing prelude of the /// legacy `route_request` step by step. -fn build_ec_request_state( +async fn build_ec_request_state( settings: &Settings, services: &RuntimeServices, req: &Request, @@ -451,7 +459,9 @@ fn build_ec_request_state( }); let (ec_context, setup_error) = - match EcContext::read_from_request_with_geo(settings, req, services, geo_info.as_ref()) { + match EcContext::read_from_request_with_geo(settings, req, services, geo_info.as_ref()) + .await + { Ok(mut context) => { context.set_device_signals(device_signals); // Orphan-recovery eligibility is intentionally left false here. @@ -607,7 +617,7 @@ async fn execute_named( return Ok(http_error(&report)); } - let mut ec = build_ec_request_state(&state.settings, &services, &req); + let mut ec = build_ec_request_state(&state.settings, &services, &req).await; // EcContext creation errors short-circuit before filters, mirroring legacy: // the legacy path returns its error response before running filter_request. if let Some(report) = ec.setup_error.take() { @@ -642,13 +652,15 @@ async fn run_named_route( ) -> Result> { match handler { NamedRouteHandler::TrustedServerDiscovery => { - handle_trusted_server_discovery(&state.settings, services, req) + handle_trusted_server_discovery(&state.settings, services, req).await } NamedRouteHandler::VerifySignature => { - handle_verify_signature(&state.settings, services, req) + handle_verify_signature(&state.settings, services, req).await + } + NamedRouteHandler::RotateKey => handle_rotate_key(&state.settings, services, req).await, + NamedRouteHandler::DeactivateKey => { + handle_deactivate_key(&state.settings, services, req).await } - NamedRouteHandler::RotateKey => handle_rotate_key(&state.settings, services, req), - NamedRouteHandler::DeactivateKey => handle_deactivate_key(&state.settings, services, req), NamedRouteHandler::AdminEcLookup | NamedRouteHandler::AdminEidsLookup => { unreachable!("admin diagnostics should be handled before EC setup") } @@ -675,10 +687,10 @@ async fn run_named_route( NamedRouteHandler::SetTester => handle_set_tester(&state.settings), NamedRouteHandler::ClearTester => handle_clear_tester(&state.settings), NamedRouteHandler::Auction => { - // The auction reads consent data, so the consent KV store must be - // available — fail closed with 503 when it is configured but - // cannot be opened, matching legacy behavior. - let consent_services = runtime_services_for_consent_route(&state.settings, services)?; + // The auction reads consent data and fails closed with 503 when the + // configured consent store cannot be resolved. That guard now lives + // in core (`consent::resolve_consent_kv`, called by `handle_auction`) + // so every adapter gets it, not just Fastly. let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?; let registry_ref = if partner_registry.is_empty() { None @@ -691,7 +703,7 @@ async fn run_named_route( ec.kv_graph.as_ref(), registry_ref, &mut ec.ec_context, - &consent_services, + services, req, ) .await @@ -703,10 +715,8 @@ async fn run_named_route( if req.method() == Method::OPTIONS { return Ok(page_bids_preflight_denied()); } - // Like the auction, page-bids reads consent data, so the consent KV - // store must be available — fail closed with 503 when configured but - // unopenable, matching legacy. - let consent_services = runtime_services_for_consent_route(&state.settings, services)?; + // Like the auction, page-bids reads consent data; `handle_page_bids` + // applies the core fail-closed consent guard. let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?; let registry_ref = if partner_registry.is_empty() { None @@ -720,7 +730,7 @@ async fn run_named_route( }; handle_page_bids( &state.settings, - &consent_services, + services, ec.kv_graph.as_ref(), auction, &mut ec.ec_context, @@ -800,7 +810,7 @@ async fn dispatch_fallback( return http_error(&report); } - let mut ec = build_ec_request_state(&state.settings, services, &req); + let mut ec = build_ec_request_state(&state.settings, services, &req).await; if let Some(report) = ec.setup_error.take() { let response = http_error(&report); return attach_dispatch_extensions(response, ec, RequestFilterEffects::default()); @@ -864,57 +874,53 @@ async fn dispatch_fallback( log::warn!("EC generation failed for publisher proxy: {err:?}"); } - // Publisher pages read consent data, so the consent KV store must be - // available — fail closed with 503 when it is configured but cannot - // be opened, matching legacy behavior. - match runtime_services_for_consent_route(&state.settings, services) { - Ok(publisher_services) => { - // Run the server-side auction with the configured creative- - // opportunity slots and collect dispatched bids from the lazy - // publisher body stream. `handle_publisher_request` matches the - // slots against the request path. The partner registry plus the - // EC identity-graph KV (`ec.kv_graph`) enrich the bid request with - // server-side EIDs, same as the legacy auction. - let slots = state.settings.creative_opportunity_slots(); - match PartnerRegistry::from_config(&state.settings.ec.partners) { - Ok(partner_registry) => { - let auction = AuctionDispatch { - orchestrator: &state.orchestrator, - slots, - registry: Some(&partner_registry), - }; - match handle_publisher_request( - &state.settings, - &publisher_services, - ec.kv_graph.as_ref(), - &mut ec.ec_context, - auction, - req, - EdgeCacheHeader::SurrogateControl, + // Publisher pages read consent data. `handle_publisher_request` applies + // the core fail-closed consent guard (503 when the configured consent + // store cannot be resolved), so no adapter-level pre-check is needed. + // + // Run the server-side auction with the configured creative-opportunity + // slots and collect dispatched bids from the lazy publisher body + // stream. `handle_publisher_request` matches the slots against the + // request path. The partner registry plus the EC identity-graph KV + // (`ec.kv_graph`) enrich the bid request with server-side EIDs, same as + // the legacy auction. + let slots = state.settings.creative_opportunity_slots(); + match PartnerRegistry::from_config(&state.settings.ec.partners) { + Ok(partner_registry) => { + let auction = AuctionDispatch { + orchestrator: &state.orchestrator, + slots, + registry: Some(&partner_registry), + }; + match handle_publisher_request( + &state.settings, + services, + ec.kv_graph.as_ref(), + &mut ec.ec_context, + auction, + req, + EdgeCacheHeader::SurrogateControl, + ) + .await + { + Ok(pub_response) => { + // Origin start succeeded on the sole publisher- + // page path: authorize orphan recovery now, and + // only for real-browser document navigations. + // Restricting it here keeps identity rotation + // within the publisher-navigation boundary — + // named routes, integration proxies, and filter + // short circuits never reach this point. + ec.ec_context.set_recovery_eligible(is_publisher_navigation); + publisher_response_into_streaming_response( + pub_response, + &method, + Arc::clone(&state.settings), + state.registry.as_ref(), + Arc::clone(&state.orchestrator), + services.clone(), ) .await - { - Ok(pub_response) => { - // Origin start succeeded on the sole publisher- - // page path: authorize orphan recovery now, and - // only for real-browser document navigations. - // Restricting it here keeps identity rotation - // within the publisher-navigation boundary — - // named routes, integration proxies, and filter - // short circuits never reach this point. - ec.ec_context.set_recovery_eligible(is_publisher_navigation); - publisher_response_into_streaming_response( - pub_response, - &method, - Arc::clone(&state.settings), - state.registry.as_ref(), - Arc::clone(&state.orchestrator), - publisher_services.clone(), - ) - .await - } - Err(e) => Err(e), - } } Err(e) => Err(e), } @@ -1349,20 +1355,7 @@ impl Hooks for TrustedServerApp { } fn stores() -> StoresMetadata { - StoresMetadata { - config: Some(StoreMetadata { - default: DEFAULT_CONFIG_STORE_ID, - ids: &[DEFAULT_CONFIG_STORE_ID], - }), - kv: Some(StoreMetadata { - default: "trusted_server_kv", - ids: &["trusted_server_kv"], - }), - secrets: Some(StoreMetadata { - default: DEFAULT_SECRET_STORE_ID, - ids: &[DEFAULT_SECRET_STORE_ID], - }), - } + trusted_server_core::stores::STORES_METADATA } } @@ -1403,11 +1396,12 @@ mod tests { RequestFilterEffects, RequestFilterInput, }; use trusted_server_core::platform::{ - ClientInfo, PlatformBackend, PlatformBackendSpec, PlatformError, PlatformHttpClient, - PlatformHttpRequest, PlatformKvStore, PlatformPendingRequest, PlatformResponse, - PlatformSelectResult, PlatformTemplateCache, PlatformTemplateCacheReservation, - RuntimeServices, TemplateCacheError, TemplateCacheKey, TemplateCacheLookup, - TemplateCacheMiss, TemplateCacheReservation, TemplateEntry, TemplateMetadata, + ClientInfo, CompositeConfigStore, CompositeSecretStore, PlatformBackend, + PlatformBackendSpec, PlatformError, PlatformHttpClient, PlatformHttpRequest, + PlatformKvStore, PlatformPendingRequest, PlatformResponse, PlatformSelectResult, + PlatformTemplateCache, PlatformTemplateCacheReservation, RuntimeServices, + TemplateCacheError, TemplateCacheKey, TemplateCacheLookup, TemplateCacheMiss, + TemplateCacheReservation, TemplateEntry, TemplateMetadata, }; use trusted_server_core::settings::Settings; @@ -1475,7 +1469,6 @@ mod tests { assert_eq!(stores.config_store_name.as_ref(), "physical_config"); assert_eq!(stores.config_key, "active_config"); - assert_eq!(stores.secret_store_name.as_ref(), "ts_secrets"); } #[test] @@ -1484,7 +1477,6 @@ mod tests { assert_eq!(stores.config_store_name.as_ref(), "trusted_server_config"); assert_eq!(stores.config_key, "trusted_server_config"); - assert_eq!(stores.secret_store_name.as_ref(), "trusted_server_secrets"); } fn settings_with_missing_consent_store() -> Settings { @@ -2595,6 +2587,60 @@ mod tests { ); } + #[test] + fn oneshot_discovery_reads_jwks_via_registry() { + // The EdgeZero dispatch path reads the JWKS document through the + // ConfigRegistry that `edgezero_main` injects into request extensions — + // resolving the non-default `jwks_store` logical id via the composite + // config store, not a direct per-store open. Without the injected + // registry the composite read errors, so a 200 with the seeded kids + // proves the registry is the read path. + let router = test_router(); + let mut req = empty_request(Method::GET, "/.well-known/trusted-server.json"); + let registry = + crate::registries::build_config_registry(&trusted_server_core::stores::STORES_METADATA) + .expect("should build the config registry from the declared stores"); + req.extensions_mut().insert(registry); + + let response = route(&router, req); + + assert_eq!( + response.status(), + StatusCode::OK, + "discovery should read JWKS through the injected ConfigRegistry" + ); + let body = String::from_utf8( + response + .into_body() + .into_bytes() + .expect("should buffer the discovery body") + .to_vec(), + ) + .expect("discovery body should be utf-8"); + assert!( + body.contains("ts-2025-10-A"), + "discovery should surface the kid seeded in the `jwks_store` config store" + ); + } + + #[test] + fn oneshot_discovery_without_registry_fails_rather_than_falling_back() { + // Strictness guard: with no ConfigRegistry in extensions the composite + // read must error instead of silently falling back to a direct store + // open. This is what makes the registry the single read path. + let router = test_router(); + let response = route( + &router, + empty_request(Method::GET, "/.well-known/trusted-server.json"), + ); + + assert_ne!( + response.status(), + StatusCode::OK, + "an absent ConfigRegistry must not silently fall back to a direct config-store read" + ); + } + #[test] fn dispatch_unregistered_method_returns_405_at_router_level() { // Documents the known router-level behavior for verbs outside the @@ -2660,9 +2706,10 @@ mod tests { "publisher fallback should fail closed when configured consent KV cannot be opened" ); - // Integration routes must NOT require the consent KV — runtime_services_for_consent_route - // is wired only into the publisher and auction branches of dispatch_fallback, not into - // the integration proxy branch. A missing consent store must not 503 integration routes. + // Integration routes must NOT require the consent KV — the core fail-closed + // guard (`consent::resolve_consent_kv`) runs inside the auction, page-bids, + // and publisher handlers only, never on the integration proxy branch of + // dispatch_fallback. A missing consent store must not 503 integration routes. let integration_response = route( &router, empty_request(Method::GET, "/integrations/datadome/tags.js"), @@ -2674,6 +2721,38 @@ mod tests { ); } + #[test] + fn declared_consent_store_resolves_through_the_injected_kv_registry() { + // Guards the removal of the Fastly-only `runtime_services_for_consent_route` + // wrapper: the consent store is now selected by logical id through the + // injected KvRegistry. With the declared `consent_store` id configured and + // the registry present, the auction must NOT fail closed — proving the + // named lookup resolves rather than the (removed) direct reopen. + let mut settings = settings_with_missing_consent_store(); + settings.consent.consent_store = Some("consent_store".to_string()); + let state = app_state_for_settings(settings); + let router = TrustedServerApp::routes_for_state(&state); + + let registry = + crate::registries::build_kv_registry(&trusted_server_core::stores::STORES_METADATA) + .expect("should build the KV registry from the declared stores"); + let mut req = request_builder() + .method(Method::POST) + .uri("/auction") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(json!({ "adUnits": [] }).to_string())) + .expect("should build auction request"); + req.extensions_mut().insert(registry); + + let response = route(&router, req); + + assert_ne!( + response.status(), + StatusCode::SERVICE_UNAVAILABLE, + "a declared consent store present in the injected KvRegistry must resolve, not fail closed" + ); + } + #[test] fn dispatch_fallback_asset_route_skips_ec_finalization() { // Parity guard for the configured asset-route fallback: a GET matching a @@ -2793,9 +2872,18 @@ mod tests { } fn streaming_runtime_services() -> RuntimeServices { + // Mirror the production shape: reads go through the registry-backed + // composite (no registry here — this route performs no config/secret + // read), writes delegate to the management-API stores. RuntimeServices::builder() - .config_store(Arc::new(crate::platform::FastlyPlatformConfigStore)) - .secret_store(Arc::new(crate::platform::FastlyPlatformSecretStore)) + .config_store(Arc::new(CompositeConfigStore::new( + None, + Arc::new(crate::platform::FastlyPlatformConfigStore), + ))) + .secret_store(Arc::new(CompositeSecretStore::new( + None, + Arc::new(crate::platform::FastlyPlatformSecretStore), + ))) .kv_store(Arc::new(NoopKvStore) as Arc) .backend(Arc::new(FixedBackend)) .http_client(Arc::new(StreamingHttpClient)) @@ -2980,8 +3068,14 @@ mod tests { let cache = Arc::new(DispatchTemplateCache::default()); let origin = Arc::new(DispatchOriginClient::default()); let services = RuntimeServices::builder() - .config_store(Arc::new(crate::platform::FastlyPlatformConfigStore)) - .secret_store(Arc::new(crate::platform::FastlyPlatformSecretStore)) + .config_store(Arc::new(CompositeConfigStore::new( + None, + Arc::new(crate::platform::FastlyPlatformConfigStore), + ))) + .secret_store(Arc::new(CompositeSecretStore::new( + None, + Arc::new(crate::platform::FastlyPlatformSecretStore), + ))) .kv_store(Arc::new(NoopKvStore) as Arc) .template_cache(Arc::clone(&cache) as Arc) .template_assembler(Arc::new(crate::esi_assembly::FastlyTemplateAssembler)) @@ -3014,7 +3108,7 @@ mod tests { let request = ctx.into_request(); let method = request.method().clone(); let mut ec_context = - match EcContext::read_from_request(&settings, &request, &services) { + match EcContext::read_from_request(&settings, &request, &services).await { Ok(context) => context, Err(report) => return Ok(super::http_error(&report)), }; diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 367a3e33f..a7dfe62f5 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -39,6 +39,7 @@ mod management_api; mod middleware; mod platform; mod rate_limiter; +mod registries; mod template_cache; mod tinybird; @@ -173,11 +174,29 @@ fn edgezero_main(mut req: FastlyRequest) { // Dispatch directly through the EdgeZero router without an intermediate // fastly::Response conversion. That preserves duplicate header values such // as multiple Set-Cookie headers. + // Build the EdgeZero store registries for this request. EdgeZero's own Fastly + // builders are private and this adapter dispatches through its own `oneshot`, + // so the registries are built locally and inserted into the request + // extensions, where `build_per_request_services` reads them back. + let stores = trusted_server_core::stores::STORES_METADATA; + let config_registry = registries::build_config_registry(&stores); + let kv_registry = registries::build_kv_registry(&stores); + let secret_registry = registries::build_secret_registry(&stores); + let mut response = match into_core_request(req) { Ok(mut core_req) => { core_req.extensions_mut().insert(config_store); core_req.extensions_mut().insert(device_signals); core_req.extensions_mut().insert(client_info); + if let Some(registry) = config_registry { + core_req.extensions_mut().insert(registry); + } + if let Some(registry) = kv_registry { + core_req.extensions_mut().insert(registry); + } + if let Some(registry) = secret_registry { + core_req.extensions_mut().insert(registry); + } match futures::executor::block_on(app.router().oneshot(core_req)) { Ok(response) => response, Err(error) => edge_error_response(error), diff --git a/crates/trusted-server-adapter-fastly/src/management_api.rs b/crates/trusted-server-adapter-fastly/src/management_api.rs index a8469bcca..48988a273 100644 --- a/crates/trusted-server-adapter-fastly/src/management_api.rs +++ b/crates/trusted-server-adapter-fastly/src/management_api.rs @@ -22,7 +22,7 @@ use base64::{Engine as _, engine::general_purpose}; use error_stack::{Report, ResultExt}; use fastly::http::{Method, StatusCode}; use fastly::{Request, Response}; -use trusted_server_core::platform::{PlatformError, PlatformSecretStore, StoreName}; +use trusted_server_core::platform::{PlatformError, StoreName}; use crate::backend::BackendConfig; use crate::platform::FastlyPlatformSecretStore; @@ -127,10 +127,15 @@ impl FastlyManagementApiClient { .change_context(PlatformError::Backend) .attach("failed to register Fastly management API backend")?; - let api_key = FastlyPlatformSecretStore - .get_string(&StoreName::from(API_KEYS_STORE), API_KEY_ENTRY) - .change_context(PlatformError::SecretStore) - .attach("failed to read Fastly API key from secret store")?; + // Deliberate direct (non-registry) read: `api-keys` is a Fastly-only + // management store, outside the logical store registry. See + // `FastlyPlatformSecretStore::read_management_secret`. + let api_key = FastlyPlatformSecretStore::read_management_secret( + &StoreName::from(API_KEYS_STORE), + API_KEY_ENTRY, + ) + .change_context(PlatformError::SecretStore) + .attach("failed to read Fastly API key from secret store")?; log::debug!("FastlyManagementApiClient: initialized for management API operations"); diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index a29b48fda..e2236df0f 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -3,24 +3,20 @@ use std::io::Read as _; use std::net::IpAddr; -use std::sync::Arc; use bytes::Bytes; -use edgezero_adapter_fastly::key_value_store::FastlyKvStore; -use edgezero_core::key_value_store::KvError; use error_stack::{Report, ResultExt}; use fastly::geo::{Geo, geo_lookup}; -use fastly::{ConfigStore, Request, SecretStore}; +use fastly::{Request, SecretStore}; use crate::backend::BackendConfig; pub(crate) use trusted_server_core::platform::UnavailableKvStore; use trusted_server_core::platform::{ BackendNamingPolicy, ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, - PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, + PlatformConfigWriter, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerCrop, PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, - PlatformImageOptimizerParams, PlatformImageOptimizerRegion, PlatformKvStore, - PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, StoreId, - StoreName, + PlatformImageOptimizerParams, PlatformImageOptimizerRegion, PlatformPendingRequest, + PlatformResponse, PlatformSecretWriter, PlatformSelectResult, StoreId, StoreName, }; use trusted_server_core::settings::TrustedClientIpConfig; @@ -28,11 +24,13 @@ use trusted_server_core::settings::TrustedClientIpConfig; // FastlyPlatformConfigStore // --------------------------------------------------------------------------- -/// Fastly [`ConfigStore`]-backed implementation of [`PlatformConfigStore`]. +/// Fastly management-API-backed **write-only** config store. /// -/// Stateless — the store name is supplied per call, matching the trait -/// signature. This replaces the store-name-at-construction pattern of -/// the legacy `FastlyConfigStore` (removed). +/// Stateless — the store id is supplied per call. Reads are *not* implemented: +/// every runtime config read resolves through the `EdgeZero` [`ConfigRegistry`] +/// behind +/// [`CompositeConfigStore`](trusted_server_core::platform::CompositeConfigStore), +/// so this type only supplies the composite's write delegate. /// /// # Write cost /// @@ -41,28 +39,17 @@ use trusted_server_core::settings::TrustedClientIpConfig; /// one request pay one round-trip per call. The `"api-keys"` secret store is /// opened per call to read the management token; the Fastly Compute SDK caches /// the open handle so that cost is negligible. +/// +/// [`ConfigRegistry`]: edgezero_core::store_registry::ConfigRegistry pub struct FastlyPlatformConfigStore; -impl PlatformConfigStore for FastlyPlatformConfigStore { - fn get(&self, store_name: &StoreName, key: &str) -> Result> { - let name = store_name.as_ref(); - let store = ConfigStore::try_open(name).map_err(|e| { - Report::new(PlatformError::ConfigStore) - .attach(format!("failed to open config store '{name}': {e}")) - })?; - store - .try_get(key) - .map_err(|e| { - Report::new(PlatformError::ConfigStore).attach(format!( - "lookup for key '{key}' in config store '{name}' failed: {e}" - )) - })? - .ok_or_else(|| { - Report::new(PlatformError::ConfigStore) - .attach(format!("key '{key}' not found in config store '{name}'")) - }) - } - +/// Write half supplied to +/// [`CompositeConfigStore`](trusted_server_core::platform::CompositeConfigStore) +/// as the management-API write delegate (key rotation). +/// +/// Core cannot implement this trait for an adapter-owned type (orphan rule), so +/// each adapter impls it for its own store. +impl PlatformConfigWriter for FastlyPlatformConfigStore { fn put(&self, store_id: &StoreId, key: &str, value: &str) -> Result<(), Report> { let client = crate::management_api::FastlyManagementApiClient::new()?; client.update_config_item(store_id.as_ref(), key, value) @@ -78,25 +65,44 @@ impl PlatformConfigStore for FastlyPlatformConfigStore { // FastlyPlatformSecretStore // --------------------------------------------------------------------------- -/// Fastly [`SecretStore`]-backed implementation of [`PlatformSecretStore`]. +/// Fastly [`SecretStore`]-backed **write-only** secret store, plus the one +/// management-store read described below. /// -/// Stateless — the store name is supplied per call. This replaces the -/// store-name-at-construction pattern of the legacy `FastlySecretStore` -/// (removed). +/// Stateless — the store id is supplied per call. Runtime secret reads resolve +/// through the `EdgeZero` [`SecretRegistry`] behind +/// [`CompositeSecretStore`](trusted_server_core::platform::CompositeSecretStore), +/// so this type implements no read trait. /// /// # Write cost /// /// `create` and `delete` have the same per-call /// [`crate::management_api::FastlyManagementApiClient`] cost described on /// [`FastlyPlatformConfigStore`]. +/// +/// [`SecretRegistry`]: edgezero_core::store_registry::SecretRegistry pub struct FastlyPlatformSecretStore; -impl PlatformSecretStore for FastlyPlatformSecretStore { - fn get_bytes( - &self, +impl FastlyPlatformSecretStore { + /// Reads a secret directly from a Fastly secret store, bypassing the + /// `EdgeZero` registry. + /// + /// **Do not "clean this up".** This is the single deliberate direct read + /// left on Fastly: [`crate::management_api::FastlyManagementApiClient`] + /// bootstraps itself from the `api-keys` secret store, a Fastly-only + /// *management* store that is intentionally **not** part of the logical + /// store registry (it is absent from `STORES_METADATA`, and its hyphenated + /// id could not be a registry id anyway). It therefore cannot be read + /// through the registry composite, and removing this method breaks every + /// management-API write, including key rotation. + /// + /// # Errors + /// + /// Returns [`PlatformError::SecretStore`] when the store cannot be opened, + /// the key does not exist, decryption fails, or the value is not UTF-8. + pub(crate) fn read_management_secret( store_name: &StoreName, key: &str, - ) -> Result, Report> { + ) -> Result> { let name = store_name.as_ref(); // Unlike ConfigStore::open (which panics), SecretStore::open already // returns Result — there is no try_open variant on SecretStore. @@ -115,15 +121,22 @@ impl PlatformSecretStore for FastlyPlatformSecretStore { Report::new(PlatformError::SecretStore) .attach(format!("key '{key}' not found in secret store '{name}'")) })?; - secret - .try_plaintext() - .map(|bytes| bytes.to_vec()) - .map_err(|e| { - Report::new(PlatformError::SecretStore) - .attach(format!("failed to decrypt secret '{key}': {e}")) - }) + let bytes = secret.try_plaintext().map_err(|e| { + Report::new(PlatformError::SecretStore) + .attach(format!("failed to decrypt secret '{key}': {e}")) + })?; + String::from_utf8(bytes.to_vec()).map_err(|e| { + Report::new(PlatformError::SecretStore) + .attach(format!("secret '{key}' is not valid UTF-8: {e}")) + }) } +} +/// Write half supplied to +/// [`CompositeSecretStore`](trusted_server_core::platform::CompositeSecretStore) +/// as the management-API write delegate (see [`PlatformConfigWriter`] above for +/// why the impl lives in the adapter). +impl PlatformSecretWriter for FastlyPlatformSecretStore { fn create( &self, store_id: &StoreId, @@ -729,16 +742,6 @@ pub fn client_info_from_request(req: &Request, client_ip: Option) -> Cli } } -/// Open a named KV store as a [`PlatformKvStore`] implementation. -/// -/// # Errors -/// -/// Returns [`KvError::Unavailable`] when the store does not exist, or -/// [`KvError::Internal`] when the Fastly SDK fails to open it. -pub fn open_kv_store(store_name: &str) -> Result, KvError> { - FastlyKvStore::open(store_name).map(|store| Arc::new(store) as Arc) -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-adapter-fastly/src/registries.rs b/crates/trusted-server-adapter-fastly/src/registries.rs new file mode 100644 index 000000000..fb36b245d --- /dev/null +++ b/crates/trusted-server-adapter-fastly/src/registries.rs @@ -0,0 +1,296 @@ +//! Per-request store registries for the Fastly adapter. +//! +//! `EdgeZero`'s own Fastly registry builders (and `dispatch_with_registries`) are +//! private to `edgezero-adapter-fastly`, and this adapter drives its own +//! `oneshot` dispatch from `main.rs`. So the three registries are built here and +//! inserted into the request extensions, where +//! [`build_per_request_services`](crate::app) reads them back to construct the +//! registry-backed composite config/secret stores and the named-KV surface. +//! +//! Every store opens **by logical id** (decision D7 — the logical id equals the +//! physical Fastly store name; an operator who needs a different physical name +//! remaps it at provisioning time, not at runtime). +//! +//! # Failure policy +//! +//! A per-store open failure is **non-fatal**: the id is logged and dropped from +//! the registry. Eagerly opening every declared id and failing the request when +//! any one of them is unavailable would let a single unprovisioned or deprecated +//! store (e.g. `creative_store`) break all traffic, converting Fastly's lazy, +//! per-route fail-closed model into eager fail-everything. When the **default** +//! id cannot be assembled, [`StoreRegistry::from_parts`] yields `None` and the +//! registry is not inserted at all; the strict composite then errors on first +//! read rather than silently falling back to a default store. + +use std::collections::BTreeMap; +use std::io; +use std::sync::Arc; + +use async_trait::async_trait; +use edgezero_adapter_fastly::config_store::FastlyConfigStore; +use edgezero_adapter_fastly::key_value_store::FastlyKvStore; +use edgezero_adapter_fastly::runtime_env_config; +use edgezero_adapter_fastly::secret_store::FastlySecretStore; +use edgezero_core::app::StoresMetadata; +use edgezero_core::config_store::{ + ConfigStore as EdgeConfigStore, ConfigStoreError, ConfigStoreHandle, +}; +use edgezero_core::key_value_store::KvHandle; +use edgezero_core::secret_store::SecretHandle; +use edgezero_core::store_registry::{ + BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, StoreRegistry, +}; +use fastly::ConfigStore; +use fastly::config_store::OpenError; +use trusted_server_core::stores::RUNTIME_ONLY_SECRET_IDS; + +/// Plain key/value reader over a Fastly Config Store. +/// +/// Deliberately **not** `EdgeZero`'s [`FastlyConfigStore`]: that store treats +/// every value as an app-config `BlobEnvelope` (or a chunk pointer to one) and +/// rejects anything else as corrupt platform state. That is right for the +/// app-config blob store and wrong for every other config store Trusted Server +/// declares, whose values are plain scalars — `jwks_store` holds +/// `active-kids`/`current-kid` strings and raw JWK JSON, `datadome_ip_bypass` +/// holds CIDR entries. Reading those through the envelope-aware store fails with +/// "value at key `…` is neither a valid `BlobEnvelope` nor a valid chunk +/// pointer", which would break discovery, signature verification, key rotation, +/// and the `DataDome` IP bypass. +/// +/// This reads exactly as the pre-registry +/// [`FastlyPlatformConfigStore`](crate::platform::FastlyPlatformConfigStore) +/// read did, so the registry cutover is behavior-preserving. (That read impl is +/// now gone: the store is write-only and all reads come through this registry.) +/// +/// **Do not collapse the two store kinds into one.** See +/// [`build_config_registry`] for which id gets which. +struct PlainFastlyConfigStore { + inner: ConfigStore, +} + +impl PlainFastlyConfigStore { + /// Open the Fastly Config Store named `name` for plain reads. + fn try_open(name: &str) -> Result { + ConfigStore::try_open(name).map(|inner| Self { inner }) + } +} + +#[async_trait(?Send)] +impl EdgeConfigStore for PlainFastlyConfigStore { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + self.inner.try_get(key).map_err(|error| { + ConfigStoreError::internal(io::Error::other(format!( + "config store lookup failed for `{key}`: {error}" + ))) + }) + } +} + +/// Build the config registry, opening one Fastly Config Store per declared id. +/// +/// The two kinds of config store bind to **different** readers, because their +/// contents differ: +/// +/// - the **default** id (`trusted_server_config`) holds the app-config +/// `BlobEnvelope` — possibly split across chunk entries — so it binds to +/// `EdgeZero`'s chunk-aware [`FastlyConfigStore`], which reassembles and +/// verifies it transparently; +/// - every **non-default** id (`jwks_store`, `datadome_ip_bypass`) holds plain +/// scalar values, so it binds to [`PlainFastlyConfigStore`]. The envelope-aware +/// store would reject those values as corrupt (see its doc comment) — this +/// split is load-bearing, not an accident. +/// +/// Under D7 each binding's `default_key` is the logical id itself. A store that +/// fails to open is logged and dropped; returns `None` when no config stores are +/// declared or the default id could not be opened. +#[must_use] +pub(crate) fn build_config_registry(stores: &StoresMetadata) -> Option { + let meta = stores.config?; + let env = runtime_env_config(*stores); + let mut by_id: BTreeMap = BTreeMap::new(); + for id in meta.ids { + // The registry key stays the logical id; the opened store is the + // physical name, which the runtime-env mapping may override. + let name = env.store_name("config", id); + let handle = if *id == meta.default { + match FastlyConfigStore::try_open(&name) { + Ok(store) => ConfigStoreHandle::new(Arc::new(store)), + Err(error) => { + log::warn!( + "Fastly app-config store `{id}` could not be opened: {error}; \ + dropping it from the registry" + ); + continue; + } + } + } else { + match PlainFastlyConfigStore::try_open(&name) { + Ok(store) => ConfigStoreHandle::new(Arc::new(store)), + Err(error) => { + log::warn!( + "Fastly config store `{id}` could not be opened: {error}; \ + dropping it from the registry" + ); + continue; + } + } + }; + by_id.insert( + (*id).to_owned(), + ConfigStoreBinding { + handle, + default_key: (*id).to_owned(), + }, + ); + } + warn_if_default_missing("config", &by_id, meta.default); + StoreRegistry::from_parts(by_id, meta.default.to_owned()) +} + +/// Build the KV registry, opening one Fastly KV Store per declared id. +/// +/// A per-store open failure is non-fatal (see the module-level failure policy). +/// Returns `None` when no KV stores are declared or the default id could not be +/// opened. +#[must_use] +pub(crate) fn build_kv_registry(stores: &StoresMetadata) -> Option { + let meta = stores.kv?; + let env = runtime_env_config(*stores); + let mut by_id: BTreeMap = BTreeMap::new(); + for id in meta.ids { + match FastlyKvStore::open(&env.store_name("kv", id)) { + Ok(store) => { + by_id.insert((*id).to_owned(), KvHandle::new(Arc::new(store))); + } + Err(error) => log::warn!( + "Fastly KV store `{id}` could not be opened: {error}; \ + dropping it from the registry" + ), + } + } + warn_if_default_missing("KV", &by_id, meta.default); + StoreRegistry::from_parts(by_id, meta.default.to_owned()) +} + +/// Build the secret registry. +/// +/// [`FastlySecretStore`] is stateless — it opens the named Fastly Secret Store +/// on each `get_bytes(store_name, key)` call — so one provider handle is shared +/// across every binding. Each declared id binds to its physical store name +/// (the logical id, unless the runtime-env mapping overrides it), and the +/// management-provisioned [`RUNTIME_ONLY_SECRET_IDS`] bind on top — they are +/// deliberately absent from `[stores.secrets]` (see their doc). Returns `None` +/// when no secret stores are declared. +#[must_use] +pub(crate) fn build_secret_registry(stores: &StoresMetadata) -> Option { + let meta = stores.secrets?; + let env = runtime_env_config(*stores); + let handle = SecretHandle::new(Arc::new(FastlySecretStore)); + let mut by_id: BTreeMap = BTreeMap::new(); + for id in meta.ids.iter().chain(RUNTIME_ONLY_SECRET_IDS) { + by_id.insert( + (*id).to_owned(), + BoundSecretStore::new(handle.clone(), env.store_name("secrets", id)), + ); + } + StoreRegistry::from_parts(by_id, meta.default.to_owned()) +} + +/// Logs when the default id of a kind could not be assembled, which drops the +/// whole registry. +fn warn_if_default_missing(kind: &str, by_id: &BTreeMap, default_id: &str) { + if !by_id.contains_key(default_id) { + log::warn!( + "Fastly {kind} registry default id `{default_id}` could not be opened; \ + dropping the {kind} registry" + ); + } +} + +#[cfg(test)] +mod tests { + use super::{build_config_registry, build_kv_registry, build_secret_registry}; + use trusted_server_core::stores::STORES_METADATA; + + #[test] + fn build_config_registry_resolves_declared_ids() { + // Arrange + Act: build from the shared store metadata against the + // Viceroy-provisioned config stores. + let registry = + build_config_registry(&STORES_METADATA).expect("should build the config registry"); + + // Assert: the default id and a declared non-default id both resolve. + assert!( + registry.default().is_some(), + "the default config store id should resolve" + ); + assert_eq!( + registry.default_id(), + "trusted_server_config", + "the registry default id should come from the shared store metadata" + ); + assert!( + registry.named("jwks_store").is_some(), + "the declared non-default `jwks_store` id should resolve" + ); + + // An id that is not declared is strictly absent — never a fallback. + assert!( + registry.named("nope").is_none(), + "an undeclared config id should not resolve" + ); + } + + #[test] + fn config_registry_reads_plain_values_from_non_default_stores() { + // Regression guard for the envelope-only `FastlyConfigStore`: non-default + // config stores hold plain scalars (here `jwks_store`'s `active-kids`), + // not app-config BlobEnvelopes. Binding them to EdgeZero's envelope-aware + // store makes every such read fail as "corrupt", which would break + // discovery, signature verification, key rotation, and the DataDome IP + // bypass. They must read back verbatim. + let registry = + build_config_registry(&STORES_METADATA).expect("should build the config registry"); + let binding = registry + .named("jwks_store") + .expect("the declared `jwks_store` id should resolve"); + + let value = futures::executor::block_on(binding.handle.get("active-kids")) + .expect("a plain config value should read without envelope validation") + .expect("`active-kids` should be present in the jwks_store"); + + assert!( + value.contains("ts-2025-10-A"), + "should return the plain config value verbatim, not an envelope-decode error" + ); + } + + #[test] + fn build_kv_registry_resolves_declared_ids() { + let registry = build_kv_registry(&STORES_METADATA).expect("should build the KV registry"); + + assert!( + registry.named("consent_store").is_some(), + "the declared `consent_store` id should resolve so consent can select it by name" + ); + assert!( + registry.named("nope").is_none(), + "an undeclared KV id should not resolve" + ); + } + + #[test] + fn build_secret_registry_binds_every_declared_id() { + let registry = + build_secret_registry(&STORES_METADATA).expect("should build the secret registry"); + + assert!( + registry.named("signing_keys").is_some(), + "the runtime-only `signing_keys` id should bind to its own Fastly secret store" + ); + assert!( + registry.named("nope").is_none(), + "an undeclared secret id should not resolve" + ); + } +} diff --git a/crates/trusted-server-adapter-fastly/src/tinybird.rs b/crates/trusted-server-adapter-fastly/src/tinybird.rs index f315a7b56..4ead23d5b 100644 --- a/crates/trusted-server-adapter-fastly/src/tinybird.rs +++ b/crates/trusted-server-adapter-fastly/src/tinybird.rs @@ -222,8 +222,9 @@ mod tests { struct NoopConfigStore; + #[async_trait::async_trait(?Send)] impl PlatformConfigStore for NoopConfigStore { - fn get( + async fn get( &self, _store_name: &StoreName, _key: &str, @@ -247,8 +248,9 @@ mod tests { struct MapSecretStore(HashMap>); + #[async_trait::async_trait(?Send)] impl PlatformSecretStore for MapSecretStore { - fn get_bytes( + async fn get_bytes( &self, _store_name: &StoreName, key: &str, diff --git a/crates/trusted-server-adapter-spin/spin.toml b/crates/trusted-server-adapter-spin/spin.toml index 7055f9563..d03a530e2 100644 --- a/crates/trusted-server-adapter-spin/spin.toml +++ b/crates/trusted-server-adapter-spin/spin.toml @@ -41,7 +41,20 @@ source = "../../target/wasm32-wasip1/release/trusted_server_adapter_spin.wasm" # origins are still served over plaintext http. Follow-up: scope this to the # configured origins once they can be enumerated from settings. allowed_outbound_hosts = ["https://*:*", "http://*:*"] -key_value_stores = ["default"] +# Spin is KV-backed for both KV and config stores, so every declared logical +# store id (from edgezero.toml `[stores.kv]` and `[stores.config]`) must be +# labeled here for the registry to open it at dispatch. Secrets resolve to flat +# component variables (see `[component.trusted-server.variables]`), not KV labels. +key_value_stores = [ + "default", + "trusted_server_kv", + "ec_identity_store", + "consent_store", + "creative_store", + "trusted_server_config", + "jwks_store", + "datadome_ip_bypass", +] [component.trusted-server.variables] v_trusted_x5fserver_x5fsecrets_v_publisher_x5fproxy_x5fsecret = "{{ v_trusted_x5fserver_x5fsecrets_v_publisher_x5fproxy_x5fsecret }}" diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 290de5aec..fb2ff82e4 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -4,7 +4,7 @@ use std::sync::Arc; #[cfg(all(feature = "spin", target_arch = "wasm32"))] use edgezero_adapter_spin::config_store::SpinConfigStore; use edgezero_adapter_spin::context::SpinRequestContext; -use edgezero_core::app::Hooks; +use edgezero_core::app::{Hooks, StoresMetadata}; #[cfg(all(feature = "spin", target_arch = "wasm32"))] use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::context::RequestContext; @@ -94,16 +94,23 @@ fn load_startup_settings() -> Result> { })?; let config_handle = ConfigStoreHandle::new(Arc::new(config_store)); let config_adapter = ConfigStoreHandleAdapter(config_handle); - let raw_envelope = config_adapter - .get(&config_store_name, &config_key) - .map_err(|error| { - Report::new(TrustedServerError::Configuration { - message: "failed to read Spin Trusted Server app-config blob".to_string(), - }) - .attach(error.to_string()) - })?; + // Startup-only reads: this runs during component construction, outside the + // request executor, so a top-level `block_on` cannot nest executors. + let raw_envelope = futures::executor::block_on( + config_adapter.get(&config_store_name, &config_key), + ) + .map_err(|error| { + Report::new(TrustedServerError::Configuration { + message: "failed to read Spin Trusted Server app-config blob".to_string(), + }) + .attach(error.to_string()) + })?; let secret_store = SpinSecretStoreAdapter; - settings_from_config_blob(&raw_envelope, &secret_store, &default_secret_store_name()) + futures::executor::block_on(settings_from_config_blob( + &raw_envelope, + &secret_store, + &default_secret_store_name(), + )) } #[cfg(not(all(feature = "spin", target_arch = "wasm32")))] @@ -397,7 +404,11 @@ fn health_response() -> Response { /// the request carries TCF consent. A malformed consent string is logged and /// falls back to the default (fail-closed) context rather than being silently /// swallowed. -fn build_ec_context(settings: &Settings, services: &RuntimeServices, req: &Request) -> EcContext { +async fn build_ec_context( + settings: &Settings, + services: &RuntimeServices, + req: &Request, +) -> EcContext { let geo_info = services .geo() .lookup(services.client_info().client_ip) @@ -406,6 +417,7 @@ fn build_ec_context(settings: &Settings, services: &RuntimeServices, req: &Reque None }); EcContext::read_from_request_with_geo(settings, req, services, geo_info.as_ref()) + .await .unwrap_or_else(|e| { log::warn!("EC context read failed: {e:?}"); EcContext::default() @@ -529,6 +541,10 @@ impl Hooks for TrustedServerApp { build_router(&state) } + + fn stores() -> StoresMetadata { + trusted_server_core::stores::STORES_METADATA + } } impl TrustedServerApp { @@ -562,6 +578,7 @@ fn build_router(state: &Arc) -> RouterService { let services = build_runtime_services(&ctx); let req = ctx.into_request(); Ok(handle_trusted_server_discovery(&s.settings, &services, req) + .await .unwrap_or_else(|e| http_error(&e))) } }; @@ -574,6 +591,7 @@ fn build_router(state: &Arc) -> RouterService { let services = build_runtime_services(&ctx); let req = ctx.into_request(); Ok(handle_verify_signature(&s.settings, &services, req) + .await .unwrap_or_else(|e| http_error(&e))) } }; @@ -622,7 +640,7 @@ fn build_router(state: &Arc) -> RouterService { // Build the geo-aware EC context so the auction consent gate sees // the caller's jurisdiction — `EcContext::default()` fails it // closed for consented users. - let mut ec_context = build_ec_context(&s.settings, &services, &req); + let mut ec_context = build_ec_context(&s.settings, &services, &req).await; Ok(handle_auction( &s.settings, &s.orchestrator, @@ -652,7 +670,7 @@ fn build_router(state: &Arc) -> RouterService { { return Ok(http_error(&error)); } - let mut ec_context = build_ec_context(&s.settings, &services, &req); + let mut ec_context = build_ec_context(&s.settings, &services, &req).await; let auction = AuctionDispatch { orchestrator: &s.orchestrator, slots: s.settings.creative_opportunity_slots(), @@ -775,7 +793,7 @@ fn build_router(state: &Arc) -> RouterService { })) }) } else { - let mut ec_context = build_ec_context(&state.settings, &services, &req); + let mut ec_context = build_ec_context(&state.settings, &services, &req).await; let auction = AuctionDispatch { orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index a81911186..089d6f0ae 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -3,15 +3,20 @@ use std::sync::Arc; use std::time::Duration; use bytes::Bytes; +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::key_value_store::{KvHandle, KvPage, KvStore}; +use edgezero_core::store_registry::{ConfigRegistry, KvRegistry, SecretRegistry}; use error_stack::{Report, ResultExt as _}; #[cfg(all(feature = "spin", target_arch = "wasm32"))] use http_body_util::BodyExt as _; +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +use trusted_server_core::platform::StoreName; use trusted_server_core::platform::{ - BackendNamingPolicy, ClientInfo, GeoInfo, KvError, PlatformBackend, PlatformBackendSpec, - PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformKvStore, - PlatformSecretStore, RuntimeServices, StoreId, StoreName, UnavailableKvStore, + BackendNamingPolicy, ClientInfo, CompositeConfigStore, CompositeSecretStore, GeoInfo, KvError, + PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformConfigWriter, PlatformError, + PlatformGeo, PlatformHttpClient, PlatformKvStore, PlatformSecretStore, PlatformSecretWriter, + RuntimeServices, StoreId, UnavailableKvStore, }; #[cfg(not(all(feature = "spin", target_arch = "wasm32")))] @@ -41,40 +46,43 @@ type BufferedResponseParts = (HeaderPairs, Vec); const SPIN_VARIABLE_HEX: &[u8; 16] = b"0123456789abcdef"; // --------------------------------------------------------------------------- -// Noop stubs — used when a handle is absent (native CI, missing binding) +// Write delegates — Spin has no runtime config/secret write API // --------------------------------------------------------------------------- +/// Write-only config store for Spin. +/// +/// Config reads resolve through the `EdgeZero` config registry behind +/// [`CompositeConfigStore`]; Spin exposes no config write API, so writes error. struct NoopConfigStore; -impl PlatformConfigStore for NoopConfigStore { - fn get(&self, _: &StoreName, _: &str) -> Result> { - Err(Report::new(PlatformError::ConfigStore).attach("config store not available")) - } - +impl PlatformConfigWriter for NoopConfigStore { fn put(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { - Err(Report::new(PlatformError::ConfigStore).attach("config store writes are not supported")) + Err(Report::new(PlatformError::ConfigStore) + .attach("config store writes are not supported on Spin")) } fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { - Err(Report::new(PlatformError::ConfigStore).attach("config store writes are not supported")) + Err(Report::new(PlatformError::ConfigStore) + .attach("config store deletes are not supported on Spin")) } } -#[cfg(not(all(feature = "spin", target_arch = "wasm32")))] +/// Write-only secret store for Spin. +/// +/// Secret reads resolve through the `EdgeZero` secret registry behind +/// [`CompositeSecretStore`]; Spin secrets are manifest-declared component +/// variables with no runtime write API, so writes error. struct NoopSecretStore; -#[cfg(not(all(feature = "spin", target_arch = "wasm32")))] -impl PlatformSecretStore for NoopSecretStore { - fn get_bytes(&self, _: &StoreName, _: &str) -> Result, Report> { - Err(Report::new(PlatformError::SecretStore).attach("secret store not available")) - } - +impl PlatformSecretWriter for NoopSecretStore { fn create(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { - Err(Report::new(PlatformError::SecretStore).attach("secret store not available")) + Err(Report::new(PlatformError::SecretStore) + .attach("secret store writes are not supported on Spin")) } fn delete(&self, _: &StoreId, _: &str) -> Result<(), Report> { - Err(Report::new(PlatformError::SecretStore).attach("secret store not available")) + Err(Report::new(PlatformError::SecretStore) + .attach("secret store deletes are not supported on Spin")) } } @@ -106,11 +114,23 @@ impl PlatformBackend for NoopBackend { /// Spin config stores are KV-backed, so reads preserve the requested key /// verbatim. Writes are unsupported on current Spin runtime config and return /// typed errors. +/// +/// Only the Spin WASM boot path constructs this (request-time reads resolve +/// through the registry composite), so it is compiled out elsewhere. +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] pub(crate) struct ConfigStoreHandleAdapter(pub(crate) ConfigStoreHandle); +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +#[async_trait::async_trait(?Send)] impl PlatformConfigStore for ConfigStoreHandleAdapter { - fn get(&self, _store_name: &StoreName, key: &str) -> Result> { - futures::executor::block_on(self.0.get(key)) + async fn get( + &self, + _store_name: &StoreName, + key: &str, + ) -> Result> { + self.0 + .get(key) + .await .map_err(|error| { Report::new(PlatformError::ConfigStore).attach(format!( "config store lookup failed for key `{key}`: {error}" @@ -672,14 +692,15 @@ fn into_spin_method(method: &edgezero_core::http::Method) -> spin_sdk::http::Met pub(crate) struct SpinSecretStoreAdapter; #[cfg(all(feature = "spin", target_arch = "wasm32"))] +#[async_trait::async_trait(?Send)] impl PlatformSecretStore for SpinSecretStoreAdapter { - fn get_bytes( + async fn get_bytes( &self, store_name: &StoreName, key: &str, ) -> Result, Report> { let variable_name = spin_secret_variable_name(store_name, key)?; - match futures::executor::block_on(spin_sdk::variables::get(&variable_name)) { + match spin_sdk::variables::get(&variable_name).await { Ok(value) => Ok(value.into_bytes()), Err(error) => Err(Report::new(PlatformError::SecretStore).attach(format!( "secret lookup failed for key `{key}` as Spin variable `{variable_name}`: {error}" @@ -704,9 +725,10 @@ impl PlatformSecretStore for SpinSecretStoreAdapter { /// Construct [`RuntimeServices`] for an incoming Spin request. /// -/// Config and KV are sourced from the `EdgeZero` handles that `run_app` injects -/// before routing. Secrets are read synchronously from Spin component -/// variables because Trusted Server's platform secret trait is sync. +/// Config, secret, and KV reads resolve through the `EdgeZero` store registries +/// that `run_app` injects into the request extensions before routing. Spin has +/// no runtime config- or secret-store write API, so both write delegates handed +/// to the composites reject writes. #[must_use] pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { let client_ip = extract_client_ip(ctx); @@ -716,25 +738,36 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R #[cfg(not(all(feature = "spin", target_arch = "wasm32")))] let http_client: Arc = Arc::new(UnavailableHttpClient); - let config_store: Arc = ctx - .config_store_default() - .map(|h| Arc::new(ConfigStoreHandleAdapter(h)) as Arc) - .unwrap_or_else(|| Arc::new(NoopConfigStore)); - + // Config reads resolve through the whole ConfigRegistry (from request + // extensions) so non-default logical ids (e.g. `jwks_store`) resolve. An + // absent registry makes composite reads error rather than silently reading a + // default store. + let config_reader = ctx.request().extensions().get::().cloned(); + let config_writer: Arc = Arc::new(NoopConfigStore); + let config_store: Arc = + Arc::new(CompositeConfigStore::new(config_reader, config_writer)); + + // KV: the default request-path store stays the run_app-injected handle; the + // full KvRegistry is carried separately for `kv_handle_named` lookups. let kv_store: Arc = ctx .kv_store_default() .map(|h| Arc::new(KvHandleAdapter(h)) as Arc) .unwrap_or_else(|| Arc::new(UnavailableKvStore)); + let kv_registry = ctx.request().extensions().get::().cloned(); - #[cfg(all(feature = "spin", target_arch = "wasm32"))] - let secret_store: Arc = Arc::new(SpinSecretStoreAdapter); - #[cfg(not(all(feature = "spin", target_arch = "wasm32")))] - let secret_store: Arc = Arc::new(NoopSecretStore); + // Secret reads resolve through the whole SecretRegistry (from request + // extensions). Spin component variables are manifest-declared with no runtime + // write API, so the write delegate rejects writes. + let secret_reader = ctx.request().extensions().get::().cloned(); + let secret_writer: Arc = Arc::new(NoopSecretStore); + let secret_store: Arc = + Arc::new(CompositeSecretStore::new(secret_reader, secret_writer)); RuntimeServices::builder() .config_store(config_store) .secret_store(secret_store) .kv_store(kv_store) + .kv_registry(kv_registry) .backend(Arc::new(NoopBackend)) .http_client(http_client) .geo(Arc::new(NullGeo)) @@ -782,6 +815,190 @@ fn extract_client_ip(ctx: &edgezero_core::context::RequestContext) -> Option, + } + + #[async_trait(?Send)] + impl ConfigStore for MemConfigStore { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + Ok(self.data.get(key).cloned()) + } + } + + /// In-memory [`SecretStore`] double keyed by `"{store_name}/{key}"`. + struct MemSecretStore { + data: HashMap, + } + + #[async_trait(?Send)] + impl SecretStore for MemSecretStore { + async fn get_bytes( + &self, + store_name: &str, + key: &str, + ) -> Result, SecretError> { + Ok(self.data.get(&format!("{store_name}/{key}")).cloned()) + } + } + + /// In-memory [`KvStore`] double. + #[derive(Default)] + struct MemKvStore { + data: Mutex>, + } + + #[async_trait(?Send)] + impl KvStore for MemKvStore { + async fn get_bytes(&self, key: &str) -> Result, KvError> { + Ok(self.data.lock().expect("should lock").get(key).cloned()) + } + + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + self.data + .lock() + .expect("should lock") + .insert(key.to_owned(), value); + Ok(()) + } + + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + _ttl: std::time::Duration, + ) -> Result<(), KvError> { + self.put_bytes(key, value).await + } + + async fn delete(&self, key: &str) -> Result<(), KvError> { + self.data.lock().expect("should lock").remove(key); + Ok(()) + } + + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Ok(KvPage::default()) + } + } + + /// Build a [`ConfigRegistry`] from `(store_id, key, value)` entries. + pub(super) fn config_registry(entries: &[(&str, &str, &str)], default: &str) -> ConfigRegistry { + let mut by_store: BTreeMap> = BTreeMap::new(); + for (id, key, value) in entries { + by_store + .entry((*id).to_owned()) + .or_default() + .insert((*key).to_owned(), (*value).to_owned()); + } + let by_id: BTreeMap = by_store + .into_iter() + .map(|(id, data)| { + let binding = ConfigStoreBinding { + default_key: id.clone(), + handle: ConfigStoreHandle::new(Arc::new(MemConfigStore { data })), + }; + (id, binding) + }) + .collect(); + StoreRegistry::from_parts(by_id, default.to_owned()) + .expect("should build a non-empty config registry") + } + + /// Build a [`SecretRegistry`] from `(store_id, key, value)` entries. + pub(super) fn secret_registry( + entries: &[(&str, &str, &[u8])], + default: &str, + ) -> SecretRegistry { + let mut data: HashMap = HashMap::new(); + let mut ids: BTreeMap = BTreeMap::new(); + for (id, key, value) in entries { + data.insert(format!("{id}/{key}"), Bytes::copy_from_slice(value)); + ids.insert((*id).to_owned(), ()); + } + let handle = SecretHandle::new(Arc::new(MemSecretStore { data })); + let by_id: BTreeMap = ids + .into_keys() + .map(|id| { + let bound = BoundSecretStore::new(handle.clone(), id.clone()); + (id, bound) + }) + .collect(); + StoreRegistry::from_parts(by_id, default.to_owned()) + .expect("should build a non-empty secret registry") + } + + /// Build a [`KvRegistry`] from `(store_id, key, value)` entries; each id maps + /// to its own in-memory store so distinct ids are observably distinct. + pub(super) fn kv_registry(entries: &[(&str, &str, &[u8])], default: &str) -> KvRegistry { + let mut by_store: BTreeMap> = BTreeMap::new(); + for (id, key, value) in entries { + let store = by_store.entry((*id).to_owned()).or_default(); + store + .data + .lock() + .expect("should lock") + .insert((*key).to_owned(), Bytes::copy_from_slice(value)); + } + let by_id: BTreeMap = by_store + .into_iter() + .map(|(id, store)| (id, KvHandle::new(store))) + .collect(); + StoreRegistry::from_parts(by_id, default.to_owned()) + .expect("should build a non-empty kv registry") + } + + /// Build a [`RequestContext`] with the three registries inserted into request + /// extensions, mirroring what `run_app` does before routing. + pub(super) fn test_context_with_registries( + config: Option, + kv: Option, + secrets: Option, + ) -> RequestContext { + let mut builder = request_builder().method("GET").uri("https://example.com/"); + if let Some(config) = config { + builder = builder.extension(config); + } + if let Some(kv) = kv { + builder = builder.extension(kv); + } + if let Some(secrets) = secrets { + builder = builder.extension(secrets); + } + let req = builder + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + RequestContext::new(req, PathParams::default()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -805,6 +1022,133 @@ mod tests { use flate2::Compression; use flate2::write::GzEncoder; use std::io::Write as _; + use trusted_server_core::platform::StoreName; + + use super::registry_test_support::{ + config_registry, kv_registry, secret_registry, test_context_with_registries, + }; + + #[test] + fn config_store_resolves_nondefault_jwks_store() { + // Arrange: registry with the default config store plus a non-default + // `jwks_store` (D5: default config id is `trusted_server_config`). + let config = config_registry( + &[ + ("trusted_server_config", "current-kid", "kid-1"), + ("jwks_store", "kid-1", "{\"kty\":\"OKP\"}"), + ], + "trusted_server_config", + ); + let ctx = test_context_with_registries(Some(config), None, None); + let services = build_runtime_services(&ctx); + + let jwk = futures::executor::block_on( + services + .config_store() + .get(&StoreName::from("jwks_store"), "kid-1"), + ) + .expect("should resolve the non-default jwks_store through the composite"); + assert_eq!( + jwk, "{\"kty\":\"OKP\"}", + "should read the seeded value from the non-default config store" + ); + + assert!( + futures::executor::block_on( + services + .config_store() + .get(&StoreName::from("no_such_store"), "kid-1") + ) + .is_err(), + "unknown config id should error, not fall back to the default store" + ); + } + + #[test] + fn secret_store_resolves_nondefault_ts_secrets_and_s3_auth() { + let secrets = secret_registry( + &[ + ("trusted_server_secrets", "API_KEY", b"default-key"), + ("ts_secrets", "server-side-key", b"dd-secret"), + ("s3_auth", "aws-secret-access-key", b"s3-secret"), + ], + "trusted_server_secrets", + ); + let ctx = test_context_with_registries(None, None, Some(secrets)); + let services = build_runtime_services(&ctx); + + let dd = futures::executor::block_on( + services + .secret_store() + .get_bytes(&StoreName::from("ts_secrets"), "server-side-key"), + ) + .expect("should resolve ts_secrets through the composite"); + assert_eq!(dd, b"dd-secret", "should read the seeded DataDome secret"); + + let s3 = futures::executor::block_on( + services + .secret_store() + .get_bytes(&StoreName::from("s3_auth"), "aws-secret-access-key"), + ) + .expect("should resolve s3_auth through the composite"); + assert_eq!(s3, b"s3-secret", "should read the seeded S3 secret"); + + assert!( + futures::executor::block_on( + services + .secret_store() + .get_bytes(&StoreName::from("no_such_store"), "x") + ) + .is_err(), + "unknown secret id should error, not fall back to the default store" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn kv_handle_named_resolves_consent_store() { + // Both ids carry the same key with different values, so a named lookup + // that resolved to the default store would be caught. + let kv = kv_registry( + &[ + ("trusted_server_kv", "marker", b"default-value"), + ("consent_store", "marker", b"consent-value"), + ], + "trusted_server_kv", + ); + let ctx = test_context_with_registries(None, Some(kv), None); + let services = build_runtime_services(&ctx); + + let named = services + .kv_handle_named("consent_store") + .expect("should resolve the consent_store handle"); + let named_value = named + .get_bytes("marker") + .await + .expect("should read from consent_store") + .expect("should find the seeded key"); + assert_eq!( + named_value.as_ref(), + b"consent-value", + "named lookup should read consent_store, not the default store" + ); + + let default_value = services + .kv_handle() + .get_bytes("marker") + .await + .expect("should read from the default KV store") + .expect("should find the seeded key"); + assert_eq!( + default_value.as_ref(), + b"default-value", + "the default request-path KV store must stay distinct from consent_store" + ); + + assert!( + services.kv_handle_named("no_such_store").is_none(), + "unknown KV id should resolve to None" + ); + } struct InMemoryConfigStore(std::collections::BTreeMap); @@ -941,12 +1285,11 @@ mod tests { ))); let adapter = ConfigStoreHandleAdapter(handle); - let value = adapter - .get( - &StoreName::from("trusted_server_config"), - "trusted_server_config", - ) - .expect("should read the verbatim config-store key"); + let value = futures::executor::block_on(adapter.get( + &StoreName::from("trusted_server_config"), + "trusted_server_config", + )) + .expect("should read the verbatim config-store key"); assert_eq!( value, "blob-envelope", @@ -1062,6 +1405,7 @@ mod tests { services .config_store() .get(&StoreName::from("config"), "missing") + .await .is_err(), "should return typed config error without injected handle" ); @@ -1351,26 +1695,6 @@ mod tests { ); } - #[test] - fn spin_variable_name_rejects_digit_leading_key() { - // Encoder enforces the lowercase-letter start contract at the boundary so - // no caller can produce aliasing (e.g. "1foo" and "n1foo" would collide). - let result = spin_variable_name("1foo", PlatformError::ConfigStore); - assert!( - result.is_err(), - "should reject digit-leading key at the encoder boundary" - ); - } - - #[test] - fn spin_variable_name_rejects_uppercase_leading_key() { - let result = spin_variable_name("Foo", PlatformError::ConfigStore); - assert!( - result.is_err(), - "should reject uppercase-leading key at the encoder boundary" - ); - } - #[test] fn wasi_forbidden_headers_are_identified() { for header in &[ diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index c0c0a7792..62d984c71 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -9,7 +9,9 @@ use serde_json::Value as JsonValue; use crate::auction::formats::AdRequest; use crate::auction::orchestrator::OrchestrationResult; -use crate::consent::{consent_allows_server_side_auction, gate_eids_by_consent}; +use crate::consent::{ + consent_allows_server_side_auction, gate_eids_by_consent, resolve_consent_kv, +}; use crate::constants::COOKIE_TS_EIDS; use crate::cookies::extract_cookie_value; use crate::ec::EcContext; @@ -110,6 +112,7 @@ const MAX_AUCTION_BODY_SIZE: usize = 256 * 1024; /// # Errors /// /// Returns an error if: +/// - The configured consent KV store cannot be resolved (fail closed) /// - The request body cannot be parsed /// - The auction request conversion fails (e.g., invalid ad units) /// - The auction execution fails @@ -123,6 +126,13 @@ pub async fn handle_auction( services: &RuntimeServices, req: Request, ) -> Result, Report> { + // Fail-closed consent guard. The auction acts on consent data (carried on + // `ec_context`), so a consent store that is configured but unresolvable + // makes this route unavailable (503) rather than letting it proceed on an + // incomplete consent picture. The handle itself is unused here — consent + // persistence is wired in the EC pipeline, not the auction. + let _consent_kv = resolve_consent_kv(settings, services)?; + // Reject oversized bodies before core buffers/parses them. The Content-Length // pre-check stops well-behaved clients early; the post-read check defends // against clients that lie about (or omit) the header. diff --git a/crates/trusted-server-core/src/auction/openrtb/tests.rs b/crates/trusted-server-core/src/auction/openrtb/tests.rs index c2d81088b..422a1bb36 100644 --- a/crates/trusted-server-core/src/auction/openrtb/tests.rs +++ b/crates/trusted-server-core/src/auction/openrtb/tests.rs @@ -168,7 +168,8 @@ fn deterministic_signer() -> RequestSigner { HashMapSecretStore::new(secret_data), Arc::new(NoopHttpClient), ); - RequestSigner::from_services(&services).expect("should load deterministic signer") + futures::executor::block_on(RequestSigner::from_services(&services)) + .expect("should load deterministic signer") } #[test] diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 954869e38..07c47649e 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -394,11 +394,10 @@ impl AuctionOrchestratorHarness { } // Signing admission deliberately precedes every backend call. - let signer = self - .plan - .signing_enabled() - .then(|| RequestSigner::from_services(context.services)) - .transpose()?; + let signer = match self.plan.signing_enabled() { + true => Some(RequestSigner::from_services(context.services).await?), + false => None, + }; self.run_routed(request, &routed, context, signer.as_ref(), auction_start) .await } @@ -1629,12 +1628,12 @@ impl AuctionOrchestrator { // A zero request budget is terminal before signing admission. In the // split path, signer initialization would otherwise read config and // secret stores even though every provider is materialized as timeout. - let signer_result = if context.timeout_ms == 0 { + let signer_result = if context.timeout_ms == 0 || !plan.signing_enabled() { Ok(None) } else { - plan.signing_enabled() - .then(|| RequestSigner::from_services(context.services)) - .transpose() + RequestSigner::from_services(context.services) + .await + .map(Some) }; let signer = match signer_result { Ok(signer) => signer, @@ -3164,8 +3163,9 @@ mod tests { reads: AtomicUsize, } + #[async_trait::async_trait(?Send)] impl PlatformConfigStore for FailingCountingConfigStore { - fn get( + async fn get( &self, _store_name: &StoreName, _key: &str, @@ -3194,8 +3194,13 @@ mod tests { delay: Duration, } + #[async_trait::async_trait(?Send)] impl PlatformConfigStore for CountingConfigStore { - fn get(&self, _store_name: &StoreName, key: &str) -> Result> { + async fn get( + &self, + _store_name: &StoreName, + key: &str, + ) -> Result> { self.reads.fetch_add(1, Ordering::Relaxed); if !self.delay.is_zero() { std::thread::sleep(self.delay); @@ -3224,8 +3229,9 @@ mod tests { key: Vec, } + #[async_trait::async_trait(?Send)] impl PlatformSecretStore for CountingSecretStore { - fn get_bytes( + async fn get_bytes( &self, _store_name: &StoreName, _key: &str, @@ -3294,8 +3300,9 @@ mod tests { struct UnusedSecretStore; + #[async_trait::async_trait(?Send)] impl PlatformSecretStore for UnusedSecretStore { - fn get_bytes( + async fn get_bytes( &self, _store_name: &StoreName, _key: &str, diff --git a/crates/trusted-server-core/src/config_payload.rs b/crates/trusted-server-core/src/config_payload.rs index 497d48b3e..a62bfcb86 100644 --- a/crates/trusted-server-core/src/config_payload.rs +++ b/crates/trusted-server-core/src/config_payload.rs @@ -30,7 +30,7 @@ pub const CONFIG_BLOB_KEY: &str = "trusted_server_config"; /// Returns [`TrustedServerError::Configuration`] when the envelope cannot be /// parsed, fails integrity verification, secret resolution fails, or resolved /// settings are invalid. -pub fn settings_from_config_blob( +pub async fn settings_from_config_blob( envelope_json: &str, secret_store: &dyn PlatformSecretStore, default_secret_store_name: &StoreName, @@ -54,7 +54,8 @@ pub fn settings_from_config_blob( &mut data, secret_store, default_secret_store_name, - )?; + ) + .await?; let settings = Settings::from_json_value(data)?; crate::config::validate_settings_for_runtime(&settings)?; Ok(settings) @@ -145,8 +146,9 @@ mod tests { struct EchoSecretStore; + #[async_trait::async_trait(?Send)] impl PlatformSecretStore for EchoSecretStore { - fn get_bytes( + async fn get_bytes( &self, _store_name: &StoreName, key: &str, @@ -175,8 +177,9 @@ mod tests { struct UnifiedSecretStore; + #[async_trait::async_trait(?Send)] impl PlatformSecretStore for UnifiedSecretStore { - fn get_bytes( + async fn get_bytes( &self, store_name: &StoreName, key: &str, @@ -237,11 +240,11 @@ mod tests { } fn load_settings(envelope_json: &str) -> Result> { - settings_from_config_blob( + futures::executor::block_on(settings_from_config_blob( envelope_json, &EchoSecretStore, &StoreName::from("trusted_server_secrets"), - ) + )) } fn settings_with_browser_bidder_overlap(auction_enabled: bool) -> Settings { @@ -360,11 +363,11 @@ mod tests { .partners .push(partner_with_pull_sync(true, "partner-pull-token-key")); - let reconstructed = settings_from_config_blob( + let reconstructed = futures::executor::block_on(settings_from_config_blob( &envelope_json(&original), &UnifiedSecretStore, &StoreName::from("ts_secrets"), - ) + )) .expect("should resolve every static credential from the mapped store"); assert_eq!( @@ -445,11 +448,11 @@ mod tests { shared_secret: Redacted::new("trusted-client-ip-key".to_owned()), }); - let reconstructed = settings_from_config_blob( + let reconstructed = futures::executor::block_on(settings_from_config_blob( &envelope_json(&original), &UnifiedSecretStore, &StoreName::from("ts_secrets"), - ) + )) .expect("should resolve the trusted client IP shared secret"); assert_eq!( @@ -473,11 +476,11 @@ mod tests { shared_secret: Redacted::new("unused-trusted-client-ip-key".to_owned()), }); - let error = settings_from_config_blob( + let error = futures::executor::block_on(settings_from_config_blob( &envelope_json(&original), &UnifiedSecretStore, &StoreName::from("ts_secrets"), - ) + )) .expect_err("should reject a missing trusted client IP shared secret"); let message = error.to_string(); @@ -503,11 +506,11 @@ mod tests { )); original.proxy.asset_routes.push(route); - let reconstructed = settings_from_config_blob( + let reconstructed = futures::executor::block_on(settings_from_config_blob( &envelope_json(&original), &UnifiedSecretStore, &StoreName::from("ts_secrets"), - ) + )) .expect("should resolve default S3 secret keys"); let AssetOriginAuth::S3SigV4(auth) = reconstructed.proxy.asset_routes[0] @@ -532,11 +535,11 @@ mod tests { .expect("should build partner without API token"); original.ec.partners.push(partner); - let reconstructed = settings_from_config_blob( + let reconstructed = futures::executor::block_on(settings_from_config_blob( &envelope_json(&original), &UnifiedSecretStore, &StoreName::from("ts_secrets"), - ) + )) .expect("should load partner without resolving an API token"); assert!( @@ -558,11 +561,11 @@ mod tests { let envelope = BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_owned()); let envelope_json = serde_json::to_string(&envelope).expect("should serialize envelope"); - let reconstructed = settings_from_config_blob( + let reconstructed = futures::executor::block_on(settings_from_config_blob( &envelope_json, &UnifiedSecretStore, &StoreName::from("ts_secrets"), - ) + )) .expect("should resolve a pull token enabled by a string boolean"); assert_eq!( @@ -589,11 +592,11 @@ mod tests { let envelope = BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_owned()); let envelope_json = serde_json::to_string(&envelope).expect("should serialize envelope"); - let reconstructed = settings_from_config_blob( + let reconstructed = futures::executor::block_on(settings_from_config_blob( &envelope_json, &UnifiedSecretStore, &StoreName::from("ts_secrets"), - ) + )) .expect("should skip a pull token disabled by a string boolean"); assert!( @@ -610,11 +613,11 @@ mod tests { .partners .push(partner_with_pull_sync(true, "unused-partner-pull-token")); - let error = settings_from_config_blob( + let error = futures::executor::block_on(settings_from_config_blob( &envelope_json(&original), &UnifiedSecretStore, &StoreName::from("ts_secrets"), - ) + )) .expect_err("should reject a missing active pull-sync token"); assert!(error.to_string().contains("ec.partners[0].ts_pull_token")); @@ -645,11 +648,11 @@ mod tests { .partners .push(partner_with_pull_sync(false, "unused-partner-pull-token")); - let reconstructed = settings_from_config_blob( + let reconstructed = futures::executor::block_on(settings_from_config_blob( &envelope_json(&original), &UnifiedSecretStore, &StoreName::from("ts_secrets"), - ) + )) .expect("should skip inactive optional feature references"); assert!(reconstructed.tinybird.auction_token_secret.is_none()); @@ -685,11 +688,11 @@ mod tests { ) .expect("should configure disabled DataDome references"); - let reconstructed = settings_from_config_blob( + let reconstructed = futures::executor::block_on(settings_from_config_blob( &envelope_json(&original), &UnifiedSecretStore, &StoreName::from("ts_secrets"), - ) + )) .expect("should skip stale DataDome protection references"); assert!( diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index f205a8363..b14398c15 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -50,10 +50,14 @@ use web_time::{SystemTime, UNIX_EPOCH}; use cookie::CookieJar; use edgezero_core::body::Body as EdgeBody; +use error_stack::Report; use http::Request; use crate::consent_config::{ConflictMode, ConsentConfig, ConsentMode}; +use crate::error::TrustedServerError; use crate::geo::GeoInfo; +use crate::platform::{KvHandle, RuntimeServices}; +use crate::settings::Settings; /// Number of deciseconds in one day (86 400 seconds × 10). const DECISECONDS_PER_DAY: u64 = 86_400 * 10; @@ -82,11 +86,11 @@ pub struct ConsentPipelineInput<'a> { /// - **Read fallback**: loads consent from KV when cookies are absent. /// - **Write-on-change**: persists cookie-sourced consent to KV. pub ec_id: Option<&'a str>, - /// KV store for consent persistence. + /// KV store handle for consent persistence. /// /// `None` when consent persistence is not configured for this request, or /// when the caller intentionally skips consent KV access. - pub kv_store: Option<&'a dyn crate::platform::PlatformKvStore>, + pub kv_store: Option, } /// Extracts, decodes, and normalizes consent signals from a request. @@ -114,7 +118,7 @@ pub struct ConsentPipelineInput<'a> { /// /// Decoding failures are logged and the corresponding decoded field is set to /// `None` — the raw string is still preserved for proxy-mode forwarding. -pub fn build_consent_context(input: &ConsentPipelineInput<'_>) -> ConsentContext { +pub async fn build_consent_context(input: &ConsentPipelineInput<'_>) -> ConsentContext { let signals = extract_consent_signals(input.jar, input.req); log_consent_signals(&signals); @@ -125,8 +129,8 @@ pub fn build_consent_context(input: &ConsentPipelineInput<'_>) -> ConsentContext // Read fallback: when the request carries no consent signals, fall back // to consent persisted in KV for this EC ID (when persistence is wired). if signals.is_empty() - && let (Some(ec_id), Some(store)) = (input.ec_id, input.kv_store) - && let Some(mut ctx) = kv::load_consent_from_kv(store, ec_id) + && let (Some(ec_id), Some(store)) = (input.ec_id, input.kv_store.as_ref()) + && let Some(mut ctx) = kv::load_consent_from_kv(store, ec_id).await { // Jurisdiction is request-local: derive it from the current // geo rather than the value stored with the persisted entry. @@ -171,8 +175,8 @@ pub fn build_consent_context(input: &ConsentPipelineInput<'_>) -> ConsentContext // Write-on-change: persist cookie-sourced consent for this EC ID (when // persistence is wired). The helper skips empty contexts and unchanged // fingerprints internally. - if let (Some(ec_id), Some(store)) = (input.ec_id, input.kv_store) { - kv::save_consent_to_kv(store, ec_id, &ctx, input.config.max_consent_age_days); + if let (Some(ec_id), Some(store)) = (input.ec_id, input.kv_store.as_ref()) { + kv::save_consent_to_kv(store, ec_id, &ctx, input.config.max_consent_age_days).await; } log_consent_context(&ctx); @@ -644,6 +648,42 @@ fn log_consent_signals(signals: &RawConsentSignals) { } } +/// Resolves the configured consent KV store from the per-request registry, +/// failing closed when it is configured but cannot be resolved. +/// +/// This is the single place the consent-store availability policy lives, shared +/// by every adapter: +/// +/// - `consent.consent_store` **configured and resolvable** → `Ok(Some(handle))`. +/// - `consent.consent_store` **configured but unresolved** (no KV registry wired, +/// or the id is not declared/openable) → `Err`. Consent-dependent routes +/// surface this as a 503 rather than silently proceeding without consent. +/// - `consent.consent_store` **unconfigured** → `Ok(None)`. Consent persistence +/// is intentionally disabled, which is not a failure. +/// +/// A silent `None` for the configured-but-unresolved case would turn a wiring or +/// provisioning fault into "consent persistence quietly off", so it is an error. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::KvStore`] when `settings.consent.consent_store` +/// names a store the request's KV registry cannot resolve. +pub fn resolve_consent_kv( + settings: &Settings, + services: &RuntimeServices, +) -> Result, Report> { + let Some(id) = settings.consent.consent_store.as_deref() else { + return Ok(None); + }; + + services.kv_handle_named(id).map(Some).ok_or_else(|| { + Report::new(TrustedServerError::KvStore { + store_name: id.to_owned(), + message: "configured consent store could not be resolved".to_owned(), + }) + }) +} + /// Logs a one-time warning when request geolocation is unavailable. fn log_missing_geo_warning_once() { if MISSING_GEO_WARNING_LOGGED.swap(true, Ordering::Relaxed) { @@ -703,7 +743,7 @@ mod tests { use super::{ ConsentPipelineInput, allows_ec_creation, apply_expiration_check, apply_tcf_conflict_resolution, build_consent_context, build_context_from_signals, - consent_allows_server_side_auction, has_explicit_ec_withdrawal, + consent_allows_server_side_auction, has_explicit_ec_withdrawal, resolve_consent_kv, }; use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::{ @@ -898,14 +938,14 @@ mod tests { let req = build_request(); let config = ConsentConfig::default(); - let ctx = build_consent_context(&ConsentPipelineInput { + let ctx = futures::executor::block_on(build_consent_context(&ConsentPipelineInput { jar: None, req: &req, config: &config, geo: None, ec_id: None, kv_store: None, - }); + })); assert_eq!( ctx.jurisdiction, @@ -927,14 +967,14 @@ mod tests { ..ConsentConfig::default() }; - let ctx = build_consent_context(&ConsentPipelineInput { + let ctx = futures::executor::block_on(build_consent_context(&ConsentPipelineInput { jar: Some(&jar), req: &req, config: &config, geo: None, ec_id: None, kv_store: None, - }); + })); assert!( ctx.gdpr_applies, @@ -957,14 +997,14 @@ mod tests { ..ConsentConfig::default() }; - let ctx = build_consent_context(&ConsentPipelineInput { + let ctx = futures::executor::block_on(build_consent_context(&ConsentPipelineInput { jar: Some(&jar), req: &req, config: &config, geo: None, ec_id: None, kv_store: None, - }); + })); assert!( ctx.gdpr_applies, @@ -1489,6 +1529,12 @@ mod tests { } } + /// Wrap a fresh [`InMemoryKvStore`] double in a [`KvHandle`] for consent + /// persistence tests. + fn kv_handle() -> crate::platform::KvHandle { + crate::platform::KvHandle::new(std::sync::Arc::new(InMemoryKvStore::new())) + } + #[async_trait::async_trait(?Send)] impl crate::platform::PlatformKvStore for InMemoryKvStore { async fn get_bytes( @@ -1547,24 +1593,27 @@ mod tests { let jar = parse_cookies_to_jar("us_privacy=1YNN"); let req = build_request(); let config = ConsentConfig::default(); - let store = InMemoryKvStore::new(); + let store = kv_handle(); - let ctx = build_consent_context(&ConsentPipelineInput { + let ctx = futures::executor::block_on(build_consent_context(&ConsentPipelineInput { jar: Some(&jar), req: &req, config: &config, geo: None, ec_id: Some("test-ec-id"), - kv_store: Some(&store), - }); + kv_store: Some(store.clone()), + })); assert_eq!( ctx.raw_us_privacy.as_deref(), Some("1YNN"), "should build cookie-sourced consent" ); - let persisted = crate::consent::kv::load_consent_from_kv(&store, "test-ec-id") - .expect("should persist cookie-sourced consent to KV"); + let persisted = futures::executor::block_on(crate::consent::kv::load_consent_from_kv( + &store, + "test-ec-id", + )) + .expect("should persist cookie-sourced consent to KV"); assert_eq!( persisted.raw_us_privacy.as_deref(), Some("1YNN"), @@ -1575,30 +1624,30 @@ mod tests { #[test] fn pipeline_falls_back_to_kv_consent_when_request_has_no_signals() { let config = ConsentConfig::default(); - let store = InMemoryKvStore::new(); + let store = kv_handle(); // First request carries a consent cookie — persisted to KV. let jar = parse_cookies_to_jar("us_privacy=1YNN"); let req = build_request(); - build_consent_context(&ConsentPipelineInput { + futures::executor::block_on(build_consent_context(&ConsentPipelineInput { jar: Some(&jar), req: &req, config: &config, geo: None, ec_id: Some("test-ec-id"), - kv_store: Some(&store), - }); + kv_store: Some(store.clone()), + })); // Second request has no consent signals — must fall back to KV. let bare_req = build_request(); - let ctx = build_consent_context(&ConsentPipelineInput { + let ctx = futures::executor::block_on(build_consent_context(&ConsentPipelineInput { jar: None, req: &bare_req, config: &config, geo: None, ec_id: Some("test-ec-id"), - kv_store: Some(&store), - }); + kv_store: Some(store.clone()), + })); assert_eq!( ctx.raw_us_privacy.as_deref(), @@ -1612,21 +1661,121 @@ mod tests { let jar = parse_cookies_to_jar("us_privacy=1YNN"); let req = build_request(); let config = ConsentConfig::default(); - let store = InMemoryKvStore::new(); + let store = kv_handle(); // ec_id is absent, so the pipeline must not touch the KV store. - build_consent_context(&ConsentPipelineInput { + futures::executor::block_on(build_consent_context(&ConsentPipelineInput { jar: Some(&jar), req: &req, config: &config, geo: None, ec_id: None, - kv_store: Some(&store), - }); + kv_store: Some(store.clone()), + })); assert!( - crate::consent::kv::load_consent_from_kv(&store, "test-ec-id").is_none(), + futures::executor::block_on(crate::consent::kv::load_consent_from_kv( + &store, + "test-ec-id" + )) + .is_none(), "should not persist consent without an EC ID" ); } + + /// Settings whose consent store id is `id` (or unconfigured when `None`). + fn settings_with_consent_store(id: Option<&str>) -> crate::settings::Settings { + let mut settings = crate::settings::Settings::from_toml( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "example.com" + cookie_domain = ".example.com" + origin_url = "https://origin.example.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + passphrase = "test-secret-key-32-bytes-minimum" + "#, + ) + .expect("should parse consent-guard test settings"); + settings.consent.consent_store = id.map(str::to_owned); + settings + } + + #[test] + fn resolve_consent_kv_errors_when_configured_store_is_unresolved() { + // Arrange: a consent store is configured but no KV registry is wired, + // so the named lookup cannot resolve it. + let settings = settings_with_consent_store(Some("consent_store")); + let services = crate::platform::test_support::noop_services(); + + // Act + let result = resolve_consent_kv(&settings, &services); + + // Assert: fail closed — never a silent `None`. + let report = result.expect_err("a configured-but-unresolved consent store should error"); + assert!( + matches!( + report.current_context(), + crate::error::TrustedServerError::KvStore { .. } + ), + "should surface a KvStore error so consent routes fail closed with 503" + ); + } + + #[test] + fn resolve_consent_kv_returns_none_when_unconfigured() { + let settings = settings_with_consent_store(None); + let services = crate::platform::test_support::noop_services(); + + let resolved = resolve_consent_kv(&settings, &services) + .expect("an unconfigured consent store should not error"); + + assert!( + resolved.is_none(), + "consent persistence is intentionally off when no consent store is configured" + ); + } + + #[test] + fn resolve_consent_kv_returns_the_named_handle_when_registered() { + let settings = settings_with_consent_store(Some("consent_store")); + let registry = edgezero_core::store_registry::KvRegistry::single_id( + "consent_store".to_owned(), + kv_handle(), + ); + let services = crate::platform::RuntimeServices::builder() + .config_store(std::sync::Arc::new( + crate::platform::test_support::NoopConfigStore, + )) + .secret_store(std::sync::Arc::new( + crate::platform::test_support::NoopSecretStore, + )) + .kv_store(std::sync::Arc::new( + edgezero_core::key_value_store::NoopKvStore, + )) + .kv_registry(Some(registry)) + .backend(std::sync::Arc::new( + crate::platform::test_support::NoopBackend, + )) + .http_client(std::sync::Arc::new( + crate::platform::test_support::NoopHttpClient, + )) + .geo(std::sync::Arc::new(crate::platform::test_support::NoopGeo)) + .client_info(crate::platform::ClientInfo::default()) + .build(); + + let resolved = resolve_consent_kv(&settings, &services) + .expect("a declared consent store should resolve"); + + assert!( + resolved.is_some(), + "a consent store present in the KV registry should resolve to a handle" + ); + } } diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 3dc6e299e..42033f46f 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -248,12 +248,12 @@ impl EcContext { /// # Errors /// /// Returns an error if cookie parsing fails. - pub fn read_from_request( + pub async fn read_from_request( settings: &Settings, req: &Request, services: &RuntimeServices, ) -> Result> { - Self::read_from_request_with_geo(settings, req, services, None) + Self::read_from_request_with_geo(settings, req, services, None).await } /// Reads EC state from an incoming request using pre-extracted geo data. @@ -264,7 +264,7 @@ impl EcContext { /// # Errors /// /// Returns an error if cookie parsing fails. - pub fn read_from_request_with_geo( + pub async fn read_from_request_with_geo( settings: &Settings, req: &Request, services: &RuntimeServices, @@ -285,15 +285,32 @@ impl EcContext { .client_ip .map(generation::normalize_ip); - // Build consent context from request-local cookies, headers, and geo. + // Resolve the consent KV store for persistence, keyed by the existing EC + // id. Resolution is deliberately best-effort: this runs on every request + // that builds an EC context, including routes that do not depend on + // consent, so a configured-but-unresolvable store must not fail here. + // Fail-closed enforcement for a missing consent store lives at the + // consent-dependent routes (`consent::resolve_consent_kv`), which keeps a + // misconfiguration from breaking unrelated routes. + let consent_kv = settings + .consent + .consent_store + .as_deref() + .and_then(|store_id| services.kv_handle_named(store_id)); + + // Build consent context from request-local cookies, headers, and geo, + // falling back to (and persisting) KV-backed consent for this EC id. A + // request without an EC id yet cannot key persistence, so it stays + // request-local until the EC cookie is present on a later request. let consent = consent_mod::build_consent_context(&ConsentPipelineInput { jar: parsed.jar.as_ref(), req, config: &settings.consent, geo: geo_info, - ec_id: None, - kv_store: None, - }); + ec_id: ec_value.as_deref(), + kv_store: consent_kv, + }) + .await; log::info!( "EC context: present={}, cookie_present={}, consent_allowed={}, jurisdiction={}", @@ -757,8 +774,12 @@ mod tests { let cookie = format!("ts-ec={ec_id}"); let req = create_test_request(&[("cookie", &cookie), ("sec-fetch-dest", "document")]); - let ec = EcContext::read_from_request(&settings, &req, &noop_services()) - .expect("should read EC context"); + let ec = futures::executor::block_on(EcContext::read_from_request( + &settings, + &req, + &noop_services(), + )) + .expect("should read EC context"); assert!( !ec.recovery_eligible(), @@ -840,8 +861,12 @@ mod tests { let ec_id = valid_ec_id("a", "HdrEc1"); let req = create_test_request(&[("x-ts-ec", &ec_id)]); - let ec = EcContext::read_from_request(&settings, &req, &noop_services()) - .expect("should read EC context"); + let ec = futures::executor::block_on(EcContext::read_from_request( + &settings, + &req, + &noop_services(), + )) + .expect("should read EC context"); assert!(ec.ec_value().is_none(), "should ignore EC from header"); assert!(!ec.ec_was_present(), "should not detect EC from header"); @@ -856,8 +881,12 @@ mod tests { let cookie = format!("ts-ec={ec_id}"); let req = create_test_request(&[("cookie", &cookie)]); - let ec = EcContext::read_from_request(&settings, &req, &noop_services()) - .expect("should read EC context"); + let ec = futures::executor::block_on(EcContext::read_from_request( + &settings, + &req, + &noop_services(), + )) + .expect("should read EC context"); assert_eq!(ec.ec_value(), Some(ec_id.as_str())); assert!(ec.ec_was_present(), "should detect EC from cookie"); @@ -873,8 +902,12 @@ mod tests { let cookie = format!("ts-ec={cookie_id}"); let req = create_test_request(&[("x-ts-ec", &header_id), ("cookie", &cookie)]); - let ec = EcContext::read_from_request(&settings, &req, &noop_services()) - .expect("should read EC context"); + let ec = futures::executor::block_on(EcContext::read_from_request( + &settings, + &req, + &noop_services(), + )) + .expect("should read EC context"); assert_eq!( ec.ec_value(), @@ -889,8 +922,12 @@ mod tests { let settings = create_test_settings(); let req = create_test_request(&[]); - let ec = EcContext::read_from_request(&settings, &req, &noop_services()) - .expect("should read EC context"); + let ec = futures::executor::block_on(EcContext::read_from_request( + &settings, + &req, + &noop_services(), + )) + .expect("should read EC context"); assert!(ec.ec_value().is_none(), "should have no EC value"); assert!(!ec.ec_was_present(), "should not detect EC"); @@ -904,8 +941,12 @@ mod tests { let cookie = format!("ts-ec={cookie_id}"); let req = create_test_request(&[("x-ts-ec", "malformed-header"), ("cookie", &cookie)]); - let ec = EcContext::read_from_request(&settings, &req, &noop_services()) - .expect("should read EC context"); + let ec = futures::executor::block_on(EcContext::read_from_request( + &settings, + &req, + &noop_services(), + )) + .expect("should read EC context"); assert_eq!( ec.ec_value(), @@ -920,8 +961,12 @@ mod tests { let settings = create_test_settings(); let req = create_test_request(&[("x-ts-ec", "bad-header"), ("cookie", "ts-ec=bad-cookie")]); - let ec = EcContext::read_from_request(&settings, &req, &noop_services()) - .expect("should read EC context"); + let ec = futures::executor::block_on(EcContext::read_from_request( + &settings, + &req, + &noop_services(), + )) + .expect("should read EC context"); assert!( ec.ec_value().is_none(), @@ -944,8 +989,12 @@ mod tests { let cookie = format!("ts-ec={ec_id}"); let req = create_test_request(&[("cookie", &cookie)]); - let mut ec = EcContext::read_from_request(&settings, &req, &noop_services()) - .expect("should read EC context"); + let mut ec = futures::executor::block_on(EcContext::read_from_request( + &settings, + &req, + &noop_services(), + )) + .expect("should read EC context"); ec.generate_if_needed(&settings, None) .expect("should not error when EC already exists"); @@ -989,8 +1038,12 @@ mod tests { let cookie_ec = valid_ec_id("e", "CkVal1"); let cookie = format!("ts-ec={cookie_ec}"); let req = create_test_request(&[("cookie", &cookie)]); - let ec = EcContext::read_from_request(&settings, &req, &noop_services()) - .expect("should read EC context"); + let ec = futures::executor::block_on(EcContext::read_from_request( + &settings, + &req, + &noop_services(), + )) + .expect("should read EC context"); assert_eq!( ec.existing_cookie_ec_id(), Some(cookie_ec.as_str()), @@ -1000,8 +1053,12 @@ mod tests { // With only header (no cookie) let header_ec = valid_ec_id("f", "HdrVl1"); let req = create_test_request(&[("x-ts-ec", &header_ec)]); - let ec = EcContext::read_from_request(&settings, &req, &noop_services()) - .expect("should read EC context"); + let ec = futures::executor::block_on(EcContext::read_from_request( + &settings, + &req, + &noop_services(), + )) + .expect("should read EC context"); assert!( ec.existing_cookie_ec_id().is_none(), "should return None when only header is present" @@ -1012,8 +1069,12 @@ mod tests { let cookie_ec2 = valid_ec_id("b", "Ck0002"); let cookie2 = format!("ts-ec={cookie_ec2}"); let req = create_test_request(&[("x-ts-ec", &header_ec2), ("cookie", &cookie2)]); - let ec = EcContext::read_from_request(&settings, &req, &noop_services()) - .expect("should read EC context"); + let ec = futures::executor::block_on(EcContext::read_from_request( + &settings, + &req, + &noop_services(), + )) + .expect("should read EC context"); assert_eq!( ec.ec_value(), Some(cookie_ec2.as_str()), @@ -1025,4 +1086,226 @@ mod tests { "should return cookie value for revocation even when header is present" ); } + + // --------------------------------------------------------------------- + // Consent KV persistence wiring + // --------------------------------------------------------------------- + + /// In-memory [`crate::platform::PlatformKvStore`] double for the consent + /// persistence wiring tests. + struct InMemoryKvStore { + entries: std::sync::Mutex>, + } + + impl InMemoryKvStore { + fn new() -> Self { + Self { + entries: std::sync::Mutex::new(std::collections::HashMap::new()), + } + } + } + + #[async_trait::async_trait(?Send)] + impl crate::platform::PlatformKvStore for InMemoryKvStore { + async fn get_bytes( + &self, + key: &str, + ) -> Result, crate::platform::KvError> { + Ok(self + .entries + .lock() + .expect("should lock entries") + .get(key) + .cloned()) + } + + async fn put_bytes( + &self, + key: &str, + value: bytes::Bytes, + ) -> Result<(), crate::platform::KvError> { + self.entries + .lock() + .expect("should lock entries") + .insert(key.to_owned(), value); + Ok(()) + } + + async fn put_bytes_with_ttl( + &self, + key: &str, + value: bytes::Bytes, + _ttl: std::time::Duration, + ) -> Result<(), crate::platform::KvError> { + self.put_bytes(key, value).await + } + + async fn delete(&self, key: &str) -> Result<(), crate::platform::KvError> { + self.entries + .lock() + .expect("should lock entries") + .remove(key); + Ok(()) + } + + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Ok(edgezero_core::key_value_store::KvPage::default()) + } + } + + /// Settings whose consent pipeline persists to the `consent_store` id. + fn settings_with_consent_store() -> Settings { + let mut settings = create_test_settings(); + settings.consent.consent_store = Some("consent_store".to_owned()); + settings + } + + /// Build services whose KV registry holds a distinct default store and a + /// named `consent_store`, returning both handles so tests can assert which + /// store was written. + fn services_with_consent_kv() -> ( + RuntimeServices, + crate::platform::KvHandle, + crate::platform::KvHandle, + ) { + let default_handle = + crate::platform::KvHandle::new(std::sync::Arc::new(InMemoryKvStore::new())); + let consent_handle = + crate::platform::KvHandle::new(std::sync::Arc::new(InMemoryKvStore::new())); + let by_id = std::collections::BTreeMap::from([ + ("trusted_server_kv".to_owned(), default_handle.clone()), + ("consent_store".to_owned(), consent_handle.clone()), + ]); + let registry = edgezero_core::store_registry::StoreRegistry::from_parts( + by_id, + "trusted_server_kv".to_owned(), + ) + .expect("should build kv registry"); + let services = crate::platform::test_support::noop_services_with_kv_registry(registry); + (services, default_handle, consent_handle) + } + + #[test] + fn read_from_request_persists_consent_to_the_named_consent_store() { + let settings = settings_with_consent_store(); + let (services, default_handle, consent_handle) = services_with_consent_kv(); + let ec_id = valid_ec_id("c", "Persis"); + let cookie = format!("ts-ec={ec_id}; us_privacy=1YNN"); + let req = create_test_request(&[("cookie", &cookie)]); + + futures::executor::block_on(EcContext::read_from_request(&settings, &req, &services)) + .expect("should read EC context"); + + let persisted = futures::executor::block_on(crate::consent::kv::load_consent_from_kv( + &consent_handle, + &ec_id, + )) + .expect("should persist cookie-sourced consent under the EC id"); + assert_eq!( + persisted.raw_us_privacy.as_deref(), + Some("1YNN"), + "should persist the cookie-sourced consent signal" + ); + assert!( + futures::executor::block_on(crate::consent::kv::load_consent_from_kv( + &default_handle, + &ec_id + )) + .is_none(), + "should resolve the named consent store, never the default KV store" + ); + } + + #[test] + fn read_from_request_falls_back_to_persisted_consent_when_request_has_no_signals() { + let settings = settings_with_consent_store(); + let (services, _default_handle, _consent_handle) = services_with_consent_kv(); + let ec_id = valid_ec_id("d", "Fallbk"); + + // First request carries a consent cookie — persisted under the EC id. + let seeding_cookie = format!("ts-ec={ec_id}; us_privacy=1YNN"); + let seeding_req = create_test_request(&[("cookie", &seeding_cookie)]); + futures::executor::block_on(EcContext::read_from_request( + &settings, + &seeding_req, + &services, + )) + .expect("should read EC context for the seeding request"); + + // Second request carries only the EC cookie — no consent signals. + let bare_cookie = format!("ts-ec={ec_id}"); + let bare_req = create_test_request(&[("cookie", &bare_cookie)]); + let ec = futures::executor::block_on(EcContext::read_from_request( + &settings, &bare_req, &services, + )) + .expect("should read EC context for the bare request"); + + assert_eq!( + ec.consent().raw_us_privacy.as_deref(), + Some("1YNN"), + "should fall back to consent persisted under the EC id" + ); + } + + #[test] + fn read_from_request_succeeds_when_consent_store_is_configured_but_unresolved() { + // A configured-but-unresolvable consent store must not fail the EC + // context: fail-closed lives at the consent-dependent routes, so a + // misconfiguration must not break routes that never read consent. + let settings = settings_with_consent_store(); + let ec_id = valid_ec_id("e", "NoRegs"); + let cookie = format!("ts-ec={ec_id}; us_privacy=1YNN"); + let req = create_test_request(&[("cookie", &cookie)]); + + let ec = futures::executor::block_on(EcContext::read_from_request( + &settings, + &req, + &noop_services(), + )) + .expect("should read EC context without a KV registry"); + + assert_eq!( + ec.consent().raw_us_privacy.as_deref(), + Some("1YNN"), + "should still build request-local consent with persistence disabled" + ); + } + + #[test] + fn read_from_request_persists_consent_from_within_an_outer_executor() { + // Regression: before the end-to-end async refactor, the consent + // persistence path bridged the async KV write with an inner + // `futures::executor::block_on`. Driving a consent-persisting read from + // within an outer executor then panicked on Fastly-shaped runtimes with + // `cannot execute LocalPool executor from within another executor: + // EnterError`. With the reads async end to end there is no inner bridge, + // so the nested-executor path must now complete cleanly. + let settings = settings_with_consent_store(); + let (services, _default_handle, consent_handle) = services_with_consent_kv(); + let ec_id = valid_ec_id("f", "Nested"); + let cookie = format!("ts-ec={ec_id}; us_privacy=1YNN"); + let req = create_test_request(&[("cookie", &cookie)]); + + // Outer executor wraps the entire consent-persisting read plus the + // follow-up KV read. If any request-path `block_on` bridge remained, + // this would panic with `EnterError` instead of returning. + let persisted = futures::executor::block_on(async { + EcContext::read_from_request(&settings, &req, &services) + .await + .expect("should read EC context from within an outer executor"); + crate::consent::kv::load_consent_from_kv(&consent_handle, &ec_id).await + }) + .expect("should persist consent under the EC id without an EnterError panic"); + + assert_eq!( + persisted.raw_us_privacy.as_deref(), + Some("1YNN"), + "should persist the cookie-sourced consent from within a nested executor" + ); + } } diff --git a/crates/trusted-server-core/src/integrations/datadome/protection.rs b/crates/trusted-server-core/src/integrations/datadome/protection.rs index a7c55bf86..c9b95b5d9 100644 --- a/crates/trusted-server-core/src/integrations/datadome/protection.rs +++ b/crates/trusted-server-core/src/integrations/datadome/protection.rs @@ -51,7 +51,7 @@ impl DataDomeIntegration { return RequestFilterDecision::Continue(RequestFilterEffects::default()); } - if !self.config.enable_protection || !self.is_request_protected(&mut input) { + if !self.config.enable_protection || !self.is_request_protected(&mut input).await { return RequestFilterDecision::Continue(RequestFilterEffects::default()); } @@ -129,7 +129,7 @@ impl DataDomeIntegration { Ok(decision) } - fn is_request_protected(&self, input: &mut RequestFilterInput<'_>) -> bool { + async fn is_request_protected(&self, input: &mut RequestFilterInput<'_>) -> bool { let req = &*input.request; if req.method() == Method::OPTIONS { return false; @@ -151,7 +151,7 @@ impl DataDomeIntegration { client_ip: input.services.client_info().client_ip, asn: input.geo_info.and_then(|geo| geo.asn), }; - match self.protection_scope.evaluate(&facts, input.services) { + match self.protection_scope.evaluate(&facts, input.services).await { ProtectionScopeDecision::Protect => {} ProtectionScopeDecision::Skip { rule_id, diff --git a/crates/trusted-server-core/src/integrations/datadome/protection_scope.rs b/crates/trusted-server-core/src/integrations/datadome/protection_scope.rs index a83f73217..857e45772 100644 --- a/crates/trusted-server-core/src/integrations/datadome/protection_scope.rs +++ b/crates/trusted-server-core/src/integrations/datadome/protection_scope.rs @@ -202,7 +202,7 @@ static IP_CIDR_SOURCE_CACHE: LazyLock< > = LazyLock::new(|| Mutex::new(HashMap::new())); fn default_ip_cidr_source_store() -> String { - "datadome-ip-bypass".to_string() + "datadome_ip_bypass".to_string() } fn default_enabled_rule() -> bool { @@ -245,7 +245,7 @@ impl ProtectionScope { }) } - pub(super) fn evaluate( + pub(super) async fn evaluate( &self, facts: &ProtectionRequestFacts<'_>, services: &RuntimeServices, @@ -271,7 +271,10 @@ impl ProtectionScope { } for source in &self.excluded_ip_cidr_sources { - if source.matches(client_ip, services, self.ip_list_cache_ttl) { + if source + .matches(client_ip, services, self.ip_list_cache_ttl) + .await + { let (rule_id, reason) = primary.unwrap_or_else(|| { (source.rule_id(), ProtectionSkipReason::ClientIpSource) }); @@ -297,7 +300,7 @@ impl ProtectionScope { if primary.is_some() && !reason.is_ip_based() { continue; } - if rule.matches(facts, services, self.ip_list_cache_ttl) { + if rule.matches(facts, services, self.ip_list_cache_ttl).await { let suppress_client_tag = reason.is_ip_based(); let (rule_id, reason) = primary.unwrap_or_else(|| (rule.id.clone(), reason)); if suppress_client_tag { @@ -375,8 +378,13 @@ impl ProtectionIpCidrSource { format!("ip-cidr-source:{}:{}", self.config_store, self.key) } - fn matches(&self, client_ip: IpAddr, services: &RuntimeServices, cache_ttl: Duration) -> bool { - match self.load_cidrs(services, cache_ttl) { + async fn matches( + &self, + client_ip: IpAddr, + services: &RuntimeServices, + cache_ttl: Duration, + ) -> bool { + match self.load_cidrs(services, cache_ttl).await { Ok(cidrs) => cidrs_match(&cidrs, client_ip), Err(err) => { log::warn!( @@ -389,7 +397,7 @@ impl ProtectionIpCidrSource { } } - fn load_cidrs( + async fn load_cidrs( &self, services: &RuntimeServices, cache_ttl: Duration, @@ -412,6 +420,7 @@ impl ProtectionIpCidrSource { let raw = services .config_store() .get(&store_name, &self.key) + .await .map_err(|err| { err.change_context(datadome_error( "Failed to read DataDome IP CIDR bypass list from Config Store", @@ -456,7 +465,7 @@ impl ProtectionExclusionRule { }) } - fn matches( + async fn matches( &self, facts: &ProtectionRequestFacts<'_>, services: &RuntimeServices, @@ -468,7 +477,7 @@ impl ProtectionExclusionRule { return false; } - self.matcher.matches(facts, services, cache_ttl) + self.matcher.matches(facts, services, cache_ttl).await } } @@ -526,7 +535,7 @@ impl ProtectionMatcher { } } - fn matches( + async fn matches( &self, facts: &ProtectionRequestFacts<'_>, services: &RuntimeServices, @@ -547,9 +556,10 @@ impl ProtectionMatcher { ProtectionMatcher::IpCidr(cidrs) => facts .client_ip .is_some_and(|client_ip| cidrs_match(cidrs, client_ip)), - ProtectionMatcher::IpCidrSource(source) => facts - .client_ip - .is_some_and(|client_ip| source.matches(client_ip, services, cache_ttl)), + ProtectionMatcher::IpCidrSource(source) => match facts.client_ip { + Some(client_ip) => source.matches(client_ip, services, cache_ttl).await, + None => false, + }, } } @@ -800,7 +810,9 @@ mod tests { let scope = ProtectionScope::compile(&config).expect("should compile scope"); let services = crate::platform::test_support::noop_services(); - let decision = scope.evaluate(&facts("FASTLYPURGE", "/page", None, None, None), &services); + let decision = futures::executor::block_on( + scope.evaluate(&facts("FASTLYPURGE", "/page", None, None, None), &services), + ); assert!(matches!( decision, @@ -819,7 +831,9 @@ mod tests { let scope = ProtectionScope::compile(&config).expect("should compile scope"); let services = crate::platform::test_support::noop_services(); - let decision = scope.evaluate(&facts("GET", "/page", None, None, Some(19750)), &services); + let decision = futures::executor::block_on( + scope.evaluate(&facts("GET", "/page", None, None, Some(19750)), &services), + ); assert!(matches!( decision, @@ -838,7 +852,7 @@ mod tests { let scope = ProtectionScope::compile(&config).expect("should compile scope"); let services = crate::platform::test_support::noop_services(); - let decision = scope.evaluate( + let decision = futures::executor::block_on(scope.evaluate( &facts( "GET", "/page", @@ -847,7 +861,7 @@ mod tests { None, ), &services, - ); + )); assert!(matches!( decision, @@ -864,7 +878,7 @@ mod tests { clear_ip_cidr_source_cache_for_tests(); let mut config = config_with_protection(); config.protection_excluded_ip_cidr_sources = vec![ProtectionIpCidrSourceConfig { - config_store: "datadome-ip-bypass".to_string(), + config_store: "datadome_ip_bypass".to_string(), key: "googlebot_ips".to_string(), }]; let scope = ProtectionScope::compile(&config).expect("should compile scope"); @@ -873,7 +887,7 @@ mod tests { let services = build_services_with_config_and_secret(HashMapConfigStore::new(data), NoopSecretStore); - let decision = scope.evaluate( + let decision = futures::executor::block_on(scope.evaluate( &facts( "GET", "/page", @@ -882,7 +896,7 @@ mod tests { None, ), &services, - ); + )); assert!(matches!( decision, @@ -909,7 +923,9 @@ mod tests { let services = crate::platform::test_support::noop_services(); assert!(matches!( - scope.evaluate(&facts("GET", "/app.JSON", None, None, None), &services), + futures::executor::block_on( + scope.evaluate(&facts("GET", "/app.JSON", None, None, None), &services) + ), ProtectionScopeDecision::Skip { reason: ProtectionSkipReason::PathRegex, suppress_client_tag: false, @@ -917,7 +933,9 @@ mod tests { } )); assert!(matches!( - scope.evaluate(&facts("POST", "/app.JSON", None, None, None), &services), + futures::executor::block_on( + scope.evaluate(&facts("POST", "/app.JSON", None, None, None), &services) + ), ProtectionScopeDecision::Protect )); } @@ -930,7 +948,7 @@ mod tests { let scope = ProtectionScope::compile(&config).expect("should compile scope"); let services = crate::platform::test_support::noop_services(); - let decision = scope.evaluate( + let decision = futures::executor::block_on(scope.evaluate( &facts( "GET", "/page", @@ -939,7 +957,7 @@ mod tests { None, ), &services, - ); + )); assert!(matches!( decision, @@ -977,7 +995,7 @@ mod tests { let scope = ProtectionScope::compile(&config).expect("should compile scope"); let services = crate::platform::test_support::noop_services(); - let decision = scope.evaluate( + let decision = futures::executor::block_on(scope.evaluate( &facts( "GET", "/page", @@ -986,7 +1004,7 @@ mod tests { primary_asn, ), &services, - ); + )); assert!(matches!( decision, @@ -1014,7 +1032,7 @@ mod tests { let scope = ProtectionScope::compile(&config).expect("should compile scope"); let services = crate::platform::test_support::noop_services(); - let decision = scope.evaluate( + let decision = futures::executor::block_on(scope.evaluate( &facts( "POST", "/page", @@ -1023,7 +1041,7 @@ mod tests { None, ), &services, - ); + )); assert!(matches!( decision, @@ -1050,10 +1068,10 @@ mod tests { let services = crate::platform::test_support::noop_services(); assert!(matches!( - scope.evaluate( + futures::executor::block_on(scope.evaluate( &facts("GET", "/page", Some("_rsc=abc&x=1"), None, None), &services - ), + )), ProtectionScopeDecision::Skip { reason: ProtectionSkipReason::QueryParamNonEmpty, suppress_client_tag: false, @@ -1061,10 +1079,10 @@ mod tests { } )); assert!(matches!( - scope.evaluate( + futures::executor::block_on(scope.evaluate( &facts("GET", "/page", Some("_rsc=&x=1"), None, None), &services - ), + )), ProtectionScopeDecision::Protect )); } diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 228cee1cd..87bc6f85e 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -3184,7 +3184,7 @@ impl AuctionProvider for PrebidAuctionProvider { let signer_with_signature = if let Some(request_signing_config) = &context.settings.request_signing { if request_signing_config.enabled { - let signer = RequestSigner::from_services(context.services)?; + let signer = RequestSigner::from_services(context.services).await?; let params = SigningParams::new( request.id.clone(), request_info.host.clone(), @@ -9332,7 +9332,7 @@ set = { networkId = 42 } HashMapSecretStore::new(secret_data), Arc::new(NoopHttpClient), ); - let signer = RequestSigner::from_services(&signing_services) + let signer = futures::executor::block_on(RequestSigner::from_services(&signing_services)) .expect("should load deterministic test signer"); let signing = SigningParams { request_id: "fictional-auction".to_string(), diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 76621baf7..a5b2232ce 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -68,6 +68,7 @@ pub mod secret_resolution; pub mod settings; pub mod settings_data; pub mod storage; +pub mod stores; pub mod streaming_processor; pub mod streaming_replacer; pub mod test_support; diff --git a/crates/trusted-server-core/src/platform/composite.rs b/crates/trusted-server-core/src/platform/composite.rs new file mode 100644 index 000000000..bb32103f2 --- /dev/null +++ b/crates/trusted-server-core/src/platform/composite.rs @@ -0,0 +1,452 @@ +//! Registry-backed composite config/secret stores (decision D6-a). +//! +//! Reads resolve through the per-request `EdgeZero` +//! [`ConfigRegistry`](edgezero_core::store_registry::ConfigRegistry) / +//! [`SecretRegistry`](edgezero_core::store_registry::SecretRegistry) by logical +//! store id; writes delegate to a management-path +//! [`PlatformConfigWriter`](super::PlatformConfigWriter) / +//! [`PlatformSecretWriter`](super::PlatformSecretWriter). + +use std::sync::Arc; + +use async_trait::async_trait; +use edgezero_core::store_registry::{ConfigRegistry, SecretRegistry}; +use error_stack::Report; + +use super::{ + PlatformConfigStore, PlatformConfigWriter, PlatformError, PlatformSecretStore, + PlatformSecretWriter, StoreId, StoreName, +}; + +/// Config store whose reads resolve through an `EdgeZero` [`ConfigRegistry`] and +/// whose writes delegate to a management-path [`PlatformConfigWriter`]. +/// +/// Reads resolve `store_name` as a **logical store id** via +/// [`ConfigRegistry::named`]; both an absent registry and an unknown id are +/// hard errors, never a silent fallback to the default store. +pub struct CompositeConfigStore { + reader: Option, + writer: Arc, +} + +impl CompositeConfigStore { + /// Create a composite config store from an optional read registry and a + /// write delegate. + /// + /// The reader is `Option` because an empty [`ConfigRegistry`] cannot be + /// constructed (`from_parts` returns `None`), so an absent registry is + /// represented as `None`. + #[must_use] + pub fn new(reader: Option, writer: Arc) -> Self { + Self { reader, writer } + } +} + +#[async_trait(?Send)] +impl PlatformConfigStore for CompositeConfigStore { + async fn get( + &self, + store_name: &StoreName, + key: &str, + ) -> Result> { + let registry = self + .reader + .as_ref() + .ok_or_else(|| Report::new(PlatformError::ConfigStore))?; + let binding = registry + .named(store_name.as_ref()) + .ok_or_else(|| Report::new(PlatformError::ConfigStore))?; + match binding.handle.get(key).await { + Ok(Some(value)) => Ok(value), + Ok(None) => Err(Report::new(PlatformError::NotFound) + .attach(format!("config key `{key}` not found"))), + Err(error) => Err(Report::new(PlatformError::ConfigStore) + .attach(format!("config store read failed: {error}"))), + } + } + + fn put(&self, store_id: &StoreId, key: &str, value: &str) -> Result<(), Report> { + self.writer.put(store_id, key, value) + } + + fn delete(&self, store_id: &StoreId, key: &str) -> Result<(), Report> { + self.writer.delete(store_id, key) + } +} + +/// Secret store whose reads resolve through an `EdgeZero` [`SecretRegistry`] and +/// whose writes delegate to a management-path [`PlatformSecretWriter`]. +/// +/// Reads resolve `store_name` as a **logical store id** via +/// [`SecretRegistry::named`]; both an absent registry and an unknown id are +/// hard errors, never a silent fallback to the default store. +pub struct CompositeSecretStore { + reader: Option, + writer: Arc, +} + +impl CompositeSecretStore { + /// Create a composite secret store from an optional read registry and a + /// write delegate. + /// + /// The reader is `Option` because an empty [`SecretRegistry`] cannot be + /// constructed (`from_parts` returns `None`), so an absent registry is + /// represented as `None`. + #[must_use] + pub fn new(reader: Option, writer: Arc) -> Self { + Self { reader, writer } + } +} + +#[async_trait(?Send)] +impl PlatformSecretStore for CompositeSecretStore { + async fn get_bytes( + &self, + store_name: &StoreName, + key: &str, + ) -> Result, Report> { + let registry = self + .reader + .as_ref() + .ok_or_else(|| Report::new(PlatformError::SecretStore))?; + let bound = registry + .named(store_name.as_ref()) + .ok_or_else(|| Report::new(PlatformError::SecretStore))?; + match bound.get_bytes(key).await { + Ok(Some(bytes)) => Ok(bytes.to_vec()), + Ok(None) => Err(Report::new(PlatformError::NotFound) + .attach(format!("secret key `{key}` not found"))), + Err(error) => Err(Report::new(PlatformError::SecretStore) + .attach(format!("secret store read failed: {error}"))), + } + } + + fn create( + &self, + store_id: &StoreId, + name: &str, + value: &str, + ) -> Result<(), Report> { + self.writer.create(store_id, name, value) + } + + fn delete(&self, store_id: &StoreId, name: &str) -> Result<(), Report> { + self.writer.delete(store_id, name) + } +} + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, BTreeSet, HashMap}; + use std::sync::{Arc, Mutex}; + + use async_trait::async_trait; + use bytes::Bytes; + use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use edgezero_core::secret_store::{SecretError, SecretHandle, SecretStore}; + use edgezero_core::store_registry::{ + BoundSecretStore, ConfigRegistry, ConfigStoreBinding, SecretRegistry, StoreRegistry, + }; + use error_stack::Report; + + use super::super::{ + CompositeConfigStore, CompositeSecretStore, PlatformConfigStore, PlatformConfigWriter, + PlatformError, PlatformSecretStore, PlatformSecretWriter, StoreId, StoreName, + }; + + /// In-memory [`ConfigStore`] double keyed by lookup key. + struct TestConfigStore { + data: HashMap, + } + + #[async_trait(?Send)] + impl ConfigStore for TestConfigStore { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + Ok(self.data.get(key).cloned()) + } + } + + /// In-memory [`SecretStore`] double keyed by `"{store_name}/{key}"`. + struct TestSecretStore { + data: HashMap, + } + + #[async_trait(?Send)] + impl SecretStore for TestSecretStore { + async fn get_bytes( + &self, + store_name: &str, + key: &str, + ) -> Result, SecretError> { + Ok(self.data.get(&format!("{store_name}/{key}")).cloned()) + } + } + + /// Build a [`ConfigRegistry`] from `(store_id, key, value)` entries. + fn config_registry(entries: &[(&str, &str, &str)], default: &str) -> ConfigRegistry { + let mut by_store: BTreeMap> = BTreeMap::new(); + for (id, key, value) in entries { + by_store + .entry((*id).to_owned()) + .or_default() + .insert((*key).to_owned(), (*value).to_owned()); + } + let by_id: BTreeMap = by_store + .into_iter() + .map(|(id, data)| { + let binding = ConfigStoreBinding { + default_key: id.clone(), + handle: ConfigStoreHandle::new(Arc::new(TestConfigStore { data })), + }; + (id, binding) + }) + .collect(); + StoreRegistry::from_parts(by_id, default.to_owned()) + .expect("should build a non-empty config registry with a present default") + } + + /// Build a [`SecretRegistry`] from `(store_id, key, value)` entries. + fn secret_registry(entries: &[(&str, &str, &[u8])], default: &str) -> SecretRegistry { + let mut data: HashMap = HashMap::new(); + let mut ids: BTreeSet = BTreeSet::new(); + for (id, key, value) in entries { + data.insert(format!("{id}/{key}"), Bytes::copy_from_slice(value)); + ids.insert((*id).to_owned()); + } + let handle = SecretHandle::new(Arc::new(TestSecretStore { data })); + let by_id: BTreeMap = ids + .into_iter() + .map(|id| { + let bound = BoundSecretStore::new(handle.clone(), id.clone()); + (id, bound) + }) + .collect(); + StoreRegistry::from_parts(by_id, default.to_owned()) + .expect("should build a non-empty secret registry with a present default") + } + + /// Records config write delegations so tests assert the target `StoreId` is + /// preserved. + #[derive(Default)] + struct RecordingConfigWriter { + puts: Mutex>, + deletes: Mutex>, + } + + impl PlatformConfigWriter for RecordingConfigWriter { + fn put( + &self, + store_id: &StoreId, + key: &str, + value: &str, + ) -> Result<(), Report> { + self.puts.lock().expect("should acquire writer lock").push(( + store_id.as_ref().to_owned(), + key.to_owned(), + value.to_owned(), + )); + Ok(()) + } + + fn delete(&self, store_id: &StoreId, key: &str) -> Result<(), Report> { + self.deletes + .lock() + .expect("should acquire writer lock") + .push((store_id.as_ref().to_owned(), key.to_owned())); + Ok(()) + } + } + + /// Records secret write delegations so tests assert the target `StoreId` is + /// preserved. + #[derive(Default)] + struct RecordingSecretWriter { + creates: Mutex>, + deletes: Mutex>, + } + + impl PlatformSecretWriter for RecordingSecretWriter { + fn create( + &self, + store_id: &StoreId, + name: &str, + value: &str, + ) -> Result<(), Report> { + self.creates + .lock() + .expect("should acquire writer lock") + .push(( + store_id.as_ref().to_owned(), + name.to_owned(), + value.to_owned(), + )); + Ok(()) + } + + fn delete(&self, store_id: &StoreId, name: &str) -> Result<(), Report> { + self.deletes + .lock() + .expect("should acquire writer lock") + .push((store_id.as_ref().to_owned(), name.to_owned())); + Ok(()) + } + } + + #[test] + fn composite_config_reads_named_store_and_writes_delegate() { + // Arrange: a ConfigRegistry with the default `trusted_server_config` + // plus a non-default `jwks_store` (D5: config default id is + // `trusted_server_config`, not `app_config`). + let reader = config_registry( + &[ + ("trusted_server_config", "current-kid", "kid-1"), + ("jwks_store", "kid-1", "{\"kty\":\"OKP\"}"), + ], + "trusted_server_config", + ); + let writer = Arc::new(RecordingConfigWriter::default()); + let composite = CompositeConfigStore::new(Some(reader), writer.clone()); + + // Act + Assert: the non-default store resolves. + let jwk = + futures::executor::block_on(composite.get(&StoreName::from("jwks_store"), "kid-1")) + .expect("should read from the non-default jwks_store"); + assert_eq!( + jwk, "{\"kty\":\"OKP\"}", + "should resolve the non-default config store by logical id" + ); + + // Unknown store id is a strict error, not a fallback to default. + let err = futures::executor::block_on(composite.get(&StoreName::from("nope"), "kid-1")) + .expect_err("should error on unknown store id"); + assert!( + matches!(err.current_context(), PlatformError::ConfigStore), + "unknown config id should map to a ConfigStore error" + ); + + // Write delegates to the management-path writer, preserving the StoreId. + composite + .put(&StoreId::from("jwks_store"), "current-kid", "kid-2") + .expect("should delegate write"); + assert_eq!( + writer + .puts + .lock() + .expect("should acquire writer lock") + .as_slice(), + &[( + "jwks_store".to_owned(), + "current-kid".to_owned(), + "kid-2".to_owned() + )], + "write must delegate to the writer with the same StoreId, key, and value" + ); + + // Key rotation also retires keys: delete must delegate too. + composite + .delete(&StoreId::from("jwks_store"), "kid-1") + .expect("should delegate delete"); + assert_eq!( + writer + .deletes + .lock() + .expect("should acquire writer lock") + .as_slice(), + &[("jwks_store".to_owned(), "kid-1".to_owned())], + "delete must delegate to the writer with the same StoreId and key" + ); + } + + #[test] + fn composite_config_absent_registry_errors() { + // A None reader is a hard error, never a silent fallback. + let writer = Arc::new(RecordingConfigWriter::default()); + let composite = CompositeConfigStore::new(None, writer); + let err = futures::executor::block_on( + composite.get(&StoreName::from("trusted_server_config"), "current-kid"), + ) + .expect_err("should error when no registry is wired"); + assert!( + matches!(err.current_context(), PlatformError::ConfigStore), + "absent config registry should map to a ConfigStore error" + ); + } + + #[test] + fn composite_secret_reads_named_store_and_writes_delegate() { + // Arrange: a SecretRegistry with default + a non-default `ts_secrets` id. + let reader = secret_registry( + &[ + ( + "trusted_server_secrets", + "API_KEY", + b"default-key".as_slice(), + ), + ("ts_secrets", "server-side-key", b"dd-secret".as_slice()), + ], + "trusted_server_secrets", + ); + let writer = Arc::new(RecordingSecretWriter::default()); + let composite = CompositeSecretStore::new(Some(reader), writer.clone()); + + // The non-default store resolves. + let value = futures::executor::block_on( + composite.get_bytes(&StoreName::from("ts_secrets"), "server-side-key"), + ) + .expect("should read from the non-default ts_secrets store"); + assert_eq!( + value, b"dd-secret", + "should resolve the non-default secret store by logical id" + ); + + // Unknown store id is a strict error. + let err = futures::executor::block_on(composite.get_bytes(&StoreName::from("nope"), "x")) + .expect_err("should error on unknown secret store"); + assert!( + matches!(err.current_context(), PlatformError::SecretStore), + "unknown secret id should map to a SecretStore error" + ); + + // create/delete delegate with the target StoreId preserved. + composite + .create(&StoreId::from("ts_secrets"), "new", "val") + .expect("should delegate create"); + assert_eq!( + writer + .creates + .lock() + .expect("should acquire writer lock") + .as_slice(), + &[("ts_secrets".to_owned(), "new".to_owned(), "val".to_owned())], + "create must delegate to the writer with the same StoreId" + ); + + composite + .delete(&StoreId::from("ts_secrets"), "new") + .expect("should delegate delete"); + assert_eq!( + writer + .deletes + .lock() + .expect("should acquire writer lock") + .as_slice(), + &[("ts_secrets".to_owned(), "new".to_owned())], + "delete must delegate to the writer with the same StoreId" + ); + } + + #[test] + fn composite_secret_absent_registry_errors() { + // A None reader is a hard error, never a silent fallback. + let writer = Arc::new(RecordingSecretWriter::default()); + let composite = CompositeSecretStore::new(None, writer); + let err = futures::executor::block_on( + composite.get_bytes(&StoreName::from("trusted_server_secrets"), "API_KEY"), + ) + .expect_err("should error when no registry is wired"); + assert!( + matches!(err.current_context(), PlatformError::SecretStore), + "absent secret registry should map to a SecretStore error" + ); + } +} diff --git a/crates/trusted-server-core/src/platform/error.rs b/crates/trusted-server-core/src/platform/error.rs index 699eb7d48..e4999168b 100644 --- a/crates/trusted-server-core/src/platform/error.rs +++ b/crates/trusted-server-core/src/platform/error.rs @@ -1,4 +1,5 @@ use derive_more::Display; +use error_stack::Report; /// Root error type for platform service operations. /// @@ -14,6 +15,16 @@ pub enum PlatformError { /// Secret store access failed. #[display("secret store error")] SecretStore, + /// The requested key was not present in an otherwise-reachable store. + /// + /// Distinct from [`PlatformError::ConfigStore`]/[`PlatformError::SecretStore`], + /// which signal that the store itself could not be read. Callers that must + /// fail closed on an unreachable store (for example, refusing to delete a + /// signing key when they cannot confirm it is not the active one) rely on + /// this distinction: a genuinely-absent key is safe to treat as absent, + /// while an unreachable store is not. + #[display("store key not found")] + NotFound, /// Backend registration or name computation failed. #[display("backend error")] Backend, @@ -29,3 +40,13 @@ pub enum PlatformError { } impl core::error::Error for PlatformError {} + +/// Returns `true` when `report`'s current context is [`PlatformError::NotFound`]. +/// +/// Lets callers distinguish a genuinely-absent key from a store that could not +/// be read, so they can fail closed on the latter (see +/// `request_signing::rotation`). +#[must_use] +pub fn is_not_found(report: &Report) -> bool { + matches!(report.current_context(), PlatformError::NotFound) +} diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index 2553229a4..0160c331a 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -35,6 +35,7 @@ use std::time::Duration; mod backend_naming; +mod composite; mod error; mod http; mod image_optimizer; @@ -50,8 +51,9 @@ pub use backend_naming::{ AuctionTargetCapabilities, AuctionTargetDescriptor, AuctionTargetId, BackendNamingError, BackendNamingPolicy, PredictedBackend, }; +pub use composite::{CompositeConfigStore, CompositeSecretStore}; pub use edgezero_core::key_value_store::{KvError, KvHandle, KvStore as PlatformKvStore}; -pub use error::PlatformError; +pub use error::{PlatformError, is_not_found}; pub use http::{ PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, PlatformSelectResult, UnavailableHttpClient, @@ -72,7 +74,10 @@ pub use template_cache::{ TemplateEntry, TemplateMetadata, TemplateMetadataEncodeError, UnavailableTemplateCache, VaryHeaderValues, VarySpec, }; -pub use traits::{PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformSecretStore}; +pub use traits::{ + PlatformBackend, PlatformConfigStore, PlatformConfigWriter, PlatformGeo, PlatformSecretStore, + PlatformSecretWriter, +}; pub use types::{ ClientInfo, GeoInfo, PlatformBackendSpec, RuntimeServices, RuntimeServicesBuilder, StoreId, StoreName, diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 70eb55a9a..cb5e03523 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -3,6 +3,7 @@ use std::net::IpAddr; use std::sync::{Arc, Mutex}; use std::time::Duration; +use async_trait::async_trait; use base64::{Engine as _, engine::general_purpose}; use ed25519_dalek::SigningKey; use error_stack::{Report, ResultExt as _}; @@ -18,8 +19,13 @@ use crate::request_signing::{JWKS_STORE_NAME, SIGNING_STORE_NAME}; pub(crate) struct NoopConfigStore; +#[async_trait(?Send)] impl PlatformConfigStore for NoopConfigStore { - fn get(&self, _store_name: &StoreName, _key: &str) -> Result> { + async fn get( + &self, + _store_name: &StoreName, + _key: &str, + ) -> Result> { Err(Report::new(PlatformError::Unsupported)) } @@ -39,8 +45,9 @@ impl PlatformConfigStore for NoopConfigStore { pub(crate) struct NoopSecretStore; +#[async_trait(?Send)] impl PlatformSecretStore for NoopSecretStore { - fn get_bytes( + async fn get_bytes( &self, _store_name: &StoreName, _key: &str, @@ -72,8 +79,13 @@ impl HashMapConfigStore { } } +#[async_trait(?Send)] impl PlatformConfigStore for HashMapConfigStore { - fn get(&self, _store_name: &StoreName, key: &str) -> Result> { + async fn get( + &self, + _store_name: &StoreName, + key: &str, + ) -> Result> { self.data .get(key) .cloned() @@ -104,8 +116,9 @@ impl HashMapSecretStore { } } +#[async_trait(?Send)] impl PlatformSecretStore for HashMapSecretStore { - fn get_bytes( + async fn get_bytes( &self, _store_name: &StoreName, key: &str, @@ -940,6 +953,24 @@ pub(crate) fn noop_services() -> RuntimeServices { build_services_with_config(NoopConfigStore) } +/// Build a [`RuntimeServices`] carrying a caller-supplied KV registry, so tests +/// can exercise named-store resolution via +/// [`RuntimeServices::kv_handle_named`]. +pub(crate) fn noop_services_with_kv_registry( + kv_registry: edgezero_core::store_registry::KvRegistry, +) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(NoopConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .kv_registry(Some(kv_registry)) + .backend(Arc::new(NoopBackend)) + .http_client(Arc::new(NoopHttpClient)) + .geo(Arc::new(NoopGeo)) + .client_info(ClientInfo::default()) + .build() +} + /// Build a [`RuntimeServices`] whose auction telemetry sink is the supplied /// recording (or otherwise custom) sink, so tests can assert which terminal /// auction events were emitted. @@ -1303,10 +1334,13 @@ mod tests { let services = build_services_with_config_and_secret(NoopConfigStore, NoopSecretStore); // Act: both stores return Unsupported (confirming the injected impls are active) - let config_result = services.config_store().get(&StoreName::from("s"), "k"); - let secret_result = services - .secret_store() - .get_bytes(&StoreName::from("s"), "k"); + let config_result = + futures::executor::block_on(services.config_store().get(&StoreName::from("s"), "k")); + let secret_result = futures::executor::block_on( + services + .secret_store() + .get_bytes(&StoreName::from("s"), "k"), + ); assert!( config_result.is_err(), @@ -1332,17 +1366,19 @@ mod tests { ); assert_eq!( - services - .config_store() - .get(&JWKS_STORE_NAME, "current-kid") - .expect("should read current-kid from config test store"), + futures::executor::block_on( + services.config_store().get(&JWKS_STORE_NAME, "current-kid") + ) + .expect("should read current-kid from config test store"), "test-kid" ); assert_eq!( - services - .secret_store() - .get_bytes(&SIGNING_STORE_NAME, "test-kid") - .expect("should read signing key bytes from secret test store"), + futures::executor::block_on( + services + .secret_store() + .get_bytes(&SIGNING_STORE_NAME, "test-kid") + ) + .expect("should read signing key bytes from secret test store"), b"secret-material".to_vec() ); } @@ -1351,14 +1387,14 @@ mod tests { fn build_request_signing_services_provides_current_kid_and_signing_key() { let services = build_request_signing_services(); - let kid = services - .config_store() - .get(&JWKS_STORE_NAME, "current-kid") - .expect("should expose current-kid in config store"); - let key_bytes = services - .secret_store() - .get_bytes(&SIGNING_STORE_NAME, &kid) - .expect("should expose signing key bytes in secret store"); + let kid = futures::executor::block_on( + services.config_store().get(&JWKS_STORE_NAME, "current-kid"), + ) + .expect("should expose current-kid in config store"); + let key_bytes = futures::executor::block_on( + services.secret_store().get_bytes(&SIGNING_STORE_NAME, &kid), + ) + .expect("should expose signing key bytes in secret store"); assert_eq!(kid, "test-kid", "should use the standard signing test kid"); assert!( diff --git a/crates/trusted-server-core/src/platform/traits.rs b/crates/trusted-server-core/src/platform/traits.rs index 6c31cd279..fd1f369d8 100644 --- a/crates/trusted-server-core/src/platform/traits.rs +++ b/crates/trusted-server-core/src/platform/traits.rs @@ -1,14 +1,16 @@ use std::net::IpAddr; +use async_trait::async_trait; use error_stack::Report; use super::{BackendNamingPolicy, GeoInfo, PlatformBackendSpec, PlatformError, StoreId, StoreName}; -/// Synchronous, object-safe access to a key-value config store. +/// Object-safe access to a key-value config store. /// -/// Reads use the edge-visible store name. Writes use the platform management -/// store identifier because Fastly separates the runtime store name from the -/// management API store ID. +/// Reads are async and use the edge-visible store name. Writes stay +/// synchronous and use the platform management store identifier because Fastly +/// separates the runtime store name from the management API store ID. +#[async_trait(?Send)] pub trait PlatformConfigStore: Send + Sync { /// Retrieve a string value from `store_name` by `key`. /// @@ -16,7 +18,8 @@ pub trait PlatformConfigStore: Send + Sync { /// /// Returns [`PlatformError::ConfigStore`] when the key does not exist or /// the store cannot be opened. - fn get(&self, store_name: &StoreName, key: &str) -> Result>; + async fn get(&self, store_name: &StoreName, key: &str) + -> Result>; /// Store a string value in the management store identified by `store_id`. /// @@ -35,10 +38,11 @@ pub trait PlatformConfigStore: Send + Sync { fn delete(&self, store_id: &StoreId, key: &str) -> Result<(), Report>; } -/// Synchronous, object-safe access to a secret store. +/// Object-safe access to a secret store. /// -/// Reads use the edge-visible store name. Writes use the platform management -/// store identifier. +/// Reads are async and use the edge-visible store name. Writes stay +/// synchronous and use the platform management store identifier. +#[async_trait(?Send)] pub trait PlatformSecretStore: Send + Sync { /// Retrieve a secret value as raw bytes from `store_name` by `key`. /// @@ -46,7 +50,7 @@ pub trait PlatformSecretStore: Send + Sync { /// /// Returns [`PlatformError::SecretStore`] when the store cannot be opened, /// the key does not exist, or decryption fails. - fn get_bytes( + async fn get_bytes( &self, store_name: &StoreName, key: &str, @@ -58,12 +62,12 @@ pub trait PlatformSecretStore: Send + Sync { /// /// Returns [`PlatformError::SecretStore`] when the secret cannot be /// retrieved or is not valid UTF-8. - fn get_string( + async fn get_string( &self, store_name: &StoreName, key: &str, ) -> Result> { - let bytes = self.get_bytes(store_name, key)?; + let bytes = self.get_bytes(store_name, key).await?; String::from_utf8(bytes).map_err(|error| { Report::new(PlatformError::SecretStore) .attach(format!("secret is not valid UTF-8: {error}")) @@ -92,6 +96,59 @@ pub trait PlatformSecretStore: Send + Sync { fn delete(&self, store_id: &StoreId, name: &str) -> Result<(), Report>; } +/// Write-only view of a config store, keyed by management [`StoreId`]. +/// +/// Extracted from [`PlatformConfigStore`] so the registry-backed composite +/// store can delegate writes to a management-path implementation without also +/// depending on that implementation's read path. Reads flow through the +/// `EdgeZero` registry instead (see `platform::composite`). +pub trait PlatformConfigWriter: Send + Sync { + /// Store a string value in the management store identified by `store_id`. + /// + /// # Errors + /// + /// Returns [`PlatformError::ConfigStore`] when the write fails or the + /// platform management API is unreachable. + fn put(&self, store_id: &StoreId, key: &str, value: &str) -> Result<(), Report>; + + /// Delete a key from the management store identified by `store_id`. + /// + /// # Errors + /// + /// Returns [`PlatformError::ConfigStore`] when the delete fails or the + /// platform management API is unreachable. + fn delete(&self, store_id: &StoreId, key: &str) -> Result<(), Report>; +} + +/// Write-only view of a secret store, keyed by management [`StoreId`]. +/// +/// Extracted from [`PlatformSecretStore`] so the registry-backed composite +/// store can delegate writes to a management-path implementation without also +/// depending on that implementation's read path. Reads flow through the +/// `EdgeZero` registry instead (see `platform::composite`). +pub trait PlatformSecretWriter: Send + Sync { + /// Create or overwrite a secret in the management store identified by `store_id`. + /// + /// # Errors + /// + /// Returns [`PlatformError::SecretStore`] when the create fails or the + /// platform management API is unreachable. + fn create( + &self, + store_id: &StoreId, + name: &str, + value: &str, + ) -> Result<(), Report>; + + /// Delete a secret from the management store identified by `store_id`. + /// + /// # Errors + /// + /// Returns [`PlatformError::SecretStore`] when the delete fails or the + /// platform management API is unreachable. + fn delete(&self, store_id: &StoreId, name: &str) -> Result<(), Report>; +} + /// Synchronous, object-safe dynamic backend management. pub trait PlatformBackend: Send + Sync { /// Return this adapter's pure backend naming and transport timer policy. diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index 7a3d09334..90b9455c6 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -3,11 +3,13 @@ use std::net::IpAddr; use std::sync::Arc; use std::time::Duration; +use edgezero_core::store_registry::KvRegistry; + use crate::auction::telemetry::{AuctionTelemetrySink, NoopAuctionTelemetrySink}; use super::{ - PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformHttpClient, PlatformKvStore, - PlatformSecretStore, + KvHandle, PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformHttpClient, + PlatformKvStore, PlatformSecretStore, }; /// Geographic information extracted from a request. @@ -69,12 +71,18 @@ pub struct ClientInfo { pub server_region: Option, } -/// Edge-visible name used to open a config or secret store at runtime. +/// Logical runtime store id used to resolve a config or secret store on read. /// /// Passed to read methods on [`super::PlatformConfigStore`] and -/// [`super::PlatformSecretStore`]. Distinct from [`StoreId`] to prevent -/// accidentally passing a management API identifier where a runtime name is -/// expected. +/// [`super::PlatformSecretStore`]. Under the registry-backed composite store +/// (`platform::composite`) this value is resolved as a **logical store id** via +/// `ConfigRegistry::named` / `SecretRegistry::named` — i.e. the `[stores.*]` id +/// declared in `edgezero.toml` (`trusted_server_config`, `jwks_store`, +/// `ts_secrets`, `datadome_ip_bypass`, …), not a physical platform store name. +/// Under the D7 convention the logical id equals the platform store name, so +/// existing call sites need no change. Distinct from [`StoreId`] (the +/// management-API write identifier) to prevent passing a write identifier where +/// a read id is expected. #[derive(Debug, Clone, PartialEq, Eq, Hash, derive_more::Display)] pub struct StoreName(String); @@ -168,6 +176,14 @@ pub struct RuntimeServices { /// per-request basis by cloning [`RuntimeServices`] with /// [`RuntimeServices::with_kv_store`]. pub(crate) kv_store: Arc, + /// Per-request registry of KV stores keyed by logical id. + /// + /// Populated by adapters from the `EdgeZero` + /// [`KvRegistry`](edgezero_core::store_registry::KvRegistry) in request + /// extensions and consumed by [`RuntimeServices::kv_handle_named`] to + /// resolve a named store (e.g. `consent_store`). `None` when no registry is + /// wired, in which case named lookups return `None`. + pub(crate) kv_registry: Option, /// Shared transformed-template cache. Defaults to /// [`UnavailableTemplateCache`], so adapters without one degrade to transforming /// per request rather than failing. Spike-only; see @@ -282,6 +298,19 @@ impl RuntimeServices { super::KvHandle::new(self.kv_store.clone()) } + /// Resolve a KV store by its logical `id` from the per-request registry. + /// + /// Returns a [`KvHandle`] for the named store, or `None` when no registry + /// is wired or `id` is not declared. Distinct from [`kv_handle`](Self::kv_handle), + /// which returns the default request-path store; use this to select a + /// non-default store such as the consent store. + #[must_use] + pub fn kv_handle_named(&self, id: &str) -> Option { + self.kv_registry + .as_ref() + .and_then(|registry| registry.named(id)) + } + /// Returns a clone of this instance with the KV store replaced by `store`. /// /// Adapters use this to lazily inject the request-specific KV store for @@ -335,6 +364,7 @@ pub struct RuntimeServicesBuilder { config_store: Option>, secret_store: Option>, kv_store: Option>, + kv_registry: Option, template_cache: Option>, template_assembler: Option>, backend: Option>, @@ -350,6 +380,7 @@ impl RuntimeServicesBuilder { config_store: None, secret_store: None, kv_store: None, + kv_registry: None, template_cache: None, template_assembler: None, backend: None, @@ -398,6 +429,16 @@ impl RuntimeServicesBuilder { self } + /// Set the per-request KV registry used by + /// [`RuntimeServices::kv_handle_named`] to resolve named stores. + /// + /// Optional: when unset, named lookups return `None`. + #[must_use] + pub fn kv_registry(mut self, kv_registry: Option) -> Self { + self.kv_registry = kv_registry; + self + } + /// Set the backend implementation. #[must_use] pub fn backend(mut self, backend: Arc) -> Self { @@ -453,6 +494,7 @@ impl RuntimeServicesBuilder { kv_store: self .kv_store .expect("should set kv_store before building RuntimeServices"), + kv_registry: self.kv_registry, // Defaulted rather than required: an adapter with no template cache // should degrade to transforming per request, not fail to build. template_cache: self diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 5d659cdbf..616faec9c 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -2444,8 +2444,9 @@ mod tests { } } + #[async_trait::async_trait(?Send)] impl PlatformSecretStore for CountingSecretStore { - fn get_bytes( + async fn get_bytes( &self, _store_name: &StoreName, key: &str, diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 05daf0b4e..e07348405 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -54,7 +54,9 @@ use crate::auction::types::{ use crate::cache_policy::{ CachePolicy, EdgeCacheHeader, cache_control_headers_are_private_or_no_store, }; -use crate::consent::{consent_allows_server_side_auction, gate_eids_by_consent}; +use crate::consent::{ + consent_allows_server_side_auction, gate_eids_by_consent, resolve_consent_kv, +}; use crate::constants::{COOKIE_TS_EIDS, HEADER_X_COMPRESS_HINT}; use crate::cookies::handle_request_cookies; use crate::creative_opportunities::{AssemblyMode, CreativeOpportunitiesConfig}; @@ -4081,8 +4083,9 @@ pub struct AuctionDispatch<'a> { /// /// # Errors /// -/// Returns a [`TrustedServerError`] if the proxy request fails or the -/// origin backend is unreachable. +/// Returns a [`TrustedServerError`] if the configured consent KV store cannot be +/// resolved (fail closed), the proxy request fails, or the origin backend is +/// unreachable. pub async fn handle_publisher_request( settings: &Settings, services: &RuntimeServices, @@ -4092,6 +4095,11 @@ pub async fn handle_publisher_request( mut req: Request, edge_header: EdgeCacheHeader, ) -> Result> { + // Fail-closed consent guard — see [`resolve_consent_kv`]. Publisher pages + // act on consent data, so a configured-but-unresolvable consent store makes + // the page unavailable (503) instead of serving it without consent. + let _consent_kv = resolve_consent_kv(settings, services)?; + log::debug!("Proxying request to publisher_origin"); // Adapter fallbacks prepare this before EC/cookie handling. Keep this @@ -6429,7 +6437,8 @@ fn normalize_page_bids_path(raw: &str) -> String { /// /// # Errors /// -/// Returns [`TrustedServerError`] if cookie parsing or EC ID generation fails. +/// Returns [`TrustedServerError`] if the configured consent KV store cannot be +/// resolved (fail closed), or if cookie parsing or EC ID generation fails. pub async fn handle_page_bids( settings: &Settings, services: &RuntimeServices, @@ -6452,6 +6461,10 @@ pub async fn handle_page_bids( return Ok(page_bids_preflight_denied()); } + // Fail-closed consent guard — like the auction, page-bids acts on consent + // data, so it must not run with a configured-but-unresolvable consent store. + let _consent_kv = resolve_consent_kv(settings, services)?; + // Deprecation signal for the transition alias. Evaluated after the // cross-site gate, so the count reflects genuine SPA clients still running a // pre-rename tsjs bundle rather than anything a third-party page can @@ -8368,8 +8381,12 @@ mod tests { .body(EdgeBody::empty()) .expect("should build test request"); - let ec_context = EcContext::read_from_request(&settings, &req, &noop_services()) - .expect("should read EC context"); + let ec_context = futures::executor::block_on(EcContext::read_from_request( + &settings, + &req, + &noop_services(), + )) + .expect("should read EC context"); assert_eq!( ec_context.ec_value(), @@ -8396,8 +8413,9 @@ mod tests { req: Request, ) -> PublisherResponse { let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - let mut ec_context = - EcContext::read_from_request(settings, &req, services).expect("should read EC context"); + let mut ec_context = EcContext::read_from_request(settings, &req, services) + .await + .expect("should read EC context"); handle_publisher_request( settings, services, @@ -20563,6 +20581,7 @@ mod tests { req: Request, ) -> Response { let mut ec_context = EcContext::read_from_request(settings, &req, &noop_services()) + .await .expect("should read EC context"); run_page_bids_response_with_ec(settings, orchestrator, slots, &mut ec_context, req) .await diff --git a/crates/trusted-server-core/src/request_signing/endpoints.rs b/crates/trusted-server-core/src/request_signing/endpoints.rs index d35e51a15..520f09fb9 100644 --- a/crates/trusted-server-core/src/request_signing/endpoints.rs +++ b/crates/trusted-server-core/src/request_signing/endpoints.rs @@ -46,16 +46,16 @@ fn request_body_bytes( /// # Errors /// /// Returns an error if JWKS cannot be retrieved, parsed, or serialized. -pub fn handle_trusted_server_discovery( +pub async fn handle_trusted_server_discovery( _settings: &Settings, services: &RuntimeServices, _req: Request, ) -> Result, Report> { - let jwks_json = crate::request_signing::jwks::get_active_jwks(services).change_context( - TrustedServerError::Configuration { + let jwks_json = crate::request_signing::jwks::get_active_jwks(services) + .await + .change_context(TrustedServerError::Configuration { message: "failed to retrieve JWKS".into(), - }, - )?; + })?; let jwks_value: serde_json::Value = serde_json::from_str(&jwks_json).change_context(TrustedServerError::Configuration { @@ -108,7 +108,7 @@ const ADMIN_MAX_BODY_BYTES: usize = 4096; /// /// Returns an error if the request body cannot be parsed as JSON or if the /// response body cannot be serialized. -pub fn handle_verify_signature( +pub async fn handle_verify_signature( _settings: &Settings, services: &RuntimeServices, req: Request, @@ -125,7 +125,8 @@ pub fn handle_verify_signature( &verify_req.signature, &verify_req.kid, services, - ); + ) + .await; let response = match verification_result { Ok(true) => VerifySignatureResponse { @@ -293,7 +294,7 @@ pub fn kid_is_creatable(kid: &str) -> bool { /// # Errors /// /// Returns an error if the request signing settings are missing or JSON parsing fails. -pub fn handle_rotate_key( +pub async fn handle_rotate_key( settings: &Settings, services: &RuntimeServices, req: Request, @@ -319,7 +320,10 @@ pub fn handle_rotate_key( } else { Ok(()) }; - let result = validation_result.and_then(|()| manager.rotate_key(services, rotate_req.kid)); + let result = match validation_result { + Ok(()) => manager.rotate_key(services, rotate_req.kid).await, + Err(error) => Err(error), + }; match result { Ok(result) => { @@ -412,7 +416,7 @@ pub struct DeactivateKeyResponse { /// # Errors /// /// Returns an error if the request signing settings are missing or JSON parsing fails. -pub fn handle_deactivate_key( +pub async fn handle_deactivate_key( settings: &Settings, services: &RuntimeServices, req: Request, @@ -435,20 +439,26 @@ pub fn handle_deactivate_key( // validation rules (e.g. digit- or uppercase-leading) can still be // deactivated or deleted. The stricter validate_kid only gates new key // creation/rotation. - let result = validate_kid_format(&deactivate_req.kid).and_then(|()| { - if deactivate_req.delete { - manager.delete_key(services, &deactivate_req.kid) - } else { - manager.deactivate_key(services, &deactivate_req.kid) + let result = match validate_kid_format(&deactivate_req.kid) { + Ok(()) => { + if deactivate_req.delete { + manager.delete_key(services, &deactivate_req.kid).await + } else { + manager.deactivate_key(services, &deactivate_req.kid).await + } } - }); + Err(error) => Err(error), + }; match result { Ok(()) => { - let remaining_keys = manager.list_active_keys(services).unwrap_or_else(|e| { - log::warn!("failed to list active keys after deactivation: {}", e); - vec![] - }); + let remaining_keys = manager + .list_active_keys(services) + .await + .unwrap_or_else(|e| { + log::warn!("failed to list active keys after deactivation: {}", e); + vec![] + }); let response = DeactivateKeyResponse { success: true, @@ -559,8 +569,13 @@ mod tests { /// Config store stub that returns a minimal JWKS with one Ed25519 key. struct StubJwksConfigStore; + #[async_trait::async_trait(?Send)] impl PlatformConfigStore for StubJwksConfigStore { - fn get(&self, _store_name: &StoreName, key: &str) -> Result> { + async fn get( + &self, + _store_name: &StoreName, + key: &str, + ) -> Result> { match key { "active-kids" => Ok("test-kid-1".to_string()), "test-kid-1" => Ok( @@ -586,8 +601,10 @@ mod tests { let services = build_request_signing_services(); let payload = "test message"; - let signer = crate::request_signing::RequestSigner::from_services(&services) - .expect("should create signer from services"); + let signer = futures::executor::block_on( + crate::request_signing::RequestSigner::from_services(&services), + ) + .expect("should create signer from services"); let signature = signer .sign(payload.as_bytes()) .expect("should sign payload"); @@ -605,7 +622,7 @@ mod tests { Some(&body), ); - let resp = handle_verify_signature(&settings, &services, req) + let resp = futures::executor::block_on(handle_verify_signature(&settings, &services, req)) .expect("should handle verification request"); assert_eq!(resp.status(), StatusCode::OK); assert_json_content_type(&resp); @@ -624,8 +641,10 @@ mod tests { let settings = crate::test_support::tests::create_test_settings(); let services = build_request_signing_services(); - let signer = crate::request_signing::RequestSigner::from_services(&services) - .expect("should create signer from services"); + let signer = futures::executor::block_on( + crate::request_signing::RequestSigner::from_services(&services), + ) + .expect("should create signer from services"); let wrong_signature = signer .sign(b"different payload") @@ -644,7 +663,7 @@ mod tests { Some(&body), ); - let resp = handle_verify_signature(&settings, &services, req) + let resp = futures::executor::block_on(handle_verify_signature(&settings, &services, req)) .expect("should handle verification request"); assert_eq!(resp.status(), StatusCode::OK); assert_json_content_type(&resp); @@ -679,7 +698,7 @@ mod tests { ); let services = noop_services(); - let resp = handle_verify_signature(&settings, &services, req) + let resp = futures::executor::block_on(handle_verify_signature(&settings, &services, req)) .expect("should return a verification response for internal errors"); assert_eq!(resp.status(), StatusCode::OK, "should return 200 OK"); @@ -715,7 +734,8 @@ mod tests { Some("not valid json"), ); - let result = handle_verify_signature(&settings, &noop_services(), req); + let result = + futures::executor::block_on(handle_verify_signature(&settings, &noop_services(), req)); assert!(result.is_err(), "Malformed JSON should error"); } @@ -724,7 +744,7 @@ mod tests { let settings = crate::test_support::tests::create_test_settings(); let req = build_request(Method::POST, "https://test.com/admin/keys/rotate", None); - let resp = handle_rotate_key(&settings, &noop_services(), req) + let resp = futures::executor::block_on(handle_rotate_key(&settings, &noop_services(), req)) .expect("should return a response even when stores are unavailable"); assert_eq!( @@ -762,7 +782,7 @@ mod tests { Some(&body_json), ); - let resp = handle_rotate_key(&settings, &noop_services(), req) + let resp = futures::executor::block_on(handle_rotate_key(&settings, &noop_services(), req)) .expect("should return a response even when stores are unavailable"); assert_eq!( @@ -794,7 +814,8 @@ mod tests { Some("invalid json"), ); - let result = handle_rotate_key(&settings, &noop_services(), req); + let result = + futures::executor::block_on(handle_rotate_key(&settings, &noop_services(), req)); assert!(result.is_err(), "Invalid JSON should return error"); } @@ -813,7 +834,7 @@ mod tests { Some(&body_json), ); - let resp = handle_rotate_key(&settings, &noop_services(), req) + let resp = futures::executor::block_on(handle_rotate_key(&settings, &noop_services(), req)) .expect("should return a response for invalid kid"); assert_eq!( @@ -856,8 +877,9 @@ mod tests { Some(&body_json), ); - let resp = handle_deactivate_key(&settings, &noop_services(), req) - .expect("should return a response even when stores are unavailable"); + let resp = + futures::executor::block_on(handle_deactivate_key(&settings, &noop_services(), req)) + .expect("should return a response even when stores are unavailable"); assert_eq!( resp.status(), @@ -896,8 +918,9 @@ mod tests { Some(&body_json), ); - let resp = handle_deactivate_key(&settings, &noop_services(), req) - .expect("should return a response even when stores are unavailable"); + let resp = + futures::executor::block_on(handle_deactivate_key(&settings, &noop_services(), req)) + .expect("should return a response even when stores are unavailable"); assert_eq!( resp.status(), @@ -932,7 +955,8 @@ mod tests { Some("invalid json"), ); - let result = handle_deactivate_key(&settings, &noop_services(), req); + let result = + futures::executor::block_on(handle_deactivate_key(&settings, &noop_services(), req)); assert!(result.is_err(), "Invalid JSON should return error"); } @@ -953,8 +977,9 @@ mod tests { Some(&body_json), ); - let resp = handle_deactivate_key(&settings, &noop_services(), req) - .expect("should return a response for invalid kid"); + let resp = + futures::executor::block_on(handle_deactivate_key(&settings, &noop_services(), req)) + .expect("should return a response for invalid kid"); assert_eq!( resp.status(), @@ -988,8 +1013,9 @@ mod tests { "https://test.com/verify-signature", Some(&oversized), ); - let err = handle_verify_signature(&settings, &noop_services(), req) - .expect_err("should reject oversized body"); + let err = + futures::executor::block_on(handle_verify_signature(&settings, &noop_services(), req)) + .expect_err("should reject oversized body"); assert_eq!( err.current_context().status_code(), StatusCode::PAYLOAD_TOO_LARGE, @@ -1001,8 +1027,9 @@ mod tests { fn verify_signature_rejects_streaming_body() { let settings = crate::test_support::tests::create_test_settings(); let req = build_streaming_request(Method::POST, "https://test.com/verify-signature"); - let err = handle_verify_signature(&settings, &noop_services(), req) - .expect_err("should reject streaming verify body"); + let err = + futures::executor::block_on(handle_verify_signature(&settings, &noop_services(), req)) + .expect_err("should reject streaming verify body"); assert_eq!( err.current_context().status_code(), StatusCode::BAD_REQUEST, @@ -1027,7 +1054,7 @@ mod tests { "https://test.com/admin/keys/rotate", Some(&oversized), ); - let err = handle_rotate_key(&settings, &noop_services(), req) + let err = futures::executor::block_on(handle_rotate_key(&settings, &noop_services(), req)) .expect_err("should reject oversized body"); assert_eq!( err.current_context().status_code(), @@ -1040,7 +1067,7 @@ mod tests { fn rotate_key_rejects_streaming_body() { let settings = crate::test_support::tests::create_test_settings(); let req = build_streaming_request(Method::POST, "https://test.com/admin/keys/rotate"); - let err = handle_rotate_key(&settings, &noop_services(), req) + let err = futures::executor::block_on(handle_rotate_key(&settings, &noop_services(), req)) .expect_err("should reject streaming rotate body"); assert_eq!( err.current_context().status_code(), @@ -1066,8 +1093,9 @@ mod tests { "https://test.com/admin/keys/deactivate", Some(&oversized), ); - let err = handle_deactivate_key(&settings, &noop_services(), req) - .expect_err("should reject oversized body"); + let err = + futures::executor::block_on(handle_deactivate_key(&settings, &noop_services(), req)) + .expect_err("should reject oversized body"); assert_eq!( err.current_context().status_code(), StatusCode::PAYLOAD_TOO_LARGE, @@ -1159,8 +1187,9 @@ mod tests { Some(&body_json), ); - let resp = handle_deactivate_key(&settings, &noop_services(), req) - .expect("should return a response for a legacy digit-leading kid"); + let resp = + futures::executor::block_on(handle_deactivate_key(&settings, &noop_services(), req)) + .expect("should return a response for a legacy digit-leading kid"); assert_ne!( resp.status(), @@ -1197,7 +1226,11 @@ mod tests { // noop_services() config store always returns Err, so the discovery // handler propagates the error rather than absorbing it into a 500. - let result = handle_trusted_server_discovery(&settings, &noop_services(), req); + let result = futures::executor::block_on(handle_trusted_server_discovery( + &settings, + &noop_services(), + req, + )); assert!( result.is_err(), @@ -1215,8 +1248,9 @@ mod tests { ); let services = build_services_with_config(StubJwksConfigStore); - let resp = handle_trusted_server_discovery(&settings, &services, req) - .expect("should return discovery document when config store is populated"); + let resp = + futures::executor::block_on(handle_trusted_server_discovery(&settings, &services, req)) + .expect("should return discovery document when config store is populated"); assert_eq!(resp.status(), StatusCode::OK, "should return 200 OK"); diff --git a/crates/trusted-server-core/src/request_signing/jwks.rs b/crates/trusted-server-core/src/request_signing/jwks.rs index 7fd7790fe..1f9a626b0 100644 --- a/crates/trusted-server-core/src/request_signing/jwks.rs +++ b/crates/trusted-server-core/src/request_signing/jwks.rs @@ -69,13 +69,16 @@ impl Keypair { /// unavailable, the `active-kids` key is missing, or any referenced JWK entry /// cannot be read. The underlying [`crate::platform::PlatformError`] is /// preserved as context in the error chain. -pub fn get_active_jwks(services: &RuntimeServices) -> Result> { - let active_kids = read_active_kids(services)?; +pub async fn get_active_jwks( + services: &RuntimeServices, +) -> Result> { + let active_kids = read_active_kids(services).await?; let mut jwks = Vec::new(); for kid in active_kids { let jwk = services .config_store() .get(&JWKS_STORE_NAME, &kid) + .await .change_context(TrustedServerError::Configuration { message: format!("failed to get JWK for kid: {kid}"), })?; @@ -105,8 +108,9 @@ mod tests { struct FailingConfigStore; + #[async_trait::async_trait(?Send)] impl PlatformConfigStore for FailingConfigStore { - fn get( + async fn get( &self, _store_name: &StoreName, _key: &str, @@ -135,7 +139,7 @@ mod tests { #[test] fn get_active_jwks_fails_with_configuration_error_when_store_unavailable() { let services = build_services_with_config(FailingConfigStore); - let result = get_active_jwks(&services); + let result = futures::executor::block_on(get_active_jwks(&services)); assert!( result.is_err(), diff --git a/crates/trusted-server-core/src/request_signing/mod.rs b/crates/trusted-server-core/src/request_signing/mod.rs index 65f97dcb5..dd5063ed0 100644 --- a/crates/trusted-server-core/src/request_signing/mod.rs +++ b/crates/trusted-server-core/src/request_signing/mod.rs @@ -22,7 +22,7 @@ use std::sync::LazyLock; -use error_stack::{Report, ResultExt as _}; +use error_stack::Report; use crate::error::TrustedServerError; use crate::platform::{RuntimeServices, StoreName}; @@ -61,15 +61,26 @@ fn parse_active_kids(active_kids: &str) -> Vec { .collect() } -fn read_active_kids(services: &RuntimeServices) -> Result, Report> { - services +async fn read_active_kids( + services: &RuntimeServices, +) -> Result, Report> { + // A missing `active-kids` entry means none have been recorded yet — an empty + // list. But an unreadable store must propagate: silently treating it as empty + // would let a rotation overwrite the real list and drop live keys from the + // published JWKS. + match services .config_store() .get(&JWKS_STORE_NAME, "active-kids") - .change_context(TrustedServerError::Configuration { - message: "failed to read active-kids from config store".into(), - }) - .attach("while fetching active kids list") - .map(|active_kids| parse_active_kids(&active_kids)) + .await + { + Ok(active_kids) => Ok(parse_active_kids(&active_kids)), + Err(report) if crate::platform::is_not_found(&report) => Ok(Vec::new()), + Err(report) => Err(report + .change_context(TrustedServerError::Configuration { + message: "failed to read active-kids from config store".into(), + }) + .attach("while fetching active kids list")), + } } pub use discovery::*; diff --git a/crates/trusted-server-core/src/request_signing/rotation.rs b/crates/trusted-server-core/src/request_signing/rotation.rs index 2d7222b70..c0e72a2e0 100644 --- a/crates/trusted-server-core/src/request_signing/rotation.rs +++ b/crates/trusted-server-core/src/request_signing/rotation.rs @@ -13,7 +13,7 @@ use uuid::Uuid; use super::{Keypair, read_active_kids}; use crate::error::TrustedServerError; -use crate::platform::{RuntimeServices, StoreId}; +use crate::platform::{RuntimeServices, StoreId, is_not_found}; use crate::request_signing::JWKS_STORE_NAME; /// Result of a key rotation operation. @@ -60,26 +60,41 @@ impl KeyRotationManager { /// # Errors /// /// Returns an error if key storage or update operations fail. - pub fn rotate_key( + pub async fn rotate_key( &self, services: &RuntimeServices, kid: Option, ) -> Result> { - let previous_kid = services + // Distinguish "no key rotated in yet" (safe: `None`/empty) from "store + // unreadable" (abort): rotating on top of an unreadable store would drop + // the existing active kids and previous key from the rotation result. + let previous_kid = match services .config_store() .get(&JWKS_STORE_NAME, "current-kid") - .ok(); - let active_kids = read_active_kids(services).unwrap_or_default(); + .await + { + Ok(current) => Some(current), + Err(report) if is_not_found(&report) => None, + Err(report) => { + return Err(report.change_context(TrustedServerError::Configuration { + message: "failed to read current signing key before rotation".into(), + })); + } + }; + let active_kids = read_active_kids(services).await?; let new_kid = match kid { Some(kid) => { - if self.key_exists(services, &kid, &active_kids) { + if self.key_exists(services, &kid, &active_kids).await? { return Err(Report::new(TrustedServerError::Configuration { message: format!("kid '{kid}' already exists; choose a unique kid"), })); } kid } - None => self.generate_unique_date_based_kid(services, &active_kids), + None => { + self.generate_unique_date_based_kid(services, &active_kids) + .await? + } }; let keypair = Keypair::generate(); @@ -142,22 +157,38 @@ impl KeyRotationManager { }) } - fn key_exists(&self, services: &RuntimeServices, kid: &str, active_kids: &[String]) -> bool { - active_kids.iter().any(|active_kid| active_kid == kid) - || services.config_store().get(&JWKS_STORE_NAME, kid).is_ok() + async fn key_exists( + &self, + services: &RuntimeServices, + kid: &str, + active_kids: &[String], + ) -> Result> { + if active_kids.iter().any(|active_kid| active_kid == kid) { + return Ok(true); + } + // Fail closed on an unreadable store: treating an unavailable store as + // "key does not exist" would let rotation mint a colliding kid and + // overwrite a live key. Only a positive not-found is a safe `false`. + match services.config_store().get(&JWKS_STORE_NAME, kid).await { + Ok(_) => Ok(true), + Err(report) if is_not_found(&report) => Ok(false), + Err(report) => Err(report.change_context(TrustedServerError::Configuration { + message: format!("failed to check whether signing key '{kid}' already exists"), + })), + } } - fn generate_unique_date_based_kid( + async fn generate_unique_date_based_kid( &self, services: &RuntimeServices, active_kids: &[String], - ) -> String { + ) -> Result> { let base_kid = generate_date_based_kid(); - if !self.key_exists(services, &base_kid, active_kids) { - return base_kid; + if !self.key_exists(services, &base_kid, active_kids).await? { + return Ok(base_kid); } - format!("{base_kid}-{}", Uuid::new_v4().simple()) + Ok(format!("{base_kid}-{}", Uuid::new_v4().simple())) } fn store_private_key( @@ -232,11 +263,11 @@ impl KeyRotationManager { /// # Errors /// /// Returns an error if the active keys cannot be retrieved from the config store. - pub fn list_active_keys( + pub async fn list_active_keys( &self, services: &RuntimeServices, ) -> Result, Report> { - read_active_kids(services) + read_active_kids(services).await } /// Deactivates a key by removing it from the active keys list. @@ -244,14 +275,15 @@ impl KeyRotationManager { /// # Errors /// /// Returns an error if this would deactivate the last active key, or if the update fails. - pub fn deactivate_key( + pub async fn deactivate_key( &self, services: &RuntimeServices, kid: &str, ) -> Result<(), Report> { - self.ensure_not_current_key(services, kid, "deactivate")?; + self.ensure_not_current_key(services, kid, "deactivate") + .await?; - let mut active_kids = self.list_active_keys(services)?; + let mut active_kids = self.list_active_keys(services).await?; active_kids.retain(|k| k != kid); if active_kids.is_empty() { @@ -268,13 +300,13 @@ impl KeyRotationManager { /// # Errors /// /// Returns an error if deactivation fails or if the key cannot be deleted from storage. - pub fn delete_key( + pub async fn delete_key( &self, services: &RuntimeServices, kid: &str, ) -> Result<(), Report> { - self.ensure_not_current_key(services, kid, "delete")?; - self.deactivate_key(services, kid)?; + self.ensure_not_current_key(services, kid, "delete").await?; + self.deactivate_key(services, kid).await?; // Delete the private key first. A failure here leaves the JWK in the // config store but no private key — the key is verifiable but cannot @@ -297,25 +329,35 @@ impl KeyRotationManager { Ok(()) } - fn ensure_not_current_key( + async fn ensure_not_current_key( &self, services: &RuntimeServices, kid: &str, operation: &str, ) -> Result<(), Report> { - if services + // Fail closed: only proceed when we can positively confirm `kid` is not + // the active signing key. A genuinely-absent `current-kid` (no key has + // been rotated in yet) means there is nothing to protect, so it is safe. + // But an unreadable config store must NOT be treated as "not current" — + // doing so would let a transient store outage delete the live key. + match services .config_store() .get(&JWKS_STORE_NAME, "current-kid") - .is_ok_and(|current| current == kid) + .await { - return Err(Report::new(TrustedServerError::Configuration { + Ok(current) if current == kid => Err(Report::new(TrustedServerError::Configuration { message: format!( "cannot {operation} '{kid}' because it is the current signing key; rotate first" ), - })); + })), + Ok(_) => Ok(()), + Err(report) if is_not_found(&report) => Ok(()), + Err(report) => Err(report.change_context(TrustedServerError::Configuration { + message: format!( + "cannot {operation} '{kid}': unable to read the current signing key to confirm it is not active" + ), + })), } - - Ok(()) } } @@ -396,15 +438,18 @@ mod tests { } } + #[async_trait::async_trait(?Send)] impl PlatformConfigStore for SpyConfigStore { - fn get(&self, _: &StoreName, key: &str) -> Result> { + async fn get(&self, _: &StoreName, key: &str) -> Result> { + // Mirror the composite store's contract: a present store with an + // absent key is `NotFound`, never a generic store error. self.inner .data .lock() .expect("should lock data") .get(key) .cloned() - .ok_or_else(|| Report::new(PlatformError::ConfigStore)) + .ok_or_else(|| Report::new(PlatformError::NotFound)) } fn put( @@ -500,8 +545,13 @@ mod tests { } } + #[async_trait::async_trait(?Send)] impl PlatformSecretStore for SpySecretStore { - fn get_bytes(&self, _: &StoreName, _: &str) -> Result, Report> { + async fn get_bytes( + &self, + _: &StoreName, + _: &str, + ) -> Result, Report> { Err(Report::new(PlatformError::SecretStore)) } @@ -574,7 +624,8 @@ mod tests { let services = build_services_with_config_and_secret(config_store, secret_store); let manager = KeyRotationManager::new("cfg-id", "sec-id"); - let result = manager.rotate_key(&services, Some("new-kid".to_owned())); + let result = + futures::executor::block_on(manager.rotate_key(&services, Some("new-kid".to_owned()))); assert!(result.is_ok(), "should succeed when stores accept writes"); let rotation = result.expect("should produce rotation result"); @@ -596,9 +647,9 @@ mod tests { let services = build_services_with_config_and_secret(config_store, secret_store); let manager = KeyRotationManager::new("cfg-id", "sec-id"); - let rotation = manager - .rotate_key(&services, Some("kid-c".to_owned())) - .expect("should rotate key successfully"); + let rotation = + futures::executor::block_on(manager.rotate_key(&services, Some("kid-c".to_owned()))) + .expect("should rotate key successfully"); assert_eq!( rotation.active_kids, @@ -606,8 +657,7 @@ mod tests { "should preserve previously active keys and append the new kid" ); - let active_kids = manager - .list_active_keys(&services) + let active_kids = futures::executor::block_on(manager.list_active_keys(&services)) .expect("should read back updated active kids"); assert_eq!( active_kids, @@ -627,9 +677,9 @@ mod tests { let services = build_services_with_config_and_secret(config_store, secret_store); let manager = KeyRotationManager::new("cfg-id", "sec-id"); - let rotation = manager - .rotate_key(&services, Some("kid-c".to_owned())) - .expect("should rotate key successfully"); + let rotation = + futures::executor::block_on(manager.rotate_key(&services, Some("kid-c".to_owned()))) + .expect("should rotate key successfully"); assert_eq!( rotation.active_kids, @@ -650,7 +700,8 @@ mod tests { build_services_with_config_and_secret(config_store.clone(), secret_store.clone()); let manager = KeyRotationManager::new("cfg-id", "sec-id"); - let result = manager.rotate_key(&services, Some("kid-a".to_owned())); + let result = + futures::executor::block_on(manager.rotate_key(&services, Some("kid-a".to_owned()))); assert!( result.is_err(), @@ -678,8 +729,7 @@ mod tests { let services = build_services_with_config_and_secret(config_store, secret_store); let manager = KeyRotationManager::new("cfg-id", "sec-id"); - let rotation = manager - .rotate_key(&services, None) + let rotation = futures::executor::block_on(manager.rotate_key(&services, None)) .expect("should rotate with a uniquified generated kid"); assert_ne!( @@ -709,7 +759,7 @@ mod tests { let services = build_services_with_config_and_secret(config_store, secret_store); let manager = KeyRotationManager::new("cfg-id", "sec-id"); - let result = manager.deactivate_key(&services, "only-key"); + let result = futures::executor::block_on(manager.deactivate_key(&services, "only-key")); assert!( result.is_err(), @@ -740,7 +790,8 @@ mod tests { let services = build_services_with_config_and_secret(config_store, secret_store); let manager = KeyRotationManager::new("cfg-id", "sec-id"); - let result = manager.rotate_key(&services, Some("new-kid".to_owned())); + let result = + futures::executor::block_on(manager.rotate_key(&services, Some("new-kid".to_owned()))); assert!( result.is_err(), @@ -756,7 +807,9 @@ mod tests { build_services_with_config_and_secret(config_store.clone(), secret_store.clone()); let manager = KeyRotationManager::new("cfg-id", "sec-id"); - let result = manager.rotate_key(&services, Some("rollback-kid".to_owned())); + let result = futures::executor::block_on( + manager.rotate_key(&services, Some("rollback-kid".to_owned())), + ); assert!(result.is_err(), "should fail when JWK write fails"); assert_eq!( @@ -778,7 +831,9 @@ mod tests { build_services_with_config_and_secret(config_store.clone(), secret_store.clone()); let manager = KeyRotationManager::new("cfg-id", "sec-id"); - let result = manager.rotate_key(&services, Some("rollback-kid".to_owned())); + let result = futures::executor::block_on( + manager.rotate_key(&services, Some("rollback-kid".to_owned())), + ); assert!(result.is_err(), "should fail when active-kids write fails"); assert_eq!( @@ -805,7 +860,7 @@ mod tests { build_services_with_config_and_secret(config_store.clone(), secret_store.clone()); let manager = KeyRotationManager::new("cfg-id", "sec-id"); - let result = manager.deactivate_key(&services, "kid-a"); + let result = futures::executor::block_on(manager.deactivate_key(&services, "kid-a")); assert!(result.is_err(), "should reject deactivating current-kid"); assert!( @@ -830,7 +885,7 @@ mod tests { build_services_with_config_and_secret(config_store.clone(), secret_store.clone()); let manager = KeyRotationManager::new("cfg-id", "sec-id"); - let result = manager.delete_key(&services, "kid-a"); + let result = futures::executor::block_on(manager.delete_key(&services, "kid-a")); assert!(result.is_err(), "should reject deleting current-kid"); assert!( @@ -857,14 +912,15 @@ mod tests { let services = build_services_with_config_and_secret(config_store, secret_store); let manager = KeyRotationManager::new("cfg-id", "sec-id"); - manager - .delete_key(&services, "kid-a") + futures::executor::block_on(manager.delete_key(&services, "kid-a")) .expect("should delete key successfully"); // After deletion, the JWK entry should be gone from the config store. - let jwk_gone = services - .config_store() - .get(&crate::request_signing::JWKS_STORE_NAME, "kid-a"); + let jwk_gone = futures::executor::block_on( + services + .config_store() + .get(&crate::request_signing::JWKS_STORE_NAME, "kid-a"), + ); assert!( jwk_gone.is_err(), "should remove JWK from the config store after deletion" diff --git a/crates/trusted-server-core/src/request_signing/signing.rs b/crates/trusted-server-core/src/request_signing/signing.rs index b76722b2b..64bb6204b 100644 --- a/crates/trusted-server-core/src/request_signing/signing.rs +++ b/crates/trusted-server-core/src/request_signing/signing.rs @@ -17,12 +17,13 @@ use crate::request_signing::{JWKS_STORE_NAME, SIGNING_STORE_NAME}; /// # Errors /// /// Returns an error if the config store cannot be accessed or the current-kid key is not found. -pub fn get_current_key_id( +pub async fn get_current_key_id( services: &RuntimeServices, ) -> Result> { services .config_store() .get(&JWKS_STORE_NAME, "current-kid") + .await .change_context(TrustedServerError::Configuration { message: "failed to read current-kid from config store".into(), }) @@ -135,15 +136,19 @@ impl RequestSigner { /// # Errors /// /// Returns an error if the key ID cannot be retrieved or the key cannot be parsed. - pub fn from_services(services: &RuntimeServices) -> Result> { - let key_id = - get_current_key_id(services).change_context(TrustedServerError::Configuration { + pub async fn from_services( + services: &RuntimeServices, + ) -> Result> { + let key_id = get_current_key_id(services).await.change_context( + TrustedServerError::Configuration { message: "failed to get current-kid".into(), - })?; + }, + )?; let key_bytes = services .secret_store() .get_bytes(&SIGNING_STORE_NAME, &key_id) + .await .change_context(TrustedServerError::Configuration { message: format!("failed to get signing key for kid: {key_id}"), })?; @@ -189,7 +194,7 @@ impl RequestSigner { /// # Errors /// /// Returns an error if the JWK cannot be retrieved, parsed, or if signature verification fails. -pub fn verify_signature( +pub async fn verify_signature( payload: &[u8], signature_b64: &str, kid: &str, @@ -198,6 +203,7 @@ pub fn verify_signature( let jwk_json = services .config_store() .get(&JWKS_STORE_NAME, kid) + .await .change_context(TrustedServerError::Configuration { message: format!("failed to get JWK for kid: {kid}"), })?; @@ -263,8 +269,8 @@ mod tests { #[test] fn from_services_loads_kid_from_config_store() { let services = build_request_signing_services(); - let signer = - RequestSigner::from_services(&services).expect("should create signer from services"); + let signer = futures::executor::block_on(RequestSigner::from_services(&services)) + .expect("should create signer from services"); assert_eq!(signer.kid, "test-kid", "should load kid from config store"); } @@ -272,8 +278,8 @@ mod tests { #[test] fn sign_produces_non_empty_url_safe_base64_signature() { let services = build_request_signing_services(); - let signer = - RequestSigner::from_services(&services).expect("should create signer from services"); + let signer = futures::executor::block_on(RequestSigner::from_services(&services)) + .expect("should create signer from services"); let signature = signer .sign(b"these pretzels are making me thirsty") @@ -289,13 +295,18 @@ mod tests { #[test] fn sign_and_verify_roundtrip_succeeds() { let services = build_request_signing_services(); - let signer = - RequestSigner::from_services(&services).expect("should create signer from services"); + let signer = futures::executor::block_on(RequestSigner::from_services(&services)) + .expect("should create signer from services"); let payload = b"test payload for verification"; let signature = signer.sign(payload).expect("should sign payload"); - let verified = verify_signature(payload, &signature, &signer.kid, &services) - .expect("should attempt verification"); + let verified = futures::executor::block_on(verify_signature( + payload, + &signature, + &signer.kid, + &services, + )) + .expect("should attempt verification"); assert!(verified, "should verify a valid signature"); } @@ -303,12 +314,17 @@ mod tests { #[test] fn verify_returns_false_for_wrong_payload() { let services = build_request_signing_services(); - let signer = - RequestSigner::from_services(&services).expect("should create signer from services"); + let signer = futures::executor::block_on(RequestSigner::from_services(&services)) + .expect("should create signer from services"); let signature = signer.sign(b"original").expect("should sign"); - let verified = verify_signature(b"wrong payload", &signature, &signer.kid, &services) - .expect("should attempt verification"); + let verified = futures::executor::block_on(verify_signature( + b"wrong payload", + &signature, + &signer.kid, + &services, + )) + .expect("should attempt verification"); assert!(!verified, "should not verify signature for wrong payload"); } @@ -316,11 +332,16 @@ mod tests { #[test] fn verify_errors_for_unknown_kid() { let services = build_request_signing_services(); - let signer = - RequestSigner::from_services(&services).expect("should create signer from services"); + let signer = futures::executor::block_on(RequestSigner::from_services(&services)) + .expect("should create signer from services"); let signature = signer.sign(b"payload").expect("should sign"); - let result = verify_signature(b"payload", &signature, "nonexistent-kid", &services); + let result = futures::executor::block_on(verify_signature( + b"payload", + &signature, + "nonexistent-kid", + &services, + )); assert!(result.is_err(), "should error for unknown kid"); } @@ -328,10 +349,15 @@ mod tests { #[test] fn verify_errors_for_malformed_signature() { let services = build_request_signing_services(); - let signer = - RequestSigner::from_services(&services).expect("should create signer from services"); + let signer = futures::executor::block_on(RequestSigner::from_services(&services)) + .expect("should create signer from services"); - let result = verify_signature(b"payload", "not-valid-base64!!!", &signer.kid, &services); + let result = futures::executor::block_on(verify_signature( + b"payload", + "not-valid-base64!!!", + &signer.kid, + &services, + )); assert!(result.is_err(), "should error for malformed signature"); } @@ -385,8 +411,8 @@ mod tests { #[test] fn sign_request_enhanced_produces_verifiable_signature() { let services = build_request_signing_services(); - let signer = - RequestSigner::from_services(&services).expect("should create signer from services"); + let signer = futures::executor::block_on(RequestSigner::from_services(&services)) + .expect("should create signer from services"); let params = SigningParams::new( "auction-123".to_owned(), "publisher.com".to_owned(), @@ -398,8 +424,13 @@ mod tests { .build_payload(&signer.kid) .expect("should build payload"); - let verified = verify_signature(payload.as_bytes(), &signature, &signer.kid, &services) - .expect("should verify"); + let verified = futures::executor::block_on(verify_signature( + payload.as_bytes(), + &signature, + &signer.kid, + &services, + )) + .expect("should verify"); assert!(verified, "enhanced request signature should be verifiable"); } @@ -407,8 +438,8 @@ mod tests { #[test] fn sign_request_different_hosts_produce_different_signatures() { let services = build_request_signing_services(); - let signer = - RequestSigner::from_services(&services).expect("should create signer from services"); + let signer = futures::executor::block_on(RequestSigner::from_services(&services)) + .expect("should create signer from services"); let params1 = SigningParams { request_id: "req-1".to_owned(), diff --git a/crates/trusted-server-core/src/secret_resolution.rs b/crates/trusted-server-core/src/secret_resolution.rs index f362a0b7b..c74eb1155 100644 --- a/crates/trusted-server-core/src/secret_resolution.rs +++ b/crates/trusted-server-core/src/secret_resolution.rs @@ -6,6 +6,7 @@ use edgezero_core::app_config::{AppConfigMeta, SecretField, SecretKind, SecretPathSegment}; use error_stack::Report; +use futures::future::LocalBoxFuture; use serde_json::Value; use crate::error::TrustedServerError; @@ -21,7 +22,7 @@ use crate::platform::{PlatformSecretStore, StoreName}; /// Returns [`TrustedServerError::Configuration`] when a required path or key /// is malformed, a secret is unavailable, is not valid UTF-8, or resolves to an /// empty value. -pub fn resolve_secret_references( +pub async fn resolve_secret_references( data: &mut Value, secret_store: &dyn PlatformSecretStore, default_store_name: &StoreName, @@ -38,106 +39,118 @@ pub fn resolve_secret_references( "", secret_store, default_store_name, - )?; + ) + .await?; } *data = resolved_data; Ok(()) } -fn resolve_field( - node: &mut Value, - field: &SecretField, - remaining: &[SecretPathSegment], - rendered_path: &str, - secret_store: &dyn PlatformSecretStore, - default_store_name: &StoreName, -) -> Result<(), Report> { - match remaining.split_first() { - Some((SecretPathSegment::Field(name), [])) => resolve_leaf( - node, - field, - name.as_ref(), - rendered_path, - secret_store, - default_store_name, - ), - Some((SecretPathSegment::OptionalField(name), [])) => { - if matches!(node.get(name.as_ref()), None | Some(Value::Null)) { - return Ok(()); +// Boxed rather than an `async fn` because the walk recurses through nested +// path segments, and a recursive `async fn` has an infinitely sized future. +fn resolve_field<'a>( + node: &'a mut Value, + field: &'a SecretField, + remaining: &'a [SecretPathSegment], + rendered_path: &'a str, + secret_store: &'a dyn PlatformSecretStore, + default_store_name: &'a StoreName, +) -> LocalBoxFuture<'a, Result<(), Report>> { + Box::pin(async move { + match remaining.split_first() { + Some((SecretPathSegment::Field(name), [])) => { + resolve_leaf( + node, + field, + name.as_ref(), + rendered_path, + secret_store, + default_store_name, + ) + .await } - resolve_leaf( - node, - field, - name.as_ref(), - rendered_path, - secret_store, - default_store_name, - ) - } - Some((SecretPathSegment::Field(name), rest)) => { - let next_path = join_field(rendered_path, name.as_ref()); - let child = node - .as_object_mut() - .and_then(|object| object.get_mut(name.as_ref())) - .ok_or_else(|| missing_path(&next_path))?; - if child.is_null() { - return Err(missing_path(&next_path)); + Some((SecretPathSegment::OptionalField(name), [])) => { + if matches!(node.get(name.as_ref()), None | Some(Value::Null)) { + return Ok(()); + } + resolve_leaf( + node, + field, + name.as_ref(), + rendered_path, + secret_store, + default_store_name, + ) + .await } - resolve_field( - child, - field, - rest, - &next_path, - secret_store, - default_store_name, - ) - } - Some((SecretPathSegment::OptionalField(name), rest)) => { - let next_path = join_field(rendered_path, name.as_ref()); - let Some(child) = node - .as_object_mut() - .and_then(|object| object.get_mut(name.as_ref())) - else { - return Ok(()); - }; - if child.is_null() { - return Ok(()); + Some((SecretPathSegment::Field(name), rest)) => { + let next_path = join_field(rendered_path, name.as_ref()); + let child = node + .as_object_mut() + .and_then(|object| object.get_mut(name.as_ref())) + .ok_or_else(|| missing_path(&next_path))?; + if child.is_null() { + return Err(missing_path(&next_path)); + } + resolve_field( + child, + field, + rest, + &next_path, + secret_store, + default_store_name, + ) + .await } - resolve_field( - child, - field, - rest, - &next_path, - secret_store, - default_store_name, - ) - } - Some((SecretPathSegment::ArrayEach, rest)) => { - let items = node.as_array_mut().ok_or_else(|| { - configuration_error(format!("expected an array at `{rendered_path}`")) - })?; - for (index, item) in items.iter_mut().enumerate() { - let indexed_path = format!("{rendered_path}[{index}]"); + Some((SecretPathSegment::OptionalField(name), rest)) => { + let next_path = join_field(rendered_path, name.as_ref()); + let Some(child) = node + .as_object_mut() + .and_then(|object| object.get_mut(name.as_ref())) + else { + return Ok(()); + }; + if child.is_null() { + return Ok(()); + } resolve_field( - item, + child, field, rest, - &indexed_path, + &next_path, secret_store, default_store_name, - )?; + ) + .await } - Ok(()) + Some((SecretPathSegment::ArrayEach, rest)) => { + let items = node.as_array_mut().ok_or_else(|| { + configuration_error(format!("expected an array at `{rendered_path}`")) + })?; + for (index, item) in items.iter_mut().enumerate() { + let indexed_path = format!("{rendered_path}[{index}]"); + resolve_field( + item, + field, + rest, + &indexed_path, + secret_store, + default_store_name, + ) + .await?; + } + Ok(()) + } + Some(_) => Err(configuration_error(format!( + "unsupported secret path segment in `{}`", + field.dotted_path() + ))), + None => Ok(()), } - Some(_) => Err(configuration_error(format!( - "unsupported secret path segment in `{}`", - field.dotted_path() - ))), - None => Ok(()), - } + }) } -fn resolve_leaf( +async fn resolve_leaf( parent: &mut Value, field: &SecretField, key: &str, @@ -168,6 +181,7 @@ fn resolve_leaf( let resolved = secret_store .get_string(default_store_name, &key_name) + .await .map_err(|_| { configuration_error(format!( "failed to resolve secret reference at `{leaf_path}` from secret store \ @@ -204,14 +218,17 @@ fn configuration_error(message: String) -> Report { mod tests { use super::*; use crate::platform::{PlatformError, StoreId}; + use async_trait::async_trait; + use futures::executor::block_on; use std::collections::BTreeMap; struct MemorySecretStore { values: BTreeMap>, } + #[async_trait(?Send)] impl PlatformSecretStore for MemorySecretStore { - fn get_bytes( + async fn get_bytes( &self, _store_name: &StoreName, key: &str, @@ -290,8 +307,12 @@ mod tests { ] }); - resolve_secret_references::(&mut data, &store(), &StoreName::from("secrets")) - .expect("should resolve nested array secrets"); + block_on(resolve_secret_references::( + &mut data, + &store(), + &StoreName::from("secrets"), + )) + .expect("should resolve nested array secrets"); assert_eq!(data["outer"][0]["token"], "resolved-a"); assert_eq!(data["outer"][1]["token"], "resolved-b"); @@ -303,15 +324,23 @@ mod tests { let mut absent = serde_json::json!({ "outer": [{"token": "token-a"}] }); - resolve_secret_references::(&mut absent, &store(), &StoreName::from("secrets")) - .expect("should skip absent optional intermediate"); + block_on(resolve_secret_references::( + &mut absent, + &store(), + &StoreName::from("secrets"), + )) + .expect("should skip absent optional intermediate"); let mut present = serde_json::json!({ "outer": [{"token": "token-a"}], "feature": {"credential": "feature-key"} }); - resolve_secret_references::(&mut present, &store(), &StoreName::from("secrets")) - .expect("should resolve present optional intermediate"); + block_on(resolve_secret_references::( + &mut present, + &store(), + &StoreName::from("secrets"), + )) + .expect("should resolve present optional intermediate"); assert_eq!(present["feature"]["credential"], "resolved-feature"); } @@ -322,11 +351,11 @@ mod tests { serde_json::json!({"outer": [{}]}), serde_json::json!({"outer": [{"token": null}]}), ] { - let err = resolve_secret_references::( + let err = block_on(resolve_secret_references::( &mut data, &store(), &StoreName::from("secrets"), - ) + )) .expect_err("should reject missing required secret path"); assert!(err.to_string().contains("missing required secret path")); @@ -338,9 +367,12 @@ mod tests { #[test] fn rejects_non_string_required_leaf() { let mut data = serde_json::json!({"outer": [{"token": true}]}); - let err = - resolve_secret_references::(&mut data, &store(), &StoreName::from("secrets")) - .expect_err("should reject non-string secret reference"); + let err = block_on(resolve_secret_references::( + &mut data, + &store(), + &StoreName::from("secrets"), + )) + .expect_err("should reject non-string secret reference"); assert!(err.to_string().contains("must be a string")); assert!(err.to_string().contains("outer[0].token")); @@ -357,9 +389,12 @@ mod tests { )]), }; - let err = - resolve_secret_references::(&mut data, &store, &StoreName::from("secrets")) - .expect_err("should reject a missing secret key"); + let err = block_on(resolve_secret_references::( + &mut data, + &store, + &StoreName::from("secrets"), + )) + .expect_err("should reject a missing secret key"); let diagnostic = format!("{err:?}"); assert!(diagnostic.contains("outer[0].token")); @@ -372,9 +407,12 @@ mod tests { #[test] fn rejects_malformed_array_path_without_resolving_values() { let mut data = serde_json::json!({"outer": {"token": "token-a"}}); - let err = - resolve_secret_references::(&mut data, &store(), &StoreName::from("secrets")) - .expect_err("should reject a non-array intermediate path"); + let err = block_on(resolve_secret_references::( + &mut data, + &store(), + &StoreName::from("secrets"), + )) + .expect_err("should reject a non-array intermediate path"); assert!(err.to_string().contains("expected an array")); assert!(!err.to_string().contains("resolved-a")); @@ -385,18 +423,24 @@ mod tests { let mut invalid = store(); invalid.values.insert("token-a".to_owned(), vec![0xff]); let mut data = serde_json::json!({"outer": [{"token": "token-a"}]}); - let err = - resolve_secret_references::(&mut data, &invalid, &StoreName::from("secrets")) - .expect_err("should reject invalid UTF-8"); + let err = block_on(resolve_secret_references::( + &mut data, + &invalid, + &StoreName::from("secrets"), + )) + .expect_err("should reject invalid UTF-8"); assert!(err.to_string().contains("outer[0].token")); let empty = MemorySecretStore { values: BTreeMap::from([("token-a".to_owned(), Vec::new())]), }; let mut data = serde_json::json!({"outer": [{"token": "token-a"}]}); - let err = - resolve_secret_references::(&mut data, &empty, &StoreName::from("secrets")) - .expect_err("should reject empty resolved value"); + let err = block_on(resolve_secret_references::( + &mut data, + &empty, + &StoreName::from("secrets"), + )) + .expect_err("should reject empty resolved value"); assert!(err.to_string().contains("outer[0].token")); } @@ -404,8 +448,11 @@ mod tests { fn does_not_mutate_data_when_resolution_fails() { let mut data = serde_json::json!({"outer": [{"token": "missing"}]}); let original = data.clone(); - let result = - resolve_secret_references::(&mut data, &store(), &StoreName::from("secrets")); + let result = block_on(resolve_secret_references::( + &mut data, + &store(), + &StoreName::from("secrets"), + )); assert!(result.is_err(), "should fail for missing secret key"); assert_eq!(data, original, "should preserve unresolved data on failure"); } diff --git a/crates/trusted-server-core/src/settings_data.rs b/crates/trusted-server-core/src/settings_data.rs index 103ac819c..5401a8b90 100644 --- a/crates/trusted-server-core/src/settings_data.rs +++ b/crates/trusted-server-core/src/settings_data.rs @@ -1,34 +1,14 @@ +use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::env_config::EnvConfig; -use error_stack::{Report, ResultExt}; -use serde::Deserialize; -use sha2::{Digest as _, Sha256}; +use error_stack::Report; -use crate::config_payload::DEFAULT_SECRET_STORE_ID; -use crate::config_payload::settings_from_config_blob; +use crate::config_payload::{DEFAULT_SECRET_STORE_ID, settings_from_config_blob}; use crate::error::TrustedServerError; -use crate::platform::{PlatformConfigStore, PlatformSecretStore, StoreName}; +use crate::platform::{PlatformSecretStore, StoreName}; use crate::settings::Settings; /// Canonical logical config store used by Trusted Server app config. pub const DEFAULT_CONFIG_STORE_ID: &str = "trusted_server_config"; -const FASTLY_CHUNK_POINTER_KIND: &str = "fastly_config_chunks"; -const FASTLY_CONFIG_ENTRY_LIMIT: usize = 8_000; - -#[derive(Debug, Deserialize)] -struct FastlyChunkPointer { - chunks: Vec, - edgezero_kind: String, - envelope_len: usize, - envelope_sha256: String, - version: u8, -} - -#[derive(Debug, Deserialize)] -struct FastlyChunkRef { - key: String, - len: usize, - sha256: String, -} /// Resolves the `EdgeZero` app-config store name from runtime configuration. #[must_use] @@ -60,135 +40,46 @@ pub fn default_secret_store_name() -> StoreName { StoreName::from(EnvConfig::from_env().store_name("secrets", DEFAULT_SECRET_STORE_ID)) } -/// Loads [`Settings`] from a platform config store and key. +/// Loads [`Settings`] from an `EdgeZero` [`ConfigStoreHandle`] and key. +/// +/// The handle is already bound to a specific config store, so only the blob +/// `key` is supplied. Reads resolve through the handle's async +/// [`ConfigStoreHandle::get`]. The handle returns a fully resolved envelope: +/// platform-specific storage details such as Fastly's config-entry chunking are +/// reassembled by `EdgeZero`'s config store, not here. Secret references in the +/// verified blob are resolved from `secret_store` before deserialization. +/// +/// This is an async startup read: adapters drive it to completion at process +/// boot (outside any request executor). /// /// # Errors /// /// Returns [`TrustedServerError::Configuration`] when the config blob is /// missing, cannot be read, fails envelope verification, secret resolution, /// or Trusted Server settings validation. -pub fn get_settings_from_config_store( - config_store: &dyn PlatformConfigStore, - secret_store: &dyn PlatformSecretStore, - store_name: &StoreName, +pub async fn get_settings_from_config_store( + config_store: &ConfigStoreHandle, key: &str, + secret_store: &dyn PlatformSecretStore, default_secret_store_name: &StoreName, ) -> Result> { - let raw_value = read_config_entry(config_store, store_name, key)?; - let envelope_json = resolve_fastly_chunk_pointer(config_store, store_name, &raw_value)?; - settings_from_config_blob(&envelope_json, secret_store, default_secret_store_name) + let envelope_json = read_config_entry(config_store, key).await?; + settings_from_config_blob(&envelope_json, secret_store, default_secret_store_name).await } -fn read_config_entry( - config_store: &dyn PlatformConfigStore, - store_name: &StoreName, +async fn read_config_entry( + config_store: &ConfigStoreHandle, key: &str, ) -> Result> { - let message = format!( - "failed to read Trusted Server app config key `{key}` from config store `{store_name}`" - ); - config_store - .get(store_name, key) - .change_context(TrustedServerError::Configuration { message }) -} - -fn resolve_fastly_chunk_pointer( - config_store: &dyn PlatformConfigStore, - store_name: &StoreName, - value: &str, -) -> Result> { - let Ok(pointer) = serde_json::from_str::(value) else { - return Ok(value.to_string()); - }; - if pointer.edgezero_kind != FASTLY_CHUNK_POINTER_KIND { - return Ok(value.to_string()); - } - if pointer.version != 1 { - return configuration_error(format!( - "unsupported Fastly config chunk pointer version {}; expected 1", - pointer.version - )); - } - if value.len() > FASTLY_CONFIG_ENTRY_LIMIT { - return configuration_error(format!( - "Fastly config chunk pointer is {} bytes, exceeding the {} byte entry limit", - value.len(), - FASTLY_CONFIG_ENTRY_LIMIT - )); - } - - let mut declared_envelope_len = 0usize; - for chunk in &pointer.chunks { - if chunk.len > FASTLY_CONFIG_ENTRY_LIMIT { - return configuration_error(format!( - "Fastly config chunk `{}` declares {} bytes, exceeding the {} byte entry limit", - chunk.key, chunk.len, FASTLY_CONFIG_ENTRY_LIMIT - )); - } - declared_envelope_len = match declared_envelope_len.checked_add(chunk.len) { - Some(total) => total, - None => { - return configuration_error( - "Fastly config chunk lengths overflowed usize".to_string(), - ); - } - }; - } - if declared_envelope_len != pointer.envelope_len { - return configuration_error(format!( - "Fastly config chunk lengths total mismatch: expected envelope length {}, got {}", - pointer.envelope_len, declared_envelope_len - )); - } - - let mut envelope_json = String::with_capacity(pointer.envelope_len); - let mut actual_envelope_len = 0usize; - for chunk in pointer.chunks { - let chunk_value = read_config_entry(config_store, store_name, &chunk.key)?; - let chunk_len = chunk_value.len(); - if chunk_len != chunk.len { - return configuration_error(format!( - "Fastly config chunk `{}` length mismatch: expected {}, got {}", - chunk.key, chunk.len, chunk_len - )); - } - actual_envelope_len = actual_envelope_len.saturating_add(chunk_len); - if actual_envelope_len > pointer.envelope_len { - return configuration_error(format!( - "Fastly config envelope exceeded declared length {} while reading chunk `{}`", - pointer.envelope_len, chunk.key - )); - } - let chunk_sha = sha256_hex(chunk_value.as_bytes()); - if chunk_sha != chunk.sha256 { - return configuration_error(format!( - "Fastly config chunk `{}` sha mismatch: expected {}, got {}", - chunk.key, chunk.sha256, chunk_sha - )); - } - envelope_json.push_str(&chunk_value); - } - - if envelope_json.len() != pointer.envelope_len { - return configuration_error(format!( - "Fastly config envelope length mismatch: expected {}, got {}", - pointer.envelope_len, - envelope_json.len() - )); - } - let envelope_sha = sha256_hex(envelope_json.as_bytes()); - if envelope_sha != pointer.envelope_sha256 { - return configuration_error(format!( - "Fastly config envelope sha mismatch: expected {}, got {}", - pointer.envelope_sha256, envelope_sha - )); + match config_store.get(key).await { + Ok(Some(value)) => Ok(value), + Ok(None) => configuration_error(format!( + "Trusted Server app config key `{key}` was not found in the config store" + )), + Err(error) => configuration_error(format!( + "failed to read Trusted Server app config key `{key}` from the config store: {error}" + )), } - - Ok(envelope_json) -} - -fn sha256_hex(bytes: &[u8]) -> String { - format!("{:x}", Sha256::digest(bytes)) } fn configuration_error(message: String) -> Result> { @@ -202,39 +93,43 @@ mod tests { use crate::platform::{PlatformError, StoreId}; use crate::settings::Settings; use crate::test_support::tests::crate_test_settings_str; + use async_trait::async_trait; use edgezero_core::blob_envelope::BlobEnvelope; - use serde_json::json; + use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; use std::collections::BTreeMap; + use std::sync::Arc; - struct MemoryConfigStore { + struct InMemoryConfigStore { entries: BTreeMap, } - impl PlatformConfigStore for MemoryConfigStore { - fn get(&self, _store_name: &StoreName, key: &str) -> Result> { - self.entries.get(key).cloned().ok_or_else(|| { - Report::new(PlatformError::ConfigStore).attach(format!("missing key `{key}`")) - }) + impl InMemoryConfigStore { + fn with(entries: &[(&str, &str)]) -> Self { + Self { + entries: entries + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect(), + } } + } - fn put( - &self, - _store_id: &StoreId, - _key: &str, - _value: &str, - ) -> Result<(), Report> { - Ok(()) + #[async_trait(?Send)] + impl ConfigStore for InMemoryConfigStore { + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + Ok(self.entries.get(key).cloned()) } + } - fn delete(&self, _store_id: &StoreId, _key: &str) -> Result<(), Report> { - Ok(()) - } + fn handle_with(entries: &[(&str, &str)]) -> ConfigStoreHandle { + ConfigStoreHandle::new(Arc::new(InMemoryConfigStore::with(entries))) } struct EchoSecretStore; + #[async_trait(?Send)] impl PlatformSecretStore for EchoSecretStore { - fn get_bytes( + async fn get_bytes( &self, _store_name: &StoreName, key: &str, @@ -261,23 +156,21 @@ mod tests { } fn envelope_json(settings: &Settings) -> String { - let data = serde_json::to_value(settings).expect("should serialize settings to JSON"); - let envelope = BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_string()); + let payload = serde_json::to_value(settings).expect("should serialize settings"); + let envelope = BlobEnvelope::new(payload, "2026-01-01T00:00:00Z".to_string()); serde_json::to_string(&envelope).expect("should serialize envelope") } fn load_settings( - config_store: &dyn PlatformConfigStore, - store_name: &StoreName, + handle: &ConfigStoreHandle, key: &str, ) -> Result> { - get_settings_from_config_store( - config_store, - &EchoSecretStore, - store_name, + futures::executor::block_on(get_settings_from_config_store( + handle, key, + &EchoSecretStore, &StoreName::from("trusted_server_secrets"), - ) + )) } #[test] @@ -327,104 +220,35 @@ mod tests { Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings"); settings.proxy.allowed_domains = vec!["*.example".to_owned(), "*.example.com".to_owned()]; let envelope_json = envelope_json(&settings); - let store = MemoryConfigStore { - entries: BTreeMap::from([(CONFIG_BLOB_KEY.to_string(), envelope_json)]), - }; + let handle = handle_with(&[(CONFIG_BLOB_KEY, &envelope_json)]); - let loaded = load_settings(&store, &StoreName::from("app_config"), CONFIG_BLOB_KEY) - .expect("should load settings"); + let loaded = load_settings(&handle, CONFIG_BLOB_KEY).expect("should load settings"); assert_eq!( loaded.publisher.domain, settings.publisher.domain, - "should load publisher domain" + "should deserialize the config blob read through the EdgeZero handle" ); } #[test] - fn loads_settings_from_fastly_chunk_pointer() { - let mut settings = - Settings::from_toml(&crate_test_settings_str()).expect("should parse test settings"); - settings.proxy.allowed_domains = vec!["*.example".to_owned(), "*.example.com".to_owned()]; - let envelope_json = envelope_json(&settings); - let midpoint = envelope_json.len() / 2; - let first_chunk = envelope_json[..midpoint].to_string(); - let second_chunk = envelope_json[midpoint..].to_string(); - let first_key = format!("{CONFIG_BLOB_KEY}.__edgezero_chunks.test.0"); - let second_key = format!("{CONFIG_BLOB_KEY}.__edgezero_chunks.test.1"); - let pointer = json!({ - "edgezero_kind": FASTLY_CHUNK_POINTER_KIND, - "version": 1, - "envelope_sha256": sha256_hex(envelope_json.as_bytes()), - "envelope_len": envelope_json.len(), - "chunks": [ - { - "key": first_key, - "sha256": sha256_hex(first_chunk.as_bytes()), - "len": first_chunk.len() - }, - { - "key": second_key, - "sha256": sha256_hex(second_chunk.as_bytes()), - "len": second_chunk.len() - } - ] - }) - .to_string(); - let store = MemoryConfigStore { - entries: BTreeMap::from([ - (CONFIG_BLOB_KEY.to_string(), pointer), - (first_key, first_chunk), - (second_key, second_chunk), - ]), - }; - - let loaded = load_settings(&store, &StoreName::from("app_config"), CONFIG_BLOB_KEY) - .expect("should load settings"); + fn fails_when_blob_value_is_not_an_envelope() { + let handle = handle_with(&[(CONFIG_BLOB_KEY, "not-an-envelope")]); - assert_eq!( - loaded.publisher.domain, settings.publisher.domain, - "should reconstruct chunked envelope" - ); - } - - #[test] - fn rejects_chunk_pointer_when_declared_lengths_do_not_match_envelope_len() { - let chunk_key = format!("{CONFIG_BLOB_KEY}.__edgezero_chunks.test.0"); - let pointer = json!({ - "edgezero_kind": FASTLY_CHUNK_POINTER_KIND, - "version": 1, - "envelope_sha256": sha256_hex(b"ab"), - "envelope_len": 1, - "chunks": [ - { - "key": chunk_key, - "sha256": sha256_hex(b"ab"), - "len": 2 - } - ] - }) - .to_string(); - let store = MemoryConfigStore { - entries: BTreeMap::from([(CONFIG_BLOB_KEY.to_string(), pointer)]), - }; - - let err = load_settings(&store, &StoreName::from("app_config"), CONFIG_BLOB_KEY) - .expect_err("should reject malformed chunk length metadata"); + let err = load_settings(&handle, CONFIG_BLOB_KEY) + .expect_err("should reject a value that is not a blob envelope"); assert!( - err.to_string().contains("chunk lengths total mismatch"), - "error should explain chunk length mismatch: {err:?}" + !err.to_string().is_empty(), + "should report a configuration error: {err:?}" ); } #[test] fn fails_when_blob_key_is_missing() { - let store = MemoryConfigStore { - entries: BTreeMap::new(), - }; + let handle = handle_with(&[]); - let err = load_settings(&store, &StoreName::from("app_config"), CONFIG_BLOB_KEY) - .expect_err("should fail when blob is missing"); + let err = + load_settings(&handle, CONFIG_BLOB_KEY).expect_err("should fail when blob is missing"); assert!( err.to_string().contains(CONFIG_BLOB_KEY), diff --git a/crates/trusted-server-core/src/storage/kv_store.rs b/crates/trusted-server-core/src/storage/kv_store.rs index cf8665b2a..5d8f9a51e 100644 --- a/crates/trusted-server-core/src/storage/kv_store.rs +++ b/crates/trusted-server-core/src/storage/kv_store.rs @@ -23,7 +23,7 @@ use sha2::{Digest, Sha256}; use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::{ConsentContext, ConsentSource}; -use crate::platform::PlatformKvStore; +use crate::platform::KvHandle; // --------------------------------------------------------------------------- // KV body (JSON, stored as value) @@ -210,8 +210,8 @@ fn parse_jurisdiction(s: &str) -> Jurisdiction { /// Entries written before PR5 have an empty `fp` (via `#[serde(default)]`), /// which never matches a computed fingerprint and triggers a self-healing /// re-write. -fn fingerprint_unchanged(store: &dyn PlatformKvStore, key: &str, new_fp: &str) -> bool { - let bytes = match futures::executor::block_on(store.get_bytes(key)) { +async fn fingerprint_unchanged(store: &KvHandle, key: &str, new_fp: &str) -> bool { + let bytes = match store.get_bytes(key).await { Ok(Some(bytes)) => bytes, _ => return false, }; @@ -232,8 +232,8 @@ fn fingerprint_unchanged(store: &dyn PlatformKvStore, key: &str, new_fp: &str) - /// * `store` — KV store opened by the adapter. /// * `ec_id` — Edge Cookie ID used as the KV key. #[must_use] -pub fn load_consent_from_kv(store: &dyn PlatformKvStore, ec_id: &str) -> Option { - let bytes = match futures::executor::block_on(store.get_bytes(ec_id)) { +pub async fn load_consent_from_kv(store: &KvHandle, ec_id: &str) -> Option { + let bytes = match store.get_bytes(ec_id).await { Ok(Some(bytes)) => bytes, Ok(None) => { log::debug!("Consent KV lookup miss"); @@ -272,8 +272,10 @@ pub fn load_consent_from_kv(store: &dyn PlatformKvStore, ec_id: &str) -> Option< /// * `ec_id` — Edge Cookie ID used as the KV key. /// * `ctx` — Current request's consent context. /// * `max_age_days` — TTL for the entry, matching `max_consent_age_days`. -pub fn save_consent_to_kv( - store: &dyn PlatformKvStore, +/// +/// The TTL is clamped to [`KvHandle::MAX_TTL`] — see [`consent_kv_ttl`]. +pub async fn save_consent_to_kv( + store: &KvHandle, ec_id: &str, ctx: &ConsentContext, max_age_days: u32, @@ -285,7 +287,7 @@ pub fn save_consent_to_kv( let fp = consent_fingerprint(ctx); - if fingerprint_unchanged(store, ec_id, &fp) { + if fingerprint_unchanged(store, ec_id, &fp).await { log::debug!("Consent unchanged; skipping write"); return; } @@ -301,11 +303,11 @@ pub fn save_consent_to_kv( } }; - let ttl = std::time::Duration::from_secs(u64::from(max_age_days) * 86_400); + let ttl = consent_kv_ttl(max_age_days); - match futures::executor::block_on(store.put_bytes_with_ttl(ec_id, body, ttl)) { + match store.put_bytes_with_ttl(ec_id, body, ttl).await { Ok(()) => { - log::info!("Saved consent to KV store (ttl={max_age_days}d)"); + log::info!("Saved consent to KV store (ttl={ttl:?})"); } Err(e) => { log::warn!("Failed to write consent to KV store: {e}"); @@ -313,6 +315,39 @@ pub fn save_consent_to_kv( } } +/// Consent KV entry TTL for `max_age_days`, clamped to [`KvHandle::MAX_TTL`]. +/// +/// `EdgeZero`'s [`KvHandle`] validates writes and rejects any TTL above +/// [`KvHandle::MAX_TTL`] (1 year) with a validation error. Trusted Server's +/// consent lifetime follows the IAB TCF norm of 13 months +/// (`MAX_CONSENT_AGE_DAYS` = 395 days) and operators may configure up to 3650 +/// days, so the configured TTL routinely exceeds that cap. Without this clamp +/// the write is rejected and — because consent KV failures are deliberately +/// non-fatal — consent would silently never persist. +/// +/// The clamp is **fail-safe**: the entry expires *earlier* than the configured +/// consent lifetime, so a returning user is re-prompted sooner rather than +/// having stale consent honored past its window. It never extends retention. +/// +/// This is an **interim** measure. Remove it once `EdgeZero` raises or +/// parameterizes `KvHandle::MAX_TTL` (upstream ask: `stackpop/edgezero#323`); +/// at that point pass the configured TTL through unchanged. +fn consent_kv_ttl(max_age_days: u32) -> std::time::Duration { + let configured = std::time::Duration::from_secs(u64::from(max_age_days) * 86_400); + + if configured > KvHandle::MAX_TTL { + log::warn!( + "Consent KV TTL {configured:?} (max_consent_age_days={max_age_days}) exceeds the \ + KV store maximum of {:?}; clamping. Consent entries will expire earlier than the \ + configured consent lifetime.", + KvHandle::MAX_TTL + ); + return KvHandle::MAX_TTL; + } + + configured +} + /// Deletes a consent entry from the KV store for a given EC ID. /// /// Used when a user revokes consent — the existing EC cookie is being @@ -320,8 +355,8 @@ pub fn save_consent_to_kv( /// /// Errors are logged but never propagated — KV failures must not /// break the request pipeline. -pub fn delete_consent_from_kv(store: &dyn PlatformKvStore, ec_id: &str) { - match futures::executor::block_on(store.delete(ec_id)) { +pub async fn delete_consent_from_kv(store: &KvHandle, ec_id: &str) { + match store.delete(ec_id).await { Ok(()) => { log::info!("Deleted consent KV entry (consent revoked)"); } @@ -533,27 +568,153 @@ mod tests { #[cfg(test)] mod new_api_tests { use super::*; + use std::sync::Arc; + use edgezero_core::key_value_store::NoopKvStore; - fn noop() -> NoopKvStore { - NoopKvStore + fn noop() -> KvHandle { + KvHandle::new(Arc::new(NoopKvStore)) } #[test] fn load_returns_none_when_key_absent() { - let result = load_consent_from_kv(&noop(), "some-ec-id"); + let result = futures::executor::block_on(load_consent_from_kv(&noop(), "some-ec-id")); assert!(result.is_none(), "should return None when key is absent"); } #[test] fn save_does_not_panic_with_noop_store() { let ctx = make_test_context(); - save_consent_to_kv(&noop(), "some-ec-id", &ctx, 30); + futures::executor::block_on(save_consent_to_kv(&noop(), "some-ec-id", &ctx, 30)); } #[test] fn delete_does_not_panic_with_noop_store() { - delete_consent_from_kv(&noop(), "some-ec-id"); + futures::executor::block_on(delete_consent_from_kv(&noop(), "some-ec-id")); + } + + /// In-memory KV double that records the TTL each write was issued with, so + /// tests can assert the consent TTL clamp. + #[derive(Default)] + struct RecordingKvStore { + entries: std::sync::Mutex>, + ttls: std::sync::Mutex>, + } + + #[async_trait::async_trait(?Send)] + impl crate::platform::PlatformKvStore for RecordingKvStore { + async fn get_bytes( + &self, + key: &str, + ) -> Result, crate::platform::KvError> { + Ok(self + .entries + .lock() + .expect("should lock entries") + .get(key) + .cloned()) + } + + async fn put_bytes( + &self, + key: &str, + value: bytes::Bytes, + ) -> Result<(), crate::platform::KvError> { + self.entries + .lock() + .expect("should lock entries") + .insert(key.to_owned(), value); + Ok(()) + } + + async fn put_bytes_with_ttl( + &self, + key: &str, + value: bytes::Bytes, + ttl: std::time::Duration, + ) -> Result<(), crate::platform::KvError> { + self.ttls.lock().expect("should lock ttls").push(ttl); + self.put_bytes(key, value).await + } + + async fn delete(&self, key: &str) -> Result<(), crate::platform::KvError> { + self.entries + .lock() + .expect("should lock entries") + .remove(key); + Ok(()) + } + + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Ok(edgezero_core::key_value_store::KvPage::default()) + } + } + + #[test] + fn consent_kv_ttl_clamps_to_kv_maximum() { + // The IAB TCF default (395 days) exceeds KvHandle::MAX_TTL (1 year) and + // must be clamped rather than rejected at the KvHandle validation gate. + assert_eq!( + consent_kv_ttl(MAX_CONSENT_AGE_DAYS_FOR_TEST), + KvHandle::MAX_TTL, + "a consent TTL above the KV maximum should clamp to KvHandle::MAX_TTL" + ); + + // The operator-configurable ceiling (3650 days) also clamps. + assert_eq!( + consent_kv_ttl(3650), + KvHandle::MAX_TTL, + "the maximum configurable consent age should clamp to KvHandle::MAX_TTL" + ); + + // A TTL under the cap passes through untouched. + assert_eq!( + consent_kv_ttl(30), + std::time::Duration::from_secs(30 * 86_400), + "a consent TTL below the KV maximum should pass through unchanged" + ); + } + + /// The IAB TCF consent lifetime Trusted Server defaults to (13 months). + const MAX_CONSENT_AGE_DAYS_FOR_TEST: u32 = 395; + + #[test] + fn save_with_default_consent_age_writes_and_round_trips_with_clamped_ttl() { + // Regression: routing consent persistence through KvHandle made the + // default 395-day TTL fail KvHandle::validate_ttl, so the write was + // rejected and — because consent KV failures are non-fatal — consent + // silently never persisted. The clamp must make this write succeed. + let store = Arc::new(RecordingKvStore::default()); + let handle = KvHandle::new(Arc::clone(&store) as Arc); + let ctx = make_test_context(); + + futures::executor::block_on(save_consent_to_kv( + &handle, + "test-ec-id", + &ctx, + MAX_CONSENT_AGE_DAYS_FOR_TEST, + )); + + // The entry actually landed and round-trips. + let loaded = futures::executor::block_on(load_consent_from_kv(&handle, "test-ec-id")) + .expect("should persist consent at the default 395-day consent age"); + assert_eq!( + loaded.raw_us_privacy, ctx.raw_us_privacy, + "persisted consent should round-trip" + ); + + // And it was written with the clamped TTL, not the configured 395 days. + let ttls = store.ttls.lock().expect("should lock ttls"); + assert_eq!( + ttls.as_slice(), + &[KvHandle::MAX_TTL], + "should write exactly once with the TTL clamped to KvHandle::MAX_TTL" + ); } #[test] diff --git a/crates/trusted-server-core/src/stores.rs b/crates/trusted-server-core/src/stores.rs new file mode 100644 index 000000000..d85317820 --- /dev/null +++ b/crates/trusted-server-core/src/stores.rs @@ -0,0 +1,161 @@ +//! Portable logical store metadata for the Trusted Server application. +//! +//! [`STORES_METADATA`] is the single source of truth for the logical store ids +//! the app declares, mirroring the `[stores.*]` tables in the workspace-root +//! `edgezero.toml`. Every adapter's `Hooks::stores()` returns this same const, +//! so the `EdgeZero` store registry is wired identically across the Fastly, Axum, +//! Cloudflare, and Spin runtimes. The anti-drift test in this module asserts the +//! const and the manifest never diverge. +//! +//! The `DataDome` IP-CIDR config store (`datadome_ip_bypass`) is declared here +//! as an underscore logical id. `EdgeZero`'s manifest validator requires store +//! ids to match `[A-Za-z0-9_]` (they become `EDGEZERO__STORES__…` env +//! segments), so the previously hyphenated names were converged onto +//! underscores. Under the D7 convention the logical id equals the physical +//! platform store name, so each maps to a same-named physical store; an +//! operator whose physical store keeps a different name overrides it out of +//! band with `EDGEZERO__STORES______NAME`. + +use edgezero_core::app::{StoreMetadata, StoresMetadata}; + +/// Secret store ids resolved at runtime but deliberately NOT declared in +/// `edgezero.toml` `[stores.secrets]`. +/// +/// The `EdgeZero` v0.0.8 CLI capability matrix marks the Axum, Cloudflare, and +/// Spin adapters Single-capable for secrets (their secret backends are flat +/// namespaces), and `ts config push` runs that check across every adapter the +/// manifest declares — so `[stores.secrets].ids` may hold only the default id. +/// The request-signing private-key store is provisioned on the management path +/// (never by `ts config push`), so it is bound registry-locally instead: the +/// Fastly and Axum registry builders add these ids on top of +/// [`STORES_METADATA`]. On adapters whose registries come from `run_app` +/// (Cloudflare, Spin) these ids do not resolve, and reads fail closed. +pub const RUNTIME_ONLY_SECRET_IDS: &[&str] = &["signing_keys"]; + +/// Logical store metadata declared by Trusted Server, shared by every adapter's +/// [`edgezero_core::app::Hooks::stores`] implementation. +/// +/// The `default` of each kind is the general-purpose registry slot; the named +/// ids carry the real reads (request signing, EC identity, consent, Tinybird). +/// Keep this in lockstep with `edgezero.toml` — the +/// `stores_metadata_matches_edgezero_manifest` test enforces it. +pub const STORES_METADATA: StoresMetadata = StoresMetadata { + config: Some(StoreMetadata { + default: "trusted_server_config", + ids: &["trusted_server_config", "jwks_store", "datadome_ip_bypass"], + }), + kv: Some(StoreMetadata { + default: "trusted_server_kv", + ids: &[ + "trusted_server_kv", + "ec_identity_store", + "consent_store", + "creative_store", + ], + }), + // Only the default id is declared: the v0.0.8 CLI rejects multi-id + // `[stores.secrets]` while any Single-capable adapter (axum, cloudflare, + // spin) is declared in the manifest. The management-provisioned + // request-signing store binds registry-locally via + // [`RUNTIME_ONLY_SECRET_IDS`] instead. The retired `ts_secrets` and + // `s3_auth` logical ids are gone: since mainline #1036, DataDome/S3/ + // Tinybird credentials resolve at startup from the default store, and + // `trusted_server_secrets` maps to a differently named physical store + // (e.g. Fastly `ts_secrets`) via `EDGEZERO__STORES__SECRETS__…__NAME`. + secrets: Some(StoreMetadata { + default: "trusted_server_secrets", + ids: &["trusted_server_secrets"], + }), +}; + +#[cfg(test)] +mod tests { + use super::*; + + fn read_manifest() -> toml::Value { + let raw = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../edgezero.toml")); + toml::from_str(raw).expect("should parse edgezero.toml") + } + + fn assert_kind(manifest: &toml::Value, kind: &str, meta: &StoreMetadata) { + let decl = manifest + .get("stores") + .and_then(|stores| stores.get(kind)) + .unwrap_or_else(|| panic!("should declare [stores.{kind}] in edgezero.toml")); + let ids: Vec<&str> = decl + .get("ids") + .and_then(toml::Value::as_array) + .unwrap_or_else(|| panic!("should have ids array for [stores.{kind}]")) + .iter() + .map(|id| { + id.as_str() + .unwrap_or_else(|| panic!("ids entry for [stores.{kind}] should be a string")) + }) + .collect(); + let default = decl + .get("default") + .and_then(toml::Value::as_str) + .unwrap_or_else(|| panic!("should have default for [stores.{kind}]")); + assert_eq!( + ids.as_slice(), + meta.ids, + "ids for [stores.{kind}] should match STORES_METADATA" + ); + assert_eq!( + default, meta.default, + "default for [stores.{kind}] should match STORES_METADATA" + ); + } + + #[test] + fn stores_metadata_matches_edgezero_manifest() { + let manifest = read_manifest(); + assert_kind( + &manifest, + "config", + &STORES_METADATA + .config + .expect("should declare config stores"), + ); + assert_kind( + &manifest, + "kv", + &STORES_METADATA.kv.expect("should declare kv stores"), + ); + assert_kind( + &manifest, + "secrets", + &STORES_METADATA + .secrets + .expect("should declare secrets stores"), + ); + } + + /// Pins the push-to-boot contract: the key `ts config push` writes must be + /// the key the runtime boot read looks up. + /// + /// `ts config push` defaults the blob key to the logical store id (only an + /// explicit `--key` overrides it), and the `EdgeZero` config registry binds + /// each store with `default_key` = its logical id. So the app-config blob + /// key must equal the declared `[stores.config]` default. If these drift, + /// `ts config push` writes one key while boot reads another and startup + /// fails with "config key not found" — the exact end-to-end break this test + /// exists to prevent. + #[test] + fn config_blob_key_matches_declared_config_store() { + let config = STORES_METADATA + .config + .expect("should declare config stores"); + + assert_eq!( + crate::config_payload::CONFIG_BLOB_KEY, + config.default, + "app-config blob key must equal the declared config store id so `ts config push` \ + (which defaults the key to the logical store id) writes what boot reads" + ); + assert!( + config.ids.contains(&crate::config_payload::CONFIG_BLOB_KEY), + "the app-config blob key must be a declared config store id" + ); + } +} diff --git a/crates/trusted-server-integration-tests/Cargo.toml b/crates/trusted-server-integration-tests/Cargo.toml index 7477fdbd1..a5f5ea540 100644 --- a/crates/trusted-server-integration-tests/Cargo.toml +++ b/crates/trusted-server-integration-tests/Cargo.toml @@ -27,12 +27,14 @@ toml = { workspace = true } trusted-server-core = { workspace = true } [dev-dependencies] +async-trait = { workspace = true } axum = { workspace = true } bytes = { workspace = true } derive_more = { workspace = true } edgezero-adapter-axum = { workspace = true, features = ["axum"] } env_logger = { workspace = true } error-stack = { workspace = true } +futures = { workspace = true } http = { workspace = true } http-body-util = { workspace = true } libc = { workspace = true } diff --git a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml index c3d9ff53d..2852f87ab 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml @@ -28,8 +28,8 @@ api_token = "integration_partner_token_bravo" [request_signing] enabled = false -config_store_id = "app_config" -secret_store_id = "secrets" +config_store_id = "jwks_store" +secret_store_id = "signing_keys" [integrations.prebid] enabled = false diff --git a/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml b/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml index 816dcbfcf..a183a706d 100644 --- a/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml +++ b/crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml @@ -9,6 +9,15 @@ [local_server.kv_stores] # These inline placeholders satisfy Viceroy's local KV configuration # requirements without exercising KV-backed application behavior. + # Default general-purpose KV registry slot (carries no data yet). + [[local_server.kv_stores.trusted_server_kv]] + key = "placeholder" + data = "placeholder" + + [[local_server.kv_stores.consent_store]] + key = "placeholder" + data = "placeholder" + [[local_server.kv_stores.counter_store]] key = "placeholder" data = "placeholder" @@ -58,6 +67,23 @@ # They were generated specifically for testing and are safe to commit — they # have never been used in any production or staging environment. [local_server.secret_stores] + # Default general-purpose secrets registry slot (carries no data yet). + [[local_server.secret_stores.trusted_server_secrets]] + key = "placeholder" + data = "test-placeholder-secret" + + [[local_server.secret_stores.ts_secrets]] + key = "tinybird_auction_append_token" + data = "test-tinybird-auction-append-token" + + [[local_server.secret_stores.ts_secrets]] + key = "tinybird_access_append_token" + data = "test-tinybird-access-append-token" + + [[local_server.secret_stores.s3_auth]] + key = "placeholder" + data = "test-placeholder-secret" + [[local_server.secret_stores.signing_keys]] key = "ts-2025-10-A" data = "NVnTYrw5xoyTJDOwoUWoPJO3A6UCCXOJJUzgGTxxx7k=" @@ -88,8 +114,9 @@ [local_server.config_stores.edgezero_runtime_env.contents] EDGEZERO__SERVICES__0000000000000000000000__STORES__SECRETS__TRUSTED_SERVER_SECRETS__NAME = "ts_secrets" - # Generated integration configs inject the trusted_server_config blob - # into the store required by the Fastly entry point. + # Generated integration configs inject the app-config blob into the + # trusted_server_config store, under the trusted_server_config key — + # the same store/key the entry point reads at boot. # GENERATED_TRUSTED_SERVER_CONFIG_STORES [local_server.config_stores.jwks_store] @@ -99,3 +126,7 @@ ts-2025-10-B = "{\"kty\":\"OKP\",\"crv\":\"Ed25519\",\"kid\":\"ts-2025-10-B\",\"use\":\"sig\",\"x\":\"HVTi04QLrIuB7jXpVfHjUTVN5aIdcbPNr50umTtN8pw\"}" current-kid = "ts-2025-10-A" active-kids = "ts-2025-10-A,ts-2025-10-B" + + [local_server.config_stores.datadome_ip_bypass] + format = "inline-toml" + [local_server.config_stores.datadome_ip_bypass.contents] diff --git a/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs b/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs index 58c26736e..0f1168969 100644 --- a/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs +++ b/crates/trusted-server-integration-tests/src/bin/generate-viceroy-config.rs @@ -146,6 +146,12 @@ fn inject_generated_config_stores(template: &str, envelope_json: &str) -> Result } fn generated_config_store_blocks(envelope_json: &str) -> String { + // The blob lives in the `trusted_server_config` store under the + // `trusted_server_config` key — the same store the entry point opens at boot + // and the same key `ts config push` writes by default (it uses the logical + // store id unless `--key` is given). Store id, blob key, and the registry + // binding's `default_key` are all one value on purpose; splitting them is + // what made local Fastly boot read an empty store. format!( r#" # Generated by generate-viceroy-config. Do not edit generated output. [local_server.config_stores.trusted_server_config] @@ -174,8 +180,9 @@ mod tests { values: HashMap>, } + #[async_trait::async_trait(?Send)] impl PlatformSecretStore for IntegrationSecretStore { - fn get_bytes( + async fn get_bytes( &self, _store_name: &StoreName, key: &str, @@ -294,6 +301,14 @@ mod tests { ); } + /// Push-to-boot contract at the generator level: the blob must land in the + /// store *and* under the key the Fastly entry point reads at boot. + /// + /// This previously asserted `trusted_server_config` was **empty** — the + /// generator wrote the blob to a separate `app_config` store while boot + /// opened `trusted_server_config`, so local Fastly booted against an empty + /// store. The store id, the blob key, and `CONFIG_BLOB_KEY` are now one + /// value, and this test pins that. #[test] fn generated_config_is_valid_toml() { let envelope = build_app_config_envelope(APP_CONFIG, None).expect("should build envelope"); @@ -301,24 +316,41 @@ mod tests { .expect("should inject generated stores"); let parsed: toml::Value = toml::from_str(&generated).expect("should parse as TOML"); + let contents = parsed["local_server"]["config_stores"] + [trusted_server_core::config_payload::CONFIG_BLOB_KEY]["contents"] + .as_table() + .expect("boot config store should be present in the generated Viceroy config"); + let blob = contents + .get(trusted_server_core::config_payload::CONFIG_BLOB_KEY) + .and_then(toml::Value::as_str) + .expect("blob must be stored under the key the boot read looks up"); assert_eq!( - parsed["local_server"]["config_stores"]["trusted_server_config"]["contents"] - ["trusted_server_config"] - .as_str(), - Some(envelope.as_str()), + blob, + envelope.as_str(), "trusted_server_config should contain the app-config blob" ); + + let settings = futures::executor::block_on(settings_from_config_blob( + blob, + &integration_secret_store(), + &StoreName::from("trusted_server_secrets"), + )) + .expect("the generated blob must verify and deserialize the way boot loads it"); + assert!( + !settings.publisher.domain.is_empty(), + "generated blob should round-trip into usable settings" + ); } #[test] fn generated_blob_verifies_and_applies_origin_override() { let envelope = build_app_config_envelope(APP_CONFIG, Some("http://127.0.0.1:9999")) .expect("should build envelope"); - let settings = settings_from_config_blob( + let settings = futures::executor::block_on(settings_from_config_blob( &envelope, &integration_secret_store(), &StoreName::from("trusted_server_secrets"), - ) + )) .expect("should verify blob"); assert_eq!( diff --git a/crates/trusted-server-integration-tests/tests/common/config.rs b/crates/trusted-server-integration-tests/tests/common/config.rs index d1fddcb95..d614f544b 100644 --- a/crates/trusted-server-integration-tests/tests/common/config.rs +++ b/crates/trusted-server-integration-tests/tests/common/config.rs @@ -35,11 +35,13 @@ pub fn integration_app_config_envelope(origin_port: u16) -> TestResult { pub fn cloudflare_config_json(origin_port: u16) -> TestResult { let envelope = integration_app_config_envelope(origin_port)?; - serde_json::to_string(&serde_json::json!({ "app_config": envelope })).map_err(|error| { - Report::new(TestError::ConfigGeneration).attach(format!( - "failed to serialize Cloudflare config binding: {error}" - )) - }) + serde_json::to_string(&serde_json::json!({ "trusted_server_config": envelope })).map_err( + |error| { + Report::new(TestError::ConfigGeneration).attach(format!( + "failed to serialize Cloudflare config binding: {error}" + )) + }, + ) } #[cfg(test)] diff --git a/crates/trusted-server-integration-tests/tests/environments/axum.rs b/crates/trusted-server-integration-tests/tests/environments/axum.rs index 3623d8491..5fd389e5b 100644 --- a/crates/trusted-server-integration-tests/tests/environments/axum.rs +++ b/crates/trusted-server-integration-tests/tests/environments/axum.rs @@ -11,25 +11,28 @@ use std::process::{Child, Command, Stdio}; const AXUM_DEFAULT_PORT: u16 = 8787; /// Secret-store entries referenced by the integration app-config fixture. +/// +/// The Axum dev server resolves secrets through the `EdgeZero` `EnvSecretStore`, +/// which reads an environment variable named exactly after the secret key. const INTEGRATION_SECRET_ENV: &[(&str, &str)] = &[ ( - "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_INTEGRATION_ADMIN_PASSWORD", + "integration_admin_password", "integration-admin-password-32-bytes-ok", ), ( - "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_INTEGRATION_PROXY_SECRET", + "integration_proxy_secret", "integration-test-proxy-secret-32-bytes-ok", ), ( - "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_INTEGRATION_EC_PASSPHRASE", + "integration_ec_passphrase", "integration-test-ec-secret-padded-32", ), ( - "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_INTEGRATION_PARTNER_TOKEN_ALPHA", + "integration_partner_token_alpha", "integration-test-token-alpha-32-bytes-ok", ), ( - "TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_INTEGRATION_PARTNER_TOKEN_BRAVO", + "integration_partner_token_bravo", "integration-test-token-bravo-32-bytes-ok", ), ]; @@ -58,12 +61,26 @@ impl RuntimeEnvironment for AxumDevServer { let app_config = integration_app_config_envelope(origin_port())?; + // Seed the app-config blob into a JSON file and point the Axum config + // store at it via TRUSTED_SERVER_AXUM_CONFIG_PATH — a file-location + // pointer, not a config-value override. The file holds a flat + // `{ "trusted_server_config": "" }` object, matching the shape the + // EdgeZero Axum config store reads. + let config_path = std::env::temp_dir().join(format!( + "trusted-server-axum-config-{}-{port}.json", + std::process::id() + )); + let config_file = serde_json::json!({ "trusted_server_config": app_config }).to_string(); + std::fs::write(&config_path, config_file) + .change_context(TestError::RuntimeSpawn) + .attach(format!( + "Failed to write Axum config file at {}", + config_path.display() + ))?; + let mut child = Command::new(&binary) .env("PORT", port.to_string()) - .env( - "TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG", - app_config, - ) + .env("TRUSTED_SERVER_AXUM_CONFIG_PATH", &config_path) .envs(INTEGRATION_SECRET_ENV.iter().copied()) .stdout(Stdio::null()) .stderr(Stdio::piped()) @@ -85,7 +102,7 @@ impl RuntimeEnvironment for AxumDevServer { }); } - let handle = AxumHandle { child }; + let handle = AxumHandle { child, config_path }; let base_url = format!("http://127.0.0.1:{port}"); // The Axum dev server returns 403 at root (no publisher config in test env), @@ -143,6 +160,7 @@ fn wait_for_any_response(base_url: &str) -> TestResult<()> { /// Implements [`Drop`] to ensure the process is killed on test cleanup. struct AxumHandle { child: Child, + config_path: std::path::PathBuf, } impl RuntimeProcessHandle for AxumHandle {} @@ -151,5 +169,6 @@ impl Drop for AxumHandle { fn drop(&mut self) { let _ = self.child.kill(); let _ = self.child.wait(); + let _ = std::fs::remove_file(&self.config_path); } } diff --git a/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs b/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs index 10d16c0a7..d87177a0f 100644 --- a/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs +++ b/crates/trusted-server-integration-tests/tests/environments/cloudflare.rs @@ -203,11 +203,12 @@ mod tests { fn inject_cloudflare_config_replaces_single_placeholder() { let template = format!("[vars]\n{TRUSTED_SERVER_CONFIG_PLACEHOLDER}\n"); - let generated = inject_cloudflare_config(&template, r#"{"app_config":"blob"}"#) + let generated = inject_cloudflare_config(&template, r#"{"trusted_server_config":"blob"}"#) .expect("should inject Cloudflare config"); assert!( - generated.contains("TRUSTED_SERVER_CONFIG = '''{\"app_config\":\"blob\"}'''"), + generated + .contains("TRUSTED_SERVER_CONFIG = '''{\"trusted_server_config\":\"blob\"}'''"), "should inject generated config JSON" ); assert!( @@ -218,7 +219,7 @@ mod tests { #[test] fn inject_cloudflare_config_rejects_missing_placeholder() { - let result = inject_cloudflare_config("[vars]\n", r#"{"app_config":"blob"}"#); + let result = inject_cloudflare_config("[vars]\n", r#"{"trusted_server_config":"blob"}"#); assert!(result.is_err(), "should reject missing placeholder"); } @@ -229,7 +230,7 @@ mod tests { "[vars]\n{TRUSTED_SERVER_CONFIG_PLACEHOLDER}\n{TRUSTED_SERVER_CONFIG_PLACEHOLDER}\n" ); - let result = inject_cloudflare_config(&template, r#"{"app_config":"blob"}"#); + let result = inject_cloudflare_config(&template, r#"{"trusted_server_config":"blob"}"#); assert!(result.is_err(), "should reject duplicate placeholders"); } diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index f70cd25b8..efa6d3055 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -71,26 +71,26 @@ The server will be available at `http://localhost:7676`. No Fastly account, CLI, or Viceroy needed. Runs natively on your machine. -The Axum adapter reads the EdgeZero config blob and secret store from -environment variables — it does **not** auto-load `.env` files. You must export -the variables into your shell before starting the server. +The Axum adapter reads the EdgeZero config blob from a local config-store file +and secrets from key-named environment variables — it does **not** auto-load +`.env` files. You must export the secret variables into your shell before +starting the server. ```bash # Create the local app config and apply the non-secret development overlay. cp trusted-server.example.toml trusted-server.toml set -a && source .env.dev && set +a -# Create the local blob-backed config-store entry. +# Create the local blob-backed config-store entry. The dev server reads it +# directly from .edgezero/local-config-trusted_server_config.json. ts config push --adapter axum --local --yes -export TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG="$( - jq -r '.trusted_server_config' .edgezero/local-config-trusted_server_config.json -)" # Populate the three secret references from the starter config for this shell. +# Each env var is named exactly after the secret key referenced in the config. # Use stable values only if you need existing proxy URLs or EC IDs to remain valid. -export TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_PUBLISHER_PROXY_SECRET="$(openssl rand -base64 32)" -export TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_EC_PASSPHRASE="$(openssl rand -base64 32)" -export TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_HANDLER_PASSWORD="$(openssl rand -base64 32)" +export publisher_proxy_secret="$(openssl rand -base64 32)" +export ec_passphrase="$(openssl rand -base64 32)" +export handler_password="$(openssl rand -base64 32)" # Build and start the dev server in the same shell. cargo run -p trusted-server-adapter-axum @@ -99,19 +99,25 @@ cargo run -p trusted-server-adapter-axum The server will be available at `http://localhost:8787`. Set `PORT=` before `cargo run` to bind the dev server to a different local port. -**Environment variable conventions used by the Axum adapter:** +**How the Axum adapter loads config and secrets:** -| Purpose | Pattern | Example | -| ------------------ | ------------------------------------- | --------------------------------------------------------------------- | -| Config store value | `TRUSTED_SERVER_CONFIG_{STORE}_{KEY}` | `TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG_TRUSTED_SERVER_CONFIG=…` | -| Secret store value | `TRUSTED_SERVER_SECRET_{STORE}_{KEY}` | `TRUSTED_SERVER_SECRET_TRUSTED_SERVER_SECRETS_PROXY_KEY=…` | +The Axum dev server reads through the same `EdgeZero` store registry as the other +adapters, backed by local files and environment variables: -The config-store value is the verified app-config blob. Secret-store values are -looked up by the key names in that blob. Store names and key names are uppercased -with hyphens and dots replaced by underscores. The quick-start exports ephemeral -secret-store values only into the current shell; do not put secret values in the +| Purpose | Source | +| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| App config | The `EdgeZero` config store on disk: `.edgezero/local-config-.json` (a JSON object of `key → value`). The default app-config store also honors `TRUSTED_SERVER_AXUM_CONFIG_PATH`, pointing it at an explicit file. | +| Secrets | Environment variables named exactly after the secret **key** in the config blob (e.g. a `proxy_secret` reference of `dev_proxy_secret` reads `dev_proxy_secret`). Set them before starting the server. | + +The config-store value is the verified app-config blob. Secret references in +that blob are resolved from the environment at startup. Export ephemeral +secret values only into the current shell; do not put secret values in the TOML config, config-store blob, or a source-controlled environment file. +> The older `TRUSTED_SERVER_CONFIG_{STORE}_{KEY}` / `TRUSTED_SERVER_SECRET_{STORE}_{KEY}` +> env conventions no longer apply — config now comes from the config-store file and +> secrets from key-named env vars. + > **Dev server limitations:** The Axum adapter does not support KV store, > geo lookup, config/secret-store writes, or admin key-management routes. > See [Architecture](/guide/architecture) for the full list. diff --git a/docs/guide/integrations/datadome.md b/docs/guide/integrations/datadome.md index f289a77b9..a45c803e8 100644 --- a/docs/guide/integrations/datadome.md +++ b/docs/guide/integrations/datadome.md @@ -293,10 +293,12 @@ Config Store-backed CIDR sources accept newline-, comma-, whitespace-, or JSON-a ```toml [[integrations.datadome.protection_excluded_ip_cidr_sources]] -config_store = "datadome-ip-bypass" +config_store = "datadome_ip_bypass" key = "googlebot_ips" ``` +> `config_store` is an EdgeZero logical store id and must match `[A-Za-z0-9_]`; map it to a differently named physical store with `EDGEZERO__STORES______NAME`. + ### Header handling DataDome can return pointer headers that identify which headers Trusted Server should copy: diff --git a/docs/superpowers/plans/2026-07-02-edgezero-store-registry-migration.md b/docs/superpowers/plans/2026-07-02-edgezero-store-registry-migration.md new file mode 100644 index 000000000..d47ce403a --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-edgezero-store-registry-migration.md @@ -0,0 +1,721 @@ +# EdgeZero Store-Registry Migration (Phase 1, D6-a) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Route trusted-server's runtime **and boot-time** config/secret **reads** through EdgeZero stores/registries, add **named/non-default KV** selection (default KV is already EdgeZero; named KV / `consent_store` selection is **not** — see Step 2c), reconcile every logical store id (kv/config/secrets) with `edgezero.toml`, and delete the duplicated Fastly chunk resolver — while **keeping** the runtime **write** path (key rotation) intact via a composite store (decision **D6-a**). + +**Architecture:** trusted-server core reads/writes stores through the bespoke `PlatformConfigStore`/`PlatformSecretStore` traits (each mixes read `get`/`get_string` + write `put`/`create`/`delete`), surfaced via `RuntimeServices` (one trait object per kind). EdgeZero's `ConfigStore`/`SecretStore` are **read-only**; per-request `ConfigRegistry`/`SecretRegistry` live in request extensions. This phase introduces a **composite store** whose _reads_ resolve from EdgeZero and whose _writes_ delegate to the existing management-API-backed impl, migrates the Fastly/Axum **boot** config read to EdgeZero, and adds **local** registry builders for Fastly's custom `oneshot` dispatch (EdgeZero's builders are `pub(crate)`). + +**Tech Stack:** Rust (mixed edition — core/fastly 2021, axum 2024; follow each crate), toolchain 1.95.0, `error-stack` `Report`, EdgeZero (`edgezero-core`/`edgezero-adapter-fastly` git dep, run `--locked`), Viceroy, `cargo test-{fastly,axum,cloudflare,spin}`. + +**Spec:** `docs/superpowers/specs/2026-07-02-edgezero-full-migration-design.md` §5 Phase 1, D5, D6, §4a. + +## Pinned dependency (verified) + +This plan targets the EdgeZero commit pinned in `Cargo.lock`: **`branch = worktree-state-nested-secrets-spec-review` @ `d8f71a4a`** (PR [stackpop/edgezero#306](https://github.com/stackpop/edgezero/pull/306), now including the merged P0 `State` + nested/array `#[secret]` work). That commit **has** every API this plan uses — re-verified against the pinned checkout: `store_registry.rs` (`StoreRegistry`/`ConfigRegistry`/`SecretRegistry`/`from_parts`), `StoresMetadata` + `Hooks::stores()` (`app.rs`), `dispatch_with_registries` + `build_*_registry` (adapter-fastly), `AxumDevServer::{with_config,with_kv,with_secret}_registry`, the `[stores.*]` `ids`/`default` manifest schema (`manifest.rs`, `deny_unknown_fields`), CloudflareConfigStore backed by **KV namespaces**, AxumConfigStore backed by **`.edgezero/local-config-.json`**. **P0 caveat:** the nested-secret work reshaped `AppConfigMeta` from a `const SECRET_FIELDS` to `fn secret_fields() -> Vec`; `TrustedServerAppConfig`'s impl was updated to match (empty until Phase 3) and `cargo check` is green on core + all four adapters + CLI at this pin. Older cargo-cache checkouts (`6ebc29a5`, `ce6bcf7`, `7ec2ad1`, …) predate this pin — **confirm any API check against `d8f71a4a`**, not an older checkout dir. + +> **Lockfile guard (execution):** `Cargo.toml` uses a **mutable branch** dependency (`branch = "worktree-state-nested-secrets-spec-review"`) and the upstream branch has **already advanced past `d8f71a4a`**. Execute every task with the committed `Cargo.lock` — build/test **`--locked`** and **do not `cargo update` the edgezero crates** unless the EdgeZero API is re-reviewed and this pin note re-verified. A silent bump could pull an unreviewed commit mid-migration. + +## 2026-07-08 post-merge amendments (SUPERSEDING — read before executing Task 3+) + +`iabtechlab/main` was merged into the branch (merge `76662d364`). This changes several +load-bearing assumptions below. Where this section conflicts with an earlier one, THIS wins. +Authoritative live state is `.superpowers/sdd/progress.md`. + +- **Pin advanced: `d8f71a4a` → `ff530286`.** The PR #306 branch head moved; the merge kept + the PR #306 branch pin (rejected main's `tag = "v0.0.4"`, which lacks `State`/nested + secrets). Every `d8f71a4a` reference in this doc now means `ff530286`. APIs re-verified + green at `ff530286` on core + all four adapters + CLI (`--locked`). Verify API checks + against the local edgezero repo `git show ff530286:…`, not a cargo-cache checkout. + +- **D5 REVERSED again → unify on `trusted_server_config`** (2026-07-08 operator decision). + The "KEEP `app_config`" note in Task 1 (below) is SUPERSEDED. The config store _id_ is + `trusted_server_config` (already in every manifest + `edgezero.toml`); the blob _key_ + stays `app_config` (`CONFIG_BLOB_KEY`). Implemented in Task 2 part 1 as a _decouple_ + (not a rename): `settings_data.rs` `default_config_store_name()` → `trusted_server_config`, + `default_config_key()` → `CONFIG_BLOB_KEY`. Manifests/generator untouched. **DONE.** + +- **Task 2 DONE** (commits `0513377e9`, `7da58df8c`). Full id lists in `edgezero.toml`; + defaults `trusted_server_kv`/`trusted_server_secrets` + `s3-auth` provisioned in + fastly.toml/viceroy-template.toml/spin.toml; shared `STORES_METADATA` const in + `core/src/stores.rs` feeds all four `Hooks::stores()`; anti-drift test enforces + const↔manifest. CF: `cloudflare.toml` left vestigial (decision C); per-id `wrangler.toml` + bindings deferred to Phase 2. + +- **H3 — Task 8 re-scope: `legacy_main` is GONE** (main #744 removed the legacy Fastly + entry point; Phase 5's legacy-delete is already done upstream). Task 8's justification + ("keep Fastly reads read+write because `legacy_main`/`platform.rs:578` use them until + Phase 5") is INVALID. Re-ground on the _real_ remaining Fastly readers: + `load_settings_from_config_store` (boot), the `build_per_request_services` fallback, and + the management-API write backing. Fastly reads may go write-only earlier than planned. + +- **H2b — Task 6: make `build_kv_registry` per-store open NON-FATAL.** It eager-opens every + declared KV id; a single unprovisioned/deprecated id (`creative_store`) would fail ALL + traffic and converts Fastly's lazy fail-closed model into eager-fail-all. Skip/log a + per-store open failure instead of propagating it into the dispatch `Result`. + +- **EC correction — Task 5: the EC identity graph BYPASSES the registry.** The plan's claim + that "`ec/*` already use `kv_handle()`" is FALSE. Fastly builds it via a bespoke direct + open: `main.rs:1378 maybe_identity_graph` / `:1449 require_identity_graph` → + `KvIdentityGraph::new(FastlyEcKvStore::new(store_name))` (`ec_kv.rs`), and no other adapter + constructs a `KvIdentityGraph` at all. Either migrate these to + `kv_handle_named("ec_identity_store")` (+ other-adapter EC wiring) or explicitly scope + EC-KV-via-registry OUT with a stated reason. Do not rely on the false premise. + +- **CF config mechanism (Phase 1/2 boundary): DO NOT move CF config to KV namespaces in + Phase 1.** CF config is served today via a plaintext side-channel `[vars]` string + (`wrangler.toml TRUSTED_SERVER_CONFIG`). Removing/replacing it is Phase 2. Any Step 6 + instruction to add per-id `[[kv_namespaces]]` for config in Phase 1 is out of scope. + +- **Consent fail-closed now fires on ALL FOUR adapters** (Task 6 Step 5b moves the guard + into core `handle_auction` via `resolve_consent_kv`; `handle_auction` is called from all + four adapter `app.rs`). Add CF/Spin auction-503 coverage (or state the core behavioral + test is deemed sufficient), and ensure CF/Spin manifests provision `consent_store`. + +- **CI gate: add `cargo clippy-cloudflare-wasm`** (now required by CLAUDE.md) to the gate + list below. + +## Global Constraints + +- **Mixed Rust edition — follow each crate's `Cargo.toml`** (not global 2024): `trusted-server-core` and `trusted-server-adapter-fastly` are **2021**; `trusted-server-adapter-axum` is **2024**. Toolchain **1.95.0**; WASM target `wasm32-wasip1`. +- **Run every cargo command `--locked`.** The `Run:` snippets in the tasks below **omit `--locked` for brevity** — the executor MUST append it to **every** cargo invocation (each command in an `&&` chain too): `cargo test-fastly --locked`, `cargo check-cloudflare --locked`, etc. The edgezero dep is a **mutable branch** whose upstream head has advanced past the pinned `d8f71a4a`; `--locked` prevents a silent bump to an unreviewed commit (see the pin note). _(Safest: `export CARGO_NET_OFFLINE=true` for the session after the preflight fetch, so no command can update the lock.)_ +- **Preflight before Task 1:** materialize + verify the pinned edgezero object locally so nobody re-checks APIs against a stale cache checkout — `cargo fetch --locked` then `cargo check -p trusted-server-core --locked`, and confirm the resolved rev is `d8f71a4a` (`grep 'edgezero-core' Cargo.lock`). The checkout dir is `…/git/checkouts/edgezero-efe7ff47d5367787//` — but **do not assume it is materialized**: on a given machine the cache may only hold an _older_ checkout (e.g. `7ec2ad1`), so `cargo fetch --locked` first, then verify the resolved rev is `d8f71a4a` before checking APIs against any checkout dir. +- Errors: `error-stack` `Report` only (no `anyhow` outside the Spin entry point); `derive_more::Display`; import `Error` from `core::error::`. +- No `unwrap()` in production (`expect("should …")`); no `println!`/`eprintln!` (use `log`). +- No wildcard imports (except `use super::*` in `#[cfg(test)]`); no imports inside functions. +- Commits: sentence case, imperative, no semantic prefixes, no `Co-Authored-By`/AI footers. +- CI gate before PR: `cargo fmt --all -- --check`; `cargo clippy-{fastly,axum,cloudflare,cloudflare-wasm,spin-native,spin-wasm}`; **`cargo check-cloudflare` + `cargo check-spin`** (wasm-target surfaces — `test-cloudflare`/`test-spin` are native and do **not** compile the wasm runtime paths); `cargo test-{fastly,axum,cloudflare,spin}`; `cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity`. +- **Every task leaves all four adapters building and green.** +- **EdgeZero `ConfigStore`/`SecretStore` are read-only.** Runtime writes stay on the management path (D6-a). +- **Registry lookup is strict:** an unknown logical id yields `None`. Every id any config field names — in **any** kind (kv/config/secrets) — must be declared in `edgezero.toml`. +- **This plan is D6-a-locked.** If Task 1 selects D6-b (move key rotation to ops/CLI) or D6-c (upstream write API), **stop after Task 1** and write a separate plan — those change the admin API surface and are out of Phase 1 scope. + +--- + +## Task 1: Kind-aware store inventory + confirm D6-a (decision gate, no deletions) + +Deliverable: a **decision record** appended to "Task 1 Output" that Tasks 2+ consume. No code is deleted here. + +**Files:** + +- Modify (append record): this plan file. +- Read-only inventory: `crates/trusted-server-core/src/**`, `crates/trusted-server-adapter-*/src/**`, `edgezero.toml`, `trusted-server.example.toml`, `crates/trusted-server-integration-tests/fixtures/**`. + +**Interfaces:** + +- Produces: the **kind-partitioned store-id map** (`{kv, config, secrets}` → each logical id → platform name → declared?) and the **write-site list**, consumed by Tasks 2, 3, 8. + +- [ ] **Step 1: Enumerate store ids by kind** + +Run: + +```bash +cd /Users/ag/projects/iab/trusted-server/.claude/worktrees/edgezero-migration-spec +# KV ids (logical ids referenced by Settings — NOT Fastly-only platform stores) +rg -n 'ec_store|consent_store|creative_store' crates/trusted-server-core/src/settings.rs crates/trusted-server-core/src/consent_config.rs crates/trusted-server-core/src/auction_config_types.rs trusted-server.example.toml +# config ids (incl. DataDome IP-CIDR config store) +rg -n 'config_store_id|jwks|JWKS_CONFIG_STORE_NAME|"app_config"|config_store\s*=|datadome-ip-bypass|default_ip_cidr_source_store' crates/trusted-server-core trusted-server.example.toml +# secret ids +rg -n 'secret_store_id|secret_store\s*=|"secrets"|ts_secrets|signing_keys|SIGNING_SECRET_STORE_NAME' crates/trusted-server-core trusted-server.example.toml +rg -n '\[stores\.' edgezero.toml +``` + +Expected (verified): **KV** ids = `ec.ec_store` (`ec_identity_store`, `settings.rs:452`), `consent.consent_store` (`consent_config.rs:80`), and `auction.creative_store` (`auction_config_types.rs:28`, default `"creative_store"`, **deprecated** — creatives are delivered inline); **config** ids = the app-config blob store (**store id `app_config`**, see D5 rule below), `request_signing.config_store_id`, the JWKS store (`JWKS_CONFIG_STORE_NAME`), and **DataDome's IP-CIDR config store** (`ProtectionIpCidrSourceConfig.config_store`, default `datadome-ip-bypass`, `protection_scope.rs:165`); **secret** ids = `secrets` (`request_signing.secret_store_id`), DataDome `ts_secrets`, the S3 secret store, `signing_keys` (`SIGNING_SECRET_STORE_NAME`) — versus `edgezero.toml` declaring only one id per kind. NOTE: `counter_store` (`RATE_COUNTER_NAME` in the Fastly `rate_limiter.rs`) and `opid_store` are **Fastly-only** platform stores, not `Settings` logical ids — out of scope for D5. `creative_store` **is** a `Settings` id: declare it in `[stores.kv]` (deprecated) so strict lookup can't fail, and flag it for removal in a later phase. + +**D5 app-config store-id/key decision** _(⚠️ SUPERSEDED 2026-07-08 — see the post-merge amendments section above: unify on `trusted_server_config` via the settings_data.rs decouple; DONE in Task 2 part 1. The KEEP-`app_config` text below is retained only for history.)_ **(operator-confirmed — KEEP `app_config`):** the app-config blob stays in config **store id `app_config`**, blob **key `app_config`** (`CONFIG_BLOB_KEY` and `DEFAULT_CONFIG_STORE_ID` **unchanged**). We resolve the `settings_data.rs` (`app_config`) vs `edgezero.toml` (was `trusted_server_config`) inconsistency by **declaring `app_config` in `edgezero.toml`** (config `default = "app_config"`), **not** by renaming the code/config/tests. This avoids the entire rename cascade (config*payload, settings_data, example, integration fixtures, Viceroy generator, test envs, Cloudflare side-channel). Key == id == `app_config`, so `ts config push`'s default key and the boot read already agree with no env/`--key`. *(The earlier "rename to `trusted_server_config`" plan was reversed by operator decision on 2026-07-07.)\_ + +**Request-signing store ids (do NOT point at app-config):** request signing reads use hard-coded `JWKS_CONFIG_STORE_NAME = "jwks_store"` (config) + `SIGNING_SECRET_STORE_NAME = "signing_keys"` (secret); writes use `request_signing.config_store_id`/`secret_store_id`. Today the example sets these to `"app_config"`/`"secrets"` — which sends **writes to a different store than reads**. Fix: set `request_signing.config_store_id = "jwks_store"` and `secret_store_id = "signing_keys"` in `trusted-server.example.toml` + fixtures, and declare `jwks_store` (config) + `signing_keys` (secret) as logical ids in `edgezero.toml`. (Under the composite, reads resolve `registry.named("jwks_store")`; writes go to the same store via the writer/management id.) + +- [ ] **Step 2: Enumerate runtime WRITE sites** + +Run: + +```bash +rg -n '\.config_store\(\)\.(put|delete)|\.secret_store\(\)\.(create|delete)' crates/trusted-server-core +``` + +Expected: only `KeyRotationManager` in `crates/trusted-server-core/src/request_signing/rotation.rs` (`store_private_key`, `store_public_jwk`, `deactivate_key`, `delete_key`). Confirm no other runtime writers. + +- [ ] **Step 3: Record the kind-partitioned D5 map** + +Append a table to "Task 1 Output": for each `{kv|config|secrets}` id → resolution (declare in `edgezero.toml`, or collapse onto the kind's default) → the concrete platform resource per adapter. **Under D7 there is no `EDGEZERO__STORES__*__NAME` mapping** — the logical id opens the same-named platform store, so the table records the _platform resource per adapter_ (Fastly local/prod store, CF KV namespace / flat secret keys, Spin KV label / variable), **not** an env var. Spec default: app-config blob → config id **`app_config`** key `app_config` (kept, not renamed); JWKS → its own config id `jwks_store`; `ec_identity_store` → kv id; declare `signing_keys`/DataDome `ts_secrets`/S3 `s3-auth` as distinct secret ids. + +- [ ] **Step 4: Confirm D6-a (or STOP)** + +Confirm this phase keeps the write-capable composite (D6-a). Record it. **If the team instead chooses D6-b/c, stop here** and open a separate plan (`…-key-rotation-ops-migration.md`); do not proceed to Task 2. + +- [ ] **Step 5: Commit the record** + +```bash +git add docs/superpowers/plans/2026-07-02-edgezero-store-registry-migration.md +git commit -m "Record Phase 1 kind-aware store-id map and confirm D6-a" +``` + +--- + +## Task 2: Declare all store ids (kv/config/secrets) in `edgezero.toml` + reconcile fields/fixtures + +**Files (exact) — app-config store KEPT as `app_config` (no rename cascade):** + +- Modify: `edgezero.toml` (`[stores.kv]`/`[stores.config]`/`[stores.secrets]` `ids` + set config `default = "app_config"`) +- Modify: `trusted-server.example.toml` + `crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml` — **only** the request-signing 2-line fix (`config_store_id = "jwks_store"`, `secret_store_id = "signing_keys"`) +- Create: `crates/trusted-server-core/src/testdata/all-store-refs.toml` +- Modify (platform manifests): `fastly.toml`, `crates/trusted-server-adapter-cloudflare/wrangler.toml`, `crates/trusted-server-adapter-cloudflare/cloudflare.toml` (reconcile/delete stale schema, Step 6), `crates/trusted-server-adapter-spin/spin.toml` (labels + drop stale `v_…` comment); **Create** `crates/trusted-server-adapter-spin/runtime-config.toml`. The Spin **serve command** (`spin up … --runtime-config-file …`) lives in `edgezero.toml` (~L95) and `CLAUDE.md` (~L87) — update both, not `spin.toml`. +- **Create** `docs/internal/store-provisioning.md` (operator runbook: Fastly mgmt-id==logical-id + store create/link, Cloudflare `wrangler secret put`, Spin runtime-config backends). **Modify** `crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml` (declare the new local Fastly stores for parity). +- Create: `crates/trusted-server-core/src/stores.rs` (shared `stores_metadata()`); Modify: each `crates/trusted-server-adapter-{fastly,axum,cloudflare,spin}/src/app.rs` (`impl Hooks` → add `fn stores()`) +- Test: `crates/trusted-server-core/src/settings.rs` (`#[cfg(test)]`) +- **NOT touched (would only change under the abandoned rename):** `config_payload.rs` (`CONFIG_BLOB_KEY` stays `app_config`), `settings_data.rs` (`DEFAULT_CONFIG_STORE_ID` stays `app_config`), `generate-viceroy-config.rs`, `tests/common/config.rs`, `tests/environments/{axum,cloudflare}.rs`, Cloudflare `app.rs` side-channel key. + +**Interfaces:** + +- Consumes: Task 1 map. +- Produces: `Settings::referenced_store_ids_by_kind() -> ReferencedStoreIds { kv: BTreeSet, config: BTreeSet, secrets: BTreeSet }`; an `edgezero.toml` whose per-kind `ids` are supersets. + +- [ ] **Step 1: Write the failing test (parameterized over multiple configs)** + +Cover the example config, the integration fixture, AND a purpose-built config that exercises every store-backed field (DataDome IP-CIDR sources, S3 auth, request-signing) so optional/targeted settings can't escape coverage. Add to `settings.rs` under `#[cfg(test)]`: + +```rust +fn assert_all_ids_declared(config_toml: &str, label: &str) { + let settings = Settings::from_toml(config_toml).unwrap_or_else(|e| panic!("{label} should parse: {e}")); + let referenced = settings.referenced_store_ids_by_kind(); + let declared = declared_store_ids_by_kind_from_manifest(); // reads edgezero.toml + for (kind, ids) in [("kv", &referenced.kv), ("config", &referenced.config), ("secrets", &referenced.secrets)] { + for id in ids { + assert!( + declared.for_kind(kind).contains(id), + "[{label}] {kind} store id `{id}` referenced by Settings is not declared in edgezero.toml", + ); + } + } +} + +#[test] +fn every_referenced_store_id_is_declared_by_kind() { + assert_all_ids_declared(include_str!("../../../trusted-server.example.toml"), "example"); + assert_all_ids_declared( + include_str!("../../trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml"), + "integration-fixture", + ); + // Purpose-built config exercising DataDome IP-CIDR, S3, and request-signing store refs. + assert_all_ids_declared(include_str!("testdata/all-store-refs.toml"), "all-store-refs"); +} +``` + +(Create `crates/trusted-server-core/src/testdata/all-store-refs.toml` populating every store-id field with a declared id. `settings.rs` lives in `src/`, so the `include_str!` path relative to it is `testdata/all-store-refs.toml` — **not** `../testdata/…`.) + +- [ ] **Step 2: Run to verify it fails** + +Run: `cargo test-fastly every_referenced_store_id_is_declared_by_kind` +Expected: FAIL — `ec_identity_store`/`consent_store`/`creative_store` (kv), `jwks_store`/`datadome-ip-bypass`/`app_config` (config), `signing_keys`/`ts_secrets` (secrets) referenced but not declared. + +- [ ] **Step 3: Implement `referenced_store_ids_by_kind()` + manifest helper** + +Add the `ReferencedStoreIds` struct + method returning **KV** ids (`ec.ec_store`, `consent.consent_store`, `auction.creative_store`), **config** ids (`request_signing.config_store_id`, the app-config store id, and **every `ProtectionIpCidrSourceConfig.config_store`** from DataDome scopes — default `datadome-ip-bypass`), **secret** ids (`request_signing.secret_store_id`, DataDome `ts_secrets`, S3). Do **not** include `counter_store`/`opid_store`. Add test-only `declared_store_ids_by_kind_from_manifest()` parsing `edgezero.toml`. + +**D5 (operator decision — keep `app_config`):** do **NOT** rename the app-config store. `config_payload.rs::CONFIG_BLOB_KEY` and `settings_data.rs::DEFAULT_CONFIG_STORE_ID` **stay `"app_config"`** — we declare `app_config` in `edgezero.toml` instead (Step 4), avoiding the rename cascade. The **only** example/fixture edit in Task 2 is the request-signing fix: set `request_signing.config_store_id = "jwks_store"` and `secret_store_id = "signing_keys"` in `trusted-server.example.toml` + the integration fixture (they must match the read constants `JWKS_CONFIG_STORE_NAME`/`SIGNING_SECRET_STORE_NAME`, not the dormant `app_config`/`secrets`). Do **not** touch the Viceroy generator, `tests/common/config.rs`, `tests/environments/{axum,cloudflare}.rs`, or the Cloudflare side-channel key — those only needed changing under the abandoned rename. + +- [ ] **Step 4: Declare every id in `edgezero.toml`** — `[stores.kv]` ids = `trusted_server_kv`, `ec_identity_store`, `consent_store`, `creative_store` (default stays `trusted_server_kv`); `[stores.config]` ids = **`app_config`**, `jwks_store`, `datadome-ip-bypass` with **`default = "app_config"`** (declare the existing app-config store name — no rename); `[stores.secrets]` ids = `trusted_server_secrets`, `signing_keys`, `ts_secrets`, `s3-auth` (default stays `trusted_server_secrets`). (Names double as the platform store names under D7.) `CONFIG_BLOB_KEY` stays `app_config`; blob key == store id == `app_config`, so `ts config push`'s default key and the boot read already agree with no env/`--key`. _(Confirm during the run whether `trusted_server_kv`/`trusted_server_secrets` are real code-referenced defaults or just edgezero.toml placeholders; if nothing references them, set the kv/secret defaults to a real referenced id instead.)_ + +- [ ] **Step 5: Wire `Hooks::stores()` on all four adapters (Blocker — metadata is not wired today).** Each `impl Hooks for TrustedServerApp` currently overrides only `routes()`; the default `stores()` returns **empty** `StoresMetadata`, so no registries can be built from it. Add `fn stores() -> StoresMetadata` returning the `[stores.*]` metadata, generated once from `edgezero.toml`. Prefer a single shared fn in `trusted-server-core` (`pub fn stores_metadata() -> StoresMetadata`) that all four adapters return, so the ids live in one place. Verify against `edgezero_core::app::StoresMetadata`/`StoreMetadata` shape. + +- [ ] **Step 5b: Anti-drift test — `stores_metadata()` and every adapter's `Hooks::stores()` must equal `edgezero.toml`.** Registries are built from `TrustedServerApp::stores()`, **not** from the `edgezero.toml` that Step 1's test validates — so a stale/incomplete `stores_metadata()` would pass Step 1 while runtime registries silently miss ids. Add a test that parses `edgezero.toml`'s `[stores.*]` ids/default and asserts they equal `trusted_server_core::stores_metadata()` **and** each `::TrustedServerApp::stores()` (per kind, ids as sets + default). Put the core half in `trusted-server-core` and one assertion in each adapter's test module (so a future adapter that forgets to return `stores_metadata()` fails). + +- [ ] **Step 6: Declare the stores in every PLATFORM manifest (Blocker — local resources missing), per each adapter's real mapping.** D7 requires each logical id to be openable as a real platform store. The adapters map kinds to concrete resources differently — declare exactly: + - **Fastly** — `fastly.toml` holds **only local Viceroy resources** under `[local_server]`: add each id as `[[local_server.kv_stores.]]` / `[local_server.config_stores.]` / `[local_server.secret_stores.]`. The **production** Fastly KV/config/secret stores + their service links are **not** in `fastly.toml` — they are an **operator/provisioning step** (create via `fastly kv-store`/`config-store`/`secret-store` + link to the service, or the EdgeZero provision path). Document those commands in the operator runbook; do not try to express them in `fastly.toml`. + - **Cloudflare manifest `cloudflare.toml` — reconcile the STALE schema (Medium blocker).** `crates/trusted-server-adapter-cloudflare/cloudflare.toml` still uses the pre-rewrite manifest schema (`[stores.kv].name = …`, `[stores.kv.adapters.cloudflare].name = …`), which the pinned EdgeZero manifest parser (`manifest.rs`, `deny_unknown_fields`) **rejects** in favor of `[stores.*]` `ids`/`default`. Either migrate it to the `ids`/`default` schema (matching `edgezero.toml`) or **delete it if `edgezero.toml` is the single source** and nothing loads `cloudflare.toml`. Do not leave a stale-schema manifest that a tool/test could load. + - **Cloudflare** (`wrangler.toml`): EdgeZero backs **config stores by a KV namespace binding** (`config_store.rs`) — so each **config** id (`app_config`, `jwks_store`, `datadome-ip-bypass`) gets a `[[kv_namespaces]]` binding (as does each KV id). **Secrets use a FLAT namespace — `CloudflareSecretStore::get_bytes` ignores `store_name` and reads `env.secret(key)`.** So do **not** `wrangler secret put signing_keys` (that provisions the wrong name). Provision the concrete secret **keys the code reads**: the signing KIDs written by `KeyRotationManager`, the DataDome `server_side_key_secret_name`, and the S3 `access_key_id` / `secret_access_key` / optional session-token keys. Document the exact `wrangler secret put ` commands in the operator runbook; `store_name`/store-id is irrelevant on Cloudflare. + - **Spin** (`spin.toml` **and** `runtime-config.toml`): config **and** KV ids open **KV-store labels** (`request.rs:282`). `spin.toml:41` currently declares only `key_value_stores = ["default"]` — extend it to list **every** kv+config logical id label (`trusted_server_kv`, `consent_store`, `creative_store`, `app_config`, `jwks_store`, `datadome-ip-bypass`). Each declared label **also needs a backend** in `runtime-config.toml` (`[key_value_store.