Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Resolved
match effective_tcf(ctx) {
Some(tcf) => allows_eid_transmission(tcf),
None => !ctx.gdpr_applies,
Comment thread
dhruv8sh marked this conversation as resolved.
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