Skip to main content

wyrd_runtime/
request_context.rs

1//! Request-scoped runtime context.
2
3use std::fmt;
4
5use chrono::{DateTime, Utc};
6use wyrd_spec::auth::Attribution;
7use wyrd_spec::ids::IdempotencyKey;
8use wyrd_spec::request_id::RequestId;
9use wyrd_spec::vala::AuditDelegationStep;
10
11use crate::principal::{Principal, PrincipalRef};
12
13/// One flattened RFC 8693 actor-chain step.
14#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
15#[serde(deny_unknown_fields)]
16pub struct DelegationStep {
17    /// Self-describing delegating principal projection.
18    pub principal: PrincipalRef,
19}
20
21impl DelegationStep {
22    /// Projects one verified delegation hop into its durable audit wire form.
23    ///
24    /// The conversion lives here rather than in `wyrd-spec` because the
25    /// runtime owns [`PrincipalRef`]; the spec crate stays free of runtime
26    /// types. Only verified identity crosses: the principal's id, its
27    /// card-free kind tag, and the card authority it acted under. No
28    /// credential material is representable in the result.
29    #[must_use]
30    pub fn audit_projection(&self) -> AuditDelegationStep {
31        AuditDelegationStep {
32            principal_id: self.principal.id,
33            principal_kind: self.principal.kind.tag(),
34            card_ref: self.principal.card_ref().cloned(),
35            card_ref_scope: self
36                .principal
37                .card_ref_scope()
38                .map(|scope| scope.as_slice().to_vec())
39                .unwrap_or_default(),
40        }
41    }
42}
43
44/// Projects an ordered delegation chain into its durable audit wire form.
45///
46/// Order is preserved exactly: the chain is initiator-first, and that order is
47/// the delegation itself rather than an incidental encoding.
48#[must_use]
49pub fn audit_delegation_chain(chain: &[DelegationStep]) -> Vec<AuditDelegationStep> {
50    chain.iter().map(DelegationStep::audit_projection).collect()
51}
52
53/// The subject/actor pair of a verified principal and its delegation chain.
54///
55/// The subject is always the verified top-level principal. The chain is
56/// initiator-first, so its last step is the current (outermost `act`) actor;
57/// with no chain the principal acts for itself. Older actors stay history in
58/// audit detail and never become the current actor.
59#[must_use]
60pub fn delegated_attribution(principal: &Principal, chain: &[DelegationStep]) -> Attribution {
61    Attribution::new(
62        principal.id,
63        chain.last().map_or(principal.id, |step| step.principal.id),
64    )
65}
66
67/// The verified effective authority one accepted operation runs under.
68///
69/// Captured once when the server accepts deferred work, such as a queued
70/// Verifier run, and stored with it so claim, retry, and restart reuse exactly
71/// what was accepted: the effective principal (for a delegated request, the
72/// subject/actor intersection authentication already produced) and the
73/// initiator-first delegation chain kept as audit history. It is never
74/// re-derived from a bare principal ID, holds no bearer token, API key, or
75/// credential ID, and is server-only state that no run view exposes.
76#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
77#[serde(deny_unknown_fields)]
78pub struct AcceptedAuthority {
79    /// Effective principal every protected downstream call is checked against.
80    pub principal: Principal,
81    /// Verified initiator-first delegation chain; empty for direct work.
82    pub delegation_chain: Vec<DelegationStep>,
83}
84
85impl AcceptedAuthority {
86    /// Capture `principal` and its verified `chain` as accepted authority.
87    ///
88    /// Drops the credential ID: which credential authenticated the accepting
89    /// request is that request's audit fact, not part of the authority later
90    /// work runs under.
91    #[must_use]
92    pub fn new(principal: &Principal, chain: &[DelegationStep]) -> Self {
93        Self {
94            principal: Principal {
95                credential_id: None,
96                ..principal.clone()
97            },
98            delegation_chain: chain.to_vec(),
99        }
100    }
101
102    /// The subject/actor pair the accepting request carried.
103    #[must_use]
104    pub fn attribution(&self) -> Attribution {
105        delegated_attribution(&self.principal, &self.delegation_chain)
106    }
107}
108
109/// Per-request context carried through runtime boundaries.
110#[derive(Clone, serde::Serialize, serde::Deserialize)]
111pub struct RequestContext {
112    /// Request correlation id.
113    pub request_id: RequestId,
114    /// Authenticated principal once auth middleware has populated it.
115    pub principal: Option<Principal>,
116    /// Flattened delegation chain.
117    pub delegation_chain: Vec<DelegationStep>,
118    /// Optional W3C traceparent.
119    pub traceparent: Option<TraceParent>,
120    /// Optional idempotency key. Redacted from debug output.
121    pub idempotency_key: Option<IdempotencyKey>,
122    /// Request start time.
123    pub started_at: DateTime<Utc>,
124}
125
126impl RequestContext {
127    /// Construct an unauthenticated request context.
128    #[must_use]
129    pub fn new(request_id: RequestId, started_at: DateTime<Utc>) -> Self {
130        Self {
131            request_id,
132            principal: None,
133            delegation_chain: Vec::new(),
134            traceparent: None,
135            idempotency_key: None,
136            started_at,
137        }
138    }
139
140    /// Borrow the authenticated principal, if populated.
141    #[must_use]
142    pub fn principal(&self) -> Option<&Principal> {
143        self.principal.as_ref()
144    }
145
146    /// True when this request was made under delegation.
147    #[must_use]
148    pub fn is_delegated(&self) -> bool {
149        !self.delegation_chain.is_empty()
150    }
151}
152
153impl fmt::Debug for RequestContext {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        f.debug_struct("RequestContext")
156            .field("request_id", &self.request_id)
157            .field(
158                "principal",
159                &self.principal.as_ref().map(|principal| principal.id),
160            )
161            .field("delegation_chain_depth", &self.delegation_chain.len())
162            .field("traceparent", &self.traceparent)
163            .field(
164                "idempotency_key",
165                &self.idempotency_key.as_ref().map(|_| "<redacted>"),
166            )
167            .field("started_at", &self.started_at)
168            .finish()
169    }
170}
171
172/// W3C traceparent header value carried unchanged.
173#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
174#[serde(transparent)]
175pub struct TraceParent(String);
176
177impl TraceParent {
178    /// Build from an already accepted traceparent header value.
179    #[must_use]
180    pub fn new(value: impl Into<String>) -> Self {
181        Self(value.into())
182    }
183
184    /// Borrow the underlying header value.
185    #[must_use]
186    pub fn as_str(&self) -> &str {
187        &self.0
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::{AcceptedAuthority, DelegationStep, RequestContext, TraceParent};
194    use crate::permission::PermissionSet;
195    use crate::principal::{Principal, PrincipalId, PrincipalKind, PrincipalRef};
196    use chrono::Utc;
197    use wyrd_semver::VersionBlock;
198    use wyrd_spec::envelope::CardKind;
199    use wyrd_spec::ids::{CardName, IdempotencyKey, SpaceName};
200    use wyrd_spec::reference::{CardRef, CardRefScope};
201    use wyrd_spec::request_id::RequestId;
202
203    fn request_id() -> RequestId {
204        RequestId::parse(&uuid::Uuid::now_v7().to_string())
205            .expect("generated UUIDv7 is a valid request id")
206    }
207
208    fn card_ref(kind: CardKind, name: &str) -> CardRef {
209        CardRef {
210            kind,
211            name: CardName::new(name).expect("static card name is valid"),
212            version: VersionBlock::parse("1.0.0").expect("static version is valid"),
213            space: Some(SpaceName::new("prod").expect("static space is valid")),
214            uid: None,
215        }
216    }
217
218    fn service_principal(name: &str) -> Principal {
219        let card_ref = card_ref(CardKind::Service, name);
220        Principal {
221            id: PrincipalId::new(uuid::Uuid::now_v7()),
222            kind: PrincipalKind::Service {
223                card_ref: Some(card_ref.clone()),
224                card_ref_scope: CardRefScope::own(&card_ref),
225            },
226            tenant_id: wyrd_spec::DataTenantId::new_v7(),
227            roles: Vec::new(),
228            effective_permissions: PermissionSet::new(),
229            credential_id: None,
230        }
231    }
232
233    #[test]
234    fn delegation_chain_default_empty() {
235        let context = RequestContext::new(request_id(), Utc::now());
236
237        assert!(context.delegation_chain.is_empty());
238        assert!(!context.is_delegated());
239    }
240
241    #[test]
242    fn delegation_step_carries_self_describing_projection() {
243        let principal = service_principal("billing");
244        let step = DelegationStep {
245            principal: PrincipalRef::from_principal(&principal),
246        };
247
248        assert_eq!(step.principal.id, principal.id);
249        assert_eq!(step.principal.kind, principal.kind);
250        assert!(step.principal.card_ref().is_some());
251    }
252
253    #[test]
254    fn delegation_chain_round_trips() {
255        let mut context = RequestContext::new(request_id(), Utc::now());
256        context.principal = Some(service_principal("current"));
257        context.traceparent = Some(TraceParent::new(
258            "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01",
259        ));
260        context.delegation_chain = vec![
261            DelegationStep {
262                principal: PrincipalRef::from_principal(&service_principal("first")),
263            },
264            DelegationStep {
265                principal: PrincipalRef::from_principal(&service_principal("second")),
266            },
267        ];
268
269        let json = serde_json::to_string(&context).expect("serialize request context");
270        let parsed: RequestContext =
271            serde_json::from_str(&json).expect("deserialize request context");
272
273        assert_eq!(parsed.delegation_chain, context.delegation_chain);
274        assert_eq!(parsed.traceparent, context.traceparent);
275        assert!(parsed.is_delegated());
276    }
277
278    #[test]
279    fn debug_redacts_idempotency_key() {
280        let mut context = RequestContext::new(request_id(), Utc::now());
281        context.idempotency_key =
282            Some(IdempotencyKey::new("client-private-retry-key").expect("valid opaque key"));
283
284        let debug = format!("{context:?}");
285
286        assert!(debug.contains(r#"idempotency_key: Some("<redacted>")"#));
287        assert!(!debug.contains("client-private-retry-key"));
288    }
289
290    #[test]
291    fn debug_redacts_delegation_chain_to_depth() {
292        let mut context = RequestContext::new(request_id(), Utc::now());
293        context.delegation_chain = vec![
294            DelegationStep {
295                principal: PrincipalRef::from_principal(&service_principal("first")),
296            },
297            DelegationStep {
298                principal: PrincipalRef::from_principal(&service_principal("second")),
299            },
300            DelegationStep {
301                principal: PrincipalRef::from_principal(&service_principal("third")),
302            },
303        ];
304
305        let debug = format!("{context:?}");
306
307        assert!(debug.contains("delegation_chain_depth: 3"));
308        assert!(!debug.contains("first"));
309        assert!(!debug.contains("second"));
310        assert!(!debug.contains("third"));
311    }
312
313    /// Accepted authority keeps the effective principal and chain, survives a
314    /// JSON round trip unchanged, never carries the accepting credential, and
315    /// projects the accepting request's subject/actor pair.
316    ///
317    /// # Panics
318    /// Panics when the authority does not serialize or deserialize, or when
319    /// the round trip changes it, keeps the credential, or projects another
320    /// pair.
321    #[test]
322    fn accepted_authority_round_trips_without_credential() {
323        let mut subject = service_principal("subject");
324        subject.credential_id = Some(uuid::Uuid::now_v7());
325        let actor = service_principal("actor");
326        let chain = vec![DelegationStep {
327            principal: PrincipalRef::from_principal(&actor),
328        }];
329
330        let accepted = AcceptedAuthority::new(&subject, &chain);
331        let json = serde_json::to_value(&accepted).expect("serialize accepted authority");
332        let parsed: AcceptedAuthority =
333            serde_json::from_value(json).expect("deserialize accepted authority");
334
335        assert_eq!(parsed, accepted);
336        assert_eq!(parsed.principal.credential_id, None);
337        assert_eq!(parsed.delegation_chain, chain);
338        assert_eq!(parsed.attribution().sub_principal_id, subject.id);
339        assert_eq!(parsed.attribution().act_principal_id, actor.id);
340        assert_eq!(
341            AcceptedAuthority::new(&subject, &[])
342                .attribution()
343                .act_principal_id,
344            subject.id
345        );
346    }
347
348    /// Accepted authority keeps a delegated exact tool grant: the effective
349    /// subject/actor meet stays exactly the one tool after a JSON round trip,
350    /// with subject and delegation history unchanged.
351    ///
352    /// # Panics
353    /// Panics when the round trip widens, drops, or rewrites the tool scope,
354    /// subject, or chain.
355    #[test]
356    fn accepted_authority_keeps_exact_tool_scope() {
357        use crate::permission::Permission;
358        use wyrd_spec::card::agent::BuiltinTool;
359        let query = Permission::tool_invoke(BuiltinTool::BifrostQuery);
360        let mut subject = service_principal("subject");
361        subject.effective_permissions = [Permission::tool_invoke_any()].into_iter().collect();
362        let actor_grants: PermissionSet = [query.clone()].into_iter().collect();
363        subject.effective_permissions = subject
364            .effective_permissions
365            .intersection(&actor_grants)
366            .expect("representable meet");
367        let chain = vec![DelegationStep {
368            principal: PrincipalRef::from_principal(&service_principal("actor")),
369        }];
370
371        let accepted = AcceptedAuthority::new(&subject, &chain);
372        let json = serde_json::to_value(&accepted).expect("serialize accepted authority");
373        let parsed: AcceptedAuthority =
374            serde_json::from_value(json).expect("deserialize accepted authority");
375
376        assert_eq!(parsed, accepted);
377        assert_eq!(parsed.principal.id, subject.id);
378        assert_eq!(parsed.delegation_chain, chain);
379        let grants = &parsed.principal.effective_permissions;
380        assert_eq!(grants.iter().collect::<Vec<_>>(), vec![&query]);
381        assert!(!grants.contains(&Permission::tool_invoke(BuiltinTool::FsRead)));
382        assert!(!grants.contains(&Permission::bifrost_query_read()));
383    }
384}