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 — task-consent
264/// delegation (`compute_delegated_contexts`) and delegated step-up ratification
265/// ([`delegated_any_approver_covers`]) — **without** any authority to act.
266///
267/// Read only by those two conferral paths; it never feeds `require_admin` or
268/// `has_context_access`, so an approver can bless a change in a context while
269/// being unable to make one. This is the axis that lets an approver be
270/// least-privilege: `role: Reader`, `allowed_contexts: []` (acts nowhere),
271/// `approve_scope: All` (may authorize anywhere).
272///
273/// Default [`ApproveScope::None`]: an entry confers nothing unless explicitly
274/// granted this — strictly additive and fail-closed. Pre-existing rows omit the
275/// field and deserialise as `None`.
276#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
277#[serde(rename_all = "snake_case", tag = "kind", content = "contexts")]
278pub enum ApproveScope {
279    /// Confers nothing (the default).
280    #[default]
281    None,
282    /// May confer any context — a cross-context authorizer. Granting this is
283    /// super-admin-only (see [`validate_approve_scope_grant`]).
284    All,
285    /// May confer these contexts (and their subtrees), and only these.
286    Contexts(Vec<String>),
287}
288
289impl ApproveScope {
290    /// Whether an approval by a holder of this scope may confer `context_id`.
291    ///
292    /// Segment-aware ancestry, matching [`AuthClaims::has_context_access`], so an
293    /// approver scoped to a parent context covers its whole subtree.
294    pub fn covers(&self, context_id: &str) -> bool {
295        match self {
296            ApproveScope::None => false,
297            ApproveScope::All => true,
298            ApproveScope::Contexts(cs) => cs
299                .iter()
300                .any(|c| crate::context_path::is_ancestor_or_self(c, context_id)),
301        }
302    }
303
304    /// Whether this scope confers nothing.
305    pub fn confers_nothing(&self) -> bool {
306        matches!(self, ApproveScope::None)
307    }
308}
309
310/// Validate that `caller` may grant `scope` on an ACL entry.
311///
312/// Mirrors [`validate_acl_modification`]'s context rule: `All` is a
313/// cross-context authorizer, so only a super-admin may confer it; a scoped
314/// `Contexts` grant requires the caller to administer every listed context.
315/// `None` is always allowed.
316pub fn validate_approve_scope_grant(
317    caller: &AuthClaims,
318    scope: &ApproveScope,
319) -> Result<(), AppError> {
320    match scope {
321        ApproveScope::None => Ok(()),
322        ApproveScope::All => {
323            if caller.is_super_admin() {
324                Ok(())
325            } else {
326                Err(AppError::Forbidden(
327                    "only super admin can grant approve-all authority".into(),
328                ))
329            }
330        }
331        ApproveScope::Contexts(cs) => {
332            if cs.is_empty() {
333                return Err(AppError::Forbidden(
334                    "approve scope must name at least one context (or use 'all')".into(),
335                ));
336            }
337            // `--approve-contexts ''` parses to `[""]`, which is not empty and
338            // so cleared the check above while naming no context at all.
339            for c in cs {
340                crate::context_path::validate_context_path(c)?;
341            }
342            for c in cs {
343                caller.require_context(c)?;
344            }
345            Ok(())
346        }
347    }
348}
349
350/// An entry in the Access Control List.
351#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct AclEntry {
353    pub did: String,
354    pub role: Role,
355    pub label: Option<String>,
356    #[serde(default)]
357    pub allowed_contexts: Vec<String>,
358    pub created_at: u64,
359    pub created_by: String,
360    /// Unix-epoch seconds at which this entry expires and should be pruned by
361    /// the background sweeper. `None` is permanent (existing pre-Phase-2
362    /// behavior; entries serialized before this field existed deserialize with
363    /// this default).
364    #[serde(default)]
365    pub expires_at: Option<u64>,
366    /// Consumer kind: Companion (user-driven) vs Service (headless). New in
367    /// M1 (vault-credential-manager design). `#[serde(default)]` ⇒ pre-M1
368    /// rows deserialise as `Service { Daemon }`.
369    #[serde(default)]
370    pub kind: ConsumerKind,
371    /// Fine-grained capability set. Empty Vec on legacy rows; the auth
372    /// layer falls back to [`derived_capabilities_for_role`] when this
373    /// is empty so existing behaviour stays byte-identical.
374    #[serde(default)]
375    pub capabilities: Vec<Capability>,
376    /// Optional Companion/Service device-binding metadata. Populated by
377    /// the M4 `device/register/0.1` flow; absent on legacy rows and on
378    /// pure ACL entries that don't represent a registered device.
379    #[serde(default)]
380    pub device: Option<DeviceBinding>,
381    /// Optimistic-concurrency version. Incremented on every
382    /// successful update; the route layer's `If-Match` header
383    /// compares against this and returns 409 Conflict on a
384    /// stale write. Closes M6 from the May 2026 security review
385    /// — two admins editing the same DID concurrently no longer
386    /// silently lose one update.
387    ///
388    /// `#[serde(default)]` so pre-versioning rows deserialise
389    /// with `version=0`. The first update bumps it to 1.
390    #[serde(default)]
391    pub version: u32,
392    /// VID authorized to ratify an AAL2 step-up for this subject — the
393    /// `recipient` the VTA addresses an `auth/step-up/approve-request/0.1` to
394    /// when a gated operation resolves to `delegated` mode (the holder's
395    /// mobile authenticator or browser companion). `None` means no delegated
396    /// approver is configured: under a `delegated` floor the operation
397    /// fail-closes (the subject can't self-approve a delegated requirement).
398    /// Mirrors the spec's `AclEntry.stepUp.approver`.
399    ///
400    /// `#[serde(default)]` so pre-existing rows deserialise as `None`.
401    #[serde(default)]
402    pub step_up_approver: Option<String>,
403    /// Per-entry step-up override raising the system floor for *this* subject —
404    /// the spec's `AclEntry.stepUp.require`. ADDITIVE-ONLY: the effective mode
405    /// is the strictest of (system floor, this override), so an override weaker
406    /// than the floor is ignored (see [`StepUpMode::strictest`]). Restricted to
407    /// `self` / `delegated` (a per-subject override never relaxes to
408    /// `delegated-any`); the ACL op layer rejects other values.
409    ///
410    /// `#[serde(default)]` so pre-existing rows deserialise as `None` (no
411    /// override; the system floor applies unchanged).
412    #[serde(default)]
413    pub step_up_require: Option<StepUpMode>,
414    /// Authority to **confer** access via an approval, decoupled from the
415    /// authority to act. Read only by the two conferral paths
416    /// (`compute_delegated_contexts`, [`delegated_any_approver_covers`]); it
417    /// never feeds `require_admin`/`has_context_access`. Lets an approver be
418    /// least-privilege (act nowhere, authorize across contexts). `#[serde(default)]`
419    /// ⇒ pre-existing rows deserialise as [`ApproveScope::None`].
420    #[serde(default)]
421    pub approve_scope: ApproveScope,
422}
423
424impl AclEntry {
425    /// Create an entry with the required identity fields. Optional metadata
426    /// takes sensible defaults: no `label`, no `allowed_contexts`, never
427    /// expires, default [`ConsumerKind`], no `capabilities`, no `device`
428    /// binding, `version = 0`, and `created_at = now`. Layer non-defaults on
429    /// with the `with_*` builder methods.
430    ///
431    /// This is the single construction entry point — adding a new optional
432    /// field here defaults it everywhere, so callers don't churn.
433    pub fn new(did: impl Into<String>, role: Role, created_by: impl Into<String>) -> Self {
434        Self {
435            did: did.into(),
436            role,
437            label: None,
438            allowed_contexts: Vec::new(),
439            created_at: crate::auth::session::now_epoch(),
440            created_by: created_by.into(),
441            expires_at: None,
442            kind: ConsumerKind::default(),
443            capabilities: Vec::new(),
444            device: None,
445            version: 0,
446            step_up_approver: None,
447            step_up_require: None,
448            approve_scope: ApproveScope::None,
449        }
450    }
451
452    /// Override `created_at` (defaults to now). Use when replaying a known
453    /// timestamp — bootstrap import, tests, migration.
454    pub fn with_created_at(mut self, created_at: u64) -> Self {
455        self.created_at = created_at;
456        self
457    }
458
459    /// Set the optional human-readable label.
460    pub fn with_label(mut self, label: Option<String>) -> Self {
461        self.label = label;
462        self
463    }
464
465    /// Set the allowed-contexts (scope) list.
466    pub fn with_contexts(mut self, allowed_contexts: Vec<String>) -> Self {
467        self.allowed_contexts = allowed_contexts;
468        self
469    }
470
471    /// Set the optional expiry (unix seconds). `None` is permanent.
472    pub fn with_expires_at(mut self, expires_at: Option<u64>) -> Self {
473        self.expires_at = expires_at;
474        self
475    }
476
477    /// Set the consumer kind (Companion vs Service).
478    pub fn with_kind(mut self, kind: ConsumerKind) -> Self {
479        self.kind = kind;
480        self
481    }
482
483    /// Set the fine-grained capability set.
484    pub fn with_capabilities(mut self, capabilities: Vec<Capability>) -> Self {
485        self.capabilities = capabilities;
486        self
487    }
488
489    /// Attach optional Companion/Service device-binding metadata.
490    pub fn with_device(mut self, device: Option<DeviceBinding>) -> Self {
491        self.device = device;
492        self
493    }
494
495    /// Set the delegated step-up approver VID (`stepUp.approver`).
496    pub fn with_step_up_approver(mut self, approver: Option<String>) -> Self {
497        self.step_up_approver = approver;
498        self
499    }
500
501    /// Set the per-entry step-up override (`stepUp.require`).
502    pub fn with_step_up_require(mut self, require: Option<StepUpMode>) -> Self {
503        self.step_up_require = require;
504        self
505    }
506
507    /// Set the approve-authority scope (what this DID may confer via approval,
508    /// without any authority to act).
509    pub fn with_approve_scope(mut self, approve_scope: ApproveScope) -> Self {
510        self.approve_scope = approve_scope;
511        self
512    }
513
514    /// Whether this entry is an admin (`Role::Admin`).
515    pub fn is_admin(&self) -> bool {
516        matches!(self.role, Role::Admin)
517    }
518
519    /// Whether this entry is a **super-admin**: an admin with no context
520    /// restriction (empty `allowed_contexts` ⇒ unrestricted), mirroring
521    /// [`AuthClaims::is_super_admin`].
522    pub fn is_super_admin(&self) -> bool {
523        self.is_admin() && self.allowed_contexts.is_empty()
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_contexts: &[String],
725) -> Result<(), AppError> {
726    // Shape first, for every caller. `validate_context_path` existed all along
727    // but was never called on the ACL write path, so a malformed id — most
728    // reachably `""`, which is what `--contexts ''` actually parses to — was
729    // storable. Super admins reached it most easily, since their check below
730    // returns before any per-context work happens. An empty id is not an
731    // identifier and matches nothing in `has_context_access`, so an entry
732    // holding one is inert while looking scoped.
733    for ctx in target_contexts {
734        crate::context_path::validate_context_path(ctx)?;
735    }
736    if caller.is_super_admin() {
737        return Ok(());
738    }
739    if target_contexts.is_empty() {
740        return Err(AppError::Forbidden(
741            "only super admin can create unrestricted accounts".into(),
742        ));
743    }
744    for ctx in target_contexts {
745        caller.require_context(ctx)?;
746    }
747    Ok(())
748}
749
750/// Authorization predicate for **`delegated-any`** step-up: may `approver`
751/// ratify an AAL2 step-up for `subject`?
752///
753/// The criterion is **context-scoped admin**:
754/// - a **super-admin** approver (admin, no context restriction) may ratify for
755///   any subject — including cross-context and global subjects;
756/// - a **context-admin** approver may ratify only for a context-scoped subject
757///   **all** of whose contexts it administers (`subject.allowed_contexts ⊆
758///   approver.allowed_contexts`). A context admin can never ratify for a global
759///   (super-admin-equivalent, empty-context) subject — only a super-admin can.
760///
761/// Non-admins never qualify. Expiry is the caller's responsibility (it should
762/// skip an expired approver entry before calling this).
763pub fn delegated_any_approver_covers(approver: &AclEntry, subject: &AclEntry) -> bool {
764    // Explicit approve authority (a least-privilege approver): covers by scope,
765    // independent of role or `allowed_contexts`. `All` covers any subject; a
766    // scoped grant covers a context-scoped subject all of whose contexts fall
767    // within the scope. A global (empty-context) subject is never covered by a
768    // scoped grant — only by `All` or a super-admin.
769    match &approver.approve_scope {
770        ApproveScope::All => return true,
771        ApproveScope::Contexts(_) => {
772            if !subject.allowed_contexts.is_empty()
773                && subject
774                    .allowed_contexts
775                    .iter()
776                    .all(|c| approver.approve_scope.covers(c))
777            {
778                return true;
779            }
780            // Fall through: the approver may still qualify via the admin path.
781        }
782        ApproveScope::None => {}
783    }
784
785    // Backward-compatible admin path: an admin confers what it holds.
786    if !approver.is_admin() {
787        return false;
788    }
789    if approver.allowed_contexts.is_empty() {
790        return true; // super-admin: covers all contexts
791    }
792    // Context admin: the subject must itself be context-scoped, and every one of
793    // its contexts must fall within the approver's. A global subject (empty
794    // contexts) requires a super-admin approver, handled by the branch above.
795    !subject.allowed_contexts.is_empty()
796        && subject
797            .allowed_contexts
798            .iter()
799            .all(|c| approver.allowed_contexts.contains(c))
800}
801
802/// Check whether an ACL entry is visible to the caller.
803///
804/// Super admins see all entries. Context admins only see entries whose
805/// `allowed_contexts` overlap with their own.
806pub fn is_acl_entry_visible(caller: &AuthClaims, entry: &AclEntry) -> bool {
807    if caller.is_super_admin() {
808        return true;
809    }
810    entry
811        .allowed_contexts
812        .iter()
813        .any(|ctx| caller.has_context_access(ctx))
814}
815
816#[cfg(test)]
817mod delegated_any_tests {
818    use super::*;
819
820    fn admin(contexts: &[&str]) -> AclEntry {
821        AclEntry::new("did:key:zApprover", Role::Admin, "did:key:zCreator")
822            .with_contexts(contexts.iter().map(|s| s.to_string()).collect())
823    }
824    fn subject(role: Role, contexts: &[&str]) -> AclEntry {
825        AclEntry::new("did:key:zSubject", role, "did:key:zCreator")
826            .with_contexts(contexts.iter().map(|s| s.to_string()).collect())
827    }
828
829    #[test]
830    fn super_admin_covers_any_subject() {
831        let sa = admin(&[]); // empty contexts ⇒ super-admin
832        assert!(delegated_any_approver_covers(
833            &sa,
834            &subject(Role::Admin, &["ctx-a"])
835        ));
836        assert!(delegated_any_approver_covers(
837            &sa,
838            &subject(Role::Reader, &[])
839        )); // global subject
840        assert!(delegated_any_approver_covers(
841            &sa,
842            &subject(Role::Application, &["ctx-a", "ctx-b"])
843        ));
844    }
845
846    #[test]
847    fn context_admin_covers_only_within_its_contexts() {
848        let ca = admin(&["ctx-a", "ctx-b"]);
849        // Subject fully within → covered.
850        assert!(delegated_any_approver_covers(
851            &ca,
852            &subject(Role::Reader, &["ctx-a"])
853        ));
854        assert!(delegated_any_approver_covers(
855            &ca,
856            &subject(Role::Reader, &["ctx-a", "ctx-b"])
857        ));
858        // Subject in a context the admin doesn't administer → NOT covered.
859        assert!(!delegated_any_approver_covers(
860            &ca,
861            &subject(Role::Reader, &["ctx-c"])
862        ));
863        assert!(!delegated_any_approver_covers(
864            &ca,
865            &subject(Role::Reader, &["ctx-a", "ctx-c"])
866        ));
867    }
868
869    #[test]
870    fn context_admin_never_covers_a_global_subject() {
871        // A global (empty-context, super-admin-equivalent) subject needs a
872        // super-admin approver; a context admin must never ratify for it.
873        let ca = admin(&["ctx-a"]);
874        assert!(!delegated_any_approver_covers(
875            &ca,
876            &subject(Role::Admin, &[])
877        ));
878    }
879
880    #[test]
881    fn non_admins_never_qualify() {
882        for role in [
883            Role::Initiator,
884            Role::Application,
885            Role::Reader,
886            Role::Monitor,
887        ] {
888            let not_admin = AclEntry::new("did:key:zX", role, "did:key:zC");
889            assert!(!delegated_any_approver_covers(
890                &not_admin,
891                &subject(Role::Reader, &["ctx-a"])
892            ));
893        }
894    }
895
896    // ── ApproveScope: a least-privilege approver confers without acting ──
897
898    /// A Reader with no contexts and no admin — it can *act* nowhere. Its only
899    /// authority is the approve scope layered on top.
900    fn pure_approver(scope: ApproveScope) -> AclEntry {
901        AclEntry::new("did:key:zApprover", Role::Reader, "did:key:zCreator")
902            .with_approve_scope(scope)
903    }
904
905    #[test]
906    fn approve_scope_covers_semantics() {
907        assert!(!ApproveScope::None.covers("ctx-a"));
908        assert!(ApproveScope::All.covers("anything"));
909        let scoped = ApproveScope::Contexts(vec!["ctx-a".into()]);
910        assert!(scoped.covers("ctx-a"));
911        assert!(!scoped.covers("ctx-b"));
912    }
913
914    #[test]
915    fn approve_all_confers_for_any_subject_without_any_admin_authority() {
916        // The whole point: an approver that holds no admin (acts nowhere) may
917        // still ratify across contexts, including a global subject.
918        let approver = pure_approver(ApproveScope::All);
919        assert!(
920            !approver.is_admin(),
921            "the approver holds no admin authority"
922        );
923        assert!(delegated_any_approver_covers(
924            &approver,
925            &subject(Role::Reader, &["ctx-a"])
926        ));
927        assert!(delegated_any_approver_covers(
928            &approver,
929            &subject(Role::Admin, &[])
930        ));
931    }
932
933    #[test]
934    fn scoped_approve_authority_covers_only_within_scope() {
935        let approver = pure_approver(ApproveScope::Contexts(vec!["ctx-a".into()]));
936        assert!(delegated_any_approver_covers(
937            &approver,
938            &subject(Role::Reader, &["ctx-a"])
939        ));
940        assert!(!delegated_any_approver_covers(
941            &approver,
942            &subject(Role::Reader, &["ctx-b"])
943        ));
944        // A global subject needs `All` (or a super-admin), never a scoped grant.
945        assert!(!delegated_any_approver_covers(
946            &approver,
947            &subject(Role::Admin, &[])
948        ));
949    }
950
951    #[test]
952    fn no_approve_scope_and_no_admin_confers_nothing() {
953        let reader = pure_approver(ApproveScope::None);
954        assert!(!delegated_any_approver_covers(
955            &reader,
956            &subject(Role::Reader, &["ctx-a"])
957        ));
958    }
959}
960
961#[cfg(test)]
962mod tests {
963    use super::*;
964    use crate::config::StoreConfig;
965    use crate::store::Store;
966
967    // ── Test fixtures ───────────────────────────────────────────────
968
969    fn temp_store() -> (Store, tempfile::TempDir) {
970        let dir = tempfile::tempdir().expect("tempdir");
971        let config = StoreConfig {
972            data_dir: dir.path().to_path_buf(),
973        };
974        let store = Store::open(&config).expect("open store");
975        (store, dir)
976    }
977
978    fn sample_entry(did: &str, role: Role) -> AclEntry {
979        AclEntry::new(did, role, "did:key:zSetup").with_label(Some(format!("test-{did}")))
980    }
981
982    #[tokio::test]
983    async fn list_acl_entries_skips_corrupt_rows() {
984        // A single undeserializable row must not abort the whole listing —
985        // otherwise one corrupt entry bricks ACL management and the auth
986        // paths that enumerate entries.
987        let (store, _dir) = temp_store();
988        let ks = store.keyspace("acl").unwrap();
989
990        store_acl_entry(&ks, &sample_entry("did:key:zAlice", Role::Admin))
991            .await
992            .unwrap();
993        // Inject garbage under the acl: prefix.
994        ks.insert_raw("acl:did:key:zCorrupt", b"{not valid json".to_vec())
995            .await
996            .unwrap();
997        store_acl_entry(&ks, &sample_entry("did:key:zBob", Role::Reader))
998            .await
999            .unwrap();
1000
1001        let entries = list_acl_entries(&ks).await.expect("listing must not abort");
1002        let dids: Vec<&str> = entries.iter().map(|e| e.did.as_str()).collect();
1003        assert!(dids.contains(&"did:key:zAlice"));
1004        assert!(dids.contains(&"did:key:zBob"));
1005        assert_eq!(entries.len(), 2, "the corrupt row is skipped, not surfaced");
1006    }
1007
1008    fn scoped_entry(did: &str, role: Role, contexts: &[&str]) -> AclEntry {
1009        AclEntry::new(did, role, "did:key:zSetup")
1010            .with_contexts(contexts.iter().map(|s| s.to_string()).collect())
1011    }
1012
1013    fn super_admin_claims() -> AuthClaims {
1014        AuthClaims {
1015            did: "did:key:zSuperAdmin".into(),
1016            role: Role::Admin,
1017            allowed_contexts: vec![],
1018            session_id: "test-session".into(),
1019            access_expires_at: 0,
1020            amr: Vec::new(),
1021            acr: String::new(),
1022        }
1023    }
1024
1025    fn context_admin_claims(contexts: &[&str]) -> AuthClaims {
1026        AuthClaims {
1027            did: "did:key:zCtxAdmin".into(),
1028            role: Role::Admin,
1029            allowed_contexts: contexts.iter().map(|s| s.to_string()).collect(),
1030            session_id: "test-session".into(),
1031            access_expires_at: 0,
1032            amr: Vec::new(),
1033            acr: String::new(),
1034        }
1035    }
1036
1037    // ── Role parsing ────────────────────────────────────────────────
1038
1039    #[test]
1040    fn role_parse_accepts_canonical_lowercase() {
1041        assert_eq!(Role::parse("admin").unwrap(), Role::Admin);
1042        assert_eq!(Role::parse("initiator").unwrap(), Role::Initiator);
1043        assert_eq!(Role::parse("application").unwrap(), Role::Application);
1044        assert_eq!(Role::parse("reader").unwrap(), Role::Reader);
1045        assert_eq!(Role::parse("monitor").unwrap(), Role::Monitor);
1046    }
1047
1048    #[test]
1049    fn role_parse_rejects_unknown() {
1050        let err = Role::parse("godmode").expect_err("unknown role must error");
1051        assert!(format!("{err:?}").contains("godmode"), "got {err:?}");
1052    }
1053
1054    // ── DeviceBinding wake channel (push wake-up binding) ───────────────
1055
1056    fn sample_binding() -> DeviceBinding {
1057        DeviceBinding {
1058            device_id: "dev-1".into(),
1059            display_name: "Glenn's iPhone".into(),
1060            platform: Some("iOS 19".into()),
1061            registered_at: "2026-06-02T00:00:00Z".into(),
1062            last_seen_at: None,
1063            disabled_at: None,
1064            wiped_at: None,
1065            hpke_public_key: None,
1066            wake: None,
1067        }
1068    }
1069
1070    #[test]
1071    fn wake_channel_round_trips_camel_case() {
1072        let mut b = sample_binding();
1073        b.wake = Some(WakeChannel {
1074            gateway: "https://gw.example".into(),
1075            handle: "z6MkOpaque".into(),
1076            allowed_triggers: vec!["did:web:mediator".into(), "did:web:vta".into()],
1077        });
1078        let json = serde_json::to_string(&b).unwrap();
1079        // Wire is camelCase, mirroring the spec shapes.
1080        assert!(json.contains("\"wake\""), "{json}");
1081        assert!(json.contains("\"allowedTriggers\""), "{json}");
1082        let back: DeviceBinding = serde_json::from_str(&json).unwrap();
1083        assert_eq!(b, back);
1084    }
1085
1086    #[test]
1087    fn legacy_row_without_wake_deserialises_to_none() {
1088        // A binding serialised before the wake field existed.
1089        let legacy =
1090            r#"{"deviceId":"dev-1","displayName":"old","registeredAt":"2026-01-01T00:00:00Z"}"#;
1091        let b: DeviceBinding = serde_json::from_str(legacy).unwrap();
1092        assert!(b.wake.is_none());
1093        assert!(!b.push_capable());
1094    }
1095
1096    #[test]
1097    fn push_capable_requires_wake_and_active_device() {
1098        let mut b = sample_binding();
1099        assert!(!b.push_capable(), "no wake channel → not push-capable");
1100
1101        b.wake = Some(WakeChannel {
1102            gateway: "did:web:gw".into(),
1103            handle: "h".into(),
1104            allowed_triggers: vec!["did:web:vta".into()],
1105        });
1106        assert!(b.push_capable(), "wake set + active → push-capable");
1107
1108        b.disabled_at = Some("2026-06-02T01:00:00Z".into());
1109        assert!(!b.push_capable(), "disabled device is not push-capable");
1110
1111        b.disabled_at = None;
1112        b.wiped_at = Some("2026-06-02T02:00:00Z".into());
1113        assert!(!b.push_capable(), "wiped device is not push-capable");
1114    }
1115
1116    #[test]
1117    fn role_parse_rejects_case_variation() {
1118        // Serde rename_all="lowercase" means Admin != Admin on the wire.
1119        // parse() mirrors that contract.
1120        assert!(Role::parse("Admin").is_err(), "case-sensitive parse");
1121        assert!(Role::parse("ADMIN").is_err());
1122    }
1123
1124    #[test]
1125    fn role_display_round_trips_with_parse() {
1126        for role in [
1127            Role::Admin,
1128            Role::Initiator,
1129            Role::Application,
1130            Role::Reader,
1131            Role::Monitor,
1132        ] {
1133            let s = format!("{role}");
1134            assert_eq!(Role::parse(&s).unwrap(), role, "display->parse cycle");
1135        }
1136    }
1137
1138    // ── Expiration ──────────────────────────────────────────────────
1139
1140    #[test]
1141    fn entry_without_expiry_never_expires() {
1142        let entry = sample_entry("did:key:zA", Role::Admin);
1143        assert!(entry.expires_at.is_none());
1144        assert!(
1145            !entry.is_expired(u64::MAX),
1146            "permanent entries never expire"
1147        );
1148    }
1149
1150    #[test]
1151    fn entry_with_future_expiry_is_not_expired() {
1152        let mut entry = sample_entry("did:key:zA", Role::Admin);
1153        entry.expires_at = Some(now_epoch() + 3600);
1154        assert!(!entry.is_expired(now_epoch()));
1155    }
1156
1157    #[test]
1158    fn entry_with_past_expiry_is_expired() {
1159        let mut entry = sample_entry("did:key:zA", Role::Admin);
1160        entry.expires_at = Some(now_epoch().saturating_sub(1));
1161        assert!(entry.is_expired(now_epoch()));
1162    }
1163
1164    #[test]
1165    fn entry_with_exact_expiry_boundary_is_expired() {
1166        // Guard choice: `now >= deadline` is expired. The boundary at
1167        // equal seconds counts as past — callers don't get a free
1168        // extra second of access.
1169        let mut entry = sample_entry("did:key:zA", Role::Admin);
1170        let now = now_epoch();
1171        entry.expires_at = Some(now);
1172        assert!(entry.is_expired(now), "now == deadline counts as expired");
1173    }
1174
1175    // ── Store CRUD ──────────────────────────────────────────────────
1176
1177    #[tokio::test]
1178    async fn crud_round_trip() {
1179        let (store, _dir) = temp_store();
1180        let acl = store.keyspace("acl").unwrap();
1181
1182        let entry = sample_entry("did:key:zAbc", Role::Admin);
1183        store_acl_entry(&acl, &entry).await.unwrap();
1184
1185        let got = get_acl_entry(&acl, "did:key:zAbc")
1186            .await
1187            .unwrap()
1188            .expect("entry should exist");
1189        assert_eq!(got.did, entry.did);
1190        assert_eq!(got.role, Role::Admin);
1191
1192        delete_acl_entry(&acl, "did:key:zAbc").await.unwrap();
1193        let gone = get_acl_entry(&acl, "did:key:zAbc").await.unwrap();
1194        assert!(gone.is_none(), "deleted entry must be gone");
1195    }
1196
1197    #[tokio::test]
1198    async fn list_returns_every_entry() {
1199        let (store, _dir) = temp_store();
1200        let acl = store.keyspace("acl").unwrap();
1201
1202        for did in ["did:key:zA", "did:key:zB", "did:key:zC"] {
1203            store_acl_entry(&acl, &sample_entry(did, Role::Reader))
1204                .await
1205                .unwrap();
1206        }
1207
1208        let entries = list_acl_entries(&acl).await.unwrap();
1209        assert_eq!(entries.len(), 3);
1210        let dids: std::collections::HashSet<_> = entries.iter().map(|e| e.did.as_str()).collect();
1211        assert!(dids.contains("did:key:zA"));
1212        assert!(dids.contains("did:key:zB"));
1213        assert!(dids.contains("did:key:zC"));
1214    }
1215
1216    // ── check_acl ───────────────────────────────────────────────────
1217
1218    #[tokio::test]
1219    async fn check_acl_returns_role_for_present_did() {
1220        let (store, _dir) = temp_store();
1221        let acl = store.keyspace("acl").unwrap();
1222        store_acl_entry(&acl, &sample_entry("did:key:zA", Role::Initiator))
1223            .await
1224            .unwrap();
1225
1226        let role = check_acl(&acl, "did:key:zA").await.unwrap();
1227        assert_eq!(role, Role::Initiator);
1228    }
1229
1230    #[tokio::test]
1231    async fn check_acl_rejects_missing_did_as_forbidden() {
1232        let (store, _dir) = temp_store();
1233        let acl = store.keyspace("acl").unwrap();
1234
1235        let err = check_acl(&acl, "did:key:zUnknown")
1236            .await
1237            .expect_err("missing DID must be rejected");
1238        assert!(
1239            matches!(err, AppError::Forbidden(_)),
1240            "got {err:?}; expected Forbidden so the handler emits 403"
1241        );
1242    }
1243
1244    #[tokio::test]
1245    async fn check_acl_rejects_expired_entry() {
1246        let (store, _dir) = temp_store();
1247        let acl = store.keyspace("acl").unwrap();
1248
1249        let mut entry = sample_entry("did:key:zExpired", Role::Admin);
1250        entry.expires_at = Some(now_epoch().saturating_sub(10));
1251        store_acl_entry(&acl, &entry).await.unwrap();
1252
1253        let err = check_acl(&acl, "did:key:zExpired")
1254            .await
1255            .expect_err("expired entry must be rejected");
1256        let msg = format!("{err:?}");
1257        assert!(
1258            matches!(err, AppError::Forbidden(_)) && msg.contains("expired"),
1259            "got {err:?}"
1260        );
1261    }
1262
1263    #[tokio::test]
1264    async fn check_acl_full_returns_role_and_contexts() {
1265        let (store, _dir) = temp_store();
1266        let acl = store.keyspace("acl").unwrap();
1267        store_acl_entry(
1268            &acl,
1269            &scoped_entry("did:key:zCtx", Role::Admin, &["ctx1", "ctx2"]),
1270        )
1271        .await
1272        .unwrap();
1273
1274        let (role, contexts) = check_acl_full(&acl, "did:key:zCtx").await.unwrap();
1275        assert_eq!(role, Role::Admin);
1276        assert_eq!(contexts, vec!["ctx1".to_string(), "ctx2".to_string()]);
1277    }
1278
1279    // ── validate_role_assignment ────────────────────────────────────
1280
1281    #[test]
1282    fn role_assignment_super_admin_can_assign_admin() {
1283        validate_role_assignment(&super_admin_claims(), &Role::Admin)
1284            .expect("super admin assigns admin");
1285    }
1286
1287    #[test]
1288    fn role_assignment_context_admin_can_assign_admin_role_itself() {
1289        // A context admin (Role::Admin with non-empty allowed_contexts)
1290        // passes validate_role_assignment for the Admin role — the
1291        // role-level check only gates `caller.role != Role::Admin`.
1292        // The actual escape-prevention is in validate_acl_modification,
1293        // which confines the new entry to the caller's own contexts.
1294        validate_role_assignment(&context_admin_claims(&["ctx1"]), &Role::Admin)
1295            .expect("context admin CAN assign Admin role; scope is enforced separately");
1296    }
1297
1298    #[test]
1299    fn role_assignment_non_admin_cannot_assign_admin() {
1300        // Initiator, Reader, Application, Monitor cannot mint admins
1301        // regardless of scope. Only callers with Role::Admin can
1302        // assign Role::Admin.
1303        let initiator = AuthClaims {
1304            did: "did:key:zIni".into(),
1305            role: Role::Initiator,
1306            allowed_contexts: vec!["ctx1".into()],
1307            session_id: "test-session".into(),
1308            access_expires_at: 0,
1309            amr: Vec::new(),
1310            acr: String::new(),
1311        };
1312        let err = validate_role_assignment(&initiator, &Role::Admin)
1313            .expect_err("non-admin must not assign admin");
1314        assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
1315    }
1316
1317    #[test]
1318    fn role_assignment_readers_cannot_assign_any_role() {
1319        let reader = AuthClaims {
1320            did: "did:key:zReader".into(),
1321            role: Role::Reader,
1322            allowed_contexts: vec!["ctx1".into()],
1323            session_id: "test-session".into(),
1324            access_expires_at: 0,
1325            amr: Vec::new(),
1326            acr: String::new(),
1327        };
1328        for target in [
1329            Role::Admin,
1330            Role::Initiator,
1331            Role::Application,
1332            Role::Reader,
1333            Role::Monitor,
1334        ] {
1335            let err = validate_role_assignment(&reader, &target)
1336                .expect_err(&format!("reader must not assign {target}"));
1337            assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
1338        }
1339    }
1340
1341    #[test]
1342    fn role_assignment_initiator_can_assign_non_admin_roles() {
1343        let initiator = AuthClaims {
1344            did: "did:key:zIni".into(),
1345            role: Role::Initiator,
1346            allowed_contexts: vec!["ctx1".into()],
1347            session_id: "test-session".into(),
1348            access_expires_at: 0,
1349            amr: Vec::new(),
1350            acr: String::new(),
1351        };
1352        validate_role_assignment(&initiator, &Role::Reader).expect("initiator can assign reader");
1353        validate_role_assignment(&initiator, &Role::Application)
1354            .expect("initiator can assign application");
1355    }
1356
1357    // ── validate_acl_modification ───────────────────────────────────
1358
1359    #[test]
1360    fn acl_modification_super_admin_can_create_unrestricted() {
1361        validate_acl_modification(&super_admin_claims(), &[]).expect("super admin unrestricted");
1362        validate_acl_modification(&super_admin_claims(), &["any-ctx".into()])
1363            .expect("super admin any-context");
1364    }
1365
1366    #[test]
1367    fn acl_modification_context_admin_cannot_create_unrestricted() {
1368        // Empty allowed_contexts on a new entry = super admin. A scoped
1369        // admin trying to create one would escape their scope.
1370        let err = validate_acl_modification(&context_admin_claims(&["ctx1"]), &[])
1371            .expect_err("context admin must not create unrestricted");
1372        assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
1373    }
1374
1375    #[test]
1376    fn acl_modification_context_admin_confined_to_own_contexts() {
1377        let caller = context_admin_claims(&["ctx1", "ctx2"]);
1378        validate_acl_modification(&caller, &["ctx1".into()]).expect("own context ok");
1379        validate_acl_modification(&caller, &["ctx1".into(), "ctx2".into()])
1380            .expect("all-own contexts ok");
1381
1382        let err = validate_acl_modification(&caller, &["ctx3".into()])
1383            .expect_err("foreign context must be rejected");
1384        assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
1385
1386        let err = validate_acl_modification(&caller, &["ctx1".into(), "ctx3".into()])
1387            .expect_err("mixed own+foreign must be rejected");
1388        assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
1389    }
1390
1391    // ── is_acl_entry_visible ────────────────────────────────────────
1392
1393    #[test]
1394    fn visibility_super_admin_sees_everything() {
1395        let caller = super_admin_claims();
1396        assert!(is_acl_entry_visible(
1397            &caller,
1398            &sample_entry("did:key:zA", Role::Admin)
1399        ));
1400        assert!(is_acl_entry_visible(
1401            &caller,
1402            &scoped_entry("did:key:zB", Role::Admin, &["private"])
1403        ));
1404    }
1405
1406    #[test]
1407    fn visibility_context_admin_sees_overlapping_entries_only() {
1408        let caller = context_admin_claims(&["ctx1", "ctx2"]);
1409
1410        // Entry scoped to ctx1 — visible (overlaps)
1411        assert!(is_acl_entry_visible(
1412            &caller,
1413            &scoped_entry("did:key:zA", Role::Reader, &["ctx1"])
1414        ));
1415
1416        // Entry scoped to ctx3 — not visible (no overlap)
1417        assert!(!is_acl_entry_visible(
1418            &caller,
1419            &scoped_entry("did:key:zB", Role::Reader, &["ctx3"])
1420        ));
1421
1422        // Super-admin entry (empty contexts) — not visible to scoped admin
1423        // so they can't enumerate holders of the higher privilege.
1424        assert!(!is_acl_entry_visible(
1425            &caller,
1426            &sample_entry("did:key:zSuper", Role::Admin)
1427        ));
1428
1429        // Entry with mixed contexts — visible if any overlap
1430        assert!(is_acl_entry_visible(
1431            &caller,
1432            &scoped_entry("did:key:zC", Role::Reader, &["ctx2", "ctx99"])
1433        ));
1434    }
1435
1436    // ── Serialization compatibility ─────────────────────────────────
1437
1438    #[test]
1439    fn acl_entry_without_expires_at_deserializes() {
1440        // Pre-Phase-2 entries were serialized without expires_at; they
1441        // must continue to load with expires_at=None (permanent). If
1442        // this test breaks, operators with older stores lose their
1443        // ACL data on upgrade.
1444        let legacy = r#"{
1445            "did": "did:key:zLegacy",
1446            "role": "admin",
1447            "label": "old admin",
1448            "allowed_contexts": [],
1449            "created_at": 1700000000,
1450            "created_by": "did:key:zSetup"
1451        }"#;
1452        let entry: AclEntry = serde_json::from_str(legacy).expect("legacy shape must deserialize");
1453        assert_eq!(entry.did, "did:key:zLegacy");
1454        assert!(entry.expires_at.is_none(), "default to permanent");
1455    }
1456
1457    #[test]
1458    fn acl_entry_with_missing_allowed_contexts_defaults_to_empty() {
1459        // Pre-ACL-scoping entries also omitted allowed_contexts.
1460        let legacy = r#"{
1461            "did": "did:key:zLegacy",
1462            "role": "admin",
1463            "label": null,
1464            "created_at": 1700000000,
1465            "created_by": "did:key:zSetup"
1466        }"#;
1467        let entry: AclEntry = serde_json::from_str(legacy).expect("legacy shape must deserialize");
1468        assert!(entry.allowed_contexts.is_empty());
1469    }
1470
1471    #[test]
1472    fn acl_entry_without_approve_scope_defaults_to_none() {
1473        // Pre-approver rows omit `approve_scope`; they must load as `None`
1474        // (confers nothing) — fail-closed.
1475        let legacy = r#"{
1476            "did": "did:key:zLegacy",
1477            "role": "reader",
1478            "label": null,
1479            "created_at": 1700000000,
1480            "created_by": "did:key:zSetup"
1481        }"#;
1482        let entry: AclEntry = serde_json::from_str(legacy).expect("legacy shape must deserialize");
1483        assert_eq!(entry.approve_scope, ApproveScope::None);
1484        assert!(entry.approve_scope.confers_nothing());
1485    }
1486
1487    #[test]
1488    fn approve_scope_round_trips_on_the_wire() {
1489        for scope in [
1490            ApproveScope::None,
1491            ApproveScope::All,
1492            ApproveScope::Contexts(vec!["ctx-a".into(), "ctx-b".into()]),
1493        ] {
1494            let e = AclEntry::new("did:key:zA", Role::Reader, "did:key:zC")
1495                .with_approve_scope(scope.clone());
1496            let json = serde_json::to_string(&e).unwrap();
1497            let back: AclEntry = serde_json::from_str(&json).unwrap();
1498            assert_eq!(back.approve_scope, scope);
1499        }
1500    }
1501
1502    #[test]
1503    fn validate_approve_scope_grant_authority() {
1504        // `All` is a cross-context authorizer: super-admin only.
1505        validate_approve_scope_grant(&super_admin_claims(), &ApproveScope::All)
1506            .expect("super admin may grant approve-all");
1507        assert!(
1508            validate_approve_scope_grant(&context_admin_claims(&["ctx-a"]), &ApproveScope::All)
1509                .is_err(),
1510            "a context admin must not grant approve-all"
1511        );
1512
1513        // A scoped grant requires the caller to hold each context.
1514        let ctx_admin = context_admin_claims(&["ctx-a"]);
1515        validate_approve_scope_grant(&ctx_admin, &ApproveScope::Contexts(vec!["ctx-a".into()]))
1516            .expect("own context ok");
1517        assert!(
1518            validate_approve_scope_grant(&ctx_admin, &ApproveScope::Contexts(vec!["ctx-b".into()]))
1519                .is_err(),
1520            "foreign context must be rejected"
1521        );
1522
1523        // `None` is always allowed; an empty context list is rejected.
1524        validate_approve_scope_grant(&ctx_admin, &ApproveScope::None).expect("none ok");
1525        assert!(
1526            validate_approve_scope_grant(&ctx_admin, &ApproveScope::Contexts(vec![])).is_err(),
1527            "empty scope must name a context or use all"
1528        );
1529    }
1530
1531    /// `--contexts ''` / `--approve-contexts ''` parse to `[""]`, not to `[]`
1532    /// — verified against clap 4 rather than assumed. A one-element list of
1533    /// the empty string cleared every `is_empty()` guard while naming no
1534    /// context, and nothing on the ACL write path validated the id's shape.
1535    /// A super admin hit this most easily: its authority check returns before
1536    /// any per-context work.
1537    #[test]
1538    fn blank_context_ids_are_rejected_on_every_acl_write_path() {
1539        let blank = vec![String::new()];
1540
1541        assert!(
1542            validate_acl_modification(&super_admin_claims(), &blank).is_err(),
1543            "a super admin must not store a context named empty-string"
1544        );
1545        assert!(
1546            validate_acl_modification(&context_admin_claims(&["ctx-a"]), &blank).is_err(),
1547            "nor may a context admin"
1548        );
1549        assert!(
1550            validate_approve_scope_grant(
1551                &super_admin_claims(),
1552                &ApproveScope::Contexts(blank.clone())
1553            )
1554            .is_err(),
1555            "an approve scope of [\"\"] names no context"
1556        );
1557
1558        // Other malformed shapes go the same way, now that the write path
1559        // validates at all.
1560        for bad in ["/ctx", "ctx/", "a//b", "ev il"] {
1561            assert!(
1562                validate_acl_modification(&super_admin_claims(), &[bad.to_string()]).is_err(),
1563                "{bad} must be rejected"
1564            );
1565        }
1566
1567        // The legitimate shapes still pass, including the empty *list* that
1568        // means super admin.
1569        validate_acl_modification(&super_admin_claims(), &[])
1570            .expect("an empty list is still how super admin is expressed");
1571        validate_acl_modification(&super_admin_claims(), &["acme/eng".to_string()])
1572            .expect("a well-formed nested path is still accepted");
1573    }
1574}