Skip to content
Open
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
77 changes: 54 additions & 23 deletions crates/trusted-server-adapter-fastly/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ use trusted_server_core::ec::registry::PartnerRegistry;
use trusted_server_core::error::TrustedServerError;
use trusted_server_core::integrations::RequestFilterEffects;
use trusted_server_core::platform::PlatformGeo as _;
use trusted_server_core::platform::RuntimeServices;
use trusted_server_core::proxy::{AssetProxyCachePolicy, stream_asset_body};
use trusted_server_core::response_privacy::TerminalPrivateResponse;
use trusted_server_core::settings::Settings;
Expand Down Expand Up @@ -323,11 +322,44 @@ fn run_edgezero_pull_sync_after_send(
partner_registry: &PartnerRegistry,
ec_state: &EcFinalizeState,
) {
if ec_state.is_real_browser
&& let Some(context) = build_pull_sync_context(&ec_state.ec_context)
{
run_pull_sync_after_send(settings, partner_registry, &context, &ec_state.services);
if !ec_state.is_real_browser {
return;
}

let prepared_context = build_pull_sync_context(&ec_state.ec_context, partner_registry);
let Some((context, kv)) =
prepare_pull_sync_after_send(prepared_context, || require_identity_graph(settings))
else {
return;
};

let limiter = FastlyRateLimiter::new(RATE_COUNTER_NAME);
dispatch_pull_sync(
settings,
&kv,
partner_registry,
&limiter,
&context,
&ec_state.services,
);
}

fn prepare_pull_sync_after_send<F>(
context: Option<PullSyncContext>,
graph_factory: F,
) -> Option<(PullSyncContext, KvIdentityGraph)>
where
F: FnOnce() -> Result<KvIdentityGraph, Report<TrustedServerError>>,
{
let context = context?;
let kv = match graph_factory() {
Ok(kv) => kv,
Err(err) => {
log::debug!("Pull sync: identity graph unavailable, skipping: {err:?}");
return None;
}
};
Some((context, kv))
}

/// Sends a finalized `EdgeZero` response to the client.
Expand Down Expand Up @@ -447,24 +479,6 @@ pub(crate) fn maybe_identity_graph(settings: &Settings) -> Option<KvIdentityGrap
.map(|store_name| KvIdentityGraph::new(FastlyEcKvStore::new(store_name)))
}

fn run_pull_sync_after_send(
settings: &Settings,
partner_registry: &PartnerRegistry,
context: &PullSyncContext,
services: &RuntimeServices,
) {
let kv = match require_identity_graph(settings) {
Ok(kv) => kv,
Err(err) => {
log::debug!("Pull sync: identity graph unavailable, skipping: {err:?}");
return;
}
};

let limiter = FastlyRateLimiter::new(RATE_COUNTER_NAME);
dispatch_pull_sync(settings, &kv, partner_registry, &limiter, context, services);
}

