Skip to main content

vti_common/acl/
mod.rs

1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5use crate::auth::extractor::AuthClaims;
6use crate::auth::step_up::StepUpMode;
7use crate::error::AppError;
8use crate::store::KeyspaceHandle;
9
10/// Roles that determine endpoint access permissions.
11///
12/// Hierarchy (most to least privileged):
13/// - **Admin** — full management access, can assign any role
14/// - **Initiator** — can manage ACL entries and application contexts
15/// - **Application** — standard API access (sign, cache write) within allowed contexts
16/// - **Reader** — read-only access to keys, contexts, DIDs within allowed contexts
17/// - **Monitor** — infrastructure-only: metrics and health endpoints
18#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)]
19#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
20#[serde(rename_all = "lowercase")]
21pub enum Role {
22    Admin,
23    Initiator,
24    Application,
25    Reader,
26    /// `Monitor` is the least-privileged role and the natural default
27    /// for a `Default::default()` `AuthClaims` (typically used in tests
28    /// or pre-authentication scaffolding). A test fixture that leaks
29    /// past its expected reach now lands on the most-restricted role
30    /// rather than the most-privileged.
31    #[default]
32    Monitor,
33}
34
35impl fmt::Display for Role {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        match self {
38            Role::Admin => write!(f, "admin"),
39            Role::Initiator => write!(f, "initiator"),
40            Role::Application => write!(f, "application"),
41            Role::Reader => write!(f, "reader"),
42            Role::Monitor => write!(f, "monitor"),
43        }
44    }
45}
46
47impl Role {
48    /// Parse a role from its string representation.
49    pub fn parse(s: &str) -> Result<Self, AppError> {
50        match s {
51            "admin" => Ok(Role::Admin),
52            "initiator" => Ok(Role::Initiator),
53            "application" => Ok(Role::Application),
54            "reader" => Ok(Role::Reader),
55            "monitor" => Ok(Role::Monitor),
56            _ => Err(AppError::Internal(format!("unknown role: {s}"))),
57        }
58    }
59}
60
61/// Consumer-kind discriminator distinguishing user-driven Companions
62/// (browser plugin, mobile app, desktop app) from headless Services
63/// (mediator, AI agent, daemon). Companion vs Service drives UX
64/// affordances and default policy posture; the variant payload narrows
65/// the form factor / service role for finer-grained policy hooks.
66///
67/// Wire form (kebab-case discriminator) matches the canonical Trust
68/// Task shared schema `device/_shared/0.1/device-binding#/$defs/ConsumerKind`.
69///
70/// `#[serde(default)]` on the AclEntry field returns `Service { Daemon }`
71/// for legacy rows that pre-date the field — a safe fallback for any
72/// existing operator-deployed mediator or daemon.
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
74#[serde(tag = "kind", rename_all = "kebab-case")]
75pub enum ConsumerKind {
76    #[serde(rename_all = "kebab-case")]
77    Companion { form_factor: CompanionFormFactor },
78    #[serde(rename_all = "camelCase")]
79    Service {
80        #[serde(rename = "serviceKind")]
81        service_kind: ServiceKind,
82    },
83}
84
85impl Default for ConsumerKind {
86    fn default() -> Self {
87        ConsumerKind::Service {
88            service_kind: ServiceKind::Daemon,
89        }
90    }
91}
92
93#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
94#[serde(rename_all = "kebab-case")]
95pub enum CompanionFormFactor {
96    Browser,
97    Mobile,
98    Desktop,
99}
100
101#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
102#[serde(rename_all = "kebab-case")]
103pub enum ServiceKind {
104    Mediator,
105    AiAgent,
106    Daemon,
107}
108
109/// Fine-grained capability flags scoped to the ACL entry's allowed
110/// contexts. Used by route handlers to gate access at finer resolution
111/// than the [`Role`] hierarchy — for example, an AI-agent Service might
112/// be granted `VaultRead` against a specific context but never
113/// `VaultWrite` or `Sign`. Wire form (kebab-case) matches the canonical
114/// `Capability` shared schema.
115///
116/// For legacy rows with no capability set, [`derived_capabilities_for_role`]
117/// produces a sensible default from the existing role (Admin gets
118/// everything, Reader gets only `vault-read`, etc.) so existing ACL
119/// behaviour is preserved bit-for-bit.
120#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
121#[serde(rename_all = "kebab-case")]
122pub enum Capability {
123    VaultRead,
124    VaultWrite,
125    ProxyLogin,
126    FillRelease,
127    PolicyAdmin,
128    DeviceAdmin,
129    Sign,
130    KeyMint,
131    /// Per-envelope Trust Task signing via `vault/sign-trust-task/0.1` —
132    /// distinct from `ProxyLogin` (which mints a session credential) and
133    /// from `Sign` (the generic signing oracle). Keeping it separate lets
134    /// operators grant proxy-login without sign-trust-task to limit
135    /// blast radius on Service consumers (AI agents, etc.).
136    SignTrustTask,
137    /// Mutating the **archival lifecycle** of a stored credential — the
138    /// `vault/credentials/{archive,unarchive,delete,restore,purge}/0.1`
139    /// tasks. Distinct from `VaultWrite` (which gates `vault/credentials/
140    /// receive` and the password-vault writes) so an operator can grant a
141    /// consumer the ability to *receive* credentials without the ability to
142    /// *remove* them — removal of a holder's credentials is a higher-trust
143    /// action. Granted to the same roles that hold `VaultWrite`.
144    CredentialWrite,
145}
146
147/// Returns true if `role` is granted `cap` by the default capability
148/// mapping. Use for capability checks against legacy ACL entries that have
149/// no explicit `capabilities` set; for entries with explicit capabilities,
150/// check the entry's set directly.
151pub fn role_has_capability(role: &Role, cap: Capability) -> bool {
152    derived_capabilities_for_role(role).contains(&cap)
153}
154
155/// Default capability set inferred from a role for entries that pre-date
156/// the explicit `capabilities` field. Keeps existing behaviour byte-identical
157/// — a pre-Phase-3 Admin still has every capability without any data
158/// migration required.
159pub fn derived_capabilities_for_role(role: &Role) -> Vec<Capability> {
160    match role {
161        Role::Admin => vec![
162            Capability::VaultRead,
163            Capability::VaultWrite,
164            Capability::CredentialWrite,
165            Capability::ProxyLogin,
166            Capability::FillRelease,
167            Capability::PolicyAdmin,
168            Capability::DeviceAdmin,
169            Capability::Sign,
170            Capability::SignTrustTask,
171            Capability::KeyMint,
172        ],
173        Role::Initiator => vec![
174            Capability::VaultRead,
175            Capability::VaultWrite,
176            Capability::CredentialWrite,
177            Capability::ProxyLogin,
178            Capability::FillRelease,
179            Capability::DeviceAdmin,
180            Capability::Sign,
181            Capability::SignTrustTask,
182            Capability::KeyMint,
183        ],
184        Role::Application => vec![
185            Capability::VaultRead,
186            Capability::ProxyLogin,
187            Capability::FillRelease,
188            Capability::Sign,
189            Capability::SignTrustTask,
190        ],
191        Role::Reader => vec![Capability::VaultRead],
192        Role::Monitor => vec![],
193    }
194}
195
196/// A device's push **wake channel** — the opaque gateway handle plus the
197/// VTA-owned trigger allowlist. Set via `device/set-wake/0.1`. Mirrors the push
198/// wake-up binding (<https://trusttasks.org/binding/push/0.1>) `WakeHandle` +
199/// `WakeTriggerPolicy`.
200///
201/// The raw platform push token is **never** stored here — it lives at the push
202/// gateway alone, behind the opaque `handle`. The VTA holds only the handle and
203/// the allowlist it provisions to the gateway.
204#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
205#[serde(rename_all = "camelCase")]
206pub struct WakeChannel {
207    /// The push gateway that issued the handle (a DID or an https URL) — where
208    /// the VTA and other triggers send a contentless wake.
209    pub gateway: String,
210    /// Opaque gateway-issued handle for this device's push channel. Reveals no
211    /// platform token; rotates when the device re-registers with the gateway.
212    pub handle: String,
213    /// DIDs the VTA has authorized to trigger a wake for this handle (the
214    /// allowlist it provisions to the gateway). Typically the device's mediator
215    /// and/or the VTA's own DID. Empty means no party may wake the device.
216    #[serde(default)]
217    pub allowed_triggers: Vec<String>,
218}
219
220/// Metadata for a registered Companion/Service device. M1 stores the field
221/// shape so the ACL row can carry it forward; the registration flow that
222/// populates it lands in M4 (`device/register/0.1`).
223///
224/// Wire form mirrors the canonical Trust Task shared schema
225/// `device/_shared/0.1/device-binding#/$defs/DeviceBinding`.
226#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
227#[serde(rename_all = "camelCase")]
228pub struct DeviceBinding {
229    pub device_id: String,
230    pub display_name: String,
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub platform: Option<String>,
233    /// RFC 3339 — when the device claimed its binding via `device/register/0.1`.
234    pub registered_at: String,
235    /// RFC 3339 — refreshed on every heartbeat / successful auth.
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub last_seen_at: Option<String>,
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub disabled_at: Option<String>,
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub wiped_at: Option<String>,
242    /// X25519 public key (`did:key` form) the maintainer HPKE-seals payloads to
243    /// (sealed secrets, session blobs, sync events). Supplied by the device at
244    /// `device/register/0.1`. `None` on legacy rows and pure ACL entries.
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub hpke_public_key: Option<String>,
247    /// Push wake channel (opaque gateway handle + VTA-owned trigger allowlist).
248    /// `None` until the device conveys a handle via `device/set-wake/0.1`;
249    /// absent on legacy rows. The push token is never stored here.
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub wake: Option<WakeChannel>,
252}
253
254impl DeviceBinding {
255    /// The non-secret `pushCapable` visibility flag (push binding §2): the
256    /// device has a usable wake channel — a handle is set and the device is
257    /// neither disabled nor wiped.
258    pub fn push_capable(&self) -> bool {
259        self.wake.is_some() && self.disabled_at.is_none() && self.wiped_at.is_none()
260    }
261}
262
263/// A DID's authority to **confer** access through an approval, re-exported
264/// from the SDK.
265///
266/// The type lives in `vta-sdk` because the DIDComm and Trust Task update
267/// bodies carry it and must be constructible by clients that never link the
268/// server crates. Only the *shape* is shared — every authorization rule over
269/// it ([`validate_approve_scope_grant`] below) stays here. Same arrangement as
270/// [`crate::context_path`].
271pub use vta_sdk::acl::{ActScope, ApproveScope};
272
273/// Decode the stored `(role, allowed_contexts)` pair into an explicit
274/// [`ActScope`].
275///
276/// This is the one and only interpretation of an empty `allowed_contexts`:
277///
278/// | role | `allowed_contexts` | scope |
279/// |---|---|---|
280/// | [`Role::Admin`] | `[]` | [`ActScope::All`] — super-admin |
281/// | any other | `[]` | [`ActScope::None`] — authorized nowhere |
282/// | any | non-empty | [`ActScope::Contexts`] |
283///
284/// Server-side rather than an inherent method on [`ActScope`], because the
285/// decode needs [`Role`], which lives here — `vta-sdk` shares the shape and the
286/// `covers` predicate, not the authorization policy over them.
287///
288/// **Call this (or one of the accessors built on it) instead of testing
289/// `allowed_contexts.is_empty()`.** That test is only correct when paired with
290/// the role, and every place that forgot the pairing has been a bug.
291pub fn act_scope_for(role: &Role, allowed_contexts: &[String]) -> ActScope {
292    match (role, allowed_contexts) {
293        (_, cs) if !cs.is_empty() => ActScope::Contexts(cs.to_vec()),
294        (Role::Admin, _) => ActScope::All,
295        _ => ActScope::None,
296    }
297}
298
299/// Validate that `caller` may grant `scope` on an ACL entry.
300///
301/// Mirrors [`validate_acl_modification`]'s context rule: `All` is a
302/// cross-context authorizer, so only a super-admin may confer it; a scoped
303/// `Contexts` grant requires the caller to administer every listed context.
304/// `None` is always allowed.
305pub fn validate_approve_scope_grant(
306    caller: &AuthClaims,
307    scope: &ApproveScope,
308) -> Result<(), AppError> {
309    match scope {
310        ApproveScope::None => Ok(()),
311        ApproveScope::All => {
312            if caller.is_super_admin() {
313                Ok(())
314            } else {
315                Err(AppError::Forbidden(
316                    "only super admin can grant approve-all authority".into(),
317                ))
318            }
319        }
320        ApproveScope::Contexts(cs) => {
321            if cs.is_empty() {
322                return Err(AppError::Forbidden(
323                    "approve scope must name at least one context (or use 'all')".into(),
324                ));
325            }
326            // `--approve-contexts ''` parses to `[""]`, which is not empty and
327            // so cleared the check above while naming no context at all.
328            for c in cs {
329                crate::context_path::validate_context_path(c)?;
330            }
331            for c in cs {
332                caller.require_context(c)?;
333            }
334            Ok(())
335        }
336    }
337}
338
339/// An entry in the Access Control List.
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct AclEntry {
342    pub did: String,
343    pub role: Role,
344    pub label: Option<String>,
345    #[serde(default)]
346    pub allowed_contexts: Vec<String>,
347    pub created_at: u64,
348    pub created_by: String,
349    /// Unix-epoch seconds at which this entry expires and should be pruned by
350    /// the background sweeper. `None` is permanent (existing pre-Phase-2
351    /// behavior; entries serialized before this field existed deserialize with
352    /// this default).
353    #[serde(default)]
354    pub expires_at: Option<u64>,
355    /// Consumer kind: Companion (user-driven) vs Service (headless). New in
356    /// M1 (vault-credential-manager design). `#[serde(default)]` ⇒ pre-M1
357    /// rows deserialise as `Service { Daemon }`.
358    #[serde(default)]
359    pub kind: ConsumerKind,
360    /// Fine-grained capability set. Empty Vec on legacy rows; the auth
361    /// layer falls back to [`derived_capabilities_for_role`] when this
362    /// is empty so existing behaviour stays byte-identical.
363    #[serde(default)]
364    pub capabilities: Vec<Capability>,
365    /// Optional Companion/Service device-binding metadata. Populated by
366    /// the M4 `device/register/0.1` flow; absent on legacy rows and on
367    /// pure ACL entries that don't represent a registered device.
368    #[serde(default)]
369    pub device: Option<DeviceBinding>,
370    /// Optimistic-concurrency version. Incremented on every
371    /// successful update; the route layer's `If-Match` header
372    /// compares against this and returns 409 Conflict on a
373    /// stale write. Closes M6 from the May 2026 security review
374    /// — two admins editing the same DID concurrently no longer
375    /// silently lose one update.
376    ///
377    /// `#[serde(default)]` so pre-versioning rows deserialise
378    /// with `version=0`. The first update bumps it to 1.
379    #[serde(default)]
380    pub version: u32,
381    /// VID authorized to ratify an AAL2 step-up for this subject — the
382    /// `recipient` the VTA addresses an `auth/step-up/approve-request/0.1` to
383    /// when a gated operation resolves to `delegated` mode (the holder's
384    /// mobile authenticator or browser companion). `None` means no delegated
385    /// approver is configured: under a `delegated` floor the operation
386    /// fail-closes (the subject can't self-approve a delegated requirement).
387    /// Mirrors the spec's `AclEntry.stepUp.approver`.
388    ///
389    /// `#[serde(default)]` so pre-existing rows deserialise as `None`.
390    #[serde(default)]
391    pub step_up_approver: Option<String>,
392    /// Per-entry step-up override raising the system floor for *this* subject —
393    /// the spec's `AclEntry.stepUp.require`. ADDITIVE-ONLY: the effective mode
394    /// is the strictest of (system floor, this override), so an override weaker
395    /// than the floor is ignored (see [`StepUpMode::strictest`]). Restricted to
396    /// `self` / `delegated` (a per-subject override never relaxes to
397    /// `delegated-any`); the ACL op layer rejects other values.
398    ///
399    /// `#[serde(default)]` so pre-existing rows deserialise as `None` (no
400    /// override; the system floor applies unchanged).
401    #[serde(default)]
402    pub step_up_require: Option<StepUpMode>,
403    /// Authority to **confer** access via an approval, decoupled from the
404    /// authority to act. Read only by the two conferral paths
405    /// (`compute_delegated_contexts`, [`delegated_any_approver_covers`]); it
406    /// never feeds `require_admin`/`has_context_access`. Lets an approver be
407    /// least-privilege (act nowhere, authorize across contexts). `#[serde(default)]`
408    /// ⇒ pre-existing rows deserialise as [`ApproveScope::None`].
409    #[serde(default)]
410    pub approve_scope: ApproveScope,
411}
412
413impl AclEntry {
414    /// Create an entry with the required identity fields. Optional metadata
415    /// takes sensible defaults: no `label`, no `allowed_contexts`, never
416    /// expires, default [`ConsumerKind`], no `capabilities`, no `device`
417    /// binding, `version = 0`, and `created_at = now`. Layer non-defaults on
418    /// with the `with_*` builder methods.
419    ///
420    /// This is the single construction entry point — adding a new optional
421    /// field here defaults it everywhere, so callers don't churn.
422    pub fn new(did: impl Into<String>, role: Role, created_by: impl Into<String>) -> Self {
423        Self {
424            did: did.into(),
425            role,
426            label: None,
427            allowed_contexts: Vec::new(),
428            created_at: crate::auth::session::now_epoch(),
429            created_by: created_by.into(),
430            expires_at: None,
431            kind: ConsumerKind::default(),
432            capabilities: Vec::new(),
433            device: None,
434            version: 0,
435            step_up_approver: None,
436            step_up_require: None,
437            approve_scope: ApproveScope::None,
438        }
439    }
440
441    /// Override `created_at` (defaults to now). Use when replaying a known
442    /// timestamp — bootstrap import, tests, migration.
443    pub fn with_created_at(mut self, created_at: u64) -> Self {
444        self.created_at = created_at;
445        self
446    }
447
448    /// Set the optional human-readable label.
449    pub fn with_label(mut self, label: Option<String>) -> Self {
450        self.label = label;
451        self
452    }
453
454    /// Set the allowed-contexts (scope) list.
455    pub fn with_contexts(mut self, allowed_contexts: Vec<String>) -> Self {
456        self.allowed_contexts = allowed_contexts;
457        self
458    }
459
460    /// Set the optional expiry (unix seconds). `None` is permanent.
461    pub fn with_expires_at(mut self, expires_at: Option<u64>) -> Self {
462        self.expires_at = expires_at;
463        self
464    }
465
466    /// Set the consumer kind (Companion vs Service).
467    pub fn with_kind(mut self, kind: ConsumerKind) -> Self {
468        self.kind = kind;
469        self
470    }
471
472    /// Set the fine-grained capability set.
473    pub fn with_capabilities(mut self, capabilities: Vec<Capability>) -> Self {
474        self.capabilities = capabilities;
475        self
476    }
477
478    /// Attach optional Companion/Service device-binding metadata.
479    pub fn with_device(mut self, device: Option<DeviceBinding>) -> Self {
480        self.device = device;
481        self
482    }
483
484    /// Set the delegated step-up approver VID (`stepUp.approver`).
485    pub fn with_step_up_approver(mut self, approver: Option<String>) -> Self {
486        self.step_up_approver = approver;
487        self
488    }
489
490    /// Set the per-entry step-up override (`stepUp.require`).
491    pub fn with_step_up_require(mut self, require: Option<StepUpMode>) -> Self {
492        self.step_up_require = require;
493        self
494    }
495
496    /// Set the approve-authority scope (what this DID may confer via approval,
497    /// without any authority to act).
498    pub fn with_approve_scope(mut self, approve_scope: ApproveScope) -> Self {
499        self.approve_scope = approve_scope;
500        self
501    }
502
503    /// Whether this entry is an admin (`Role::Admin`).
504    pub fn is_admin(&self) -> bool {
505        matches!(self.role, Role::Admin)
506    }
507
508    /// This entry's authority to **act**, decoded from `(role,
509    /// allowed_contexts)`. Use this rather than reading `allowed_contexts`
510    /// directly — see [`ActScope`] for why.
511    pub fn act_scope(&self) -> ActScope {
512        act_scope_for(&self.role, &self.allowed_contexts)
513    }
514
515    /// Whether this entry may act in `context_id`, honouring context ancestry.
516    pub fn can_act_in(&self, context_id: &str) -> bool {
517        self.act_scope().covers(context_id)
518    }
519
520    /// Whether this entry is a **super-admin**: an admin whose [`ActScope`] is
521    /// unrestricted, mirroring [`AuthClaims::is_super_admin`].
522    pub fn is_super_admin(&self) -> bool {
523        self.is_admin() && self.act_scope().is_unrestricted()
524    }
525
526    /// Set the optimistic-concurrency version (defaults to 0).
527    pub fn with_version(mut self, version: u32) -> Self {
528        self.version = version;
529        self
530    }
531
532    /// Returns true if this entry has passed its configured `expires_at`.
533    /// Permanent entries (no `expires_at`) never expire.
534    pub fn is_expired(&self, now_unix: u64) -> bool {
535        match self.expires_at {
536            Some(deadline) => now_unix >= deadline,
537            None => false,
538        }
539    }
540
541    /// Strong validator string suitable for the `ETag` response
542    /// header and the `If-Match` precondition on subsequent
543    /// updates. Combines the DID and the version so a moving
544    /// version increment can never accidentally validate against
545    /// the wrong row.
546    ///
547    /// Format: `W/"<did_hash>:<version>"` — `W/` because the
548    /// underlying ACL entry isn't byte-identical between writes
549    /// (timestamps, label edits don't change semantic content
550    /// but do change bytes); the `did_hash` is a 64-bit FxHash
551    /// to keep the header short.
552    pub fn etag(&self) -> String {
553        use std::collections::hash_map::DefaultHasher;
554        use std::hash::{Hash, Hasher};
555        let mut h = DefaultHasher::new();
556        self.did.hash(&mut h);
557        format!("W/\"{:016x}:{}\"", h.finish(), self.version)
558    }
559}
560
561fn acl_key(did: &str) -> String {
562    format!("acl:{did}")
563}
564
565/// Retrieve an ACL entry by DID.
566pub async fn get_acl_entry(acl: &KeyspaceHandle, did: &str) -> Result<Option<AclEntry>, AppError> {
567    acl.get(acl_key(did)).await
568}
569
570/// Store (create or overwrite) an ACL entry.
571///
572/// Unconditional write — no version check. Use
573/// [`update_acl_entry_versioned`] in route handlers that accept
574/// an `If-Match` precondition; this raw store is for bootstrap
575/// paths (admin import, initial seed, sweeper) where there's no
576/// concurrent-edit risk.
577pub async fn store_acl_entry(acl: &KeyspaceHandle, entry: &AclEntry) -> Result<(), AppError> {
578    acl.insert(acl_key(&entry.did), entry).await?;
579    // Re-seal the TEE integrity manifest so this ACL change is reflected in the
580    // sealed snapshot (P0.2a). No-op unless running in a TEE.
581    crate::integrity::reseal_if_active().await
582}
583
584/// Optimistic-concurrency-checked write.
585///
586/// `expected_version` is the version the caller observed on
587/// their read; the function refuses to overwrite if the stored
588/// row has moved ahead. On success the stored row's version is
589/// bumped to `expected_version + 1`.
590///
591/// Returns `Ok(new_version)` on success, `Err(AppError::Conflict)`
592/// on a stale write (the caller should re-read, re-apply their
593/// edits to the fresh row, and retry).
594///
595/// Atomicity: implemented as a read-modify-write inside a
596/// keyspace-level `swap`-style sequence. Single-process fjall
597/// serialises within the closure; cross-replica deployments rely
598/// on the underlying store's `swap` semantics.
599pub async fn update_acl_entry_versioned(
600    acl: &KeyspaceHandle,
601    mut new_entry: AclEntry,
602    expected_version: u32,
603) -> Result<u32, AppError> {
604    let key = acl_key(&new_entry.did);
605    let current: Option<AclEntry> = acl.get(key.clone()).await?;
606    let stored_version = current.as_ref().map(|e| e.version).unwrap_or(0);
607    if stored_version != expected_version {
608        return Err(AppError::Conflict(format!(
609            "ACL entry for {} has moved ahead (expected v{}, found v{}); re-read and retry",
610            new_entry.did, expected_version, stored_version,
611        )));
612    }
613    new_entry.version = expected_version + 1;
614    acl.insert(key, &new_entry).await?;
615    crate::integrity::reseal_if_active().await?; // P0.2a
616    Ok(new_entry.version)
617}
618
619/// Delete an ACL entry by DID.
620pub async fn delete_acl_entry(acl: &KeyspaceHandle, did: &str) -> Result<(), AppError> {
621    acl.remove(acl_key(did)).await?;
622    // Re-seal so the deletion is reflected in the manifest (P0.2a). No-op
623    // outside a TEE.
624    crate::integrity::reseal_if_active().await
625}
626
627/// List all ACL entries.
628///
629/// A row that fails to deserialize is **skipped with a warning**, not
630/// propagated: one corrupt entry must not take down ACL management or
631/// the auth paths that enumerate entries (a `?` here would abort the
632/// whole listing). Backup export deliberately takes the opposite stance
633/// and fails loudly — an incomplete *backup* is worse than a degraded
634/// *list*.
635pub async fn list_acl_entries(acl: &KeyspaceHandle) -> Result<Vec<AclEntry>, AppError> {
636    let raw = acl.prefix_iter_raw("acl:").await?;
637    let mut entries = Vec::with_capacity(raw.len());
638    let mut skipped = 0usize;
639    for (key, value) in raw {
640        match serde_json::from_slice::<AclEntry>(&value) {
641            Ok(entry) => entries.push(entry),
642            Err(e) => {
643                skipped += 1;
644                tracing::warn!(
645                    key = %String::from_utf8_lossy(&key),
646                    error = %e,
647                    "skipping undeserializable ACL row in list_acl_entries"
648                );
649            }
650        }
651    }
652    if skipped > 0 {
653        tracing::warn!(skipped, "list_acl_entries skipped corrupt rows");
654    }
655    Ok(entries)
656}
657
658fn now_epoch() -> u64 {
659    std::time::SystemTime::now()
660        .duration_since(std::time::UNIX_EPOCH)
661        .map(|d| d.as_secs())
662        .unwrap_or(0)
663}
664
665/// Check whether a DID is in the ACL and return its role.
666///
667/// Returns `Forbidden` if the DID is not found or if its entry has expired.
668pub async fn check_acl(acl: &KeyspaceHandle, did: &str) -> Result<Role, AppError> {
669    match get_acl_entry(acl, did).await? {
670        Some(entry) if entry.is_expired(now_epoch()) => {
671            Err(AppError::Forbidden(format!("ACL entry expired: {did}")))
672        }
673        Some(entry) => Ok(entry.role),
674        None => Err(AppError::Forbidden(format!("DID not in ACL: {did}"))),
675    }
676}
677
678/// Check whether a DID is in the ACL and return its role and allowed contexts.
679///
680/// Returns `Forbidden` under the same conditions as [`check_acl`].
681pub async fn check_acl_full(
682    acl: &KeyspaceHandle,
683    did: &str,
684) -> Result<(Role, Vec<String>), AppError> {
685    match get_acl_entry(acl, did).await? {
686        Some(entry) if entry.is_expired(now_epoch()) => {
687            Err(AppError::Forbidden(format!("ACL entry expired: {did}")))
688        }
689        Some(entry) => Ok((entry.role, entry.allowed_contexts)),
690        None => Err(AppError::Forbidden(format!("DID not in ACL: {did}"))),
691    }
692}
693
694/// Validate that the caller is allowed to assign the given role.
695///
696/// - Only Admins can assign the Admin role.
697/// - Reader, Application, and Monitor roles cannot assign any role.
698pub fn validate_role_assignment(caller: &AuthClaims, target_role: &Role) -> Result<(), AppError> {
699    if matches!(
700        caller.role,
701        Role::Monitor | Role::Reader | Role::Application
702    ) {
703        return Err(AppError::Forbidden(
704            "insufficient role to assign roles".into(),
705        ));
706    }
707    if *target_role == Role::Admin && caller.role != Role::Admin {
708        return Err(AppError::Forbidden(
709            "only admins can assign the admin role".into(),
710        ));
711    }
712    Ok(())
713}
714
715/// Validate that the caller is allowed to create or modify an ACL entry
716/// with the given `target_contexts`.
717///
718/// - Super admins can do anything.
719/// - Context admins cannot create entries with empty `allowed_contexts`
720///   (that would grant super admin access) and can only assign contexts
721///   they themselves have access to.
722pub fn validate_acl_modification(
723    caller: &AuthClaims,
724    target_role: &Role,
725    target_contexts: &[String],
726) -> Result<(), AppError> {
727    // Shape first, for every caller. `validate_context_path` existed all along
728    // but was never called on the ACL write path, so a malformed id — most
729    // reachably `""`, which is what `--contexts ''` actually parses to — was
730    // storable. Super admins reached it most easily, since their check below
731    // returns before any per-context work happens. An empty id is not an
732    // identifier and matches nothing in `covers`, so an entry holding one is
733    // inert while looking scoped.
734    for ctx in target_contexts {
735        crate::context_path::validate_context_path(ctx)?;
736    }
737    if caller.is_super_admin() {
738        return Ok(());
739    }
740    // The target's authority to *act*, decoded from its own role and contexts —
741    // which is why the role is required. Previously this function saw only the
742    // context list and refused *any* empty target, unable to tell an
743    // unrestricted grant from an acts-nowhere one. That barred a context admin
744    // from creating a least-privilege approver (acts nowhere, confers via
745    // `approve_scope`) — the very shape the CLI recommends — for no reason
746    // beyond the ambiguity.
747    match act_scope_for(target_role, target_contexts) {
748        // Unrestricted (admin + no contexts): a super-admin grant, and only a
749        // super-admin may confer it.
750        ActScope::All => Err(AppError::Forbidden(
751            "only super admin can create an unrestricted (super-admin) account".into(),
752        )),
753        // Acts nowhere (any other role + no contexts): grants no authority to
754        // act at all, so anyone who may manage the ACL may create it. Whatever
755        // *conferral* such an entry carries is a separate grant, gated
756        // independently by `validate_approve_scope_grant`, so a context admin
757        // still cannot confer a context it does not hold.
758        ActScope::None => Ok(()),
759        // Scoped: the caller must administer every named context.
760        ActScope::Contexts(cs) => {
761            for ctx in &cs {
762                caller.require_context(ctx)?;
763            }
764            Ok(())
765        }
766    }
767}
768
769/// Authorization predicate for **`delegated-any`** step-up: may `approver`
770/// ratify an AAL2 step-up for `subject`?
771///
772/// The criterion is **context-scoped admin**:
773/// - a **super-admin** approver (admin, no context restriction) may ratify for
774///   any subject — including cross-context and global subjects;
775/// - a **context-admin** approver may ratify only for a context-scoped subject
776///   **all** of whose contexts it administers (`subject.allowed_contexts ⊆
777///   approver.allowed_contexts`). A context admin can never ratify for a global
778///   (super-admin-equivalent, empty-context) subject — only a super-admin can.
779///
780/// Non-admins never qualify. Expiry is the caller's responsibility (it should
781/// skip an expired approver entry before calling this).
782pub fn delegated_any_approver_covers(approver: &AclEntry, subject: &AclEntry) -> bool {
783    // Explicit approve authority (a least-privilege approver): covers by scope,
784    // independent of role or `allowed_contexts`. `All` covers any subject; a
785    // scoped grant covers a context-scoped subject all of whose contexts fall
786    // within the scope. A global (empty-context) subject is never covered by a
787    // scoped grant — only by `All` or a super-admin.
788    match &approver.approve_scope {
789        ApproveScope::All => return true,
790        ApproveScope::Contexts(_) => {
791            // A scoped grant covers a context-scoped subject all of whose
792            // contexts fall within the scope. A global (unrestricted) or
793            // acts-nowhere subject has no `Contexts` scope to match here.
794            if let ActScope::Contexts(subject_ctxs) = subject.act_scope()
795                && subject_ctxs
796                    .iter()
797                    .all(|c| approver.approve_scope.covers(c))
798            {
799                return true;
800            }
801            // Fall through: the approver may still qualify via the admin path.
802        }
803        ApproveScope::None => {}
804    }
805
806    // Backward-compatible admin path: an admin confers what it holds.
807    if !approver.is_admin() {
808        return false;
809    }
810    match approver.act_scope() {
811        // Super-admin: covers all contexts.
812        ActScope::All => true,
813        // Context admin: the subject must itself be context-scoped, and every
814        // one of its contexts must fall within the approver's. A global subject
815        // needs a super-admin approver (handled above).
816        //
817        // Ancestry-aware, via the same `covers` the approve-scope path above
818        // uses. This was an exact `contains` until now, which made a context
819        // admin's conferral *narrower* than an equivalent explicit
820        // `ApproveScope` grant and contradicted the stated ACL-gate rule —
821        // "any `allowed_contexts` entry `is_ancestor_or_self` of the target"
822        // (`docs/05-design-notes/hierarchical-contexts.md`). The helper landed
823        // five days before this function was written and simply was not reached
824        // for. While contexts stay flat the two are identical; they diverge
825        // only once sub-contexts exist, which is exactly the case the hierarchy
826        // work intended to cover.
827        approver_scope @ ActScope::Contexts(_) => match subject.act_scope() {
828            ActScope::Contexts(subject_ctxs) => {
829                subject_ctxs.iter().all(|c| approver_scope.covers(c))
830            }
831            _ => false,
832        },
833        // Unreachable: an admin is never acts-nowhere. Fail closed regardless.
834        ActScope::None => false,
835    }
836}
837
838/// Whether `entry` holds authority to act in `context_id` — the predicate
839/// behind every "which entries are relevant to this context?" filter.
840///
841/// One function because the two `acl list --context` implementations answered
842/// this differently, and both were wrong in a different direction. The offline
843/// CLI matched `allowed_contexts.is_empty() || contains(ctx)`, so an
844/// **empty list matched every context**; the online operation matched
845/// `contains(ctx)`, so an **empty list matched none**. Same command, opposite
846/// answers, and neither is the truth: an empty list means unrestricted for
847/// [`Role::Admin`] (a super-admin does hold every context) and *nothing at all*
848/// for every other role (which therefore holds none).
849///
850/// Ancestry is segment-aware, matching [`AuthClaims::has_context_access`] and
851/// the VTC's equivalent filter: an entry scoped to a parent context does grant
852/// its subtree, so it genuinely carries a child id. Both VTA filters compared
853/// with `contains`, so neither surfaced it.
854pub fn acl_entry_can_act_in(entry: &AclEntry, context_id: &str) -> bool {
855    entry.act_scope().covers(context_id)
856}
857
858/// Whether an ACL entry is within the caller's authority to **manage**.
859///
860/// Super admins may manage any entry. A context admin may manage an entry only
861/// if the entry *acts* in a context the caller holds — so neither an
862/// unrestricted ([`ActScope::All`]) nor an acts-nowhere ([`ActScope::None`])
863/// entry is manageable by a context admin, neither naming a context to overlap.
864///
865/// This is the predicate the **mutation** paths gate on (update, delete). It
866/// deliberately ignores [`ApproveScope`]: an entry may confer into your context
867/// while acting entirely inside someone else's, and being able to *see* such an
868/// entry must not become authority to delete it. For the read paths, see
869/// [`is_acl_entry_auditable`].
870pub fn is_acl_entry_visible(caller: &AuthClaims, entry: &AclEntry) -> bool {
871    if caller.is_super_admin() {
872        return true;
873    }
874    entry
875        .act_scope()
876        .named_contexts()
877        .iter()
878        .any(|ctx| caller.has_context_access(ctx))
879}
880
881/// Whether an ACL entry should be **readable** by the caller — the superset of
882/// [`is_acl_entry_visible`] used by `acl list` / `acl get`.
883///
884/// Adds entries holding *conferral* authority over a context the caller
885/// administers. A least-privilege approver acts nowhere, so it names no context
886/// on the act axis and was invisible to the very admins whose contexts it can
887/// confer: an operator asking "who can authorize a change in my context?" could
888/// not see the answer. Conferral is authority, and authority in your context
889/// should be auditable by its admin.
890///
891/// **Read-only by construction.** Widening what an admin may see is not
892/// widening what they may do, and keeping this separate from
893/// [`is_acl_entry_visible`] is what stops it from becoming that — an entry can
894/// administer someone else's context while conferring into yours, and a single
895/// merged predicate would have made that entry deletable by you.
896///
897/// The act axis is unchanged: an unrestricted (super-admin) entry still does
898/// not surface to a context admin merely by being unrestricted.
899pub fn is_acl_entry_auditable(caller: &AuthClaims, entry: &AclEntry) -> bool {
900    if is_acl_entry_visible(caller, entry) {
901        return true;
902    }
903    match &entry.approve_scope {
904        // Confers everywhere, therefore into the caller's contexts too.
905        ApproveScope::All => true,
906        ApproveScope::Contexts(cs) => cs.iter().any(|ctx| caller.has_context_access(ctx)),
907        ApproveScope::None => false,
908    }
909}
910
911#[cfg(test)]
912mod delegated_any_tests {
913    use super::*;
914
915    fn admin(contexts: &[&str]) -> AclEntry {
916        AclEntry::new("did:key:zApprover", Role::Admin, "did:key:zCreator")
917            .with_contexts(contexts.iter().map(|s| s.to_string()).collect())
918    }
919    fn subject(role: Role, contexts: &[&str]) -> AclEntry {
920        AclEntry::new("did:key:zSubject", role, "did:key:zCreator")
921            .with_contexts(contexts.iter().map(|s| s.to_string()).collect())
922    }
923
924    /// A context admin of a *parent* may ratify for a subject scoped to its
925    /// subtree. This was refused until now — the admin path used exact
926    /// membership while the approve-scope path beside it used ancestry, making
927    /// admin standing narrower than an equivalent explicit `ApproveScope`
928    /// grant and contradicting the ACL-gate rule in
929    /// `docs/05-design-notes/hierarchical-contexts.md`.
930    #[test]
931    fn context_admin_covers_a_subject_in_its_subtree() {
932        let approver = admin(&["acme"]);
933        assert!(
934            delegated_any_approver_covers(&approver, &subject(Role::Reader, &["acme/eng"])),
935            "an admin of `acme` administers `acme/eng`, so it may ratify for it"
936        );
937        assert!(
938            delegated_any_approver_covers(
939                &approver,
940                &subject(Role::Reader, &["acme/eng", "acme/sales"])
941            ),
942            "…and for a subject scoped to several of its descendants"
943        );
944    }
945
946    /// The segment-aware guard still holds: a sibling with a shared string
947    /// prefix is not a descendant.
948    #[test]
949    fn context_admin_does_not_cover_a_prefix_sibling() {
950        let approver = admin(&["acme"]);
951        assert!(
952            !delegated_any_approver_covers(&approver, &subject(Role::Reader, &["acme-evil"])),
953            "`acme` must not cover `acme-evil` — segments, not string prefixes"
954        );
955    }
956
957    /// A subject holding *any* context outside the approver's subtree is still
958    /// refused — widening to ancestry must not become "covers if it covers one".
959    #[test]
960    fn context_admin_refuses_a_subject_partly_outside_its_subtree() {
961        let approver = admin(&["acme"]);
962        assert!(
963            !delegated_any_approver_covers(
964                &approver,
965                &subject(Role::Reader, &["acme/eng", "other"])
966            ),
967            "every subject context must fall within the approver's"
968        );
969    }
970
971    /// Flat contexts behave exactly as before, which is why this is safe to
972    /// change now: the two readings only diverge once sub-contexts exist.
973    #[test]
974    fn flat_contexts_are_unchanged_by_ancestry() {
975        let approver = admin(&["ctx-a"]);
976        assert!(delegated_any_approver_covers(
977            &approver,
978            &subject(Role::Reader, &["ctx-a"])
979        ));
980        assert!(!delegated_any_approver_covers(
981            &approver,
982            &subject(Role::Reader, &["ctx-b"])
983        ));
984    }
985
986    #[test]
987    fn super_admin_covers_any_subject() {
988        let sa = admin(&[]); // empty contexts ⇒ super-admin
989        assert!(delegated_any_approver_covers(
990            &sa,
991            &subject(Role::Admin, &["ctx-a"])
992        ));
993        assert!(delegated_any_approver_covers(
994            &sa,
995            &subject(Role::Reader, &[])
996        )); // global subject
997        assert!(delegated_any_approver_covers(
998            &sa,
999            &subject(Role::Application, &["ctx-a", "ctx-b"])
1000        ));
1001    }
1002
1003    #[test]
1004    fn context_admin_covers_only_within_its_contexts() {
1005        let ca = admin(&["ctx-a", "ctx-b"]);
1006        // Subject fully within → covered.
1007        assert!(delegated_any_approver_covers(
1008            &ca,
1009            &subject(Role::Reader, &["ctx-a"])
1010        ));
1011        assert!(delegated_any_approver_covers(
1012            &ca,
1013            &subject(Role::Reader, &["ctx-a", "ctx-b"])
1014        ));
1015        // Subject in a context the admin doesn't administer → NOT covered.
1016        assert!(!delegated_any_approver_covers(
1017            &ca,
1018            &subject(Role::Reader, &["ctx-c"])
1019        ));
1020        assert!(!delegated_any_approver_covers(
1021            &ca,
1022            &subject(Role::Reader, &["ctx-a", "ctx-c"])
1023        ));
1024    }
1025
1026    #[test]
1027    fn context_admin_never_covers_a_global_subject() {
1028        // A global (empty-context, super-admin-equivalent) subject needs a
1029        // super-admin approver; a context admin must never ratify for it.
1030        let ca = admin(&["ctx-a"]);
1031        assert!(!delegated_any_approver_covers(
1032            &ca,
1033            &subject(Role::Admin, &[])
1034        ));
1035    }
1036
1037    #[test]
1038    fn non_admins_never_qualify() {
1039        for role in [
1040            Role::Initiator,
1041            Role::Application,
1042            Role::Reader,
1043            Role::Monitor,
1044        ] {
1045            let not_admin = AclEntry::new("did:key:zX", role, "did:key:zC");
1046            assert!(!delegated_any_approver_covers(
1047                &not_admin,
1048                &subject(Role::Reader, &["ctx-a"])
1049            ));
1050        }
1051    }
1052
1053    // ── ApproveScope: a least-privilege approver confers without acting ──
1054
1055    /// A Reader with no contexts and no admin — it can *act* nowhere. Its only
1056    /// authority is the approve scope layered on top.
1057    fn pure_approver(scope: ApproveScope) -> AclEntry {
1058        AclEntry::new("did:key:zApprover", Role::Reader, "did:key:zCreator")
1059            .with_approve_scope(scope)
1060    }
1061
1062    #[test]
1063    fn approve_scope_covers_semantics() {
1064        assert!(!ApproveScope::None.covers("ctx-a"));
1065        assert!(ApproveScope::All.covers("anything"));
1066        let scoped = ApproveScope::Contexts(vec!["ctx-a".into()]);
1067        assert!(scoped.covers("ctx-a"));
1068        assert!(!scoped.covers("ctx-b"));
1069    }
1070
1071    #[test]
1072    fn approve_all_confers_for_any_subject_without_any_admin_authority() {
1073        // The whole point: an approver that holds no admin (acts nowhere) may
1074        // still ratify across contexts, including a global subject.
1075        let approver = pure_approver(ApproveScope::All);
1076        assert!(
1077            !approver.is_admin(),
1078            "the approver holds no admin authority"
1079        );
1080        assert!(delegated_any_approver_covers(
1081            &approver,
1082            &subject(Role::Reader, &["ctx-a"])
1083        ));
1084        assert!(delegated_any_approver_covers(
1085            &approver,
1086            &subject(Role::Admin, &[])
1087        ));
1088    }
1089
1090    #[test]
1091    fn scoped_approve_authority_covers_only_within_scope() {
1092        let approver = pure_approver(ApproveScope::Contexts(vec!["ctx-a".into()]));
1093        assert!(delegated_any_approver_covers(
1094            &approver,
1095            &subject(Role::Reader, &["ctx-a"])
1096        ));
1097        assert!(!delegated_any_approver_covers(
1098            &approver,
1099            &subject(Role::Reader, &["ctx-b"])
1100        ));
1101        // A global subject needs `All` (or a super-admin), never a scoped grant.
1102        assert!(!delegated_any_approver_covers(
1103            &approver,
1104            &subject(Role::Admin, &[])
1105        ));
1106    }
1107
1108    #[test]
1109    fn no_approve_scope_and_no_admin_confers_nothing() {
1110        let reader = pure_approver(ApproveScope::None);
1111        assert!(!delegated_any_approver_covers(
1112            &reader,
1113            &subject(Role::Reader, &["ctx-a"])
1114        ));
1115    }
1116}
1117
1118#[cfg(test)]
1119mod tests {
1120    use super::*;
1121    use crate::config::StoreConfig;
1122    use crate::store::Store;
1123
1124    // ── Test fixtures ───────────────────────────────────────────────
1125
1126    fn temp_store() -> (Store, tempfile::TempDir) {
1127        let dir = tempfile::tempdir().expect("tempdir");
1128        let config = StoreConfig {
1129            data_dir: dir.path().to_path_buf(),
1130        };
1131        let store = Store::open(&config).expect("open store");
1132        (store, dir)
1133    }
1134
1135    fn sample_entry(did: &str, role: Role) -> AclEntry {
1136        AclEntry::new(did, role, "did:key:zSetup").with_label(Some(format!("test-{did}")))
1137    }
1138
1139    #[tokio::test]
1140    async fn list_acl_entries_skips_corrupt_rows() {
1141        // A single undeserializable row must not abort the whole listing —
1142        // otherwise one corrupt entry bricks ACL management and the auth
1143        // paths that enumerate entries.
1144        let (store, _dir) = temp_store();
1145        let ks = store.keyspace("acl").unwrap();
1146
1147        store_acl_entry(&ks, &sample_entry("did:key:zAlice", Role::Admin))
1148            .await
1149            .unwrap();
1150        // Inject garbage under the acl: prefix.
1151        ks.insert_raw("acl:did:key:zCorrupt", b"{not valid json".to_vec())
1152            .await
1153            .unwrap();
1154        store_acl_entry(&ks, &sample_entry("did:key:zBob", Role::Reader))
1155            .await
1156            .unwrap();
1157
1158        let entries = list_acl_entries(&ks).await.expect("listing must not abort");
1159        let dids: Vec<&str> = entries.iter().map(|e| e.did.as_str()).collect();
1160        assert!(dids.contains(&"did:key:zAlice"));
1161        assert!(dids.contains(&"did:key:zBob"));
1162        assert_eq!(entries.len(), 2, "the corrupt row is skipped, not surfaced");
1163    }
1164
1165    fn scoped_entry(did: &str, role: Role, contexts: &[&str]) -> AclEntry {
1166        AclEntry::new(did, role, "did:key:zSetup")
1167            .with_contexts(contexts.iter().map(|s| s.to_string()).collect())
1168    }
1169
1170    fn super_admin_claims() -> AuthClaims {
1171        AuthClaims {
1172            did: "did:key:zSuperAdmin".into(),
1173            role: Role::Admin,
1174            allowed_contexts: vec![],
1175            session_id: "test-session".into(),
1176            access_expires_at: 0,
1177            amr: Vec::new(),
1178            acr: String::new(),
1179        }
1180    }
1181
1182    fn context_admin_claims(contexts: &[&str]) -> AuthClaims {
1183        AuthClaims {
1184            did: "did:key:zCtxAdmin".into(),
1185            role: Role::Admin,
1186            allowed_contexts: contexts.iter().map(|s| s.to_string()).collect(),
1187            session_id: "test-session".into(),
1188            access_expires_at: 0,
1189            amr: Vec::new(),
1190            acr: String::new(),
1191        }
1192    }
1193
1194    // ── Role parsing ────────────────────────────────────────────────
1195
1196    #[test]
1197    fn role_parse_accepts_canonical_lowercase() {
1198        assert_eq!(Role::parse("admin").unwrap(), Role::Admin);
1199        assert_eq!(Role::parse("initiator").unwrap(), Role::Initiator);
1200        assert_eq!(Role::parse("application").unwrap(), Role::Application);
1201        assert_eq!(Role::parse("reader").unwrap(), Role::Reader);
1202        assert_eq!(Role::parse("monitor").unwrap(), Role::Monitor);
1203    }
1204
1205    #[test]
1206    fn role_parse_rejects_unknown() {
1207        let err = Role::parse("godmode").expect_err("unknown role must error");
1208        assert!(format!("{err:?}").contains("godmode"), "got {err:?}");
1209    }
1210
1211    // ── DeviceBinding wake channel (push wake-up binding) ───────────────
1212
1213    fn sample_binding() -> DeviceBinding {
1214        DeviceBinding {
1215            device_id: "dev-1".into(),
1216            display_name: "Glenn's iPhone".into(),
1217            platform: Some("iOS 19".into()),
1218            registered_at: "2026-06-02T00:00:00Z".into(),
1219            last_seen_at: None,
1220            disabled_at: None,
1221            wiped_at: None,
1222            hpke_public_key: None,
1223            wake: None,
1224        }
1225    }
1226
1227    #[test]
1228    fn wake_channel_round_trips_camel_case() {
1229        let mut b = sample_binding();
1230        b.wake = Some(WakeChannel {
1231            gateway: "https://gw.example".into(),
1232            handle: "z6MkOpaque".into(),
1233            allowed_triggers: vec!["did:web:mediator".into(), "did:web:vta".into()],
1234        });
1235        let json = serde_json::to_string(&b).unwrap();
1236        // Wire is camelCase, mirroring the spec shapes.
1237        assert!(json.contains("\"wake\""), "{json}");
1238        assert!(json.contains("\"allowedTriggers\""), "{json}");
1239        let back: DeviceBinding = serde_json::from_str(&json).unwrap();
1240        assert_eq!(b, back);
1241    }
1242
1243    #[test]
1244    fn legacy_row_without_wake_deserialises_to_none() {
1245        // A binding serialised before the wake field existed.
1246        let legacy =
1247            r#"{"deviceId":"dev-1","displayName":"old","registeredAt":"2026-01-01T00:00:00Z"}"#;
1248        let b: DeviceBinding = serde_json::from_str(legacy).unwrap();
1249        assert!(b.wake.is_none());
1250        assert!(!b.push_capable());
1251    }
1252
1253    #[test]
1254    fn push_capable_requires_wake_and_active_device() {
1255        let mut b = sample_binding();
1256        assert!(!b.push_capable(), "no wake channel → not push-capable");
1257
1258        b.wake = Some(WakeChannel {
1259            gateway: "did:web:gw".into(),
1260            handle: "h".into(),
1261            allowed_triggers: vec!["did:web:vta".into()],
1262        });
1263        assert!(b.push_capable(), "wake set + active → push-capable");
1264
1265        b.disabled_at = Some("2026-06-02T01:00:00Z".into());
1266        assert!(!b.push_capable(), "disabled device is not push-capable");
1267
1268        b.disabled_at = None;
1269        b.wiped_at = Some("2026-06-02T02:00:00Z".into());
1270        assert!(!b.push_capable(), "wiped device is not push-capable");
1271    }
1272
1273    #[test]
1274    fn role_parse_rejects_case_variation() {
1275        // Serde rename_all="lowercase" means Admin != Admin on the wire.
1276        // parse() mirrors that contract.
1277        assert!(Role::parse("Admin").is_err(), "case-sensitive parse");
1278        assert!(Role::parse("ADMIN").is_err());
1279    }
1280
1281    #[test]
1282    fn role_display_round_trips_with_parse() {
1283        for role in [
1284            Role::Admin,
1285            Role::Initiator,
1286            Role::Application,
1287            Role::Reader,
1288            Role::Monitor,
1289        ] {
1290            let s = format!("{role}");
1291            assert_eq!(Role::parse(&s).unwrap(), role, "display->parse cycle");
1292        }
1293    }
1294
1295    // ── Expiration ──────────────────────────────────────────────────
1296
1297    #[test]
1298    fn entry_without_expiry_never_expires() {
1299        let entry = sample_entry("did:key:zA", Role::Admin);
1300        assert!(entry.expires_at.is_none());
1301        assert!(
1302            !entry.is_expired(u64::MAX),
1303            "permanent entries never expire"
1304        );
1305    }
1306
1307    #[test]
1308    fn entry_with_future_expiry_is_not_expired() {
1309        let mut entry = sample_entry("did:key:zA", Role::Admin);
1310        entry.expires_at = Some(now_epoch() + 3600);
1311        assert!(!entry.is_expired(now_epoch()));
1312    }
1313
1314    #[test]
1315    fn entry_with_past_expiry_is_expired() {
1316        let mut entry = sample_entry("did:key:zA", Role::Admin);
1317        entry.expires_at = Some(now_epoch().saturating_sub(1));
1318        assert!(entry.is_expired(now_epoch()));
1319    }
1320
1321    #[test]
1322    fn entry_with_exact_expiry_boundary_is_expired() {
1323        // Guard choice: `now >= deadline` is expired. The boundary at
1324        // equal seconds counts as past — callers don't get a free
1325        // extra second of access.
1326        let mut entry = sample_entry("did:key:zA", Role::Admin);
1327        let now = now_epoch();
1328        entry.expires_at = Some(now);
1329        assert!(entry.is_expired(now), "now == deadline counts as expired");
1330    }
1331
1332    // ── Store CRUD ──────────────────────────────────────────────────
1333
1334    #[tokio::test]
1335    async fn crud_round_trip() {
1336        let (store, _dir) = temp_store();
1337        let acl = store.keyspace("acl").unwrap();
1338
1339        let entry = sample_entry("did:key:zAbc", Role::Admin);
1340        store_acl_entry(&acl, &entry).await.unwrap();
1341
1342        let got = get_acl_entry(&acl, "did:key:zAbc")
1343            .await
1344            .unwrap()
1345            .expect("entry should exist");
1346        assert_eq!(got.did, entry.did);
1347        assert_eq!(got.role, Role::Admin);
1348
1349        delete_acl_entry(&acl, "did:key:zAbc").await.unwrap();
1350        let gone = get_acl_entry(&acl, "did:key:zAbc").await.unwrap();
1351        assert!(gone.is_none(), "deleted entry must be gone");
1352    }
1353
1354    #[tokio::test]
1355    async fn list_returns_every_entry() {
1356        let (store, _dir) = temp_store();
1357        let acl = store.keyspace("acl").unwrap();
1358
1359        for did in ["did:key:zA", "did:key:zB", "did:key:zC"] {
1360            store_acl_entry(&acl, &sample_entry(did, Role::Reader))
1361                .await
1362                .unwrap();
1363        }
1364
1365        let entries = list_acl_entries(&acl).await.unwrap();
1366        assert_eq!(entries.len(), 3);
1367        let dids: std::collections::HashSet<_> = entries.iter().map(|e| e.did.as_str()).collect();
1368        assert!(dids.contains("did:key:zA"));
1369        assert!(dids.contains("did:key:zB"));
1370        assert!(dids.contains("did:key:zC"));
1371    }
1372
1373    // ── check_acl ───────────────────────────────────────────────────
1374
1375    #[tokio::test]
1376    async fn check_acl_returns_role_for_present_did() {
1377        let (store, _dir) = temp_store();
1378        let acl = store.keyspace("acl").unwrap();
1379        store_acl_entry(&acl, &sample_entry("did:key:zA", Role::Initiator))
1380            .await
1381            .unwrap();
1382
1383        let role = check_acl(&acl, "did:key:zA").await.unwrap();
1384        assert_eq!(role, Role::Initiator);
1385    }
1386
1387    #[tokio::test]
1388    async fn check_acl_rejects_missing_did_as_forbidden() {
1389        let (store, _dir) = temp_store();
1390        let acl = store.keyspace("acl").unwrap();
1391
1392        let err = check_acl(&acl, "did:key:zUnknown")
1393            .await
1394            .expect_err("missing DID must be rejected");
1395        assert!(
1396            matches!(err, AppError::Forbidden(_)),
1397            "got {err:?}; expected Forbidden so the handler emits 403"
1398        );
1399    }
1400
1401    #[tokio::test]
1402    async fn check_acl_rejects_expired_entry() {
1403        let (store, _dir) = temp_store();
1404        let acl = store.keyspace("acl").unwrap();
1405
1406        let mut entry = sample_entry("did:key:zExpired", Role::Admin);
1407        entry.expires_at = Some(now_epoch().saturating_sub(10));
1408        store_acl_entry(&acl, &entry).await.unwrap();
1409
1410        let err = check_acl(&acl, "did:key:zExpired")
1411            .await
1412            .expect_err("expired entry must be rejected");
1413        let msg = format!("{err:?}");
1414        assert!(
1415            matches!(err, AppError::Forbidden(_)) && msg.contains("expired"),
1416            "got {err:?}"
1417        );
1418    }
1419
1420    #[tokio::test]
1421    async fn check_acl_full_returns_role_and_contexts() {
1422        let (store, _dir) = temp_store();
1423        let acl = store.keyspace("acl").unwrap();
1424        store_acl_entry(
1425            &acl,
1426            &scoped_entry("did:key:zCtx", Role::Admin, &["ctx1", "ctx2"]),
1427        )
1428        .await
1429        .unwrap();
1430
1431        let (role, contexts) = check_acl_full(&acl, "did:key:zCtx").await.unwrap();
1432        assert_eq!(role, Role::Admin);
1433        assert_eq!(contexts, vec!["ctx1".to_string(), "ctx2".to_string()]);
1434    }
1435
1436    // ── validate_role_assignment ────────────────────────────────────
1437
1438    #[test]
1439    fn role_assignment_super_admin_can_assign_admin() {
1440        validate_role_assignment(&super_admin_claims(), &Role::Admin)
1441            .expect("super admin assigns admin");
1442    }
1443
1444    #[test]
1445    fn role_assignment_context_admin_can_assign_admin_role_itself() {
1446        // A context admin (Role::Admin with non-empty allowed_contexts)
1447        // passes validate_role_assignment for the Admin role — the
1448        // role-level check only gates `caller.role != Role::Admin`.
1449        // The actual escape-prevention is in validate_acl_modification,
1450        // which confines the new entry to the caller's own contexts.
1451        validate_role_assignment(&context_admin_claims(&["ctx1"]), &Role::Admin)
1452            .expect("context admin CAN assign Admin role; scope is enforced separately");
1453    }
1454
1455    #[test]
1456    fn role_assignment_non_admin_cannot_assign_admin() {
1457        // Initiator, Reader, Application, Monitor cannot mint admins
1458        // regardless of scope. Only callers with Role::Admin can
1459        // assign Role::Admin.
1460        let initiator = AuthClaims {
1461            did: "did:key:zIni".into(),
1462            role: Role::Initiator,
1463            allowed_contexts: vec!["ctx1".into()],
1464            session_id: "test-session".into(),
1465            access_expires_at: 0,
1466            amr: Vec::new(),
1467            acr: String::new(),
1468        };
1469        let err = validate_role_assignment(&initiator, &Role::Admin)
1470            .expect_err("non-admin must not assign admin");
1471        assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
1472    }
1473
1474    #[test]
1475    fn role_assignment_readers_cannot_assign_any_role() {
1476        let reader = AuthClaims {
1477            did: "did:key:zReader".into(),
1478            role: Role::Reader,
1479            allowed_contexts: vec!["ctx1".into()],
1480            session_id: "test-session".into(),
1481            access_expires_at: 0,
1482            amr: Vec::new(),
1483            acr: String::new(),
1484        };
1485        for target in [
1486            Role::Admin,
1487            Role::Initiator,
1488            Role::Application,
1489            Role::Reader,
1490            Role::Monitor,
1491        ] {
1492            let err = validate_role_assignment(&reader, &target)
1493                .expect_err(&format!("reader must not assign {target}"));
1494            assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
1495        }
1496    }
1497
1498    #[test]
1499    fn role_assignment_initiator_can_assign_non_admin_roles() {
1500        let initiator = AuthClaims {
1501            did: "did:key:zIni".into(),
1502            role: Role::Initiator,
1503            allowed_contexts: vec!["ctx1".into()],
1504            session_id: "test-session".into(),
1505            access_expires_at: 0,
1506            amr: Vec::new(),
1507            acr: String::new(),
1508        };
1509        validate_role_assignment(&initiator, &Role::Reader).expect("initiator can assign reader");
1510        validate_role_assignment(&initiator, &Role::Application)
1511            .expect("initiator can assign application");
1512    }
1513
1514    // ── validate_acl_modification ───────────────────────────────────
1515
1516    #[test]
1517    fn acl_modification_super_admin_can_create_anything() {
1518        // admin + no contexts = super-admin (unrestricted)
1519        validate_acl_modification(&super_admin_claims(), &Role::Admin, &[])
1520            .expect("super admin unrestricted");
1521        validate_acl_modification(&super_admin_claims(), &Role::Reader, &["any-ctx".into()])
1522            .expect("super admin any-context");
1523    }
1524
1525    #[test]
1526    fn acl_modification_context_admin_cannot_create_unrestricted() {
1527        // admin + no contexts decodes to `All` — a super-admin grant — and
1528        // only a super-admin may confer it.
1529        let err = validate_acl_modification(&context_admin_claims(&["ctx1"]), &Role::Admin, &[])
1530            .expect_err("context admin must not create an unrestricted entry");
1531        assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
1532    }
1533
1534    /// The widening: a *non-admin* with no contexts decodes to `None` — acts
1535    /// nowhere, grants no authority to act — so a context admin may create it.
1536    /// This is how a least-privilege approver is minted; its conferral is gated
1537    /// separately by `validate_approve_scope_grant`.
1538    #[test]
1539    fn acl_modification_context_admin_can_create_acts_nowhere() {
1540        for role in [
1541            Role::Reader,
1542            Role::Application,
1543            Role::Initiator,
1544            Role::Monitor,
1545        ] {
1546            validate_acl_modification(&context_admin_claims(&["ctx1"]), &role, &[]).unwrap_or_else(
1547                |e| panic!("context admin must be able to create an acts-nowhere {role:?}: {e:?}"),
1548            );
1549        }
1550    }
1551
1552    #[test]
1553    fn acl_modification_context_admin_confined_to_own_contexts() {
1554        let caller = context_admin_claims(&["ctx1", "ctx2"]);
1555        validate_acl_modification(&caller, &Role::Reader, &["ctx1".into()])
1556            .expect("own context ok");
1557        validate_acl_modification(&caller, &Role::Reader, &["ctx1".into(), "ctx2".into()])
1558            .expect("all-own contexts ok");
1559
1560        let err = validate_acl_modification(&caller, &Role::Reader, &["ctx3".into()])
1561            .expect_err("foreign context must be rejected");
1562        assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
1563
1564        let err =
1565            validate_acl_modification(&caller, &Role::Reader, &["ctx1".into(), "ctx3".into()])
1566                .expect_err("mixed own+foreign must be rejected");
1567        assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
1568    }
1569
1570    // ── acl_entry_can_act_in ────────────────────────────────────────
1571
1572    /// A super-admin holds every context, so a context filter must surface it.
1573    /// The online `list_acl` used a bare `contains()` and omitted these — an
1574    /// operator auditing "who can reach context X" never saw the entries with
1575    /// the most authority over it.
1576    #[test]
1577    fn can_act_in_includes_super_admins() {
1578        let super_admin = sample_entry("did:key:zSuper", Role::Admin);
1579        assert!(acl_entry_can_act_in(&super_admin, "anything"));
1580    }
1581
1582    /// An acts-nowhere entry holds none. The offline `vta acl list` matched
1583    /// `is_empty() || contains()` and surfaced these under *every* context.
1584    #[test]
1585    fn can_act_in_excludes_acts_nowhere_entries() {
1586        for role in [Role::Reader, Role::Application, Role::Initiator] {
1587            let entry = sample_entry("did:key:zNowhere", role.clone());
1588            assert!(
1589                !acl_entry_can_act_in(&entry, "anything"),
1590                "{role:?} with no contexts acts nowhere"
1591            );
1592        }
1593    }
1594
1595    /// Hierarchy-aware, matching `has_context_access` and the VTC's filter.
1596    /// Both VTA filters used `contains`, so neither surfaced a parent-scoped
1597    /// entry under a child id.
1598    #[test]
1599    fn can_act_in_covers_the_subtree() {
1600        let entry = scoped_entry("did:key:zParent", Role::Reader, &["parent"]);
1601        assert!(acl_entry_can_act_in(&entry, "parent"), "self");
1602        assert!(acl_entry_can_act_in(&entry, "parent/child"), "subtree");
1603        assert!(!acl_entry_can_act_in(&entry, "other"), "unrelated");
1604        assert!(
1605            !acl_entry_can_act_in(&entry, "parentless"),
1606            "a string prefix is not an ancestor"
1607        );
1608    }
1609
1610    /// The two implementations this replaces disagreed on exactly one input
1611    /// class — an empty `allowed_contexts` — and the disagreement was total:
1612    /// one matched every context, the other none. Pin both halves together so
1613    /// a future edit cannot reintroduce either reading.
1614    #[test]
1615    fn can_act_in_resolves_the_offline_online_disagreement() {
1616        let ctx = "openvtc";
1617        // Offline said yes to this, online said no. Correct answer: yes.
1618        assert!(acl_entry_can_act_in(
1619            &sample_entry("did:key:zA", Role::Admin),
1620            ctx
1621        ));
1622        // Offline said yes to this, online said no. Correct answer: no.
1623        assert!(!acl_entry_can_act_in(
1624            &sample_entry("did:key:zB", Role::Reader),
1625            ctx
1626        ));
1627    }
1628
1629    // ── is_acl_entry_auditable ──────────────────────────────────────
1630
1631    /// The audit gap this closes: a least-privilege approver acts nowhere, so
1632    /// it names no context on the act axis and never overlapped a context
1633    /// admin's scope — leaving that admin unable to see who could authorize a
1634    /// change in their own context.
1635    #[test]
1636    fn auditable_surfaces_an_approver_scoped_to_the_callers_context() {
1637        let caller = context_admin_claims(&["ctx1"]);
1638        let approver = AclEntry::new("did:key:zApprover", Role::Reader, "did:key:zSetup")
1639            .with_approve_scope(ApproveScope::Contexts(vec!["ctx1".into()]));
1640
1641        assert!(
1642            !is_acl_entry_visible(&caller, &approver),
1643            "acts nowhere, so it is not the caller's to manage"
1644        );
1645        assert!(
1646            is_acl_entry_auditable(&caller, &approver),
1647            "but it confers in ctx1, so ctx1's admin must be able to see it"
1648        );
1649    }
1650
1651    /// An `All` approver confers into every context, the caller's included.
1652    #[test]
1653    fn auditable_surfaces_an_approve_all_holder() {
1654        let caller = context_admin_claims(&["ctx1"]);
1655        let approver = AclEntry::new("did:key:zGlobal", Role::Reader, "did:key:zSetup")
1656            .with_approve_scope(ApproveScope::All);
1657        assert!(is_acl_entry_auditable(&caller, &approver));
1658    }
1659
1660    /// Conferral is subtree-aware, matching `ApproveScope::covers`.
1661    #[test]
1662    fn auditable_follows_context_ancestry() {
1663        let caller = context_admin_claims(&["parent"]);
1664        let approver = AclEntry::new("did:key:zChild", Role::Reader, "did:key:zSetup")
1665            .with_approve_scope(ApproveScope::Contexts(vec!["parent/child".into()]));
1666        assert!(
1667            is_acl_entry_auditable(&caller, &approver),
1668            "an admin of the parent context administers the subtree it confers in"
1669        );
1670    }
1671
1672    /// An approver for someone else's context stays hidden — the widening is
1673    /// scoped to conferral that actually reaches the caller.
1674    #[test]
1675    fn auditable_hides_an_approver_for_a_foreign_context() {
1676        let caller = context_admin_claims(&["ctx1"]);
1677        let approver = AclEntry::new("did:key:zOther", Role::Reader, "did:key:zSetup")
1678            .with_approve_scope(ApproveScope::Contexts(vec!["ctx2".into()]));
1679        assert!(!is_acl_entry_auditable(&caller, &approver));
1680    }
1681
1682    /// **Read-only by construction.** The mutation predicate must not follow
1683    /// the approve axis, or seeing an entry would become authority to delete
1684    /// it. An entry that admins someone else's context while conferring into
1685    /// ours is exactly the case that would be an escalation.
1686    #[test]
1687    fn auditable_does_not_confer_manage_authority() {
1688        let caller = context_admin_claims(&["ctx1"]);
1689        let foreign_admin = scoped_entry("did:key:zForeignAdmin", Role::Admin, &["ctx2"])
1690            .with_approve_scope(ApproveScope::Contexts(vec!["ctx1".into()]));
1691
1692        assert!(
1693            is_acl_entry_auditable(&caller, &foreign_admin),
1694            "confers into ctx1, so ctx1's admin can see it"
1695        );
1696        assert!(
1697            !is_acl_entry_visible(&caller, &foreign_admin),
1698            "but it admins ctx2 — ctx1's admin must not be able to update or delete it"
1699        );
1700    }
1701
1702    /// Entries carrying no conferral are unaffected: `auditable` collapses to
1703    /// `visible`, so this widens nothing for the ordinary case.
1704    #[test]
1705    fn auditable_matches_visible_when_nothing_is_conferred() {
1706        let caller = context_admin_claims(&["ctx1"]);
1707        for entry in [
1708            scoped_entry("did:key:zA", Role::Reader, &["ctx1"]),
1709            scoped_entry("did:key:zB", Role::Reader, &["ctx2"]),
1710            sample_entry("did:key:zC", Role::Admin),
1711            sample_entry("did:key:zD", Role::Reader),
1712        ] {
1713            assert_eq!(
1714                is_acl_entry_auditable(&caller, &entry),
1715                is_acl_entry_visible(&caller, &entry),
1716                "{} must be unaffected by the approve axis",
1717                entry.did
1718            );
1719        }
1720    }
1721
1722    /// A super-admin caller sees everything either way.
1723    #[test]
1724    fn auditable_is_total_for_a_super_admin() {
1725        let caller = super_admin_claims();
1726        let entry = scoped_entry("did:key:zAny", Role::Reader, &["whatever"]);
1727        assert!(is_acl_entry_auditable(&caller, &entry));
1728    }
1729
1730    // ── is_acl_entry_visible ────────────────────────────────────────
1731
1732    #[test]
1733    fn visibility_super_admin_sees_everything() {
1734        let caller = super_admin_claims();
1735        assert!(is_acl_entry_visible(
1736            &caller,
1737            &sample_entry("did:key:zA", Role::Admin)
1738        ));
1739        assert!(is_acl_entry_visible(
1740            &caller,
1741            &scoped_entry("did:key:zB", Role::Admin, &["private"])
1742        ));
1743    }
1744
1745    #[test]
1746    fn visibility_context_admin_sees_overlapping_entries_only() {
1747        let caller = context_admin_claims(&["ctx1", "ctx2"]);
1748
1749        // Entry scoped to ctx1 — visible (overlaps)
1750        assert!(is_acl_entry_visible(
1751            &caller,
1752            &scoped_entry("did:key:zA", Role::Reader, &["ctx1"])
1753        ));
1754
1755        // Entry scoped to ctx3 — not visible (no overlap)
1756        assert!(!is_acl_entry_visible(
1757            &caller,
1758            &scoped_entry("did:key:zB", Role::Reader, &["ctx3"])
1759        ));
1760
1761        // Super-admin entry (empty contexts) — not visible to scoped admin
1762        // so they can't enumerate holders of the higher privilege.
1763        assert!(!is_acl_entry_visible(
1764            &caller,
1765            &sample_entry("did:key:zSuper", Role::Admin)
1766        ));
1767
1768        // Entry with mixed contexts — visible if any overlap
1769        assert!(is_acl_entry_visible(
1770            &caller,
1771            &scoped_entry("did:key:zC", Role::Reader, &["ctx2", "ctx99"])
1772        ));
1773    }
1774
1775    // ── Serialization compatibility ─────────────────────────────────
1776
1777    #[test]
1778    fn acl_entry_without_expires_at_deserializes() {
1779        // Pre-Phase-2 entries were serialized without expires_at; they
1780        // must continue to load with expires_at=None (permanent). If
1781        // this test breaks, operators with older stores lose their
1782        // ACL data on upgrade.
1783        let legacy = r#"{
1784            "did": "did:key:zLegacy",
1785            "role": "admin",
1786            "label": "old admin",
1787            "allowed_contexts": [],
1788            "created_at": 1700000000,
1789            "created_by": "did:key:zSetup"
1790        }"#;
1791        let entry: AclEntry = serde_json::from_str(legacy).expect("legacy shape must deserialize");
1792        assert_eq!(entry.did, "did:key:zLegacy");
1793        assert!(entry.expires_at.is_none(), "default to permanent");
1794    }
1795
1796    #[test]
1797    fn acl_entry_with_missing_allowed_contexts_defaults_to_empty() {
1798        // Pre-ACL-scoping entries also omitted allowed_contexts.
1799        let legacy = r#"{
1800            "did": "did:key:zLegacy",
1801            "role": "admin",
1802            "label": null,
1803            "created_at": 1700000000,
1804            "created_by": "did:key:zSetup"
1805        }"#;
1806        let entry: AclEntry = serde_json::from_str(legacy).expect("legacy shape must deserialize");
1807        assert!(entry.allowed_contexts.is_empty());
1808    }
1809
1810    #[test]
1811    fn acl_entry_without_approve_scope_defaults_to_none() {
1812        // Pre-approver rows omit `approve_scope`; they must load as `None`
1813        // (confers nothing) — fail-closed.
1814        let legacy = r#"{
1815            "did": "did:key:zLegacy",
1816            "role": "reader",
1817            "label": null,
1818            "created_at": 1700000000,
1819            "created_by": "did:key:zSetup"
1820        }"#;
1821        let entry: AclEntry = serde_json::from_str(legacy).expect("legacy shape must deserialize");
1822        assert_eq!(entry.approve_scope, ApproveScope::None);
1823        assert!(entry.approve_scope.confers_nothing());
1824    }
1825
1826    #[test]
1827    fn approve_scope_round_trips_on_the_wire() {
1828        for scope in [
1829            ApproveScope::None,
1830            ApproveScope::All,
1831            ApproveScope::Contexts(vec!["ctx-a".into(), "ctx-b".into()]),
1832        ] {
1833            let e = AclEntry::new("did:key:zA", Role::Reader, "did:key:zC")
1834                .with_approve_scope(scope.clone());
1835            let json = serde_json::to_string(&e).unwrap();
1836            let back: AclEntry = serde_json::from_str(&json).unwrap();
1837            assert_eq!(back.approve_scope, scope);
1838        }
1839    }
1840
1841    #[test]
1842    fn validate_approve_scope_grant_authority() {
1843        // `All` is a cross-context authorizer: super-admin only.
1844        validate_approve_scope_grant(&super_admin_claims(), &ApproveScope::All)
1845            .expect("super admin may grant approve-all");
1846        assert!(
1847            validate_approve_scope_grant(&context_admin_claims(&["ctx-a"]), &ApproveScope::All)
1848                .is_err(),
1849            "a context admin must not grant approve-all"
1850        );
1851
1852        // A scoped grant requires the caller to hold each context.
1853        let ctx_admin = context_admin_claims(&["ctx-a"]);
1854        validate_approve_scope_grant(&ctx_admin, &ApproveScope::Contexts(vec!["ctx-a".into()]))
1855            .expect("own context ok");
1856        assert!(
1857            validate_approve_scope_grant(&ctx_admin, &ApproveScope::Contexts(vec!["ctx-b".into()]))
1858                .is_err(),
1859            "foreign context must be rejected"
1860        );
1861
1862        // `None` is always allowed; an empty context list is rejected.
1863        validate_approve_scope_grant(&ctx_admin, &ApproveScope::None).expect("none ok");
1864        assert!(
1865            validate_approve_scope_grant(&ctx_admin, &ApproveScope::Contexts(vec![])).is_err(),
1866            "empty scope must name a context or use all"
1867        );
1868    }
1869
1870    /// `--contexts ''` / `--approve-contexts ''` parse to `[""]`, not to `[]`
1871    /// — verified against clap 4 rather than assumed. A one-element list of
1872    /// the empty string cleared every `is_empty()` guard while naming no
1873    /// context, and nothing on the ACL write path validated the id's shape.
1874    /// A super admin hit this most easily: its authority check returns before
1875    /// any per-context work.
1876    #[test]
1877    fn blank_context_ids_are_rejected_on_every_acl_write_path() {
1878        let blank = vec![String::new()];
1879
1880        assert!(
1881            validate_acl_modification(&super_admin_claims(), &Role::Reader, &blank).is_err(),
1882            "a super admin must not store a context named empty-string"
1883        );
1884        assert!(
1885            validate_acl_modification(&context_admin_claims(&["ctx-a"]), &Role::Reader, &blank)
1886                .is_err(),
1887            "nor may a context admin"
1888        );
1889        assert!(
1890            validate_approve_scope_grant(
1891                &super_admin_claims(),
1892                &ApproveScope::Contexts(blank.clone())
1893            )
1894            .is_err(),
1895            "an approve scope of [\"\"] names no context"
1896        );
1897
1898        // Other malformed shapes go the same way, now that the write path
1899        // validates at all.
1900        for bad in ["/ctx", "ctx/", "a//b", "ev il"] {
1901            assert!(
1902                validate_acl_modification(&super_admin_claims(), &Role::Reader, &[bad.to_string()])
1903                    .is_err(),
1904                "{bad} must be rejected"
1905            );
1906        }
1907
1908        // The legitimate shapes still pass, including the empty *list* that
1909        // means super admin.
1910        validate_acl_modification(&super_admin_claims(), &Role::Admin, &[])
1911            .expect("admin + empty list is still how super admin is expressed");
1912        validate_acl_modification(
1913            &super_admin_claims(),
1914            &Role::Reader,
1915            &["acme/eng".to_string()],
1916        )
1917        .expect("a well-formed nested path is still accepted");
1918    }
1919}