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}