Skip to content

Commit 1a5d245

Browse files
committed
Enable consent KV persistence via the named consent store
The consent pipeline already implemented read-fallback and write-on-change, but the only production caller passed no EC id and no KV store, so nothing persisted. Wire both: key persistence on the existing EC id, and resolve the configured consent store through the KV registry. Resolution is best-effort because this runs on every request that builds an EC context. Fail-closed enforcement for a missing consent store stays at the consent-dependent routes, so a misconfiguration cannot break unrelated ones.
1 parent fafb69f commit 1a5d245

2 files changed

Lines changed: 208 additions & 3 deletions

File tree

crates/trusted-server-core/src/ec/mod.rs

Lines changed: 190 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -212,14 +212,30 @@ impl EcContext {
212212
.client_ip
213213
.map(generation::normalize_ip);
214214

215-
// Build consent context from request-local cookies, headers, and geo.
215+
// Resolve the consent KV store for persistence, keyed by the existing EC
216+
// id. Resolution is deliberately best-effort: this runs on every request
217+
// that builds an EC context, including routes that do not depend on
218+
// consent, so a configured-but-unresolvable store must not fail here.
219+
// Fail-closed enforcement for a missing consent store lives at the
220+
// consent-dependent routes (`consent::resolve_consent_kv`), which keeps a
221+
// misconfiguration from breaking unrelated routes.
222+
let consent_kv = settings
223+
.consent
224+
.consent_store
225+
.as_deref()
226+
.and_then(|store_id| services.kv_handle_named(store_id));
227+
228+
// Build consent context from request-local cookies, headers, and geo,
229+
// falling back to (and persisting) KV-backed consent for this EC id. A
230+
// request without an EC id yet cannot key persistence, so it stays
231+
// request-local until the EC cookie is present on a later request.
216232
let consent = consent_mod::build_consent_context(&ConsentPipelineInput {
217233
jar: parsed.jar.as_ref(),
218234
req,
219235
config: &settings.consent,
220236
geo: geo_info,
221-
ec_id: None,
222-
kv_store: None,
237+
ec_id: ec_value.as_deref(),
238+
kv_store: consent_kv,
223239
});
224240

225241
log::info!(
@@ -678,4 +694,175 @@ mod tests {
678694
"should return cookie value for revocation even when header is present"
679695
);
680696
}
697+
698+
// ---------------------------------------------------------------------
699+
// Consent KV persistence wiring
700+
// ---------------------------------------------------------------------
701+
702+
/// In-memory [`crate::platform::PlatformKvStore`] double for the consent
703+
/// persistence wiring tests.
704+
struct InMemoryKvStore {
705+
entries: std::sync::Mutex<std::collections::HashMap<String, bytes::Bytes>>,
706+
}
707+
708+
impl InMemoryKvStore {
709+
fn new() -> Self {
710+
Self {
711+
entries: std::sync::Mutex::new(std::collections::HashMap::new()),
712+
}
713+
}
714+
}
715+
716+
#[async_trait::async_trait(?Send)]
717+
impl crate::platform::PlatformKvStore for InMemoryKvStore {
718+
async fn get_bytes(
719+
&self,
720+
key: &str,
721+
) -> Result<Option<bytes::Bytes>, crate::platform::KvError> {
722+
Ok(self
723+
.entries
724+
.lock()
725+
.expect("should lock entries")
726+
.get(key)
727+
.cloned())
728+
}
729+
730+
async fn put_bytes(
731+
&self,
732+
key: &str,
733+
value: bytes::Bytes,
734+
) -> Result<(), crate::platform::KvError> {
735+
self.entries
736+
.lock()
737+
.expect("should lock entries")
738+
.insert(key.to_owned(), value);
739+
Ok(())
740+
}
741+
742+
async fn put_bytes_with_ttl(
743+
&self,
744+
key: &str,
745+
value: bytes::Bytes,
746+
_ttl: std::time::Duration,
747+
) -> Result<(), crate::platform::KvError> {
748+
self.put_bytes(key, value).await
749+
}
750+
751+
async fn delete(&self, key: &str) -> Result<(), crate::platform::KvError> {
752+
self.entries
753+
.lock()
754+
.expect("should lock entries")
755+
.remove(key);
756+
Ok(())
757+
}
758+
759+
async fn list_keys_page(
760+
&self,
761+
_prefix: &str,
762+
_cursor: Option<&str>,
763+
_limit: usize,
764+
) -> Result<edgezero_core::key_value_store::KvPage, crate::platform::KvError> {
765+
Ok(edgezero_core::key_value_store::KvPage::default())
766+
}
767+
}
768+
769+
/// Settings whose consent pipeline persists to the `consent_store` id.
770+
fn settings_with_consent_store() -> Settings {
771+
let mut settings = create_test_settings();
772+
settings.consent.consent_store = Some("consent_store".to_owned());
773+
settings
774+
}
775+
776+
/// Build services whose KV registry holds a distinct default store and a
777+
/// named `consent_store`, returning both handles so tests can assert which
778+
/// store was written.
779+
fn services_with_consent_kv() -> (
780+
RuntimeServices,
781+
crate::platform::KvHandle,
782+
crate::platform::KvHandle,
783+
) {
784+
let default_handle =
785+
crate::platform::KvHandle::new(std::sync::Arc::new(InMemoryKvStore::new()));
786+
let consent_handle =
787+
crate::platform::KvHandle::new(std::sync::Arc::new(InMemoryKvStore::new()));
788+
let by_id = std::collections::BTreeMap::from([
789+
("trusted_server_kv".to_owned(), default_handle.clone()),
790+
("consent_store".to_owned(), consent_handle.clone()),
791+
]);
792+
let registry = edgezero_core::store_registry::StoreRegistry::from_parts(
793+
by_id,
794+
"trusted_server_kv".to_owned(),
795+
)
796+
.expect("should build kv registry");
797+
let services = crate::platform::test_support::noop_services_with_kv_registry(registry);
798+
(services, default_handle, consent_handle)
799+
}
800+
801+
#[test]
802+
fn read_from_request_persists_consent_to_the_named_consent_store() {
803+
let settings = settings_with_consent_store();
804+
let (services, default_handle, consent_handle) = services_with_consent_kv();
805+
let ec_id = valid_ec_id("c", "Persis");
806+
let cookie = format!("ts-ec={ec_id}; us_privacy=1YNN");
807+
let req = create_test_request(&[("cookie", &cookie)]);
808+
809+
EcContext::read_from_request(&settings, &req, &services).expect("should read EC context");
810+
811+
let persisted = crate::consent::kv::load_consent_from_kv(&consent_handle, &ec_id)
812+
.expect("should persist cookie-sourced consent under the EC id");
813+
assert_eq!(
814+
persisted.raw_us_privacy.as_deref(),
815+
Some("1YNN"),
816+
"should persist the cookie-sourced consent signal"
817+
);
818+
assert!(
819+
crate::consent::kv::load_consent_from_kv(&default_handle, &ec_id).is_none(),
820+
"should resolve the named consent store, never the default KV store"
821+
);
822+
}
823+
824+
#[test]
825+
fn read_from_request_falls_back_to_persisted_consent_when_request_has_no_signals() {
826+
let settings = settings_with_consent_store();
827+
let (services, _default_handle, _consent_handle) = services_with_consent_kv();
828+
let ec_id = valid_ec_id("d", "Fallbk");
829+
830+
// First request carries a consent cookie — persisted under the EC id.
831+
let seeding_cookie = format!("ts-ec={ec_id}; us_privacy=1YNN");
832+
let seeding_req = create_test_request(&[("cookie", &seeding_cookie)]);
833+
EcContext::read_from_request(&settings, &seeding_req, &services)
834+
.expect("should read EC context for the seeding request");
835+
836+
// Second request carries only the EC cookie — no consent signals.
837+
let bare_cookie = format!("ts-ec={ec_id}");
838+
let bare_req = create_test_request(&[("cookie", &bare_cookie)]);
839+
let ec = EcContext::read_from_request(&settings, &bare_req, &services)
840+
.expect("should read EC context for the bare request");
841+
842+
assert_eq!(
843+
ec.consent().raw_us_privacy.as_deref(),
844+
Some("1YNN"),
845+
"should fall back to consent persisted under the EC id"
846+
);
847+
}
848+
849+
#[test]
850+
fn read_from_request_succeeds_when_consent_store_is_configured_but_unresolved() {
851+
// A configured-but-unresolvable consent store must not fail the EC
852+
// context: fail-closed lives at the consent-dependent routes, so a
853+
// misconfiguration must not break routes that never read consent.
854+
let settings = settings_with_consent_store();
855+
let ec_id = valid_ec_id("e", "NoRegs");
856+
let cookie = format!("ts-ec={ec_id}; us_privacy=1YNN");
857+
let req = create_test_request(&[("cookie", &cookie)]);
858+
859+
let ec = EcContext::read_from_request(&settings, &req, &noop_services())
860+
.expect("should read EC context without a KV registry");
861+
862+
assert_eq!(
863+
ec.consent().raw_us_privacy.as_deref(),
864+
Some("1YNN"),
865+
"should still build request-local consent with persistence disabled"
866+
);
867+
}
681868
}

