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}