Skip to main content

vti_common/acl/
mod.rs

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