1use 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#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct DelegationStep {
18 pub principal: PrincipalRef,
20}
21
22impl DelegationStep {
23 #[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#[must_use]
45pub fn audit_delegation_chain(chain: &[DelegationStep]) -> Vec<AuditDelegationStep> {
46 chain.iter().map(DelegationStep::audit_projection).collect()
47}
48
49#[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#[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
79pub 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#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
115#[serde(deny_unknown_fields)]
116pub struct AcceptedAuthority {
117 pub principal: Principal,
119 pub delegation_chain: Vec<DelegationStep>,
121}
122
123impl AcceptedAuthority {
124 #[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 #[must_use]
138 pub fn owning_root(&self) -> Option<&CardUid> {
139 owning_root(&self.principal, &self.delegation_chain)
140 }
141
142 #[must_use]
144 pub fn attribution(&self) -> Attribution {
145 delegated_attribution(&self.principal, &self.delegation_chain)
146 }
147}
148
149#[derive(Clone, serde::Serialize, serde::Deserialize)]
151pub struct RequestContext {
152 pub request_id: RequestId,
154 pub principal: Option<Principal>,
156 pub delegation_chain: Vec<DelegationStep>,
158 pub traceparent: Option<TraceParent>,
160 pub idempotency_key: Option<IdempotencyKey>,
162 pub started_at: DateTime<Utc>,
164}
165
166impl RequestContext {
167 #[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 #[must_use]
182 pub fn principal(&self) -> Option<&Principal> {
183 self.principal.as_ref()
184 }
185
186 #[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#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
214#[serde(transparent)]
215pub struct TraceParent(String);
216
217impl TraceParent {
218 #[must_use]
220 pub fn new(value: impl Into<String>) -> Self {
221 Self(value.into())
222 }
223
224 #[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 #[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 #[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 #[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}