From 01305cfa405d121a526a115358322f4a490c8c74 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 8 Oct 2026 17:23:31 +0100 Subject: [PATCH 1/8] Add one module context and the adapter that injects what a module names Core builds one `ModuleContext` where it calls modules, by borrowing what the request already holds, and every module called there borrows the same context. A module is handed a `ModuleCall`, which is the context together with the permissions that module declares, and its process function names what it needs as parameters. `ModuleCall::inject` passes each one in from the context. A parameter is the request (method, path, query, host and scheme), the reader's evidence, the connection, the response, the resolved permissions, the decoded consent, the settings, the services, the location, the device signals, the Edge Cookie identifier, the document state or the request's typed extensions. It may be an `Option` of one, which is `None` when the value cannot be passed, or a tuple of them. A function takes the module first and up to eight parameters, `inject_with` passes one argument of the caller's own after the module, and the function may be async. The location, the device signals and the Edge Cookie identifier each carry the permissions the module that produced them declares. Such a value is passed only to a module that declares every one of those permissions and is granted them on this request. A withheld value is `None` for an `Option` parameter, and a call that names the value itself is skipped, with the reason in the debug log. Core holds modules as trait objects, which cannot have a generic method, so each kind of module's trait method will take a `ModuleCall` and the module's implementation hands its own function to `inject`. That call is made where the module's type is known, so nothing is boxed, allocated or copied. Nothing calls the context yet. The commits that follow hand it to each kind of module. `PermissionSet` gains `difference`, and a `Display` that lists its Data Use identifiers, which the reason for a skipped call uses. --- crates/trusted-server-core/src/ec/mod.rs | 8 + crates/trusted-server-core/src/lib.rs | 1 + .../trusted-server-core/src/module_context.rs | 775 ++++++++++++++++++ .../src/module_context/inject.rs | 164 ++++ .../src/module_context/tests.rs | 474 +++++++++++ crates/trusted-server-core/src/permissions.rs | 23 + 6 files changed, 1445 insertions(+) create mode 100644 crates/trusted-server-core/src/module_context.rs create mode 100644 crates/trusted-server-core/src/module_context/inject.rs create mode 100644 crates/trusted-server-core/src/module_context/tests.rs diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 3b321c578e..b878160ceb 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -1004,6 +1004,14 @@ impl EcContext { } /// Sets pull-sync marker state in focused unit tests. + /// The same test-only context with a resolved location. + #[cfg(test)] + #[must_use] + pub(crate) fn with_geo_for_test(mut self, geo: GeoInfo) -> Self { + self.geo_info = Some(geo); + self + } + #[cfg(test)] pub(crate) fn set_pull_sync_marker_for_test(&mut self, state: PullSyncMarkerState) { self.pull_sync_marker = state; diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index c456a57409..b275f85059 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -53,6 +53,7 @@ pub mod host_rewrite; pub mod html_processor; pub mod http_util; pub mod integrations; +pub mod module_context; pub mod module_name; pub(crate) mod module_secrets; pub mod openrtb; diff --git a/crates/trusted-server-core/src/module_context.rs b/crates/trusted-server-core/src/module_context.rs new file mode 100644 index 0000000000..e3513af18e --- /dev/null +++ b/crates/trusted-server-core/src/module_context.rs @@ -0,0 +1,775 @@ +//! One context for every module, and the values a module's process function +//! may name as parameters. +//! +//! Core builds one [`ModuleContext`] where it calls modules, from what the +//! request already holds. The context borrows everything it carries, so +//! building it copies no request data, and every module called there borrows +//! the same context. Each module is handed a [`ModuleCall`], being the context +//! together with the permissions that module declares. +//! +//! A module's process function names what it needs as parameters, and +//! [`ModuleCall::inject`] passes each one in from the context. A parameter may +//! be any type that implements [`FromModuleContext`], an `Option` of one, which +//! is `None` when the value cannot be passed, or a tuple of them. The function +//! shapes that can be called are in [`inject`]. +//! +//! | Parameter | What it is | Absent where | +//! | --- | --- | --- | +//! | [`ModuleRequest`] | method, path, query, host and scheme | never | +//! | `&dyn RequestInfo` | the reader's evidence, headers and client IP | the work is shared by every reader | +//! | `&ClientInfo` | the reader's connection | as above | +//! | [`ModuleResponse`] | status and headers | no response exists yet | +//! | `&PermissionState` | the permissions resolved for the request | before they are resolved, and where the work is shared | +//! | `&ConsentContext` | the decoded consent signals | as above | +//! | `&Settings` | the deployment's settings | a stored page's fetch plan | +//! | `&RuntimeServices` | stores, caches, backends and the HTTP client | as above | +//! | `&GeoInfo` | the reader's location | no location resolved, or withheld | +//! | `&DeviceSignals` | the device classification | not classified, or withheld | +//! | [`EdgeCookie`] | the request's Edge Cookie identifier | none, or withheld | +//! | `&IntegrationDocumentState` | state shared by middleware on one document | outside a document | +//! | [`ModuleExtensions`] | the request's typed extension map | outside a page request | +//! +//! # Permissions +//! +//! Geo, the device signals and the Edge Cookie identifier each carry the +//! permissions the module that produced them declares. Such a value is passed +//! only to a module that declares every one of those permissions and is granted +//! them on this request. A value whose producer declares none is passed to +//! every module, because evidence is not rationed and its use is what the +//! permissions govern. When a value is withheld, an `Option` parameter is +//! `None`, and a call naming the value itself is skipped, with the reason in +//! the debug log. + +use core::fmt; +use std::sync::Mutex; + +use edgezero_core::body::Body as EdgeBody; +use http::{HeaderMap, Method, StatusCode}; + +use crate::consent::ConsentContext; +use crate::ec::EcContext; +use crate::ec::device::DeviceSignals; +use crate::error::TrustedServerError; +use crate::evidence::RequestInfo; +use crate::geo::GeoInfo; +use crate::integrations::IntegrationDocumentState; +use crate::permissions::{PermissionSet, PermissionState}; +use crate::platform::{ClientInfo, RuntimeServices}; +use crate::settings::Settings; + +pub mod inject; + +pub use inject::{Process, ProcessWith}; + +/// What a request resolved to: its method and address, and the host and +/// scheme it was made for. +/// +/// Carries nothing about who asked, so it is in every context, including those +/// whose work is shared by every reader. +#[derive(Debug, Clone, Copy)] +pub struct ModuleRequest<'r> { + method: &'r Method, + path: &'r str, + query: &'r str, + host: &'r str, + scheme: &'r str, +} + +impl<'r> ModuleRequest<'r> { + /// A request for `path` on `host` over `scheme`, with no query. + #[must_use] + pub fn new(method: &'r Method, host: &'r str, scheme: &'r str, path: &'r str) -> Self { + Self { + method, + path, + query: "", + host, + scheme, + } + } + + /// The same request with the query string, without its leading `?`. + #[must_use] + pub fn with_query(self, query: &'r str) -> Self { + Self { query, ..self } + } + + /// The request method. + #[must_use] + pub fn method(&self) -> &'r Method { + self.method + } + + /// The URL path, without the query string. + #[must_use] + pub fn path(&self) -> &'r str { + self.path + } + + /// The query string, without its leading `?`, or `""`. + #[must_use] + pub fn query(&self) -> &'r str { + self.query + } + + /// The publisher-facing host the reader asked for. + #[must_use] + pub fn host(&self) -> &'r str { + self.host + } + + /// The publisher-facing scheme the reader asked for. + #[must_use] + pub fn scheme(&self) -> &'r str { + self.scheme + } +} + +/// A [`ModuleRequest`] that owns its parts, for a caller that must keep them +/// past the borrow of the request, such as one that hands the request on. +#[derive(Debug, Clone)] +pub struct ResolvedRequest { + method: Method, + path: String, + query: String, + host: String, + scheme: String, +} + +impl ResolvedRequest { + /// The method, address, host and scheme of `request`, the host and + /// scheme read as the publisher path reads them. + #[must_use] + pub fn of(request: &http::Request, client: &ClientInfo) -> Self { + let resolved = crate::http_util::RequestInfo::from_request(request, client); + Self { + method: request.method().clone(), + path: request.uri().path().to_owned(), + query: request.uri().query().unwrap_or_default().to_owned(), + host: resolved.host, + scheme: resolved.scheme, + } + } + + /// The borrowed view a context carries. + #[must_use] + pub fn view(&self) -> ModuleRequest<'_> { + ModuleRequest::new(&self.method, &self.host, &self.scheme, &self.path) + .with_query(&self.query) + } +} + +/// The response a module runs on. +#[derive(Debug, Clone, Copy)] +pub struct ModuleResponse<'r> { + status: StatusCode, + headers: &'r HeaderMap, +} + +impl<'r> ModuleResponse<'r> { + /// A response with `status` and `headers`. + #[must_use] + pub fn new(status: StatusCode, headers: &'r HeaderMap) -> Self { + Self { status, headers } + } + + /// The response status. + #[must_use] + pub fn status(&self) -> StatusCode { + self.status + } + + /// The response headers. + #[must_use] + pub fn headers(&self) -> &'r HeaderMap { + self.headers + } +} + +/// The request's Edge Cookie identifier, as the module that created it wrote +/// it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EdgeCookie<'r>(&'r str); + +impl<'r> EdgeCookie<'r> { + /// The identifier. + #[must_use] + pub fn as_str(&self) -> &'r str { + self.0 + } +} + +/// The request's typed extension map, where a module leaves a value for a +/// later one on the same request. +/// +/// A page request's preparers are handed it writable, and the serve +/// middleware read what they left. Everywhere else it is read only. +#[derive(Clone, Copy)] +pub struct ModuleExtensions<'r>(ExtensionsSource<'r>); + +#[derive(Clone, Copy)] +enum ExtensionsSource<'r> { + ReadOnly(&'r http::Extensions), + Writable(&'r Mutex), +} + +impl<'r> ModuleExtensions<'r> { + /// The map, read only. + #[must_use] + pub fn read_only(extensions: &'r http::Extensions) -> Self { + Self(ExtensionsSource::ReadOnly(extensions)) + } + + /// The map, which a module may add to. + #[must_use] + pub fn writable(extensions: &'r Mutex) -> Self { + Self(ExtensionsSource::Writable(extensions)) + } + + /// A copy of the value of type `T`, when the map holds one. + #[must_use] + pub fn get(&self) -> Option { + match self.0 { + ExtensionsSource::ReadOnly(extensions) => extensions.get::().cloned(), + ExtensionsSource::Writable(extensions) => extensions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get::() + .cloned(), + } + } + + /// Puts `value` in the map, in place of any value of its type. + /// + /// # Errors + /// + /// Gives `value` back when the map is read only here. + pub fn insert(&self, value: T) -> Result<(), T> { + match self.0 { + ExtensionsSource::ReadOnly(_) => Err(value), + ExtensionsSource::Writable(extensions) => { + extensions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(value); + Ok(()) + } + } + } +} + +impl fmt::Debug for ModuleExtensions<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let writable = matches!(self.0, ExtensionsSource::Writable(_)); + f.debug_struct("ModuleExtensions") + .field("writable", &writable) + .finish_non_exhaustive() + } +} + +/// A value whose use needs the permissions its producer declares. +struct Gated<'r, T: ?Sized> { + value: &'r T, + requires: PermissionSet, +} + +impl Clone for Gated<'_, T> { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for Gated<'_, T> {} + +/// Everything a module may be passed for one request. +/// +/// Built once where core calls modules, by borrowing what the request already +/// holds, and borrowed by every module called there. A module never sees it +/// directly. It is handed a [`ModuleCall`] and names what it needs, see +/// [`ModuleCall::inject`]. +pub struct ModuleContext<'r> { + request: ModuleRequest<'r>, + evidence: Option<&'r dyn RequestInfo>, + client: Option<&'r ClientInfo>, + response: Option>, + permissions: Option<&'r PermissionState>, + consent: Option<&'r ConsentContext>, + settings: Option<&'r Settings>, + services: Option<&'r RuntimeServices>, + geo: Option>, + device: Option>, + edge_cookie: Option>, + document_state: Option<&'r IntegrationDocumentState>, + extensions: Option>, +} + +impl<'r> ModuleContext<'r> { + /// A context carrying `request` and nothing else. + /// + /// This is the context of work shared by every reader, such as a stored + /// page's fetch plan, until the builder methods add to it. + #[must_use] + pub fn new(request: ModuleRequest<'r>) -> Self { + Self { + request, + evidence: None, + client: None, + response: None, + permissions: None, + consent: None, + settings: None, + services: None, + geo: None, + device: None, + edge_cookie: None, + document_state: None, + extensions: None, + } + } + + /// The same context carrying the reader's evidence. + #[must_use] + pub fn with_evidence(self, evidence: &'r dyn RequestInfo) -> Self { + Self { + evidence: Some(evidence), + ..self + } + } + + /// The same context carrying the reader's connection. + #[must_use] + pub fn with_client(self, client: &'r ClientInfo) -> Self { + Self { + client: Some(client), + ..self + } + } + + /// The same context carrying the response a module runs on. + #[must_use] + pub fn with_response(self, response: ModuleResponse<'r>) -> Self { + Self { + response: Some(response), + ..self + } + } + + /// The same context carrying the permissions resolved for the request. + #[must_use] + pub fn with_permissions(self, permissions: &'r PermissionState) -> Self { + Self { + permissions: Some(permissions), + ..self + } + } + + /// The same context carrying the request's decoded consent signals. + #[must_use] + pub fn with_consent(self, consent: &'r ConsentContext) -> Self { + Self { + consent: Some(consent), + ..self + } + } + + /// The same context carrying the deployment's settings. + #[must_use] + pub fn with_settings(self, settings: &'r Settings) -> Self { + Self { + settings: Some(settings), + ..self + } + } + + /// The same context carrying the request's services. + #[must_use] + pub fn with_services(self, services: &'r RuntimeServices) -> Self { + Self { + services: Some(services), + ..self + } + } + + /// The same context carrying the reader's location, whose use needs + /// `requires`, being what the geo module that resolved it declares. + #[must_use] + pub fn with_geo(self, geo: &'r GeoInfo, requires: PermissionSet) -> Self { + Self { + geo: Some(Gated { + value: geo, + requires, + }), + ..self + } + } + + /// The same context carrying the device signals, whose use needs + /// `requires`, being what the device module that derived them declares. + #[must_use] + pub fn with_device(self, device: &'r DeviceSignals, requires: PermissionSet) -> Self { + Self { + device: Some(Gated { + value: device, + requires, + }), + ..self + } + } + + /// The same context carrying the request's Edge Cookie identifier, whose + /// use needs `requires`, being what the Edge Cookie module declares. + #[must_use] + pub fn with_edge_cookie(self, id: &'r str, requires: PermissionSet) -> Self { + Self { + edge_cookie: Some(Gated { + value: id, + requires, + }), + ..self + } + } + + /// The same context carrying the state shared by the middleware working + /// on one document. + #[must_use] + pub fn with_document_state(self, document_state: &'r IntegrationDocumentState) -> Self { + Self { + document_state: Some(document_state), + ..self + } + } + + /// The same context carrying the request's typed extension map. + #[must_use] + pub fn with_extensions(self, extensions: ModuleExtensions<'r>) -> Self { + Self { + extensions: Some(extensions), + ..self + } + } + + /// The same context carrying what the request's Edge Cookie state holds, + /// being its permissions, consent, location, device signals and Edge + /// Cookie identifier, and the request's services and connection. + /// + /// Each gated value needs what its producer declares. The location needs + /// what the geo module in `services` declares, the device signals what the + /// device module in `services` declares, and the identifier what the Edge + /// Cookie module the state was built with declares. + #[must_use] + pub fn with_request_state(self, state: &'r EcContext, services: &'r RuntimeServices) -> Self { + let mut context = self + .with_services(services) + .with_client(services.client_info()) + .with_permissions(state.permissions()) + .with_consent(state.consent()); + if let Some(geo) = state.geo_info() { + context = context.with_geo(geo, services.geo().required_permissions()); + } + if let Some(device) = state.device_signals() { + let requires = services + .device_module() + .map_or_else(PermissionSet::none, |module| module.required_permissions()); + context = context.with_device(device, requires); + } + if let (Some(id), Some(module)) = (state.ec_value(), state.selected_module()) { + context = context.with_edge_cookie(id, module.required_permissions()); + } + context + } + + /// What the request resolved to. + #[must_use] + pub fn request(&self) -> ModuleRequest<'r> { + self.request + } + + /// The call to one module, named `module`, which declares the permissions + /// in `declared`. + #[must_use] + pub fn call(&self, module: &'static str, declared: PermissionSet) -> ModuleCall<'_> { + ModuleCall { + context: self, + module, + declared, + } + } +} + +impl fmt::Debug for ModuleContext<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ModuleContext") + .field("request", &self.request) + .field("evidence", &self.evidence.is_some()) + .field("response", &self.response.map(|response| response.status)) + .field("permissions", &self.permissions.is_some()) + .field("settings", &self.settings.is_some()) + .field("services", &self.services.is_some()) + .field("geo", &self.geo.is_some()) + .field("device", &self.device.is_some()) + .field("edge_cookie", &self.edge_cookie.is_some()) + .finish_non_exhaustive() + } +} + +/// Core's call to one module, being the request's [`ModuleContext`] and the +/// permissions the module declares. +/// +/// A module's process function is handed its parameters through +/// [`inject`](Self::inject). +#[derive(Debug, Clone, Copy)] +pub struct ModuleCall<'c> { + context: &'c ModuleContext<'c>, + module: &'static str, + declared: PermissionSet, +} + +impl<'c> ModuleCall<'c> { + /// The module being called. + #[must_use] + pub fn module(&self) -> &'static str { + self.module + } + + /// The permissions the module declares. + #[must_use] + pub fn declared(&self) -> PermissionSet { + self.declared + } + + /// A gated value, when this module may be passed it. + fn gated( + &self, + value: Option>, + name: &'static str, + ) -> Result<&'c T, Withheld> { + let gated = value.ok_or(Withheld::absent(name))?; + if gated.requires.is_empty() { + return Ok(gated.value); + } + let undeclared = gated.requires.difference(self.declared); + if !undeclared.is_empty() { + return Err(Withheld::new(name, WithheldReason::NotDeclared(undeclared))); + } + let state = self + .context + .permissions + .ok_or(Withheld::new(name, WithheldReason::Unresolved))?; + let unset = gated.requires.difference(state.permissions()); + if !unset.is_empty() { + return Err(Withheld::new(name, WithheldReason::NotGranted(unset))); + } + Ok(gated.value) + } +} + +/// Why a value is not passed to a module. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Withheld { + value: &'static str, + reason: WithheldReason, +} + +/// What kept a value from a module. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WithheldReason { + /// The context carries no such value where this module runs. + Absent, + /// The module does not declare these permissions, which the value's use + /// needs. + NotDeclared(PermissionSet), + /// These permissions, which the value's use needs, are not set on this + /// request. + NotGranted(PermissionSet), + /// The value's use needs permissions, and none are resolved where this + /// module runs. + Unresolved, +} + +impl Withheld { + fn new(value: &'static str, reason: WithheldReason) -> Self { + Self { value, reason } + } + + fn absent(value: &'static str) -> Self { + Self::new(value, WithheldReason::Absent) + } + + /// The value that was withheld, such as `geo`. + #[must_use] + pub fn value(&self) -> &'static str { + self.value + } + + /// Why it was withheld. + #[must_use] + pub fn reason(&self) -> WithheldReason { + self.reason + } +} + +impl fmt::Display for Withheld { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let value = self.value; + match self.reason { + WithheldReason::Absent => write!(f, "there is no {value} where this module runs"), + WithheldReason::NotDeclared(missing) => write!( + f, + "{value} is withheld because the module does not declare {missing}, which its \ + use needs" + ), + WithheldReason::NotGranted(missing) => write!( + f, + "{value} is withheld because {missing} is not set on this request" + ), + WithheldReason::Unresolved => write!( + f, + "{value} is withheld because its use needs permissions and none are resolved \ + where this module runs" + ), + } + } +} + +impl core::error::Error for Withheld {} + +/// A route whose parameter is withheld answers `403`, the same as a route +/// refusing a permission itself. +impl From for error_stack::Report { + fn from(withheld: Withheld) -> Self { + error_stack::Report::new(TrustedServerError::Forbidden { + message: withheld.to_string(), + }) + } +} + +/// A value a module's process function can name as a parameter. +/// +/// Implemented for each value a [`ModuleContext`] carries, for an `Option` of +/// one, which is `None` rather than withheld, and for tuples of up to eight. +pub trait FromModuleContext<'c>: Sized { + /// The value for this call. + /// + /// # Errors + /// + /// [`Withheld`] when the context carries no such value here, or when the + /// value's use needs a permission this module does not declare or is not + /// granted. + fn from_module_context(call: &ModuleCall<'c>) -> Result; +} + +impl<'c, T: FromModuleContext<'c>> FromModuleContext<'c> for Option { + fn from_module_context(call: &ModuleCall<'c>) -> Result { + Ok(T::from_module_context(call).ok()) + } +} + +impl<'c> FromModuleContext<'c> for ModuleRequest<'c> { + fn from_module_context(call: &ModuleCall<'c>) -> Result { + Ok(call.context.request) + } +} + +impl<'c> FromModuleContext<'c> for &'c dyn RequestInfo { + fn from_module_context(call: &ModuleCall<'c>) -> Result { + call.context + .evidence + .ok_or(Withheld::absent("request evidence")) + } +} + +impl<'c> FromModuleContext<'c> for &'c ClientInfo { + fn from_module_context(call: &ModuleCall<'c>) -> Result { + call.context.client.ok_or(Withheld::absent("client info")) + } +} + +impl<'c> FromModuleContext<'c> for ModuleResponse<'c> { + fn from_module_context(call: &ModuleCall<'c>) -> Result { + call.context.response.ok_or(Withheld::absent("response")) + } +} + +impl<'c> FromModuleContext<'c> for &'c PermissionState { + fn from_module_context(call: &ModuleCall<'c>) -> Result { + call.context + .permissions + .ok_or(Withheld::absent("permissions")) + } +} + +impl<'c> FromModuleContext<'c> for &'c ConsentContext { + fn from_module_context(call: &ModuleCall<'c>) -> Result { + call.context.consent.ok_or(Withheld::absent("consent")) + } +} + +impl<'c> FromModuleContext<'c> for &'c Settings { + fn from_module_context(call: &ModuleCall<'c>) -> Result { + call.context.settings.ok_or(Withheld::absent("settings")) + } +} + +impl<'c> FromModuleContext<'c> for &'c RuntimeServices { + fn from_module_context(call: &ModuleCall<'c>) -> Result { + call.context.services.ok_or(Withheld::absent("services")) + } +} + +impl<'c> FromModuleContext<'c> for &'c GeoInfo { + fn from_module_context(call: &ModuleCall<'c>) -> Result { + call.gated(call.context.geo, "geo") + } +} + +impl<'c> FromModuleContext<'c> for &'c DeviceSignals { + fn from_module_context(call: &ModuleCall<'c>) -> Result { + call.gated(call.context.device, "device signals") + } +} + +impl<'c> FromModuleContext<'c> for EdgeCookie<'c> { + fn from_module_context(call: &ModuleCall<'c>) -> Result { + call.gated(call.context.edge_cookie, "the Edge Cookie") + .map(EdgeCookie) + } +} + +impl<'c> FromModuleContext<'c> for &'c IntegrationDocumentState { + fn from_module_context(call: &ModuleCall<'c>) -> Result { + call.context + .document_state + .ok_or(Withheld::absent("document state")) + } +} + +impl<'c> FromModuleContext<'c> for ModuleExtensions<'c> { + fn from_module_context(call: &ModuleCall<'c>) -> Result { + call.context + .extensions + .ok_or(Withheld::absent("request extensions")) + } +} + +/// A tuple of values is passed when every one of them is. +macro_rules! tuple_from_module_context { + ($($value:ident),+) => { + impl<'c, $($value: FromModuleContext<'c>),+> FromModuleContext<'c> for ($($value,)+) { + fn from_module_context(call: &ModuleCall<'c>) -> Result { + Ok(($($value::from_module_context(call)?,)+)) + } + } + }; +} + +tuple_from_module_context!(A1); +tuple_from_module_context!(A1, A2); +tuple_from_module_context!(A1, A2, A3); +tuple_from_module_context!(A1, A2, A3, A4); +tuple_from_module_context!(A1, A2, A3, A4, A5); +tuple_from_module_context!(A1, A2, A3, A4, A5, A6); +tuple_from_module_context!(A1, A2, A3, A4, A5, A6, A7); +tuple_from_module_context!(A1, A2, A3, A4, A5, A6, A7, A8); + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-core/src/module_context/inject.rs b/crates/trusted-server-core/src/module_context/inject.rs new file mode 100644 index 0000000000..f3a6793509 --- /dev/null +++ b/crates/trusted-server-core/src/module_context/inject.rs @@ -0,0 +1,164 @@ +//! Calling a module's process function with its parameters taken from the +//! request's [`ModuleContext`](super::ModuleContext). +//! +//! Core holds modules as trait objects, and a trait object cannot have a +//! generic method. So each kind of module's trait method takes a +//! [`ModuleCall`], and the module's implementation of it hands its own process +//! function to [`ModuleCall::inject`]. That call is generic and is made where +//! the module's type is known, so nothing is boxed, allocated or copied. This +//! file is the one place that turns a function's parameters into values from +//! the context. +//! +//! The function takes the module first, then up to eight parameters, each a +//! [`FromModuleContext`] value. [`ModuleCall::inject_with`] passes one argument +//! of the caller's own after the module, for the input that is the module's to +//! act on, such as the request a route answers. The function may be `async`, +//! in which case the future it returns is what comes back. +//! +//! ``` +//! use trusted_server_core::evidence::RequestInfo; +//! use trusted_server_core::geo::GeoInfo; +//! use trusted_server_core::module_context::{ModuleCall, Withheld}; +//! +//! struct Greeter; +//! +//! impl Greeter { +//! fn greet(&self, request: &dyn RequestInfo, geo: Option<&GeoInfo>) -> String { +//! let place = geo.map_or("somewhere", |geo| geo.country.as_str()); +//! format!("{} from {place}", request.user_agent()) +//! } +//! +//! fn process(&self, call: ModuleCall<'_>) -> Result { +//! call.inject(self, Self::greet) +//! } +//! } +//! ``` + +use super::{FromModuleContext, ModuleCall, Withheld}; + +/// A function core can call with the module and its parameters taken from a +/// [`ModuleCall`]. +/// +/// Implemented for every function and closure taking `&M` and up to eight +/// [`FromModuleContext`] parameters, so a module never implements it itself. +pub trait Process<'c, M: ?Sized, Args, O> { + /// Calls the function. + /// + /// # Errors + /// + /// [`Withheld`] for the first parameter that cannot be passed, in which case + /// the function is not called. + fn process(self, module: &'c M, call: &ModuleCall<'c>) -> Result; +} + +/// A function core can call with the module, one argument of the caller's +/// own, and its parameters taken from a [`ModuleCall`]. +/// +/// Implemented for every function and closure taking `&M`, the argument, and +/// up to eight [`FromModuleContext`] parameters. +pub trait ProcessWith<'c, M: ?Sized, X, Args, O> { + /// Calls the function. + /// + /// # Errors + /// + /// [`Withheld`] for the first parameter that cannot be passed, in which case + /// the function is not called and the argument is dropped. + fn process_with(self, module: &'c M, argument: X, call: &ModuleCall<'c>) + -> Result; +} + +macro_rules! process { + ($($parameter:ident),*) => { + impl<'c, M, F, O, $($parameter,)*> Process<'c, M, ($($parameter,)*), O> for F + where + M: ?Sized + 'c, + F: FnOnce(&'c M, $($parameter,)*) -> O, + $($parameter: FromModuleContext<'c>,)* + { + #[allow(non_snake_case, reason = "the parameters are named by their types")] + fn process(self, module: &'c M, call: &ModuleCall<'c>) -> Result { + let _ = call; + $(let $parameter = $parameter::from_module_context(call)?;)* + Ok(self(module, $($parameter,)*)) + } + } + + impl<'c, M, X, F, O, $($parameter,)*> ProcessWith<'c, M, X, ($($parameter,)*), O> for F + where + M: ?Sized + 'c, + F: FnOnce(&'c M, X, $($parameter,)*) -> O, + $($parameter: FromModuleContext<'c>,)* + { + #[allow(non_snake_case, reason = "the parameters are named by their types")] + fn process_with( + self, + module: &'c M, + argument: X, + call: &ModuleCall<'c>, + ) -> Result { + let _ = call; + $(let $parameter = $parameter::from_module_context(call)?;)* + Ok(self(module, argument, $($parameter,)*)) + } + } + }; +} + +process!(); +process!(A1); +process!(A1, A2); +process!(A1, A2, A3); +process!(A1, A2, A3, A4); +process!(A1, A2, A3, A4, A5); +process!(A1, A2, A3, A4, A5, A6); +process!(A1, A2, A3, A4, A5, A6, A7); +process!(A1, A2, A3, A4, A5, A6, A7, A8); + +impl<'c> ModuleCall<'c> { + /// Calls `function` with `module` and each of its other parameters taken + /// from the context. + /// + /// A parameter that is withheld skips the call, which is logged at debug + /// level with the reason, and an `Option` parameter that is withheld is + /// `None` instead. See [`module_context`](super) for what is withheld when. + /// + /// # Errors + /// + /// [`Withheld`] when a parameter cannot be passed, and the function is not + /// called. + pub fn inject(&self, module: &'c M, function: F) -> Result + where + M: ?Sized, + F: Process<'c, M, Args, O>, + { + function + .process(module, self) + .inspect_err(|withheld| self.log_skip(withheld)) + } + + /// Calls `function` with `module`, `argument`, and each of its other + /// parameters taken from the context, as [`inject`](Self::inject) does. + /// + /// # Errors + /// + /// [`Withheld`] when a parameter cannot be passed, and the function is not + /// called. + pub fn inject_with( + &self, + module: &'c M, + argument: X, + function: F, + ) -> Result + where + M: ?Sized, + F: ProcessWith<'c, M, X, Args, O>, + { + function + .process_with(module, argument, self) + .inspect_err(|withheld| self.log_skip(withheld)) + } + + fn log_skip(&self, withheld: &Withheld) { + log::debug!("Skipping module `{}`: {withheld}", self.module()); + } +} diff --git a/crates/trusted-server-core/src/module_context/tests.rs b/crates/trusted-server-core/src/module_context/tests.rs new file mode 100644 index 0000000000..1d54704ea9 --- /dev/null +++ b/crates/trusted-server-core/src/module_context/tests.rs @@ -0,0 +1,474 @@ +use std::cell::Cell; +use std::sync::{Arc, Mutex}; + +use http::{HeaderMap, HeaderValue, Method, StatusCode, header}; + +use super::*; +use crate::ec::module::HmacModule; +use crate::evidence::OwnedRequestInfo; +use crate::permissions::{Permission, PermissionSet, PermissionState}; +use crate::platform::test_support::{noop_services, noop_services_with_client_ip}; +use crate::redacted::Redacted; + +/// The permission the gated values of these tests need. +fn storage() -> PermissionSet { + PermissionSet::none().with(Permission::StoreOnDevice) +} + +fn geo() -> GeoInfo { + GeoInfo { + city: "Bristol".to_owned(), + country: "GB".to_owned(), + continent: "Europe".to_owned(), + latitude: 51.45, + longitude: -2.58, + metro_code: 0, + region: Some("BST".to_owned()), + asn: None, + } +} + +fn device() -> DeviceSignals { + DeviceSignals::derive_ua_only("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/146.0") +} + +fn evidence() -> OwnedRequestInfo { + let mut headers = HeaderMap::new(); + headers.insert( + header::USER_AGENT, + HeaderValue::from_static("ExampleAgent/1.0"), + ); + OwnedRequestInfo::new("203.0.113.7".to_owned(), headers) +} + +/// A module that is no more than the functions these tests hand to it. +struct Module; + +#[test] +fn the_request_reaches_every_function_that_names_it() { + let method = Method::POST; + let request = + ModuleRequest::new(&method, "publisher.example", "https", "/page").with_query("a=1"); + let context = ModuleContext::new(request); + let call = context.call("module", PermissionSet::none()); + + let seen = call + .inject(&Module, |_: &Module, request: ModuleRequest<'_>| { + ( + request.method().clone(), + request.host().to_owned(), + request.scheme().to_owned(), + request.path().to_owned(), + request.query().to_owned(), + ) + }) + .expect("the request is in every context"); + + assert_eq!( + seen, + ( + Method::POST, + "publisher.example".to_owned(), + "https".to_owned(), + "/page".to_owned(), + "a=1".to_owned(), + ) + ); +} + +#[test] +fn each_value_the_context_carries_reaches_a_function_that_names_it() { + let method = Method::GET; + let evidence = evidence(); + let client = ClientInfo { + tls_protocol: Some("TLSv1.3".to_owned()), + ..ClientInfo::default() + }; + let mut response_headers = HeaderMap::new(); + response_headers.insert("x-origin", HeaderValue::from_static("yes")); + let permissions = PermissionState::new(storage()); + let consent = ConsentContext::default(); + let mut settings = Settings::default(); + settings.publisher.domain = "publisher.example".to_owned(); + let services_ip: std::net::IpAddr = "203.0.113.9".parse().expect("should parse an IP"); + let services = noop_services_with_client_ip(services_ip); + let document_state = IntegrationDocumentState::default(); + document_state.get_or_insert_with("module", || 7_u32); + let mut extensions = http::Extensions::new(); + extensions.insert(11_u64); + let context = ModuleContext::new(ModuleRequest::new(&method, "h", "https", "/")) + .with_evidence(&evidence) + .with_client(&client) + .with_response(ModuleResponse::new(StatusCode::CREATED, &response_headers)) + .with_permissions(&permissions) + .with_consent(&consent) + .with_settings(&settings) + .with_services(&services) + .with_document_state(&document_state) + .with_extensions(ModuleExtensions::read_only(&extensions)); + let call = context.call("module", PermissionSet::none()); + + let first = call + .inject( + &Module, + |_: &Module, + evidence: &dyn RequestInfo, + client: &ClientInfo, + response: ModuleResponse<'_>, + permissions: &PermissionState, + consent: &ConsentContext| { + ( + evidence.user_agent().to_owned(), + evidence.client_ip().to_owned(), + client.tls_protocol.clone(), + response.status(), + response.headers().contains_key("x-origin"), + permissions.is_set(Permission::StoreOnDevice), + consent.jurisdiction.clone(), + ) + }, + ) + .expect("every value named is carried"); + assert_eq!( + first, + ( + "ExampleAgent/1.0".to_owned(), + "203.0.113.7".to_owned(), + Some("TLSv1.3".to_owned()), + StatusCode::CREATED, + true, + true, + ConsentContext::default().jurisdiction, + ) + ); + + let second = call + .inject( + &Module, + |_: &Module, + settings: &Settings, + services: &RuntimeServices, + document: &IntegrationDocumentState, + extensions: ModuleExtensions<'_>| { + ( + settings.publisher.domain.clone(), + services.client_info().client_ip, + document.get::("module").map(|value| *value), + extensions.get::(), + ) + }, + ) + .expect("every value named is carried"); + assert_eq!( + second, + ( + "publisher.example".to_owned(), + Some(services_ip), + Some(7), + Some(11), + ) + ); +} + +#[test] +fn a_value_the_context_does_not_carry_is_none_or_skips_the_call() { + let method = Method::GET; + let context = ModuleContext::new(ModuleRequest::new(&method, "h", "https", "/")); + let call = context.call("module", PermissionSet::none()); + + let absent = call + .inject( + &Module, + |_: &Module, + evidence: Option<&dyn RequestInfo>, + settings: Option<&Settings>, + permissions: Option<&PermissionState>| { + ( + evidence.is_none(), + settings.is_none(), + permissions.is_none(), + ) + }, + ) + .expect("an optional parameter never skips the call"); + assert_eq!(absent, (true, true, true)); + + let called = Cell::new(false); + let skipped = call + .inject(&Module, |_: &Module, _settings: &Settings| called.set(true)) + .expect_err("a parameter the context does not carry skips the call"); + assert!(!called.get(), "the function is not called"); + assert_eq!(skipped.value(), "settings"); + assert_eq!(skipped.reason(), WithheldReason::Absent); +} + +/// The three gated values, each set on a context that needs `requires` for +/// it, read back through `Option` parameters by a module declaring +/// `declared`. +fn gated_values( + permissions: Option<&PermissionState>, + requires: PermissionSet, + declared: PermissionSet, +) -> (bool, bool, bool) { + let method = Method::GET; + let geo = geo(); + let device = device(); + let mut context = ModuleContext::new(ModuleRequest::new(&method, "h", "https", "/")) + .with_geo(&geo, requires) + .with_device(&device, requires) + .with_edge_cookie("an-id", requires); + if let Some(permissions) = permissions { + context = context.with_permissions(permissions); + } + context + .call("module", declared) + .inject( + &Module, + |_: &Module, + geo: Option<&GeoInfo>, + device: Option<&DeviceSignals>, + edge_cookie: Option>| { + (geo.is_some(), device.is_some(), edge_cookie.is_some()) + }, + ) + .expect("an optional parameter never skips the call") +} + +#[test] +fn a_gated_value_reaches_a_module_that_declares_its_permission_and_is_granted_it() { + let granted = PermissionState::new(storage()); + + assert_eq!( + gated_values(Some(&granted), storage(), storage()), + (true, true, true) + ); +} + +#[test] +fn a_gated_value_is_withheld_from_a_module_that_does_not_declare_its_permission() { + let granted = PermissionState::new(storage()); + + assert_eq!( + gated_values(Some(&granted), storage(), PermissionSet::none()), + (false, false, false), + "granted on the request is not enough without the declaration" + ); +} + +#[test] +fn a_gated_value_is_withheld_when_its_permission_is_not_set() { + let refused = PermissionState::default(); + + assert_eq!( + gated_values(Some(&refused), storage(), storage()), + (false, false, false), + "the declaration is not enough without the permission being set" + ); + assert_eq!( + gated_values(None, storage(), storage()), + (false, false, false), + "and nothing is granted where no permissions are resolved" + ); +} + +#[test] +fn a_value_whose_producer_declares_nothing_reaches_every_module() { + assert_eq!( + gated_values(None, PermissionSet::none(), PermissionSet::none()), + (true, true, true), + "evidence is not rationed" + ); +} + +#[test] +fn a_withheld_value_skips_a_call_that_names_it_and_says_why() { + let method = Method::GET; + let geo = geo(); + let refused = PermissionState::default(); + let context = ModuleContext::new(ModuleRequest::new(&method, "h", "https", "/")) + .with_geo(&geo, storage()) + .with_permissions(&refused); + let called = Cell::new(false); + + let undeclared = context + .call("module", PermissionSet::none()) + .inject(&Module, |_: &Module, _geo: &GeoInfo| called.set(true)) + .expect_err("geo is withheld from a module that does not declare storage"); + let unset = context + .call("module", storage()) + .inject(&Module, |_: &Module, _geo: &GeoInfo| called.set(true)) + .expect_err("geo is withheld while storage is not set"); + + assert!(!called.get(), "a skipped call is never made"); + assert_eq!(undeclared.reason(), WithheldReason::NotDeclared(storage())); + assert_eq!(unset.reason(), WithheldReason::NotGranted(storage())); + assert_eq!( + undeclared.to_string(), + "geo is withheld because the module does not declare necessary.operations.storage, \ + which its use needs" + ); +} + +#[test] +fn an_argument_of_the_callers_own_is_passed_after_the_module() { + let method = Method::GET; + let context = ModuleContext::new(ModuleRequest::new(&method, "h", "https", "/path")); + + let answer = context + .call("module", PermissionSet::none()) + .inject_with( + &Module, + String::from("argument"), + |_: &Module, argument: String, request: ModuleRequest<'_>| { + format!("{argument} {}", request.path()) + }, + ) + .expect("nothing is withheld"); + + assert_eq!(answer, "argument /path"); +} + +impl Module { + async fn classify(&self, evidence: &dyn RequestInfo, services: &RuntimeServices) -> String { + format!( + "{} {}", + evidence.user_agent(), + services.client_info().client_ip.is_some() + ) + } + + #[allow( + clippy::too_many_arguments, + reason = "the most parameters a process function may name" + )] + fn eight( + &self, + _request: ModuleRequest<'_>, + _evidence: &dyn RequestInfo, + _permissions: &PermissionState, + _settings: &Settings, + _services: &RuntimeServices, + _geo: Option<&GeoInfo>, + _device: Option<&DeviceSignals>, + _edge_cookie: Option>, + ) -> usize { + 8 + } +} + +#[test] +fn an_async_function_is_called_and_its_future_comes_back() { + let method = Method::GET; + let evidence = evidence(); + let services = noop_services_with_client_ip("203.0.113.9".parse().expect("should parse an IP")); + let context = ModuleContext::new(ModuleRequest::new(&method, "h", "https", "/")) + .with_evidence(&evidence) + .with_services(&services); + let call = context.call("module", PermissionSet::none()); + + let future = call + .inject(&Module, Module::classify) + .expect("nothing is withheld"); + + assert_eq!(futures::executor::block_on(future), "ExampleAgent/1.0 true"); +} + +#[test] +fn a_function_may_name_eight_values_and_a_tuple_groups_more() { + let method = Method::GET; + let evidence = evidence(); + let permissions = PermissionState::default(); + let deployment = Settings::default(); + let request_services = noop_services(); + let context = ModuleContext::new(ModuleRequest::new(&method, "h", "https", "/")) + .with_evidence(&evidence) + .with_permissions(&permissions) + .with_settings(&deployment) + .with_services(&request_services); + let call = context.call("module", PermissionSet::none()); + + assert_eq!(call.inject(&Module, Module::eight).expect("all carried"), 8); + let grouped = call + .inject( + &Module, + |_: &Module, (settings, services): (&Settings, &RuntimeServices)| { + ( + std::ptr::eq(settings, &deployment), + std::ptr::eq(services, &request_services), + ) + }, + ) + .expect("a tuple is passed when every value in it is"); + assert_eq!( + grouped, + (true, true), + "the tuple holds the context's own values" + ); +} + +#[test] +fn a_writable_extension_map_keeps_what_a_module_puts_in_it() { + let map = Mutex::new(http::Extensions::new()); + let writable = ModuleExtensions::writable(&map); + let read_only_map = http::Extensions::new(); + let read_only = ModuleExtensions::read_only(&read_only_map); + + writable + .insert(String::from("hint")) + .expect("a writable map takes a value"); + assert_eq!(writable.get::().as_deref(), Some("hint")); + assert_eq!( + read_only.insert(String::from("hint")), + Err(String::from("hint")), + "a read-only map gives the value back" + ); + assert_eq!(read_only.get::(), None); +} + +#[test] +fn the_request_state_carries_each_value_gated_by_what_its_producer_declares() { + let method = Method::GET; + let mut state = EcContext::new_for_test(Some("an-id".to_owned()), ConsentContext::default()) + .with_module_for_test(Arc::new(HmacModule::new(Redacted::new( + "test-secret-key-32-bytes-minimum".to_owned(), + )))) + .with_geo_for_test(geo()); + state.set_device_signals(device()); + let services = + noop_services().with_device_module(Arc::new(crate::ec::device::BuiltinDeviceModule)); + let context = ModuleContext::new(ModuleRequest::new(&method, "h", "https", "/")) + .with_request_state(&state, &services); + + let undeclared = context + .call("module", PermissionSet::none()) + .inject( + &Module, + |_: &Module, + geo: Option<&GeoInfo>, + device: Option<&DeviceSignals>, + edge_cookie: Option>, + permissions: &PermissionState| { + ( + geo.map(|geo| geo.country.clone()), + device.is_some(), + edge_cookie.map(|id| id.as_str().to_owned()), + permissions.is_set(Permission::StoreOnDevice), + ) + }, + ) + .expect("nothing named is required"); + assert_eq!( + undeclared, + (Some("GB".to_owned()), true, None, true), + "geo and the device signals come from producers declaring nothing, and the \ + identifier needs what the HMAC module declares" + ); + + let declared = context + .call("module", storage()) + .inject(&Module, |_: &Module, edge_cookie: EdgeCookie<'_>| { + edge_cookie.as_str().to_owned() + }) + .expect("a module declaring storage is passed the identifier"); + assert_eq!(declared, "an-id"); +} diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs index 8652bfd0d7..122b01dc52 100644 --- a/crates/trusted-server-core/src/permissions.rs +++ b/crates/trusted-server-core/src/permissions.rs @@ -288,6 +288,12 @@ impl PermissionSet { Self(self.0 | other.0) } + /// The permissions in this set and not in `other`. + #[must_use] + pub const fn difference(self, other: PermissionSet) -> Self { + Self(self.0 & !other.0) + } + /// Iterates the permissions in the set, in bit-index order. /// /// The built-ins read nothing from the full set; this serves a module or @@ -297,6 +303,23 @@ impl PermissionSet { } } +/// The Data Use identifiers in the set, in bit-index order, separated by +/// `, `, or `nothing` for the empty set. +impl core::fmt::Display for PermissionSet { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + if self.is_empty() { + return f.write_str("nothing"); + } + for (index, permission) in self.iter().enumerate() { + if index > 0 { + f.write_str(", ")?; + } + f.write_str(permission.as_str())?; + } + Ok(()) + } +} + impl FromIterator for PermissionSet { fn from_iter>(iter: I) -> Self { iter.into_iter() From 8921de1919377137f24dc7b44e061b4038345d95 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 8 Oct 2026 17:51:29 +0100 Subject: [PATCH 2/8] The device module is handed the module context `DeviceModule::detect` takes a `ModuleCall`, and a device module names what it reads through `inject`. Classification now sees the request it classifies, being its method, address, host and scheme, and the settings, as well as the evidence and the services it had before. The Fastly entry point is the one place a device module is asked. It builds the context from the Fastly request before that request is converted, reading the host from the `Host` header and the scheme from the TLS mark the entry point has already set from Fastly's own metadata. The module it asks is borrowed, whether a registration supplied it or the `[device]` selector built it, so the request allocates nothing more than before. Classification runs before the permissions are resolved, so no value whose use needs a permission is passed to it. Signals stay infallible. A module whose function is not passed what it names answers `DeviceSignals::unknown`, an unknown device that does not look like a browser, and a test asks the built-in module with no evidence to show it. The built-in module converts in core, and the Fastly module and the seam probe's in their own crates. `module_context::test_support::request` gives a module's own tests a request, behind the `test-utils` feature. --- crates/device/fastly/src/lib.rs | 32 ++++--- crates/testing/seam-probe/src/lib.rs | 7 +- .../trusted-server-adapter-fastly/src/lib.rs | 92 ++++++++++++++---- crates/trusted-server-core/src/ec/device.rs | 96 ++++++++++++------- .../src/integrations/registry.rs | 6 +- .../trusted-server-core/src/module_context.rs | 16 ++++ 6 files changed, 171 insertions(+), 78 deletions(-) diff --git a/crates/device/fastly/src/lib.rs b/crates/device/fastly/src/lib.rs index 7ed8399ff3..3a71e493a5 100644 --- a/crates/device/fastly/src/lib.rs +++ b/crates/device/fastly/src/lib.rs @@ -3,7 +3,8 @@ //! [`FastlyDeviceModule`] strengthens the built-in User-Agent classification //! with the host's TLS (JA4) and HTTP/2 signals, for deployments on Fastly //! Compute. It is selected by `[device] module = "fastly"` and wired in by the -//! Fastly adapter, which injects the request info and the captured host signals. +//! Fastly adapter, which injects the captured host signals and hands the module +//! the request's module context. //! //! [`FastlyHostSignals`] captures those signals from a live Fastly request //! (`get_tls_ja4()`, `get_client_h2_fingerprint()`) into owned values, so it can @@ -20,6 +21,7 @@ use std::sync::Arc; use fastly::Request as FastlyRequest; use trusted_server_core::ec::device::{DeviceModule, DeviceSignals}; use trusted_server_core::evidence::{HostSignals, RequestInfo}; +use trusted_server_core::module_context::ModuleCall; /// Host-computed client signals captured from a live Fastly request. /// @@ -68,9 +70,9 @@ impl HostSignals for FastlyHostSignals { /// /// Classifies a request with [`DeviceSignals::derive`], which strengthens the /// User-Agent classification with the host signals. It reads the User-Agent -/// from its injected [`RequestInfo`] and the TLS and HTTP/2 signals from its -/// injected [`HostSignals`], so the browser/bot gate is backed by the live -/// request. +/// from the [`RequestInfo`] it names in the module context and the TLS and +/// HTTP/2 signals from its injected [`HostSignals`], so the browser/bot gate +/// is backed by the live request. pub struct FastlyDeviceModule { host_signals: Arc, } @@ -81,6 +83,15 @@ impl FastlyDeviceModule { pub fn new(host_signals: Arc) -> Self { Self { host_signals } } + + /// The signals the User-Agent and the host signals give together. + fn classify(&self, request_info: &dyn RequestInfo) -> DeviceSignals { + DeviceSignals::derive( + request_info.user_agent(), + self.host_signals.ja4(), + self.host_signals.h2(), + ) + } } #[async_trait::async_trait(?Send)] @@ -89,15 +100,8 @@ impl DeviceModule for FastlyDeviceModule { "fastly" } - async fn detect( - &self, - request_info: &dyn RequestInfo, - _services: &trusted_server_core::platform::RuntimeServices, - ) -> DeviceSignals { - DeviceSignals::derive( - request_info.user_agent(), - self.host_signals.ja4(), - self.host_signals.h2(), - ) + async fn detect(&self, call: ModuleCall<'_>) -> DeviceSignals { + call.inject(self, Self::classify) + .unwrap_or_else(|_| DeviceSignals::unknown()) } } diff --git a/crates/testing/seam-probe/src/lib.rs b/crates/testing/seam-probe/src/lib.rs index af6c3db1fa..74f93ac999 100644 --- a/crates/testing/seam-probe/src/lib.rs +++ b/crates/testing/seam-probe/src/lib.rs @@ -45,6 +45,7 @@ use trusted_server_core::integrations::{ CarriedJsModule, IntegrationBuilder, IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, }; +use trusted_server_core::module_context::ModuleCall; use trusted_server_core::platform::{ GeoInfo, PlatformError, PlatformGeo, PlatformResponse, RuntimeServices, }; @@ -247,11 +248,7 @@ impl DeviceModule for SeamProbeDevice { module_name() } - async fn detect( - &self, - _request_info: &dyn RequestInfo, - _services: &trusted_server_core::platform::RuntimeServices, - ) -> DeviceSignals { + async fn detect(&self, _call: ModuleCall<'_>) -> DeviceSignals { DeviceSignals { is_mobile: 1, platform_class: Some("seam-probe".to_owned()), diff --git a/crates/trusted-server-adapter-fastly/src/lib.rs b/crates/trusted-server-adapter-fastly/src/lib.rs index 1cb655b217..61cd2bde41 100644 --- a/crates/trusted-server-adapter-fastly/src/lib.rs +++ b/crates/trusted-server-adapter-fastly/src/lib.rs @@ -33,6 +33,7 @@ use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::TrustedServerError; use trusted_server_core::evidence::{BorrowedRequestInfo, HostSignals}; use trusted_server_core::integrations::RequestFilterEffects; +use trusted_server_core::module_context::{ModuleContext, ModuleRequest}; use trusted_server_core::platform::build_geo_module; use trusted_server_core::platform::{ ClientInfo, PlatformKvStore, RuntimeServices, UnavailableKvStore, @@ -960,7 +961,9 @@ pub(crate) fn extract_cookie_value(req: &HttpRequest, name: &str) -> Option>, @@ -985,18 +988,46 @@ pub(crate) async fn derive_device_signals( .map(|ip| ip.to_string()) .unwrap_or_default(); let request_info = BorrowedRequestInfo::new(&client_ip, None).with_headers(&headers); - match registered { - Some(module) => module.detect(&request_info, services).await, + let built; + let module: &dyn DeviceModule = match registered.as_deref() { + Some(module) => module, None => { - build_device_module(settings, || { + built = build_device_module(settings, || { let host_signals: Arc = Arc::new(FastlyHostSignals::from_request(req)); Box::new(FastlyDeviceModule::new(host_signals)) as Box - }) - .detect(&request_info, services) - .await + }); + built.as_ref() } - } + }; + let context = ModuleContext::new(entry_request(req)) + .with_evidence(&request_info) + .with_settings(settings) + .with_services(services); + module + .detect(context.call(module.id(), module.required_permissions())) + .await +} + +/// What the client's request resolved to, read off the Fastly request before +/// it is converted. +/// +/// The scheme is `https` when the entry point marked the request as arriving +/// over TLS, which it does from Fastly's own TLS metadata. +fn entry_request(req: &FastlyRequest) -> ModuleRequest<'_> { + let scheme = if req.get_header_str("fastly-ssl").is_some() { + "https" + } else { + "http" + }; + ModuleRequest::new( + req.get_method(), + req.get_header_str(header::HOST.as_str()) + .unwrap_or_default(), + scheme, + req.get_path(), + ) + .with_query(req.get_query_str().unwrap_or_default()) } #[cfg(test)] @@ -1041,10 +1072,30 @@ mod tests { } /// A device module standing in for one a registration supplies. It marks - /// the signals it returns with the cookie header it was shown, so a test - /// can tell both that it ran and what it saw. + /// the signals it returns with the cookie header, the host, the scheme + /// and the address it was shown, so a test can tell both that it ran and + /// what it saw. struct MarkedDeviceModule; + impl MarkedDeviceModule { + fn mark( + &self, + request: ModuleRequest<'_>, + request_info: &dyn trusted_server_core::evidence::RequestInfo, + ) -> DeviceSignals { + let mut signals = DeviceSignals::derive_ua_only(request_info.user_agent()); + signals.platform_class = Some(format!( + "marked:{}:{}://{}{}?{}", + request_info.header("cookie").unwrap_or_default(), + request.scheme(), + request.host(), + request.path(), + request.query(), + )); + signals + } + } + #[async_trait::async_trait(?Send)] impl DeviceModule for MarkedDeviceModule { fn id(&self) -> &'static str { @@ -1053,22 +1104,20 @@ mod tests { async fn detect( &self, - request_info: &dyn trusted_server_core::evidence::RequestInfo, - _services: &RuntimeServices, + call: trusted_server_core::module_context::ModuleCall<'_>, ) -> DeviceSignals { - let mut signals = DeviceSignals::derive_ua_only(request_info.user_agent()); - signals.platform_class = Some(format!( - "marked:{}", - request_info.header("cookie").unwrap_or_default() - )); - signals + call.inject(self, Self::mark) + .unwrap_or_else(|_| DeviceSignals::unknown()) } } fn page_request() -> FastlyRequest { - let mut req = FastlyRequest::get("https://example.com/"); + let mut req = FastlyRequest::get("https://example.com/article?page=2"); + req.set_header("host", "example.com"); req.set_header("user-agent", "Mozilla/5.0 (X11; Linux x86_64) Chrome/140.0"); req.set_header("cookie", "example_width=1280"); + // The entry point marks a request that arrived over TLS this way. + req.set_header("fastly-ssl", "1"); req } @@ -1087,8 +1136,9 @@ mod tests { assert_eq!( signals.platform_class.as_deref(), - Some("marked:example_width=1280"), - "the registered module should run and see the page's cookies" + Some("marked:example_width=1280:https://example.com/article?page=2"), + "the registered module should run and see the page's cookies and the request \ + it classifies" ); } diff --git a/crates/trusted-server-core/src/ec/device.rs b/crates/trusted-server-core/src/ec/device.rs index 950942ecc2..415151c26d 100644 --- a/crates/trusted-server-core/src/ec/device.rs +++ b/crates/trusted-server-core/src/ec/device.rs @@ -1,11 +1,12 @@ //! Device signal derivation for bot detection and browser classification. //! //! The [`DeviceSignals`] derivation here is pure computation, with no KV I/O or -//! Fastly SDK calls. A [`DeviceModule`] is wired by dependency injection. It -//! reads the [`RequestInfo`] for the User-Agent from the borrowed argument -//! passed to `detect` at call time, and on a host that supplies them the -//! [`HostSignals`](crate::evidence::HostSignals) for the TLS and HTTP/2 signals -//! injected into its constructor, then classifies the request from both. +//! Fastly SDK calls. A [`DeviceModule`] is handed the request's module context +//! when `detect` is called and names what it reads from it, the +//! [`RequestInfo`] for the User-Agent among them. On a host that supplies +//! them, it also reads the [`HostSignals`](crate::evidence::HostSignals) for +//! the TLS and HTTP/2 signals injected into its constructor, then classifies +//! the request from both. //! //! # Signals //! @@ -21,6 +22,7 @@ use sha2::{Digest as _, Sha256}; use super::kv_types::KvDevice; use crate::evidence::RequestInfo; +use crate::module_context::ModuleCall; use crate::settings::Settings; /// Device signals derived from a single request. @@ -49,6 +51,13 @@ pub struct DeviceSignals { } impl DeviceSignals { + /// The signals of a request nothing could be read from, being an unknown + /// device that does not look like a browser. + #[must_use] + pub fn unknown() -> Self { + Self::derive_ua_only("") + } + /// Derives device signals from the User-Agent alone, with no /// host-specific TLS or HTTP/2 evidence. /// @@ -138,22 +147,23 @@ pub trait DeviceModule: Send + Sync { /// and logs. fn id(&self) -> &'static str; - /// Classifies the request into [`DeviceSignals`], reading the request data - /// it needs from the [`RequestInfo`] passed borrowed at call time (plus any - /// host signals injected into its constructor). + /// Classifies the request into [`DeviceSignals`]. + /// + /// An implementation hands its own function to [`ModuleCall::inject`], + /// naming what it reads, usually the request's evidence and the + /// services, as well as any host signals injected into its constructor. + /// Classification runs before the permissions are resolved, so no value + /// whose use needs a permission is passed here. /// /// Device signals gate identity operations and must always yield a value, - /// so this is infallible: a module that cannot determine a signal returns - /// the unknown variant rather than failing the request. + /// so this is infallible. A module that cannot determine a signal, or + /// whose function was not passed what it names, returns + /// [`DeviceSignals::unknown`] rather than failing the request. /// Asynchronous because a device module may reach a backend, a /// key-value store or a secret to classify a request, and a module that /// cannot make those calls cannot be written at all. The built-in - /// User-Agent provider does no I/O and returns immediately. - async fn detect( - &self, - request_info: &dyn RequestInfo, - services: &crate::platform::RuntimeServices, - ) -> DeviceSignals; + /// User-Agent module does no I/O and returns immediately. + async fn detect(&self, call: ModuleCall<'_>) -> DeviceSignals; /// The permissions this module's data use requires. /// @@ -179,6 +189,11 @@ impl BuiltinDeviceModule { pub fn new() -> Self { Self } + + /// The signals the User-Agent alone gives. + fn classify(&self, request_info: &dyn RequestInfo) -> DeviceSignals { + DeviceSignals::derive_ua_only(request_info.user_agent()) + } } #[async_trait::async_trait(?Send)] @@ -187,12 +202,9 @@ impl DeviceModule for BuiltinDeviceModule { "builtin" } - async fn detect( - &self, - request_info: &dyn RequestInfo, - _services: &crate::platform::RuntimeServices, - ) -> DeviceSignals { - DeviceSignals::derive_ua_only(request_info.user_agent()) + async fn detect(&self, call: ModuleCall<'_>) -> DeviceSignals { + call.inject(self, Self::classify) + .unwrap_or_else(|_| DeviceSignals::unknown()) } } @@ -402,6 +414,7 @@ fn evaluate_known_browser(ja4_class: Option<&str>, h2_fp_hash: Option<&str>) -> mod tests { use super::*; use crate::evidence::OwnedRequestInfo; + use crate::module_context::{ModuleContext, test_support}; // Chrome Mac UA const CHROME_MAC_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \ @@ -801,14 +814,12 @@ mod tests { let module = BuiltinDeviceModule::new(); assert_eq!(module.id(), "builtin"); - // The built-in module classifies from the User-Agent in the request - // info passed to `detect` alone, recording no host signal. + // The built-in module classifies from the User-Agent in the request's + // evidence alone, recording no host signal. let request_info = request_info_with_ua(CHROME_MAC_UA); + let context = ModuleContext::new(test_support::request("/")).with_evidence(&request_info); let signals = module - .detect( - &request_info, - &crate::platform::test_support::noop_services(), - ) + .detect(context.call(module.id(), module.required_permissions())) .await; assert_eq!( signals, @@ -821,6 +832,27 @@ mod tests { ); } + #[tokio::test] + async fn a_device_module_not_passed_what_it_names_answers_unknown() { + let module = BuiltinDeviceModule::new(); + // A context with no evidence, as for work shared by every reader. + let context = ModuleContext::new(test_support::request("/")); + + let signals = module + .detect(context.call(module.id(), module.required_permissions())) + .await; + + assert_eq!( + signals, + DeviceSignals::unknown(), + "should answer the unknown device rather than fail the request" + ); + assert!( + !signals.looks_like_browser, + "the unknown device should not look like a browser" + ); + } + /// Builds request info carrying the given User-Agent, for module tests. fn request_info_with_ua(user_agent: &str) -> OwnedRequestInfo { let mut headers = http::HeaderMap::new(); @@ -842,12 +874,8 @@ mod tests { "fastly" } - async fn detect( - &self, - _request_info: &dyn RequestInfo, - _services: &crate::platform::RuntimeServices, - ) -> DeviceSignals { - DeviceSignals::derive_ua_only("") + async fn detect(&self, _call: ModuleCall<'_>) -> DeviceSignals { + DeviceSignals::unknown() } } diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index a618dafddc..6c88f4df4e 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -5184,8 +5184,7 @@ mod tests { async fn detect( &self, - _request_info: &dyn crate::evidence::RequestInfo, - _services: &crate::platform::RuntimeServices, + _call: crate::module_context::ModuleCall<'_>, ) -> crate::ec::device::DeviceSignals { crate::ec::device::DeviceSignals { is_mobile: 2, @@ -5507,8 +5506,7 @@ mod tests { async fn detect( &self, - _request_info: &dyn crate::evidence::RequestInfo, - _services: &crate::platform::RuntimeServices, + _call: crate::module_context::ModuleCall<'_>, ) -> crate::ec::device::DeviceSignals { crate::ec::device::DeviceSignals { is_mobile: 1, diff --git a/crates/trusted-server-core/src/module_context.rs b/crates/trusted-server-core/src/module_context.rs index e3513af18e..4c7c54c369 100644 --- a/crates/trusted-server-core/src/module_context.rs +++ b/crates/trusted-server-core/src/module_context.rs @@ -771,5 +771,21 @@ tuple_from_module_context!(A1, A2, A3, A4, A5, A6); tuple_from_module_context!(A1, A2, A3, A4, A5, A6, A7); tuple_from_module_context!(A1, A2, A3, A4, A5, A6, A7, A8); +/// Requests for a module's own tests, which have no request to resolve. +#[cfg(any(test, feature = "test-utils"))] +pub mod test_support { + use http::Method; + + use super::ModuleRequest; + + static GET: Method = Method::GET; + + /// A `GET` of `path` on `publisher.example` over `https`, with no query. + #[must_use] + pub fn request(path: &'static str) -> ModuleRequest<'static> { + ModuleRequest::new(&GET, "publisher.example", "https", path) + } +} + #[cfg(test)] mod tests; From 80a352706ba3d95b5266b2b179902ee39499e239 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 8 Oct 2026 18:16:33 +0100 Subject: [PATCH 3/8] The Edge Cookie module is handed the module context `EdgeCookieModule::generate` takes a `ModuleCall`, and `resolve_from_client` takes a `ModuleCall` and the posted payload. A module names what it needs through `inject`, or `inject_with` for the payload. `IdentityInput` and `ClientResolveInput` are gone, because the permissions and consent they carried are in the context, along with the request the identifier is created for, its host and scheme, the settings, the location and the device signals. `EcContext` snapshots what the request resolved to beside the headers it already kept, under the same condition, so generation later in the request and orphan recovery hand the module the request they are for. The snapshot holds the host and the scheme where it held the path and the query alone. `candidate_id` takes the settings and builds the context from the snapshot and its own state. The resolve endpoint builds the context from the resolve request and the state read for it, and lends the module the normalized client IP that state holds, the same form the module sees when it generates. The built-in HMAC and host signal modules, the `client_fixed` module and the shared wrapper convert in core, and the seam probe's module in its own crate. A module whose function is not passed what it names produces no identifier. The HMAC module still refuses a request whose evidence carries no client IP. The test of the evidence a module reads at generate time also checks the host and address reach it, and the resolve round trip checks the module is handed the resolve request. --- crates/testing/seam-probe/src/lib.rs | 31 +- .../tests/seam_probe.rs | 14 +- .../src/app/seam_probe_tests.rs | 16 +- crates/trusted-server-core/src/ec/finalize.rs | 13 +- crates/trusted-server-core/src/ec/mod.rs | 205 +++++++------ crates/trusted-server-core/src/ec/module.rs | 282 ++++++++---------- crates/trusted-server-core/src/ec/resolve.rs | 107 ++++--- crates/trusted-server-core/src/edge_cookie.rs | 13 +- .../src/integrations/registry.rs | 4 +- .../trusted-server-core/src/module_context.rs | 5 +- 10 files changed, 379 insertions(+), 311 deletions(-) diff --git a/crates/testing/seam-probe/src/lib.rs b/crates/testing/seam-probe/src/lib.rs index 74f93ac999..5380043f2d 100644 --- a/crates/testing/seam-probe/src/lib.rs +++ b/crates/testing/seam-probe/src/lib.rs @@ -36,9 +36,7 @@ use trusted_server_core::auction::demand::{ use trusted_server_core::auction::provider::{AuctionProvider, ProviderRequestOutcome}; use trusted_server_core::auction::types::{AuctionContext, AuctionRequest, AuctionResponse}; use trusted_server_core::ec::device::{DeviceModule, DeviceSignals}; -use trusted_server_core::ec::module::{ - EdgeCookieModule, GeneratedEdgeCookie, IdentityInput, ModuleCode, -}; +use trusted_server_core::ec::module::{EdgeCookieModule, GeneratedEdgeCookie, ModuleCode}; use trusted_server_core::error::TrustedServerError; use trusted_server_core::evidence::RequestInfo; use trusted_server_core::integrations::{ @@ -207,6 +205,20 @@ pub struct SeamProbeEc; /// The four-character code core stamps on identifiers this module owns. const SEAM_PROBE_EC_CODE: ModuleCode = trusted_server_core::module_code!("sprb"); +impl SeamProbeEc { + /// An identifier derived from the client IP in the request's evidence. + fn derive(&self, request_info: &dyn RequestInfo) -> GeneratedEdgeCookie { + // Derived from evidence so the test can prove this ran rather than the + // built-in module, and prove the evidence actually arrived. + let ip = request_info.client_ip(); + let id = format!("seam-probe-{}", if ip.is_empty() { "no-ip" } else { ip }); + GeneratedEdgeCookie { + id: Some(id), + response_headers: Vec::new(), + } + } +} + #[async_trait::async_trait(?Send)] impl EdgeCookieModule for SeamProbeEc { fn id(&self) -> &'static str { @@ -219,18 +231,9 @@ impl EdgeCookieModule for SeamProbeEc { async fn generate( &self, - request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &trusted_server_core::platform::RuntimeServices, + call: ModuleCall<'_>, ) -> Result> { - // Derived from evidence so the test can prove this ran rather than the - // built-in module, and prove the evidence actually arrived. - let ip = request_info.client_ip(); - let id = format!("seam-probe-{}", if ip.is_empty() { "no-ip" } else { ip }); - Ok(GeneratedEdgeCookie { - id: Some(id), - response_headers: Vec::new(), - }) + Ok(call.inject(self, Self::derive).unwrap_or_default()) } fn accepts_id(&self, value: &str) -> bool { diff --git a/crates/trusted-server-adapter-axum/tests/seam_probe.rs b/crates/trusted-server-adapter-axum/tests/seam_probe.rs index 740cf7dcf5..82e1db37cd 100644 --- a/crates/trusted-server-adapter-axum/tests/seam_probe.rs +++ b/crates/trusted-server-adapter-axum/tests/seam_probe.rs @@ -18,12 +18,12 @@ use tower::{Service as _, ServiceExt as _}; use trusted_server_adapter_axum::app::TrustedServerApp; use trusted_server_core::auction::compile_auction_plan; use trusted_server_core::config::validate_settings_for_deploy_with; -use trusted_server_core::ec::module::IdentityInput; use trusted_server_core::error::TrustedServerError; use trusted_server_core::evidence::OwnedRequestInfo; use trusted_server_core::integrations::{ IntegrationBuilder, IntegrationRegistration, IntegrationRegistry, }; +use trusted_server_core::module_context::{ModuleContext, ModuleRequest}; use trusted_server_core::platform::{ BackendNamingPolicy, ClientInfo, DisabledGeo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformSecretStore, RuntimeServices, StoreId, StoreName, @@ -697,8 +697,18 @@ async fn ec_module_generates_an_identifier_with_the_modules_prefix() { .expect("`[ec] module = \"testing.seam-probe\"` should resolve the module's module"); let request_info = OwnedRequestInfo::new("192.0.2.1".to_owned(), HeaderMap::new()); + let services = stub_services(); + let method = axum::http::Method::GET; + let context = ModuleContext::new(ModuleRequest::new( + &method, + "publisher.example", + "https", + "/", + )) + .with_evidence(&request_info) + .with_services(&services); let generated = module - .generate(&request_info, &IdentityInput::default(), &stub_services()) + .generate(context.call(module.id(), module.required_permissions())) .await .expect("the module's module should generate an identifier"); diff --git a/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs b/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs index 2ae2f01ff0..251a9b62f7 100644 --- a/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs @@ -14,10 +14,10 @@ use edgezero_core::http::{HeaderMap, Method, Response, request_builder}; use edgezero_core::router::RouterService; use error_stack::Report; use futures::executor::block_on; -use trusted_server_core::ec::module::IdentityInput; use trusted_server_core::error::TrustedServerError; use trusted_server_core::evidence::OwnedRequestInfo; use trusted_server_core::integrations::{IntegrationBuilder, IntegrationRegistration}; +use trusted_server_core::module_context::{ModuleContext, ModuleRequest}; use trusted_server_core::settings::Settings; use trusted_server_core::tsjs::tsjs_script_src; use trusted_server_core::tsjs_bundle::{JsModulePart, compile_time_parts}; @@ -439,8 +439,18 @@ fn ec_and_device_selectors_naming_a_module_resolve_the_modules_it_declares() { let services = build_finalize_services(&state.settings, Arc::clone(&state.default_kv_store)); let request_info = OwnedRequestInfo::new("192.0.2.1".to_owned(), HeaderMap::new()); - let generated = block_on(module.generate(&request_info, &IdentityInput::default(), &services)) - .expect("the module's Edge Cookie module should generate an identifier"); + let method = Method::GET; + let context = ModuleContext::new(ModuleRequest::new( + &method, + "publisher.example", + "https", + "/", + )) + .with_evidence(&request_info) + .with_services(&services); + let generated = + block_on(module.generate(context.call(module.id(), module.required_permissions()))) + .expect("the module's Edge Cookie module should generate an identifier"); assert_eq!( generated.id.as_deref(), diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 124acb3d64..edf8a41682 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -273,7 +273,10 @@ async fn recover_orphaned_ec( // identifier. Whether the client IP is needed is that module's decision. const MAX_RECOVERY_ATTEMPTS: usize = 5; for _attempt in 0..MAX_RECOVERY_ATTEMPTS { - let ec_id = match ec_context.candidate_id(module.as_ref(), services).await { + let ec_id = match ec_context + .candidate_id(module.as_ref(), settings, services) + .await + { Ok(Some(ec_id)) => ec_id, Ok(None) => { log::info!("Orphan EC recovery skipped because the module produced no identifier"); @@ -2328,9 +2331,7 @@ mod tests { async fn generate( &self, - _request_info: &dyn crate::evidence::RequestInfo, - _input: &crate::ec::module::IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: crate::module_context::ModuleCall<'_>, ) -> Result< crate::ec::module::GeneratedEdgeCookie, error_stack::Report, @@ -2456,9 +2457,7 @@ mod tests { async fn generate( &self, - _request_info: &dyn crate::evidence::RequestInfo, - _input: &crate::ec::module::IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: crate::module_context::ModuleCall<'_>, ) -> Result< crate::ec::module::GeneratedEdgeCookie, error_stack::Report, diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index b878160ceb..a72eb9bedc 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -89,11 +89,12 @@ use crate::ec::cookies::ec_id_has_only_allowed_chars; use crate::error::TrustedServerError; use crate::evidence::BorrowedRequestInfo; use crate::geo::GeoInfo; +use crate::module_context::{ModuleContext, ResolvedRequest}; use crate::permissions::{Permission, PermissionState}; use crate::platform::RuntimeServices; use crate::settings::Settings; use device::DeviceSignals; -use module::{EdgeCookieModule, GeneratedEdgeCookie, IdentityInput}; +use module::{EdgeCookieModule, GeneratedEdgeCookie}; use self::kv::{CreateIfAbsentOutcome, KvIdentityGraph}; use self::kv_types::KvEntry; @@ -277,18 +278,17 @@ pub struct EcContext { /// instead of being dropped by the built-in shape check. `None` when no /// module is configured. selected_module: Option>, - /// A snapshot of the request evidence a module reads when it creates an - /// identifier, at generation or in orphan recovery: the request headers (so - /// a module can read cookies and client hints), and the URL path and query - /// string (so it can read request parameters). Captured once at - /// construction, when a module is configured and the request either - /// carries no usable identifier or is a document navigation, the only - /// request that can recover an orphaned one. So a no-module deployment - /// and a returning visitor's subresource requests clone nothing. A module - /// reads these through [`RequestInfo`](crate::evidence::RequestInfo). + /// A snapshot of the request a module is handed when it creates an + /// identifier, at generation or in orphan recovery, being the request + /// headers (so a module can read cookies and client hints) and what the + /// request resolved to (its method, address, host and scheme). Captured + /// once at construction, when a module is configured and the request + /// either carries no usable identifier or is a document navigation, the + /// only request that can recover an orphaned one. So a no-module + /// deployment and a returning visitor's subresource requests clone + /// nothing. A module reads them through its module call. request_headers: http::HeaderMap, - request_path: String, - request_query: String, + request: ResolvedRequest, /// Response headers a module asked to set, captured when it creates an /// identifier (in [`EcContext::generate_if_needed`], or during orphan /// recovery in EC finalization) and applied to the response by EC @@ -428,24 +428,23 @@ impl EcContext { log::trace!("Existing EC ID found: {}", log_id(id)); } - // Snapshot the request evidence a module reads when it creates an + // Snapshot the request a module is handed when it creates an // identifier (the headers, so it can read cookies and client hints, and - // the URL path and query, so it can read request parameters). Capture - // only when a module is configured and either no identifier exists or - // the request is a document navigation, which orphan recovery may need, - // so a no-module deployment and a returning visitor's subresource - // requests clone nothing. Creation runs after the request body may be - // consumed, so the snapshot is owned. - let (request_headers, request_path, request_query) = if selected_module.is_some() + // what the request resolved to, with the address, so it can read + // request parameters). Capture only when a module is configured and + // either no identifier exists or the request is a document navigation, + // which orphan recovery may need, so a no-module deployment and a + // returning visitor's subresource requests clone nothing. Creation runs + // after the request body may be consumed, so the snapshot is owned. + let (request_headers, request) = if selected_module.is_some() && (ec_value.is_none() || crate::http_util::is_navigation_request(req)) { ( req.headers().clone(), - req.uri().path().to_owned(), - req.uri().query().unwrap_or_default().to_owned(), + ResolvedRequest::of(req, services.client_info()), ) } else { - (http::HeaderMap::new(), String::new(), String::new()) + (http::HeaderMap::new(), ResolvedRequest::default()) }; // Capture the client IP from platform services (normalized). @@ -519,8 +518,7 @@ impl EcContext { device_signals: None, selected_module, request_headers, - request_path, - request_query, + request, response_headers: Vec::new(), kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, @@ -605,10 +603,11 @@ impl EcContext { /// identifier, and the finished identifier, with its module code applied, /// is checked against the global bounds. Generation and orphan recovery both /// use this, so a rotated identifier comes from the same module - /// and passes the same checks as a new one. The request evidence captured at - /// read time (client IP, headers, and the URL path and query) is passed - /// borrowed through [`RequestInfo`](crate::evidence::RequestInfo), and the - /// built-in HMAC module reads only the client IP. + /// and passes the same checks as a new one. The module is handed a module + /// context carrying the request captured at read time (the client IP, the + /// headers, and what the request resolved to), the settings, the services + /// and what this context resolved for the request, and the built-in HMAC + /// module reads only the client IP. /// /// # Errors /// @@ -622,19 +621,27 @@ impl EcContext { pub(crate) async fn candidate_id( &mut self, ec_module: &dyn EdgeCookieModule, + settings: &Settings, services: &RuntimeServices, ) -> Result, Report> { - let input = IdentityInput { - permissions: Some(&self.permissions), - consent: Some(&self.consent), - }; + // Lend the request captured at read time, being the client IP, the + // request headers (so a module reads cookies and client hints), and + // what the request resolved to, with the address (so it reads request + // parameters). A built-in module reads only the client IP, and a vendor + // module names what it needs. + let request = self.request.view(); let request_info = BorrowedRequestInfo::new( self.client_ip.as_deref().unwrap_or_default(), Some(&self.request_headers), ) - .with_request_target(&self.request_path, &self.request_query); - let generated: GeneratedEdgeCookie = - ec_module.generate(&request_info, &input, services).await?; + .with_request_target(request.path(), request.query()); + let context = ModuleContext::new(request) + .with_evidence(&request_info) + .with_settings(settings) + .with_request_state(self, services); + let generated: GeneratedEdgeCookie = ec_module + .generate(context.call(ec_module.id(), ec_module.required_permissions())) + .await?; // Check every response header the module asked for against core's // reserved surface before any of them are kept. A module may set its // own cookies and headers, but not a managed `ts-` cookie, a header in @@ -732,7 +739,7 @@ impl EcContext { ) -> Result<(), Report> { const MAX_CREATE_ATTEMPTS: usize = 5; for attempt in 0..MAX_CREATE_ATTEMPTS { - let Some(ec_id) = self.candidate_id(ec_module, services).await? else { + let Some(ec_id) = self.candidate_id(ec_module, settings, services).await? else { return Ok(()); }; // Key the identity graph by the module's canonical form of the @@ -1161,8 +1168,7 @@ impl EcContext { device_signals: None, selected_module: None, request_headers: http::HeaderMap::new(), - request_path: String::new(), - request_query: String::new(), + request: ResolvedRequest::default(), response_headers: Vec::new(), kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, @@ -1207,8 +1213,7 @@ impl EcContext { device_signals: None, selected_module: None, request_headers: http::HeaderMap::new(), - request_path: String::new(), - request_query: String::new(), + request: ResolvedRequest::default(), response_headers: Vec::new(), kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, @@ -1242,8 +1247,7 @@ impl EcContext { device_signals: None, selected_module: None, request_headers: http::HeaderMap::new(), - request_path: String::new(), - request_query: String::new(), + request: ResolvedRequest::default(), response_headers: Vec::new(), kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, @@ -1303,6 +1307,7 @@ pub(crate) mod tests { }; use crate::ec::module::{EcModuleSelection, ModuleCode}; use crate::evidence::RequestInfo; + use crate::module_context::{ModuleCall, ModuleRequest}; use crate::platform::test_support::noop_services; use crate::test_support::tests::create_test_settings; @@ -1485,9 +1490,7 @@ pub(crate) mod tests { async fn generate( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, ) -> Result> { Ok(GeneratedEdgeCookie::default()) } @@ -1609,31 +1612,28 @@ pub(crate) mod tests { ); } - /// A module that records the request query parameter `id` and the `Cookie` - /// header it is given at generate time, proving request evidence (parameters - /// and cookies) reaches a module through the organic generate path. + /// A module that records the request query parameter `id`, the `Cookie` + /// header, the client IP and the host and address the request resolved + /// to, as it is given them at generate time, proving the request reaches a + /// module through the organic generate path. #[derive(Debug, Default)] struct EvidenceCapturingModule { seen: std::sync::Mutex>, seen_client_ip: std::sync::Mutex>, + seen_request: std::sync::Mutex>, } - #[async_trait::async_trait(?Send)] - impl EdgeCookieModule for EvidenceCapturingModule { - fn id(&self) -> &'static str { - "evidence" - } - - fn code(&self) -> ModuleCode { - crate::module_code!("t0ev") - } - - async fn generate( + impl EvidenceCapturingModule { + fn capture( &self, request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + request: ModuleRequest<'_>, ) -> Result> { + *self + .seen_request + .lock() + .expect("should lock the seen request") = + Some((request.host().to_owned(), request.path().to_owned())); let query_id = request_info.query_param("id").unwrap_or_default(); let cookie = request_info.header("cookie").unwrap_or_default().to_owned(); *self.seen.lock().expect("should lock seen evidence") = Some((query_id, cookie)); @@ -1647,6 +1647,24 @@ pub(crate) mod tests { response_headers: Vec::new(), }) } + } + + #[async_trait::async_trait(?Send)] + impl EdgeCookieModule for EvidenceCapturingModule { + fn id(&self) -> &'static str { + "evidence" + } + + fn code(&self) -> ModuleCode { + crate::module_code!("t0ev") + } + + async fn generate( + &self, + call: ModuleCall<'_>, + ) -> Result> { + call.inject(self, Self::capture)? + } fn accepts_id(&self, value: &str) -> bool { !value.is_empty() @@ -1666,6 +1684,7 @@ pub(crate) mod tests { let req = Request::builder() .method("GET") .uri("http://example.com/page?id=abc123&debug=1") + .header("host", "example.com") .header("cookie", "client-id=xyz789") .body(EdgeBody::empty()) .expect("should build request"); @@ -1688,6 +1707,15 @@ pub(crate) mod tests { Some(("abc123".to_owned(), "client-id=xyz789".to_owned())), "the module should read the request query parameter and cookies at generate time" ); + assert_eq!( + module + .seen_request + .lock() + .expect("should lock the seen request") + .clone(), + Some(("example.com".to_owned(), "/page".to_owned())), + "the module should be handed the host and address the request resolved to" + ); assert_eq!( ec.ec_value(), Some("t0ev~evidence-ec"), @@ -1720,7 +1748,7 @@ pub(crate) mod tests { .expect("should read EC context"); assert!(ec.ec_was_present(), "the identifier should be read back"); let selected = ec.selected_module().expect("a module is selected"); - ec.candidate_id(selected.as_ref(), &services) + ec.candidate_id(selected.as_ref(), &settings, &services) .await .expect("should create a replacement"); @@ -1754,9 +1782,7 @@ pub(crate) mod tests { async fn generate( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, ) -> Result> { Ok(GeneratedEdgeCookie { id: Some("Opaque_EC_Value_MixedCase_123".to_owned()), @@ -1878,24 +1904,13 @@ pub(crate) mod tests { /// config store at generate time, which is the whole point of handing modules /// the platform services. Nothing about the value is known when the /// module is constructed, so an identifier carrying it can only come from - /// a real read through the services passed to `generate`. + /// a real read through the services it names in `generate`. #[derive(Debug)] struct ConfigReadingModule; - #[async_trait::async_trait(?Send)] - impl EdgeCookieModule for ConfigReadingModule { - fn id(&self) -> &'static str { - "config-reading" - } - - fn code(&self) -> ModuleCode { - crate::module_code!("t0cr") - } - - async fn generate( + impl ConfigReadingModule { + fn read_tenant( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, services: &crate::platform::RuntimeServices, ) -> Result> { let tenant = services @@ -1911,6 +1926,24 @@ pub(crate) mod tests { response_headers: Vec::new(), }) } + } + + #[async_trait::async_trait(?Send)] + impl EdgeCookieModule for ConfigReadingModule { + fn id(&self) -> &'static str { + "config-reading" + } + + fn code(&self) -> ModuleCode { + crate::module_code!("t0cr") + } + + async fn generate( + &self, + call: ModuleCall<'_>, + ) -> Result> { + call.inject(self, Self::read_tenant)? + } fn accepts_id(&self, value: &str) -> bool { !value.is_empty() @@ -2060,9 +2093,7 @@ pub(crate) mod tests { async fn generate( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, ) -> Result> { Ok(GeneratedEdgeCookie { id: Some("bad;value with spaces".to_owned()), @@ -2123,9 +2154,7 @@ pub(crate) mod tests { async fn generate( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, ) -> Result> { Ok(GeneratedEdgeCookie { id: self.id.map(str::to_owned), @@ -2467,9 +2496,7 @@ pub(crate) mod tests { async fn generate( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, ) -> Result> { Ok(GeneratedEdgeCookie { id: Some("MiXeD.CaseId".to_owned()), diff --git a/crates/trusted-server-core/src/ec/module.rs b/crates/trusted-server-core/src/ec/module.rs index 050dd2558c..f81aca3643 100644 --- a/crates/trusted-server-core/src/ec/module.rs +++ b/crates/trusted-server-core/src/ec/module.rs @@ -16,12 +16,13 @@ //! [`is_request_scoped`](EdgeCookieModule::is_request_scoped)). //! //! Request evidence reaches a module at call time rather than at -//! construction. [`EdgeCookieModule::generate`] borrows a [`RequestInfo`], -//! which carries the normalized client IP, the User-Agent and the request -//! headers, for the life of the call, alongside an [`IdentityInput`] holding -//! the request's gating context. A module reads what it needs and retains -//! nothing. Core snapshots the headers, path and query it lends to the module -//! at generate time, and the module itself keeps none of it. +//! construction. [`EdgeCookieModule::generate`] is handed a [`ModuleCall`], +//! and the module names what it reads from the request's module context, such +//! as the [`RequestInfo`] carrying the normalized client IP, the User-Agent and +//! the request headers, or the permissions and consent resolved for the +//! request. A module reads what it needs and retains nothing. Core snapshots +//! the headers and what the request resolved to at read time, for generation +//! later in the request, and the module itself keeps none of it. //! //! [`HmacModule`] is the built-in server-side implementation. It derives the //! identifier from the client IP using HMAC over the configured passphrase. @@ -31,10 +32,10 @@ use std::sync::Arc; use error_stack::Report; use serde::{Deserialize, Serialize}; -use crate::consent::ConsentContext; use crate::error::TrustedServerError; use crate::evidence::{HostSignals, RequestInfo}; -use crate::permissions::{Permission, PermissionSet, PermissionState}; +use crate::module_context::ModuleCall; +use crate::permissions::{Permission, PermissionSet}; use crate::redacted::Redacted; use crate::settings::{Ec, EcModuleBlock}; @@ -164,53 +165,6 @@ const BUILTIN_MODULE_KEYS: &[&str] = &[ /// enveloped identifier is one of its own. pub const HMAC_MODULE_CODE: ModuleCode = crate::module_code!(HMAC_MODULE_KEY); -/// The request-scoped gating context passed to [`EdgeCookieModule::generate`]. -/// -/// Request data reaches a module through the `request_info` parameter of -/// [`EdgeCookieModule::generate`], not through this struct and not through -/// anything injected into the module's constructor. This struct carries only -/// the per-request gating context a module may read for behavior beyond -/// gating. On the organic request path the gate has confirmed the module's -/// required permissions are set before `generate` is called. A test calling -/// `edge_cookie::generate_ec_id` reaches `generate` without that gate. -#[derive(Default)] -pub struct IdentityInput<'a> { - /// The permissions resolved for this request, when the calling path carries - /// them. A module reads this only for behavior beyond gating. The main - /// organic path supplies them; the publisher path passes `None`. - pub permissions: Option<&'a PermissionState>, - - /// The request's consent context, when available, for module-specific - /// logic. The core gates on permissions, not consent, so a module reads - /// this only to forward or record consent. [`HmacModule`] ignores it. - pub consent: Option<&'a ConsentContext>, -} - -/// Inputs available to [`EdgeCookieModule::resolve_from_client`]. -/// -/// Carries the value a client produced and posted to the Edge Cookie resolve -/// endpoint, alongside the same gating context as [`IdentityInput`]. The posted -/// value reaches the module as [`payload`](Self::payload), not through -/// anything injected into the module. Unlike trusted edge-derived -/// data, [`payload`](Self::payload) arrives from the browser, so an -/// implementation must verify it before deriving an identifier from it. -pub struct ClientResolveInput<'a> { - /// The raw body the client posted to the resolve endpoint. For a vendor - /// module this is its own JSON envelope; for the built-in - /// `ClientFixedModule` demo it is the fixed known word the page script - /// posts. - pub payload: &'a [u8], - - /// The permissions resolved for the resolve request. The endpoint has - /// already confirmed the module's required permissions are set, so a - /// module reads this only for behavior beyond gating. - pub permissions: Option<&'a PermissionState>, - - /// The resolve request's consent context, for module-specific logic. The - /// core gates on permissions, not consent. - pub consent: Option<&'a ConsentContext>, -} - /// The outcome of [`EdgeCookieModule::generate`]. /// /// Carries the derived identifier, if any, and any response headers the module @@ -659,8 +613,15 @@ pub trait EdgeCookieModule: Send + Sync + core::fmt::Debug { false } - /// Derives an Edge Cookie identifier from the request evidence in - /// `request_info` and the gating context in `input`. + /// Derives an Edge Cookie identifier for the request. + /// + /// An implementation hands its own function to [`ModuleCall::inject`], + /// naming what it reads, such as the request's evidence, the permissions + /// and consent resolved for it, or the services. On the request path core + /// calls this only once the permissions the module declares are set, so + /// the module reads the permissions only for behavior beyond that gate. A + /// function that is not passed what it names produces no identifier for + /// the request. /// /// A server-side module creates here. A client-side module defers here /// (returns `id: None`) and creates later in @@ -670,16 +631,14 @@ pub trait EdgeCookieModule: Send + Sync + core::fmt::Debug { /// # Errors /// /// Returns [`TrustedServerError::EdgeCookie`] when derivation fails. - /// Asynchronous, and handed the platform services, because a module may - /// reach a backend, a key-value store or a secret to derive an identifier, - /// and a module that cannot make those calls cannot be written at all. - /// A module that derives from data already in hand still declares an - /// async method and returns immediately. + /// Asynchronous, and able to name the platform services, because a module + /// may reach a backend, a key-value store or a secret to derive an + /// identifier, and a module that cannot make those calls cannot be written + /// at all. A module that derives from data already in hand still declares + /// an async method and returns immediately. async fn generate( &self, - request_info: &dyn RequestInfo, - input: &IdentityInput<'_>, - services: &crate::platform::RuntimeServices, + call: ModuleCall<'_>, ) -> Result>; /// Returns whether `value` is a well-formed identifier this module issues. @@ -735,31 +694,36 @@ pub trait EdgeCookieModule: Send + Sync + core::fmt::Debug { PermissionSet::none() } - /// Derives an Edge Cookie identifier from a value the client produced and - /// posted to the resolve endpoint (`POST /_ts/api/v1/ec/resolve`). + /// Derives an Edge Cookie identifier from `payload`, the value the client + /// produced and posted to the resolve endpoint + /// (`POST /_ts/api/v1/ec/resolve`). /// /// This is the client-side counterpart to [`generate`](Self::generate). A /// module that cannot derive an identifier at the edge defers from /// `generate` (returning `id: None`, optionally with response headers that /// trigger client-side work), and the page posts its result back here. The /// payload arrives from the browser, so an implementation MUST verify it - /// (for example checking a signature) before trusting it. The default - /// returns no identifier, so a module that creates entirely server-side - /// (such as [`HmacModule`]) need not implement it. + /// (for example checking a signature) before trusting it. It names what + /// else it reads from the resolve request's module context through + /// [`ModuleCall::inject_with`], with the payload as its own argument. The + /// endpoint has already confirmed the permissions the module declares are + /// set. The default returns no identifier, so a module that creates + /// entirely server-side (such as [`HmacModule`]) need not implement it. /// /// # Errors /// /// Returns [`TrustedServerError::EdgeCookie`] when processing the payload /// fails. A payload that is merely unverified or absent yields `id: None` /// rather than an error, so the request proceeds without an Edge Cookie. - /// Asynchronous, and handed the platform services, for the same reason as - /// [`generate`](Self::generate). Verifying a payload the browser posted is - /// the case that most needs them, because checking a signature or a nonce - /// generally means reading a secret or calling the vendor's backend. + /// Asynchronous, and able to name the platform services, for the same + /// reason as [`generate`](Self::generate). Verifying a payload the browser + /// posted is the case that most needs them, because checking a signature + /// or a nonce generally means reading a secret or calling the vendor's + /// backend. async fn resolve_from_client( &self, - _input: &ClientResolveInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, + _payload: &[u8], ) -> Result> { Ok(GeneratedEdgeCookie::default()) } @@ -789,23 +753,12 @@ impl HmacModule { pub fn new(passphrase: Redacted) -> Self { Self { passphrase } } -} - -#[async_trait::async_trait(?Send)] -impl EdgeCookieModule for HmacModule { - fn id(&self) -> &'static str { - HMAC_MODULE_KEY - } - - fn code(&self) -> ModuleCode { - HMAC_MODULE_CODE - } - async fn generate( + /// The identifier the client IP in `request_info` gives under the + /// passphrase. + fn derive( &self, request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, ) -> Result> { let client_ip = request_info.client_ip(); if client_ip.is_empty() { @@ -821,6 +774,25 @@ impl EdgeCookieModule for HmacModule { response_headers: Vec::new(), }) } +} + +#[async_trait::async_trait(?Send)] +impl EdgeCookieModule for HmacModule { + fn id(&self) -> &'static str { + HMAC_MODULE_KEY + } + + fn code(&self) -> ModuleCode { + HMAC_MODULE_CODE + } + + async fn generate( + &self, + call: ModuleCall<'_>, + ) -> Result> { + call.inject(self, Self::derive) + .unwrap_or_else(|_| Ok(GeneratedEdgeCookie::default())) + } fn required_permissions(&self) -> PermissionSet { // The HMAC module writes the Edge Cookie to the device, so it requires @@ -852,29 +824,12 @@ impl HostSignalModule { host_signals, } } -} - -#[async_trait::async_trait(?Send)] -impl EdgeCookieModule for HostSignalModule { - fn id(&self) -> &'static str { - HOST_SIGNALS_MODULE_KEY - } - - // Built from the signals of one request, so it is only ever valid for - // that request and must never be kept and reused. - fn is_request_scoped(&self) -> bool { - true - } - - fn code(&self) -> ModuleCode { - crate::module_code!("hs00") - } - async fn generate( + /// The identifier the host signals and the client IP in `request_info` + /// give under the passphrase, or none without any host signal. + fn derive( &self, request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, ) -> Result> { let ja4 = self.host_signals.ja4().unwrap_or_default(); let h2 = self.host_signals.h2().unwrap_or_default(); @@ -894,6 +849,31 @@ impl EdgeCookieModule for HostSignalModule { response_headers: Vec::new(), }) } +} + +#[async_trait::async_trait(?Send)] +impl EdgeCookieModule for HostSignalModule { + fn id(&self) -> &'static str { + HOST_SIGNALS_MODULE_KEY + } + + // Built from the signals of one request, so it is only ever valid for + // that request and must never be kept and reused. + fn is_request_scoped(&self) -> bool { + true + } + + fn code(&self) -> ModuleCode { + crate::module_code!("hs00") + } + + async fn generate( + &self, + call: ModuleCall<'_>, + ) -> Result> { + call.inject(self, Self::derive) + .unwrap_or_else(|_| Ok(GeneratedEdgeCookie::default())) + } fn required_permissions(&self) -> PermissionSet { // Writes the Edge Cookie to the device, so it requires necessary.operations.storage @@ -947,25 +927,23 @@ impl EdgeCookieModule for ClientFixedModule { async fn generate( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, ) -> Result> { - // No identifier is derived at the edge: the value comes from the page - // script, which posts it to the resolve endpoint. + // No identifier is derived at the edge, because the value comes from + // the page script, which posts it to the resolve endpoint. Ok(GeneratedEdgeCookie::default()) } async fn resolve_from_client( &self, - input: &ClientResolveInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, + payload: &[u8], ) -> Result> { // Verify the posted value against the known shared word, then create it as // the Edge Cookie. A value that does not match yields no Edge Cookie. // This stands in for a real module's verification (for example // checking a signature) before it trusts a client-supplied value. - let matches = core::str::from_utf8(input.payload) + let matches = core::str::from_utf8(payload) .map(str::trim) .is_ok_and(|value| value == EXPECTED_VALUE); @@ -1382,11 +1360,9 @@ impl EdgeCookieModule for SharedModule { async fn generate( &self, - request_info: &dyn RequestInfo, - input: &IdentityInput<'_>, - services: &crate::platform::RuntimeServices, + call: ModuleCall<'_>, ) -> Result> { - self.0.generate(request_info, input, services).await + self.0.generate(call).await } fn accepts_id(&self, value: &str) -> bool { @@ -1403,10 +1379,10 @@ impl EdgeCookieModule for SharedModule { async fn resolve_from_client( &self, - input: &ClientResolveInput<'_>, - services: &crate::platform::RuntimeServices, + call: ModuleCall<'_>, + payload: &[u8], ) -> Result> { - self.0.resolve_from_client(input, services).await + self.0.resolve_from_client(call, payload).await } } @@ -1414,6 +1390,7 @@ impl EdgeCookieModule for SharedModule { mod tests { use super::*; use crate::evidence::OwnedRequestInfo; + use crate::module_context::ModuleContext; use crate::platform::test_support::noop_services; use crate::test_support::tests::{select_hmac_module, select_host_signals_module}; use http::HeaderMap; @@ -1611,9 +1588,7 @@ mod tests { async fn generate( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, ) -> Result> { Ok(GeneratedEdgeCookie::default()) } @@ -1838,6 +1813,21 @@ mod tests { Redacted::from("a-test-passphrase-32-bytes-minimum".to_owned()) } + /// What `module` generates with `request_info` as the request's evidence, + /// asked through its module call as core asks it. + async fn generated_by( + module: &dyn EdgeCookieModule, + request_info: &OwnedRequestInfo, + ) -> Result> { + let services = noop_services(); + let context = ModuleContext::new(crate::module_context::test_support::request("/")) + .with_evidence(request_info) + .with_services(&services); + module + .generate(context.call(module.id(), module.required_permissions())) + .await + } + fn test_request_info() -> OwnedRequestInfo { OwnedRequestInfo::new("203.0.113.1".to_owned(), HeaderMap::new()) } @@ -1905,9 +1895,7 @@ mod tests { async fn generate( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, ) -> Result> { Ok(GeneratedEdgeCookie::default()) } @@ -1962,9 +1950,7 @@ mod tests { async fn generate( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, ) -> Result> { Ok(GeneratedEdgeCookie::default()) } @@ -2029,9 +2015,7 @@ mod tests { async fn generate( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, ) -> Result> { Ok(GeneratedEdgeCookie::default()) } @@ -2106,8 +2090,7 @@ mod tests { }); let module = HostSignalModule::new(test_passphrase(), signals); let request_info = test_request_info(); - let generated = module - .generate(&request_info, &IdentityInput::default(), &noop_services()) + let generated = generated_by(&module, &request_info) .await .expect("should generate"); assert!( @@ -2139,9 +2122,7 @@ mod tests { async fn generate( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, ) -> Result> { Ok(GeneratedEdgeCookie::default()) } @@ -2186,8 +2167,7 @@ mod tests { #[tokio::test] async fn client_fixed_defers_in_generate() { let request_info = test_request_info(); - let generated = ClientFixedModule - .generate(&request_info, &IdentityInput::default(), &noop_services()) + let generated = generated_by(&ClientFixedModule, &request_info) .await .expect("should generate"); assert!( @@ -2223,13 +2203,12 @@ mod tests { ("hmac with any payload", &hmac, b"anything", None), ]; for (case, module, payload, expected) in cases { - let input = ClientResolveInput { - payload, - permissions: None, - consent: None, - }; + let context = ModuleContext::new(crate::module_context::test_support::request("/")); let generated = module - .resolve_from_client(&input, &noop_services()) + .resolve_from_client( + context.call(module.id(), module.required_permissions()), + payload, + ) .await .unwrap_or_else(|err| panic!("{case}: should resolve, got: {err}")); assert_eq!( @@ -2290,8 +2269,7 @@ mod tests { }); let module = HostSignalModule::new(test_passphrase(), signals); let request_info = test_request_info(); - let generated = module - .generate(&request_info, &IdentityInput::default(), &noop_services()) + let generated = generated_by(&module, &request_info) .await .expect("should generate"); assert!( diff --git a/crates/trusted-server-core/src/ec/resolve.rs b/crates/trusted-server-core/src/ec/resolve.rs index 2cde8b0820..1cd76907bf 100644 --- a/crates/trusted-server-core/src/ec/resolve.rs +++ b/crates/trusted-server-core/src/ec/resolve.rs @@ -19,13 +19,15 @@ use error_stack::Report; use http::{HeaderValue, Request, Response, StatusCode, header}; use crate::error::TrustedServerError; +use crate::evidence::BorrowedRequestInfo; +use crate::module_context::{ModuleContext, ResolvedRequest}; use crate::settings::Settings; use super::EcContext; use super::cookies::{ec_id_has_only_allowed_chars, set_ec_cookie, set_resolved_marker_cookie}; use super::kv::KvIdentityGraph; use super::kv_types::KvEntry; -use super::module::{ClientResolveInput, apply_module_response_headers}; +use super::module::apply_module_response_headers; /// Maximum size of a resolve request body. /// @@ -111,18 +113,32 @@ pub async fn handle_ec_resolve( if content_length_exceeds_limit(&req, MAX_BODY_SIZE) { return Ok(status_only(StatusCode::PAYLOAD_TOO_LARGE)); } - let payload = req.into_body().into_bytes().unwrap_or_default(); + let resolved = ResolvedRequest::of(&req, services.client_info()); + let (parts, body) = req.into_parts(); + let payload = body.into_bytes().unwrap_or_default(); if payload.len() > MAX_BODY_SIZE { return Ok(status_only(StatusCode::PAYLOAD_TOO_LARGE)); } - let input = ClientResolveInput { - payload: payload.as_ref(), - permissions: Some(ec_context.permissions()), - consent: Some(ec_context.consent()), - }; - - let generated = module.resolve_from_client(&input, services).await?; + // The module is handed the resolve request's own context, its evidence + // and what was resolved for it, with the posted value as its argument. + // The client IP is the normalized one the request's Edge Cookie state + // holds, the same form the module sees when it generates. + let evidence = BorrowedRequestInfo::new( + ec_context.client_ip().unwrap_or_default(), + Some(&parts.headers), + ) + .with_request_target(parts.uri.path(), parts.uri.query().unwrap_or_default()); + let context = ModuleContext::new(resolved.view()) + .with_evidence(&evidence) + .with_settings(settings) + .with_request_state(ec_context, services); + let generated = module + .resolve_from_client( + context.call(module.id(), module.required_permissions()), + payload.as_ref(), + ) + .await?; log::debug!( "EC resolve handled (module={}): id {}", module.id(), @@ -408,9 +424,8 @@ mod tests { use crate::consent::types::ConsentContext; use crate::ec::module::{ CLIENT_FIXED_MODULE_KEY, EcModuleSelection, EdgeCookieModule, GeneratedEdgeCookie, - IdentityInput, }; - use crate::evidence::RequestInfo; + use crate::module_context::{ModuleCall, ModuleRequest}; use crate::platform::test_support::{noop_services, noop_services_with_ec_module}; use crate::test_support::tests::create_test_settings; use http::Method; @@ -494,8 +509,12 @@ mod tests { /// (like any opaque-identifier module) returns the value unchanged. /// /// [`normalize_id_for_kv`]: EdgeCookieModule::normalize_id_for_kv - #[derive(Debug)] - struct TestIdModule; + #[derive(Debug, Default)] + struct TestIdModule { + /// The address the module was last asked for, so a test can read + /// back the request it was handed. + seen_path: std::sync::Mutex>, + } #[async_trait::async_trait(?Send)] impl EdgeCookieModule for TestIdModule { @@ -509,9 +528,7 @@ mod tests { async fn generate( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, ) -> Result> { // The identifier is created in the browser, so the edge derives nothing. Ok(GeneratedEdgeCookie::default()) @@ -519,13 +536,33 @@ mod tests { async fn resolve_from_client( &self, - input: &ClientResolveInput<'_>, - _services: &crate::platform::RuntimeServices, + call: ModuleCall<'_>, + payload: &[u8], + ) -> Result> { + call.inject_with(self, payload, Self::verify)? + } + + fn accepts_id(&self, value: &str) -> bool { + is_uuid(value) + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_owned() + } + } + + impl TestIdModule { + /// Accepts a well-formed UUID, recording the address of the request + /// it was handed. + fn verify( + &self, + payload: &[u8], + request: ModuleRequest<'_>, ) -> Result> { + *self.seen_path.lock().expect("should lock the seen address") = + Some(request.path().to_owned()); // The page posts the identifier it generated. Accept a well-formed UUID. - let value = core::str::from_utf8(input.payload) - .unwrap_or_default() - .trim(); + let value = core::str::from_utf8(payload).unwrap_or_default().trim(); if is_uuid(value) { Ok(GeneratedEdgeCookie { id: Some(value.to_owned()), @@ -535,14 +572,6 @@ mod tests { Ok(GeneratedEdgeCookie::default()) } } - - fn accepts_id(&self, value: &str) -> bool { - is_uuid(value) - } - - fn normalize_id_for_kv(&self, value: &str) -> String { - value.to_owned() - } } /// The identifier [`ResolveHeaderModule`] creates when asked to. @@ -570,17 +599,15 @@ mod tests { async fn generate( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, ) -> Result> { Ok(GeneratedEdgeCookie::default()) } async fn resolve_from_client( &self, - _input: &ClientResolveInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, + _payload: &[u8], ) -> Result> { Ok(GeneratedEdgeCookie { id: self.id.map(str::to_owned), @@ -803,7 +830,8 @@ mod tests { // A client-generated first-party UUID, the value the browser posts back. const TEST_ID: &str = "3f2504e0-4f89-41d3-9a0c-0305e82c3301"; - let module: Arc = Arc::new(TestIdModule); + let test_id = Arc::new(TestIdModule::default()); + let module: Arc = test_id.clone(); let mut settings = create_test_settings(); settings.ec.module = Some(EcModuleSelection::from("testid")); let services = noop_services_with_ec_module(Arc::clone(&module)); @@ -859,6 +887,15 @@ mod tests { .is_some(), "the resolve should persist the identity-graph row, so withdrawal can reach it" ); + assert_eq!( + test_id + .seen_path + .lock() + .expect("should lock the seen address") + .as_deref(), + Some("/_ts/api/v1/ec/resolve"), + "the module is handed the resolve request it answers" + ); // 3. A later request carries the EC cookie, and the identifier reads // back verbatim because the module's own `accepts_id` decides its diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index c649318263..46e47922e6 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -15,12 +15,12 @@ use crate::cookies::handle_request_cookies; use crate::ec::cookies::ec_id_has_only_allowed_chars; #[cfg(test)] use crate::ec::generation::normalize_ip; -#[cfg(test)] -use crate::ec::module::IdentityInput; use crate::ec::module::{module_owns_id, request_module}; use crate::error::TrustedServerError; #[cfg(test)] use crate::evidence::BorrowedRequestInfo; +#[cfg(test)] +use crate::module_context::{ModuleContext, test_support}; use crate::platform::RuntimeServices; use crate::settings::Settings; @@ -65,10 +65,13 @@ pub async fn generate_ec_id( // than from this request info. let request_info = BorrowedRequestInfo::new(&client_ip, request_headers); // This helper skips the permission gate, and the built-in module reads - // neither the resolved permissions nor the consent context, so they are - // not threaded here. + // neither the resolved permissions nor the consent context, so the + // context carries the request's evidence and the services alone. + let context = ModuleContext::new(test_support::request("/")) + .with_evidence(&request_info) + .with_services(services); let generated = module - .generate(&request_info, &IdentityInput::default(), services) + .generate(context.call(module.id(), module.required_permissions())) .await?; let generated = crate::ec::module::GeneratedEdgeCookie { id: generated diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 6c88f4df4e..b92f2e1f11 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -5535,9 +5535,7 @@ mod tests { async fn generate( &self, - _request_info: &dyn crate::evidence::RequestInfo, - _input: &crate::ec::module::IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: crate::module_context::ModuleCall<'_>, ) -> Result> { Ok(crate::ec::module::GeneratedEdgeCookie::default()) } diff --git a/crates/trusted-server-core/src/module_context.rs b/crates/trusted-server-core/src/module_context.rs index 4c7c54c369..11c6577066 100644 --- a/crates/trusted-server-core/src/module_context.rs +++ b/crates/trusted-server-core/src/module_context.rs @@ -127,7 +127,10 @@ impl<'r> ModuleRequest<'r> { /// A [`ModuleRequest`] that owns its parts, for a caller that must keep them /// past the borrow of the request, such as one that hands the request on. -#[derive(Debug, Clone)] +/// +/// The default is a `GET` of nothing, which a caller holds where it captured +/// no request. +#[derive(Debug, Clone, Default)] pub struct ResolvedRequest { method: Method, path: String, From 4350d152024c41c1b5c4e53a135e301a1814832e Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 8 Oct 2026 18:39:31 +0100 Subject: [PATCH 4/8] The permission signal modules are handed the module context Permission assembly builds one module context per request, carrying what the request resolved to, the evidence, consent, settings, services and location, and every signal module is asked in it. `assemble_permissions` takes that context in place of the consent and evidence, and reads both from it. `SignalInput` keeps every field and gains the context, and `call()` gives a module its call into it, so `signal` can name anything else the request offers. `valid_signal` and `tdls` take a `ModuleCall`. A module declares `required_permissions`, none by default, though nothing gated is passed here because the permissions are what the run produces. `SignalInput::new` still gives an input for a module asked on its own, in `ModuleContext::empty`, and `with_context` puts one in a request's context. The request view on this path borrows from the request, so assembly allocates nothing more per request. `http_util` gives the host and scheme borrowed, and `RequestInfo::from_request` owns copies of them as before. The GPC, GPP, Model Terms, TCF and US Privacy modules read their signals and terms in their own functions, handed to the call, each naming only what it reads, being the consent record for the four IAB schemes and the evidence for the Model Terms. `ModuleContext` is `Copy`, being borrows alone, so a caller with no request builds on the empty context. The permissions inspector does, and the combined signal tests assemble in a context. A new core test shows a module answering for the host of the request it is handed. --- crates/permission-signal/gpc/src/lib.rs | 21 +-- crates/permission-signal/gpp/src/lib.rs | 25 ++-- crates/permission-signal/mtm/src/lib.rs | 56 ++++---- crates/permission-signal/tcf/src/lib.rs | 29 ++-- .../permission-signal/us-privacy/src/lib.rs | 25 ++-- .../tests/permission_signals.rs | 23 +++- crates/trusted-server-core/src/ec/consent.rs | 119 ++++++++++++---- crates/trusted-server-core/src/ec/mod.rs | 14 +- crates/trusted-server-core/src/http_util.rs | 53 ++++--- .../trusted-server-core/src/module_context.rs | 62 +++++++-- .../src/permission_signal/mod.rs | 130 +++++++++++++----- tools/permissions-inspector/wasm/src/lib.rs | 13 +- 12 files changed, 392 insertions(+), 178 deletions(-) diff --git a/crates/permission-signal/gpc/src/lib.rs b/crates/permission-signal/gpc/src/lib.rs index 98e2a81bf4..f6d61dde6c 100644 --- a/crates/permission-signal/gpc/src/lib.rs +++ b/crates/permission-signal/gpc/src/lib.rs @@ -10,7 +10,7 @@ //! scheme. Why is set out once, in `permission_signal/README.md` in core. use trusted_server_core::consent::ConsentContext; -use trusted_server_core::evidence::RequestInfo; +use trusted_server_core::module_context::ModuleCall; use trusted_server_core::permission_signal::{PermissionSignalModule, SignalInput}; use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission, ValidSignal}; @@ -40,6 +40,12 @@ impl GpcModule { pub const fn new() -> Self { Self } + + /// The signal this scheme reads from the request's consent record, when + /// it can use one. + fn read_signal(&self, consent: &ConsentContext) -> Option { + consent.gpc.then(|| ValidSignal::new(short(), "gpc", "1")) + } } impl PermissionSignalModule for GpcModule { @@ -65,12 +71,8 @@ impl PermissionSignalModule for GpcModule { /// The header's one value, when it was sent. There is nothing to /// decode, so a sent header is always valid. - fn valid_signal( - &self, - consent: &ConsentContext, - _evidence: &dyn RequestInfo, - ) -> Option { - consent.gpc.then(|| ValidSignal::new(short(), "gpc", "1")) + fn valid_signal(&self, call: ModuleCall<'_>) -> Option { + call.inject(self, Self::read_signal).ok().flatten() } } @@ -107,14 +109,13 @@ mod tests { #[test] fn vouches_for_the_header_only_when_it_was_sent() { - let evidence = OwnedRequestInfo::default(); assert_eq!( - GpcModule::new().valid_signal(&with_header(true), &evidence), + GpcModule::new().read_signal(&with_header(true)), Some(ValidSignal::new(short(), "gpc", "1")), "a sent header is the one value it can carry" ); assert_eq!( - GpcModule::new().valid_signal(&with_header(false), &evidence), + GpcModule::new().read_signal(&with_header(false)), None, "no header is no signal" ); diff --git a/crates/permission-signal/gpp/src/lib.rs b/crates/permission-signal/gpp/src/lib.rs index 212a74ecbb..a18cb33128 100644 --- a/crates/permission-signal/gpp/src/lib.rs +++ b/crates/permission-signal/gpp/src/lib.rs @@ -8,7 +8,7 @@ //! scheme. Why is set out once, in `permission_signal/README.md` in core. use trusted_server_core::consent::ConsentContext; -use trusted_server_core::evidence::RequestInfo; +use trusted_server_core::module_context::ModuleCall; use trusted_server_core::permission_signal::{PermissionSignalModule, SignalInput}; use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission, ValidSignal}; @@ -38,6 +38,14 @@ impl GppSaleOptOutModule { pub const fn new() -> Self { Self } + + /// The signal this scheme reads from the request's consent record, when + /// it can use one. + fn read_signal(&self, consent: &ConsentContext) -> Option { + consent.gpp.as_ref()?; + let raw = consent.raw_gpp_string.as_deref()?; + Some(ValidSignal::new(short(), "gpp", raw)) + } } impl PermissionSignalModule for GppSaleOptOutModule { @@ -76,14 +84,8 @@ impl PermissionSignalModule for GppSaleOptOutModule { } /// The GPP string, when it decoded. - fn valid_signal( - &self, - consent: &ConsentContext, - _evidence: &dyn RequestInfo, - ) -> Option { - consent.gpp.as_ref()?; - let raw = consent.raw_gpp_string.as_deref()?; - Some(ValidSignal::new(short(), "gpp", raw)) + fn valid_signal(&self, call: ModuleCall<'_>) -> Option { + call.inject(self, Self::read_signal).ok().flatten() } } @@ -155,13 +157,12 @@ mod tests { #[test] fn vouches_for_the_gpp_string_only_when_it_decoded() { - let evidence = OwnedRequestInfo::default(); let decoded = ConsentContext { raw_gpp_string: Some("DBABMA~CPreadable".to_owned()), ..with_sale_opt_out(Some(false)) }; assert_eq!( - GppSaleOptOutModule::new().valid_signal(&decoded, &evidence), + GppSaleOptOutModule::new().read_signal(&decoded), Some(ValidSignal::new(short(), "gpp", "DBABMA~CPreadable")), "a decoded string is vouched for as received, whatever it says" ); @@ -170,7 +171,7 @@ mod tests { ..ConsentContext::default() }; assert_eq!( - GppSaleOptOutModule::new().valid_signal(&unreadable, &evidence), + GppSaleOptOutModule::new().read_signal(&unreadable), None, "an unreadable string is not" ); diff --git a/crates/permission-signal/mtm/src/lib.rs b/crates/permission-signal/mtm/src/lib.rs index ce2a51332d..f1692667e4 100644 --- a/crates/permission-signal/mtm/src/lib.rs +++ b/crates/permission-signal/mtm/src/lib.rs @@ -39,9 +39,9 @@ use std::sync::OnceLock; -use trusted_server_core::consent::ConsentContext; use trusted_server_core::constants::COOKIE_MTM_PREF; use trusted_server_core::evidence::RequestInfo; +use trusted_server_core::module_context::ModuleCall; use trusted_server_core::permission_signal::{PermissionSignalModule, SignalInput}; use trusted_server_core::permissions::{ ConsentSignal, Permission, PermissionSet, SignalPolicy, ValidSignal, @@ -147,6 +147,19 @@ impl MtmModule { pub const fn new() -> Self { Self } + + /// The word in the request's evidence, when one is present. + fn read_signal(&self, evidence: &dyn RequestInfo) -> Option { + preference(evidence).map(|word| ValidSignal::new(short(), SCHEME, word.as_str())) + } + + /// The Model Terms, when the request's evidence carries a word. + fn read_tdls(&self, evidence: &dyn RequestInfo) -> Vec { + if preference(evidence).is_none() { + return Vec::new(); + } + Tdl::new(TERMS).into_iter().collect() + } } /// Every Data Use either word says something about, computed once. @@ -219,21 +232,14 @@ impl PermissionSignalModule for MtmModule { /// The word, when one is present. There is nothing else to validate, /// because a value that is not one of the two words is not an answer. - fn valid_signal( - &self, - _consent: &ConsentContext, - evidence: &dyn RequestInfo, - ) -> Option { - preference(evidence).map(|word| ValidSignal::new(short(), SCHEME, word.as_str())) + fn valid_signal(&self, call: ModuleCall<'_>) -> Option { + call.inject(self, Self::read_signal).ok().flatten() } /// The Model Terms, whenever a word is present, because either answer is /// given under them. Nothing is declared without an answer. - fn tdls(&self, _consent: &ConsentContext, evidence: &dyn RequestInfo) -> Vec { - if preference(evidence).is_none() { - return Vec::new(); - } - Tdl::new(TERMS).into_iter().collect() + fn tdls(&self, call: ModuleCall<'_>) -> Vec { + call.inject(self, Self::read_tdls).unwrap_or_default() } } @@ -242,6 +248,7 @@ mod tests { use http::HeaderMap; use super::*; + use trusted_server_core::consent::ConsentContext; use trusted_server_core::evidence::OwnedRequestInfo; use trusted_server_core::permissions::Acquisition; @@ -510,29 +517,21 @@ mod tests { #[test] fn vouches_for_the_word_and_declares_the_terms_only_when_one_is_present() { - let consent = ConsentContext::default(); let module = MtmModule::new(); assert_eq!( - module.valid_signal(&consent, &with_cookie("standard")), + module.read_signal(&with_cookie("standard")), Some(ValidSignal::new(short(), SCHEME, "standard")) ); - let declared = module.tdls(&consent, &with_cookie("personalized")); + let declared = module.read_tdls(&with_cookie("personalized")); assert_eq!( declared.iter().map(Tdl::as_str).collect::>(), vec![TERMS], "an answer is given under the Model Terms" ); - assert_eq!(module.valid_signal(&consent, &with_cookie("maybe")), None); - assert!(module.tdls(&consent, &with_cookie("maybe")).is_empty()); - assert_eq!( - module.valid_signal(&consent, &OwnedRequestInfo::default()), - None - ); - assert!( - module - .tdls(&consent, &OwnedRequestInfo::default()) - .is_empty() - ); + assert_eq!(module.read_signal(&with_cookie("maybe")), None); + assert!(module.read_tdls(&with_cookie("maybe")).is_empty()); + assert_eq!(module.read_signal(&OwnedRequestInfo::default()), None); + assert!(module.read_tdls(&OwnedRequestInfo::default()).is_empty()); } /// `non-marketing` changes nothing, so every Data Use the Model Terms @@ -556,15 +555,14 @@ mod tests { #[test] fn non_marketing_is_still_a_recorded_answer_under_the_terms() { let evidence = with_cookie("non-marketing"); - let consent = ConsentContext::default(); let module = MtmModule::new(); let signal = module - .valid_signal(&consent, &evidence) + .read_signal(&evidence) .expect("a decline is a valid answer"); assert_eq!(signal.value, "non-marketing"); assert_eq!( - module.tdls(&consent, &evidence).len(), + module.read_tdls(&evidence).len(), 1, "the answer was given under the Model Terms whatever it said", ); diff --git a/crates/permission-signal/tcf/src/lib.rs b/crates/permission-signal/tcf/src/lib.rs index ccdfdfbfe4..1b64f37080 100644 --- a/crates/permission-signal/tcf/src/lib.rs +++ b/crates/permission-signal/tcf/src/lib.rs @@ -21,7 +21,7 @@ pub use mapping::purpose_for; #[cfg(test)] use trusted_server_core::consent::types::TcfConsent; use trusted_server_core::consent::{ConsentContext, effective_tcf}; -use trusted_server_core::evidence::RequestInfo; +use trusted_server_core::module_context::ModuleCall; use trusted_server_core::permission_signal::{PermissionSignalModule, SignalInput}; use trusted_server_core::permissions::{ ConsentSignal, Permission, PermissionSet, SignalPolicy, ValidSignal, @@ -57,6 +57,14 @@ impl TcfModule { pub const fn new() -> Self { Self } + + /// The signal this scheme reads from the request's consent record, when + /// it can use one. + fn read_signal(&self, consent: &ConsentContext) -> Option { + consent.tcf.as_ref()?; + let raw = consent.raw_tc_string.as_deref()?; + Some(ValidSignal::new(short(), "tcf", raw)) + } } impl PermissionSignalModule for TcfModule { @@ -112,14 +120,8 @@ impl PermissionSignalModule for TcfModule { /// The standalone TC string, when it decoded and has not expired. A record /// carried inside a GPP string is the GPP string's, which the GPP /// module vouches for. - fn valid_signal( - &self, - consent: &ConsentContext, - _evidence: &dyn RequestInfo, - ) -> Option { - consent.tcf.as_ref()?; - let raw = consent.raw_tc_string.as_deref()?; - Some(ValidSignal::new(short(), "tcf", raw)) + fn valid_signal(&self, call: ModuleCall<'_>) -> Option { + call.inject(self, Self::read_signal).ok().flatten() } /// Only a TCF record refusing storage withdraws, because only TCF records @@ -322,13 +324,12 @@ mod tests { #[test] fn vouches_for_the_standalone_tc_string_only_when_it_decoded() { - let evidence = OwnedRequestInfo::default(); let decoded = ConsentContext { raw_tc_string: Some("CPreadable".to_owned()), ..with_record(&[1]) }; assert_eq!( - TcfModule::new().valid_signal(&decoded, &evidence), + TcfModule::new().read_signal(&decoded), Some(ValidSignal::new(short(), "tcf", "CPreadable")), "a decoded record vouches for the string as received" ); @@ -337,7 +338,7 @@ mod tests { ..ConsentContext::default() }; assert_eq!( - TcfModule::new().valid_signal(&unreadable, &evidence), + TcfModule::new().read_signal(&unreadable), None, "an unreadable string is not vouched for" ); @@ -347,12 +348,12 @@ mod tests { ..ConsentContext::default() }; assert_eq!( - TcfModule::new().valid_signal(&expired, &evidence), + TcfModule::new().read_signal(&expired), None, "an expired record is not one this module uses, so it is not vouched for" ); assert_eq!( - TcfModule::new().valid_signal(&ConsentContext::default(), &evidence), + TcfModule::new().read_signal(&ConsentContext::default()), None, "and no record is no signal" ); diff --git a/crates/permission-signal/us-privacy/src/lib.rs b/crates/permission-signal/us-privacy/src/lib.rs index 7005258caf..b08b884fda 100644 --- a/crates/permission-signal/us-privacy/src/lib.rs +++ b/crates/permission-signal/us-privacy/src/lib.rs @@ -10,7 +10,7 @@ //! scheme. Why is set out once, in `permission_signal/README.md` in core. use trusted_server_core::consent::{ConsentContext, PrivacyFlag}; -use trusted_server_core::evidence::RequestInfo; +use trusted_server_core::module_context::ModuleCall; use trusted_server_core::permission_signal::{PermissionSignalModule, SignalInput}; use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission, ValidSignal}; @@ -40,6 +40,14 @@ impl UsPrivacyModule { pub const fn new() -> Self { Self } + + /// The signal this scheme reads from the request's consent record, when + /// it can use one. + fn read_signal(&self, consent: &ConsentContext) -> Option { + consent.us_privacy.as_ref()?; + let raw = consent.raw_us_privacy.as_deref()?; + Some(ValidSignal::new(short(), "us_privacy", raw)) + } } impl PermissionSignalModule for UsPrivacyModule { @@ -78,14 +86,8 @@ impl PermissionSignalModule for UsPrivacyModule { } /// The US Privacy string, when it decoded. - fn valid_signal( - &self, - consent: &ConsentContext, - _evidence: &dyn RequestInfo, - ) -> Option { - consent.us_privacy.as_ref()?; - let raw = consent.raw_us_privacy.as_deref()?; - Some(ValidSignal::new(short(), "us_privacy", raw)) + fn valid_signal(&self, call: ModuleCall<'_>) -> Option { + call.inject(self, Self::read_signal).ok().flatten() } } @@ -157,13 +159,12 @@ mod tests { #[test] fn vouches_for_the_us_privacy_string_only_when_it_decoded() { - let evidence = OwnedRequestInfo::default(); let decoded = ConsentContext { raw_us_privacy: Some("1YNN".to_owned()), ..with_sale_flag(PrivacyFlag::No) }; assert_eq!( - UsPrivacyModule::new().valid_signal(&decoded, &evidence), + UsPrivacyModule::new().read_signal(&decoded), Some(ValidSignal::new(short(), "us_privacy", "1YNN")), "a decoded string is vouched for as received, whatever it says" ); @@ -172,7 +173,7 @@ mod tests { ..ConsentContext::default() }; assert_eq!( - UsPrivacyModule::new().valid_signal(&unreadable, &evidence), + UsPrivacyModule::new().read_signal(&unreadable), None, "an unreadable string is not" ); diff --git a/crates/trusted-server-adapter-axum/tests/permission_signals.rs b/crates/trusted-server-adapter-axum/tests/permission_signals.rs index 23ce88266f..5dec34de63 100644 --- a/crates/trusted-server-adapter-axum/tests/permission_signals.rs +++ b/crates/trusted-server-adapter-axum/tests/permission_signals.rs @@ -23,6 +23,7 @@ use trusted_server_core::consent::types::{GppConsent, TcfConsent, UsPrivacy}; use trusted_server_core::consent::{ConsentContext, PrivacyFlag}; use trusted_server_core::ec::consent::{GeoStatus, assemble_permissions}; use trusted_server_core::evidence::OwnedRequestInfo; +use trusted_server_core::module_context::{ModuleContext, test_support}; use trusted_server_core::permission_signal::{ PermissionSignalModule, build_permission_signal_modules, }; @@ -89,7 +90,21 @@ fn assembled( geo: GeoStatus<'_>, modules: &[Arc], ) -> PermissionState { - assemble_permissions(consent, &no_evidence(), geo, modules) + assembled_with(consent, &no_evidence(), geo, modules) +} + +/// The permissions assembled for a request carrying `consent` and `evidence`, +/// with the modules handed the request's module context. +fn assembled_with( + consent: &ConsentContext, + evidence: &OwnedRequestInfo, + geo: GeoStatus<'_>, + modules: &[Arc], +) -> PermissionState { + let context = ModuleContext::new(test_support::request("/")) + .with_consent(consent) + .with_evidence(evidence); + assemble_permissions(&context, geo, modules) } /// Builds a minimal decoded TCF record consenting to the given 1-indexed @@ -618,7 +633,7 @@ fn a_pmp_answer_in_the_eu_settles_what_the_model_terms_cover_and_declares_them() ); OwnedRequestInfo::new(String::new(), headers) }; - let standard = assemble_permissions( + let standard = assembled_with( &ConsentContext::default(), &evidence("standard"), GeoStatus::Located(&geo), @@ -647,7 +662,7 @@ fn a_pmp_answer_in_the_eu_settles_what_the_model_terms_cover_and_declares_them() vec!["https://m4ow.uk/mtm/2.txt"], "an answer is given under the versioned Model Terms" ); - let personalized = assemble_permissions( + let personalized = assembled_with( &ConsentContext::default(), &evidence("personalized"), GeoStatus::Located(&geo), @@ -657,7 +672,7 @@ fn a_pmp_answer_in_the_eu_settles_what_the_model_terms_cover_and_declares_them() personalized.is_set(Permission::SelectPersonalisedAds), "personalized grants targeting too" ); - let unanswered = assemble_permissions( + let unanswered = assembled_with( &ConsentContext::default(), &no_evidence(), GeoStatus::Located(&geo), diff --git a/crates/trusted-server-core/src/ec/consent.rs b/crates/trusted-server-core/src/ec/consent.rs index ac57539bf1..430a210713 100644 --- a/crates/trusted-server-core/src/ec/consent.rs +++ b/crates/trusted-server-core/src/ec/consent.rs @@ -18,7 +18,8 @@ use std::sync::Arc; use crate::consent::ConsentContext; use crate::consent::jurisdiction::Jurisdiction; -use crate::evidence::RequestInfo; +use crate::evidence::{OwnedRequestInfo, RequestInfo}; +use crate::module_context::ModuleContext; use crate::permission_signal::{self, PermissionSignalModule}; use crate::permissions::{ Acquisition, ConsentSignal, Permission, PermissionMaps, PermissionState, SignalPolicy, @@ -102,15 +103,28 @@ pub fn default_jurisdiction(geo: GeoStatus<'_>) -> Jurisdiction { /// [`PermissionState::storage_withdrawn`], and declare the terms documents the /// request's data is available under, read through /// [`PermissionState::tdls`]. +/// +/// The modules are handed `context`, the request's module context, and the +/// signals are read from the consent and evidence it carries. A context +/// carrying neither assembles the state of a request that carried no signal. #[must_use] pub fn assemble_permissions( - consent: &ConsentContext, - evidence: &dyn RequestInfo, + context: &ModuleContext<'_>, geo: GeoStatus<'_>, modules: &[Arc], ) -> PermissionState { + let no_consent; + let consent = match context.consent() { + Some(consent) => consent, + None => { + no_consent = ConsentContext::default(); + &no_consent + } + }; + let no_evidence = OwnedRequestInfo::default(); + let evidence = context.evidence().unwrap_or(&no_evidence); let maps = PermissionMaps::standard(); - let signal = permission_signal(consent, evidence, maps.signals(), modules); + let signal = permission_signal(context, consent, evidence, maps.signals(), modules); let state = match geo { GeoStatus::Failed => PermissionMaps::floor_with(signal), GeoStatus::Located(_) | GeoStatus::NoLocation => { @@ -125,6 +139,7 @@ pub fn assemble_permissions( let withdrawn = permission_signal::withdrawn( modules, Permission::StoreOnDevice, + context, consent, evidence, maps.signals(), @@ -135,8 +150,8 @@ pub fn assemble_permissions( // worth a page waiting for. The rest are unset, not pending. .awaiting_only(permission_signal::answerable(modules, maps.signals())) .with_storage_withdrawn(withdrawn) - .with_tdls(permission_signal::tdls(modules, consent, evidence)) - .with_signals(permission_signal::signals(modules, consent, evidence)) + .with_tdls(permission_signal::tdls(modules, context)) + .with_signals(permission_signal::signals(modules, context)) } /// The acquisition rule for Edge Cookie storage in the request's resolved @@ -184,13 +199,16 @@ pub fn storage_acquisition(geo: GeoStatus<'_>) -> Acquisition { /// /// [`combine`]: crate::permission_signal::combine fn permission_signal<'a>( + context: &'a ModuleContext<'a>, consent: &'a ConsentContext, evidence: &'a dyn RequestInfo, signals: &'a SignalPolicy, modules: &'a [Arc], ) -> impl Fn(Permission, Acquisition) -> ConsentSignal + 'a { move |permission, baseline| { - permission_signal::combine(modules, permission, consent, evidence, signals, baseline) + permission_signal::combine( + modules, permission, context, consent, evidence, signals, baseline, + ) } } @@ -199,7 +217,7 @@ mod tests { use http::HeaderMap; use super::*; - use crate::evidence::OwnedRequestInfo; + use crate::module_context::{ModuleCall, ModuleRequest}; use crate::permission_signal::SignalInput; use crate::permissions::{PermissionSet, ValidSignal}; use crate::test_support::tests::create_test_settings; @@ -233,11 +251,7 @@ mod tests { ConsentSignal::Neutral } - fn tdls( - &self, - _consent: &ConsentContext, - _evidence: &dyn crate::evidence::RequestInfo, - ) -> Vec { + fn tdls(&self, _call: ModuleCall<'_>) -> Vec { vec![ crate::tdl::Tdl::new("https://terms.example.com/marketing/2.txt") .expect("should accept the test locator"), @@ -258,7 +272,11 @@ mod tests { geo: GeoStatus<'_>, modules: &[Arc], ) -> PermissionState { - assemble_permissions(consent, &no_evidence(), geo, modules) + let evidence = no_evidence(); + let context = ModuleContext::new(crate::module_context::test_support::request("/")) + .with_consent(consent) + .with_evidence(&evidence); + assemble_permissions(&context, geo, modules) } fn us_ca_geo() -> GeoInfo { @@ -328,7 +346,7 @@ mod tests { fn a_module_declaring_terms_reaches_the_assembled_state() { let consent = ConsentContext::default(); let modules: Vec> = vec![Arc::new(DeclaringTerms)]; - let state = assemble_permissions(&consent, &no_evidence(), GeoStatus::NoLocation, &modules); + let state = assembled(&consent, GeoStatus::NoLocation, &modules); let addresses: Vec<&str> = state.tdls().iter().map(crate::tdl::Tdl::as_str).collect(); assert_eq!( addresses, @@ -340,8 +358,7 @@ mod tests { #[test] fn a_state_assembled_from_the_shipped_kind_of_module_declares_no_terms() { let consent = ConsentContext::default(); - let state = - assemble_permissions(&consent, &no_evidence(), GeoStatus::NoLocation, &granting()); + let state = assembled(&consent, GeoStatus::NoLocation, &granting()); assert!( state.tdls().is_empty(), "should declare nothing, because a scheme carrying no terms says nothing about them" @@ -426,6 +443,15 @@ mod tests { /// standing in for a scheme that decoded it. struct VouchingForTcf; + impl VouchingForTcf { + fn vouch(&self, consent: &ConsentContext) -> Option { + consent + .raw_tc_string + .as_deref() + .map(|raw| ValidSignal::new("vouching", "tcf", raw)) + } + } + impl PermissionSignalModule for VouchingForTcf { fn id(&self) -> &'static str { "vouching" @@ -435,18 +461,59 @@ mod tests { ConsentSignal::Neutral } - fn valid_signal( - &self, - consent: &ConsentContext, - _evidence: &dyn crate::evidence::RequestInfo, - ) -> Option { - consent - .raw_tc_string - .as_deref() - .map(|raw| ValidSignal::new("vouching", "tcf", raw)) + fn valid_signal(&self, call: ModuleCall<'_>) -> Option { + call.inject(self, Self::vouch).ok().flatten() + } + } + + /// A module that grants storage only on one host, so a test can see the + /// request reach a signal module through its module call. + struct GrantingOnHost(&'static str); + + impl GrantingOnHost { + fn on_host(&self, request: ModuleRequest<'_>) -> bool { + request.host() == self.0 } } + impl PermissionSignalModule for GrantingOnHost { + fn id(&self) -> &'static str { + "granting-on-host" + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + match input.call().inject(self, Self::on_host) { + Ok(true) if permission == Permission::StoreOnDevice => ConsentSignal::Grant, + _ => ConsentSignal::Neutral, + } + } + + fn grants(&self, _policy: &SignalPolicy) -> PermissionSet { + PermissionSet::none().with(Permission::StoreOnDevice) + } + } + + #[test] + fn a_signal_module_is_handed_the_request_it_answers_for() { + // The top node requires a signal for storage, so storage is set only + // where the module grants it, and it grants only on its own host. + let consent = ConsentContext::default(); + let here: Vec> = + vec![Arc::new(GrantingOnHost("publisher.example"))]; + let elsewhere: Vec> = + vec![Arc::new(GrantingOnHost("elsewhere.example"))]; + + assert!( + assembled(&consent, GeoStatus::NoLocation, &here).is_set(Permission::StoreOnDevice), + "the module answers for the request it was handed" + ); + assert!( + !assembled(&consent, GeoStatus::NoLocation, &elsewhere) + .is_set(Permission::StoreOnDevice), + "and a request for another host is not that request" + ); + } + #[test] fn the_assembled_state_carries_the_signals_the_modules_vouched_for() { let consent = ConsentContext { diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index a72eb9bedc..28df96e99c 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -89,7 +89,7 @@ use crate::ec::cookies::ec_id_has_only_allowed_chars; use crate::error::TrustedServerError; use crate::evidence::BorrowedRequestInfo; use crate::geo::GeoInfo; -use crate::module_context::{ModuleContext, ResolvedRequest}; +use crate::module_context::{ModuleContext, ModuleRequest, ResolvedRequest}; use crate::permissions::{Permission, PermissionState}; use crate::platform::RuntimeServices; use crate::settings::Settings; @@ -480,9 +480,17 @@ impl EcContext { Some(req.headers()), ) .with_request_target(req.uri().path(), req.uri().query().unwrap_or_default()); + let mut module_context = ModuleContext::new(ModuleRequest::of(req, services.client_info())) + .with_evidence(&evidence) + .with_consent(&consent) + .with_settings(settings) + .with_services(services) + .with_client(services.client_info()); + if let Some(geo) = geo_info { + module_context = module_context.with_geo(geo, services.geo().required_permissions()); + } let permissions = consent::assemble_permissions( - &consent, - &evidence, + &module_context, geo_status, services.permission_signal_modules(), ); diff --git a/crates/trusted-server-core/src/http_util.rs b/crates/trusted-server-core/src/http_util.rs index 457e503568..ee95183600 100644 --- a/crates/trusted-server-core/src/http_util.rs +++ b/crates/trusted-server-core/src/http_util.rs @@ -152,18 +152,30 @@ impl RequestInfo { /// [`sanitize_forwarded_headers`] at the edge, so `Host` and /// [`ClientInfo`] TLS detection are the only sources that fire. pub fn from_request(req: &Request, client_info: &ClientInfo) -> Self { - let host = extract_request_host(req); - let scheme = detect_request_scheme( - req, - client_info.tls_protocol.as_deref(), - client_info.tls_cipher.as_deref(), - ); - - Self { host, scheme } + Self { + host: request_host(req).to_owned(), + scheme: request_scheme(req, client_info).to_owned(), + } } } -fn extract_request_host(req: &Request) -> String { +/// The host a request was made for, read as [`RequestInfo::from_request`] +/// reads it, borrowed from the request. +pub(crate) fn request_host(req: &Request) -> &str { + extract_request_host(req) +} + +/// The scheme a request was made over, read as +/// [`RequestInfo::from_request`] reads it. +pub(crate) fn request_scheme(req: &Request, client_info: &ClientInfo) -> &'static str { + detect_request_scheme( + req, + client_info.tls_protocol.as_deref(), + client_info.tls_cipher.as_deref(), + ) +} + +fn extract_request_host(req: &Request) -> &str { req.headers() .get("forwarded") .and_then(|h| h.to_str().ok()) @@ -180,7 +192,6 @@ fn extract_request_host(req: &Request) -> String { .and_then(|h| h.to_str().ok()) }) .unwrap_or_default() - .to_owned() } fn parse_forwarded_param<'a>(forwarded: &'a str, param: &str) -> Option<&'a str> { @@ -221,9 +232,15 @@ fn strip_quotes(value: &str) -> &str { } } -fn normalize_scheme(value: &str) -> Option { - let scheme = value.trim().to_ascii_lowercase(); - (scheme == "https" || scheme == "http").then_some(scheme) +fn normalize_scheme(value: &str) -> Option<&'static str> { + let scheme = value.trim(); + if scheme.eq_ignore_ascii_case("https") { + Some("https") + } else if scheme.eq_ignore_ascii_case("http") { + Some("http") + } else { + None + } } /// Detects the request scheme (HTTP or HTTPS) using Fastly SDK methods and headers. @@ -238,17 +255,17 @@ fn detect_request_scheme( req: &Request, tls_protocol: Option<&str>, tls_cipher: Option<&str>, -) -> String { +) -> &'static str { // 1. First try ClientInfo TLS fields populated at the adapter entry point. if let Some(tls_protocol) = tls_protocol { log::debug!("TLS protocol detected: {tls_protocol}"); - return "https".to_owned(); + return "https"; } // Also check TLS cipher - if present, connection is HTTPS. if tls_cipher.is_some() { log::debug!("TLS cipher detected, using HTTPS"); - return "https".to_owned(); + return "https"; } // 2. Try the Forwarded header (RFC 7239) @@ -284,11 +301,11 @@ fn detect_request_scheme( && let Ok(ssl_str) = ssl.to_str() && (ssl_str == "1" || ssl_str.to_lowercase() == "true") { - return "https".to_owned(); + return "https"; } // Default to HTTP - "http".to_owned() + "http" } /// Build a static text response with strong `ETag` and standard caching headers. diff --git a/crates/trusted-server-core/src/module_context.rs b/crates/trusted-server-core/src/module_context.rs index 11c6577066..b9c0fbc799 100644 --- a/crates/trusted-server-core/src/module_context.rs +++ b/crates/trusted-server-core/src/module_context.rs @@ -78,7 +78,7 @@ pub struct ModuleRequest<'r> { impl<'r> ModuleRequest<'r> { /// A request for `path` on `host` over `scheme`, with no query. #[must_use] - pub fn new(method: &'r Method, host: &'r str, scheme: &'r str, path: &'r str) -> Self { + pub const fn new(method: &'r Method, host: &'r str, scheme: &'r str, path: &'r str) -> Self { Self { method, path, @@ -88,6 +88,19 @@ impl<'r> ModuleRequest<'r> { } } + /// What `request` resolved to, borrowed from it, the host and scheme read + /// as the publisher path reads them. + #[must_use] + pub fn of(request: &'r http::Request, client: &ClientInfo) -> Self { + Self::new( + request.method(), + crate::http_util::request_host(request), + crate::http_util::request_scheme(request, client), + request.uri().path(), + ) + .with_query(request.uri().query().unwrap_or_default()) + } + /// The same request with the query string, without its leading `?`. #[must_use] pub fn with_query(self, query: &'r str) -> Self { @@ -144,13 +157,13 @@ impl ResolvedRequest { /// scheme read as the publisher path reads them. #[must_use] pub fn of(request: &http::Request, client: &ClientInfo) -> Self { - let resolved = crate::http_util::RequestInfo::from_request(request, client); + let view = ModuleRequest::of(request, client); Self { - method: request.method().clone(), - path: request.uri().path().to_owned(), - query: request.uri().query().unwrap_or_default().to_owned(), - host: resolved.host, - scheme: resolved.scheme, + method: view.method.clone(), + path: view.path.to_owned(), + query: view.query.to_owned(), + host: view.host.to_owned(), + scheme: view.scheme.to_owned(), } } @@ -290,6 +303,9 @@ impl Copy for Gated<'_, T> {} /// holds, and borrowed by every module called there. A module never sees it /// directly. It is handed a [`ModuleCall`] and names what it needs, see /// [`ModuleCall::inject`]. +/// +/// It holds only borrows, so a copy is a copy of those borrows. +#[derive(Clone, Copy)] pub struct ModuleContext<'r> { request: ModuleRequest<'r>, evidence: Option<&'r dyn RequestInfo>, @@ -312,7 +328,7 @@ impl<'r> ModuleContext<'r> { /// This is the context of work shared by every reader, such as a stored /// page's fetch plan, until the builder methods add to it. #[must_use] - pub fn new(request: ModuleRequest<'r>) -> Self { + pub const fn new(request: ModuleRequest<'r>) -> Self { Self { request, evidence: None, @@ -487,6 +503,16 @@ impl<'r> ModuleContext<'r> { self.request } + /// The request's decoded consent signals, when the context carries them. + pub(crate) fn consent(&self) -> Option<&'r ConsentContext> { + self.consent + } + + /// The reader's evidence, when the context carries it. + pub(crate) fn evidence(&self) -> Option<&'r dyn RequestInfo> { + self.evidence + } + /// The call to one module, named `module`, which declares the permissions /// in `declared`. #[must_use] @@ -499,6 +525,26 @@ impl<'r> ModuleContext<'r> { } } +/// The method of the empty context. +static EMPTY_METHOD: Method = Method::GET; + +/// A context carrying nothing but a request for `/` on no host, for a module +/// asked outside any request. +static EMPTY: ModuleContext<'static> = + ModuleContext::new(ModuleRequest::new(&EMPTY_METHOD, "", "", "/")); + +impl ModuleContext<'static> { + /// The context of a module asked outside any request, which carries + /// nothing a module could name but an empty request. + /// + /// A caller with no request to resolve, such as a tool that evaluates + /// signals on their own, builds on a copy of it with the builder methods. + #[must_use] + pub fn empty() -> &'static Self { + &EMPTY + } +} + impl fmt::Debug for ModuleContext<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("ModuleContext") diff --git a/crates/trusted-server-core/src/permission_signal/mod.rs b/crates/trusted-server-core/src/permission_signal/mod.rs index fcb5f534fa..72769196a3 100644 --- a/crates/trusted-server-core/src/permission_signal/mod.rs +++ b/crates/trusted-server-core/src/permission_signal/mod.rs @@ -7,6 +7,7 @@ use error_stack::Report; use crate::consent::ConsentContext; use crate::error::TrustedServerError; use crate::evidence::RequestInfo; +use crate::module_context::{ModuleCall, ModuleContext}; use crate::permissions::{ Acquisition, ConsentSignal, Permission, PermissionSet, SignalPolicy, ValidSignal, }; @@ -16,7 +17,11 @@ use crate::tdl::Tdl; /// What a signal module may read about a request. /// /// A struct rather than a parameter list, so a module needing something new -/// does not change every implementation. +/// does not change every implementation. The fields are what the signal run +/// is deciding. Anything else in the request's module context, such as what +/// the request resolved to or the settings, a module names through +/// [`call`](Self::call). The permissions are what the run produces, so none +/// are resolved yet and no value whose use needs one is passed here. pub struct SignalInput<'a> { /// The decoded consent record for this request. /// @@ -54,10 +59,13 @@ pub struct SignalInput<'a> { /// False while answering a consultation, which is what stops two modules /// that consult each other from looping. may_ask: bool, + /// The request's module context. + context: &'a ModuleContext<'a>, } impl<'a> SignalInput<'a> { - /// An input for a module asked on its own, outside an ordered run. + /// An input for a module asked on its own, outside an ordered run, in a + /// context carrying nothing, see [`ModuleContext::empty`]. #[must_use] pub fn new( consent: &'a ConsentContext, @@ -74,6 +82,26 @@ impl<'a> SignalInput<'a> { modules: &[], position: 0, may_ask: true, + context: ModuleContext::empty(), + } + } + + /// The same input in the request's module context. + #[must_use] + pub fn with_context(self, context: &'a ModuleContext<'a>) -> Self { + Self { context, ..self } + } + + /// The module's call into the request's module context, which it hands + /// its own function to, naming what it needs. A module asked on its own + /// is called by no name and declares nothing. + #[must_use] + pub fn call(&self) -> ModuleCall<'a> { + match self.modules.get(self.position) { + Some(module) => self + .context + .call(module.id(), module.required_permissions()), + None => self.context.call("", PermissionSet::none()), } } @@ -129,6 +157,7 @@ impl<'a> SignalInput<'a> { modules: self.modules, position, may_ask: false, + context: self.context, }; Some(module.signal(permission, &input)) } @@ -167,6 +196,13 @@ pub trait PermissionSignalModule: Send + Sync { /// `[permission-signal] modules` may write as `example`. fn id(&self) -> &'static str; + /// The permissions this module declares, which decide whether a gated + /// value is passed to it. None by default. Signal modules run before the + /// permissions are resolved, so a declaration here passes nothing yet. + fn required_permissions(&self) -> PermissionSet { + PermissionSet::none() + } + /// How this module would amend `permission` for this request. fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal; @@ -216,12 +252,10 @@ pub trait PermissionSignalModule: Send + Sync { /// taken silently. Core carries what every module vouched for on the /// permission state, and a signal nobody vouched for is dropped from /// everything Trusted Server sends on, so a corrupt record never reaches - /// a page or a bid request. - fn valid_signal( - &self, - _consent: &ConsentContext, - _evidence: &dyn RequestInfo, - ) -> Option { + /// a page or a bid request. An implementation hands its own function to + /// [`ModuleCall::inject`], naming what it reads, usually the consent or + /// the evidence. + fn valid_signal(&self, _call: ModuleCall<'_>) -> Option { None } @@ -238,8 +272,9 @@ pub trait PermissionSignalModule: Send + Sync { /// says stays between the parties bound by it. /// /// A locator must point at a document that is never edited once - /// published, which [`Tdl`] documents and cannot enforce. - fn tdls(&self, _consent: &ConsentContext, _evidence: &dyn RequestInfo) -> Vec { + /// published, which [`Tdl`] documents and cannot enforce. An + /// implementation names what it reads through [`ModuleCall::inject`]. + fn tdls(&self, _call: ModuleCall<'_>) -> Vec { Vec::new() } } @@ -252,6 +287,7 @@ pub trait PermissionSignalModule: Send + Sync { pub(crate) fn combine( modules: &[Arc], permission: Permission, + context: &ModuleContext<'_>, consent: &ConsentContext, evidence: &dyn RequestInfo, policy: &SignalPolicy, @@ -268,6 +304,7 @@ pub(crate) fn combine( modules, position, may_ask: true, + context, }; // Every module is asked, because a later one may amend what an // earlier one settled. Stopping at the first answer would make the @@ -296,6 +333,7 @@ pub(crate) fn combine( pub(crate) fn withdrawn( modules: &[Arc], permission: Permission, + context: &ModuleContext<'_>, consent: &ConsentContext, evidence: &dyn RequestInfo, policy: &SignalPolicy, @@ -314,6 +352,7 @@ pub(crate) fn withdrawn( modules, position, may_ask: true, + context, }; module.withdraws(permission, &input) }) @@ -328,12 +367,11 @@ pub(crate) fn withdrawn( #[must_use] pub(crate) fn tdls( modules: &[Arc], - consent: &ConsentContext, - evidence: &dyn RequestInfo, + context: &ModuleContext<'_>, ) -> Arc<[Tdl]> { let mut declared: Vec = Vec::new(); for module in modules { - for tdl in module.tdls(consent, evidence) { + for tdl in module.tdls(context.call(module.id(), module.required_permissions())) { if !declared.contains(&tdl) { declared.push(tdl); } @@ -365,12 +403,13 @@ pub fn answerable( #[must_use] pub(crate) fn signals( modules: &[Arc], - consent: &ConsentContext, - evidence: &dyn RequestInfo, + context: &ModuleContext<'_>, ) -> Arc<[ValidSignal]> { modules .iter() - .filter_map(|module| module.valid_signal(consent, evidence)) + .filter_map(|module| { + module.valid_signal(context.call(module.id(), module.required_permissions())) + }) .collect() } @@ -502,6 +541,7 @@ mod tests { use super::*; use crate::evidence::OwnedRequestInfo; + use crate::module_context::test_support; /// A module that always answers the same thing, for testing the rule /// rather than any particular scheme. @@ -552,7 +592,7 @@ mod tests { ConsentSignal::Neutral } - fn tdls(&self, _consent: &ConsentContext, _evidence: &dyn RequestInfo) -> Vec { + fn tdls(&self, _call: ModuleCall<'_>) -> Vec { vec![Tdl::new(self.1).expect("should accept the test locator")] } } @@ -578,6 +618,31 @@ mod tests { OwnedRequestInfo::new(String::new(), HeaderMap::new()) } + /// The signals `modules` vouch for, asked in a context carrying `consent` + /// and no evidence. + fn signals_for( + modules: &[Arc], + consent: &ConsentContext, + ) -> Arc<[ValidSignal]> { + let evidence = no_evidence(); + let context = ModuleContext::new(test_support::request("/")) + .with_consent(consent) + .with_evidence(&evidence); + signals(modules, &context) + } + + /// The terms `modules` declare, asked as [`signals_for`] asks. + fn tdls_for( + modules: &[Arc], + consent: &ConsentContext, + ) -> Arc<[Tdl]> { + let evidence = no_evidence(); + let context = ModuleContext::new(test_support::request("/")) + .with_consent(consent) + .with_evidence(&evidence); + tdls(modules, &context) + } + fn fixed(id: &'static str, signal: ConsentSignal) -> Arc { Arc::new(Fixed(id, signal)) } @@ -643,11 +708,7 @@ mod tests { ConsentSignal::Neutral } - fn valid_signal( - &self, - _consent: &ConsentContext, - _evidence: &dyn RequestInfo, - ) -> Option { + fn valid_signal(&self, _call: ModuleCall<'_>) -> Option { Some(ValidSignal::new(self.0, self.0, self.1)) } } @@ -660,7 +721,7 @@ mod tests { fixed("silent", ConsentSignal::Revoke), Arc::new(Vouching("second", "two")), ]; - let valid = signals(&modules, &consent, &no_evidence()); + let valid = signals_for(&modules, &consent); assert_eq!( &*valid, &[ @@ -670,12 +731,7 @@ mod tests { "should carry what was vouched for, in configured order, and nothing else" ); assert!( - signals( - &[fixed("silent", ConsentSignal::Grant)], - &consent, - &no_evidence() - ) - .is_empty(), + signals_for(&[fixed("silent", ConsentSignal::Grant)], &consent).is_empty(), "a module vouches for nothing unless it says otherwise" ); } @@ -683,11 +739,7 @@ mod tests { #[test] fn a_module_declaring_no_terms_leaves_the_list_empty() { let consent = ConsentContext::default(); - let declared = tdls( - &[fixed("quiet", ConsentSignal::Grant)], - &consent, - &no_evidence(), - ); + let declared = tdls_for(&[fixed("quiet", ConsentSignal::Grant)], &consent); assert!( declared.is_empty(), "should declare nothing, because the IAB schemes carry no terms" @@ -702,7 +754,7 @@ mod tests { fixed("quiet", ConsentSignal::Neutral), Arc::new(Declaring("second", "https://terms.example.com/b/1.txt")), ]; - let declared = tdls(&modules, &consent, &no_evidence()); + let declared = tdls_for(&modules, &consent); let addresses: Vec<&str> = declared.iter().map(Tdl::as_str).collect(); assert_eq!( addresses, @@ -721,7 +773,7 @@ mod tests { Arc::new(Declaring("first", "https://terms.example.com/a/1.txt")), Arc::new(Declaring("second", "https://terms.example.com/a/1.txt")), ]; - let declared = tdls(&modules, &consent, &no_evidence()); + let declared = tdls_for(&modules, &consent); assert_eq!( declared.len(), 1, @@ -736,7 +788,7 @@ mod tests { Arc::new(Declaring("first", "https://terms.example.com/a/1.txt")), Arc::new(Declaring("second", "https://terms.example.com/a/2.txt")), ]; - let declared = tdls(&modules, &consent, &no_evidence()); + let declared = tdls_for(&modules, &consent); assert_eq!( declared.len(), 2, @@ -748,7 +800,7 @@ mod tests { fn no_modules_declare_nothing() { let consent = ConsentContext::default(); assert!( - tdls(&[], &consent, &no_evidence()).is_empty(), + tdls_for(&[], &consent).is_empty(), "should declare nothing when no module runs, rather than implying terms" ); } @@ -759,6 +811,7 @@ mod tests { combine( modules, Permission::StoreOnDevice, + ModuleContext::empty(), &consent, &no_evidence(), &policy, @@ -781,6 +834,7 @@ mod tests { withdrawn( modules, Permission::StoreOnDevice, + ModuleContext::empty(), &consent, &no_evidence(), &policy, diff --git a/tools/permissions-inspector/wasm/src/lib.rs b/tools/permissions-inspector/wasm/src/lib.rs index 5a852f9df4..a826d8388d 100644 --- a/tools/permissions-inspector/wasm/src/lib.rs +++ b/tools/permissions-inspector/wasm/src/lib.rs @@ -11,6 +11,7 @@ use trusted_server_core::consent::build_context_from_signals; use trusted_server_core::consent::types::RawConsentSignals; use trusted_server_core::ec::consent::{GeoStatus, assemble_permissions}; use trusted_server_core::evidence::OwnedRequestInfo; +use trusted_server_core::module_context::ModuleContext; use trusted_server_core::permission_signal::PermissionSignalModule; use trusted_server_core::permissions::{Permission, PermissionMaps}; use trusted_server_core::platform::GeoInfo; @@ -58,15 +59,20 @@ fn eval_json(input: &str) -> String { // request evidence, so empty evidence changes none of their answers. A // module that read a header or a cookie would need real evidence here. let evidence = OwnedRequestInfo::default(); + // The page stands for no request, so the modules are asked in the empty + // context with the signals entered. + let context = ModuleContext::empty() + .with_consent(&ctx) + .with_evidence(&evidence); let modules = modules(); let maps = PermissionMaps::standard(); let (state, jurisdiction) = match input.geo.as_str() { "failed" => { - let state = assemble_permissions(&ctx, &evidence, GeoStatus::Failed, &modules); + let state = assemble_permissions(&context, GeoStatus::Failed, &modules); (state, "unknown".to_string()) } "none" => { - let state = assemble_permissions(&ctx, &evidence, GeoStatus::NoLocation, &modules); + let state = assemble_permissions(&context, GeoStatus::NoLocation, &modules); (state, jurisdiction_name(maps.default_jurisdiction())) } _ => { @@ -80,8 +86,7 @@ fn eval_json(input: &str) -> String { region: input.region.clone().filter(|r| !r.is_empty()), asn: None, }; - let state = - assemble_permissions(&ctx, &evidence, GeoStatus::Located(&info), &modules); + let state = assemble_permissions(&context, GeoStatus::Located(&info), &modules); let jurisdiction = jurisdiction_name( maps.jurisdiction_for(input.country.as_deref(), input.region.as_deref()), ); From 8d18b40b2e889cd947340e1a4584b67c48e10ec0 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 8 Oct 2026 19:06:06 +0100 Subject: [PATCH 5/8] Module routes are handed the module context `IntegrationProxy::handle` takes the route's `ModuleCall` and the request, in place of the settings and the services, and a route declares the permissions it needs with `required_permissions`, none by default. The registry builds the context for the request it dispatches from the request's resolved state, so a route can name the permissions, consent, location, device signals and Edge Cookie identifier resolved for the request, as well as the settings and the services. The identifier only reaches a route that declares the permission its use needs, as for every other kind of module. Each route hands its own function to `inject_with`, with the request as that function's own argument, and the function names only what it reads. Seven routes name the settings and the services, four name the services alone, and the APS renderer, which answers the same page to everyone, takes nothing from its call. The tests of a route call its function directly. The route takes the request, so the context holds a copy of what the request resolved to, being its method, address, host and scheme. It holds no copy of the headers, because the route has the request itself, so a route is not passed the evidence. The Axum, Cloudflare and Spin adapters dispatched a module route with an empty Edge Cookie context, which carried no permissions. They now resolve it for the request, as the publisher path on those adapters does and as Fastly does for every request. On a navigation the registry generates an identifier for the route's context when the permissions allow, as it does on Fastly. These three adapters do not run the Edge Cookie finalization that writes the cookie on a response, so on them that identifier lasts for the one request. A new test shows a route handed its request's permissions and identifier, and one that declares nothing seeing the permissions and not the identifier. `ModuleCall::empty` gives a call outside any request, for a test of a route that takes nothing. --- crates/ad-tag/google/src/lib.rs | 44 +++-- crates/auction/aps/src/lib.rs | 26 +-- crates/auction/prebid/src/lib.rs | 50 +++--- crates/audience/permutive/src/lib.rs | 74 +++++---- crates/bot-protection/datadome/src/lib.rs | 42 +++-- crates/cmp/didomi/src/lib.rs | 96 +++++------ crates/cmp/sourcepoint/src/lib.rs | 49 +++--- crates/identity/lockr/src/lib.rs | 40 +++-- crates/tag/google-tag-manager/src/lib.rs | 68 ++++---- crates/testing/seam-probe/src/lib.rs | 37 +++-- crates/testing/testlight/src/lib.rs | 46 +++-- crates/trusted-server-adapter-axum/src/app.rs | 4 +- .../src/app.rs | 8 +- crates/trusted-server-adapter-spin/src/app.rs | 8 +- .../src/integrations/js_asset_proxy.rs | 72 ++++---- .../src/integrations/registry.rs | 157 +++++++++++++++++- .../trusted-server-core/src/module_context.rs | 13 +- docs/guide/integration-guide.md | 27 ++- 18 files changed, 574 insertions(+), 287 deletions(-) diff --git a/crates/ad-tag/google/src/lib.rs b/crates/ad-tag/google/src/lib.rs index 3472280ea9..403f40cf1c 100644 --- a/crates/ad-tag/google/src/lib.rs +++ b/crates/ad-tag/google/src/lib.rs @@ -440,25 +440,14 @@ pub fn register( )) } -#[async_trait(?Send)] -impl IntegrationProxy for GptIntegration { - fn integration_name(&self) -> &'static str { - GPT_INTEGRATION_ID - } - - fn routes(&self) -> Vec { - vec![ - self.get("/script"), - self.get("/pagead/*"), - self.get("/tag/*"), - ] - } - - async fn handle( +impl GptIntegration { + /// Answers the request, with the settings and the services the route + /// names in its module call. + async fn route( &self, + req: http::Request, settings: &Settings, services: &RuntimeServices, - req: http::Request, ) -> Result, Report> { let path = req.uri().path().to_string(); @@ -477,6 +466,29 @@ impl IntegrationProxy for GptIntegration { } } +#[async_trait(?Send)] +impl IntegrationProxy for GptIntegration { + fn integration_name(&self) -> &'static str { + GPT_INTEGRATION_ID + } + + fn routes(&self) -> Vec { + vec![ + self.get("/script"), + self.get("/pagead/*"), + self.get("/tag/*"), + ] + } + + async fn handle( + &self, + call: trusted_server_core::module_context::ModuleCall<'_>, + req: http::Request, + ) -> Result, Report> { + call.inject_with(self, req, Self::route)?.await + } +} + impl IntegrationAttributeRewriter for GptIntegration { fn integration_id(&self) -> &'static str { GPT_INTEGRATION_ID diff --git a/crates/auction/aps/src/lib.rs b/crates/auction/aps/src/lib.rs index 24c44c8bdc..7d8692d2ff 100644 --- a/crates/auction/aps/src/lib.rs +++ b/crates/auction/aps/src/lib.rs @@ -58,9 +58,9 @@ use trusted_server_core::openrtb::{ Banner, Device, Format, Geo, Imp, OpenRtbRequest, Publisher, Regs, RegsExt, Site, User, UserExt, to_openrtb_i32, }; +use trusted_server_core::platform::PlatformResponse; #[cfg(test)] -use trusted_server_core::platform::PlatformHttpRequest; -use trusted_server_core::platform::{PlatformResponse, RuntimeServices}; +use trusted_server_core::platform::{PlatformHttpRequest, RuntimeServices}; use trusted_server_core::settings::Settings; pub(crate) const APS_INTEGRATION_ID: &str = "aps"; @@ -2048,10 +2048,11 @@ impl IntegrationProxy for ApsRendererIntegration { .collect() } + // The renderer page is the same for every request, so the route takes + // nothing from its module call. async fn handle( &self, - _settings: &Settings, - _services: &RuntimeServices, + _call: trusted_server_core::module_context::ModuleCall<'_>, request: http::Request, ) -> Result, Report> { if request.method() != Method::GET || request.uri().path() != APS_RENDERER_ROUTE { @@ -3206,16 +3207,16 @@ mod tests { assert_eq!(routes[0].method, Method::GET); assert_eq!(routes[0].path, APS_RENDERER_ROUTE); - let settings = create_test_settings(); - let services = noop_services(); let request = http::Request::builder() .method(Method::GET) .uri(APS_RENDERER_ROUTE) .body(EdgeBody::empty()) .expect("should build renderer request"); - let response = - futures::executor::block_on(integration.handle(&settings, &services, request)) - .expect("should serve renderer"); + let response = futures::executor::block_on(integration.handle( + trusted_server_core::module_context::ModuleCall::empty(), + request, + )) + .expect("should serve renderer"); assert_eq!(response.status(), StatusCode::OK); assert_eq!( response.headers()[header::CONTENT_TYPE], @@ -3233,8 +3234,11 @@ mod tests { .uri(APS_RENDERER_ROUTE) .body(EdgeBody::empty()) .expect("should build method rejection request"); - let response = futures::executor::block_on(integration.handle(&settings, &services, post)) - .expect("should reject unsupported method"); + let response = futures::executor::block_on(integration.handle( + trusted_server_core::module_context::ModuleCall::empty(), + post, + )) + .expect("should reject unsupported method"); assert_eq!(response.status(), StatusCode::NOT_FOUND); } diff --git a/crates/auction/prebid/src/lib.rs b/crates/auction/prebid/src/lib.rs index 4e3fab11f2..4a4e161499 100644 --- a/crates/auction/prebid/src/lib.rs +++ b/crates/auction/prebid/src/lib.rs @@ -1126,6 +1126,35 @@ pub fn register( )) } +impl PrebidIntegration { + /// Answers the request, with the settings and the services the route + /// names in its module call. + async fn route( + &self, + req: http::Request, + settings: &Settings, + services: &RuntimeServices, + ) -> Result, Report> { + let path = req.uri().path().to_string(); + let method = req.method().clone(); + + match method { + Method::GET if self.is_managed_external() && path == PREBID_BUNDLE_ROUTE => { + self.handle_external_bundle(settings, services, req).await + } + // Serve empty JS for matching script patterns + Method::GET if self.matches_script_pattern(&path) => self.handle_script_handler(), + _ => http::Response::builder() + .status(StatusCode::NOT_FOUND) + .body(EdgeBody::from("Not Found")) + .change_context(TrustedServerError::Integration { + integration: PREBID_INTEGRATION_ID.to_string(), + message: "Failed to build Prebid not found response".to_string(), + }), + } + } +} + #[async_trait(?Send)] impl IntegrationProxy for PrebidIntegration { fn integration_name(&self) -> &'static str { @@ -1152,27 +1181,10 @@ impl IntegrationProxy for PrebidIntegration { async fn handle( &self, - settings: &Settings, - services: &RuntimeServices, + call: trusted_server_core::module_context::ModuleCall<'_>, req: http::Request, ) -> Result, Report> { - let path = req.uri().path().to_string(); - let method = req.method().clone(); - - match method { - Method::GET if self.is_managed_external() && path == PREBID_BUNDLE_ROUTE => { - self.handle_external_bundle(settings, services, req).await - } - // Serve empty JS for matching script patterns - Method::GET if self.matches_script_pattern(&path) => self.handle_script_handler(), - _ => http::Response::builder() - .status(StatusCode::NOT_FOUND) - .body(EdgeBody::from("Not Found")) - .change_context(TrustedServerError::Integration { - integration: PREBID_INTEGRATION_ID.to_string(), - message: "Failed to build Prebid not found response".to_string(), - }), - } + call.inject_with(self, req, Self::route)?.await } } diff --git a/crates/audience/permutive/src/lib.rs b/crates/audience/permutive/src/lib.rs index 08d19f9e90..16679f1fe0 100644 --- a/crates/audience/permutive/src/lib.rs +++ b/crates/audience/permutive/src/lib.rs @@ -357,38 +357,14 @@ pub fn register( )) } -#[async_trait(?Send)] -impl IntegrationProxy for PermutiveIntegration { - fn integration_name(&self) -> &'static str { - PERMUTIVE_INTEGRATION_ID - } - - fn routes(&self) -> Vec { - vec![ - // API proxy endpoints - self.get("/api/*"), - self.post("/api/*"), - // Secure Signals endpoints - self.get("/secure-signal/*"), - self.post("/secure-signal/*"), - // Events endpoints - self.get("/events/*"), - self.post("/events/*"), - // Sync endpoints - self.get("/sync/*"), - self.post("/sync/*"), - // CDN endpoint - self.get("/cdn/*"), - // SDK serving - self.get("/sdk"), - ] - } - - async fn handle( +impl PermutiveIntegration { + /// Answers the request, with the settings and the services the route + /// names in its module call. + async fn route( &self, + req: http::Request, settings: &Settings, services: &RuntimeServices, - req: http::Request, ) -> Result, Report> { let path = req.uri().path().to_string(); @@ -448,6 +424,42 @@ impl IntegrationProxy for PermutiveIntegration { } } +#[async_trait(?Send)] +impl IntegrationProxy for PermutiveIntegration { + fn integration_name(&self) -> &'static str { + PERMUTIVE_INTEGRATION_ID + } + + fn routes(&self) -> Vec { + vec![ + // API proxy endpoints + self.get("/api/*"), + self.post("/api/*"), + // Secure Signals endpoints + self.get("/secure-signal/*"), + self.post("/secure-signal/*"), + // Events endpoints + self.get("/events/*"), + self.post("/events/*"), + // Sync endpoints + self.get("/sync/*"), + self.post("/sync/*"), + // CDN endpoint + self.get("/cdn/*"), + // SDK serving + self.get("/sdk"), + ] + } + + async fn handle( + &self, + call: trusted_server_core::module_context::ModuleCall<'_>, + req: http::Request, + ) -> Result, Report> { + call.inject_with(self, req, Self::route)?.await + } +} + impl IntegrationAttributeRewriter for PermutiveIntegration { fn integration_id(&self) -> &'static str { PERMUTIVE_INTEGRATION_ID @@ -678,7 +690,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); - let response = futures::executor::block_on(integration.handle(&settings, &services, req)) + let response = futures::executor::block_on(integration.route(req, &settings, &services)) .expect("should proxy request"); assert_eq!( @@ -720,7 +732,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); - let response = futures::executor::block_on(integration.handle(&settings, &services, req)) + let response = futures::executor::block_on(integration.route(req, &settings, &services)) .expect("should proxy request"); assert_eq!( response.status(), diff --git a/crates/bot-protection/datadome/src/lib.rs b/crates/bot-protection/datadome/src/lib.rs index e6842b5595..88ed844994 100644 --- a/crates/bot-protection/datadome/src/lib.rs +++ b/crates/bot-protection/datadome/src/lib.rs @@ -874,6 +874,29 @@ impl DataDomeIntegration { } } +impl DataDomeIntegration { + /// Answers the request, with the services the route names in its module + /// call. + async fn route( + &self, + req: http::Request, + services: &RuntimeServices, + ) -> Result, Report> { + let path = req.uri().path().to_string(); + + if path == "/integrations/datadome/tags.js" { + self.handle_tags_js(services, req).await + } else if path.starts_with("/integrations/datadome/js/") { + self.handle_js_api(services, req).await + } else { + Err(Report::new(Self::error(format!( + "Unknown DataDome route: {}", + path + )))) + } + } +} + #[async_trait(?Send)] impl IntegrationProxy for DataDomeIntegration { fn integration_name(&self) -> &'static str { @@ -895,22 +918,10 @@ impl IntegrationProxy for DataDomeIntegration { async fn handle( &self, - _settings: &Settings, - services: &RuntimeServices, + call: trusted_server_core::module_context::ModuleCall<'_>, req: http::Request, ) -> Result, Report> { - let path = req.uri().path().to_string(); - - if path == "/integrations/datadome/tags.js" { - self.handle_tags_js(services, req).await - } else if path.starts_with("/integrations/datadome/js/") { - self.handle_js_api(services, req).await - } else { - Err(Report::new(Self::error(format!( - "Unknown DataDome route: {}", - path - )))) - } + call.inject_with(self, req, Self::route)?.await } } @@ -1718,7 +1729,6 @@ mod tests { let services = build_services_with_http_client( Arc::clone(&stub) as Arc ); - let settings = create_test_settings(); let integration = DataDomeIntegration::new(test_config()); let req = http::Request::builder() .method(http::Method::GET) @@ -1726,7 +1736,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); - let response = futures::executor::block_on(integration.handle(&settings, &services, req)) + let response = futures::executor::block_on(integration.route(req, &services)) .expect("should proxy request"); assert_eq!( diff --git a/crates/cmp/didomi/src/lib.rs b/crates/cmp/didomi/src/lib.rs index ef1a4ecc6d..8b753a39c7 100644 --- a/crates/cmp/didomi/src/lib.rs +++ b/crates/cmp/didomi/src/lib.rs @@ -442,25 +442,13 @@ pub fn register( )) } -#[async_trait(?Send)] -impl IntegrationProxy for DidomiIntegration { - fn integration_name(&self) -> &'static str { - DIDOMI_INTEGRATION_ID - } - - fn proxy_prefix(&self) -> String { - self.resolved_prefix() - } - - fn routes(&self) -> Vec { - vec![self.get("/*"), self.post("/*")] - } - - async fn handle( +impl DidomiIntegration { + /// Answers the request, with the services the route names in its module + /// call. + async fn route( &self, - _settings: &Settings, - services: &RuntimeServices, req: http::Request, + services: &RuntimeServices, ) -> Result, Report> { let (parts, body) = req.into_parts(); let path = parts.uri.path().to_string(); @@ -556,6 +544,29 @@ impl IntegrationProxy for DidomiIntegration { } } +#[async_trait(?Send)] +impl IntegrationProxy for DidomiIntegration { + fn integration_name(&self) -> &'static str { + DIDOMI_INTEGRATION_ID + } + + fn proxy_prefix(&self) -> String { + self.resolved_prefix() + } + + fn routes(&self) -> Vec { + vec![self.get("/*"), self.post("/*")] + } + + async fn handle( + &self, + call: trusted_server_core::module_context::ModuleCall<'_>, + req: http::Request, + ) -> Result, Report> { + call.inject_with(self, req, Self::route)?.await + } +} + impl IntegrationHeadInjector for DidomiIntegration { fn integration_id(&self) -> &'static str { DIDOMI_INTEGRATION_ID @@ -838,7 +849,6 @@ mod tests { Arc::clone(&stub), GeoResult::Value(Some(geo_info("us", Some("ca")))), ); - let settings = create_test_settings(); let integration = DidomiIntegration::new(Arc::new(config_with_geo_query_parameters())); let request = http::Request::builder() .method(Method::GET) @@ -846,9 +856,8 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); - let response = - futures::executor::block_on(integration.handle(&settings, &services, request)) - .expect("should return redirect"); + let response = futures::executor::block_on(integration.route(request, &services)) + .expect("should return redirect"); assert_eq!( response.status(), @@ -894,7 +903,6 @@ mod tests { Arc::clone(&stub), GeoResult::Value(Some(geo_info("us", Some("ca")))), ); - let settings = create_test_settings(); let integration = DidomiIntegration::new(Arc::new(DidomiIntegrationConfig { proxy_path: Some("publisher/privacy".to_string()), ..config_with_geo_query_parameters() @@ -905,9 +913,8 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); - let response = - futures::executor::block_on(integration.handle(&settings, &services, request)) - .expect("should return redirect"); + let response = futures::executor::block_on(integration.route(request, &services)) + .expect("should return redirect"); assert_eq!(response.status(), http::StatusCode::TEMPORARY_REDIRECT); assert_eq!( @@ -932,7 +939,6 @@ mod tests { Arc::clone(&stub), GeoResult::Value(Some(geo_info("us", Some("us-ca")))), ); - let settings = create_test_settings(); let integration = DidomiIntegration::new(Arc::new(config_with_geo_query_parameters())); let request = http::Request::builder() .method(Method::GET) @@ -942,9 +948,8 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); - let response = - futures::executor::block_on(integration.handle(&settings, &services, request)) - .expect("should proxy canonical loader"); + let response = futures::executor::block_on(integration.route(request, &services)) + .expect("should proxy canonical loader"); assert_eq!(response.status(), http::StatusCode::OK); assert_eq!( @@ -975,7 +980,6 @@ mod tests { Arc::clone(&stub), GeoResult::Value(Some(geo_info("US", Some("CA")))), ); - let settings = create_test_settings(); let integration = DidomiIntegration::new(Arc::new(config())); let request = http::Request::builder() .method(Method::GET) @@ -983,9 +987,8 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); - let response = - futures::executor::block_on(integration.handle(&settings, &services, request)) - .expect("should proxy loader"); + let response = futures::executor::block_on(integration.route(request, &services)) + .expect("should proxy loader"); assert_eq!(response.status(), http::StatusCode::OK); assert_eq!( @@ -1000,7 +1003,6 @@ mod tests { let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, b"sdk".to_vec()); let services = services_with_geo(Arc::clone(&stub), GeoResult::Failure); - let settings = create_test_settings(); let integration = DidomiIntegration::new(Arc::new(config_with_geo_query_parameters())); let request = http::Request::builder() .method(Method::GET) @@ -1008,9 +1010,8 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); - let response = - futures::executor::block_on(integration.handle(&settings, &services, request)) - .expect("should proxy unrelated SDK asset"); + let response = futures::executor::block_on(integration.route(request, &services)) + .expect("should proxy unrelated SDK asset"); assert_eq!(response.status(), http::StatusCode::OK); assert_eq!( @@ -1030,7 +1031,6 @@ mod tests { ] { let stub = Arc::new(StubHttpClient::new()); let services = services_with_geo(Arc::clone(&stub), geo_result); - let settings = create_test_settings(); let integration = DidomiIntegration::new(Arc::new(config_with_geo_query_parameters())); let request = http::Request::builder() .method(Method::GET) @@ -1038,9 +1038,8 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); - let response = - futures::executor::block_on(integration.handle(&settings, &services, request)) - .expect("should return controlled geo failure"); + let response = futures::executor::block_on(integration.route(request, &services)) + .expect("should return controlled geo failure"); assert_eq!( response.status(), @@ -1087,7 +1086,6 @@ mod tests { ], ); let services = services_with_geo(Arc::clone(&stub), GeoResult::Value(None)); - let settings = create_test_settings(); let integration = DidomiIntegration::new(Arc::new(config())); let request = http::Request::builder() .method(Method::GET) @@ -1095,9 +1093,8 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); - let response = - futures::executor::block_on(integration.handle(&settings, &services, request)) - .expect("should proxy API request"); + let response = futures::executor::block_on(integration.route(request, &services)) + .expect("should proxy API request"); assert_eq!( stub.recorded_cache_intents(), @@ -1146,7 +1143,6 @@ mod tests { Arc::clone(&stub), GeoResult::Value(Some(geo_info("US", Some("CA")))), ); - let settings = create_test_settings(); let integration = DidomiIntegration::new(Arc::new(config_with_geo_query_parameters())); let request = http::Request::builder() .method(Method::GET) @@ -1154,9 +1150,8 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); - let response = - futures::executor::block_on(integration.handle(&settings, &services, request)) - .expect("should proxy SDK request"); + let response = futures::executor::block_on(integration.route(request, &services)) + .expect("should proxy SDK request"); assert_eq!( stub.recorded_cache_intents(), @@ -1418,7 +1413,6 @@ mod tests { let services = build_services_with_http_client( Arc::clone(&stub) as Arc ); - let settings = create_test_settings(); let integration = DidomiIntegration::new(Arc::new(config())); let req = http::Request::builder() .method(http::Method::GET) @@ -1426,7 +1420,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); - let response = futures::executor::block_on(integration.handle(&settings, &services, req)) + let response = futures::executor::block_on(integration.route(req, &services)) .expect("should proxy request"); assert_eq!( diff --git a/crates/cmp/sourcepoint/src/lib.rs b/crates/cmp/sourcepoint/src/lib.rs index e7a961c84c..b9cd3e90a8 100644 --- a/crates/cmp/sourcepoint/src/lib.rs +++ b/crates/cmp/sourcepoint/src/lib.rs @@ -808,27 +808,13 @@ pub fn register( )) } -#[async_trait(?Send)] -impl IntegrationProxy for SourcepointIntegration { - fn integration_name(&self) -> &'static str { - SOURCEPOINT_INTEGRATION_ID - } - - fn routes(&self) -> Vec { - let endpoint_path = format!("/integrations/{SOURCEPOINT_INTEGRATION_ID}/cdn/*"); - vec![ - self.get("/cdn/*"), - self.post("/cdn/*"), - IntegrationEndpoint::new(Method::HEAD, endpoint_path.clone()), - IntegrationEndpoint::new(Method::OPTIONS, endpoint_path), - ] - } - - async fn handle( +impl SourcepointIntegration { + /// Answers the request, with the services the route names in its module + /// call. + async fn route( &self, - _settings: &Settings, - services: &RuntimeServices, req: Request, + services: &RuntimeServices, ) -> Result, Report> { let path = req.uri().path().to_string(); let method = req.method().clone(); @@ -1020,6 +1006,31 @@ impl IntegrationProxy for SourcepointIntegration { } } +#[async_trait(?Send)] +impl IntegrationProxy for SourcepointIntegration { + fn integration_name(&self) -> &'static str { + SOURCEPOINT_INTEGRATION_ID + } + + fn routes(&self) -> Vec { + let endpoint_path = format!("/integrations/{SOURCEPOINT_INTEGRATION_ID}/cdn/*"); + vec![ + self.get("/cdn/*"), + self.post("/cdn/*"), + IntegrationEndpoint::new(Method::HEAD, endpoint_path.clone()), + IntegrationEndpoint::new(Method::OPTIONS, endpoint_path), + ] + } + + async fn handle( + &self, + call: trusted_server_core::module_context::ModuleCall<'_>, + req: Request, + ) -> Result, Report> { + call.inject_with(self, req, Self::route)?.await + } +} + impl IntegrationAttributeRewriter for SourcepointIntegration { fn integration_id(&self) -> &'static str { SOURCEPOINT_INTEGRATION_ID diff --git a/crates/identity/lockr/src/lib.rs b/crates/identity/lockr/src/lib.rs index a066b3a139..5b79563d94 100644 --- a/crates/identity/lockr/src/lib.rs +++ b/crates/identity/lockr/src/lib.rs @@ -378,21 +378,14 @@ pub fn register( )) } -#[async_trait(?Send)] -impl IntegrationProxy for LockrIntegration { - fn integration_name(&self) -> &'static str { - LOCKR_INTEGRATION_ID - } - - fn routes(&self) -> Vec { - vec![self.get("/sdk"), self.post("/api/*"), self.get("/api/*")] - } - - async fn handle( +impl LockrIntegration { + /// Answers the request, with the settings and the services the route + /// names in its module call. + async fn route( &self, + req: http::Request, settings: &Settings, services: &RuntimeServices, - req: http::Request, ) -> Result, Report> { let path = req.uri().path().to_string(); @@ -409,6 +402,25 @@ impl IntegrationProxy for LockrIntegration { } } +#[async_trait(?Send)] +impl IntegrationProxy for LockrIntegration { + fn integration_name(&self) -> &'static str { + LOCKR_INTEGRATION_ID + } + + fn routes(&self) -> Vec { + vec![self.get("/sdk"), self.post("/api/*"), self.get("/api/*")] + } + + async fn handle( + &self, + call: trusted_server_core::module_context::ModuleCall<'_>, + req: http::Request, + ) -> Result, Report> { + call.inject_with(self, req, Self::route)?.await + } +} + impl IntegrationAttributeRewriter for LockrIntegration { fn integration_id(&self) -> &'static str { LOCKR_INTEGRATION_ID @@ -593,7 +605,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); - let response = futures::executor::block_on(integration.handle(&settings, &services, req)) + let response = futures::executor::block_on(integration.route(req, &settings, &services)) .expect("should proxy request"); assert_eq!( @@ -636,7 +648,7 @@ mod tests { .body(EdgeBody::from(payload.to_vec())) .expect("should build request"); - let response = futures::executor::block_on(integration.handle(&settings, &services, req)) + let response = futures::executor::block_on(integration.route(req, &settings, &services)) .expect("should proxy request"); assert_eq!(response.status(), http::StatusCode::OK, "should return OK"); diff --git a/crates/tag/google-tag-manager/src/lib.rs b/crates/tag/google-tag-manager/src/lib.rs index aaa76fbd47..35c8069f30 100644 --- a/crates/tag/google-tag-manager/src/lib.rs +++ b/crates/tag/google-tag-manager/src/lib.rs @@ -947,33 +947,14 @@ pub fn register( )) } -#[async_trait(?Send)] -impl IntegrationProxy for GoogleTagManagerIntegration { - fn integration_name(&self) -> &'static str { - GTM_INTEGRATION_ID - } - - fn routes(&self) -> Vec { - vec![ - // Proxy for the main GTM script - self.get("/gtm.js"), - // Proxy for the gtag script (if used) - self.get("/gtag/js"), - self.get("/gtag.js"), - // Analytics beacons (GA4/UA) - // The GTM script is rewritten to point these beacons to our proxy. - self.get("/collect"), - self.post("/collect"), - self.get("/g/collect"), - self.post("/g/collect"), - ] - } - - async fn handle( +impl GoogleTagManagerIntegration { + /// Answers the request, with the settings and the services the route + /// names in its module call. + async fn route( &self, + req: http::Request, settings: &Settings, services: &RuntimeServices, - req: http::Request, ) -> Result, Report> { let mut req = req; let path = req.uri().path().to_string(); @@ -1022,6 +1003,37 @@ impl IntegrationProxy for GoogleTagManagerIntegration { } } +#[async_trait(?Send)] +impl IntegrationProxy for GoogleTagManagerIntegration { + fn integration_name(&self) -> &'static str { + GTM_INTEGRATION_ID + } + + fn routes(&self) -> Vec { + vec![ + // Proxy for the main GTM script + self.get("/gtm.js"), + // Proxy for the gtag script (if used) + self.get("/gtag/js"), + self.get("/gtag.js"), + // Analytics beacons (GA4/UA) + // The GTM script is rewritten to point these beacons to our proxy. + self.get("/collect"), + self.post("/collect"), + self.get("/g/collect"), + self.post("/g/collect"), + ] + } + + async fn handle( + &self, + call: trusted_server_core::module_context::ModuleCall<'_>, + req: http::Request, + ) -> Result, Report> { + call.inject_with(self, req, Self::route)?.await + } +} + impl IntegrationAttributeRewriter for GoogleTagManagerIntegration { fn integration_id(&self) -> &'static str { GTM_INTEGRATION_ID @@ -1771,7 +1783,7 @@ mod tests { ); integration - .handle(&settings, &services, req) + .route(req, &settings, &services) .await .expect("should handle the GTM request") }) @@ -2111,7 +2123,7 @@ mod tests { ); integration - .handle(&settings, &services, req) + .route(req, &settings, &services) .await .expect("should handle the GTM request") }) @@ -2879,7 +2891,7 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= let settings = make_settings(); let response = integration - .handle(&settings, &noop_services(), req) + .route(req, &settings, &noop_services()) .await .expect("handle should not return error"); @@ -2913,7 +2925,7 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= let settings = make_settings(); let response = integration - .handle(&settings, &noop_services(), req) + .route(req, &settings, &noop_services()) .await .expect("handle should not return error"); diff --git a/crates/testing/seam-probe/src/lib.rs b/crates/testing/seam-probe/src/lib.rs index 5380043f2d..9a1720803a 100644 --- a/crates/testing/seam-probe/src/lib.rs +++ b/crates/testing/seam-probe/src/lib.rs @@ -415,21 +415,13 @@ impl AuctionProvider for SeamProbeAdServer { /// Proxy that reports what the seam delivered to this request. pub struct SeamProbeProxy; -#[async_trait(?Send)] -impl IntegrationProxy for SeamProbeProxy { - fn integration_name(&self) -> &'static str { - SEAM_PROBE_ID - } - - fn routes(&self) -> Vec { - vec![self.get(REPORT_ROUTE_SUFFIX)] - } - - async fn handle( +impl SeamProbeProxy { + /// Answers the request, with the services the route names in its module + /// call. + async fn route( &self, - _settings: &Settings, - services: &RuntimeServices, req: Request, + services: &RuntimeServices, ) -> Result, Report> { let country = match services .geo() @@ -460,6 +452,25 @@ impl IntegrationProxy for SeamProbeProxy { } } +#[async_trait(?Send)] +impl IntegrationProxy for SeamProbeProxy { + fn integration_name(&self) -> &'static str { + SEAM_PROBE_ID + } + + fn routes(&self) -> Vec { + vec![self.get(REPORT_ROUTE_SUFFIX)] + } + + async fn handle( + &self, + call: trusted_server_core::module_context::ModuleCall<'_>, + req: Request, + ) -> Result, Report> { + call.inject_with(self, req, Self::route)?.await + } +} + /// Rejects a country that is not exactly two ASCII letters. /// /// # Errors diff --git a/crates/testing/testlight/src/lib.rs b/crates/testing/testlight/src/lib.rs index 6cfbbf26db..118c72e641 100644 --- a/crates/testing/testlight/src/lib.rs +++ b/crates/testing/testlight/src/lib.rs @@ -197,21 +197,14 @@ pub fn register( )) } -#[async_trait(?Send)] -impl IntegrationProxy for TestlightIntegration { - fn integration_name(&self) -> &'static str { - TESTLIGHT_INTEGRATION_ID - } - - fn routes(&self) -> Vec { - vec![self.post("/auction")] - } - - async fn handle( +impl TestlightIntegration { + /// Answers the request, with the settings and the services the route + /// names in its module call. + async fn route( &self, + req: http::Request, settings: &Settings, services: &RuntimeServices, - req: http::Request, ) -> Result, Report> { let (parts, body) = req.into_parts(); let payload_bytes = @@ -270,6 +263,25 @@ impl IntegrationProxy for TestlightIntegration { } } +#[async_trait(?Send)] +impl IntegrationProxy for TestlightIntegration { + fn integration_name(&self) -> &'static str { + TESTLIGHT_INTEGRATION_ID + } + + fn routes(&self) -> Vec { + vec![self.post("/auction")] + } + + async fn handle( + &self, + call: trusted_server_core::module_context::ModuleCall<'_>, + req: http::Request, + ) -> Result, Report> { + call.inject_with(self, req, Self::route)?.await + } +} + impl IntegrationAttributeRewriter for TestlightIntegration { fn integration_id(&self) -> &'static str { TESTLIGHT_INTEGRATION_ID @@ -479,7 +491,7 @@ mod tests { ); let response = integration - .handle(&settings, &services, req) + .route(req, &settings, &services) .await .expect("should proxy Testlight request"); @@ -543,10 +555,10 @@ mod tests { let settings = create_test_settings(); let refused = testlight_integration() - .handle( + .route( + testlight_auction_request(FOREIGN_CODED_EC_ID), &settings, &services, - testlight_auction_request(FOREIGN_CODED_EC_ID), ) .await .expect_err("a foreign module code should not be proxied upstream"); @@ -574,10 +586,10 @@ mod tests { settings.ec.module_blocks.clear(); let refused = testlight_integration() - .handle( + .route( + testlight_auction_request(VALID_SYNTHETIC_ID), &settings, &services, - testlight_auction_request(VALID_SYNTHETIC_ID), ) .await .expect_err("a deployment that creates no identifier should proxy none"); diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 3baf8e687d..e46b3bf41a 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -370,7 +370,9 @@ async fn dispatch_fallback( } if state.registry.has_route(&method, &path) { - let mut ec_context = EcContext::default(); + // A module route is handed what was resolved for its request, the + // permissions among them, as the publisher path is. + let mut ec_context = build_ec_context(state, services, &req).await?; return state .registry .handle_proxy(ProxyDispatchInput { diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 38f7ae6eef..f70cca9950 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -706,7 +706,13 @@ fn build_router(state: &Arc) -> RouterService { EdgeCacheHeader::CloudflareCdnCacheControl, ) } else if state.registry.has_route(&method, &path) { - let mut ec_context = EcContext::default(); + // A module route is handed what was resolved for its request, + // the permissions among them, as the publisher path is. + let mut ec_context = match build_ec_context(&state.settings, &services, &req).await + { + Ok(context) => context, + Err(report) => return Ok(http_error(&report)), + }; state .registry .handle_proxy(ProxyDispatchInput { diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 8bdb57febe..7c49117ab6 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -995,7 +995,13 @@ fn build_router(state: &Arc) -> RouterService { let result = if method == Method::GET && path.starts_with("/static/tsjs=") { handle_tsjs_dynamic(&req, &state.registry, EdgeCacheHeader::SMaxageFallback) } else if state.registry.has_route(&method, &path) { - let mut ec_context = EcContext::default(); + // A module route is handed what was resolved for its request, + // the permissions among them, as the publisher path is. + let mut ec_context = match build_ec_context(&state.settings, &services, &req).await + { + Ok(context) => context, + Err(report) => return Ok(http_error(&report)), + }; state .registry .handle_proxy(ProxyDispatchInput { diff --git a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs index 9b7990ae38..6677417024 100644 --- a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs +++ b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs @@ -497,26 +497,14 @@ pub fn register( )) } -#[async_trait(?Send)] -impl IntegrationProxy for JsAssetProxyIntegration { - fn integration_name(&self) -> &'static str { - JS_ASSET_PROXY_INTEGRATION_ID - } - - fn routes(&self) -> Vec { - self.config - .assets - .iter() - .filter(|asset| asset.proxy == JsAssetProxyMode::Enabled) - .map(|asset| IntegrationEndpoint::new(Method::GET, asset.path.clone())) - .collect() - } - - async fn handle( +impl JsAssetProxyIntegration { + /// Answers the request, with the settings and the services the route + /// names in its module call. + async fn route( &self, + req: Request, settings: &Settings, services: &RuntimeServices, - req: Request, ) -> Result, Report> { let request_path = req.uri().path().to_string(); let asset = self.enabled_asset_for_path(&request_path).ok_or_else(|| { @@ -554,6 +542,30 @@ impl IntegrationProxy for JsAssetProxyIntegration { } } +#[async_trait(?Send)] +impl IntegrationProxy for JsAssetProxyIntegration { + fn integration_name(&self) -> &'static str { + JS_ASSET_PROXY_INTEGRATION_ID + } + + fn routes(&self) -> Vec { + self.config + .assets + .iter() + .filter(|asset| asset.proxy == JsAssetProxyMode::Enabled) + .map(|asset| IntegrationEndpoint::new(Method::GET, asset.path.clone())) + .collect() + } + + async fn handle( + &self, + call: crate::module_context::ModuleCall<'_>, + req: Request, + ) -> Result, Report> { + call.inject_with(self, req, Self::route)?.await + } +} + impl IntegrationAttributeRewriter for JsAssetProxyIntegration { fn integration_id(&self) -> &'static str { JS_ASSET_PROXY_INTEGRATION_ID @@ -1405,7 +1417,7 @@ mod tests { ); let response = integration - .handle(&settings, &services, request) + .route(request, &settings, &services) .await .expect("should proxy conditional asset response"); @@ -1455,13 +1467,13 @@ mod tests { Arc::clone(&buffered_stub) as Arc ); let success = integration - .handle( - &settings, - &buffered_services, + .route( build_http_request( Method::GET, "https://publisher.example.com/assets/vendor.js", ), + &settings, + &buffered_services, ) .await .expect("should proxy buffered asset response"); @@ -1475,13 +1487,13 @@ mod tests { Arc::clone(&streaming_stub) as Arc ); let success = integration - .handle( - &settings, - &streaming_services, + .route( build_http_request( Method::GET, "https://publisher.example.com/assets/vendor.js", ), + &settings, + &streaming_services, ) .await .expect("should proxy streaming asset response"); @@ -1503,13 +1515,13 @@ mod tests { Arc::clone(&unavailable_stub) as Arc, ); let unavailable = integration - .handle( - &settings, - &unavailable_services, + .route( build_http_request( Method::GET, "https://publisher.example.com/assets/vendor.js", ), + &settings, + &unavailable_services, ) .await .expect("should map unavailable origin response"); @@ -1528,13 +1540,13 @@ mod tests { Arc::clone(&status_stub) as Arc ); let status = integration - .handle( - &settings, - &status_services, + .route( build_http_request( Method::GET, "https://publisher.example.com/assets/vendor.js", ), + &settings, + &status_services, ) .await .expect("should map non-success origin response"); diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index b92f2e1f11..b3cb49a253 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -18,6 +18,7 @@ use crate::ec::module::{EcModuleSelection, EdgeCookieModule}; use crate::error::TrustedServerError; use crate::geo::GeoInfo; use crate::http_util::is_navigation_request; +use crate::module_context::{ModuleCall, ModuleContext, ResolvedRequest}; use crate::platform::{DisabledGeo, PlatformGeo, RuntimeServices}; use crate::settings::Settings; use crate::streaming_processor::StreamProcessor; @@ -416,11 +417,27 @@ pub trait IntegrationProxy: Send + Sync { /// or define routes manually for backwards compatibility. fn routes(&self) -> Vec; + /// The permissions this route declares, which decide whether a gated + /// value, such as the Edge Cookie identifier, is passed to it. None by + /// default. + fn required_permissions(&self) -> crate::permissions::PermissionSet { + crate::permissions::PermissionSet::none() + } + /// Handle the proxied request. + /// + /// `call` is the route's call into the request's module context, which + /// carries what the request resolved to, the permissions, consent, + /// location, device signals and Edge Cookie identifier resolved for it, + /// the settings and the services. An implementation hands its own + /// function to [`ModuleCall::inject_with`], with `req` as its own + /// argument, naming what else it needs. The route holds the request + /// itself, so the context carries no separate copy of its evidence. A + /// route whose use needs a permission declares it and checks it is + /// granted, because nothing gates the route itself. async fn handle( &self, - settings: &Settings, - services: &RuntimeServices, + call: ModuleCall<'_>, req: Request, ) -> Result, Report>; @@ -1916,7 +1933,20 @@ impl IntegrationRegistry { // Remove any caller-supplied EC header rather than forwarding it. req.headers_mut().remove(HEADER_X_TS_EC.clone()); - Some(proxy.handle(settings, services, req).await) + // The route takes the request, so the context holds a copy of + // what it resolved to, beside what was resolved for it. + let resolved = ResolvedRequest::of(&req, services.client_info()); + let context = ModuleContext::new(resolved.view()) + .with_settings(settings) + .with_request_state(ec_context, services); + Some( + proxy + .handle( + context.call(proxy.integration_name(), proxy.required_permissions()), + req, + ) + .await, + ) } else { None } @@ -3327,8 +3357,7 @@ mod tests { async fn handle( &self, - _settings: &Settings, - _services: &RuntimeServices, + _call: crate::module_context::ModuleCall<'_>, _req: Request, ) -> Result, Report> { Ok(Response::new(EdgeBody::empty())) @@ -3410,8 +3439,7 @@ mod tests { async fn handle( &self, - _settings: &Settings, - _services: &RuntimeServices, + _call: crate::module_context::ModuleCall<'_>, req: http::Request, ) -> Result, Report> { let response = http::Response::builder() @@ -3903,8 +3931,7 @@ mod tests { async fn handle( &self, - _settings: &Settings, - _services: &RuntimeServices, + _call: crate::module_context::ModuleCall<'_>, req: Request, ) -> Result, Report> { let mut response = Response::builder() @@ -3970,6 +3997,118 @@ mod tests { ); } + /// A route that answers with what its module call handed it. + struct StateRoute { + declared: PermissionSet, + } + + impl StateRoute { + fn report( + &self, + _req: Request, + request: crate::module_context::ModuleRequest<'_>, + permissions: &PermissionState, + edge_cookie: Option>, + ) -> Result, Report> { + let body = format!( + "{} storage={} id={}", + request.host(), + permissions.is_set(Permission::StoreOnDevice), + edge_cookie.map_or("none", |id| id.as_str()), + ); + Ok(Response::builder() + .status(StatusCode::OK) + .body(EdgeBody::from(body)) + .expect("should build the response")) + } + } + + #[async_trait(?Send)] + impl IntegrationProxy for StateRoute { + fn integration_name(&self) -> &'static str { + "state" + } + + fn routes(&self) -> Vec { + vec![IntegrationEndpoint::get("/integrations/test/state")] + } + + fn required_permissions(&self) -> PermissionSet { + self.declared + } + + async fn handle( + &self, + call: ModuleCall<'_>, + req: Request, + ) -> Result, Report> { + call.inject_with(self, req, Self::report)? + } + } + + /// A route is handed the permissions resolved for its request, and the + /// request's Edge Cookie identifier when it declares the permission the + /// identifier's use needs. + #[test] + fn a_route_is_handed_its_requests_permissions_and_identity() { + let settings = create_test_settings(); + let storage = PermissionSet::none().with(Permission::StoreOnDevice); + let answer = |declared: PermissionSet| { + let registry = IntegrationRegistry::from_routes(vec![( + Method::GET, + "/integrations/test/state", + ( + Arc::new(StateRoute { declared }) as Arc, + "state", + ), + )]); + let req = Request::builder() + .method(Method::GET) + .uri("https://test-publisher.com/integrations/test/state") + .header(header::HOST, "test-publisher.com") + .body(EdgeBody::empty()) + .expect("should build request"); + let mut ec_context = EcContext::new_for_test( + Some("an-id".to_owned()), + crate::consent::ConsentContext::default(), + ) + .with_module_for_test(Arc::new(crate::ec::module::HmacModule::new( + crate::redacted::Redacted::new("test-secret-key-32-bytes-minimum".to_owned()), + ))); + let services = noop_services(); + let response = futures::executor::block_on(registry.handle_proxy(ProxyDispatchInput { + method: &Method::GET, + path: "/integrations/test/state", + settings: &settings, + kv: None, + ec_context: &mut ec_context, + services: &services, + req, + })) + .expect("should find the route") + .expect("the route should answer"); + String::from_utf8( + response + .into_body() + .into_bytes() + .unwrap_or_default() + .to_vec(), + ) + .expect("the answer is text") + }; + + assert_eq!( + answer(storage), + "test-publisher.com storage=true id=an-id", + "a route declaring storage is handed the identifier" + ); + assert_eq!( + answer(PermissionSet::none()), + "test-publisher.com storage=true id=none", + "and one declaring nothing still sees the permissions but not the identifier" + ); + } + #[test] fn handle_proxy_rejects_invalid_ec_request_header() { let settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/module_context.rs b/crates/trusted-server-core/src/module_context.rs index b9c0fbc799..ae6c047716 100644 --- a/crates/trusted-server-core/src/module_context.rs +++ b/crates/trusted-server-core/src/module_context.rs @@ -16,8 +16,8 @@ //! | Parameter | What it is | Absent where | //! | --- | --- | --- | //! | [`ModuleRequest`] | method, path, query, host and scheme | never | -//! | `&dyn RequestInfo` | the reader's evidence, headers and client IP | the work is shared by every reader | -//! | `&ClientInfo` | the reader's connection | as above | +//! | `&dyn RequestInfo` | the reader's evidence, headers and client IP | the work is shared by every reader, and in a route, which holds the request itself | +//! | `&ClientInfo` | the reader's connection | the work is shared by every reader | //! | [`ModuleResponse`] | status and headers | no response exists yet | //! | `&PermissionState` | the permissions resolved for the request | before they are resolved, and where the work is shared | //! | `&ConsentContext` | the decoded consent signals | as above | @@ -545,6 +545,15 @@ impl ModuleContext<'static> { } } +impl ModuleCall<'static> { + /// A call into [`ModuleContext::empty`], by no name and declaring + /// nothing, for a module asked outside any request, such as by a test. + #[must_use] + pub fn empty() -> Self { + ModuleContext::empty().call("", PermissionSet::none()) + } +} + impl fmt::Debug for ModuleContext<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("ModuleContext") diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index d8ca298eef..6724b46250 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -7,8 +7,10 @@ Axum, or Spin SDK types. ## Choose the narrowest hook -- `IntegrationProxy` owns explicit method/path endpoints and receives - `Settings`, `RuntimeServices`, and an EdgeZero-neutral request. +- `IntegrationProxy` owns explicit method/path endpoints and receives an + EdgeZero-neutral request and its call into the request's module context, + from which it names the `Settings`, the `RuntimeServices` and whatever + else it needs. - `IntegrationAttributeRewriter` inspects selected HTML attributes. - `IntegrationScriptRewriter` handles one declared selector. - `IntegrationHeadInjector` inserts deterministic head markup. @@ -193,12 +195,21 @@ is omitted, so adapter startup must construct the complete service graph. ## Proxy implementation rules -An `IntegrationProxy::handle` implementation receives the complete runtime -service graph. Register or predict backends through `services.backend()`, -send through `services.http_client()`, bound request and response bodies, and -return `Report` with integration context. The registry -strips internal identity headers before dispatch; an integration must opt into -any explicit forwarding behavior. +An `IntegrationProxy::handle` implementation receives the request and its +`ModuleCall`. It hands a function of its own to `call.inject_with`, with the +request as that function's own argument, and names what else it needs as +parameters, such as `&Settings` and `&RuntimeServices`, which is the complete +runtime service graph. Register or predict backends through +`services.backend()`, send through `services.http_client()`, bound request and +response bodies, and return `Report` with integration +context. The registry strips internal identity headers before dispatch; an +integration must opt into any explicit forwarding behavior. + +A route that names a value whose use needs a permission, such as the Edge +Cookie identifier, declares that permission in `required_permissions` and is +passed the value only on a request that grants it. Nothing gates the route +itself, so a route that must not run without a permission reads the +`&PermissionState` and refuses. Streaming support is an adapter capability. Request `PlatformHttpRequest::with_stream_response()` only when the caller has From d48858f6e59a438dad4a316658185422a1cf74ee Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 8 Oct 2026 19:24:27 +0100 Subject: [PATCH 6/8] The module context carries only what a module is handed The response, the document state and the request's typed extension map leave the context, with `ModuleResponse`, `ModuleExtensions` and the three builder methods that set them. Nothing here sets them. They belong to the middleware that follows and arrive with it, so every value the context offers has a caller that hands it. The table of what is absent where now says what this tree does. The evidence is absent in a route, which holds the request itself. The connection, the permissions and the consent are absent where the device is classified, and the permissions where the signal modules run. The settings and the services are absent outside any request, which is the empty context a module asked on its own is called in. --- .../trusted-server-core/src/module_context.rs | 182 ++---------------- .../src/module_context/tests.rs | 57 +----- 2 files changed, 19 insertions(+), 220 deletions(-) diff --git a/crates/trusted-server-core/src/module_context.rs b/crates/trusted-server-core/src/module_context.rs index ae6c047716..4b87f962b3 100644 --- a/crates/trusted-server-core/src/module_context.rs +++ b/crates/trusted-server-core/src/module_context.rs @@ -16,18 +16,19 @@ //! | Parameter | What it is | Absent where | //! | --- | --- | --- | //! | [`ModuleRequest`] | method, path, query, host and scheme | never | -//! | `&dyn RequestInfo` | the reader's evidence, headers and client IP | the work is shared by every reader, and in a route, which holds the request itself | -//! | `&ClientInfo` | the reader's connection | the work is shared by every reader | -//! | [`ModuleResponse`] | status and headers | no response exists yet | -//! | `&PermissionState` | the permissions resolved for the request | before they are resolved, and where the work is shared | -//! | `&ConsentContext` | the decoded consent signals | as above | -//! | `&Settings` | the deployment's settings | a stored page's fetch plan | -//! | `&RuntimeServices` | stores, caches, backends and the HTTP client | as above | +//! | `&dyn RequestInfo` | the reader's evidence, headers and client IP | in a route, which holds the request itself | +//! | `&ClientInfo` | the reader's connection | where the device is classified | +//! | `&PermissionState` | the permissions resolved for the request | before they are resolved, which is where the device is classified and where the signal modules run | +//! | `&ConsentContext` | the decoded consent signals | where the device is classified | +//! | `&Settings` | the deployment's settings | outside any request | +//! | `&RuntimeServices` | stores, caches, backends and the HTTP client | outside any request | //! | `&GeoInfo` | the reader's location | no location resolved, or withheld | //! | `&DeviceSignals` | the device classification | not classified, or withheld | //! | [`EdgeCookie`] | the request's Edge Cookie identifier | none, or withheld | -//! | `&IntegrationDocumentState` | state shared by middleware on one document | outside a document | -//! | [`ModuleExtensions`] | the request's typed extension map | outside a page request | +//! +//! A module asked outside any request, such as a signal module asked on its +//! own, is called in [`ModuleContext::empty`], which carries the request line +//! of the table and nothing else. //! //! # Permissions //! @@ -41,10 +42,9 @@ //! the debug log. use core::fmt; -use std::sync::Mutex; use edgezero_core::body::Body as EdgeBody; -use http::{HeaderMap, Method, StatusCode}; +use http::Method; use crate::consent::ConsentContext; use crate::ec::EcContext; @@ -52,7 +52,6 @@ use crate::ec::device::DeviceSignals; use crate::error::TrustedServerError; use crate::evidence::RequestInfo; use crate::geo::GeoInfo; -use crate::integrations::IntegrationDocumentState; use crate::permissions::{PermissionSet, PermissionState}; use crate::platform::{ClientInfo, RuntimeServices}; use crate::settings::Settings; @@ -64,8 +63,7 @@ pub use inject::{Process, ProcessWith}; /// What a request resolved to: its method and address, and the host and /// scheme it was made for. /// -/// Carries nothing about who asked, so it is in every context, including those -/// whose work is shared by every reader. +/// Carries nothing about who asked, so it is in every context. #[derive(Debug, Clone, Copy)] pub struct ModuleRequest<'r> { method: &'r Method, @@ -175,33 +173,6 @@ impl ResolvedRequest { } } -/// The response a module runs on. -#[derive(Debug, Clone, Copy)] -pub struct ModuleResponse<'r> { - status: StatusCode, - headers: &'r HeaderMap, -} - -impl<'r> ModuleResponse<'r> { - /// A response with `status` and `headers`. - #[must_use] - pub fn new(status: StatusCode, headers: &'r HeaderMap) -> Self { - Self { status, headers } - } - - /// The response status. - #[must_use] - pub fn status(&self) -> StatusCode { - self.status - } - - /// The response headers. - #[must_use] - pub fn headers(&self) -> &'r HeaderMap { - self.headers - } -} - /// The request's Edge Cookie identifier, as the module that created it wrote /// it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -215,74 +186,6 @@ impl<'r> EdgeCookie<'r> { } } -/// The request's typed extension map, where a module leaves a value for a -/// later one on the same request. -/// -/// A page request's preparers are handed it writable, and the serve -/// middleware read what they left. Everywhere else it is read only. -#[derive(Clone, Copy)] -pub struct ModuleExtensions<'r>(ExtensionsSource<'r>); - -#[derive(Clone, Copy)] -enum ExtensionsSource<'r> { - ReadOnly(&'r http::Extensions), - Writable(&'r Mutex), -} - -impl<'r> ModuleExtensions<'r> { - /// The map, read only. - #[must_use] - pub fn read_only(extensions: &'r http::Extensions) -> Self { - Self(ExtensionsSource::ReadOnly(extensions)) - } - - /// The map, which a module may add to. - #[must_use] - pub fn writable(extensions: &'r Mutex) -> Self { - Self(ExtensionsSource::Writable(extensions)) - } - - /// A copy of the value of type `T`, when the map holds one. - #[must_use] - pub fn get(&self) -> Option { - match self.0 { - ExtensionsSource::ReadOnly(extensions) => extensions.get::().cloned(), - ExtensionsSource::Writable(extensions) => extensions - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .get::() - .cloned(), - } - } - - /// Puts `value` in the map, in place of any value of its type. - /// - /// # Errors - /// - /// Gives `value` back when the map is read only here. - pub fn insert(&self, value: T) -> Result<(), T> { - match self.0 { - ExtensionsSource::ReadOnly(_) => Err(value), - ExtensionsSource::Writable(extensions) => { - extensions - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert(value); - Ok(()) - } - } - } -} - -impl fmt::Debug for ModuleExtensions<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let writable = matches!(self.0, ExtensionsSource::Writable(_)); - f.debug_struct("ModuleExtensions") - .field("writable", &writable) - .finish_non_exhaustive() - } -} - /// A value whose use needs the permissions its producer declares. struct Gated<'r, T: ?Sized> { value: &'r T, @@ -310,7 +213,6 @@ pub struct ModuleContext<'r> { request: ModuleRequest<'r>, evidence: Option<&'r dyn RequestInfo>, client: Option<&'r ClientInfo>, - response: Option>, permissions: Option<&'r PermissionState>, consent: Option<&'r ConsentContext>, settings: Option<&'r Settings>, @@ -318,22 +220,19 @@ pub struct ModuleContext<'r> { geo: Option>, device: Option>, edge_cookie: Option>, - document_state: Option<&'r IntegrationDocumentState>, - extensions: Option>, } impl<'r> ModuleContext<'r> { /// A context carrying `request` and nothing else. /// - /// This is the context of work shared by every reader, such as a stored - /// page's fetch plan, until the builder methods add to it. + /// This is the context of a module asked outside any request, until the + /// builder methods add to it. #[must_use] pub const fn new(request: ModuleRequest<'r>) -> Self { Self { request, evidence: None, client: None, - response: None, permissions: None, consent: None, settings: None, @@ -341,8 +240,6 @@ impl<'r> ModuleContext<'r> { geo: None, device: None, edge_cookie: None, - document_state: None, - extensions: None, } } @@ -364,15 +261,6 @@ impl<'r> ModuleContext<'r> { } } - /// The same context carrying the response a module runs on. - #[must_use] - pub fn with_response(self, response: ModuleResponse<'r>) -> Self { - Self { - response: Some(response), - ..self - } - } - /// The same context carrying the permissions resolved for the request. #[must_use] pub fn with_permissions(self, permissions: &'r PermissionState) -> Self { @@ -448,25 +336,6 @@ impl<'r> ModuleContext<'r> { } } - /// The same context carrying the state shared by the middleware working - /// on one document. - #[must_use] - pub fn with_document_state(self, document_state: &'r IntegrationDocumentState) -> Self { - Self { - document_state: Some(document_state), - ..self - } - } - - /// The same context carrying the request's typed extension map. - #[must_use] - pub fn with_extensions(self, extensions: ModuleExtensions<'r>) -> Self { - Self { - extensions: Some(extensions), - ..self - } - } - /// The same context carrying what the request's Edge Cookie state holds, /// being its permissions, consent, location, device signals and Edge /// Cookie identifier, and the request's services and connection. @@ -559,7 +428,6 @@ impl fmt::Debug for ModuleContext<'_> { f.debug_struct("ModuleContext") .field("request", &self.request) .field("evidence", &self.evidence.is_some()) - .field("response", &self.response.map(|response| response.status)) .field("permissions", &self.permissions.is_some()) .field("settings", &self.settings.is_some()) .field("services", &self.services.is_some()) @@ -742,12 +610,6 @@ impl<'c> FromModuleContext<'c> for &'c ClientInfo { } } -impl<'c> FromModuleContext<'c> for ModuleResponse<'c> { - fn from_module_context(call: &ModuleCall<'c>) -> Result { - call.context.response.ok_or(Withheld::absent("response")) - } -} - impl<'c> FromModuleContext<'c> for &'c PermissionState { fn from_module_context(call: &ModuleCall<'c>) -> Result { call.context @@ -793,22 +655,6 @@ impl<'c> FromModuleContext<'c> for EdgeCookie<'c> { } } -impl<'c> FromModuleContext<'c> for &'c IntegrationDocumentState { - fn from_module_context(call: &ModuleCall<'c>) -> Result { - call.context - .document_state - .ok_or(Withheld::absent("document state")) - } -} - -impl<'c> FromModuleContext<'c> for ModuleExtensions<'c> { - fn from_module_context(call: &ModuleCall<'c>) -> Result { - call.context - .extensions - .ok_or(Withheld::absent("request extensions")) - } -} - /// A tuple of values is passed when every one of them is. macro_rules! tuple_from_module_context { ($($value:ident),+) => { diff --git a/crates/trusted-server-core/src/module_context/tests.rs b/crates/trusted-server-core/src/module_context/tests.rs index 1d54704ea9..4fa48e025a 100644 --- a/crates/trusted-server-core/src/module_context/tests.rs +++ b/crates/trusted-server-core/src/module_context/tests.rs @@ -1,7 +1,7 @@ use std::cell::Cell; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; -use http::{HeaderMap, HeaderValue, Method, StatusCode, header}; +use http::{HeaderMap, HeaderValue, Method, header}; use super::*; use crate::ec::module::HmacModule; @@ -84,28 +84,19 @@ fn each_value_the_context_carries_reaches_a_function_that_names_it() { tls_protocol: Some("TLSv1.3".to_owned()), ..ClientInfo::default() }; - let mut response_headers = HeaderMap::new(); - response_headers.insert("x-origin", HeaderValue::from_static("yes")); let permissions = PermissionState::new(storage()); let consent = ConsentContext::default(); let mut settings = Settings::default(); settings.publisher.domain = "publisher.example".to_owned(); let services_ip: std::net::IpAddr = "203.0.113.9".parse().expect("should parse an IP"); let services = noop_services_with_client_ip(services_ip); - let document_state = IntegrationDocumentState::default(); - document_state.get_or_insert_with("module", || 7_u32); - let mut extensions = http::Extensions::new(); - extensions.insert(11_u64); let context = ModuleContext::new(ModuleRequest::new(&method, "h", "https", "/")) .with_evidence(&evidence) .with_client(&client) - .with_response(ModuleResponse::new(StatusCode::CREATED, &response_headers)) .with_permissions(&permissions) .with_consent(&consent) .with_settings(&settings) - .with_services(&services) - .with_document_state(&document_state) - .with_extensions(ModuleExtensions::read_only(&extensions)); + .with_services(&services); let call = context.call("module", PermissionSet::none()); let first = call @@ -114,15 +105,12 @@ fn each_value_the_context_carries_reaches_a_function_that_names_it() { |_: &Module, evidence: &dyn RequestInfo, client: &ClientInfo, - response: ModuleResponse<'_>, permissions: &PermissionState, consent: &ConsentContext| { ( evidence.user_agent().to_owned(), evidence.client_ip().to_owned(), client.tls_protocol.clone(), - response.status(), - response.headers().contains_key("x-origin"), permissions.is_set(Permission::StoreOnDevice), consent.jurisdiction.clone(), ) @@ -135,8 +123,6 @@ fn each_value_the_context_carries_reaches_a_function_that_names_it() { "ExampleAgent/1.0".to_owned(), "203.0.113.7".to_owned(), Some("TLSv1.3".to_owned()), - StatusCode::CREATED, - true, true, ConsentContext::default().jurisdiction, ) @@ -145,29 +131,15 @@ fn each_value_the_context_carries_reaches_a_function_that_names_it() { let second = call .inject( &Module, - |_: &Module, - settings: &Settings, - services: &RuntimeServices, - document: &IntegrationDocumentState, - extensions: ModuleExtensions<'_>| { + |_: &Module, settings: &Settings, services: &RuntimeServices| { ( settings.publisher.domain.clone(), services.client_info().client_ip, - document.get::("module").map(|value| *value), - extensions.get::(), ) }, ) .expect("every value named is carried"); - assert_eq!( - second, - ( - "publisher.example".to_owned(), - Some(services_ip), - Some(7), - Some(11), - ) - ); + assert_eq!(second, ("publisher.example".to_owned(), Some(services_ip))); } #[test] @@ -406,25 +378,6 @@ fn a_function_may_name_eight_values_and_a_tuple_groups_more() { ); } -#[test] -fn a_writable_extension_map_keeps_what_a_module_puts_in_it() { - let map = Mutex::new(http::Extensions::new()); - let writable = ModuleExtensions::writable(&map); - let read_only_map = http::Extensions::new(); - let read_only = ModuleExtensions::read_only(&read_only_map); - - writable - .insert(String::from("hint")) - .expect("a writable map takes a value"); - assert_eq!(writable.get::().as_deref(), Some("hint")); - assert_eq!( - read_only.insert(String::from("hint")), - Err(String::from("hint")), - "a read-only map gives the value back" - ); - assert_eq!(read_only.get::(), None); -} - #[test] fn the_request_state_carries_each_value_gated_by_what_its_producer_declares() { let method = Method::GET; From e8c7ef5f5a3995e683c81789bc7d9bf99ccae76e Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Thu, 8 Oct 2026 19:24:28 +0100 Subject: [PATCH 7/8] The integration guide says what a module is handed A new section sets out the module context for a module author: the function a module hands to its call, the values that function may name, what happens when one cannot be passed, the permissions a gated value needs, and what the context carries for each kind of module. The example is a route that names the settings and, when it may be passed, the location. --- docs/guide/integration-guide.md | 86 +++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 6724b46250..038324ae01 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -342,6 +342,92 @@ startup. The message lists the modules of that type the deployment runs, and says when the name is a module no section selects or one that supplies no module of that type. +### What a module is handed + +Core builds one module context for a request and hands each module it calls +a `ModuleCall`, which is that context together with the permissions the +module declares. The module hands a function of its own to `call.inject`, +and that function names what it needs as parameters. Each is passed in from +the context, which borrows what the request already holds. + +```rust +impl ExampleProxy { + /// Answers with the country the request came from, when the location + /// may be passed to this route. + async fn route( + &self, + _req: Request, + settings: &Settings, + geo: Option<&GeoInfo>, + ) -> Result, Report> { + let country = geo.map_or("unknown", |geo| geo.country.as_str()); + let body = format!("{} {country}", settings.publisher.domain); + Ok(Response::new(EdgeBody::from(body))) + } +} + +#[async_trait(?Send)] +impl IntegrationProxy for ExampleProxy { + fn integration_name(&self) -> &'static str { + "example" + } + + fn routes(&self) -> Vec { + vec![self.get("/country")] + } + + async fn handle( + &self, + call: ModuleCall<'_>, + req: Request, + ) -> Result, Report> { + call.inject_with(self, req, Self::route)?.await + } +} +``` + +| Parameter | What it is | +| ------------------ | ---------------------------------------------------------- | +| `ModuleRequest` | The method, path, query, host and scheme of the request | +| `&dyn RequestInfo` | The reader's evidence, being the headers and the client IP | +| `&ClientInfo` | The reader's connection | +| `&PermissionState` | The permissions resolved for the request | +| `&ConsentContext` | The decoded consent signals | +| `&Settings` | The deployment's settings | +| `&RuntimeServices` | Stores, caches, backends and the HTTP client | +| `&GeoInfo` | The reader's location | +| `&DeviceSignals` | The device classification | +| `EdgeCookie` | The request's Edge Cookie identifier | + +A parameter may be an `Option` of one of these, which is `None` where the +value is absent or withheld, or a tuple of several. A function that names a +value itself is not called when the value cannot be passed, and the reason +goes to the debug log. The module decides what that means. A device module +answers the unknown device, an Edge Cookie module creates no identifier, and +a route's `?` answers `403`. `call.inject_with` passes one argument of the +caller's own after the module, which is how a route takes its request and +how an Edge Cookie module takes the value a page posted. + +The location, the device signals and the Edge Cookie identifier each carry +the permissions that the module which produced them declares. One of them is +passed only to a module that declares every one of those permissions in its +`required_permissions` and is granted them on this request. A value whose +producer declares no permission is passed to every module. + +What a context carries depends on where the module is called. + +| Module | Called through | What its context carries | +| ----------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Device | `DeviceModule::detect` | The request, the evidence, the settings and the services. No permission is resolved yet, so nothing whose use needs one | +| Permission signal | `SignalInput::call`, `valid_signal` and `tdls` | The request, the evidence, the connection, the consent, the settings and the services, and the location when its use needs no permission. The permissions are what the run produces | +| Edge Cookie | `EdgeCookieModule::generate` and `resolve_from_client` | Every row of the table above | +| Route | `IntegrationProxy::handle` | Every row of the table above except the evidence, because the route holds the request itself | + +A request preparer and a response finalizer are plain functions on the +builder and are not handed the context. A preparer changes the request before +anything about it is resolved, and a finalizer is handed only what its own +module left for the request. + ### Acting on one request A module that decides something about one request and acts on it later From 359e30a32c123288d303cf0b1d74c225f83496a5 Mon Sep 17 00:00:00 2001 From: James Rogers Date: Mon, 12 Oct 2026 13:58:04 +0100 Subject: [PATCH 8/8] A value absent where a route runs answers 500, and each rule is said once A route whose parameter the request's permissions withhold answers 403 as before. A value absent where the route runs, or needing permissions that are not resolved there, is a fault in how the module was written or placed, and answers 500 rather than 403. The injection rule is stated in the module_context rustdoc and the trait methods link to it. The route helpers' repeated doc sentence, three repeats of the Edge Cookie snapshot comment and the guide's copy of the parameter table are removed. A misplaced test helper doc is moved back to its function, and the device-signal gate's limit for a module the adapter builds is stated. --- crates/ad-tag/google/src/lib.rs | 2 - crates/auction/prebid/src/lib.rs | 2 - crates/audience/permutive/src/lib.rs | 2 - crates/bot-protection/datadome/src/lib.rs | 2 - crates/cmp/didomi/src/lib.rs | 2 - crates/cmp/sourcepoint/src/lib.rs | 2 - crates/identity/lockr/src/lib.rs | 2 - crates/tag/google-tag-manager/src/lib.rs | 2 - crates/testing/seam-probe/src/lib.rs | 2 - crates/testing/testlight/src/lib.rs | 2 - crates/trusted-server-core/src/ec/device.rs | 4 +- crates/trusted-server-core/src/ec/mod.rs | 22 ++--------- crates/trusted-server-core/src/ec/module.rs | 11 ++---- .../src/integrations/js_asset_proxy.rs | 2 - .../src/integrations/registry.rs | 12 ++---- .../trusted-server-core/src/module_context.rs | 32 ++++++++++------ .../src/module_context/inject.rs | 14 +++---- .../src/module_context/tests.rs | 31 ++++++++++++++- .../src/permission_signal/mod.rs | 8 ++-- docs/guide/integration-guide.md | 38 +++++++------------ 20 files changed, 83 insertions(+), 111 deletions(-) diff --git a/crates/ad-tag/google/src/lib.rs b/crates/ad-tag/google/src/lib.rs index 403f40cf1c..9d29c6b780 100644 --- a/crates/ad-tag/google/src/lib.rs +++ b/crates/ad-tag/google/src/lib.rs @@ -441,8 +441,6 @@ pub fn register( } impl GptIntegration { - /// Answers the request, with the settings and the services the route - /// names in its module call. async fn route( &self, req: http::Request, diff --git a/crates/auction/prebid/src/lib.rs b/crates/auction/prebid/src/lib.rs index 4a4e161499..40489e459f 100644 --- a/crates/auction/prebid/src/lib.rs +++ b/crates/auction/prebid/src/lib.rs @@ -1127,8 +1127,6 @@ pub fn register( } impl PrebidIntegration { - /// Answers the request, with the settings and the services the route - /// names in its module call. async fn route( &self, req: http::Request, diff --git a/crates/audience/permutive/src/lib.rs b/crates/audience/permutive/src/lib.rs index 16679f1fe0..40cb0f6374 100644 --- a/crates/audience/permutive/src/lib.rs +++ b/crates/audience/permutive/src/lib.rs @@ -358,8 +358,6 @@ pub fn register( } impl PermutiveIntegration { - /// Answers the request, with the settings and the services the route - /// names in its module call. async fn route( &self, req: http::Request, diff --git a/crates/bot-protection/datadome/src/lib.rs b/crates/bot-protection/datadome/src/lib.rs index 88ed844994..28725ec2c8 100644 --- a/crates/bot-protection/datadome/src/lib.rs +++ b/crates/bot-protection/datadome/src/lib.rs @@ -875,8 +875,6 @@ impl DataDomeIntegration { } impl DataDomeIntegration { - /// Answers the request, with the services the route names in its module - /// call. async fn route( &self, req: http::Request, diff --git a/crates/cmp/didomi/src/lib.rs b/crates/cmp/didomi/src/lib.rs index 8b753a39c7..8eabbf5b51 100644 --- a/crates/cmp/didomi/src/lib.rs +++ b/crates/cmp/didomi/src/lib.rs @@ -443,8 +443,6 @@ pub fn register( } impl DidomiIntegration { - /// Answers the request, with the services the route names in its module - /// call. async fn route( &self, req: http::Request, diff --git a/crates/cmp/sourcepoint/src/lib.rs b/crates/cmp/sourcepoint/src/lib.rs index b9cd3e90a8..5fee648383 100644 --- a/crates/cmp/sourcepoint/src/lib.rs +++ b/crates/cmp/sourcepoint/src/lib.rs @@ -809,8 +809,6 @@ pub fn register( } impl SourcepointIntegration { - /// Answers the request, with the services the route names in its module - /// call. async fn route( &self, req: Request, diff --git a/crates/identity/lockr/src/lib.rs b/crates/identity/lockr/src/lib.rs index 5b79563d94..b74170d7f4 100644 --- a/crates/identity/lockr/src/lib.rs +++ b/crates/identity/lockr/src/lib.rs @@ -379,8 +379,6 @@ pub fn register( } impl LockrIntegration { - /// Answers the request, with the settings and the services the route - /// names in its module call. async fn route( &self, req: http::Request, diff --git a/crates/tag/google-tag-manager/src/lib.rs b/crates/tag/google-tag-manager/src/lib.rs index 35c8069f30..5dc84ff739 100644 --- a/crates/tag/google-tag-manager/src/lib.rs +++ b/crates/tag/google-tag-manager/src/lib.rs @@ -948,8 +948,6 @@ pub fn register( } impl GoogleTagManagerIntegration { - /// Answers the request, with the settings and the services the route - /// names in its module call. async fn route( &self, req: http::Request, diff --git a/crates/testing/seam-probe/src/lib.rs b/crates/testing/seam-probe/src/lib.rs index 9a1720803a..6127a7016d 100644 --- a/crates/testing/seam-probe/src/lib.rs +++ b/crates/testing/seam-probe/src/lib.rs @@ -416,8 +416,6 @@ impl AuctionProvider for SeamProbeAdServer { pub struct SeamProbeProxy; impl SeamProbeProxy { - /// Answers the request, with the services the route names in its module - /// call. async fn route( &self, req: Request, diff --git a/crates/testing/testlight/src/lib.rs b/crates/testing/testlight/src/lib.rs index 118c72e641..884af941a6 100644 --- a/crates/testing/testlight/src/lib.rs +++ b/crates/testing/testlight/src/lib.rs @@ -198,8 +198,6 @@ pub fn register( } impl TestlightIntegration { - /// Answers the request, with the settings and the services the route - /// names in its module call. async fn route( &self, req: http::Request, diff --git a/crates/trusted-server-core/src/ec/device.rs b/crates/trusted-server-core/src/ec/device.rs index 415151c26d..41cec34968 100644 --- a/crates/trusted-server-core/src/ec/device.rs +++ b/crates/trusted-server-core/src/ec/device.rs @@ -149,9 +149,7 @@ pub trait DeviceModule: Send + Sync { /// Classifies the request into [`DeviceSignals`]. /// - /// An implementation hands its own function to [`ModuleCall::inject`], - /// naming what it reads, usually the request's evidence and the - /// services, as well as any host signals injected into its constructor. + /// See [`crate::module_context`] for what an implementation may name. /// Classification runs before the permissions are resolved, so no value /// whose use needs a permission is passed here. /// diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 28df96e99c..6383abaeed 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -428,14 +428,7 @@ impl EcContext { log::trace!("Existing EC ID found: {}", log_id(id)); } - // Snapshot the request a module is handed when it creates an - // identifier (the headers, so it can read cookies and client hints, and - // what the request resolved to, with the address, so it can read - // request parameters). Capture only when a module is configured and - // either no identifier exists or the request is a document navigation, - // which orphan recovery may need, so a no-module deployment and a - // returning visitor's subresource requests clone nothing. Creation runs - // after the request body may be consumed, so the snapshot is owned. + // Owned, because creation runs after the request body may be consumed. let (request_headers, request) = if selected_module.is_some() && (ec_value.is_none() || crate::http_util::is_navigation_request(req)) { @@ -611,11 +604,7 @@ impl EcContext { /// identifier, and the finished identifier, with its module code applied, /// is checked against the global bounds. Generation and orphan recovery both /// use this, so a rotated identifier comes from the same module - /// and passes the same checks as a new one. The module is handed a module - /// context carrying the request captured at read time (the client IP, the - /// headers, and what the request resolved to), the settings, the services - /// and what this context resolved for the request, and the built-in HMAC - /// module reads only the client IP. + /// and passes the same checks as a new one. /// /// # Errors /// @@ -632,11 +621,6 @@ impl EcContext { settings: &Settings, services: &RuntimeServices, ) -> Result, Report> { - // Lend the request captured at read time, being the client IP, the - // request headers (so a module reads cookies and client hints), and - // what the request resolved to, with the address (so it reads request - // parameters). A built-in module reads only the client IP, and a vendor - // module names what it needs. let request = self.request.view(); let request_info = BorrowedRequestInfo::new( self.client_ip.as_deref().unwrap_or_default(), @@ -1018,7 +1002,6 @@ impl EcContext { &mut self.pull_sync_marker } - /// Sets pull-sync marker state in focused unit tests. /// The same test-only context with a resolved location. #[cfg(test)] #[must_use] @@ -1027,6 +1010,7 @@ impl EcContext { self } + /// Sets pull-sync marker state in focused unit tests. #[cfg(test)] pub(crate) fn set_pull_sync_marker_for_test(&mut self, state: PullSyncMarkerState) { self.pull_sync_marker = state; diff --git a/crates/trusted-server-core/src/ec/module.rs b/crates/trusted-server-core/src/ec/module.rs index f81aca3643..0141f221c0 100644 --- a/crates/trusted-server-core/src/ec/module.rs +++ b/crates/trusted-server-core/src/ec/module.rs @@ -615,13 +615,10 @@ pub trait EdgeCookieModule: Send + Sync + core::fmt::Debug { /// Derives an Edge Cookie identifier for the request. /// - /// An implementation hands its own function to [`ModuleCall::inject`], - /// naming what it reads, such as the request's evidence, the permissions - /// and consent resolved for it, or the services. On the request path core - /// calls this only once the permissions the module declares are set, so - /// the module reads the permissions only for behavior beyond that gate. A - /// function that is not passed what it names produces no identifier for - /// the request. + /// See [`crate::module_context`] for what an implementation may name. On + /// the request path core calls this only once the permissions the module + /// declares are set. A function that is not passed what it names produces + /// no identifier for the request. /// /// A server-side module creates here. A client-side module defers here /// (returns `id: None`) and creates later in diff --git a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs index 6677417024..fb80c60b20 100644 --- a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs +++ b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs @@ -498,8 +498,6 @@ pub fn register( } impl JsAssetProxyIntegration { - /// Answers the request, with the settings and the services the route - /// names in its module call. async fn route( &self, req: Request, diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index b3cb49a253..88d949f5da 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -426,15 +426,9 @@ pub trait IntegrationProxy: Send + Sync { /// Handle the proxied request. /// - /// `call` is the route's call into the request's module context, which - /// carries what the request resolved to, the permissions, consent, - /// location, device signals and Edge Cookie identifier resolved for it, - /// the settings and the services. An implementation hands its own - /// function to [`ModuleCall::inject_with`], with `req` as its own - /// argument, naming what else it needs. The route holds the request - /// itself, so the context carries no separate copy of its evidence. A - /// route whose use needs a permission declares it and checks it is - /// granted, because nothing gates the route itself. + /// Hand a function to [`ModuleCall::inject_with`] with `req` as its + /// argument. A route whose use needs a permission declares it and checks + /// it is granted, because nothing gates the route itself. async fn handle( &self, call: ModuleCall<'_>, diff --git a/crates/trusted-server-core/src/module_context.rs b/crates/trusted-server-core/src/module_context.rs index 4b87f962b3..c75e74db0c 100644 --- a/crates/trusted-server-core/src/module_context.rs +++ b/crates/trusted-server-core/src/module_context.rs @@ -1,5 +1,5 @@ -//! One context for every module, and the values a module's process function -//! may name as parameters. +//! One context for every module, and the values a module's function may +//! name as parameters. //! //! Core builds one [`ModuleContext`] where it calls modules, from what the //! request already holds. The context borrows everything it carries, so @@ -7,7 +7,7 @@ //! the same context. Each module is handed a [`ModuleCall`], being the context //! together with the permissions that module declares. //! -//! A module's process function names what it needs as parameters, and +//! A module's function names what it needs as parameters, and //! [`ModuleCall::inject`] passes each one in from the context. A parameter may //! be any type that implements [`FromModuleContext`], an `Option` of one, which //! is `None` when the value cannot be passed, or a tuple of them. The function @@ -206,8 +206,6 @@ impl Copy for Gated<'_, T> {} /// holds, and borrowed by every module called there. A module never sees it /// directly. It is handed a [`ModuleCall`] and names what it needs, see /// [`ModuleCall::inject`]. -/// -/// It holds only borrows, so a copy is a copy of those borrows. #[derive(Clone, Copy)] pub struct ModuleContext<'r> { request: ModuleRequest<'r>, @@ -343,7 +341,9 @@ impl<'r> ModuleContext<'r> { /// Each gated value needs what its producer declares. The location needs /// what the geo module in `services` declares, the device signals what the /// device module in `services` declares, and the identifier what the Edge - /// Cookie module the state was built with declares. + /// Cookie module the state was built with declares. Signals from a device + /// module the adapter builds itself, as Fastly does with none registered, + /// are gated by nothing, so such a module must declare no permission. #[must_use] pub fn with_request_state(self, state: &'r EcContext, services: &'r RuntimeServices) -> Self { let mut context = self @@ -441,7 +441,7 @@ impl fmt::Debug for ModuleContext<'_> { /// Core's call to one module, being the request's [`ModuleContext`] and the /// permissions the module declares. /// -/// A module's process function is handed its parameters through +/// A module's function is handed its parameters through /// [`inject`](Self::inject). #[derive(Debug, Clone, Copy)] pub struct ModuleCall<'c> { @@ -559,17 +559,25 @@ impl fmt::Display for Withheld { impl core::error::Error for Withheld {} -/// A route whose parameter is withheld answers `403`, the same as a route -/// refusing a permission itself. +/// A route whose parameter the request's permissions withhold answers `403`. +/// A value absent where the module runs, or needing permissions that are not +/// resolved there, is a fault in how the module was written or placed, and +/// answers `500`. impl From for error_stack::Report { fn from(withheld: Withheld) -> Self { - error_stack::Report::new(TrustedServerError::Forbidden { - message: withheld.to_string(), + let message = withheld.to_string(); + error_stack::Report::new(match withheld.reason { + WithheldReason::NotDeclared(_) | WithheldReason::NotGranted(_) => { + TrustedServerError::Forbidden { message } + } + WithheldReason::Absent | WithheldReason::Unresolved => { + TrustedServerError::Configuration { message } + } }) } } -/// A value a module's process function can name as a parameter. +/// A value a module's function can name as a parameter. /// /// Implemented for each value a [`ModuleContext`] carries, for an `Option` of /// one, which is `None` rather than withheld, and for tuples of up to eight. diff --git a/crates/trusted-server-core/src/module_context/inject.rs b/crates/trusted-server-core/src/module_context/inject.rs index f3a6793509..ba41fed5f0 100644 --- a/crates/trusted-server-core/src/module_context/inject.rs +++ b/crates/trusted-server-core/src/module_context/inject.rs @@ -1,13 +1,9 @@ -//! Calling a module's process function with its parameters taken from the -//! request's [`ModuleContext`](super::ModuleContext). +//! Calling a module's function with its parameters taken from the request's +//! [`ModuleContext`](super::ModuleContext). //! -//! Core holds modules as trait objects, and a trait object cannot have a -//! generic method. So each kind of module's trait method takes a -//! [`ModuleCall`], and the module's implementation of it hands its own process -//! function to [`ModuleCall::inject`]. That call is generic and is made where -//! the module's type is known, so nothing is boxed, allocated or copied. This -//! file is the one place that turns a function's parameters into values from -//! the context. +//! A trait method takes a [`ModuleCall`] and the implementation hands its own +//! function to [`ModuleCall::inject`], which is generic where the module type +//! is known. //! //! The function takes the module first, then up to eight parameters, each a //! [`FromModuleContext`] value. [`ModuleCall::inject_with`] passes one argument diff --git a/crates/trusted-server-core/src/module_context/tests.rs b/crates/trusted-server-core/src/module_context/tests.rs index 4fa48e025a..dff4fbc923 100644 --- a/crates/trusted-server-core/src/module_context/tests.rs +++ b/crates/trusted-server-core/src/module_context/tests.rs @@ -311,7 +311,7 @@ impl Module { #[allow( clippy::too_many_arguments, - reason = "the most parameters a process function may name" + reason = "the most parameters a module function may name" )] fn eight( &self, @@ -425,3 +425,32 @@ fn the_request_state_carries_each_value_gated_by_what_its_producer_declares() { .expect("a module declaring storage is passed the identifier"); assert_eq!(declared, "an-id"); } + +#[test] +fn a_route_answers_forbidden_only_for_a_value_its_permissions_withhold() { + use crate::error::IntoHttpResponse as _; + + let status = |reason| { + error_stack::Report::::from(Withheld::new("geo", reason)) + .current_context() + .status_code() + }; + + assert_eq!( + status(WithheldReason::NotDeclared(storage())), + http::StatusCode::FORBIDDEN + ); + assert_eq!( + status(WithheldReason::NotGranted(storage())), + http::StatusCode::FORBIDDEN + ); + assert_eq!( + status(WithheldReason::Absent), + http::StatusCode::INTERNAL_SERVER_ERROR, + "a value absent where the route runs is the route's fault, not the reader's" + ); + assert_eq!( + status(WithheldReason::Unresolved), + http::StatusCode::INTERNAL_SERVER_ERROR + ); +} diff --git a/crates/trusted-server-core/src/permission_signal/mod.rs b/crates/trusted-server-core/src/permission_signal/mod.rs index 72769196a3..74d7d33b8e 100644 --- a/crates/trusted-server-core/src/permission_signal/mod.rs +++ b/crates/trusted-server-core/src/permission_signal/mod.rs @@ -252,9 +252,8 @@ pub trait PermissionSignalModule: Send + Sync { /// taken silently. Core carries what every module vouched for on the /// permission state, and a signal nobody vouched for is dropped from /// everything Trusted Server sends on, so a corrupt record never reaches - /// a page or a bid request. An implementation hands its own function to - /// [`ModuleCall::inject`], naming what it reads, usually the consent or - /// the evidence. + /// a page or a bid request. See [`crate::module_context`] for what an + /// implementation may name. fn valid_signal(&self, _call: ModuleCall<'_>) -> Option { None } @@ -272,8 +271,7 @@ pub trait PermissionSignalModule: Send + Sync { /// says stays between the parties bound by it. /// /// A locator must point at a document that is never edited once - /// published, which [`Tdl`] documents and cannot enforce. An - /// implementation names what it reads through [`ModuleCall::inject`]. + /// published, which [`Tdl`] documents and cannot enforce. fn tdls(&self, _call: ModuleCall<'_>) -> Vec { Vec::new() } diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 038324ae01..d10be83bc5 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -201,9 +201,9 @@ request as that function's own argument, and names what else it needs as parameters, such as `&Settings` and `&RuntimeServices`, which is the complete runtime service graph. Register or predict backends through `services.backend()`, send through `services.http_client()`, bound request and -response bodies, and return `Report` with integration -context. The registry strips internal identity headers before dispatch; an -integration must opt into any explicit forwarding behavior. +response bodies, and return `Report` with the route's +context. The registry strips internal identity headers before dispatch, and a +route must opt into any explicit forwarding behavior. A route that names a value whose use needs a permission, such as the Edge Cookie identifier, declares that permission in `required_permissions` and is @@ -386,27 +386,17 @@ impl IntegrationProxy for ExampleProxy { } ``` -| Parameter | What it is | -| ------------------ | ---------------------------------------------------------- | -| `ModuleRequest` | The method, path, query, host and scheme of the request | -| `&dyn RequestInfo` | The reader's evidence, being the headers and the client IP | -| `&ClientInfo` | The reader's connection | -| `&PermissionState` | The permissions resolved for the request | -| `&ConsentContext` | The decoded consent signals | -| `&Settings` | The deployment's settings | -| `&RuntimeServices` | Stores, caches, backends and the HTTP client | -| `&GeoInfo` | The reader's location | -| `&DeviceSignals` | The device classification | -| `EdgeCookie` | The request's Edge Cookie identifier | - -A parameter may be an `Option` of one of these, which is `None` where the -value is absent or withheld, or a tuple of several. A function that names a -value itself is not called when the value cannot be passed, and the reason -goes to the debug log. The module decides what that means. A device module -answers the unknown device, an Edge Cookie module creates no identifier, and -a route's `?` answers `403`. `call.inject_with` passes one argument of the -caller's own after the module, which is how a route takes its request and -how an Edge Cookie module takes the value a page posted. +The values a function may name, and where each is absent, are listed in the +documentation of `trusted_server_core::module_context`. A parameter may be an +`Option` of one of them, which is `None` where the value is absent or +withheld, or a tuple of several. A function that names a value itself is not +called when the value cannot be passed, and the reason goes to the debug log. +The module decides what that means. A device module answers the unknown +device, an Edge Cookie module creates no identifier, and a route's `?` answers +`403` for a value the request's permissions withhold and `500` for one absent +where the route runs. `call.inject_with` passes one argument of the caller's +own after the module, which is how a route takes its request and how an Edge +Cookie module takes the value a page posted. The location, the device signals and the Edge Cookie identifier each carry the permissions that the module which produced them declares. One of them is