Skip to main content

vti_common/auth/
extractor.rs

1use std::sync::Arc;
2
3use axum::extract::FromRequestParts;
4use axum::http::request::Parts;
5use axum_extra::TypedHeader;
6use axum_extra::headers::Authorization;
7use axum_extra::headers::authorization::Bearer;
8use tracing::warn;
9
10use crate::acl::{ActScope, Role, act_scope_for};
11use crate::auth::jwt::JwtKeys;
12use crate::auth::session::{SessionState, get_session};
13use crate::error::AppError;
14use crate::store::KeyspaceHandle;
15
16/// Trait that each service's `AppState` implements to provide the data
17/// needed by the auth extractors.
18pub trait AuthState: Clone + Send + Sync + 'static {
19    fn jwt_keys(&self) -> Option<&Arc<JwtKeys>>;
20    fn sessions_ks(&self) -> &KeyspaceHandle;
21}
22
23/// Extracted from a valid JWT Bearer token on protected routes.
24///
25/// Add this as a handler parameter to require authentication:
26/// ```ignore
27/// async fn handler(_auth: AuthClaims, ...) { }
28/// ```
29#[derive(Debug, Default, Clone)]
30pub struct AuthClaims {
31    pub did: String,
32    pub role: Role,
33    pub allowed_contexts: Vec<String>,
34    /// JWT `session_id` claim. Carried through so handlers can do
35    /// session-targeted operations (sign-out, refresh-token
36    /// rotation) without re-decoding the JWT.
37    pub session_id: String,
38    /// JWT `exp` claim — Unix-second expiry. Surfaced so
39    /// `whoami`-style endpoints can return the access-token
40    /// lifetime without re-decoding.
41    pub access_expires_at: u64,
42    /// Authentication Methods References per [RFC 8176]. Mirrors
43    /// `Claims.amr` from the bearer JWT. Handlers gating sensitive
44    /// operations check this to decide whether a step-up is needed.
45    pub amr: Vec<String>,
46    /// Authentication Context Class Reference per OIDC Core §2.
47    /// Typical values: `"aal1"` / `"aal2"` / `"aal3"`. Handlers gating
48    /// step-up read this directly.
49    pub acr: String,
50}
51
52/// Name of the admin UX session cookie set by the VTC's
53/// `POST /v1/auth/admin-login` + `POST /v1/auth/passkey-login/finish`
54/// flows. When the `Authorization: Bearer` header is absent,
55/// [`AuthClaims`] falls back to reading a JWT out of this cookie.
56/// The cookie is set with `Path=/; SameSite=Strict; Secure; HttpOnly`
57/// so the browser sends it on `/v1/*` API calls; `HttpOnly` keeps
58/// JS on any path from reading it, and `SameSite=Strict` blocks
59/// cross-site CSRF.
60pub const ADMIN_SESSION_COOKIE: &str = "vtc_admin_session";
61
62impl<S: AuthState> FromRequestParts<S> for AuthClaims {
63    type Rejection = AppError;
64
65    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
66        // Try `Authorization: Bearer <jwt>` first. Programmatic
67        // clients (cnm-cli, DIDComm bridges, the existing
68        // `/v1/auth/` flow) all use this path.
69        let bearer_token = TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state)
70            .await
71            .ok()
72            .map(|TypedHeader(auth)| auth.token().to_string());
73
74        // Fall back to the admin session cookie (Phase 5 M5.2.3).
75        // Set by `POST /v1/auth/admin-login`; carries the same JWT
76        // as the bearer path.
77        let token: String = match bearer_token {
78            Some(t) => t,
79            None => match cookie_token(parts, ADMIN_SESSION_COOKIE) {
80                Some(t) => t,
81                None => {
82                    warn!(
83                        "auth rejected: no Authorization header and no {ADMIN_SESSION_COOKIE} cookie"
84                    );
85                    return Err(AppError::Unauthorized(
86                        "missing or invalid Authorization header".into(),
87                    ));
88                }
89            },
90        };
91        let token = token.as_str();
92
93        // Decode and validate JWT
94        let jwt_keys = state
95            .jwt_keys()
96            .ok_or_else(|| AppError::Unauthorized("auth not configured".into()))?;
97
98        let claims = jwt_keys.decode(token)?;
99
100        // Verify session exists and is authenticated
101        let session = get_session(state.sessions_ks(), &claims.session_id)
102            .await?
103            .ok_or_else(|| {
104                warn!(session_id = %claims.session_id, "auth rejected: session not found");
105                AppError::Unauthorized("session not found".into())
106            })?;
107
108        if session.state != SessionState::Authenticated {
109            warn!(session_id = %claims.session_id, "auth rejected: session not in authenticated state");
110            return Err(AppError::Unauthorized("session not authenticated".into()));
111        }
112
113        // jti pin: when the session records a `token_id`, only the token whose
114        // `jti` matches it authenticates. Minting a fresh token (login, refresh,
115        // step-up) rotates `token_id`, so every previously-issued access token
116        // for this session is superseded immediately — the mechanism that keeps
117        // a non-rotating session_id revocable. Skipped when `token_id` is unset
118        // (sessions written before this field, or intrinsic-sender sessions that
119        // carry no JWT), preserving their existing behaviour.
120        if let Some(ref pinned) = session.token_id
121            && claims.jti != *pinned
122        {
123            warn!(session_id = %claims.session_id, "auth rejected: token superseded (jti mismatch)");
124            return Err(AppError::Unauthorized("token superseded".into()));
125        }
126
127        let role = Role::parse(&claims.role)?;
128
129        Ok(AuthClaims {
130            did: claims.sub,
131            role,
132            allowed_contexts: claims.contexts,
133            session_id: claims.session_id,
134            access_expires_at: claims.exp,
135            amr: claims.amr,
136            acr: claims.acr,
137        })
138    }
139}
140
141impl AuthClaims {
142    /// **UNSAFE**: Synthesize a super-admin claim with no wire-level
143    /// verification. Only for **on-host offline CLI** invocations — the
144    /// trust boundary is the OS process, not the network.
145    ///
146    /// Feature-gated behind `cli-synthesis` so this function is physically
147    /// absent from enclave and server-only builds. Any caller compiles
148    /// iff the feature is on; calling this from a route handler is a bug
149    /// that the type system can't catch (the resulting `AuthClaims` is
150    /// indistinguishable from a legitimate one), so the name loudly marks
151    /// the footgun.
152    ///
153    /// The trust model: a process that can execute the VTA binary AND
154    /// read the keystore + seed store is already trusted by the OS to
155    /// act as the VTA itself. Offline CLIs that mutate state (mint keys,
156    /// seal bundles, export admin credentials) pre-date any over-the-
157    /// wire authentication, so wire-level claims can't gate them. The
158    /// caller-supplied `channel` is recorded in the audit log so misuse
159    /// can be traced back to the specific CLI path.
160    ///
161    /// Downstream hardening (tracked as review item 9 follow-up):
162    /// - Require an operator-side credential (env var / local config
163    ///   pointing at a key in the ACL) before synthesizing.
164    /// - Audit-log process identity (`uid`, `pid`, `cwd`) alongside
165    ///   `channel` so a forensic investigator can distinguish
166    ///   operator-intentional runs from lateral-movement abuse.
167    ///
168    /// The sentinel DID format `"cli:<channel>"` (not `did:*`) is
169    /// deliberate — it doesn't round-trip through DID resolution and
170    /// can't be confused with a real caller DID in log correlation.
171    #[cfg(feature = "cli-synthesis")]
172    pub fn unsafe_local_cli_super_admin(channel: &str) -> Self {
173        Self {
174            did: format!("cli:{channel}"),
175            role: Role::Admin,
176            allowed_contexts: Vec::new(),
177            // CLI synthesis bypasses the session store entirely.
178            // The sentinel session_id matches the DID format and
179            // `access_expires_at: 0` makes the synthesized claim
180            // visibly "no real expiry" to any log scraper.
181            session_id: format!("cli:{channel}"),
182            access_expires_at: 0,
183            // CLI synthesis is a process-local trust boundary; the auth
184            // method is the OS user, not a wire factor. Surface `"cli"`
185            // in amr so a downstream auditor distinguishes synthesized
186            // claims from real authenticated sessions.
187            amr: vec!["cli".to_string()],
188            acr: String::new(),
189        }
190    }
191
192    /// This caller's authority to **act**, decoded from `(role,
193    /// allowed_contexts)`.
194    ///
195    /// Use this — or [`has_context_access`](Self::has_context_access), which is
196    /// built on it — rather than inspecting `allowed_contexts` directly. An
197    /// empty list means *unrestricted* for [`Role::Admin`] and *nothing at all*
198    /// for every other role; a call site that tests `is_empty()` without the
199    /// role gets one of those two cases backwards. See [`ActScope`].
200    pub fn act_scope(&self) -> ActScope {
201        act_scope_for(&self.role, &self.allowed_contexts)
202    }
203
204    /// Returns `true` if the caller is an admin whose [`ActScope`] is
205    /// unrestricted.
206    pub fn is_super_admin(&self) -> bool {
207        self.role == Role::Admin && self.act_scope().is_unrestricted()
208    }
209
210    /// Returns `true` if the caller may act in the given context — because
211    /// their [`ActScope`] is unrestricted, or because it names `context_id`
212    /// itself **or an ancestor of it** (folder-level authority: admin of a
213    /// parent context covers the whole subtree).
214    ///
215    /// Ancestry is the segment-aware
216    /// [`is_ancestor_or_self`](crate::context_path::is_ancestor_or_self) — a
217    /// pure, store-free check over the verified JWT's contexts. For today's flat
218    /// (single-segment, childless) contexts this is identical to the previous
219    /// exact match.
220    pub fn has_context_access(&self, context_id: &str) -> bool {
221        self.act_scope().covers(context_id)
222    }
223
224    /// Clone these claims with `extra` contexts merged into `allowed_contexts`.
225    ///
226    /// This is how a **consented per-task delegation** is realized: an approver
227    /// who holds admin in a context authorizes one specific task, and the
228    /// executor runs *that one dispatch* under the requester's identity widened
229    /// to include the delegated context. The widening lives only for the single
230    /// consented, payload-bound, single-use execution — it is never persisted
231    /// onto the session or the JWT, so the agent accrues no standing authority.
232    ///
233    /// Never *widens* a super-admin (empty `allowed_contexts` already means "all
234    /// contexts", so there is nothing to add and replacing the empty list would
235    /// wrongly *narrow* it) and is a no-op when `extra` is empty. Duplicates are
236    /// dropped so repeated delegation can't bloat the list.
237    pub fn with_delegated_contexts(&self, extra: &[String]) -> Self {
238        let mut claims = self.clone();
239        if extra.is_empty() || claims.is_super_admin() {
240            return claims;
241        }
242        for ctx in extra {
243            if !claims.allowed_contexts.iter().any(|c| c == ctx) {
244                claims.allowed_contexts.push(ctx.clone());
245            }
246        }
247        claims
248    }
249
250    /// Realize a **consented grant** for a single dispatch: the approval conferred
251    /// full authority over `extra`, so the requester need hold **no standing
252    /// admin at all**.
253    ///
254    /// Unlike [`with_delegated_contexts`] — which widens context but keeps the
255    /// requester's role — this also lifts the role to [`Role::Admin`], because
256    /// the grant authorizes the exact bound task in full. That is what lets a
257    /// purely unprivileged agent (a Reader that can act nowhere) execute a task an
258    /// approver blessed: the approval *is* the authority. Ephemeral in exactly the
259    /// same way as the context widening — built for one dispatch, never persisted
260    /// to the session, JWT, or ACL — so the agent accrues no standing power.
261    ///
262    /// A no-op when `extra` is empty (nothing was delegated — an ordinary
263    /// same-context, already-authorized execution) and for a super-admin (already
264    /// unrestricted; adding to the empty list would wrongly narrow it).
265    pub fn with_delegated_authority(&self, extra: &[String]) -> Self {
266        let mut claims = self.clone();
267        if extra.is_empty() || claims.is_super_admin() {
268            return claims;
269        }
270        claims.role = Role::Admin;
271        for ctx in extra {
272            if !claims.allowed_contexts.iter().any(|c| c == ctx) {
273                claims.allowed_contexts.push(ctx.clone());
274            }
275        }
276        claims
277    }
278
279    /// Check that the caller has access to the given context.
280    ///
281    /// Admins with an empty `allowed_contexts` list have unrestricted access.
282    pub fn require_context(&self, context_id: &str) -> Result<(), AppError> {
283        if self.has_context_access(context_id) {
284            return Ok(());
285        }
286        Err(AppError::Forbidden(format!(
287            "no access to context: {context_id}"
288        )))
289    }
290
291    /// If the caller has exactly one allowed context, return it.
292    pub fn default_context(&self) -> Option<&str> {
293        if self.allowed_contexts.len() == 1 {
294            Some(&self.allowed_contexts[0])
295        } else {
296            None
297        }
298    }
299
300    /// Require at least Reader role (all roles except Monitor).
301    ///
302    /// Use for read-only endpoints that access business data (keys, contexts, DIDs).
303    /// Monitor can only see metrics and health.
304    pub fn require_read(&self) -> Result<(), AppError> {
305        if self.role == Role::Monitor {
306            return Err(AppError::Forbidden("reader role or higher required".into()));
307        }
308        Ok(())
309    }
310
311    /// Require at least Application role (Admin, Initiator, or Application).
312    ///
313    /// Use for write operations: signing, cache writes, and other actions that
314    /// produce artifacts or modify state.
315    pub fn require_write(&self) -> Result<(), AppError> {
316        if matches!(self.role, Role::Admin | Role::Initiator | Role::Application) {
317            return Ok(());
318        }
319        Err(AppError::Forbidden(
320            "application role or higher required".into(),
321        ))
322    }
323
324    /// Require the caller to have Admin role.
325    pub fn require_admin(&self) -> Result<(), AppError> {
326        if self.role == Role::Admin {
327            return Ok(());
328        }
329        Err(AppError::Forbidden("admin role required".into()))
330    }
331
332    /// Require the caller to have Admin or Initiator role.
333    pub fn require_manage(&self) -> Result<(), AppError> {
334        if self.role == Role::Admin || self.role == Role::Initiator {
335            return Ok(());
336        }
337        Err(AppError::Forbidden(
338            "admin or initiator role required".into(),
339        ))
340    }
341
342    /// Require the caller to be a super admin (Admin + unrestricted).
343    pub fn require_super_admin(&self) -> Result<(), AppError> {
344        if self.is_super_admin() {
345            return Ok(());
346        }
347        Err(AppError::Forbidden("super admin required".into()))
348    }
349}
350
351/// Extractor that requires the caller to have Admin or Initiator role.
352///
353/// Use on endpoints that manage ACL entries and other management tasks:
354/// ```ignore
355/// async fn handler(auth: ManageAuth, ...) { }
356/// ```
357#[derive(Debug, Clone)]
358pub struct ManageAuth(pub AuthClaims);
359
360impl<S: AuthState> FromRequestParts<S> for ManageAuth {
361    type Rejection = AppError;
362
363    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
364        let claims = AuthClaims::from_request_parts(parts, state).await?;
365
366        match claims.role {
367            Role::Admin | Role::Initiator => Ok(ManageAuth(claims)),
368            _ => {
369                warn!(did = %claims.did, role = %claims.role, "auth rejected: admin or initiator role required");
370                Err(AppError::Forbidden(
371                    "admin or initiator role required".into(),
372                ))
373            }
374        }
375    }
376}
377
378/// Extractor that requires the caller to have Admin role.
379///
380/// Use on endpoints that modify configuration, create/delete keys, etc.:
381/// ```ignore
382/// async fn handler(auth: AdminAuth, ...) { }
383/// ```
384#[derive(Debug, Clone)]
385pub struct AdminAuth(pub AuthClaims);
386
387impl<S: AuthState> FromRequestParts<S> for AdminAuth {
388    type Rejection = AppError;
389
390    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
391        let claims = AuthClaims::from_request_parts(parts, state).await?;
392
393        match claims.role {
394            Role::Admin => Ok(AdminAuth(claims)),
395            _ => {
396                warn!(did = %claims.did, role = %claims.role, "auth rejected: admin role required");
397                Err(AppError::Forbidden("admin role required".into()))
398            }
399        }
400    }
401}
402
403/// Extractor that requires a **stepped-up** session (JWT `acr == "aal2"`).
404///
405/// Use on routes that demand a second factor beyond the base DID
406/// challenge-response (`aal1`) — typical examples: ACL edits,
407/// key rotation, backup export, anything that lets an attacker
408/// with a leaked `aal1` token pivot to a long-lived foothold.
409///
410/// ```ignore
411/// async fn rotate_keys(auth: StepUpAuth, ...) { /* aal2 enforced */ }
412/// ```
413///
414/// A request with a lower `acr` is rejected with
415/// [`AppError::StepUpRequired`] (403 + body
416/// `{ "error": "step_up_required", "requiredAcr": "aal2" }`). The
417/// wallet uses that signal to trigger a passkey-login or
418/// VTA-approval ceremony — distinct from a generic `forbidden`
419/// it would get from a role gate.
420///
421/// **Trust model**: the gate reads `acr` from the JWT claims the
422/// `AuthClaims` extractor already verified (signature, expiry,
423/// session existence). Step-up tokens are stateless during their
424/// access-window; the canonical refresh handler preserves `acr`
425/// across rotation. If a step-up access-token leaks, the only
426/// brake is the short access-token TTL (or [`M2`] — shorter TTL
427/// when `acr=aal2`).
428#[derive(Debug, Clone)]
429pub struct StepUpAuth(pub AuthClaims);
430
431impl<S: AuthState> FromRequestParts<S> for StepUpAuth {
432    type Rejection = AppError;
433
434    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
435        let claims = AuthClaims::from_request_parts(parts, state).await?;
436
437        if claims.acr == "aal2" {
438            Ok(StepUpAuth(claims))
439        } else {
440            warn!(
441                did = %claims.did,
442                acr = %claims.acr,
443                "auth rejected: step-up (aal2) required",
444            );
445            Err(AppError::StepUpRequired(
446                "operation requires a stepped-up (aal2) session".into(),
447            ))
448        }
449    }
450}
451
452/// Extractor that requires the caller to be a super admin (Admin role with
453/// empty `allowed_contexts`).
454///
455/// Use on endpoints that only unrestricted administrators should access,
456/// such as creating/deleting contexts or modifying global configuration:
457/// ```ignore
458/// async fn handler(auth: SuperAdminAuth, ...) { }
459/// ```
460#[derive(Debug, Clone)]
461pub struct SuperAdminAuth(pub AuthClaims);
462
463impl<S: AuthState> FromRequestParts<S> for SuperAdminAuth {
464    type Rejection = AppError;
465
466    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
467        let claims = AuthClaims::from_request_parts(parts, state).await?;
468
469        if !claims.is_super_admin() {
470            warn!(did = %claims.did, "auth rejected: super admin required");
471            return Err(AppError::Forbidden("super admin required".into()));
472        }
473
474        Ok(SuperAdminAuth(claims))
475    }
476}
477
478/// Extractor that requires the caller to have at least Application role
479/// (Admin, Initiator, or Application).
480///
481/// Use on endpoints that perform write operations — signing, cache writes,
482/// and other actions that produce artifacts or modify state:
483/// ```ignore
484/// async fn handler(auth: WriteAuth, ...) { }
485/// ```
486#[derive(Debug, Clone)]
487pub struct WriteAuth(pub AuthClaims);
488
489impl<S: AuthState> FromRequestParts<S> for WriteAuth {
490    type Rejection = AppError;
491
492    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
493        let claims = AuthClaims::from_request_parts(parts, state).await?;
494
495        match claims.role {
496            Role::Admin | Role::Initiator | Role::Application => Ok(WriteAuth(claims)),
497            _ => {
498                warn!(did = %claims.did, role = %claims.role, "auth rejected: application role or higher required");
499                Err(AppError::Forbidden(
500                    "application role or higher required".into(),
501                ))
502            }
503        }
504    }
505}
506
507/// Pull a named cookie value off the request `Cookie` headers.
508/// Returns `None` when the cookie isn't present. Does **not**
509/// percent-decode — cookie values minted by the VTC's admin-login
510/// flow are JWTs (base64url + dots), which are ASCII-safe.
511fn cookie_token(parts: &Parts, name: &str) -> Option<String> {
512    parts
513        .headers
514        .get_all(axum::http::header::COOKIE)
515        .iter()
516        .filter_map(|v| v.to_str().ok())
517        .flat_map(|s| s.split(';'))
518        .map(|s| s.trim())
519        .find_map(|kv| {
520            let (k, v) = kv.split_once('=')?;
521            (k == name).then(|| v.to_string())
522        })
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    #[test]
530    fn has_context_access_grants_the_subtree_to_a_parent_admin() {
531        // A context admin scoped to `acme/eng` (not super-admin — the list is
532        // non-empty), so ancestry applies.
533        let claims = AuthClaims {
534            role: Role::Admin,
535            allowed_contexts: vec!["acme/eng".into()],
536            ..Default::default()
537        };
538        assert!(!claims.is_super_admin());
539
540        // Self + every descendant.
541        assert!(claims.has_context_access("acme/eng"));
542        assert!(claims.has_context_access("acme/eng/team-a"));
543        assert!(claims.has_context_access("acme/eng/team-a/squad-1"));
544
545        // NOT the parent, a sibling, or a prefix-confusion look-alike.
546        assert!(!claims.has_context_access("acme"));
547        assert!(!claims.has_context_access("acme/ops"));
548        assert!(!claims.has_context_access("acme/engineering"));
549
550        assert!(claims.require_context("acme/eng/team-a").is_ok());
551        assert!(claims.require_context("acme/ops").is_err());
552    }
553
554    #[test]
555    fn with_delegated_contexts_widens_a_scoped_admin_for_one_call() {
556        let base = AuthClaims {
557            role: Role::Admin,
558            allowed_contexts: vec!["ctx-a".into()],
559            ..Default::default()
560        };
561        // Before: no access to the delegated context.
562        assert!(base.require_context("openvtc").is_err());
563
564        let widened = base.with_delegated_contexts(&["openvtc".into()]);
565        assert!(widened.require_context("openvtc").is_ok());
566        assert!(
567            widened.require_context("ctx-a").is_ok(),
568            "keeps its own context"
569        );
570        // The delegation is a fresh value — the caller's own claims are untouched.
571        assert!(base.require_context("openvtc").is_err());
572    }
573
574    #[test]
575    fn with_delegated_contexts_is_a_noop_for_empty_or_super_admin() {
576        let scoped = AuthClaims {
577            role: Role::Admin,
578            allowed_contexts: vec!["ctx-a".into()],
579            ..Default::default()
580        };
581        // Empty delegation changes nothing.
582        assert_eq!(
583            scoped.with_delegated_contexts(&[]).allowed_contexts,
584            scoped.allowed_contexts
585        );
586        // A super-admin (empty list = all contexts) must never be narrowed to a
587        // scoped list by a delegation.
588        let sa = AuthClaims {
589            role: Role::Admin,
590            ..Default::default()
591        };
592        assert!(sa.is_super_admin());
593        let after = sa.with_delegated_contexts(&["openvtc".into()]);
594        assert!(after.is_super_admin(), "super-admin stays unrestricted");
595        assert!(after.allowed_contexts.is_empty());
596    }
597
598    #[test]
599    fn with_delegated_authority_lifts_a_non_admin_for_one_dispatch() {
600        // Fix 2: a purely unprivileged agent (Reader, acts nowhere) executes a
601        // task an approver blessed — the grant confers both admin and context.
602        let reader = AuthClaims {
603            role: Role::Reader,
604            allowed_contexts: vec![],
605            ..Default::default()
606        };
607        assert!(reader.require_admin().is_err());
608        assert!(!reader.has_context_access("openvtc"));
609
610        let widened = reader.with_delegated_authority(&["openvtc".into()]);
611        assert!(widened.require_admin().is_ok(), "grant confers admin");
612        assert!(
613            widened.has_context_access("openvtc"),
614            "grant confers the context"
615        );
616
617        // The original is untouched — no standing elevation persists.
618        assert!(reader.require_admin().is_err());
619        assert!(!reader.has_context_access("openvtc"));
620    }
621
622    #[test]
623    fn with_delegated_authority_is_a_noop_for_empty_or_super_admin() {
624        let reader = AuthClaims {
625            role: Role::Reader,
626            allowed_contexts: vec![],
627            ..Default::default()
628        };
629        // Empty delegation changes nothing (an ordinary self-authorized execution).
630        let after = reader.with_delegated_authority(&[]);
631        assert_eq!(after.role, Role::Reader);
632        assert!(after.allowed_contexts.is_empty());
633
634        // A super-admin is already unrestricted; never narrow it to a scoped list.
635        let sa = AuthClaims {
636            role: Role::Admin,
637            ..Default::default()
638        };
639        assert!(sa.is_super_admin());
640        let after = sa.with_delegated_authority(&["openvtc".into()]);
641        assert!(after.is_super_admin(), "super-admin stays unrestricted");
642    }
643
644    #[test]
645    fn with_delegated_contexts_dedups() {
646        let base = AuthClaims {
647            role: Role::Admin,
648            allowed_contexts: vec!["ctx-a".into()],
649            ..Default::default()
650        };
651        let widened = base.with_delegated_contexts(&["ctx-a".into(), "openvtc".into()]);
652        assert_eq!(widened.allowed_contexts, vec!["ctx-a", "openvtc"]);
653    }
654
655    #[test]
656    fn flat_context_grant_is_exact_match_only() {
657        // A single-segment grant with no sub-contexts behaves exactly as before.
658        let claims = AuthClaims {
659            role: Role::Reader,
660            allowed_contexts: vec!["prod-mediator".into()],
661            ..Default::default()
662        };
663        assert!(claims.has_context_access("prod-mediator"));
664        assert!(!claims.has_context_access("prod-mediator-2"));
665        assert!(!claims.has_context_access("other"));
666    }
667
668    #[cfg(feature = "cli-synthesis")]
669    #[test]
670    fn local_cli_synthesizes_super_admin_with_channel_sentinel() {
671        let claims = AuthClaims::unsafe_local_cli_super_admin("provision-integration");
672        assert_eq!(claims.did, "cli:provision-integration");
673        assert_eq!(claims.role, Role::Admin);
674        assert!(claims.allowed_contexts.is_empty());
675        assert!(claims.is_super_admin());
676    }
677
678    #[cfg(feature = "cli-synthesis")]
679    #[test]
680    fn local_cli_grants_any_context_access() {
681        let claims = AuthClaims::unsafe_local_cli_super_admin("keys-bundle");
682        // Super-admin has access to every context — enforced elsewhere
683        // but assert it explicitly here so a future refactor that
684        // breaks the invariant gets caught.
685        assert!(claims.has_context_access("any-context"));
686        assert!(claims.has_context_access("another"));
687        claims
688            .require_context("prod-mediator")
689            .expect("super-admin passes require_context");
690    }
691
692    #[cfg(feature = "cli-synthesis")]
693    #[test]
694    fn local_cli_did_sentinel_cannot_be_confused_with_real_did() {
695        // The `cli:<channel>` format must not round-trip as a
696        // `did:*` URI — otherwise audit-log correlation would muddle
697        // CLI-synthesized claims with real caller identities.
698        let claims = AuthClaims::unsafe_local_cli_super_admin("context-reprovision");
699        assert!(!claims.did.starts_with("did:"));
700        assert!(claims.did.starts_with("cli:"));
701    }
702
703    #[cfg(feature = "cli-synthesis")]
704    #[test]
705    fn local_cli_channel_embedded_in_did() {
706        // Audit-log grep'ability: each synthesis records its `channel`
707        // distinctly so forensic investigation can attribute CLI
708        // actions to the specific code path that ran them.
709        let a = AuthClaims::unsafe_local_cli_super_admin("provision-integration");
710        let b = AuthClaims::unsafe_local_cli_super_admin("keys-bundle");
711        assert_ne!(a.did, b.did);
712    }
713}