crates/trusted-server-core/src/platform/test_support.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,24 @@ pub(crate) fn noop_services() -> RuntimeServices {
621621
build_services_with_config(NoopConfigStore)
622622
}
623623

624+
/// Build a [`RuntimeServices`] carrying a caller-supplied KV registry, so tests
625+
/// can exercise named-store resolution via
626+
/// [`RuntimeServices::kv_handle_named`].
627+
pub(crate) fn noop_services_with_kv_registry(
628+
kv_registry: edgezero_core::store_registry::KvRegistry,
629+
) -> RuntimeServices {
630+
RuntimeServices::builder()
631+
.config_store(Arc::new(NoopConfigStore))
632+
.secret_store(Arc::new(NoopSecretStore))
633+
.kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore))
634+
.kv_registry(Some(kv_registry))
635+
.backend(Arc::new(NoopBackend))
636+
.http_client(Arc::new(NoopHttpClient))
637+
.geo(Arc::new(NoopGeo))
638+
.client_info(ClientInfo::default())
639+
.build()
640+
}
641+
624642
/// Build a [`RuntimeServices`] with a caller-supplied HTTP client and a [`StubBackend`].
625643
///
626644
/// Uses [`StubBackend`] (always returns `Ok("stub-backend")`) rather than

0 commit comments

Comments
 (0)