Skip to content
Open
99 changes: 99 additions & 0 deletions crates/trusted-server-core/src/auction/endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,12 @@ pub async fn handle_auction(
} else {
None
};
// Carry the full request-local EID set to response finalization so KV
// ingestion uses what this request actually sent, not just whatever fits
// in the size-capped `ts-eids` cookie (see `ec::finalize::ec_finalize_response`).
if let Some(eids) = &client_eids {
ec_context.set_client_eids(eids.clone());
}

// Resolve partner EIDs from the KV identity graph when the user has a valid
// EC and both KV and partner stores are available. Gate the read on a
Expand Down Expand Up @@ -797,6 +803,99 @@ mod tests {
);
}

#[tokio::test]
async fn auction_body_eids_reach_kv_even_when_the_ts_eids_cookie_is_absent() {
// Regression test for #1184: `/auction` sends every EID it has in the
// request body, but response finalization used to ingest identity
// graph updates only from the `ts-eids` cookie — which the browser
// caps in size and may not have sent at all. `handle_auction` must
// hand its parsed body EIDs to `ec_context` so finalization ingests
// them regardless of what the cookie carried.
let settings = create_test_settings();
let mut orchestrator = AuctionOrchestrator::new(AuctionConfig {
enabled: true,
providers: AuctionConfig::legacy_provider_map(&["eid_capturing_provider"]),
timeout_ms: 2000,
mediator: None,
..Default::default()
});
orchestrator.register_provider(Arc::new(EidCapturingProvider {
had_eids: Arc::new(std::sync::Mutex::new(None)),
}));
let registry = PartnerRegistry::from_config(&[counting_test_partner("id5-sync.com")])
.expect("should build partner registry");

let graph = KvIdentityGraph::in_memory("test_store");
let ec_id = format!("{}.eidbdy", "a".repeat(64));
let mut live = crate::ec::kv_types::KvEntry::tombstone(1000);
live.consent.ok = true;
graph.create(&ec_id, &live).expect("should seed live row");

let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, Some(&ec_id));
ec_context.set_eid_sync_source(crate::ec::EidSyncSource::Auction);
let req = Request::builder()
.method("POST")
.uri("https://test-publisher.com/auction")
.body(EdgeBody::from(
serde_json::to_vec(&json!({
"adUnits": [
{
"code": "div-gpt-ad-1",
"mediaTypes": { "banner": { "sizes": [[300, 250]] } }
}
],
"eids": [
{"source": "id5-sync.com", "uids": [{"id": "ID5_from_body", "atype": 1}]}
]
}))
.expect("should serialize body"),
))
.expect("should build auction request");

// The capturing provider deliberately fails its launch; identity
// resolution — the subject of this test — completes before dispatch.
let _ = handle_auction(
&settings,
&orchestrator,
Some(&graph),
Some(&registry),
&mut ec_context,
&noop_services(),
req,
)
.await;

assert_eq!(
ec_context.client_eids().map(|eids| eids
.iter()
.map(|eid| eid.source.as_str())
.collect::<Vec<_>>()),
Some(vec!["id5-sync.com"]),
"the endpoint must hand its parsed body EIDs to the request context"
);

let mut response = http::Response::new(EdgeBody::empty());
crate::ec::finalize::ec_finalize_response(
&settings,
&mut ec_context,
Some(&graph),
&registry,
None, // No `ts-eids` cookie on this request at all.
None,
&mut response,
);

let (stored, _) = graph
.get(&ec_id)
.expect("should read store")
.expect("row should exist");
assert_eq!(
stored.ids.get("id5-sync.com").map(|id| id.uid.as_str()),
Some("ID5_from_body"),
"the body's EID must be ingested into KV without a ts-eids cookie"
);
}

/// Provider that fails the test if it is ever contacted. Used to prove the
/// `/auction` consent gate short-circuits before any outbound bid request.
struct PanicOnBidProvider;
Expand Down
113 changes: 112 additions & 1 deletion crates/trusted-server-core/src/consent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,29 @@ pub fn gate_eids_by_consent<T>(
}
}

