diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 2f432957..61f9e977 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -12,6 +12,8 @@ use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; use trusted_server_core::ec::EcContext; +use trusted_server_core::ec::admin::handle_admin_eids_lookup; +use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; use trusted_server_core::proxy::{ @@ -252,6 +254,8 @@ enum NamedRouteHandler { TrustedServerDiscovery, VerifySignature, AdminNotSupported, + AdminEcNotSupported, + AdminEidsLookup, /// Legacy `/admin/keys/*` aliases — denied locally with 404 so they never /// reach the publisher fallback (which would leak admin credentials). LegacyAdminDenied, @@ -279,7 +283,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_routes() -> [NamedRoute; 12] { +fn named_routes() -> [NamedRoute; 15] { [ NamedRoute { path: "/.well-known/trusted-server.json", @@ -304,6 +308,26 @@ fn named_routes() -> [NamedRoute; 12] { primary_methods: &[Method::POST], handler: NamedRouteHandler::AdminNotSupported, }, + // Admin EC lookup routes. Registered explicitly (like the key routes + // above) so they never fall through to the publisher fallback, and + // they match `Settings::ADMIN_ENDPOINTS` for auth coverage. + NamedRoute { + path: "/_ts/admin/ec", + primary_methods: &[Method::GET], + handler: NamedRouteHandler::AdminEcNotSupported, + }, + NamedRoute { + path: "/_ts/admin/ec/{id}", + primary_methods: &[Method::GET], + handler: NamedRouteHandler::AdminEcNotSupported, + }, + // Admin EIDs echo: pure request inspection (no KV), so the dev + // server serves the real handler. + NamedRoute { + path: "/_ts/admin/eids", + primary_methods: &[Method::GET], + handler: NamedRouteHandler::AdminEidsLookup, + }, // The legacy non-`/_ts` aliases (`/admin/keys/*`) are denied locally with // a 404, matching the Fastly and Cloudflare adapters: the production // basic-auth handler regex `^/_ts/admin` does not match them, and letting @@ -388,6 +412,26 @@ fn named_route_handler( ); Ok(resp) } + NamedRouteHandler::AdminEcNotSupported => { + // The EC identity graph is Fastly KV backed; the Axum + // dev server has no store to read. + let body = edgezero_core::body::Body::from( + "Admin EC lookup is not supported on the Axum dev server.\n\ + Use the Fastly adapter (via Viceroy or deployed) to inspect EC entries.\n", + ); + let mut resp = Response::new(body); + *resp.status_mut() = StatusCode::NOT_IMPLEMENTED; + resp.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + Ok(resp) + } + NamedRouteHandler::AdminEidsLookup => { + let partner_registry = + PartnerRegistry::from_config(&state.settings.ec.partners)?; + handle_admin_eids_lookup(&partner_registry, &req) + } NamedRouteHandler::LegacyAdminDenied => Ok(legacy_admin_alias_denied()), NamedRouteHandler::Auction => { // Build the geo-aware EC context so the auction consent diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index c4bf7d99..f64c9361 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -74,6 +74,9 @@ fn all_explicit_routes_are_registered() { ("POST", "/verify-signature"), ("POST", "/_ts/admin/keys/rotate"), ("POST", "/_ts/admin/keys/deactivate"), + ("GET", "/_ts/admin/ec"), + ("GET", "/_ts/admin/ec/{id}"), + ("GET", "/_ts/admin/eids"), ("POST", "/admin/keys/rotate"), ("POST", "/admin/keys/deactivate"), ("POST", "/auction"), @@ -256,6 +259,63 @@ async fn admin_route_without_credentials_returns_401() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn authenticated_admin_ec_routes_return_501() { + // The EC identity graph is Fastly KV backed, so the Axum dev server + // answers the admin EC lookup routes locally with 501 instead of letting + // them fall through to the publisher fallback. + let sample_ec_id = format!("{}.abc123", "a".repeat(64)); + for path in [ + "/_ts/admin/ec".to_owned(), + format!("/_ts/admin/ec/{sample_ec_id}"), + ] { + let mut svc = make_service(); + let req = Request::builder() + .method("GET") + .uri(&path) + .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==") + .body(AxumBody::empty()) + .expect("should build request"); + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + assert_eq!( + resp.status().as_u16(), + 501, + "{path} should report that Axum EC lookup is unsupported" + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn authenticated_admin_eids_route_returns_200() { + // The EIDs echo is pure request inspection (no KV), so the dev server + // serves the real handler. + let mut svc = make_service(); + let req = Request::builder() + .method("GET") + .uri("/_ts/admin/eids") + .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==") + .body(AxumBody::empty()) + .expect("should build request"); + let resp = svc + .ready() + .await + .expect("should be ready") + .call(req) + .await + .expect("should respond"); + assert_eq!( + resp.status().as_u16(), + 200, + "/_ts/admin/eids should serve the real EIDs echo handler" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn legacy_admin_aliases_denied_locally_not_proxied_to_publisher() { // Regression for the credential-leak finding: the production basic-auth regex diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index c931360f..59352202 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -13,6 +13,8 @@ use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; #[cfg(target_arch = "wasm32")] use trusted_server_core::config_payload::settings_from_config_blob; use trusted_server_core::ec::EcContext; +use trusted_server_core::ec::admin::handle_admin_eids_lookup; +use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; use trusted_server_core::platform::RuntimeServices; @@ -242,6 +244,20 @@ fn admin_key_management_not_supported() -> Response { response } +fn admin_ec_lookup_not_supported() -> Response { + let body = edgezero_core::body::Body::from( + "Admin EC lookup is not supported on Cloudflare Workers.\n\ + Use the Fastly adapter (via Viceroy or deployed) to inspect EC entries.\n", + ); + let mut response = Response::new(body); + *response.status_mut() = StatusCode::NOT_IMPLEMENTED; + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + response +} + /// Builds the local `404 Not Found` returned for legacy `/admin/keys/*` /// aliases on the Cloudflare adapter. /// @@ -461,6 +477,26 @@ fn build_router(state: &Arc) -> RouterService { .post("/_ts/admin/keys/deactivate", |_ctx: RequestContext| async { Ok::(admin_key_management_not_supported()) }) + // Admin EC lookup routes. Registered explicitly (like the key + // routes above) so they never fall through to the publisher + // fallback, and they match `Settings::ADMIN_ENDPOINTS` for auth + // coverage. The EC identity graph is Fastly KV backed, so this + // adapter has no store to read. + .get("/_ts/admin/ec", |_ctx: RequestContext| async { + Ok::(admin_ec_lookup_not_supported()) + }) + .get("/_ts/admin/ec/{id}", |_ctx: RequestContext| async { + Ok::(admin_ec_lookup_not_supported()) + }) + // Admin EIDs echo: pure request inspection (no KV), so this + // adapter serves the real handler. + .get( + "/_ts/admin/eids", + make_handler(Arc::clone(&state), |s, _services, req| async move { + let partner_registry = PartnerRegistry::from_config(&s.settings.ec.partners)?; + handle_admin_eids_lookup(&partner_registry, &req) + }), + ) .post( "/auction", make_handler(Arc::clone(&state), |s, services, req| async move { diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index df278194..8ecd020b 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -215,6 +215,9 @@ fn all_explicit_routes_are_registered() { ("POST", "/verify-signature"), ("POST", "/_ts/admin/keys/rotate"), ("POST", "/_ts/admin/keys/deactivate"), + ("GET", "/_ts/admin/ec"), + ("GET", "/_ts/admin/ec/{id}"), + ("GET", "/_ts/admin/eids"), ("POST", "/auction"), ("GET", "/first-party/proxy"), ("GET", "/first-party/click"), @@ -264,6 +267,51 @@ async fn authenticated_admin_routes_return_501() { } } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn authenticated_admin_ec_routes_return_501() { + // The EC identity graph is Fastly KV backed, so Cloudflare answers the + // admin EC lookup routes locally with 501 instead of letting them fall + // through to the publisher fallback. + let sample_ec_id = format!("{}.abc123", "a".repeat(64)); + for path in [ + "/_ts/admin/ec".to_owned(), + format!("/_ts/admin/ec/{sample_ec_id}"), + ] { + let req = request_builder() + .method("GET") + .uri(&path) + .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let resp = route(test_router(), req).await; + + assert_eq!( + resp.status().as_u16(), + 501, + "{path} should report that Cloudflare EC lookup is unsupported" + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn authenticated_admin_eids_route_returns_200() { + // The EIDs echo is pure request inspection (no KV), so this adapter + // serves the real handler. + let req = request_builder() + .method("GET") + .uri("/_ts/admin/eids") + .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let resp = route(test_router(), req).await; + + assert_eq!( + resp.status().as_u16(), + 200, + "/_ts/admin/eids should serve the real EIDs echo handler" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn admin_route_without_credentials_returns_401() { let router = test_router(); diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 955ff235..1703b2d6 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -22,6 +22,9 @@ //! | POST | `/verify-signature` | [`handle_verify_signature`] | //! | POST | `/_ts/admin/keys/rotate` | [`handle_rotate_key`] | //! | POST | `/_ts/admin/keys/deactivate` | [`handle_deactivate_key`] | +//! | GET | `/_ts/admin/ec` | [`handle_admin_ec_lookup`] | +//! | GET | `/_ts/admin/ec/{id}` | [`handle_admin_ec_lookup`] | +//! | GET | `/_ts/admin/eids` | [`handle_admin_eids_lookup`] | //! | POST | `/_ts/api/v1/batch-sync` | [`handle_batch_sync`] | //! | GET | `/_ts/api/v1/identify` | [`handle_identify`] | //! | GET | `/_ts/set-tester` | [`handle_set_tester`] | @@ -98,6 +101,7 @@ use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; use trusted_server_core::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS}; use trusted_server_core::ec::EcContext; +use trusted_server_core::ec::admin::{handle_admin_ec_lookup, handle_admin_eids_lookup}; use trusted_server_core::ec::batch_sync::handle_batch_sync; use trusted_server_core::ec::consent::ec_consent_withdrawn; use trusted_server_core::ec::device::DeviceSignals; @@ -565,6 +569,18 @@ async fn run_named_route( } NamedRouteHandler::RotateKey => handle_rotate_key(&state.settings, services, req), NamedRouteHandler::DeactivateKey => handle_deactivate_key(&state.settings, services, req), + NamedRouteHandler::AdminEcLookup => { + // Deliberately NOT `ec.kv_graph`: that copy is bot-gated (None for + // non-browser clients), and operators hit this auth-gated endpoint + // with curl. Build the graph directly from settings instead. + let kv = crate::maybe_identity_graph(&state.settings); + let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?; + handle_admin_ec_lookup(kv.as_ref(), &partner_registry, &req) + } + NamedRouteHandler::AdminEidsLookup => { + let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?; + handle_admin_eids_lookup(&partner_registry, &req) + } NamedRouteHandler::LegacyAdminDenied => Ok(legacy_admin_alias_denied()), NamedRouteHandler::BatchSync => { // Dispatched by execute_named before EC state is built. @@ -987,6 +1003,8 @@ enum NamedRouteHandler { VerifySignature, RotateKey, DeactivateKey, + AdminEcLookup, + AdminEidsLookup, /// Legacy `/admin/keys/*` aliases — denied locally with 404 so they never /// reach the publisher fallback (which would leak admin credentials). LegacyAdminDenied, @@ -1039,6 +1057,25 @@ const NAMED_ROUTES: &[NamedRoute] = &[ primary_methods: &[Method::POST], handler: NamedRouteHandler::DeactivateKey, }, + // Admin EC lookup: the bare route reads the EC ID from the caller's + // `ts-ec` cookie; the parameterized route takes an explicit EC ID. + NamedRoute { + path: "/_ts/admin/ec", + primary_methods: &[Method::GET], + handler: NamedRouteHandler::AdminEcLookup, + }, + NamedRoute { + path: "/_ts/admin/ec/{id}", + primary_methods: &[Method::GET], + handler: NamedRouteHandler::AdminEcLookup, + }, + // Admin EIDs echo: decodes the request's ts-eids/sharedId cookies with + // an ingestion preview. Pure request inspection — no KV access. + NamedRoute { + path: "/_ts/admin/eids", + primary_methods: &[Method::GET], + handler: NamedRouteHandler::AdminEidsLookup, + }, // The legacy non-`/_ts` aliases (`/admin/keys/*`) are denied locally with a // 404 instead of executing key operations: the production basic-auth handler // regex `^/_ts/admin` does not match them, and letting them fall through to @@ -1624,6 +1661,44 @@ mod tests { } } + #[test] + fn admin_ec_lookup_routes_are_registered() { + // Both lookup shapes must be explicitly routed to the admin EC + // handler: the bare cookie-based route and the parameterized route. + // Leaving either unrouted would fall through to the publisher + // fallback, forwarding the caller's `Authorization` header to the + // origin. + for path in ["/_ts/admin/ec", "/_ts/admin/ec/{id}"] { + let route = NAMED_ROUTES + .iter() + .find(|route| route.path == path) + .unwrap_or_else(|| panic!("{path} must be a named route")); + assert!( + matches!(route.handler, NamedRouteHandler::AdminEcLookup), + "{path} must map to the admin EC lookup handler" + ); + assert_eq!( + route.primary_methods, + &[Method::GET], + "{path} must have GET as its only primary method" + ); + } + + let eids_route = NAMED_ROUTES + .iter() + .find(|route| route.path == "/_ts/admin/eids") + .expect("should register /_ts/admin/eids as a named route"); + assert!( + matches!(eids_route.handler, NamedRouteHandler::AdminEidsLookup), + "/_ts/admin/eids must map to the admin EIDs lookup handler" + ); + assert_eq!( + eids_route.primary_methods, + &[Method::GET], + "/_ts/admin/eids must have GET as its only primary method" + ); + } + #[test] fn legacy_admin_aliases_denied_locally_not_proxied_to_publisher() { // Regression for the credential-leak finding: with a production-shaped diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 2291fce7..51909998 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -11,6 +11,8 @@ use error_stack::Report; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator}; use trusted_server_core::ec::EcContext; +use trusted_server_core::ec::admin::handle_admin_eids_lookup; +use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::sanitize_forwarded_headers; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; @@ -141,12 +143,15 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[ Method::DELETE, ]; -fn named_fallback_paths() -> [(&'static str, &'static [Method]); 12] { +fn named_fallback_paths() -> [(&'static str, &'static [Method]); 15] { [ ("/.well-known/trusted-server.json", &[Method::GET]), ("/verify-signature", &[Method::POST]), ("/_ts/admin/keys/rotate", &[Method::POST]), ("/_ts/admin/keys/deactivate", &[Method::POST]), + ("/_ts/admin/ec", &[Method::GET]), + ("/_ts/admin/ec/{id}", &[Method::GET]), + ("/_ts/admin/eids", &[Method::GET]), ("/admin/keys/rotate", LEGACY_ADMIN_DENY_METHODS), ("/admin/keys/deactivate", LEGACY_ADMIN_DENY_METHODS), ("/auction", &[Method::POST]), @@ -359,6 +364,20 @@ fn admin_key_management_not_supported() -> Response { response } +fn admin_ec_lookup_not_supported() -> Response { + let body = edgezero_core::body::Body::from( + "Admin EC lookup is not supported on Fermyon Spin.\n\ + Use the Fastly adapter (via Viceroy or deployed) to inspect EC entries.\n", + ); + let mut response = Response::new(body); + *response.status_mut() = StatusCode::NOT_IMPLEMENTED; + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + response +} + // --------------------------------------------------------------------------- // Error helper // --------------------------------------------------------------------------- @@ -511,6 +530,23 @@ fn build_router(state: &Arc) -> RouterService { Ok::(admin_key_management_not_supported()) }; + let admin_ec_not_supported_handler = |_ctx: RequestContext| async { + Ok::(admin_ec_lookup_not_supported()) + }; + + // Admin EIDs echo: pure request inspection (no KV), so this adapter + // serves the real handler. + let s = Arc::clone(&state); + let admin_eids_handler = move |ctx: RequestContext| { + let s = Arc::clone(&s); + async move { + let req = ctx.into_request(); + let result = PartnerRegistry::from_config(&s.settings.ec.partners) + .and_then(|registry| handle_admin_eids_lookup(®istry, &req)); + Ok::(result.unwrap_or_else(|e| http_error(&e))) + } + }; + // /auction let s = Arc::clone(&state); let auction_handler = move |ctx: RequestContext| { @@ -730,6 +766,14 @@ fn build_router(state: &Arc) -> RouterService { // credentials and key-management payloads to the origin. .post("/_ts/admin/keys/rotate", admin_not_supported_handler) .post("/_ts/admin/keys/deactivate", admin_not_supported_handler) + // Admin EC lookup routes. Registered explicitly (like the key + // routes above) so they never fall through to the publisher + // fallback, and they match `Settings::ADMIN_ENDPOINTS` for auth + // coverage. The EC identity graph is Fastly KV backed, so this + // adapter has no store to read. + .get("/_ts/admin/ec", admin_ec_not_supported_handler) + .get("/_ts/admin/ec/{id}", admin_ec_not_supported_handler) + .get("/_ts/admin/eids", admin_eids_handler) .post("/auction", auction_handler) .get("/__ts/page-bids", page_bids_handler) .route( diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 9b96dbd7..d502a394 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -113,6 +113,51 @@ async fn authenticated_admin_routes_return_501() { } } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn authenticated_admin_ec_routes_return_501() { + // The EC identity graph is Fastly KV backed, so Spin answers the admin + // EC lookup routes locally with 501 instead of letting them fall through + // to the publisher fallback. + let sample_ec_id = format!("{}.abc123", "a".repeat(64)); + for path in [ + "/_ts/admin/ec".to_owned(), + format!("/_ts/admin/ec/{sample_ec_id}"), + ] { + let req = request_builder() + .method("GET") + .uri(&path) + .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let resp = route(test_router(), req).await; + + assert_eq!( + resp.status().as_u16(), + 501, + "{path} should report that Spin EC lookup is unsupported" + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn authenticated_admin_eids_route_returns_200() { + // The EIDs echo is pure request inspection (no KV), so this adapter + // serves the real handler. + let req = request_builder() + .method("GET") + .uri("/_ts/admin/eids") + .header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==") + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let resp = route(test_router(), req).await; + + assert_eq!( + resp.status().as_u16(), + 200, + "/_ts/admin/eids should serve the real EIDs echo handler" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn health_route_returns_ok() { // Parity with the Fastly/Axum adapters: GET /health is a cheap liveness probe diff --git a/crates/trusted-server-core/src/ec/admin.rs b/crates/trusted-server-core/src/ec/admin.rs new file mode 100644 index 00000000..a4e6465a --- /dev/null +++ b/crates/trusted-server-core/src/ec/admin.rs @@ -0,0 +1,925 @@ +//! Admin endpoints for inspecting EC identity state. +//! +//! Serves `GET /_ts/admin/ec` (EC ID taken from the request's `ts-ec` +//! cookie) and `GET /_ts/admin/ec/{id}` (explicit EC ID). Returns the raw +//! stored [`KvEntry`] plus a derived view of the EIDs the auction would +//! attach, so operators can debug KV-to-auction propagation without KV +//! console access. +//! +//! Also serves `GET /_ts/admin/eids`, which echoes the request's `ts-eids` +//! and `sharedId` cookies with an ingestion preview — the client-side half +//! of EID propagation that is never stored server-side. +//! +//! Authentication is enforced by the `^/_ts/admin` basic-auth handler +//! configuration; startup validation rejects configs that leave these paths +//! uncovered (see `Settings::ADMIN_ENDPOINTS`). Because the endpoints are +//! auth-gated and operator-facing, responses intentionally include full +//! internal detail (raw consent strings, partner UIDs, parse errors). + +use http::{Request, Response, StatusCode, header}; +use serde::Serialize; +use serde_json::Value as JsonValue; + +use edgezero_core::body::Body as EdgeBody; +use error_stack::{Report, ResultExt as _}; + +use crate::constants::{COOKIE_SHAREDID, COOKIE_TS_EC, COOKIE_TS_EIDS}; +use crate::error::TrustedServerError; +use crate::openrtb::Eid; + +use super::eids::{resolve_partner_ids, to_eids}; +use super::generation::is_valid_ec_id; +use super::kv::KvIdentityGraph; +use super::kv_backend::EcKvLookup; +use super::kv_types::{KvEntry, KvMetadata}; +use super::log_id; +use super::prebid_eids::{ + collect_prebid_eid_updates, collect_sharedid_update, dedupe_partner_updates, + parse_prebid_eids_cookie, +}; +use super::registry::PartnerRegistry; + +/// Route prefix shared by the cookie-based and explicit-ID lookup routes. +const ADMIN_EC_PATH: &str = "/_ts/admin/ec"; + +/// Successful admin EC lookup payload. +#[derive(Debug, Serialize)] +struct AdminEcLookupResponse { + /// The EC ID that was looked up. + ec_id: String, + /// Platform KV store name the entry was read from. + store: String, + /// Store generation marker for the entry. + generation: u64, + /// `true` when the entry is a consent-withdrawal tombstone + /// (`consent.ok = false`). Absent when the body failed to parse. + #[serde(skip_serializing_if = "Option::is_none")] + tombstone: Option, + /// The stored entry, re-serialized verbatim except for derived + /// `created_iso` / `updated_iso` companions added next to the stored + /// unix-seconds timestamps for readability. Absent when the body + /// failed to deserialize (see `entry_error` / `raw_body`). + #[serde(skip_serializing_if = "Option::is_none")] + entry: Option, + /// Deserialization or validation failure detail for the entry body. + #[serde(skip_serializing_if = "Option::is_none")] + entry_error: Option, + /// Raw entry body (lossy UTF-8) when it could not be deserialized. + #[serde(skip_serializing_if = "Option::is_none")] + raw_body: Option, + /// The stored KV metadata mirror, when present and parseable. + #[serde(skip_serializing_if = "Option::is_none")] + metadata: Option, + /// Deserialization failure detail for the metadata, including its raw + /// value. + #[serde(skip_serializing_if = "Option::is_none")] + metadata_error: Option, + /// Derived auction view. Present only when the entry deserializes and + /// validates — the same precondition the auction read path applies, so + /// its absence means the auction would attach no KV-derived EIDs. + /// Live requests additionally gate on per-request consent, which is not + /// reproducible here. + #[serde(skip_serializing_if = "Option::is_none")] + auction: Option, +} + +/// What the auction EID decoration would produce for this entry. +#[derive(Debug, Serialize)] +struct AuctionEidsView { + /// EIDs the auction would attach to `user.eids`, exactly as produced by + /// the auction resolution path. + eids: Vec, + /// Stored partner IDs that the auction resolution filters out, with the + /// reason each was skipped. + skipped: Vec, +} + +/// A stored partner ID excluded from auction EIDs. +#[derive(Debug, Serialize)] +struct SkippedPartnerId { + /// Partner namespace key in the entry's `ids` map. + source_domain: String, + /// Why the auction resolution skips it: `empty_uid`, `not_in_registry`, + /// or `bidstream_disabled`. + reason: &'static str, +} + +/// Handles `GET /_ts/admin/ec` and `GET /_ts/admin/ec/{id}`. +/// +/// Resolves the EC ID from the path when present, falling back to the +/// request's `ts-ec` cookie for the bare route. Responds: +/// +/// - `200 OK` with an [`AdminEcLookupResponse`] JSON body when the key +/// exists (including corrupt entries, which are reported with +/// `entry_error` and `raw_body` instead of failing closed); +/// - `400 Bad Request` when the resolved ID is not a valid EC ID; +/// - `404 Not Found` when the key does not exist, or the bare route was +/// called without a `ts-ec` cookie; +/// - `501 Not Implemented` when no EC identity graph is configured. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::KvStore`] when the store open or read +/// fails. +pub fn handle_admin_ec_lookup( + kv: Option<&KvIdentityGraph>, + registry: &PartnerRegistry, + req: &Request, +) -> Result, Report> { + let Some(kv) = kv else { + return Ok(json_error( + StatusCode::NOT_IMPLEMENTED, + "EC identity graph is not configured on this deployment", + )); + }; + + let ec_id = match requested_ec_id(req) { + Ok(ec_id) => ec_id, + Err(response) => return Ok(*response), + }; + + let Some(lookup) = kv.lookup_raw(&ec_id)? else { + log::info!("Admin EC lookup: no entry for '{}'", log_id(&ec_id)); + return Ok(json_error( + StatusCode::NOT_FOUND, + "EC entry not found (KV reads are eventually consistent; a very \ + recent entry may not be visible yet)", + )); + }; + + log::info!("Admin EC lookup: returning entry for '{}'", log_id(&ec_id)); + let payload = build_lookup_response(registry, kv.store_name(), ec_id, &lookup); + let body = + serde_json::to_string(&payload).change_context(TrustedServerError::Configuration { + message: "failed to serialize admin EC lookup response".to_owned(), + })?; + Ok(json_response(StatusCode::OK, body)) +} + +/// Resolves the EC ID to look up from the path or the `ts-ec` cookie. +/// +/// Returns the (boxed) error response to send directly when no valid ID is +/// available. +fn requested_ec_id(req: &Request) -> Result>> { + let remainder = req + .uri() + .path() + .strip_prefix(ADMIN_EC_PATH) + .unwrap_or("") + .trim_matches('/'); + + let ec_id = if remainder.is_empty() { + match extract_cookie_value(req, COOKIE_TS_EC) { + Some(cookie_ec_id) => cookie_ec_id, + None => { + return Err(Box::new(json_error( + StatusCode::NOT_FOUND, + "no EC ID in path and no ts-ec cookie on the request — pass \ + an explicit id: /_ts/admin/ec/{id}", + ))); + } + } + } else { + remainder.to_owned() + }; + + if !is_valid_ec_id(&ec_id) { + return Err(Box::new(json_error( + StatusCode::BAD_REQUEST, + "invalid EC ID format (expected {64hex}.{6alnum})", + ))); + } + + Ok(ec_id) +} + +/// Builds the success payload from a raw KV lookup. +/// +/// Parse failures are reported in the payload rather than propagated, so +/// corrupt entries remain inspectable. +fn build_lookup_response( + registry: &PartnerRegistry, + store_name: &str, + ec_id: String, + lookup: &EcKvLookup, +) -> AdminEcLookupResponse { + let mut payload = AdminEcLookupResponse { + ec_id, + store: store_name.to_owned(), + generation: lookup.generation, + tombstone: None, + entry: None, + entry_error: None, + raw_body: None, + metadata: None, + metadata_error: None, + auction: None, + }; + + match serde_json::from_slice::(&lookup.body) { + Ok(entry) => { + payload.tombstone = Some(!entry.consent.ok); + match entry.validate() { + Ok(()) => payload.auction = Some(build_auction_view(registry, &entry)), + Err(message) => { + payload.entry_error = Some(format!( + "entry failed validation (auction reads fail closed \ + and attach no EIDs): {message}" + )); + } + } + payload.entry = Some(entry_json_with_iso_timestamps(&entry)); + } + Err(error) => { + payload.entry_error = Some(format!("failed to deserialize entry: {error}")); + payload.raw_body = Some(String::from_utf8_lossy(&lookup.body).into_owned()); + } + } + + match &lookup.metadata { + None => {} + Some(bytes) => match serde_json::from_slice::(bytes) { + Ok(metadata) => { + payload.metadata = + Some(serde_json::to_value(&metadata).expect("should serialize KvMetadata")); + } + Err(error) => { + payload.metadata_error = Some(format!( + "failed to deserialize metadata: {error} (raw: {})", + String::from_utf8_lossy(bytes) + )); + } + }, + } + + payload +} + +/// Serializes an entry, adding derived ISO 8601 companions next to the +/// stored unix-seconds timestamps (`created_iso`, `consent.updated_iso`). +/// +/// The stored numeric values stay untouched so the echo remains faithful to +/// what is in KV; the ISO fields exist purely for operator readability. +fn entry_json_with_iso_timestamps(entry: &KvEntry) -> JsonValue { + let mut entry_json = serde_json::to_value(entry).expect("should serialize KvEntry"); + + if let Some(object) = entry_json.as_object_mut() { + if let Some(iso) = iso_timestamp(entry.created) { + object.insert("created_iso".to_owned(), JsonValue::String(iso)); + } + if let Some(consent) = object.get_mut("consent").and_then(JsonValue::as_object_mut) + && let Some(iso) = iso_timestamp(entry.consent.updated) + { + consent.insert("updated_iso".to_owned(), JsonValue::String(iso)); + } + } + + entry_json +} + +/// Formats a unix-seconds timestamp as ISO 8601 (`yyyy-MM-ddTHH:mm:ss.SSSZ`). +/// +/// Returns `None` for values outside the representable date range. +fn iso_timestamp(unix_seconds: u64) -> Option { + let unix_seconds = i64::try_from(unix_seconds).ok()?; + chrono::DateTime::from_timestamp(unix_seconds, 0) + .map(|datetime| datetime.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()) +} + +/// Derives the auction EID view for a valid entry, mirroring the filters in +/// [`resolve_partner_ids`] and reporting why each stored ID was skipped. +fn build_auction_view(registry: &PartnerRegistry, entry: &KvEntry) -> AuctionEidsView { + let resolved = resolve_partner_ids(registry, entry); + let eids = to_eids(&resolved); + + let mut skipped = Vec::new(); + for (source_domain, partner_uid) in &entry.ids { + let reason = if partner_uid.uid.is_empty() { + "empty_uid" + } else { + match registry.get(source_domain) { + None => "not_in_registry", + Some(partner) if !partner.bidstream_enabled => "bidstream_disabled", + Some(_) => continue, + } + }; + skipped.push(SkippedPartnerId { + source_domain: source_domain.clone(), + reason, + }); + } + + AuctionEidsView { eids, skipped } +} + +/// Admin EIDs echo payload. +#[derive(Debug, Serialize)] +struct AdminEidsResponse { + /// Whether a `ts-eids` cookie was present on the request. + cookie_present: bool, + /// EIDs parsed from the `ts-eids` cookie. Absent when the cookie is + /// missing or failed to parse. + #[serde(skip_serializing_if = "Option::is_none")] + eids: Option>, + /// Parse failure detail when the `ts-eids` cookie could not be decoded. + #[serde(skip_serializing_if = "Option::is_none")] + parse_error: Option, + /// Whether a `sharedId` cookie was present on the request. + sharedid_present: bool, + /// Number of partners configured in the registry. + partners_configured: usize, + /// Preview of what cookie ingestion would write into the EC entry's + /// `ids` map on a navigation carrying these cookies. + ingest: IngestPreview, +} + +/// What cookie ingestion would store, and what it would drop. +#[derive(Debug, Serialize)] +struct IngestPreview { + /// Cookie sources matched to a configured partner, with the UID that + /// would be stored (deduplicated exactly like the ingestion path). + matched: Vec, + /// `ts-eids` sources with no configured partner; dropped on ingestion. + unmatched: Vec, +} + +/// A cookie-derived partner UID that ingestion would store. +#[derive(Debug, Serialize)] +struct MatchedPartnerId { + /// Partner namespace key in the EC entry's `ids` map. + source_domain: String, + /// The UID that would be stored. + uid: String, +} + +/// Handles `GET /_ts/admin/eids`. +/// +/// Echoes the request's `ts-eids` and `sharedId` cookies: the parsed EID +/// list plus a preview of what cookie ingestion would write into the EC +/// entry's `ids` map given the configured partner registry. Pure request +/// inspection — no KV access — so it works on every adapter. +/// +/// Always responds `200 OK`; missing or malformed cookies are reported in +/// the payload instead of as errors. +/// +/// # Errors +/// +/// Returns [`TrustedServerError::Configuration`] only when the response +/// payload fails JSON serialization. +pub fn handle_admin_eids_lookup( + registry: &PartnerRegistry, + req: &Request, +) -> Result, Report> { + let eids_cookie = extract_cookie_value(req, COOKIE_TS_EIDS); + let sharedid_cookie = extract_cookie_value(req, COOKIE_SHAREDID); + + let (eids, parse_error) = match &eids_cookie { + None => (None, None), + Some(value) => match parse_prebid_eids_cookie(value) { + Ok(parsed) => (Some(parsed), None), + Err(error) => ( + None, + Some(format!("failed to parse ts-eids cookie: {error}")), + ), + }, + }; + + // Mirror the ingestion path (`ingest_eid_cookies`): collect matches from + // both cookies, then dedupe the same way so the preview reports exactly + // what a navigation would store. + let mut updates = Vec::new(); + if let Some(value) = &eids_cookie { + updates.extend(collect_prebid_eid_updates(value, registry)); + } + if let Some(value) = &sharedid_cookie + && let Some(update) = collect_sharedid_update(value, registry) + { + updates.push(update); + } + let matched = dedupe_partner_updates(updates) + .into_iter() + .map(|update| MatchedPartnerId { + source_domain: update.partner_id, + uid: update.uid, + }) + .collect(); + + let unmatched = eids + .as_ref() + .map(|parsed| { + parsed + .iter() + .filter(|eid| registry.find_by_source_domain(&eid.source).is_none()) + .map(|eid| eid.source.clone()) + .collect() + }) + .unwrap_or_default(); + + let payload = AdminEidsResponse { + cookie_present: eids_cookie.is_some(), + eids, + parse_error, + sharedid_present: sharedid_cookie.is_some(), + partners_configured: registry.len(), + ingest: IngestPreview { matched, unmatched }, + }; + + let body = + serde_json::to_string(&payload).change_context(TrustedServerError::Configuration { + message: "failed to serialize admin EIDs response".to_owned(), + })?; + Ok(json_response(StatusCode::OK, body)) +} + +fn extract_cookie_value(req: &Request, name: &str) -> Option { + let cookie_header = req + .headers() + .get(header::COOKIE) + .and_then(|value| value.to_str().ok())?; + for pair in cookie_header.split(';') { + let pair = pair.trim(); + if let Some((key, value)) = pair.split_once('=') + && key.trim() == name + { + return Some(value.trim().to_owned()); + } + } + None +} + +fn json_error(status: StatusCode, message: &str) -> Response { + let body = serde_json::json!({ "error": message }); + json_response(status, body.to_string()) +} + +fn json_response(status: StatusCode, body: String) -> Response { + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .header(header::CACHE_CONTROL, "no-store") + .body(EdgeBody::from(body.into_bytes())) + .expect("should build admin EC lookup response") +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; + + use super::*; + use crate::ec::kv_backend::test_support::InMemoryEcKv; + use crate::ec::kv_backend::{EcKvStore as _, EcKvWrite, EcKvWriteMode}; + use crate::ec::kv_types::KvPartnerId; + use crate::redacted::Redacted; + use crate::settings::EcPartner; + + fn test_ec_id() -> String { + format!("{}.abc123", "a".repeat(64)) + } + + fn make_test_partner(source_domain: &str, bidstream_enabled: bool) -> EcPartner { + EcPartner { + name: format!("Partner {source_domain}"), + source_domain: source_domain.to_owned(), + openrtb_atype: EcPartner::default_openrtb_atype(), + bidstream_enabled, + api_token: Redacted::new(format!("test-token-{source_domain:-<32}")), + batch_rate_limit: EcPartner::default_batch_rate_limit(), + pull_sync_enabled: false, + pull_sync_url: None, + pull_sync_allowed_domains: vec![], + pull_sync_ttl_sec: EcPartner::default_pull_sync_ttl_sec(), + pull_sync_rate_limit: EcPartner::default_pull_sync_rate_limit(), + ts_pull_token: None, + } + } + + fn test_registry() -> PartnerRegistry { + PartnerRegistry::from_config(&[ + make_test_partner("bidstream.example", true), + make_test_partner("disabled.example", false), + ]) + .expect("should build test partner registry") + } + + fn get_request(path: &str) -> Request { + Request::builder() + .method("GET") + .uri(format!("https://edge.example.com{path}")) + .body(EdgeBody::empty()) + .expect("should build test request") + } + + fn get_request_with_cookie(path: &str, cookie: &str) -> Request { + Request::builder() + .method("GET") + .uri(format!("https://edge.example.com{path}")) + .header(header::COOKIE, cookie) + .body(EdgeBody::empty()) + .expect("should build test request") + } + + fn kv_with_entry(ec_id: &str, entry: &KvEntry) -> KvIdentityGraph { + let kv = KvIdentityGraph::in_memory("test-store"); + kv.create(ec_id, entry).expect("should seed KV entry"); + kv + } + + fn kv_with_raw_body(ec_id: &str, body: &str) -> KvIdentityGraph { + let metadata = serde_json::json!({ "ok": true, "country": "US", "v": 1 }).to_string(); + let store = InMemoryEcKv::new("test-store"); + store + .insert( + ec_id, + EcKvWrite { + body, + metadata: &metadata, + ttl: Duration::from_secs(60), + mode: EcKvWriteMode::Add, + }, + ) + .expect("should seed raw KV body"); + KvIdentityGraph::new(store) + } + + fn response_json(response: Response) -> JsonValue { + serde_json::from_slice(&response.into_body().into_bytes().unwrap_or_default()) + .expect("should parse response body as JSON") + } + + fn sample_entry() -> KvEntry { + let mut entry = KvEntry::minimal("bidstream.example", "uid-live", 1_741_824_000); + entry.ids.insert( + "disabled.example".to_owned(), + KvPartnerId { + uid: "uid-disabled".to_owned(), + }, + ); + entry.ids.insert( + "unknown.example".to_owned(), + KvPartnerId { + uid: "uid-unknown".to_owned(), + }, + ); + entry + } + + #[test] + fn returns_entry_with_auction_view() { + let ec_id = test_ec_id(); + let kv = kv_with_entry(&ec_id, &sample_entry()); + let req = get_request(&format!("/_ts/admin/ec/{ec_id}")); + + let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), &req) + .expect("should handle lookup"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("no-store"), + "should send no-store on admin responses" + ); + + let json = response_json(response); + assert_eq!(json["ec_id"], ec_id.as_str()); + assert_eq!(json["store"], "test-store"); + assert_eq!(json["tombstone"], false); + assert_eq!( + json["entry"]["ids"]["bidstream.example"]["uid"], "uid-live", + "should echo the stored entry verbatim" + ); + assert_eq!( + json["entry"]["created"], 1_741_824_000_u64, + "should keep the stored unix-seconds timestamp" + ); + assert_eq!( + json["entry"]["created_iso"], "2025-03-13T00:00:00.000Z", + "should add an ISO 8601 companion for created" + ); + assert_eq!( + json["entry"]["consent"]["updated_iso"], "2025-03-13T00:00:00.000Z", + "should add an ISO 8601 companion for consent.updated" + ); + + let eids = json["auction"]["eids"] + .as_array() + .expect("should have auction eids"); + assert_eq!(eids.len(), 1, "should resolve only the bidstream partner"); + assert_eq!(eids[0]["source"], "bidstream.example"); + assert_eq!(eids[0]["uids"][0]["id"], "uid-live"); + + let skipped = json["auction"]["skipped"] + .as_array() + .expect("should have skipped list"); + assert_eq!(skipped.len(), 2, "should report both filtered partners"); + assert!( + skipped + .iter() + .any(|s| s["source_domain"] == "disabled.example" + && s["reason"] == "bidstream_disabled"), + "should report the bidstream-disabled partner" + ); + assert!( + skipped.iter().any( + |s| s["source_domain"] == "unknown.example" && s["reason"] == "not_in_registry" + ), + "should report the unregistered partner" + ); + } + + #[test] + fn reports_tombstone_entries() { + let ec_id = test_ec_id(); + let kv = KvIdentityGraph::in_memory("test-store"); + kv.write_withdrawal_tombstone(&ec_id) + .expect("should write tombstone"); + let req = get_request(&format!("/_ts/admin/ec/{ec_id}")); + + let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), &req) + .expect("should handle lookup"); + + assert_eq!(response.status(), StatusCode::OK); + let json = response_json(response); + assert_eq!(json["tombstone"], true, "should flag tombstone entries"); + assert!( + json["auction"]["eids"] + .as_array() + .expect("should have auction eids") + .is_empty(), + "tombstone should resolve no EIDs" + ); + } + + #[test] + fn missing_entry_returns_404() { + let kv = KvIdentityGraph::in_memory("test-store"); + let req = get_request(&format!("/_ts/admin/ec/{}", test_ec_id())); + + let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), &req) + .expect("should handle lookup"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[test] + fn invalid_id_returns_400() { + let kv = KvIdentityGraph::in_memory("test-store"); + let req = get_request("/_ts/admin/ec/not-a-valid-id"); + + let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), &req) + .expect("should handle lookup"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[test] + fn corrupt_entry_returns_parse_error_and_raw_body() { + let ec_id = test_ec_id(); + let kv = kv_with_raw_body(&ec_id, "not json at all"); + let req = get_request(&format!("/_ts/admin/ec/{ec_id}")); + + let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), &req) + .expect("should handle lookup"); + + assert_eq!( + response.status(), + StatusCode::OK, + "corrupt entries should be inspectable, not opaque errors" + ); + let json = response_json(response); + assert!( + json["entry_error"] + .as_str() + .expect("should have entry_error") + .contains("failed to deserialize"), + "should describe the parse failure" + ); + assert_eq!(json["raw_body"], "not json at all"); + assert!(json.get("entry").is_none(), "should omit unparsed entry"); + assert!( + json.get("auction").is_none(), + "should omit auction view for unparseable entries" + ); + assert_eq!( + json["metadata"]["country"], "US", + "should still parse the stored metadata" + ); + } + + #[test] + fn invalid_schema_version_reports_validation_error() { + let ec_id = test_ec_id(); + let body = serde_json::json!({ + "v": 99, + "created": 1000, + "consent": { "ok": true, "updated": 1000 }, + "geo": { "country": "US" } + }) + .to_string(); + let kv = kv_with_raw_body(&ec_id, &body); + let req = get_request(&format!("/_ts/admin/ec/{ec_id}")); + + let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), &req) + .expect("should handle lookup"); + + assert_eq!(response.status(), StatusCode::OK); + let json = response_json(response); + assert!( + json["entry_error"] + .as_str() + .expect("should have entry_error") + .contains("failed validation"), + "should describe the validation failure" + ); + assert_eq!(json["entry"]["v"], 99, "should still show the parsed entry"); + assert!( + json.get("auction").is_none(), + "should omit auction view when the auction read would fail closed" + ); + } + + #[test] + fn bare_route_uses_ts_ec_cookie() { + let ec_id = test_ec_id(); + let kv = kv_with_entry(&ec_id, &sample_entry()); + let req = get_request_with_cookie("/_ts/admin/ec", &format!("other=1; ts-ec={ec_id}; x=2")); + + let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), &req) + .expect("should handle lookup"); + + assert_eq!(response.status(), StatusCode::OK); + let json = response_json(response); + assert_eq!( + json["ec_id"], + ec_id.as_str(), + "should resolve the EC ID from the ts-ec cookie" + ); + } + + #[test] + fn bare_route_without_cookie_returns_404() { + let kv = KvIdentityGraph::in_memory("test-store"); + let req = get_request("/_ts/admin/ec"); + + let response = handle_admin_ec_lookup(Some(&kv), &test_registry(), &req) + .expect("should handle lookup"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let json = response_json(response); + assert!( + json["error"] + .as_str() + .expect("should have error message") + .contains("ts-ec cookie"), + "should explain the missing cookie" + ); + } + + #[test] + fn missing_identity_graph_returns_501() { + let req = get_request(&format!("/_ts/admin/ec/{}", test_ec_id())); + + let response = + handle_admin_ec_lookup(None, &test_registry(), &req).expect("should handle lookup"); + + assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); + } + + #[test] + fn kv_read_failure_propagates() { + let kv = KvIdentityGraph::failing("broken-store"); + let req = get_request(&format!("/_ts/admin/ec/{}", test_ec_id())); + + let result = handle_admin_ec_lookup(Some(&kv), &test_registry(), &req); + + assert!(result.is_err(), "should propagate KV read failures"); + } + + fn eids_cookie_for(entries: &serde_json::Value) -> String { + BASE64.encode(entries.to_string()) + } + + #[test] + fn eids_lookup_without_cookies_returns_empty_payload() { + let req = get_request("/_ts/admin/eids"); + + let response = + handle_admin_eids_lookup(&test_registry(), &req).expect("should handle eids lookup"); + + assert_eq!(response.status(), StatusCode::OK); + let json = response_json(response); + assert_eq!(json["cookie_present"], false); + assert_eq!(json["sharedid_present"], false); + assert_eq!(json["partners_configured"], 2); + assert!( + json["ingest"]["matched"] + .as_array() + .expect("should have matched list") + .is_empty(), + "should preview no matches without cookies" + ); + } + + #[test] + fn eids_lookup_parses_cookie_and_previews_ingestion() { + let cookie = eids_cookie_for(&serde_json::json!([ + { + "source": "bidstream.example", + "uids": [{ "id": "uid-configured", "atype": 1 }] + }, + { + "source": "unknown.example", + "uids": [{ "id": "uid-unknown", "atype": 1 }] + } + ])); + let req = get_request_with_cookie("/_ts/admin/eids", &format!("ts-eids={cookie}")); + + let response = + handle_admin_eids_lookup(&test_registry(), &req).expect("should handle eids lookup"); + + assert_eq!(response.status(), StatusCode::OK); + let json = response_json(response); + assert_eq!(json["cookie_present"], true); + assert_eq!( + json["eids"] + .as_array() + .expect("should have parsed eids") + .len(), + 2, + "should echo both parsed EID sources" + ); + + let matched = json["ingest"]["matched"] + .as_array() + .expect("should have matched list"); + assert_eq!(matched.len(), 1, "should match only the configured partner"); + assert_eq!(matched[0]["source_domain"], "bidstream.example"); + assert_eq!(matched[0]["uid"], "uid-configured"); + + let unmatched = json["ingest"]["unmatched"] + .as_array() + .expect("should have unmatched list"); + assert_eq!(unmatched.len(), 1, "should report the unregistered source"); + assert_eq!(unmatched[0], "unknown.example"); + } + + #[test] + fn eids_lookup_reports_parse_error() { + let req = get_request_with_cookie("/_ts/admin/eids", "ts-eids=!!!not-base64!!!"); + + let response = + handle_admin_eids_lookup(&test_registry(), &req).expect("should handle eids lookup"); + + assert_eq!( + response.status(), + StatusCode::OK, + "malformed cookies should be reported, not errored" + ); + let json = response_json(response); + assert_eq!(json["cookie_present"], true); + assert!( + json["parse_error"] + .as_str() + .expect("should have parse_error") + .contains("ts-eids"), + "should describe the parse failure" + ); + assert!(json.get("eids").is_none(), "should omit unparsed eids"); + assert!( + json["ingest"]["matched"] + .as_array() + .expect("should have matched list") + .is_empty(), + "unparseable cookie should preview no matches" + ); + } + + #[test] + fn eids_lookup_includes_sharedid_match() { + let registry = PartnerRegistry::from_config(&[ + make_test_partner("bidstream.example", true), + make_test_partner("sharedid.org", true), + ]) + .expect("should build sharedid test registry"); + let req = get_request_with_cookie("/_ts/admin/eids", "sharedId=shared-uid-123"); + + let response = + handle_admin_eids_lookup(®istry, &req).expect("should handle eids lookup"); + + assert_eq!(response.status(), StatusCode::OK); + let json = response_json(response); + assert_eq!(json["cookie_present"], false); + assert_eq!(json["sharedid_present"], true); + + let matched = json["ingest"]["matched"] + .as_array() + .expect("should have matched list"); + assert_eq!(matched.len(), 1, "should match the sharedid partner"); + assert_eq!(matched[0]["source_domain"], "sharedid.org"); + assert_eq!(matched[0]["uid"], "shared-uid-123"); + } +} diff --git a/crates/trusted-server-core/src/ec/kv.rs b/crates/trusted-server-core/src/ec/kv.rs index 7be76755..3572581c 100644 --- a/crates/trusted-server-core/src/ec/kv.rs +++ b/crates/trusted-server-core/src/ec/kv.rs @@ -21,7 +21,7 @@ use crate::error::TrustedServerError; use super::current_timestamp; use super::generation::ec_hash; -use super::kv_backend::{EcKvStore, EcKvWrite, EcKvWriteMode, EcKvWriteOutcome}; +use super::kv_backend::{EcKvLookup, EcKvStore, EcKvWrite, EcKvWriteMode, EcKvWriteOutcome}; use super::kv_types::{KvEntry, KvMetadata, KvNetwork}; use super::log_id; @@ -170,6 +170,25 @@ impl KvIdentityGraph { Ok((body, meta_str)) } + /// Reads the raw stored body, metadata, and generation for an EC ID key. + /// + /// Unlike [`Self::get`], the entry body is returned without + /// deserialization or validation, so corrupt or legacy-schema records can + /// still be inspected instead of failing closed. Used by the admin EC + /// lookup endpoint. + /// + /// Returns `Ok(None)` when the key does not exist. + /// + /// # Errors + /// + /// Returns [`TrustedServerError::KvStore`] on store open or read failure. + pub fn lookup_raw( + &self, + ec_id: &str, + ) -> Result, Report> { + self.store.lookup(ec_id) + } + /// Reads the full entry and its generation marker for CAS writes. /// /// Returns `Ok(None)` when the key does not exist. diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 408ea9b3..50eda4d6 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -31,6 +31,7 @@ mod auth; +pub mod admin; pub mod batch_sync; pub mod consent; pub mod cookies; diff --git a/crates/trusted-server-core/src/ec/prebid_eids.rs b/crates/trusted-server-core/src/ec/prebid_eids.rs index 2f99c818..0d7167e6 100644 --- a/crates/trusted-server-core/src/ec/prebid_eids.rs +++ b/crates/trusted-server-core/src/ec/prebid_eids.rs @@ -175,7 +175,7 @@ fn ingest_eid_cookies_with_writer( } } -fn collect_prebid_eid_updates( +pub(crate) fn collect_prebid_eid_updates( cookie_value: &str, registry: &PartnerRegistry, ) -> Vec { @@ -209,7 +209,7 @@ fn collect_prebid_eid_updates( updates } -fn dedupe_partner_updates(updates: Vec) -> Vec { +pub(crate) fn dedupe_partner_updates(updates: Vec) -> Vec { let mut latest = std::collections::BTreeMap::new(); for update in updates { latest.insert(update.partner_id, update.uid); @@ -246,7 +246,7 @@ pub fn ingest_sharedid_cookie( ingest_eid_cookies(None, Some(cookie_value), ec_id, kv, registry); } -fn collect_sharedid_update( +pub(crate) fn collect_sharedid_update( cookie_value: &str, registry: &PartnerRegistry, ) -> Option { diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index c49e9968..851f8511 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -2205,9 +2205,19 @@ impl Settings { /// where any of these paths lack a matching handler, ensuring admin /// endpoints are always protected by authentication. /// Update [`ADMIN_ENDPOINTS`](Self::ADMIN_ENDPOINTS) when adding new - /// admin routes to `crates/trusted-server-adapter-fastly/src/main.rs`. - pub(crate) const ADMIN_ENDPOINTS: &[&str] = - &["/_ts/admin/keys/rotate", "/_ts/admin/keys/deactivate"]; + /// admin routes to `crates/trusted-server-adapter-fastly/src/app.rs`. + /// + /// The `/_ts/admin/ec/{id}` entry is the literal router pattern; handler + /// path regexes are matched against it verbatim, so prefix-style admin + /// regexes (e.g. `^/_ts/admin`) cover it while regexes too narrow to + /// cover the parameterized route are rejected fail-closed. + pub(crate) const ADMIN_ENDPOINTS: &[&str] = &[ + "/_ts/admin/keys/rotate", + "/_ts/admin/keys/deactivate", + "/_ts/admin/ec", + "/_ts/admin/ec/{id}", + "/_ts/admin/eids", + ]; /// Returns admin endpoint paths that no configured handler covers. /// @@ -5294,7 +5304,13 @@ origin_host_header_overide = "www.example.com""#, .expect("should check admin coverage"); assert_eq!( uncovered, - vec!["/_ts/admin/keys/rotate", "/_ts/admin/keys/deactivate"], + vec![ + "/_ts/admin/keys/rotate", + "/_ts/admin/keys/deactivate", + "/_ts/admin/ec", + "/_ts/admin/ec/{id}", + "/_ts/admin/eids", + ], "should report every admin endpoint as uncovered" ); } @@ -5328,7 +5344,12 @@ origin_host_header_overide = "www.example.com""#, .expect("should check admin coverage"); assert_eq!( uncovered, - vec!["/_ts/admin/keys/deactivate"], + vec![ + "/_ts/admin/keys/deactivate", + "/_ts/admin/ec", + "/_ts/admin/ec/{id}", + "/_ts/admin/eids", + ], "should detect the admin endpoints not covered by the narrow handler" ); }