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::Role;
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    /// Returns `true` if the caller is an admin with unrestricted access
193    /// (empty `allowed_contexts`).
194    pub fn is_super_admin(&self) -> bool {
195        self.role == Role::Admin && self.allowed_contexts.is_empty()
196    }
197
198    /// Returns `true` if the caller has access to the given context — as a super
199    /// admin, or because one of their `allowed_contexts` is `context_id` itself
200    /// **or an ancestor of it** (folder-level authority: admin of a parent
201    /// context covers the whole subtree).
202    ///
203    /// Ancestry is the segment-aware
204    /// [`is_ancestor_or_self`](crate::context_path::is_ancestor_or_self) — a
205    /// pure, store-free check over the verified JWT's contexts. For today's flat
206    /// (single-segment, childless) contexts this is identical to the previous
207    /// exact match.
208    pub fn has_context_access(&self, context_id: &str) -> bool {
209        self.is_super_admin()
210            || self
211                .allowed_contexts
212                .iter()
213                .any(|allowed| crate::context_path::is_ancestor_or_self(allowed, context_id))
214    }
215
216    /// Clone these claims with `extra` contexts merged into `allowed_contexts`.
217    ///
218    /// This is how a **consented per-task delegation** is realized: an approver
219    /// who holds admin in a context authorizes one specific task, and the
220    /// executor runs *that one dispatch* under the requester's identity widened
221    /// to include the delegated context. The widening lives only for the single
222    /// consented, payload-bound, single-use execution — it is never persisted
223    /// onto the session or the JWT, so the agent accrues no standing authority.
224    ///
225    /// Never *widens* a super-admin (empty `allowed_contexts` already means "all
226    /// contexts", so there is nothing to add and replacing the empty list would
227    /// wrongly *narrow* it) and is a no-op when `extra` is empty. Duplicates are
228    /// dropped so repeated delegation can't bloat the list.
229    pub fn with_delegated_contexts(&self, extra: &[String]) -> Self {
230        let mut claims = self.clone();
231        if extra.is_empty() || claims.is_super_admin() {
232            return claims;
233        }
234        for ctx in extra {
235            if !claims.allowed_contexts.iter().any(|c| c == ctx) {
236                claims.allowed_contexts.push(ctx.clone());
237            }
238        }
239        claims
240    }
241
242    /// Realize a **consented grant** for a single dispatch: the approval conferred
243    /// full authority over `extra`, so the requester need hold **no standing
244    /// admin at all**.
245    ///
246    /// Unlike [`with_delegated_contexts`] — which widens context but keeps the
247    /// requester's role — this also lifts the role to [`Role::Admin`], because
248    /// the grant authorizes the exact bound task in full. That is what lets a
249    /// purely unprivileged agent (a Reader that can act nowhere) execute a task an
250    /// approver blessed: the approval *is* the authority. Ephemeral in exactly the
251    /// same way as the context widening — built for one dispatch, never persisted
252    /// to the session, JWT, or ACL — so the agent accrues no standing power.
253    ///
254    /// A no-op when `extra` is empty (nothing was delegated — an ordinary
255    /// same-context, already-authorized execution) and for a super-admin (already
256    /// unrestricted; adding to the empty list would wrongly narrow it).
257    pub fn with_delegated_authority(&self, extra: &[String]) -> Self {
258        let mut claims = self.clone();
259        if extra.is_empty() || claims.is_super_admin() {
260            return claims;
261        }
262        claims.role = Role::Admin;
263        for ctx in extra {
264            if !claims.allowed_contexts.iter().any(|c| c == ctx) {
265                claims.allowed_contexts.push(ctx.clone());
266            }
267        }
268        claims
269    }
270
271    /// Check that the caller has access to the given context.
272    ///
273    /// Admins with an empty `allowed_contexts` list have unrestricted access.
274    pub fn require_context(&self, context_id: &str) -> Result<(), AppError> {
275        if self.has_context_access(context_id) {
276            return Ok(());
277        }
278        Err(AppError::Forbidden(format!(
279            "no access to context: {context_id}"
280        )))
281    }
282
283    /// If the caller has exactly one allowed context, return it.
284    pub fn default_context(&self) -> Option<&str> {
285        if self.allowed_contexts.len() == 1 {
286            Some(&self.allowed_contexts[0])
287        } else {
288            None
289        }
290    }
291
292    /// Require at least Reader role (all roles except Monitor).
293    ///
294    /// Use for read-only endpoints that access business data (keys, contexts, DIDs).
295    /// Monitor can only see metrics and health.
296    pub fn require_read(&self) -> Result<(), AppError> {
297        if self.role == Role::Monitor {
298            return Err(AppError::Forbidden("reader role or higher required".into()));
299        }
300        Ok(())
301    }
302
303    /// Require at least Application role (Admin, Initiator, or Application).
304    ///
305    /// Use for write operations: signing, cache writes, and other actions that
306    /// produce artifacts or modify state.
307    pub fn require_write(&self) -> Result<(), AppError> {
308        if matches!(self.role, Role::Admin | Role::Initiator | Role::Application) {
309            return Ok(());
310        }
311        Err(AppError::Forbidden(
312            "application role or higher required".into(),
313        ))
314    }
315
316    /// Require the caller to have Admin role.
317    pub fn require_admin(&self) -> Result<(), AppError> {
318        if self.role == Role::Admin {
319            return Ok(());
320        }
321        Err(AppError::Forbidden("admin role required".into()))
322    }
323
324    /// Require the caller to have Admin or Initiator role.
325    pub fn require_manage(&self) -> Result<(), AppError> {
326        if self.role == Role::Admin || self.role == Role::Initiator {
327            return Ok(());
328        }
329        Err(AppError::Forbidden(
330            "admin or initiator role required".into(),
331        ))
332    }
333
334    /// Require the caller to be a super admin (Admin + unrestricted).
335    pub fn require_super_admin(&self) -> Result<(), AppError> {
336        if self.is_super_admin() {
337            return Ok(());
338        }
339        Err(AppError::Forbidden("super admin required".into()))
340    }
341}
342
343/// Extractor that requires the caller to have Admin or Initiator role.
344///
345/// Use on endpoints that manage ACL entries and other management tasks:
346/// ```ignore
347/// async fn handler(auth: ManageAuth, ...) { }
348/// ```
349#[derive(Debug, Clone)]
350pub struct ManageAuth(pub AuthClaims);
351
352impl<S: AuthState> FromRequestParts<S> for ManageAuth {
353    type Rejection = AppError;
354
355    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
356        let claims = AuthClaims::from_request_parts(parts, state).await?;
357
358        match claims.role {
359            Role::Admin | Role::Initiator => Ok(ManageAuth(claims)),
360            _ => {
361                warn!(did = %claims.did, role = %claims.role, "auth rejected: admin or initiator role required");
362                Err(AppError::Forbidden(
363                    "admin or initiator role required".into(),
364                ))
365            }
366        }
367    }
368}
369
370/// Extractor that requires the caller to have Admin role.
371///
372/// Use on endpoints that modify configuration, create/delete keys, etc.:
373/// ```ignore
374/// async fn handler(auth: AdminAuth, ...) { }
375/// ```
376#[derive(Debug, Clone)]
377pub struct AdminAuth(pub AuthClaims);
378
379impl<S: AuthState> FromRequestParts<S> for AdminAuth {
380    type Rejection = AppError;
381
382    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
383        let claims = AuthClaims::from_request_parts(parts, state).await?;
384
385        match claims.role {
386            Role::Admin => Ok(AdminAuth(claims)),
387            _ => {
388                warn!(did = %claims.did, role = %claims.role, "auth rejected: admin role required");
389                Err(AppError::Forbidden("admin role required".into()))
390            }
391        }
392    }
393}
394
395/// Extractor that requires a **stepped-up** session (JWT `acr == "aal2"`).
396///
397/// Use on routes that demand a second factor beyond the base DID
398/// challenge-response (`aal1`) — typical examples: ACL edits,
399/// key rotation, backup export, anything that lets an attacker
400/// with a leaked `aal1` token pivot to a long-lived foothold.
401///
402/// ```ignore
403/// async fn rotate_keys(auth: StepUpAuth, ...) { /* aal2 enforced */ }
404/// ```
405///
406/// A request with a lower `acr` is rejected with
407/// [`AppError::StepUpRequired`] (403 + body
408/// `{ "error": "step_up_required", "requiredAcr": "aal2" }`). The
409/// wallet uses that signal to trigger a passkey-login or
410/// VTA-approval ceremony — distinct from a generic `forbidden`
411/// it would get from a role gate.
412///
413/// **Trust model**: the gate reads `acr` from the JWT claims the
414/// `AuthClaims` extractor already verified (signature, expiry,
415/// session existence). Step-up tokens are stateless during their
416/// access-window; the canonical refresh handler preserves `acr`
417/// across rotation. If a step-up access-token leaks, the only
418/// brake is the short access-token TTL (or [`M2`] — shorter TTL
419/// when `acr=aal2`).
420#[derive(Debug, Clone)]
421pub struct StepUpAuth(pub AuthClaims);
422
423impl<S: AuthState> FromRequestParts<S> for StepUpAuth {
424    type Rejection = AppError;
425
426    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
427        let claims = AuthClaims::from_request_parts(parts, state).await?;
428
429        if claims.acr == "aal2" {
430            Ok(StepUpAuth(claims))
431        } else {
432            warn!(
433                did = %claims.did,
434                acr = %claims.acr,
435                "auth rejected: step-up (aal2) required",
436            );
437            Err(AppError::StepUpRequired(
438                "operation requires a stepped-up (aal2) session".into(),
439            ))
440        }
441    }
442}
443
444/// Extractor that requires the caller to be a super admin (Admin role with
445/// empty `allowed_contexts`).
446///
447/// Use on endpoints that only unrestricted administrators should access,
448/// such as creating/deleting contexts or modifying global configuration:
449/// ```ignore
450/// async fn handler(auth: SuperAdminAuth, ...) { }
451/// ```
452#[derive(Debug, Clone)]
453pub struct SuperAdminAuth(pub AuthClaims);
454
455impl<S: AuthState> FromRequestParts<S> for SuperAdminAuth {
456    type Rejection = AppError;
457
458    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
459        let claims = AuthClaims::from_request_parts(parts, state).await?;
460
461        if !claims.is_super_admin() {
462            warn!(did = %claims.did, "auth rejected: super admin required");
463            return Err(AppError::Forbidden("super admin required".into()));
464        }
465
466        Ok(SuperAdminAuth(claims))
467    }
468}
469
470/// Extractor that requires the caller to have at least Application role
471/// (Admin, Initiator, or Application).
472///
473/// Use on endpoints that perform write operations — signing, cache writes,
474/// and other actions that produce artifacts or modify state:
475/// ```ignore
476/// async fn handler(auth: WriteAuth, ...) { }
477/// ```
478#[derive(Debug, Clone)]
479pub struct WriteAuth(pub AuthClaims);
480
481impl<S: AuthState> FromRequestParts<S> for WriteAuth {
482    type Rejection = AppError;
483
484    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
485        let claims = AuthClaims::from_request_parts(parts, state).await?;
486
487        match claims.role {
488            Role::Admin | Role::Initiator | Role::Application => Ok(WriteAuth(claims)),
489            _ => {
490                warn!(did = %claims.did, role = %claims.role, "auth rejected: application role or higher required");
491                Err(AppError::Forbidden(
492                    "application role or higher required".into(),
493                ))
494            }
495        }
496    }
497}
498
499/// Pull a named cookie value off the request `Cookie` headers.
500/// Returns `None` when the cookie isn't present. Does **not**
501/// percent-decode — cookie values minted by the VTC's admin-login
502/// flow are JWTs (base64url + dots), which are ASCII-safe.
503fn cookie_token(parts: &Parts, name: &str) -> Option<String> {
504    parts
505        .headers
506        .get_all(axum::http::header::COOKIE)
507        .iter()
508        .filter_map(|v| v.to_str().ok())
509        .flat_map(|s| s.split(';'))
510        .map(|s| s.trim())
511        .find_map(|kv| {
512            let (k, v) = kv.split_once('=')?;
513            (k == name).then(|| v.to_string())
514        })
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    #[test]
522    fn has_context_access_grants_the_subtree_to_a_parent_admin() {
523        // A context admin scoped to `acme/eng` (not super-admin — the list is
524        // non-empty), so ancestry applies.
525        let claims = AuthClaims {
526            role: Role::Admin,
527            allowed_contexts: vec!["acme/eng".into()],
528            ..Default::default()
529        };
530        assert!(!claims.is_super_admin());
531
532        // Self + every descendant.
533        assert!(claims.has_context_access("acme/eng"));
534        assert!(claims.has_context_access("acme/eng/team-a"));
535        assert!(claims.has_context_access("acme/eng/team-a/squad-1"));
536
537        // NOT the parent, a sibling, or a prefix-confusion look-alike.
538        assert!(!claims.has_context_access("acme"));
539        assert!(!claims.has_context_access("acme/ops"));
540        assert!(!claims.has_context_access("acme/engineering"));
541
542        assert!(claims.require_context("acme/eng/team-a").is_ok());
543        assert!(claims.require_context("acme/ops").is_err());
544    }
545
546    #[test]
547    fn with_delegated_contexts_widens_a_scoped_admin_for_one_call() {
548        let base = AuthClaims {
549            role: Role::Admin,
550            allowed_contexts: vec!["ctx-a".into()],
551            ..Default::default()
552        };
553        // Before: no access to the delegated context.
554        assert!(base.require_context("openvtc").is_err());
555
556        let widened = base.with_delegated_contexts(&["openvtc".into()]);
557        assert!(widened.require_context("openvtc").is_ok());
558        assert!(
559            widened.require_context("ctx-a").is_ok(),
560            "keeps its own context"
561        );
562        // The delegation is a fresh value — the caller's own claims are untouched.
563        assert!(base.require_context("openvtc").is_err());
564    }
565
566    #[test]
567    fn with_delegated_contexts_is_a_noop_for_empty_or_super_admin() {
568        let scoped = AuthClaims {
569            role: Role::Admin,
570            allowed_contexts: vec!["ctx-a".into()],
571            ..Default::default()
572        };
573        // Empty delegation changes nothing.
574        assert_eq!(
575            scoped.with_delegated_contexts(&[]).allowed_contexts,
576            scoped.allowed_contexts
577        );
578        // A super-admin (empty list = all contexts) must never be narrowed to a
579        // scoped list by a delegation.
580        let sa = AuthClaims {
581            role: Role::Admin,
582            ..Default::default()
583        };
584        assert!(sa.is_super_admin());
585        let after = sa.with_delegated_contexts(&["openvtc".into()]);
586        assert!(after.is_super_admin(), "super-admin stays unrestricted");
587        assert!(after.allowed_contexts.is_empty());
588    }
589
590    #[test]
591    fn with_delegated_authority_lifts_a_non_admin_for_one_dispatch() {
592        // Fix 2: a purely unprivileged agent (Reader, acts nowhere) executes a
593        // task an approver blessed — the grant confers both admin and context.
594        let reader = AuthClaims {
595            role: Role::Reader,
596            allowed_contexts: vec![],
597            ..Default::default()
598        };
599        assert!(reader.require_admin().is_err());
600        assert!(!reader.has_context_access("openvtc"));
601
602        let widened = reader.with_delegated_authority(&["openvtc".into()]);
603        assert!(widened.require_admin().is_ok(), "grant confers admin");
604        assert!(
605            widened.has_context_access("openvtc"),
606            "grant confers the context"
607        );
608
609        // The original is untouched — no standing elevation persists.
610        assert!(reader.require_admin().is_err());
611        assert!(!reader.has_context_access("openvtc"));
612    }
613
614    #[test]
615    fn with_delegated_authority_is_a_noop_for_empty_or_super_admin() {
616        let reader = AuthClaims {
617            role: Role::Reader,
618            allowed_contexts: vec![],
619            ..Default::default()
620        };
621        // Empty delegation changes nothing (an ordinary self-authorized execution).
622        let after = reader.with_delegated_authority(&[]);
623        assert_eq!(after.role, Role::Reader);
624        assert!(after.allowed_contexts.is_empty());
625
626        // A super-admin is already unrestricted; never narrow it to a scoped list.
627        let sa = AuthClaims {
628            role: Role::Admin,
629            ..Default::default()
630        };
631        assert!(sa.is_super_admin());
632        let after = sa.with_delegated_authority(&["openvtc".into()]);
633        assert!(after.is_super_admin(), "super-admin stays unrestricted");
634    }
635
636    #[test]
637    fn with_delegated_contexts_dedups() {
638        let base = AuthClaims {
639            role: Role::Admin,
640            allowed_contexts: vec!["ctx-a".into()],
641            ..Default::default()
642        };
643        let widened = base.with_delegated_contexts(&["ctx-a".into(), "openvtc".into()]);
644        assert_eq!(widened.allowed_contexts, vec!["ctx-a", "openvtc"]);
645    }
646
647    #[test]
648    fn flat_context_grant_is_exact_match_only() {
649        // A single-segment grant with no sub-contexts behaves exactly as before.
650        let claims = AuthClaims {
651            role: Role::Reader,
652            allowed_contexts: vec!["prod-mediator".into()],
653            ..Default::default()
654        };
655        assert!(claims.has_context_access("prod-mediator"));
656        assert!(!claims.has_context_access("prod-mediator-2"));
657        assert!(!claims.has_context_access("other"));
658    }
659
660    #[cfg(feature = "cli-synthesis")]
661    #[test]
662    fn local_cli_synthesizes_super_admin_with_channel_sentinel() {
663        let claims = AuthClaims::unsafe_local_cli_super_admin("provision-integration");
664        assert_eq!(claims.did, "cli:provision-integration");
665        assert_eq!(claims.role, Role::Admin);
666        assert!(claims.allowed_contexts.is_empty());
667        assert!(claims.is_super_admin());
668    }
669
670    #[cfg(feature = "cli-synthesis")]
671    #[test]
672    fn local_cli_grants_any_context_access() {
673        let claims = AuthClaims::unsafe_local_cli_super_admin("keys-bundle");
674        // Super-admin has access to every context — enforced elsewhere
675        // but assert it explicitly here so a future refactor that
676        // breaks the invariant gets caught.
677        assert!(claims.has_context_access("any-context"));
678        assert!(claims.has_context_access("another"));
679        claims
680            .require_context("prod-mediator")
681            .expect("super-admin passes require_context");
682    }
683
684    #[cfg(feature = "cli-synthesis")]
685    #[test]
686    fn local_cli_did_sentinel_cannot_be_confused_with_real_did() {
687        // The `cli:<channel>` format must not round-trip as a
688        // `did:*` URI — otherwise audit-log correlation would muddle
689        // CLI-synthesized claims with real caller identities.
690        let claims = AuthClaims::unsafe_local_cli_super_admin("context-reprovision");
691        assert!(!claims.did.starts_with("did:"));
692        assert!(claims.did.starts_with("cli:"));
693    }
694
695    #[cfg(feature = "cli-synthesis")]
696    #[test]
697    fn local_cli_channel_embedded_in_did() {
698        // Audit-log grep'ability: each synthesis records its `channel`
699        // distinctly so forensic investigation can attribute CLI
700        // actions to the specific code path that ran them.
701        let a = AuthClaims::unsafe_local_cli_super_admin("provision-integration");
702        let b = AuthClaims::unsafe_local_cli_super_admin("keys-bundle");
703        assert_ne!(a.did, b.did);
704    }
705}