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::error::WyrdError;
8use wyrd_spec::ids::{CardUid, IdempotencyKey};
9use wyrd_spec::request_id::RequestId;
10use wyrd_spec::vala::AuditDelegationStep;
11
12use crate::principal::{Principal, PrincipalRef};
13
14/// One flattened RFC 8693 actor-chain step.
15#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct DelegationStep {
18    /// Self-describing delegating principal projection.
19    pub principal: PrincipalRef,
20}
21
22impl DelegationStep {
23    /// Projects one verified delegation hop into its durable audit wire form.
24    ///
25    /// The conversion lives here rather than in `wyrd-spec` because the
26    /// runtime owns [`PrincipalRef`]; the spec crate stays free of runtime
27    /// types. Only verified identity crosses: the principal's id, its kind
28    /// tag, and the exact root it acted under. No credential material is
29    /// representable in the result.
30    #[must_use]
31    pub fn audit_projection(&self) -> AuditDelegationStep {
32        AuditDelegationStep {
33            principal_id: self.principal.id,
34            principal_kind: self.principal.kind.tag(),
35            workload_uid: self.principal.workload_uid.clone(),
36        }
37    }
38}
39
40/// Projects an ordered delegation chain into its durable audit wire form.
41///
42/// Order is preserved exactly: the chain is initiator-first, and that order is
43/// the delegation itself rather than an incidental encoding.
44#[must_use]
45pub fn audit_delegation_chain(chain: &[DelegationStep]) -> Vec<AuditDelegationStep> {
46    chain.iter().map(DelegationStep::audit_projection).collect()
47}
48
49/// The subject/actor pair of a verified principal and its delegation chain.
50///
51/// The subject is always the verified top-level principal. The chain is
52/// initiator-first, so its last step is the current (outermost `act`) actor;
53/// with no chain the principal acts for itself. Older actors stay history in
54/// audit detail and never become the current actor.
55#[must_use]
56pub fn delegated_attribution(principal: &Principal, chain: &[DelegationStep]) -> Attribution {
57    Attribution::new(
58        principal.id,
59        chain.last().map_or(principal.id, |step| step.principal.id),
60    )
61}
62
63/// The exact root that owns work done by `principal` under `chain`.
64///
65/// The current (outermost) actor's root when delegated, otherwise the
66/// subject's own selected root; `None` for rootless work.
67#[must_use]
68pub fn owning_root<'a>(
69    principal: &'a Principal,
70    chain: &'a [DelegationStep],
71) -> Option<&'a CardUid> {
72    chain
73        .last()
74        .map_or(principal.workload_uid.as_ref(), |step| {
75            step.principal.workload_uid.as_ref()
76        })
77}
78
79/// Whether a request's `selected` workload root must still be resolved for a
80/// verified bearer.
81///
82/// A bearer whose owning root ([`owning_root`]) is already signed is
83/// immutable: an equal selector is an idempotent `Ok(false)` and a different
84/// one is refused, never unpinned. A rootless bearer answers `Ok(true)`, and
85/// the selector is resolved under its signed Card access. Every ingress
86/// routes a selector through here, so none silently drops or rebinds one.
87///
88/// # Errors
89/// Returns [`WyrdError::SignedWorkloadMismatch`] when `selected` differs from
90/// the signed root.
91pub fn needs_root_resolution(
92    principal: &Principal,
93    chain: &[DelegationStep],
94    selected: &CardUid,
95) -> Result<bool, WyrdError> {
96    match owning_root(principal, chain) {
97        Some(signed) if signed == selected => Ok(false),
98        Some(signed) => Err(WyrdError::signed_workload_mismatch(signed, selected)),
99        None => Ok(true),
100    }
101}
102
103/// The verified effective authority one accepted operation runs under.
104///
105/// Captured once when the server accepts deferred work, such as a queued
106/// Verifier run, and stored with it so claim, retry, and restart reuse exactly
107/// what was accepted: the effective principal (for a delegated request, the
108/// subject/actor intersection authentication already produced, with its
109/// signed Card limit and root) and the initiator-first delegation chain with
110/// each participant's root and non-secret credential provenance. It is never
111/// re-derived from a bare principal ID or the account's current roles, holds
112/// no bearer token or secret, and is server-only state that no run view
113/// exposes.
114#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
115#[serde(deny_unknown_fields)]
116pub struct AcceptedAuthority {
117    /// Effective principal every protected downstream call is checked against.
118    pub principal: Principal,
119    /// Verified initiator-first delegation chain; empty for direct work.
120    pub delegation_chain: Vec<DelegationStep>,
121}
122
123impl AcceptedAuthority {
124    /// Capture `principal` and its verified `chain` as accepted authority.
125    ///
126    /// Keeps the non-secret credential ID as provenance; it never
127    /// authorizes later work.
128    #[must_use]
129    pub fn new(principal: &Principal, chain: &[DelegationStep]) -> Self {
130        Self {
131            principal: principal.clone(),
132            delegation_chain: chain.to_vec(),
133        }
134    }
135
136    /// The exact root that owns the accepted work.
137    #[must_use]
138    pub fn owning_root(&self) -> Option<&CardUid> {
139        owning_root(&self.principal, &self.delegation_chain)
140    }
141
142    /// The subject/actor pair the accepting request carried.
143    #[must_use]
144    pub fn attribution(&self) -> Attribution {
145        delegated_attribution(&self.principal, &self.delegation_chain)
146    }
147}
148
149/// Per-request context carried through runtime boundaries.
150#[derive(Clone, serde::Serialize, serde::Deserialize)]
151pub struct RequestContext {
152    /// Request correlation id.
153    pub request_id: RequestId,
154    /// Authenticated principal once auth middleware has populated it.
155    pub principal: Option<Principal>,
156    /// Flattened delegation chain.
157    pub delegation_chain: Vec<DelegationStep>,
158    /// Optional W3C traceparent.
159    pub traceparent: Option<TraceParent>,
160    /// Optional idempotency key. Redacted from debug output.
161    pub idempotency_key: Option<IdempotencyKey>,
162    /// Request start time.
163    pub started_at: DateTime<Utc>,
164}
165
166impl RequestContext {
167    /// Construct an unauthenticated request context.
168    #[must_use]
169    pub fn new(request_id: RequestId, started_at: DateTime<Utc>) -> Self {
170        Self {
171            request_id,
172            principal: None,
173            delegation_chain: Vec::new(),
174            traceparent: None,
175            idempotency_key: None,
176            started_at,
177        }
178    }
179
180    /// Borrow the authenticated principal, if populated.
181    #[must_use]
182    pub fn principal(&self) -> Option<&Principal> {
183        self.principal.as_ref()
184    }
185
186    /// True when this request was made under delegation.
187    #[must_use]
188    pub fn is_delegated(&self) -> bool {
189        !self.delegation_chain.is_empty()
190    }
191}
192
193impl fmt::Debug for RequestContext {
194    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195        f.debug_struct("RequestContext")
196            .field("request_id", &self.request_id)
197            .field(
198                "principal",
199                &self.principal.as_ref().map(|principal| principal.id),
200            )
201            .field("delegation_chain_depth", &self.delegation_chain.len())
202            .field("traceparent", &self.traceparent)
203            .field(
204                "idempotency_key",
205                &self.idempotency_key.as_ref().map(|_| "<redacted>"),
206            )
207            .field("started_at", &self.started_at)
208            .finish()
209    }
210}
211
212/// W3C traceparent header value carried unchanged.
213#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
214#[serde(transparent)]
215pub struct TraceParent(String);
216
217impl TraceParent {
218    /// Build from an already accepted traceparent header value.
219    #[must_use]
220    pub fn new(value: impl Into<String>) -> Self {
221        Self(value.into())
222    }
223
224    /// Borrow the underlying header value.
225    #[must_use]
226    pub fn as_str(&self) -> &str {
227        &self.0
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::{
234        AcceptedAuthority, DelegationStep, RequestContext, TraceParent, needs_root_resolution,
235    };
236    use crate::permission::PermissionSet;
237    use crate::principal::{Principal, PrincipalId, PrincipalKind, PrincipalRef};
238    use chrono::Utc;
239    use wyrd_spec::auth::CardAccess;
240    use wyrd_spec::ids::{CardUid, IdempotencyKey};
241    use wyrd_spec::request_id::RequestId;
242
243    fn request_id() -> RequestId {
244        RequestId::parse(&uuid::Uuid::now_v7().to_string())
245            .expect("generated UUIDv7 is a valid request id")
246    }
247
248    fn service_principal(_name: &str) -> Principal {
249        let root = CardUid::from_uuid(uuid::Uuid::now_v7()).expect("v7 Card UID");
250        Principal::new(
251            PrincipalId::new(uuid::Uuid::now_v7()),
252            PrincipalKind::Service,
253            wyrd_spec::DataTenantId::new_v7(),
254            Vec::new(),
255            PermissionSet::new(),
256        )
257        .with_card_access(
258            CardAccess::Scoped {
259                cards: vec![root.clone()],
260            },
261            Some(root),
262        )
263    }
264
265    /// A signed root accepts only an equal selector; a rootless bearer
266    /// resolves any selector.
267    ///
268    /// # Panics
269    ///
270    /// Panics when an equal selector needs resolution or errors, a differing
271    /// selector is not refused as `SignedWorkloadMismatch` for the signed root
272    /// or for a delegation chain carrying it, or a rootless bearer does not
273    /// need resolution.
274    #[test]
275    fn selector_never_rebinds_a_signed_root() {
276        let rooted = service_principal("rooted");
277        let signed = rooted.workload_uid.clone().expect("signed root");
278        let other = CardUid::from_uuid(uuid::Uuid::now_v7()).expect("v7 Card UID");
279        assert!(!needs_root_resolution(&rooted, &[], &signed).expect("equal selector"));
280        assert!(matches!(
281            needs_root_resolution(&rooted, &[], &other),
282            Err(wyrd_spec::error::WyrdError::SignedWorkloadMismatch { .. })
283        ));
284        let rootless = Principal::new(
285            PrincipalId::new(uuid::Uuid::now_v7()),
286            PrincipalKind::User,
287            wyrd_spec::DataTenantId::new_v7(),
288            Vec::new(),
289            PermissionSet::new(),
290        );
291        assert!(needs_root_resolution(&rootless, &[], &other).expect("rootless"));
292        let delegated = [DelegationStep {
293            principal: PrincipalRef::from_principal(&rooted),
294        }];
295        assert!(matches!(
296            needs_root_resolution(&rootless, &delegated, &other),
297            Err(wyrd_spec::error::WyrdError::SignedWorkloadMismatch { .. })
298        ));
299    }
300
301    #[test]
302    fn delegation_chain_default_empty() {
303        let context = RequestContext::new(request_id(), Utc::now());
304
305        assert!(context.delegation_chain.is_empty());
306        assert!(!context.is_delegated());
307    }
308
309    #[test]
310    fn delegation_step_carries_self_describing_projection() {
311        let principal = service_principal("billing");
312        let step = DelegationStep {
313            principal: PrincipalRef::from_principal(&principal),
314        };
315
316        assert_eq!(step.principal.id, principal.id);
317        assert_eq!(step.principal.kind, principal.kind);
318        assert_eq!(step.principal.workload_uid, principal.workload_uid);
319    }
320
321    #[test]
322    fn delegation_chain_round_trips() {
323        let mut context = RequestContext::new(request_id(), Utc::now());
324        context.principal = Some(service_principal("current"));
325        context.traceparent = Some(TraceParent::new(
326            "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01",
327        ));
328        context.delegation_chain = vec![
329            DelegationStep {
330                principal: PrincipalRef::from_principal(&service_principal("first")),
331            },
332            DelegationStep {
333                principal: PrincipalRef::from_principal(&service_principal("second")),
334            },
335        ];
336
337        let json = serde_json::to_string(&context).expect("serialize request context");
338        let parsed: RequestContext =
339            serde_json::from_str(&json).expect("deserialize request context");
340
341        assert_eq!(parsed.delegation_chain, context.delegation_chain);
342        assert_eq!(parsed.traceparent, context.traceparent);
343        assert!(parsed.is_delegated());
344    }
345
346    #[test]
347    fn debug_redacts_idempotency_key() {
348        let mut context = RequestContext::new(request_id(), Utc::now());
349        context.idempotency_key =
350            Some(IdempotencyKey::new("client-private-retry-key").expect("valid opaque key"));
351
352        let debug = format!("{context:?}");
353
354        assert!(debug.contains(r#"idempotency_key: Some("<redacted>")"#));
355        assert!(!debug.contains("client-private-retry-key"));
356    }
357
358    #[test]
359    fn debug_redacts_delegation_chain_to_depth() {
360        let mut context = RequestContext::new(request_id(), Utc::now());
361        context.delegation_chain = vec![
362            DelegationStep {
363                principal: PrincipalRef::from_principal(&service_principal("first")),
364            },
365            DelegationStep {
366                principal: PrincipalRef::from_principal(&service_principal("second")),
367            },
368            DelegationStep {
369                principal: PrincipalRef::from_principal(&service_principal("third")),
370            },
371        ];
372
373        let debug = format!("{context:?}");
374
375        assert!(debug.contains("delegation_chain_depth: 3"));
376        assert!(!debug.contains("first"));
377        assert!(!debug.contains("second"));
378        assert!(!debug.contains("third"));
379    }
380
381    /// Accepted authority keeps the effective principal, Card limit, roots,
382    /// chain, and credential provenance across a JSON round trip, and
383    /// projects the accepting request's subject/actor pair and owning root.
384    ///
385    /// # Panics
386    /// Panics when the authority does not serialize or deserialize, or when
387    /// the round trip changes it or projects another pair or owner.
388    #[test]
389    fn accepted_authority_round_trips_with_provenance() {
390        let mut subject = service_principal("subject");
391        subject.credential_id = Some(uuid::Uuid::now_v7());
392        let actor = service_principal("actor");
393        let chain = vec![DelegationStep {
394            principal: PrincipalRef::from_principal(&actor),
395        }];
396
397        let accepted = AcceptedAuthority::new(&subject, &chain);
398        let json = serde_json::to_value(&accepted).expect("serialize accepted authority");
399        let parsed: AcceptedAuthority =
400            serde_json::from_value(json).expect("deserialize accepted authority");
401
402        assert_eq!(parsed, accepted);
403        assert_eq!(parsed.principal.credential_id, subject.credential_id);
404        assert_eq!(parsed.principal.card_access, subject.card_access);
405        assert_eq!(parsed.delegation_chain, chain);
406        assert_eq!(parsed.owning_root(), actor.workload_uid.as_ref());
407        assert_eq!(
408            AcceptedAuthority::new(&subject, &[]).owning_root(),
409            subject.workload_uid.as_ref()
410        );
411        assert_eq!(parsed.attribution().sub_principal_id, subject.id);
412        assert_eq!(parsed.attribution().act_principal_id, actor.id);
413        assert_eq!(
414            AcceptedAuthority::new(&subject, &[])
415                .attribution()
416                .act_principal_id,
417            subject.id
418        );
419    }
420
421    /// Accepted authority keeps a delegated exact tool grant: the effective
422    /// subject/actor meet stays exactly the one tool after a JSON round trip,
423    /// with subject and delegation history unchanged.
424    ///
425    /// # Panics
426    /// Panics when the round trip widens, drops, or rewrites the tool scope,
427    /// subject, or chain.
428    #[test]
429    fn accepted_authority_keeps_exact_tool_scope() {
430        use crate::permission::Permission;
431        use wyrd_spec::card::agent::BuiltinTool;
432        let query = Permission::tool_invoke(BuiltinTool::BifrostQuery);
433        let mut subject = service_principal("subject");
434        subject.effective_permissions = [Permission::tool_invoke_any()].into_iter().collect();
435        let actor_grants: PermissionSet = [query.clone()].into_iter().collect();
436        subject.effective_permissions = subject
437            .effective_permissions
438            .intersection(&actor_grants)
439            .expect("representable meet");
440        let chain = vec![DelegationStep {
441            principal: PrincipalRef::from_principal(&service_principal("actor")),
442        }];
443
444        let accepted = AcceptedAuthority::new(&subject, &chain);
445        let json = serde_json::to_value(&accepted).expect("serialize accepted authority");
446        let parsed: AcceptedAuthority =
447            serde_json::from_value(json).expect("deserialize accepted authority");
448
449        assert_eq!(parsed, accepted);
450        assert_eq!(parsed.principal.id, subject.id);
451        assert_eq!(parsed.delegation_chain, chain);
452        let grants = &parsed.principal.effective_permissions;
453        assert_eq!(grants.iter().collect::<Vec<_>>(), vec![&query]);
454        assert!(!grants.contains(&Permission::tool_invoke(BuiltinTool::FsRead)));
455        assert!(!grants.contains(&Permission::bifrost_query_read()));
456    }
457}