Skip to main content

wami_core/
context.rs

1//! WAMI Context - Authentication and Authorization Context
2//!
3//! The `WamiContext` carries authentication and authorization information for all WAMI operations.
4//! It is created during authentication and used throughout the system to determine:
5//! - Which tenant and instance the operation targets
6//! - Who is performing the operation (caller identity)
7//! - Whether authorization checks should be applied
8//!
9//! # Security
10//!
11//! **CRITICAL:** Contexts should ONLY be created through `AuthenticationService.authenticate()`.
12//! The builder is public for internal use and testing, but manually creating contexts
13//! bypasses authentication and is a security risk.
14//!
15//! # Proper Usage Example
16//!
17//! This example shows how `WamiContext` is used in the main `wami` crate.
18//! In `wami-core`, you typically construct contexts directly using the builder:
19//!
20//! ```rust
21//! use wami_core::arn::{TenantPath, WamiArn};
22//! use wami_core::context::WamiContext;
23//!
24//! // The caller ARN is the only required field: the tenant path, the instance
25//! // and whether the caller is root are all read from it.
26//! let context = WamiContext::builder()
27//!     .caller_arn(
28//!         WamiArn::builder()
29//!             .service(wami_core::arn::Service::Iam)
30//!             .tenant_path(TenantPath::single(0))
31//!             .wami_instance("123456789012")
32//!             .resource("user", "admin")
33//!             .build()
34//!             .unwrap(),
35//!     )
36//!     .build()
37//!     .unwrap();
38//!
39//! assert_eq!(context.instance_id(), "123456789012");
40//! assert_eq!(context.tenant_path(), &TenantPath::single(0));
41//! assert!(!context.is_root());
42//! ```
43//!
44//! `tenant_path` and `instance_id` can still be set explicitly, but only to
45//! widen an operation beyond the caller's own scope — cross-tenant work or
46//! impersonation. Restating them to repeat what the ARN already says is what
47//! allowed the two to drift apart.
48
49use crate::arn::{TenantPath, WamiArn};
50use crate::error::{AmiError, Result};
51use serde::{Deserialize, Serialize};
52
53/// How many times authority may pass hands within one context.
54///
55/// Reaching it refuses the transition rather than dropping the oldest step. A
56/// truncated chain still satisfies any check made against it while no longer
57/// describing what happened — an audit trail that lies is worse than one that
58/// stops.
59pub const MAX_PROVENANCE_DEPTH: usize = 8;
60
61/// How authority passed to a principal.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub enum Transition {
64    /// The principal proved who they are; the start of every chain.
65    Authenticated,
66    /// A role was assumed, under this session name.
67    AssumedRole {
68        /// The session name given at assumption time.
69        session_name: String,
70    },
71    /// An SSO permission set was applied.
72    PermissionSet {
73        /// The permission set name.
74        name: String,
75    },
76    /// An external identity provider vouched for the principal.
77    Federated {
78        /// The issuer that vouched.
79        issuer: String,
80    },
81}
82
83/// One link in the chain: who held authority, and how they came to hold it.
84///
85/// Deliberately not constructible from outside this crate. The chain is meant
86/// to be trusted by policy conditions, and a `Step` anyone can build is a
87/// provenance anyone can claim.
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89pub struct Step {
90    principal: WamiArn,
91    via: Transition,
92}
93
94impl Step {
95    /// Who held authority at this step.
96    pub fn principal(&self) -> &WamiArn {
97        &self.principal
98    }
99
100    /// How they came to hold it.
101    pub fn via(&self) -> &Transition {
102        &self.via
103    }
104
105    /// The service that performed this transition, in ARN terms.
106    pub fn service(&self) -> &'static str {
107        match self.via {
108            // Federation is STS's business, as it is in AWS: AssumeRoleWithSAML
109            // and GetFederationToken both live there.
110            Transition::AssumedRole { .. } | Transition::Federated { .. } => "sts",
111            Transition::PermissionSet { .. } => "sso",
112            Transition::Authenticated => "iam",
113        }
114    }
115
116    /// This step as one `service:type/value` segment of a trail.
117    fn segment(&self) -> String {
118        match &self.via {
119            Transition::Authenticated => {
120                format!("iam:user/{}", escape(self.principal.resource_id()))
121            }
122            Transition::AssumedRole { session_name } => format!(
123                "sts:assumed-role/{}/{}",
124                escape(self.principal.resource_id()),
125                escape(session_name)
126            ),
127            Transition::PermissionSet { name } => {
128                format!("sso:permission-set/{}", escape(name))
129            }
130            Transition::Federated { issuer } => format!("sts:federated/{}", escape(issuer)),
131        }
132    }
133}
134
135/// Percent-escape the two characters that structure a trail.
136///
137/// An issuer such as `https://idp.example` carries both. Left raw, a trail
138/// would gain segment boundaries out of a value, and `LIKE '%:sts:%'` would
139/// match text that names no service at all.
140fn escape(value: &str) -> String {
141    value
142        .replace('%', "%25")
143        .replace(':', "%3A")
144        .replace('/', "%2F")
145}
146
147/// Session information for temporary credentials
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct SessionInfo {
150    /// Session token identifier
151    pub session_token: String,
152    /// Session expiration time (Unix timestamp)
153    pub expiration: i64,
154    /// Assumed role ARN (if this is an assumed role session)
155    pub assumed_role_arn: Option<WamiArn>,
156}
157
158/// WAMI Context - carries authentication and authorization information
159///
160/// This context is created during authentication and passed to all service operations.
161/// It contains information about who is performing the operation and where it should be executed.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163#[serde(try_from = "WireContext")]
164pub struct WamiContext {
165    /// The tenant path where operations will be performed
166    tenant_path: TenantPath,
167
168    /// The WAMI instance ID
169    instance_id: String,
170
171    /// The ARN of the caller (user or assumed role)
172    caller_arn: WamiArn,
173
174    /// How authority reached `caller_arn`, oldest first.
175    ///
176    /// The last step always names `caller_arn`: both are written by the same
177    /// call, so the chain cannot come to describe someone other than the
178    /// caller. Not settable through the builder — see `through`.
179    provenance: Vec<Step>,
180
181    /// Whether the caller is a root user (bypasses all authorization)
182    is_root: bool,
183
184    /// Optional default region for operations
185    region: Option<String>,
186
187    /// Optional session information for temporary credentials
188    session_info: Option<SessionInfo>,
189
190    /// Source IP address of the request (for condition evaluation)
191    #[serde(skip_serializing_if = "Option::is_none")]
192    source_ip: Option<String>,
193
194    /// Whether MFA was used for authentication (for condition evaluation)
195    #[serde(skip_serializing_if = "Option::is_none")]
196    mfa_present: Option<bool>,
197
198    /// Whether the request was made over a secure transport (HTTPS)
199    #[serde(skip_serializing_if = "Option::is_none")]
200    secure_transport: Option<bool>,
201}
202
203/// The wire shape a `WamiContext` is deserialised through.
204///
205/// `build` and `through` are the only ways to construct a context in memory,
206/// and both keep the chain ending on `caller_arn`. Deserialisation bypasses
207/// them, so it is checked here instead: a context arriving with an empty chain
208/// would look entirely normal while `provenance().last()` returned `None` to
209/// anything reading the chain to decide — a silent absence rather than a
210/// refusal, which is the harder kind to notice.
211#[derive(Deserialize)]
212struct WireContext {
213    tenant_path: TenantPath,
214    instance_id: String,
215    caller_arn: WamiArn,
216    provenance: Vec<Step>,
217    is_root: bool,
218    region: Option<String>,
219    session_info: Option<SessionInfo>,
220    source_ip: Option<String>,
221    mfa_present: Option<bool>,
222    secure_transport: Option<bool>,
223}
224
225impl TryFrom<WireContext> for WamiContext {
226    type Error = AmiError;
227
228    fn try_from(wire: WireContext) -> Result<Self> {
229        match wire.provenance.last() {
230            None => {
231                return Err(AmiError::InvalidParameter {
232                    message: "context has no provenance: every context records how \
233                              authority was obtained, starting at authentication"
234                        .to_string(),
235                })
236            }
237            Some(last) if last.principal != wire.caller_arn => {
238                return Err(AmiError::InvalidParameter {
239                    message: format!(
240                        "provenance ends on {} but the caller is {}",
241                        last.principal, wire.caller_arn
242                    ),
243                })
244            }
245            Some(_) => {}
246        }
247
248        if wire.provenance.len() > MAX_PROVENANCE_DEPTH {
249            return Err(AmiError::InvalidParameter {
250                message: format!(
251                    "provenance is {} steps deep, past the maximum of {MAX_PROVENANCE_DEPTH}",
252                    wire.provenance.len()
253                ),
254            });
255        }
256
257        Ok(WamiContext {
258            tenant_path: wire.tenant_path,
259            instance_id: wire.instance_id,
260            caller_arn: wire.caller_arn,
261            provenance: wire.provenance,
262            is_root: wire.is_root,
263            region: wire.region,
264            session_info: wire.session_info,
265            source_ip: wire.source_ip,
266            mfa_present: wire.mfa_present,
267            secure_transport: wire.secure_transport,
268        })
269    }
270}
271
272impl WamiContext {
273    /// Create a new context builder
274    pub fn builder() -> WamiContextBuilder {
275        WamiContextBuilder::default()
276    }
277
278    /// Check if the caller is a root user
279    ///
280    /// Root users have full access and bypass all authorization checks.
281    pub fn is_root(&self) -> bool {
282        self.is_root
283    }
284
285    /// Get the caller's ARN
286    pub fn caller_arn(&self) -> &WamiArn {
287        &self.caller_arn
288    }
289
290    /// How authority reached the current caller, oldest first.
291    ///
292    /// The chain answers "by what route", which no ARN should: an ARN names a
293    /// thing, and one that grows a segment per service traversed stops being
294    /// comparable — policies matching on it would break, and a trailing
295    /// wildcard added to compensate would swallow segments nobody intended.
296    pub fn provenance(&self) -> &[Step] {
297        &self.provenance
298    }
299
300    /// The provenance as one string, for an audit log.
301    ///
302    /// The first principal, then one `service:type/value` segment per hand-off:
303    ///
304    /// ```text
305    /// arn:wami:iam:12345678:wami:999:user/alice:sts:assumed-role/DataScientist/session1
306    /// ```
307    ///
308    /// It exists to be **queried**. A trail in a text column answers "which
309    /// requests went through STS then SSO" with `LIKE '%:sts:%:sso:%'` — one
310    /// index, no join, no JSON operators. That is the most common question
311    /// asked of an audit log, and the structured chain answers it poorly.
312    ///
313    /// This is a projection, not a serialisation: it is deliberately lossy, and
314    /// there is no parser back. Reconstructing a context from it would give
315    /// something that looks authoritative while having lost the full ARNs — use
316    /// [`WamiContext::provenance`] when the structure is what matters.
317    ///
318    /// Note it is *not* the caller's ARN, and must never be used as one. An
319    /// identifier that grows a segment per service traversed stops comparing
320    /// equal to itself, and every policy written against it silently stops
321    /// matching.
322    pub fn provenance_trail(&self) -> String {
323        let mut steps = self.provenance.iter();
324        let Some(first) = steps.next() else {
325            // Unreachable through build, through or deserialisation, all of
326            // which require a non-empty chain.
327            return String::new();
328        };
329
330        let mut trail = first.principal.to_string();
331        for step in steps {
332            trail.push(':');
333            trail.push_str(&step.segment());
334        }
335        trail
336    }
337
338    /// Derive the context that results from authority passing to `principal`.
339    ///
340    /// One call writes both the new caller and the step recording the move, so
341    /// the chain cannot end up describing someone other than the caller. The
342    /// tenant path and instance follow the new principal, exactly as they do
343    /// when a context is built.
344    ///
345    /// Root is never regained: a context that was not root cannot become root
346    /// by assuming something, whatever that something is named. It can only be
347    /// kept, and only by staying on a root principal.
348    ///
349    /// Fails past [`MAX_PROVENANCE_DEPTH`].
350    #[allow(clippy::result_large_err)]
351    pub fn through(&self, principal: WamiArn, via: Transition) -> Result<WamiContext> {
352        if self.provenance.len() >= MAX_PROVENANCE_DEPTH {
353            return Err(AmiError::InvalidParameter {
354                message: format!(
355                    "authority has already passed hands {MAX_PROVENANCE_DEPTH} times in this context"
356                ),
357            });
358        }
359
360        let mut next = self.clone();
361        next.is_root = self.is_root && principal.is_root_user();
362        next.tenant_path = principal.tenant_path.clone();
363        next.instance_id = principal.wami_instance_id.clone();
364
365        // Attributes proving something about *who authenticated* do not survive
366        // a change of identity. A caller who authenticated with MFA and then
367        // assumed a role would otherwise still claim MFA, and a policy
368        // requiring it on the role would see a factor belonging to someone who
369        // is no longer the caller. Same for the session: one opened for alice
370        // describes alice, not what she became.
371        //
372        // Applying a permission set is not a change of identity — the caller
373        // stays who they were — so it keeps both.
374        if matches!(
375            via,
376            Transition::AssumedRole { .. } | Transition::Federated { .. }
377        ) {
378            next.mfa_present = None;
379            next.session_info = None;
380        }
381
382        // source_ip and secure_transport describe the request, not the
383        // principal, and are true regardless of who holds authority.
384
385        next.provenance.push(Step {
386            principal: principal.clone(),
387            via,
388        });
389        next.caller_arn = principal;
390        Ok(next)
391    }
392
393    /// Get the tenant path
394    pub fn tenant_path(&self) -> &TenantPath {
395        &self.tenant_path
396    }
397
398    /// Get the instance ID
399    pub fn instance_id(&self) -> &str {
400        &self.instance_id
401    }
402
403    /// Get the default region (if set)
404    pub fn region(&self) -> Option<&str> {
405        self.region.as_deref()
406    }
407
408    /// Get session information (if temporary credentials)
409    pub fn session_info(&self) -> Option<&SessionInfo> {
410        self.session_info.as_ref()
411    }
412
413    /// Get the source IP address (if set)
414    pub fn source_ip(&self) -> Option<&str> {
415        self.source_ip.as_deref()
416    }
417
418    /// Check if MFA was used for this request (if known)
419    pub fn mfa_present(&self) -> Option<bool> {
420        self.mfa_present
421    }
422
423    /// Check if the request uses secure transport (if known)
424    pub fn secure_transport(&self) -> Option<bool> {
425        self.secure_transport
426    }
427
428    /// Check if this context can access a specific tenant path
429    ///
430    /// A context can access:
431    /// - Its own tenant
432    /// - Any child tenant below it in the hierarchy
433    /// - If root user: any tenant in the instance
434    pub fn can_access_tenant(&self, target_tenant: &TenantPath) -> bool {
435        // Root user can access any tenant
436        if self.is_root {
437            return true;
438        }
439
440        // Check if target tenant is the same or a child of context tenant
441        target_tenant.starts_with(self.tenant_path())
442    }
443
444    /// Check if the session has expired (for temporary credentials)
445    pub fn is_expired(&self) -> bool {
446        if let Some(session) = &self.session_info {
447            let now = chrono::Utc::now().timestamp();
448            return now >= session.expiration;
449        }
450        false
451    }
452}
453
454/// Builder for creating a WamiContext
455#[derive(Default)]
456pub struct WamiContextBuilder {
457    tenant_path: Option<TenantPath>,
458    instance_id: Option<String>,
459    caller_arn: Option<WamiArn>,
460    /// `None` means "derive from the caller ARN"; an explicit value always wins.
461    is_root: Option<bool>,
462    region: Option<String>,
463    session_info: Option<SessionInfo>,
464    source_ip: Option<String>,
465    mfa_present: Option<bool>,
466    secure_transport: Option<bool>,
467}
468
469impl WamiContextBuilder {
470    /// Set the tenant path
471    pub fn tenant_path(mut self, tenant_path: TenantPath) -> Self {
472        self.tenant_path = Some(tenant_path);
473        self
474    }
475
476    /// Set the instance ID
477    pub fn instance_id(mut self, instance_id: impl Into<String>) -> Self {
478        self.instance_id = Some(instance_id.into());
479        self
480    }
481
482    /// Set the caller ARN
483    pub fn caller_arn(mut self, caller_arn: WamiArn) -> Self {
484        self.caller_arn = Some(caller_arn);
485        self
486    }
487
488    /// Set whether the caller is a root user
489    ///
490    /// Leave it unset to derive it from the caller ARN. An explicit value is
491    /// never overridden — in particular `is_root(false)` stays false even for
492    /// a root ARN, which is what makes deliberate privilege dropping possible.
493    pub fn is_root(mut self, is_root: bool) -> Self {
494        self.is_root = Some(is_root);
495        self
496    }
497
498    /// Set the default region
499    pub fn region(mut self, region: impl Into<String>) -> Self {
500        self.region = Some(region.into());
501        self
502    }
503
504    /// Set session information for temporary credentials
505    pub fn session_info(mut self, session_info: SessionInfo) -> Self {
506        self.session_info = Some(session_info);
507        self
508    }
509
510    /// Set the source IP address of the request
511    pub fn source_ip(mut self, ip: impl Into<String>) -> Self {
512        self.source_ip = Some(ip.into());
513        self
514    }
515
516    /// Set whether MFA was used for authentication
517    pub fn mfa_present(mut self, present: bool) -> Self {
518        self.mfa_present = Some(present);
519        self
520    }
521
522    /// Set whether the request uses secure transport (HTTPS)
523    pub fn secure_transport(mut self, secure: bool) -> Self {
524        self.secure_transport = Some(secure);
525        self
526    }
527
528    /// Build the WamiContext
529    #[allow(clippy::result_large_err)]
530    pub fn build(self) -> Result<WamiContext> {
531        // The ARN comes first because the other three fields are derived from
532        // it. Stating them again is allowed, but only to widen the scope of an
533        // operation (cross-tenant, impersonation); leaving them out is the
534        // normal case and cannot drift from the caller's identity.
535        let caller_arn = self.caller_arn.ok_or_else(|| AmiError::InvalidParameter {
536            message: "caller_arn is required".to_string(),
537        })?;
538
539        let tenant_path = self
540            .tenant_path
541            .unwrap_or_else(|| caller_arn.tenant_path.clone());
542
543        let instance_id = self
544            .instance_id
545            .unwrap_or_else(|| caller_arn.wami_instance_id.clone());
546
547        // Validate that instance_id is not empty
548        if instance_id.trim().is_empty() {
549            return Err(AmiError::InvalidParameter {
550                message: "instance_id cannot be empty".to_string(),
551            });
552        }
553
554        Ok(WamiContext {
555            tenant_path,
556            instance_id,
557            is_root: self.is_root.unwrap_or_else(|| caller_arn.is_root_user()),
558            // The chain starts here rather than at the first `through`, or an
559            // audit trail would begin mid-route with no way to tell who came
560            // first. A freshly built context is one that just proved itself.
561            provenance: vec![Step {
562                principal: caller_arn.clone(),
563                via: Transition::Authenticated,
564            }],
565            caller_arn,
566            region: self.region,
567            session_info: self.session_info,
568            source_ip: self.source_ip,
569            mfa_present: self.mfa_present,
570            secure_transport: self.secure_transport,
571        })
572    }
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578
579    #[test]
580    fn test_context_builder() {
581        let arn: WamiArn = "arn:wami:iam:12345678/87654321:wami:999888777:user/12345"
582            .parse()
583            .unwrap();
584
585        let context = WamiContext::builder()
586            .instance_id("999888777")
587            .tenant_path(TenantPath::new(vec![12345678, 87654321]))
588            .caller_arn(arn.clone())
589            .is_root(false)
590            .region("us-east-1")
591            .build()
592            .unwrap();
593
594        assert_eq!(context.instance_id(), "999888777");
595        assert_eq!(context.tenant_path().to_string(), "12345678/87654321");
596        assert_eq!(context.caller_arn(), &arn);
597        assert!(!context.is_root());
598        assert_eq!(context.region(), Some("us-east-1"));
599    }
600
601    #[test]
602    fn test_root_context() {
603        let arn: WamiArn = "arn:wami:iam:0:wami:999888777:user/root".parse().unwrap();
604
605        let context = WamiContext::builder()
606            .instance_id("999888777")
607            .tenant_path(TenantPath::single(0))
608            .caller_arn(arn)
609            .is_root(true)
610            .build()
611            .unwrap();
612
613        assert!(context.is_root());
614        assert_eq!(context.tenant_path().to_string(), "0");
615    }
616
617    #[test]
618    fn test_can_access_tenant() {
619        let arn: WamiArn = "arn:wami:iam:12345678:wami:999888777:user/12345"
620            .parse()
621            .unwrap();
622
623        let context = WamiContext::builder()
624            .instance_id("999888777")
625            .tenant_path(TenantPath::single(12345678))
626            .caller_arn(arn)
627            .is_root(false)
628            .build()
629            .unwrap();
630
631        // Can access same tenant
632        assert!(context.can_access_tenant(&TenantPath::single(12345678)));
633
634        // Can access child tenant
635        assert!(context.can_access_tenant(&TenantPath::new(vec![12345678, 87654321])));
636
637        // Cannot access sibling tenant
638        assert!(!context.can_access_tenant(&TenantPath::single(99999999)));
639
640        // Cannot access parent tenant (root)
641        assert!(!context.can_access_tenant(&TenantPath::single(0)));
642    }
643
644    #[test]
645    fn test_root_can_access_any_tenant() {
646        let arn: WamiArn = "arn:wami:iam:0:wami:999888777:user/root".parse().unwrap();
647
648        let context = WamiContext::builder()
649            .instance_id("999888777")
650            .tenant_path(TenantPath::single(0))
651            .caller_arn(arn)
652            .is_root(true)
653            .build()
654            .unwrap();
655
656        // Root can access any tenant
657        assert!(context.can_access_tenant(&TenantPath::single(0)));
658        assert!(context.can_access_tenant(&TenantPath::single(12345678)));
659        assert!(context.can_access_tenant(&TenantPath::new(vec![12345678, 87654321, 99999999])));
660    }
661
662    #[test]
663    fn test_session_expiration() {
664        let arn: WamiArn = "arn:wami:iam:12345678:wami:999888777:user/12345"
665            .parse()
666            .unwrap();
667
668        let future_time = chrono::Utc::now().timestamp() + 3600; // 1 hour from now
669        let session = SessionInfo {
670            session_token: "token123".to_string(),
671            expiration: future_time,
672            assumed_role_arn: None,
673        };
674
675        let context = WamiContext::builder()
676            .instance_id("999888777")
677            .tenant_path(TenantPath::single(12345678))
678            .caller_arn(arn)
679            .session_info(session)
680            .build()
681            .unwrap();
682
683        assert!(!context.is_expired());
684    }
685
686    #[test]
687    fn test_expired_session() {
688        let arn: WamiArn = "arn:wami:iam:12345678:wami:999888777:user/12345"
689            .parse()
690            .unwrap();
691
692        let past_time = chrono::Utc::now().timestamp() - 3600; // 1 hour ago
693        let session = SessionInfo {
694            session_token: "token123".to_string(),
695            expiration: past_time,
696            assumed_role_arn: None,
697        };
698
699        let context = WamiContext::builder()
700            .instance_id("999888777")
701            .tenant_path(TenantPath::single(12345678))
702            .caller_arn(arn)
703            .session_info(session)
704            .build()
705            .unwrap();
706
707        assert!(context.is_expired());
708    }
709
710    #[test]
711    fn test_context_builder_all_fields() {
712        let arn: WamiArn = "arn:wami:iam:12345678:wami:999888777:user/12345"
713            .parse()
714            .unwrap();
715        let future_time = chrono::Utc::now().timestamp() + 3600;
716        let session = SessionInfo {
717            session_token: "token123".to_string(),
718            expiration: future_time,
719            assumed_role_arn: None,
720        };
721
722        let context = WamiContext::builder()
723            .instance_id("999888777")
724            .tenant_path(TenantPath::single(12345678))
725            .caller_arn(arn.clone())
726            .is_root(false)
727            .region("us-west-2")
728            .session_info(session.clone())
729            .build()
730            .unwrap();
731
732        assert_eq!(context.instance_id(), "999888777");
733        assert_eq!(context.caller_arn(), &arn);
734        assert_eq!(context.region(), Some("us-west-2"));
735        assert_eq!(
736            context.session_info().map(|s| s.session_token.as_str()),
737            Some("token123")
738        );
739    }
740
741    #[test]
742    fn test_context_without_optional_fields() {
743        let arn: WamiArn = "arn:wami:iam:12345678:wami:999888777:user/12345"
744            .parse()
745            .unwrap();
746
747        let context = WamiContext::builder()
748            .instance_id("999888777")
749            .tenant_path(TenantPath::single(12345678))
750            .caller_arn(arn)
751            .is_root(false)
752            .build()
753            .unwrap();
754
755        assert_eq!(context.region(), None);
756        assert!(context.session_info().is_none());
757    }
758
759    #[test]
760    fn test_caller_arn_is_the_only_required_field() {
761        // tenant_path and instance_id are derived, so neither is enough alone.
762        let result = WamiContext::builder()
763            .tenant_path(TenantPath::single(0))
764            .build();
765        assert!(result.is_err());
766
767        let result = WamiContext::builder().instance_id("999888777").build();
768        assert!(result.is_err());
769    }
770
771    /// Helper: an ARN for `user_id` inside `tenant`.
772    fn arn_for(tenant: u64, user_id: &str) -> WamiArn {
773        WamiArn::builder()
774            .service(crate::arn::Service::Iam)
775            .tenant_path(TenantPath::single(tenant))
776            .wami_instance("999888777")
777            .resource("user", user_id)
778            .build()
779            .unwrap()
780    }
781
782    #[test]
783    fn test_scope_is_derived_from_caller_arn() {
784        let context = WamiContext::builder()
785            .caller_arn(arn_for(12345678, "alice"))
786            .build()
787            .unwrap();
788
789        assert_eq!(context.tenant_path(), &TenantPath::single(12345678));
790        assert_eq!(context.instance_id(), "999888777");
791    }
792
793    #[test]
794    fn test_explicit_scope_still_wins() {
795        // Cross-tenant operations are the reason the overrides remain.
796        let context = WamiContext::builder()
797            .caller_arn(arn_for(12345678, "alice"))
798            .tenant_path(TenantPath::single(87654321))
799            .instance_id("111222333")
800            .build()
801            .unwrap();
802
803        assert_eq!(context.tenant_path(), &TenantPath::single(87654321));
804        assert_eq!(context.instance_id(), "111222333");
805    }
806
807    #[test]
808    fn test_root_is_derived_from_root_arn() {
809        let context = WamiContext::builder()
810            .caller_arn(arn_for(0, "root"))
811            .build()
812            .unwrap();
813
814        assert!(context.is_root());
815    }
816
817    #[test]
818    fn test_a_user_named_root_in_another_tenant_is_not_root() {
819        // The whole reason is_root_user checks the tenant as well as the name:
820        // is_root bypasses every authorization check, so it must not be
821        // reachable by naming a resource `root` in a tenant one controls.
822        let context = WamiContext::builder()
823            .caller_arn(arn_for(12345678, "root"))
824            .build()
825            .unwrap();
826
827        assert!(!context.is_root());
828    }
829
830    #[test]
831    fn test_ordinary_user_in_root_tenant_is_not_root() {
832        let context = WamiContext::builder()
833            .caller_arn(arn_for(0, "alice"))
834            .build()
835            .unwrap();
836
837        assert!(!context.is_root());
838    }
839
840    #[test]
841    fn a_built_context_starts_its_chain_at_authentication() {
842        // Starting at the first `through` would leave an audit trail beginning
843        // mid-route, with nothing saying who came first.
844        let context = WamiContext::builder()
845            .caller_arn(arn_for(12345678, "alice"))
846            .build()
847            .unwrap();
848
849        assert_eq!(context.provenance().len(), 1);
850        assert_eq!(context.provenance()[0].via(), &Transition::Authenticated);
851        assert_eq!(context.provenance()[0].principal(), context.caller_arn());
852    }
853
854    #[test]
855    fn through_moves_the_caller_and_records_the_move_together() {
856        let alice = WamiContext::builder()
857            .caller_arn(arn_for(12345678, "alice"))
858            .build()
859            .unwrap();
860
861        let role = arn_for(12345678, "DataScientist");
862        let assumed = alice
863            .through(
864                role.clone(),
865                Transition::AssumedRole {
866                    session_name: "session1".to_string(),
867                },
868            )
869            .unwrap();
870
871        assert_eq!(assumed.caller_arn(), &role);
872        assert_eq!(assumed.provenance().len(), 2);
873        // The invariant that makes the chain worth trusting.
874        assert_eq!(
875            assumed.provenance().last().unwrap().principal(),
876            assumed.caller_arn()
877        );
878        // And the question #49 wanted answered: who was it before?
879        assert_eq!(assumed.provenance()[0].principal().resource_id(), "alice");
880    }
881
882    #[test]
883    fn the_arn_itself_never_changes_shape() {
884        // The whole reason provenance lives here and not in the ARN: a policy
885        // matching on the caller must keep matching after a role is assumed.
886        let alice_arn = arn_for(12345678, "alice");
887        let alice = WamiContext::builder()
888            .caller_arn(alice_arn.clone())
889            .build()
890            .unwrap();
891
892        let assumed = alice
893            .through(
894                arn_for(12345678, "DataScientist"),
895                Transition::AssumedRole {
896                    session_name: "s".to_string(),
897                },
898            )
899            .unwrap();
900
901        assert_eq!(assumed.provenance()[0].principal(), &alice_arn);
902        assert!(!assumed.caller_arn().to_string().contains("assumed-role"));
903        assert!(!assumed.caller_arn().to_string().contains(":iam:policy"));
904    }
905
906    #[test]
907    fn root_is_never_regained_by_assuming_something() {
908        // A principal named `root` in the root tenant is what is_root_user
909        // accepts — so this is the exact shape an escalation would take.
910        let alice = WamiContext::builder()
911            .caller_arn(arn_for(12345678, "alice"))
912            .build()
913            .unwrap();
914        assert!(!alice.is_root());
915
916        let escalated = alice
917            .through(arn_for(0, "root"), Transition::Authenticated)
918            .unwrap();
919        assert!(!escalated.is_root(), "assuming root granted root");
920    }
921
922    #[test]
923    fn root_is_dropped_when_authority_moves_elsewhere() {
924        let root = WamiContext::builder()
925            .caller_arn(arn_for(0, "root"))
926            .build()
927            .unwrap();
928        assert!(root.is_root());
929
930        let as_role = root
931            .through(
932                arn_for(12345678, "DataScientist"),
933                Transition::AssumedRole {
934                    session_name: "s".to_string(),
935                },
936            )
937            .unwrap();
938        assert!(!as_role.is_root());
939    }
940
941    #[test]
942    fn scope_follows_the_new_principal() {
943        // Same rule as build: two sources for one truth can disagree.
944        let alice = WamiContext::builder()
945            .caller_arn(arn_for(12345678, "alice"))
946            .build()
947            .unwrap();
948
949        let elsewhere = alice
950            .through(arn_for(87654321, "bob"), Transition::Authenticated)
951            .unwrap();
952
953        assert_eq!(elsewhere.tenant_path(), &TenantPath::single(87654321));
954    }
955
956    #[test]
957    fn the_chain_refuses_to_grow_past_its_bound() {
958        // Refused, not truncated: a shortened chain would still satisfy any
959        // check made against it while no longer describing what happened.
960        let mut context = WamiContext::builder()
961            .caller_arn(arn_for(12345678, "alice"))
962            .build()
963            .unwrap();
964
965        // One step is already spent on authentication.
966        for i in 1..MAX_PROVENANCE_DEPTH {
967            context = context
968                .through(
969                    arn_for(12345678, &format!("role{i}")),
970                    Transition::Authenticated,
971                )
972                .unwrap();
973        }
974        assert_eq!(context.provenance().len(), MAX_PROVENANCE_DEPTH);
975
976        let refused = context.through(arn_for(12345678, "one-too-many"), Transition::Authenticated);
977        assert!(refused.is_err());
978        assert_eq!(context.provenance().len(), MAX_PROVENANCE_DEPTH);
979    }
980
981    #[test]
982    fn a_context_without_provenance_is_refused_on_the_wire() {
983        // build() and through() keep the chain ending on caller_arn.
984        // Deserialisation bypasses both, and an empty chain looks entirely
985        // normal while provenance().last() returns None to whatever reads it.
986        let json = r#"{
987            "tenant_path": [12345678],
988            "instance_id": "999888777",
989            "caller_arn": "arn:wami:iam:12345678:wami:999888777:user/alice",
990            "provenance": [],
991            "is_root": false,
992            "region": null,
993            "session_info": null
994        }"#;
995
996        assert!(serde_json::from_str::<WamiContext>(json).is_err());
997    }
998
999    #[test]
1000    fn a_chain_ending_on_someone_else_is_refused() {
1001        // The forgery this guards: claim a route through a privileged role
1002        // while acting as somebody else entirely.
1003        let alice = WamiContext::builder()
1004            .caller_arn(arn_for(12345678, "alice"))
1005            .build()
1006            .unwrap();
1007
1008        let mut tampered: serde_json::Value =
1009            serde_json::from_str(&serde_json::to_string(&alice).unwrap()).unwrap();
1010        tampered["caller_arn"] =
1011            serde_json::json!("arn:wami:iam:12345678:wami:999888777:user/mallory");
1012
1013        let err = serde_json::from_value::<WamiContext>(tampered).unwrap_err();
1014        assert!(err.to_string().contains("provenance ends on"), "{err}");
1015    }
1016
1017    #[test]
1018    fn an_overlong_chain_is_refused_on_the_wire() {
1019        // through() refuses to build one; the wire must not be a way around it.
1020        let mut context = WamiContext::builder()
1021            .caller_arn(arn_for(12345678, "alice"))
1022            .build()
1023            .unwrap();
1024        for i in 1..MAX_PROVENANCE_DEPTH {
1025            context = context
1026                .through(
1027                    arn_for(12345678, &format!("role{i}")),
1028                    Transition::Authenticated,
1029                )
1030                .unwrap();
1031        }
1032
1033        let mut value: serde_json::Value =
1034            serde_json::from_str(&serde_json::to_string(&context).unwrap()).unwrap();
1035        let extra = value["provenance"][0].clone();
1036        value["provenance"].as_array_mut().unwrap().push(extra);
1037
1038        assert!(serde_json::from_value::<WamiContext>(value).is_err());
1039    }
1040
1041    #[test]
1042    fn assuming_a_role_drops_the_mfa_of_whoever_authenticated() {
1043        // alice proved MFA; DataScientist did not. Carrying the flag across
1044        // would show a policy a factor belonging to someone who is no longer
1045        // the caller.
1046        let alice = WamiContext::builder()
1047            .caller_arn(arn_for(12345678, "alice"))
1048            .mfa_present(true)
1049            .session_info(SessionInfo {
1050                session_token: "tok".to_string(),
1051                expiration: 9_999_999_999,
1052                assumed_role_arn: None,
1053            })
1054            .build()
1055            .unwrap();
1056        assert_eq!(alice.mfa_present(), Some(true));
1057
1058        let assumed = alice
1059            .through(
1060                arn_for(12345678, "DataScientist"),
1061                Transition::AssumedRole {
1062                    session_name: "s".to_string(),
1063                },
1064            )
1065            .unwrap();
1066
1067        assert_eq!(assumed.mfa_present(), None);
1068        assert!(assumed.session_info().is_none());
1069    }
1070
1071    /// A stand-in for `principal LIKE '%…%'`, so the assertions below are the
1072    /// queries #49 asks for rather than a paraphrase of them.
1073    fn matches_like(trail: &str, pattern: &str) -> bool {
1074        let mut rest = trail;
1075        for (i, part) in pattern.split('%').enumerate() {
1076            if part.is_empty() {
1077                continue;
1078            }
1079            match (i, rest.find(part)) {
1080                (_, None) => return false,
1081                (0, Some(0)) | (1.., Some(_)) => {
1082                    rest = &rest[rest.find(part).unwrap() + part.len()..]
1083                }
1084                (0, Some(_)) => return false,
1085            }
1086        }
1087        pattern.ends_with('%') || rest.is_empty()
1088    }
1089
1090    #[test]
1091    fn a_trail_answers_the_queries_the_issue_asks_for() {
1092        let trail = WamiContext::builder()
1093            .caller_arn(arn_for(12345678, "alice"))
1094            .build()
1095            .unwrap()
1096            .through(
1097                arn_for(12345678, "DataScientist"),
1098                Transition::AssumedRole {
1099                    session_name: "session-abc123".to_string(),
1100                },
1101            )
1102            .unwrap()
1103            .through(
1104                arn_for(12345678, "DataScientist"),
1105                Transition::PermissionSet {
1106                    name: "DeveloperAccess".to_string(),
1107                },
1108            )
1109            .unwrap()
1110            .provenance_trail();
1111
1112        // The three from the issue, verbatim.
1113        assert!(matches_like(&trail, "%:sts:assumed-role/%"));
1114        assert!(matches_like(&trail, "%:sso:%"));
1115        assert!(matches_like(&trail, "%:sts:%:sso:%"));
1116
1117        // And the negative that gives those any meaning.
1118        assert!(!matches_like(&trail, "%:iam:policy/ReadOnly%"));
1119
1120        // The trail starts at the identity, not at a segment.
1121        assert!(trail.starts_with("arn:wami:"));
1122        assert!(trail.contains("user/alice"));
1123        assert!(trail.contains("assumed-role/DataScientist/session-abc123"));
1124    }
1125
1126    #[test]
1127    fn a_trail_is_not_the_caller_arn() {
1128        // The distinction the whole design rests on: the trail grows, the
1129        // identifier policies match against does not.
1130        let alice = WamiContext::builder()
1131            .caller_arn(arn_for(12345678, "alice"))
1132            .build()
1133            .unwrap();
1134        let assumed = alice
1135            .through(
1136                arn_for(12345678, "role"),
1137                Transition::AssumedRole {
1138                    session_name: "s".to_string(),
1139                },
1140            )
1141            .unwrap();
1142
1143        assert_ne!(assumed.provenance_trail(), assumed.caller_arn().to_string());
1144        assert_eq!(
1145            assumed.caller_arn().to_string(),
1146            arn_for(12345678, "role").to_string()
1147        );
1148    }
1149
1150    #[test]
1151    fn a_value_cannot_forge_a_segment_boundary() {
1152        // An issuer is free text and carries both `:` and `/`. Left raw,
1153        // `LIKE '%:sso:%'` would match a trail that never touched SSO.
1154        let trail = WamiContext::builder()
1155            .caller_arn(arn_for(12345678, "alice"))
1156            .build()
1157            .unwrap()
1158            .through(
1159                arn_for(12345678, "bob"),
1160                Transition::Federated {
1161                    issuer: "https://idp.example/:sso:permission-set/Admin".to_string(),
1162                },
1163            )
1164            .unwrap()
1165            .provenance_trail();
1166
1167        assert!(matches_like(&trail, "%:sts:federated/%"));
1168        assert!(
1169            !matches_like(&trail, "%:sso:permission-set/%"),
1170            "an issuer forged an SSO segment: {trail}"
1171        );
1172    }
1173
1174    #[test]
1175    fn each_transition_names_its_service() {
1176        let context = WamiContext::builder()
1177            .caller_arn(arn_for(12345678, "alice"))
1178            .build()
1179            .unwrap();
1180        assert_eq!(context.provenance()[0].service(), "iam");
1181
1182        let assumed = context
1183            .through(
1184                arn_for(12345678, "r"),
1185                Transition::AssumedRole {
1186                    session_name: "s".to_string(),
1187                },
1188            )
1189            .unwrap();
1190        assert_eq!(assumed.provenance()[1].service(), "sts");
1191
1192        let sso = assumed
1193            .through(
1194                arn_for(12345678, "r"),
1195                Transition::PermissionSet {
1196                    name: "n".to_string(),
1197                },
1198            )
1199            .unwrap();
1200        assert_eq!(sso.provenance()[2].service(), "sso");
1201
1202        let federated = context
1203            .through(
1204                arn_for(12345678, "b"),
1205                Transition::Federated {
1206                    issuer: "i".to_string(),
1207                },
1208            )
1209            .unwrap();
1210        assert_eq!(federated.provenance()[1].service(), "sts");
1211    }
1212
1213    #[test]
1214    fn federation_drops_them_too() {
1215        // Being vouched for by an external issuer is as much a change of
1216        // identity as assuming a role: whatever the previous principal proved
1217        // says nothing about the one now holding authority.
1218        let alice = WamiContext::builder()
1219            .caller_arn(arn_for(12345678, "alice"))
1220            .mfa_present(true)
1221            .session_info(SessionInfo {
1222                session_token: "tok".to_string(),
1223                expiration: 9_999_999_999,
1224                assumed_role_arn: None,
1225            })
1226            .build()
1227            .unwrap();
1228
1229        let federated = alice
1230            .through(
1231                arn_for(12345678, "external-bob"),
1232                Transition::Federated {
1233                    issuer: "https://idp.example".to_string(),
1234                },
1235            )
1236            .unwrap();
1237
1238        assert_eq!(federated.mfa_present(), None);
1239        assert!(federated.session_info().is_none());
1240        assert_eq!(
1241            federated.provenance().last().unwrap().via(),
1242            &Transition::Federated {
1243                issuer: "https://idp.example".to_string()
1244            }
1245        );
1246    }
1247
1248    #[test]
1249    fn every_field_survives_a_round_trip() {
1250        // The wire type restates all ten fields, so a context serialised with
1251        // each of them set has to come back whole — a field dropped in that
1252        // restatement would be silently lost on every deserialisation.
1253        let context = WamiContext::builder()
1254            .caller_arn(arn_for(12345678, "alice"))
1255            .region("eu-west-3")
1256            .session_info(SessionInfo {
1257                session_token: "tok".to_string(),
1258                expiration: 9_999_999_999,
1259                assumed_role_arn: Some(arn_for(12345678, "role")),
1260            })
1261            .source_ip("203.0.113.7")
1262            .mfa_present(true)
1263            .secure_transport(true)
1264            .build()
1265            .unwrap();
1266
1267        let back: WamiContext =
1268            serde_json::from_str(&serde_json::to_string(&context).unwrap()).unwrap();
1269
1270        assert_eq!(back.caller_arn(), context.caller_arn());
1271        assert_eq!(back.tenant_path(), context.tenant_path());
1272        assert_eq!(back.instance_id(), context.instance_id());
1273        assert_eq!(back.is_root(), context.is_root());
1274        assert_eq!(back.region(), Some("eu-west-3"));
1275        assert_eq!(back.source_ip(), Some("203.0.113.7"));
1276        assert_eq!(back.mfa_present(), Some(true));
1277        assert_eq!(back.secure_transport(), Some(true));
1278        assert_eq!(back.provenance(), context.provenance());
1279        assert_eq!(
1280            back.session_info().map(|s| s.session_token.as_str()),
1281            Some("tok")
1282        );
1283    }
1284
1285    #[test]
1286    fn a_permission_set_is_not_a_change_of_identity() {
1287        // The caller stays who they were, so what they proved still holds.
1288        let alice = WamiContext::builder()
1289            .caller_arn(arn_for(12345678, "alice"))
1290            .mfa_present(true)
1291            .build()
1292            .unwrap();
1293
1294        let scoped = alice
1295            .through(
1296                arn_for(12345678, "alice"),
1297                Transition::PermissionSet {
1298                    name: "DeveloperAccess".to_string(),
1299                },
1300            )
1301            .unwrap();
1302
1303        assert_eq!(scoped.mfa_present(), Some(true));
1304    }
1305
1306    #[test]
1307    fn request_attributes_survive_a_transition() {
1308        // The source address and transport describe the request, not the
1309        // principal — they are true whoever holds authority.
1310        let alice = WamiContext::builder()
1311            .caller_arn(arn_for(12345678, "alice"))
1312            .source_ip("203.0.113.7")
1313            .secure_transport(true)
1314            .build()
1315            .unwrap();
1316
1317        let assumed = alice
1318            .through(
1319                arn_for(12345678, "role"),
1320                Transition::AssumedRole {
1321                    session_name: "s".to_string(),
1322                },
1323            )
1324            .unwrap();
1325
1326        assert_eq!(assumed.source_ip(), Some("203.0.113.7"));
1327        assert_eq!(assumed.secure_transport(), Some(true));
1328    }
1329
1330    #[test]
1331    fn deriving_a_context_leaves_the_original_alone() {
1332        let alice = WamiContext::builder()
1333            .caller_arn(arn_for(12345678, "alice"))
1334            .build()
1335            .unwrap();
1336
1337        let _ = alice
1338            .through(arn_for(12345678, "role"), Transition::Authenticated)
1339            .unwrap();
1340
1341        assert_eq!(alice.provenance().len(), 1);
1342        assert_eq!(alice.caller_arn().resource_id(), "alice");
1343    }
1344
1345    #[test]
1346    fn transitions_survive_serialisation() {
1347        // The chain is only useful if it reaches a log or a policy engine.
1348        let context = WamiContext::builder()
1349            .caller_arn(arn_for(12345678, "alice"))
1350            .build()
1351            .unwrap()
1352            .through(
1353                arn_for(12345678, "DataScientist"),
1354                Transition::AssumedRole {
1355                    session_name: "session1".to_string(),
1356                },
1357            )
1358            .unwrap();
1359
1360        let json = serde_json::to_string(&context).unwrap();
1361        let back: WamiContext = serde_json::from_str(&json).unwrap();
1362
1363        assert_eq!(back.provenance(), context.provenance());
1364        assert_eq!(back.caller_arn(), context.caller_arn());
1365    }
1366
1367    #[test]
1368    fn test_explicit_is_root_false_beats_a_root_arn() {
1369        // Dropping privileges deliberately has to remain possible.
1370        let context = WamiContext::builder()
1371            .caller_arn(arn_for(0, "root"))
1372            .is_root(false)
1373            .build()
1374            .unwrap();
1375
1376        assert!(!context.is_root());
1377    }
1378}