/// Constructs a `KvIdentityGraph` from settings, or returns an error if the
/// `ec_store` config is not set.
pub(crate) fn require_identity_graph(
Expand Down Expand Up @@ -540,6 +554,23 @@ mod tests {
.expect("should parse test settings")
}

#[test]
fn pull_sync_noop_states_skip_post_send_graph_factory() {
let calls = std::cell::Cell::new(0);
let result = prepare_pull_sync_after_send(None, || {
calls.set(calls.get() + 1);
Err(Report::new(TrustedServerError::KvStore {
store_name: "unexpected".to_owned(),
message: "graph factory should not run".to_owned(),
}))
});
assert!(
result.is_none(),
"a skipped pull-sync plan should return none"
);
assert_eq!(calls.get(), 0, "should not invoke the graph factory");
}
Comment thread
ChristianPavilonis marked this conversation as resolved.

#[test]
fn health_response_short_circuits_get_health() {
let req = FastlyRequest::get("https://example.com/health");
Expand Down
2 changes: 2 additions & 0 deletions crates/trusted-server-core/src/constants.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use http::header::HeaderName;

pub const COOKIE_TS_EC: &str = "ts-ec";
/// Short-lived signed proof that the current EC row has every pull-partner UID.
pub const COOKIE_TS_EC_PULL_COMPLETE: &str = "ts-ec-pull-complete";
/// Cookie written by the Trusted Server JS SDK containing a standard-base64-encoded
/// JSON array of Extended User IDs (`[{ source, uids }]`) from identity providers.
pub const COOKIE_TS_EIDS: &str = "ts-eids";
Expand Down
157 changes: 157 additions & 0 deletions crates/trusted-server-core/src/ec/finalize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ use super::kv::{
};
use super::kv_types::KvEntry;
use super::prebid_eids::collect_eid_cookie_updates;
use super::pull_sync_marker::{expire_marker, reconcile_marker};
use super::registry::PartnerRegistry;
use super::{EcKvSnapshot, current_timestamp, log_id};

Expand Down Expand Up @@ -52,10 +53,17 @@ pub fn ec_finalize_response(
sharedid_cookie: Option<&str>,
response: &mut Response<EdgeBody>,
) {
ec_context.validate_pull_sync_marker(settings, registry);
let consent_allows_ec = ec_consent_granted(ec_context.consent());
let consent_withdrawn = ec_consent_withdrawn(ec_context.consent());

if !consent_allows_ec {
// Expire the request-local marker independently of the EC cookie: a
// withdrawal must stop any pending pull-sync disclosure window.
if consent_withdrawn && ec_context.pull_sync_marker().was_present() {
expire_marker(ec_context.pull_sync_marker_mut(), response);
}

finalize_unusable_consent(
settings,
ec_context,
Expand Down Expand Up @@ -86,6 +94,8 @@ pub fn ec_finalize_response(
}
}

reconcile_pull_sync_marker(settings, registry, ec_context, response);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench — Orphan recovery silently stops firing on the marker-skip path.

The PR summary says snapshot reads required by "orphan recovery" are preserved. They aren't. Recovery is gated four lines above this one on the snapshot being Missing:

if matches!(ec_context.kv_snapshot(), EcKvSnapshot::Missing { .. })
    && ec_context.recovery_eligible()

On the marker-skip path the preload never runs, so the snapshot is NotRead, not Missing. And updates is necessarily empty there — a ts-eids/sharedId cookie would have tripped eid_cookie_may_need_persistence and forced the preload — so upsert_partner_ids_from_snapshot returns at its if updates.is_empty() { return snapshot; } short-circuit (ec/kv.rs:557) with NotRead intact. matches!(NotRead, Missing { .. }) is false, and confirm_then_recover_orphaned_ec is never called.

I verified this rather than inferring it. Taking the existing finalize_rotates_orphaned_cookie_to_new_backed_ec test and changing only the seeded snapshot from Missing to NotRead:

SCRATCH: rotated=false set_cookie_present=false

The orphan is not rotated and no replacement cookie is issued. With Missing the same test rotates correctly.

Two things make this worse than a one-request delay:

  1. It is self-perpetuating. reconcile_marker's Failed { .. } | EcKvSnapshot::NotRead arm leaves a Valid marker untouched (it only expires an Invalid one). So the orphaned user skips the read, keeps the marker, and skips again on every subsequent navigation for the remaining hour.
  2. The affected population is exactly the at-risk one. ts-ec is year-lived while the marker is Max-Age=3600, and a marker holder had a complete row an hour ago — precisely the user whose row may since have been deleted or evicted. This is the case Thread EC KV read through the request and recover orphaned cookies #885 was merged to handle.

The direct fix is a fifth hatch:

// in EcSnapshotPreloadInput
recovery_eligible: bool,

// in should_preload_ec_snapshot
let marker_can_skip = input.marker_valid
    && !input.auction_needs_row
    && !input.eid_cookie_may_need_persistence
    && !input.privacy_needs_row
    && !input.recovery_eligible
    && !input.snapshot_already_read;

But note that recovery_eligible is set for real-browser navigations, which is close to the whole target population — so combined with the separate finding on publisher.rs:4417, it may leave the optimization with very little left. If you'd rather keep the deferral as a deliberate trade-off, that's defensible, but then please correct the PR description and the docs/guide/edge-cookies.md sentence that lists "detected orphan recovery" among the paths that "continue to use KV" — detection is the read being skipped — and add a test pinning the chosen behaviour.

Apply manually — can't be auto-applied as a suggestion because the fix spans the struct definition, the gate body, and the call site across two separate hunks.


// Ordinary returning-user page views no longer refresh the browser
// cookie, emit the EC header, or update KV TTL.
return;
Expand All @@ -97,6 +107,7 @@ pub fn ec_finalize_response(
if ec_context.ec_generated() {
let (Some(graph), Some(ec_id)) = (kv, ec_context.ec_value().map(str::to_owned)) else {
log::info!("Skipping generated EC response write because KV graph is unavailable");
reconcile_pull_sync_marker(settings, registry, ec_context, response);
return;
};

Expand All @@ -113,6 +124,26 @@ pub fn ec_finalize_response(
log::warn!("Skipping generated EC cookie because backing row is not authoritative");
}
}

reconcile_pull_sync_marker(settings, registry, ec_context, response);
}

fn reconcile_pull_sync_marker(
Comment thread
ChristianPavilonis marked this conversation as resolved.
settings: &Settings,
registry: &PartnerRegistry,
ec_context: &mut EcContext,
response: &mut Response<EdgeBody>,
) {
let ec_id = ec_context.ec_value().map(str::to_owned);
let snapshot = ec_context.kv_snapshot().clone();
reconcile_marker(
settings,
registry,
ec_id.as_deref(),
&snapshot,
ec_context.pull_sync_marker_mut(),
response,
);
}

fn recover_orphaned_ec(
Expand Down Expand Up @@ -1523,6 +1554,132 @@ mod tests {
);
}

#[test]
fn finalize_sets_marker_for_complete_pull_partner_snapshot() {
let settings = create_test_settings();
let ec_id = sample_ec_id("compl1");
let mut partner = make_partner("ssp.example.com");
partner.pull_sync_enabled = true;
partner.pull_sync_url = Some("https://sync.example.com/pull".to_owned());
partner.pull_sync_allowed_domains = vec!["sync.example.com".to_owned()];
partner.ts_pull_token = Some(Redacted::new("pull-token".to_owned()));
let registry = PartnerRegistry::from_config(&[partner]).expect("should build registry");
let mut ec_context = make_context(
Some(&ec_id),
Some(&ec_id),
true,
false,
Jurisdiction::NonRegulated,
);
let mut entry = live_entry();
entry.ids.insert(
"ssp.example.com".to_owned(),
crate::ec::kv_types::KvPartnerId {
uid: "partner-uid".to_owned(),
},
);
ec_context.set_kv_snapshot(EcKvSnapshot::Present {
ec_id,
entry: Box::new(entry),
generation: Some(1),
});
let mut response = empty_response();

ec_finalize_response(
&settings,
&mut ec_context,
None,
&registry,
None,
None,
&mut response,
);

let cookies = response
.headers()
.get_all(http::header::SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.collect::<Vec<_>>();
assert!(
cookies
.iter()
.any(|cookie| cookie.starts_with("ts-ec-pull-complete=v1.")),
"complete snapshot should issue the marker"
);
}

#[test]
fn explicit_withdrawal_without_marker_or_ec_cookie_does_not_set_cookie() {
let settings = create_test_settings();
let consent = ConsentContext {
jurisdiction: Jurisdiction::UsState("CA".to_owned()),
gpc: true,
source: ConsentSource::Cookie,
..Default::default()
};
let mut ec_context = make_context_with_consent(None, None, false, false, consent);
let mut response = empty_response();

ec_finalize_response(
&settings,
&mut ec_context,
None,
&PartnerRegistry::empty(),
None,
None,
&mut response,
);

assert!(
response.headers().get(http::header::SET_COOKIE).is_none(),
"withdrawal without browser identity state should not add a cookie"
);
}

#[test]
fn explicit_withdrawal_expires_marker_without_ec_cookie() {
let settings = create_test_settings();
let consent = ConsentContext {
jurisdiction: Jurisdiction::UsState("CA".to_owned()),
gpc: true,
source: ConsentSource::Cookie,
..Default::default()
};
let mut ec_context = make_context_with_consent(None, None, false, false, consent);
ec_context.set_pull_sync_marker_for_test(
crate::ec::pull_sync_marker::PullSyncMarkerState::Invalid,
);
let mut response = empty_response();

ec_finalize_response(
&settings,
&mut ec_context,
None,
&PartnerRegistry::empty(),
None,
None,
&mut response,
);

let cookies = response
.headers()
.get_all(http::header::SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.collect::<Vec<_>>();
assert!(
cookies.iter().any(|cookie| {
cookie.starts_with("ts-ec-pull-complete=;") && cookie.contains("Max-Age=0")
}),
"withdrawal should expire the marker independently of EC cookie state"
);
assert!(
cookies.iter().all(|cookie| !cookie.starts_with("ts-ec=;")),
"missing EC cookie should not add an EC-cookie expiry"
);
}

fn live_entry() -> KvEntry {
let mut entry = KvEntry::tombstone(1000);
entry.consent.ok = true;
Expand Down
Loading
Loading