Skip to content

Commit d5be3d9

Browse files
committed
Add admin endpoint to look up EC entries by id
Adds GET /_ts/admin/ec/{id} (explicit EC ID) and GET /_ts/admin/ec (EC ID from the caller's ts-ec cookie) so operators can inspect EC identity graph entries and debug KV-to-auction EID propagation. The core handler returns the stored KvEntry verbatim (including raw consent strings and partner UIDs), the KV metadata mirror, the store generation marker, and a derived auction view showing exactly which EIDs the auction would attach and why each stored partner ID was skipped (empty_uid, not_in_registry, bidstream_disabled). Corrupt entries are returned with the parse error and raw body via the new KvIdentityGraph::lookup_raw instead of failing closed. The routes join Settings::ADMIN_ENDPOINTS so startup validation rejects configs whose basic-auth handler regex does not cover them. The EC identity graph is Fastly KV backed, so the Axum, Cloudflare, and Spin adapters register the routes to local 501 responses, keeping them off the publisher fallback that would forward the Authorization header to the origin. Closes #921
1 parent 0e5dbb2 commit d5be3d9

11 files changed

Lines changed: 885 additions & 8 deletions

File tree

crates/trusted-server-adapter-axum/src/app.rs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ enum NamedRouteHandler {
252252
TrustedServerDiscovery,
253253
VerifySignature,
254254
AdminNotSupported,
255+
AdminEcNotSupported,
255256
/// Legacy `/admin/keys/*` aliases — denied locally with 404 so they never
256257
/// reach the publisher fallback (which would leak admin credentials).
257258
LegacyAdminDenied,
@@ -279,7 +280,7 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[
279280
Method::DELETE,
280281
];
281282

282-
fn named_routes() -> [NamedRoute; 12] {
283+
fn named_routes() -> [NamedRoute; 14] {
283284
[
284285
NamedRoute {
285286
path: "/.well-known/trusted-server.json",
@@ -304,6 +305,19 @@ fn named_routes() -> [NamedRoute; 12] {
304305
primary_methods: &[Method::POST],
305306
handler: NamedRouteHandler::AdminNotSupported,
306307
},
308+
// Admin EC lookup routes. Registered explicitly (like the key routes
309+
// above) so they never fall through to the publisher fallback, and
310+
// they match `Settings::ADMIN_ENDPOINTS` for auth coverage.
311+
NamedRoute {
312+
path: "/_ts/admin/ec",
313+
primary_methods: &[Method::GET],
314+
handler: NamedRouteHandler::AdminEcNotSupported,
315+
},
316+
NamedRoute {
317+
path: "/_ts/admin/ec/{id}",
318+
primary_methods: &[Method::GET],
319+
handler: NamedRouteHandler::AdminEcNotSupported,
320+
},
307321
// The legacy non-`/_ts` aliases (`/admin/keys/*`) are denied locally with
308322
// a 404, matching the Fastly and Cloudflare adapters: the production
309323
// basic-auth handler regex `^/_ts/admin` does not match them, and letting
@@ -388,6 +402,21 @@ fn named_route_handler(
388402
);
389403
Ok(resp)
390404
}
405+
NamedRouteHandler::AdminEcNotSupported => {
406+
// The EC identity graph is Fastly KV backed; the Axum
407+
// dev server has no store to read.
408+
let body = edgezero_core::body::Body::from(
409+
"Admin EC lookup is not supported on the Axum dev server.\n\
410+
Use the Fastly adapter (via Viceroy or deployed) to inspect EC entries.\n",
411+
);
412+
let mut resp = Response::new(body);
413+
*resp.status_mut() = StatusCode::NOT_IMPLEMENTED;
414+
resp.headers_mut().insert(
415+
header::CONTENT_TYPE,
416+
HeaderValue::from_static("text/plain; charset=utf-8"),
417+
);
418+
Ok(resp)
419+
}
391420
NamedRouteHandler::LegacyAdminDenied => Ok(legacy_admin_alias_denied()),
392421
NamedRouteHandler::Auction => {
393422
// Build the geo-aware EC context so the auction consent

crates/trusted-server-adapter-axum/tests/routes.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ fn all_explicit_routes_are_registered() {
7474
("POST", "/verify-signature"),
7575
("POST", "/_ts/admin/keys/rotate"),
7676
("POST", "/_ts/admin/keys/deactivate"),
77+
("GET", "/_ts/admin/ec"),
78+
("GET", "/_ts/admin/ec/{id}"),
7779
("POST", "/admin/keys/rotate"),
7880
("POST", "/admin/keys/deactivate"),
7981
("POST", "/auction"),
@@ -256,6 +258,38 @@ async fn admin_route_without_credentials_returns_401() {
256258
);
257259
}
258260

261+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
262+
async fn authenticated_admin_ec_routes_return_501() {
263+
// The EC identity graph is Fastly KV backed, so the Axum dev server
264+
// answers the admin EC lookup routes locally with 501 instead of letting
265+
// them fall through to the publisher fallback.
266+
let sample_ec_id = format!("{}.abc123", "a".repeat(64));
267+
for path in [
268+
"/_ts/admin/ec".to_owned(),
269+
format!("/_ts/admin/ec/{sample_ec_id}"),
270+
] {
271+
let mut svc = make_service();
272+
let req = Request::builder()
273+
.method("GET")
274+
.uri(&path)
275+
.header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==")
276+
.body(AxumBody::empty())
277+
.expect("should build request");
278+
let resp = svc
279+
.ready()
280+
.await
281+
.expect("should be ready")
282+
.call(req)
283+
.await
284+
.expect("should respond");
285+
assert_eq!(
286+
resp.status().as_u16(),
287+
501,
288+
"{path} should report that Axum EC lookup is unsupported"
289+
);
290+
}
291+
}
292+
259293
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
260294
async fn legacy_admin_aliases_denied_locally_not_proxied_to_publisher() {
261295
// Regression for the credential-leak finding: the production basic-auth regex

crates/trusted-server-adapter-cloudflare/src/app.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,20 @@ fn admin_key_management_not_supported() -> Response {
242242
response
243243
}
244244

245+
fn admin_ec_lookup_not_supported() -> Response {
246+
let body = edgezero_core::body::Body::from(
247+
"Admin EC lookup is not supported on Cloudflare Workers.\n\
248+
Use the Fastly adapter (via Viceroy or deployed) to inspect EC entries.\n",
249+
);
250+
let mut response = Response::new(body);
251+
*response.status_mut() = StatusCode::NOT_IMPLEMENTED;
252+
response.headers_mut().insert(
253+
header::CONTENT_TYPE,
254+
HeaderValue::from_static("text/plain; charset=utf-8"),
255+
);
256+
response
257+
}
258+
245259
/// Builds the local `404 Not Found` returned for legacy `/admin/keys/*`
246260
/// aliases on the Cloudflare adapter.
247261
///
@@ -461,6 +475,17 @@ fn build_router(state: &Arc<AppState>) -> RouterService {
461475
.post("/_ts/admin/keys/deactivate", |_ctx: RequestContext| async {
462476
Ok::<Response, EdgeError>(admin_key_management_not_supported())
463477
})
478+
// Admin EC lookup routes. Registered explicitly (like the key
479+
// routes above) so they never fall through to the publisher
480+
// fallback, and they match `Settings::ADMIN_ENDPOINTS` for auth
481+
// coverage. The EC identity graph is Fastly KV backed, so this
482+
// adapter has no store to read.
483+
.get("/_ts/admin/ec", |_ctx: RequestContext| async {
484+
Ok::<Response, EdgeError>(admin_ec_lookup_not_supported())
485+
})
486+
.get("/_ts/admin/ec/{id}", |_ctx: RequestContext| async {
487+
Ok::<Response, EdgeError>(admin_ec_lookup_not_supported())
488+
})
464489
.post(
465490
"/auction",
466491
make_handler(Arc::clone(&state), |s, services, req| async move {

crates/trusted-server-adapter-cloudflare/tests/routes.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,8 @@ fn all_explicit_routes_are_registered() {
215215
("POST", "/verify-signature"),
216216
("POST", "/_ts/admin/keys/rotate"),
217217
("POST", "/_ts/admin/keys/deactivate"),
218+
("GET", "/_ts/admin/ec"),
219+
("GET", "/_ts/admin/ec/{id}"),
218220
("POST", "/auction"),
219221
("GET", "/first-party/proxy"),
220222
("GET", "/first-party/click"),
@@ -264,6 +266,32 @@ async fn authenticated_admin_routes_return_501() {
264266
}
265267
}
266268

269+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
270+
async fn authenticated_admin_ec_routes_return_501() {
271+
// The EC identity graph is Fastly KV backed, so Cloudflare answers the
272+
// admin EC lookup routes locally with 501 instead of letting them fall
273+
// through to the publisher fallback.
274+
let sample_ec_id = format!("{}.abc123", "a".repeat(64));
275+
for path in [
276+
"/_ts/admin/ec".to_owned(),
277+
format!("/_ts/admin/ec/{sample_ec_id}"),
278+
] {
279+
let req = request_builder()
280+
.method("GET")
281+
.uri(&path)
282+
.header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==")
283+
.body(edgezero_core::body::Body::empty())
284+
.expect("should build request");
285+
let resp = route(test_router(), req).await;
286+
287+
assert_eq!(
288+
resp.status().as_u16(),
289+
501,
290+
"{path} should report that Cloudflare EC lookup is unsupported"
291+
);
292+
}
293+
}
294+
267295
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
268296
async fn admin_route_without_credentials_returns_401() {
269297
let router = test_router();

crates/trusted-server-adapter-fastly/src/app.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
//! | POST | `/verify-signature` | [`handle_verify_signature`] |
2323
//! | POST | `/_ts/admin/keys/rotate` | [`handle_rotate_key`] |
2424
//! | POST | `/_ts/admin/keys/deactivate` | [`handle_deactivate_key`] |
25+
//! | GET | `/_ts/admin/ec` | [`handle_admin_ec_lookup`] |
26+
//! | GET | `/_ts/admin/ec/{id}` | [`handle_admin_ec_lookup`] |
2527
//! | POST | `/_ts/api/v1/batch-sync` | [`handle_batch_sync`] |
2628
//! | GET | `/_ts/api/v1/identify` | [`handle_identify`] |
2729
//! | GET | `/_ts/set-tester` | [`handle_set_tester`] |
@@ -98,6 +100,7 @@ use trusted_server_core::auction::endpoints::handle_auction;
98100
use trusted_server_core::auction::{AuctionOrchestrator, build_orchestrator};
99101
use trusted_server_core::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS};
100102
use trusted_server_core::ec::EcContext;
103+
use trusted_server_core::ec::admin::handle_admin_ec_lookup;
101104
use trusted_server_core::ec::batch_sync::handle_batch_sync;
102105
use trusted_server_core::ec::consent::ec_consent_withdrawn;
103106
use trusted_server_core::ec::device::DeviceSignals;
@@ -565,6 +568,10 @@ async fn run_named_route(
565568
}
566569
NamedRouteHandler::RotateKey => handle_rotate_key(&state.settings, services, req),
567570
NamedRouteHandler::DeactivateKey => handle_deactivate_key(&state.settings, services, req),
571+
NamedRouteHandler::AdminEcLookup => {
572+
let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?;
573+
handle_admin_ec_lookup(ec.kv_graph.as_ref(), &partner_registry, &req)
574+
}
568575
NamedRouteHandler::LegacyAdminDenied => Ok(legacy_admin_alias_denied()),
569576
NamedRouteHandler::BatchSync => {
570577
// Dispatched by execute_named before EC state is built.
@@ -987,6 +994,7 @@ enum NamedRouteHandler {
987994
VerifySignature,
988995
RotateKey,
989996
DeactivateKey,
997+
AdminEcLookup,
990998
/// Legacy `/admin/keys/*` aliases — denied locally with 404 so they never
991999
/// reach the publisher fallback (which would leak admin credentials).
9921000
LegacyAdminDenied,
@@ -1039,6 +1047,18 @@ const NAMED_ROUTES: &[NamedRoute] = &[
10391047
primary_methods: &[Method::POST],
10401048
handler: NamedRouteHandler::DeactivateKey,
10411049
},
1050+
// Admin EC lookup: the bare route reads the EC ID from the caller's
1051+
// `ts-ec` cookie; the parameterized route takes an explicit EC ID.
1052+
NamedRoute {
1053+
path: "/_ts/admin/ec",
1054+
primary_methods: &[Method::GET],
1055+
handler: NamedRouteHandler::AdminEcLookup,
1056+
},
1057+
NamedRoute {
1058+
path: "/_ts/admin/ec/{id}",
1059+
primary_methods: &[Method::GET],
1060+
handler: NamedRouteHandler::AdminEcLookup,
1061+
},
10421062
// The legacy non-`/_ts` aliases (`/admin/keys/*`) are denied locally with a
10431063
// 404 instead of executing key operations: the production basic-auth handler
10441064
// regex `^/_ts/admin` does not match them, and letting them fall through to
@@ -1624,6 +1644,30 @@ mod tests {
16241644
}
16251645
}
16261646

1647+
#[test]
1648+
fn admin_ec_lookup_routes_are_registered() {
1649+
// Both lookup shapes must be explicitly routed to the admin EC
1650+
// handler: the bare cookie-based route and the parameterized route.
1651+
// Leaving either unrouted would fall through to the publisher
1652+
// fallback, forwarding the caller's `Authorization` header to the
1653+
// origin.
1654+
for path in ["/_ts/admin/ec", "/_ts/admin/ec/{id}"] {
1655+
let route = NAMED_ROUTES
1656+
.iter()
1657+
.find(|route| route.path == path)
1658+
.unwrap_or_else(|| panic!("{path} must be a named route"));
1659+
assert!(
1660+
matches!(route.handler, NamedRouteHandler::AdminEcLookup),
1661+
"{path} must map to the admin EC lookup handler"
1662+
);
1663+
assert_eq!(
1664+
route.primary_methods,
1665+
&[Method::GET],
1666+
"{path} must have GET as its only primary method"
1667+
);
1668+
}
1669+
}
1670+
16271671
#[test]
16281672
fn legacy_admin_aliases_denied_locally_not_proxied_to_publisher() {
16291673
// Regression for the credential-leak finding: with a production-shaped

crates/trusted-server-adapter-spin/src/app.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,12 +141,14 @@ const LEGACY_ADMIN_DENY_METHODS: &[Method] = &[
141141
Method::DELETE,
142142
];
143143

144-
fn named_fallback_paths() -> [(&'static str, &'static [Method]); 12] {
144+
fn named_fallback_paths() -> [(&'static str, &'static [Method]); 14] {
145145
[
146146
("/.well-known/trusted-server.json", &[Method::GET]),
147147
("/verify-signature", &[Method::POST]),
148148
("/_ts/admin/keys/rotate", &[Method::POST]),
149149
("/_ts/admin/keys/deactivate", &[Method::POST]),
150+
("/_ts/admin/ec", &[Method::GET]),
151+
("/_ts/admin/ec/{id}", &[Method::GET]),
150152
("/admin/keys/rotate", LEGACY_ADMIN_DENY_METHODS),
151153
("/admin/keys/deactivate", LEGACY_ADMIN_DENY_METHODS),
152154
("/auction", &[Method::POST]),
@@ -359,6 +361,20 @@ fn admin_key_management_not_supported() -> Response {
359361
response
360362
}
361363

364+
fn admin_ec_lookup_not_supported() -> Response {
365+
let body = edgezero_core::body::Body::from(
366+
"Admin EC lookup is not supported on Fermyon Spin.\n\
367+
Use the Fastly adapter (via Viceroy or deployed) to inspect EC entries.\n",
368+
);
369+
let mut response = Response::new(body);
370+
*response.status_mut() = StatusCode::NOT_IMPLEMENTED;
371+
response.headers_mut().insert(
372+
header::CONTENT_TYPE,
373+
HeaderValue::from_static("text/plain; charset=utf-8"),
374+
);
375+
response
376+
}
377+
362378
// ---------------------------------------------------------------------------
363379
// Error helper
364380
// ---------------------------------------------------------------------------
@@ -511,6 +527,10 @@ fn build_router(state: &Arc<AppState>) -> RouterService {
511527
Ok::<Response, EdgeError>(admin_key_management_not_supported())
512528
};
513529

530+
let admin_ec_not_supported_handler = |_ctx: RequestContext| async {
531+
Ok::<Response, EdgeError>(admin_ec_lookup_not_supported())
532+
};
533+
514534
// /auction
515535
let s = Arc::clone(&state);
516536
let auction_handler = move |ctx: RequestContext| {
@@ -730,6 +750,13 @@ fn build_router(state: &Arc<AppState>) -> RouterService {
730750
// credentials and key-management payloads to the origin.
731751
.post("/_ts/admin/keys/rotate", admin_not_supported_handler)
732752
.post("/_ts/admin/keys/deactivate", admin_not_supported_handler)
753+
// Admin EC lookup routes. Registered explicitly (like the key
754+
// routes above) so they never fall through to the publisher
755+
// fallback, and they match `Settings::ADMIN_ENDPOINTS` for auth
756+
// coverage. The EC identity graph is Fastly KV backed, so this
757+
// adapter has no store to read.
758+
.get("/_ts/admin/ec", admin_ec_not_supported_handler)
759+
.get("/_ts/admin/ec/{id}", admin_ec_not_supported_handler)
733760
.post("/auction", auction_handler)
734761
.get("/__ts/page-bids", page_bids_handler)
735762
.route(

crates/trusted-server-adapter-spin/tests/routes.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,32 @@ async fn authenticated_admin_routes_return_501() {
113113
}
114114
}
115115

116+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
117+
async fn authenticated_admin_ec_routes_return_501() {
118+
// The EC identity graph is Fastly KV backed, so Spin answers the admin
119+
// EC lookup routes locally with 501 instead of letting them fall through
120+
// to the publisher fallback.
121+
let sample_ec_id = format!("{}.abc123", "a".repeat(64));
122+
for path in [
123+
"/_ts/admin/ec".to_owned(),
124+
format!("/_ts/admin/ec/{sample_ec_id}"),
125+
] {
126+
let req = request_builder()
127+
.method("GET")
128+
.uri(&path)
129+
.header("authorization", "Basic YWRtaW46YWRtaW4tcGFzcw==")
130+
.body(edgezero_core::body::Body::empty())
131+
.expect("should build request");
132+
let resp = route(test_router(), req).await;
133+
134+
assert_eq!(
135+
resp.status().as_u16(),
136+
501,
137+
"{path} should report that Spin EC lookup is unsupported"
138+
);
139+
}
140+
}
141+
116142
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
117143
async fn health_route_returns_ok() {
118144
// Parity with the Fastly/Axum adapters: GET /health is a cheap liveness probe

0 commit comments

Comments
 (0)