/// Returns whether consent allows EIDs to be written to the identity graph.
///
/// Applies the same decision as [`gate_eids_by_consent`] for a present
/// [`ConsentContext`], without logging: the effective TCF consent (standalone
/// TC string or GPP EU TCF section) must grant Purpose 1 (storage/access)
/// **and** Purpose 4 (personalized ads). With no TCF data, EIDs are allowed
/// only when GDPR does not apply.
///
/// A TCF signal means TCF rules apply. In decode mode a TC signal always sets
/// `gdpr_applies`, so checking the decoded TCF first is equivalent to checking
/// `gdpr_applies` first. In [`ConsentMode::Proxy`] the TC string is not
/// decoded, so a request carrying a TC cookie has no effective TCF and GDPR
/// applies: EID writes are withheld even when Purpose 4 is granted. This
/// fails closed, matching how [`gate_eids_by_consent`] strips egress EIDs in
/// proxy mode.
#[must_use]
pub(crate) fn allows_eid_persistence(ctx: &ConsentContext) -> bool {
Comment thread
dhruv8sh marked this conversation as resolved.
match effective_tcf(ctx) {
Some(tcf) => allows_eid_transmission(tcf),
None => !ctx.gdpr_applies,
Comment thread
dhruv8sh marked this conversation as resolved.
}
}

// ---------------------------------------------------------------------------
// EC consent gating
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -659,7 +682,7 @@ mod tests {
use http::Request;

use super::{
ConsentPipelineInput, allows_ec_creation, apply_expiration_check,
ConsentPipelineInput, allows_ec_creation, allows_eid_persistence, apply_expiration_check,
apply_tcf_conflict_resolution, build_consent_context, build_context_from_signals,
consent_allows_server_side_auction, gate_eids_by_consent, has_explicit_ec_withdrawal,
};
Expand Down Expand Up @@ -762,6 +785,94 @@ mod tests {
}
}

#[test]
fn eid_persistence_matches_gate_eids_by_consent() {
let gpp_only = |allows_eids: bool| GppConsent {
version: 1,
section_ids: vec![2],
eu_tcf: Some(make_tcf(0, allows_eids)),
us_sale_opt_out: None,
};
let cases = [
(
"Purpose 1 + 4 granted",
ConsentContext {
gdpr_applies: true,
tcf: Some(make_tcf(0, true)),
..ConsentContext::default()
},
true,
),
(
"Purpose 1 only",
ConsentContext {
gdpr_applies: true,
tcf: Some(make_tcf(0, false)),
..ConsentContext::default()
},
false,
),
(
"Purpose 4 only",
ConsentContext {
gdpr_applies: true,
tcf: Some(
TcfBuilder::new()
.with_storage(false)
.with_personalized_ads(true)
.build(),
),
..ConsentContext::default()
},
false,
),
(
"GPP EU TCF section grants Purpose 1 + 4",
ConsentContext {
gdpr_applies: true,
gpp: Some(gpp_only(true)),
..ConsentContext::default()
},
true,
),
(
"GPP EU TCF section denies Purpose 4",
ConsentContext {
gdpr_applies: true,
gpp: Some(gpp_only(false)),
..ConsentContext::default()
},
false,
),
(
"GDPR applies without TCF",
ConsentContext {
gdpr_applies: true,
..ConsentContext::default()
},
false,
),
(
"GDPR does not apply without TCF",
ConsentContext::default(),
true,
),
];

for (label, ctx, expected) in cases {
assert_eq!(
allows_eid_persistence(&ctx),
expected,
"should decide EID persistence for case: {label}"
);
assert_eq!(
gate_eids_by_consent(Some(vec![1_u8]), Some(&ctx)).is_some(),
expected,
"should match gate_eids_by_consent for case: {label}"
);
}
}

#[test]
fn auction_allowed_for_known_non_gdpr_jurisdiction_without_tcf_signal() {
let ctx = ConsentContext {
Expand Down
6 changes: 4 additions & 2 deletions crates/trusted-server-core/src/ec/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -600,8 +600,10 @@ pub fn handle_admin_eids_lookup(
};

// Collect matches from both cookies, then dedupe the same way as response
// finalization so the preview reports exactly what an eligible request
// would store.
// finalization. The preview is cookie-only and ungated: it ignores the
// `/auction` request-body EIDs, the consent gate on identity-graph writes,
// and UIDs already stored in KV, so it lists candidate matches rather than
// exactly what a request would store.
if let Some(value) = &sharedid_cookie
&& let Some(update) = collect_sharedid_update(value, registry)
{
Expand Down
Loading
Loading