use std::fmt;
use serde::{Deserialize, Serialize};
use crate::auth::extractor::AuthClaims;
use crate::auth::step_up::StepUpMode;
use crate::error::AppError;
use crate::store::KeyspaceHandle;
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "lowercase")]
pub enum Role {
Admin,
Initiator,
Application,
Reader,
#[default]
Monitor,
}
impl fmt::Display for Role {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Role::Admin => write!(f, "admin"),
Role::Initiator => write!(f, "initiator"),
Role::Application => write!(f, "application"),
Role::Reader => write!(f, "reader"),
Role::Monitor => write!(f, "monitor"),
}
}
}
impl Role {
pub fn parse(s: &str) -> Result<Self, AppError> {
match s {
"admin" => Ok(Role::Admin),
"initiator" => Ok(Role::Initiator),
"application" => Ok(Role::Application),
"reader" => Ok(Role::Reader),
"monitor" => Ok(Role::Monitor),
_ => Err(AppError::Internal(format!("unknown role: {s}"))),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum ConsumerKind {
#[serde(rename_all = "kebab-case")]
Companion { form_factor: CompanionFormFactor },
#[serde(rename_all = "camelCase")]
Service {
#[serde(rename = "serviceKind")]
service_kind: ServiceKind,
},
}
impl Default for ConsumerKind {
fn default() -> Self {
ConsumerKind::Service {
service_kind: ServiceKind::Daemon,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum CompanionFormFactor {
Browser,
Mobile,
Desktop,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum ServiceKind {
Mediator,
AiAgent,
Daemon,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "kebab-case")]
pub enum Capability {
VaultRead,
VaultWrite,
ProxyLogin,
FillRelease,
PolicyAdmin,
DeviceAdmin,
Sign,
KeyMint,
SignTrustTask,
CredentialWrite,
}
pub fn role_has_capability(role: &Role, cap: Capability) -> bool {
derived_capabilities_for_role(role).contains(&cap)
}
pub fn derived_capabilities_for_role(role: &Role) -> Vec<Capability> {
match role {
Role::Admin => vec![
Capability::VaultRead,
Capability::VaultWrite,
Capability::CredentialWrite,
Capability::ProxyLogin,
Capability::FillRelease,
Capability::PolicyAdmin,
Capability::DeviceAdmin,
Capability::Sign,
Capability::SignTrustTask,
Capability::KeyMint,
],
Role::Initiator => vec![
Capability::VaultRead,
Capability::VaultWrite,
Capability::CredentialWrite,
Capability::ProxyLogin,
Capability::FillRelease,
Capability::DeviceAdmin,
Capability::Sign,
Capability::SignTrustTask,
Capability::KeyMint,
],
Role::Application => vec![
Capability::VaultRead,
Capability::ProxyLogin,
Capability::FillRelease,
Capability::Sign,
Capability::SignTrustTask,
],
Role::Reader => vec![Capability::VaultRead],
Role::Monitor => vec![],
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct WakeChannel {
pub gateway: String,
pub handle: String,
#[serde(default)]
pub allowed_triggers: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DeviceBinding {
pub device_id: String,
pub display_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub platform: Option<String>,
pub registered_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_seen_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disabled_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wiped_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hpke_public_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wake: Option<WakeChannel>,
}
impl DeviceBinding {
pub fn push_capable(&self) -> bool {
self.wake.is_some() && self.disabled_at.is_none() && self.wiped_at.is_none()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case", tag = "kind", content = "contexts")]
pub enum ApproveScope {
#[default]
None,
All,
Contexts(Vec<String>),
}
impl ApproveScope {
pub fn covers(&self, context_id: &str) -> bool {
match self {
ApproveScope::None => false,
ApproveScope::All => true,
ApproveScope::Contexts(cs) => cs
.iter()
.any(|c| crate::context_path::is_ancestor_or_self(c, context_id)),
}
}
pub fn confers_nothing(&self) -> bool {
matches!(self, ApproveScope::None)
}
}
pub fn validate_approve_scope_grant(
caller: &AuthClaims,
scope: &ApproveScope,
) -> Result<(), AppError> {
match scope {
ApproveScope::None => Ok(()),
ApproveScope::All => {
if caller.is_super_admin() {
Ok(())
} else {
Err(AppError::Forbidden(
"only super admin can grant approve-all authority".into(),
))
}
}
ApproveScope::Contexts(cs) => {
if cs.is_empty() {
return Err(AppError::Forbidden(
"approve scope must name at least one context (or use 'all')".into(),
));
}
for c in cs {
caller.require_context(c)?;
}
Ok(())
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AclEntry {
pub did: String,
pub role: Role,
pub label: Option<String>,
#[serde(default)]
pub allowed_contexts: Vec<String>,
pub created_at: u64,
pub created_by: String,
#[serde(default)]
pub expires_at: Option<u64>,
#[serde(default)]
pub kind: ConsumerKind,
#[serde(default)]
pub capabilities: Vec<Capability>,
#[serde(default)]
pub device: Option<DeviceBinding>,
#[serde(default)]
pub version: u32,
#[serde(default)]
pub step_up_approver: Option<String>,
#[serde(default)]
pub step_up_require: Option<StepUpMode>,
#[serde(default)]
pub approve_scope: ApproveScope,
}
impl AclEntry {
pub fn new(did: impl Into<String>, role: Role, created_by: impl Into<String>) -> Self {
Self {
did: did.into(),
role,
label: None,
allowed_contexts: Vec::new(),
created_at: crate::auth::session::now_epoch(),
created_by: created_by.into(),
expires_at: None,
kind: ConsumerKind::default(),
capabilities: Vec::new(),
device: None,
version: 0,
step_up_approver: None,
step_up_require: None,
approve_scope: ApproveScope::None,
}
}
pub fn with_created_at(mut self, created_at: u64) -> Self {
self.created_at = created_at;
self
}
pub fn with_label(mut self, label: Option<String>) -> Self {
self.label = label;
self
}
pub fn with_contexts(mut self, allowed_contexts: Vec<String>) -> Self {
self.allowed_contexts = allowed_contexts;
self
}
pub fn with_expires_at(mut self, expires_at: Option<u64>) -> Self {
self.expires_at = expires_at;
self
}
pub fn with_kind(mut self, kind: ConsumerKind) -> Self {
self.kind = kind;
self
}
pub fn with_capabilities(mut self, capabilities: Vec<Capability>) -> Self {
self.capabilities = capabilities;
self
}
pub fn with_device(mut self, device: Option<DeviceBinding>) -> Self {
self.device = device;
self
}
pub fn with_step_up_approver(mut self, approver: Option<String>) -> Self {
self.step_up_approver = approver;
self
}
pub fn with_step_up_require(mut self, require: Option<StepUpMode>) -> Self {
self.step_up_require = require;
self
}
pub fn with_approve_scope(mut self, approve_scope: ApproveScope) -> Self {
self.approve_scope = approve_scope;
self
}
pub fn is_admin(&self) -> bool {
matches!(self.role, Role::Admin)
}
pub fn is_super_admin(&self) -> bool {
self.is_admin() && self.allowed_contexts.is_empty()
}
pub fn with_version(mut self, version: u32) -> Self {
self.version = version;
self
}
pub fn is_expired(&self, now_unix: u64) -> bool {
match self.expires_at {
Some(deadline) => now_unix >= deadline,
None => false,
}
}
pub fn etag(&self) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
self.did.hash(&mut h);
format!("W/\"{:016x}:{}\"", h.finish(), self.version)
}
}
fn acl_key(did: &str) -> String {
format!("acl:{did}")
}
pub async fn get_acl_entry(acl: &KeyspaceHandle, did: &str) -> Result<Option<AclEntry>, AppError> {
acl.get(acl_key(did)).await
}
pub async fn store_acl_entry(acl: &KeyspaceHandle, entry: &AclEntry) -> Result<(), AppError> {
acl.insert(acl_key(&entry.did), entry).await?;
crate::integrity::reseal_if_active().await
}
pub async fn update_acl_entry_versioned(
acl: &KeyspaceHandle,
mut new_entry: AclEntry,
expected_version: u32,
) -> Result<u32, AppError> {
let key = acl_key(&new_entry.did);
let current: Option<AclEntry> = acl.get(key.clone()).await?;
let stored_version = current.as_ref().map(|e| e.version).unwrap_or(0);
if stored_version != expected_version {
return Err(AppError::Conflict(format!(
"ACL entry for {} has moved ahead (expected v{}, found v{}); re-read and retry",
new_entry.did, expected_version, stored_version,
)));
}
new_entry.version = expected_version + 1;
acl.insert(key, &new_entry).await?;
crate::integrity::reseal_if_active().await?; Ok(new_entry.version)
}
pub async fn delete_acl_entry(acl: &KeyspaceHandle, did: &str) -> Result<(), AppError> {
acl.remove(acl_key(did)).await?;
crate::integrity::reseal_if_active().await
}
pub async fn list_acl_entries(acl: &KeyspaceHandle) -> Result<Vec<AclEntry>, AppError> {
let raw = acl.prefix_iter_raw("acl:").await?;
let mut entries = Vec::with_capacity(raw.len());
let mut skipped = 0usize;
for (key, value) in raw {
match serde_json::from_slice::<AclEntry>(&value) {
Ok(entry) => entries.push(entry),
Err(e) => {
skipped += 1;
tracing::warn!(
key = %String::from_utf8_lossy(&key),
error = %e,
"skipping undeserializable ACL row in list_acl_entries"
);
}
}
}
if skipped > 0 {
tracing::warn!(skipped, "list_acl_entries skipped corrupt rows");
}
Ok(entries)
}
fn now_epoch() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub async fn check_acl(acl: &KeyspaceHandle, did: &str) -> Result<Role, AppError> {
match get_acl_entry(acl, did).await? {
Some(entry) if entry.is_expired(now_epoch()) => {
Err(AppError::Forbidden(format!("ACL entry expired: {did}")))
}
Some(entry) => Ok(entry.role),
None => Err(AppError::Forbidden(format!("DID not in ACL: {did}"))),
}
}
pub async fn check_acl_full(
acl: &KeyspaceHandle,
did: &str,
) -> Result<(Role, Vec<String>), AppError> {
match get_acl_entry(acl, did).await? {
Some(entry) if entry.is_expired(now_epoch()) => {
Err(AppError::Forbidden(format!("ACL entry expired: {did}")))
}
Some(entry) => Ok((entry.role, entry.allowed_contexts)),
None => Err(AppError::Forbidden(format!("DID not in ACL: {did}"))),
}
}
pub fn validate_role_assignment(caller: &AuthClaims, target_role: &Role) -> Result<(), AppError> {
if matches!(
caller.role,
Role::Monitor | Role::Reader | Role::Application
) {
return Err(AppError::Forbidden(
"insufficient role to assign roles".into(),
));
}
if *target_role == Role::Admin && caller.role != Role::Admin {
return Err(AppError::Forbidden(
"only admins can assign the admin role".into(),
));
}
Ok(())
}
pub fn validate_acl_modification(
caller: &AuthClaims,
target_contexts: &[String],
) -> Result<(), AppError> {
if caller.is_super_admin() {
return Ok(());
}
if target_contexts.is_empty() {
return Err(AppError::Forbidden(
"only super admin can create unrestricted accounts".into(),
));
}
for ctx in target_contexts {
caller.require_context(ctx)?;
}
Ok(())
}
pub fn delegated_any_approver_covers(approver: &AclEntry, subject: &AclEntry) -> bool {
match &approver.approve_scope {
ApproveScope::All => return true,
ApproveScope::Contexts(_) => {
if !subject.allowed_contexts.is_empty()
&& subject
.allowed_contexts
.iter()
.all(|c| approver.approve_scope.covers(c))
{
return true;
}
}
ApproveScope::None => {}
}
if !approver.is_admin() {
return false;
}
if approver.allowed_contexts.is_empty() {
return true; }
!subject.allowed_contexts.is_empty()
&& subject
.allowed_contexts
.iter()
.all(|c| approver.allowed_contexts.contains(c))
}
pub fn is_acl_entry_visible(caller: &AuthClaims, entry: &AclEntry) -> bool {
if caller.is_super_admin() {
return true;
}
entry
.allowed_contexts
.iter()
.any(|ctx| caller.has_context_access(ctx))
}
#[cfg(test)]
mod delegated_any_tests {
use super::*;
fn admin(contexts: &[&str]) -> AclEntry {
AclEntry::new("did:key:zApprover", Role::Admin, "did:key:zCreator")
.with_contexts(contexts.iter().map(|s| s.to_string()).collect())
}
fn subject(role: Role, contexts: &[&str]) -> AclEntry {
AclEntry::new("did:key:zSubject", role, "did:key:zCreator")
.with_contexts(contexts.iter().map(|s| s.to_string()).collect())
}
#[test]
fn super_admin_covers_any_subject() {
let sa = admin(&[]); assert!(delegated_any_approver_covers(
&sa,
&subject(Role::Admin, &["ctx-a"])
));
assert!(delegated_any_approver_covers(
&sa,
&subject(Role::Reader, &[])
)); assert!(delegated_any_approver_covers(
&sa,
&subject(Role::Application, &["ctx-a", "ctx-b"])
));
}
#[test]
fn context_admin_covers_only_within_its_contexts() {
let ca = admin(&["ctx-a", "ctx-b"]);
assert!(delegated_any_approver_covers(
&ca,
&subject(Role::Reader, &["ctx-a"])
));
assert!(delegated_any_approver_covers(
&ca,
&subject(Role::Reader, &["ctx-a", "ctx-b"])
));
assert!(!delegated_any_approver_covers(
&ca,
&subject(Role::Reader, &["ctx-c"])
));
assert!(!delegated_any_approver_covers(
&ca,
&subject(Role::Reader, &["ctx-a", "ctx-c"])
));
}
#[test]
fn context_admin_never_covers_a_global_subject() {
let ca = admin(&["ctx-a"]);
assert!(!delegated_any_approver_covers(
&ca,
&subject(Role::Admin, &[])
));
}
#[test]
fn non_admins_never_qualify() {
for role in [
Role::Initiator,
Role::Application,
Role::Reader,
Role::Monitor,
] {
let not_admin = AclEntry::new("did:key:zX", role, "did:key:zC");
assert!(!delegated_any_approver_covers(
¬_admin,
&subject(Role::Reader, &["ctx-a"])
));
}
}
fn pure_approver(scope: ApproveScope) -> AclEntry {
AclEntry::new("did:key:zApprover", Role::Reader, "did:key:zCreator")
.with_approve_scope(scope)
}
#[test]
fn approve_scope_covers_semantics() {
assert!(!ApproveScope::None.covers("ctx-a"));
assert!(ApproveScope::All.covers("anything"));
let scoped = ApproveScope::Contexts(vec!["ctx-a".into()]);
assert!(scoped.covers("ctx-a"));
assert!(!scoped.covers("ctx-b"));
}
#[test]
fn approve_all_confers_for_any_subject_without_any_admin_authority() {
let approver = pure_approver(ApproveScope::All);
assert!(
!approver.is_admin(),
"the approver holds no admin authority"
);
assert!(delegated_any_approver_covers(
&approver,
&subject(Role::Reader, &["ctx-a"])
));
assert!(delegated_any_approver_covers(
&approver,
&subject(Role::Admin, &[])
));
}
#[test]
fn scoped_approve_authority_covers_only_within_scope() {
let approver = pure_approver(ApproveScope::Contexts(vec!["ctx-a".into()]));
assert!(delegated_any_approver_covers(
&approver,
&subject(Role::Reader, &["ctx-a"])
));
assert!(!delegated_any_approver_covers(
&approver,
&subject(Role::Reader, &["ctx-b"])
));
assert!(!delegated_any_approver_covers(
&approver,
&subject(Role::Admin, &[])
));
}
#[test]
fn no_approve_scope_and_no_admin_confers_nothing() {
let reader = pure_approver(ApproveScope::None);
assert!(!delegated_any_approver_covers(
&reader,
&subject(Role::Reader, &["ctx-a"])
));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::StoreConfig;
use crate::store::Store;
fn temp_store() -> (Store, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let config = StoreConfig {
data_dir: dir.path().to_path_buf(),
};
let store = Store::open(&config).expect("open store");
(store, dir)
}
fn sample_entry(did: &str, role: Role) -> AclEntry {
AclEntry::new(did, role, "did:key:zSetup").with_label(Some(format!("test-{did}")))
}
#[tokio::test]
async fn list_acl_entries_skips_corrupt_rows() {
let (store, _dir) = temp_store();
let ks = store.keyspace("acl").unwrap();
store_acl_entry(&ks, &sample_entry("did:key:zAlice", Role::Admin))
.await
.unwrap();
ks.insert_raw("acl:did:key:zCorrupt", b"{not valid json".to_vec())
.await
.unwrap();
store_acl_entry(&ks, &sample_entry("did:key:zBob", Role::Reader))
.await
.unwrap();
let entries = list_acl_entries(&ks).await.expect("listing must not abort");
let dids: Vec<&str> = entries.iter().map(|e| e.did.as_str()).collect();
assert!(dids.contains(&"did:key:zAlice"));
assert!(dids.contains(&"did:key:zBob"));
assert_eq!(entries.len(), 2, "the corrupt row is skipped, not surfaced");
}
fn scoped_entry(did: &str, role: Role, contexts: &[&str]) -> AclEntry {
AclEntry::new(did, role, "did:key:zSetup")
.with_contexts(contexts.iter().map(|s| s.to_string()).collect())
}
fn super_admin_claims() -> AuthClaims {
AuthClaims {
did: "did:key:zSuperAdmin".into(),
role: Role::Admin,
allowed_contexts: vec![],
session_id: "test-session".into(),
access_expires_at: 0,
amr: Vec::new(),
acr: String::new(),
}
}
fn context_admin_claims(contexts: &[&str]) -> AuthClaims {
AuthClaims {
did: "did:key:zCtxAdmin".into(),
role: Role::Admin,
allowed_contexts: contexts.iter().map(|s| s.to_string()).collect(),
session_id: "test-session".into(),
access_expires_at: 0,
amr: Vec::new(),
acr: String::new(),
}
}
#[test]
fn role_parse_accepts_canonical_lowercase() {
assert_eq!(Role::parse("admin").unwrap(), Role::Admin);
assert_eq!(Role::parse("initiator").unwrap(), Role::Initiator);
assert_eq!(Role::parse("application").unwrap(), Role::Application);
assert_eq!(Role::parse("reader").unwrap(), Role::Reader);
assert_eq!(Role::parse("monitor").unwrap(), Role::Monitor);
}
#[test]
fn role_parse_rejects_unknown() {
let err = Role::parse("godmode").expect_err("unknown role must error");
assert!(format!("{err:?}").contains("godmode"), "got {err:?}");
}
fn sample_binding() -> DeviceBinding {
DeviceBinding {
device_id: "dev-1".into(),
display_name: "Glenn's iPhone".into(),
platform: Some("iOS 19".into()),
registered_at: "2026-06-02T00:00:00Z".into(),
last_seen_at: None,
disabled_at: None,
wiped_at: None,
hpke_public_key: None,
wake: None,
}
}
#[test]
fn wake_channel_round_trips_camel_case() {
let mut b = sample_binding();
b.wake = Some(WakeChannel {
gateway: "https://gw.example".into(),
handle: "z6MkOpaque".into(),
allowed_triggers: vec!["did:web:mediator".into(), "did:web:vta".into()],
});
let json = serde_json::to_string(&b).unwrap();
assert!(json.contains("\"wake\""), "{json}");
assert!(json.contains("\"allowedTriggers\""), "{json}");
let back: DeviceBinding = serde_json::from_str(&json).unwrap();
assert_eq!(b, back);
}
#[test]
fn legacy_row_without_wake_deserialises_to_none() {
let legacy =
r#"{"deviceId":"dev-1","displayName":"old","registeredAt":"2026-01-01T00:00:00Z"}"#;
let b: DeviceBinding = serde_json::from_str(legacy).unwrap();
assert!(b.wake.is_none());
assert!(!b.push_capable());
}
#[test]
fn push_capable_requires_wake_and_active_device() {
let mut b = sample_binding();
assert!(!b.push_capable(), "no wake channel → not push-capable");
b.wake = Some(WakeChannel {
gateway: "did:web:gw".into(),
handle: "h".into(),
allowed_triggers: vec!["did:web:vta".into()],
});
assert!(b.push_capable(), "wake set + active → push-capable");
b.disabled_at = Some("2026-06-02T01:00:00Z".into());
assert!(!b.push_capable(), "disabled device is not push-capable");
b.disabled_at = None;
b.wiped_at = Some("2026-06-02T02:00:00Z".into());
assert!(!b.push_capable(), "wiped device is not push-capable");
}
#[test]
fn role_parse_rejects_case_variation() {
assert!(Role::parse("Admin").is_err(), "case-sensitive parse");
assert!(Role::parse("ADMIN").is_err());
}
#[test]
fn role_display_round_trips_with_parse() {
for role in [
Role::Admin,
Role::Initiator,
Role::Application,
Role::Reader,
Role::Monitor,
] {
let s = format!("{role}");
assert_eq!(Role::parse(&s).unwrap(), role, "display->parse cycle");
}
}
#[test]
fn entry_without_expiry_never_expires() {
let entry = sample_entry("did:key:zA", Role::Admin);
assert!(entry.expires_at.is_none());
assert!(
!entry.is_expired(u64::MAX),
"permanent entries never expire"
);
}
#[test]
fn entry_with_future_expiry_is_not_expired() {
let mut entry = sample_entry("did:key:zA", Role::Admin);
entry.expires_at = Some(now_epoch() + 3600);
assert!(!entry.is_expired(now_epoch()));
}
#[test]
fn entry_with_past_expiry_is_expired() {
let mut entry = sample_entry("did:key:zA", Role::Admin);
entry.expires_at = Some(now_epoch().saturating_sub(1));
assert!(entry.is_expired(now_epoch()));
}
#[test]
fn entry_with_exact_expiry_boundary_is_expired() {
let mut entry = sample_entry("did:key:zA", Role::Admin);
let now = now_epoch();
entry.expires_at = Some(now);
assert!(entry.is_expired(now), "now == deadline counts as expired");
}
#[tokio::test]
async fn crud_round_trip() {
let (store, _dir) = temp_store();
let acl = store.keyspace("acl").unwrap();
let entry = sample_entry("did:key:zAbc", Role::Admin);
store_acl_entry(&acl, &entry).await.unwrap();
let got = get_acl_entry(&acl, "did:key:zAbc")
.await
.unwrap()
.expect("entry should exist");
assert_eq!(got.did, entry.did);
assert_eq!(got.role, Role::Admin);
delete_acl_entry(&acl, "did:key:zAbc").await.unwrap();
let gone = get_acl_entry(&acl, "did:key:zAbc").await.unwrap();
assert!(gone.is_none(), "deleted entry must be gone");
}
#[tokio::test]
async fn list_returns_every_entry() {
let (store, _dir) = temp_store();
let acl = store.keyspace("acl").unwrap();
for did in ["did:key:zA", "did:key:zB", "did:key:zC"] {
store_acl_entry(&acl, &sample_entry(did, Role::Reader))
.await
.unwrap();
}
let entries = list_acl_entries(&acl).await.unwrap();
assert_eq!(entries.len(), 3);
let dids: std::collections::HashSet<_> = entries.iter().map(|e| e.did.as_str()).collect();
assert!(dids.contains("did:key:zA"));
assert!(dids.contains("did:key:zB"));
assert!(dids.contains("did:key:zC"));
}
#[tokio::test]
async fn check_acl_returns_role_for_present_did() {
let (store, _dir) = temp_store();
let acl = store.keyspace("acl").unwrap();
store_acl_entry(&acl, &sample_entry("did:key:zA", Role::Initiator))
.await
.unwrap();
let role = check_acl(&acl, "did:key:zA").await.unwrap();
assert_eq!(role, Role::Initiator);
}
#[tokio::test]
async fn check_acl_rejects_missing_did_as_forbidden() {
let (store, _dir) = temp_store();
let acl = store.keyspace("acl").unwrap();
let err = check_acl(&acl, "did:key:zUnknown")
.await
.expect_err("missing DID must be rejected");
assert!(
matches!(err, AppError::Forbidden(_)),
"got {err:?}; expected Forbidden so the handler emits 403"
);
}
#[tokio::test]
async fn check_acl_rejects_expired_entry() {
let (store, _dir) = temp_store();
let acl = store.keyspace("acl").unwrap();
let mut entry = sample_entry("did:key:zExpired", Role::Admin);
entry.expires_at = Some(now_epoch().saturating_sub(10));
store_acl_entry(&acl, &entry).await.unwrap();
let err = check_acl(&acl, "did:key:zExpired")
.await
.expect_err("expired entry must be rejected");
let msg = format!("{err:?}");
assert!(
matches!(err, AppError::Forbidden(_)) && msg.contains("expired"),
"got {err:?}"
);
}
#[tokio::test]
async fn check_acl_full_returns_role_and_contexts() {
let (store, _dir) = temp_store();
let acl = store.keyspace("acl").unwrap();
store_acl_entry(
&acl,
&scoped_entry("did:key:zCtx", Role::Admin, &["ctx1", "ctx2"]),
)
.await
.unwrap();
let (role, contexts) = check_acl_full(&acl, "did:key:zCtx").await.unwrap();
assert_eq!(role, Role::Admin);
assert_eq!(contexts, vec!["ctx1".to_string(), "ctx2".to_string()]);
}
#[test]
fn role_assignment_super_admin_can_assign_admin() {
validate_role_assignment(&super_admin_claims(), &Role::Admin)
.expect("super admin assigns admin");
}
#[test]
fn role_assignment_context_admin_can_assign_admin_role_itself() {
validate_role_assignment(&context_admin_claims(&["ctx1"]), &Role::Admin)
.expect("context admin CAN assign Admin role; scope is enforced separately");
}
#[test]
fn role_assignment_non_admin_cannot_assign_admin() {
let initiator = AuthClaims {
did: "did:key:zIni".into(),
role: Role::Initiator,
allowed_contexts: vec!["ctx1".into()],
session_id: "test-session".into(),
access_expires_at: 0,
amr: Vec::new(),
acr: String::new(),
};
let err = validate_role_assignment(&initiator, &Role::Admin)
.expect_err("non-admin must not assign admin");
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
}
#[test]
fn role_assignment_readers_cannot_assign_any_role() {
let reader = AuthClaims {
did: "did:key:zReader".into(),
role: Role::Reader,
allowed_contexts: vec!["ctx1".into()],
session_id: "test-session".into(),
access_expires_at: 0,
amr: Vec::new(),
acr: String::new(),
};
for target in [
Role::Admin,
Role::Initiator,
Role::Application,
Role::Reader,
Role::Monitor,
] {
let err = validate_role_assignment(&reader, &target)
.expect_err(&format!("reader must not assign {target}"));
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
}
}
#[test]
fn role_assignment_initiator_can_assign_non_admin_roles() {
let initiator = AuthClaims {
did: "did:key:zIni".into(),
role: Role::Initiator,
allowed_contexts: vec!["ctx1".into()],
session_id: "test-session".into(),
access_expires_at: 0,
amr: Vec::new(),
acr: String::new(),
};
validate_role_assignment(&initiator, &Role::Reader).expect("initiator can assign reader");
validate_role_assignment(&initiator, &Role::Application)
.expect("initiator can assign application");
}
#[test]
fn acl_modification_super_admin_can_create_unrestricted() {
validate_acl_modification(&super_admin_claims(), &[]).expect("super admin unrestricted");
validate_acl_modification(&super_admin_claims(), &["any-ctx".into()])
.expect("super admin any-context");
}
#[test]
fn acl_modification_context_admin_cannot_create_unrestricted() {
let err = validate_acl_modification(&context_admin_claims(&["ctx1"]), &[])
.expect_err("context admin must not create unrestricted");
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
}
#[test]
fn acl_modification_context_admin_confined_to_own_contexts() {
let caller = context_admin_claims(&["ctx1", "ctx2"]);
validate_acl_modification(&caller, &["ctx1".into()]).expect("own context ok");
validate_acl_modification(&caller, &["ctx1".into(), "ctx2".into()])
.expect("all-own contexts ok");
let err = validate_acl_modification(&caller, &["ctx3".into()])
.expect_err("foreign context must be rejected");
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
let err = validate_acl_modification(&caller, &["ctx1".into(), "ctx3".into()])
.expect_err("mixed own+foreign must be rejected");
assert!(matches!(err, AppError::Forbidden(_)), "got {err:?}");
}
#[test]
fn visibility_super_admin_sees_everything() {
let caller = super_admin_claims();
assert!(is_acl_entry_visible(
&caller,
&sample_entry("did:key:zA", Role::Admin)
));
assert!(is_acl_entry_visible(
&caller,
&scoped_entry("did:key:zB", Role::Admin, &["private"])
));
}
#[test]
fn visibility_context_admin_sees_overlapping_entries_only() {
let caller = context_admin_claims(&["ctx1", "ctx2"]);
assert!(is_acl_entry_visible(
&caller,
&scoped_entry("did:key:zA", Role::Reader, &["ctx1"])
));
assert!(!is_acl_entry_visible(
&caller,
&scoped_entry("did:key:zB", Role::Reader, &["ctx3"])
));
assert!(!is_acl_entry_visible(
&caller,
&sample_entry("did:key:zSuper", Role::Admin)
));
assert!(is_acl_entry_visible(
&caller,
&scoped_entry("did:key:zC", Role::Reader, &["ctx2", "ctx99"])
));
}
#[test]
fn acl_entry_without_expires_at_deserializes() {
let legacy = r#"{
"did": "did:key:zLegacy",
"role": "admin",
"label": "old admin",
"allowed_contexts": [],
"created_at": 1700000000,
"created_by": "did:key:zSetup"
}"#;
let entry: AclEntry = serde_json::from_str(legacy).expect("legacy shape must deserialize");
assert_eq!(entry.did, "did:key:zLegacy");
assert!(entry.expires_at.is_none(), "default to permanent");
}
#[test]
fn acl_entry_with_missing_allowed_contexts_defaults_to_empty() {
let legacy = r#"{
"did": "did:key:zLegacy",
"role": "admin",
"label": null,
"created_at": 1700000000,
"created_by": "did:key:zSetup"
}"#;
let entry: AclEntry = serde_json::from_str(legacy).expect("legacy shape must deserialize");
assert!(entry.allowed_contexts.is_empty());
}
#[test]
fn acl_entry_without_approve_scope_defaults_to_none() {
let legacy = r#"{
"did": "did:key:zLegacy",
"role": "reader",
"label": null,
"created_at": 1700000000,
"created_by": "did:key:zSetup"
}"#;
let entry: AclEntry = serde_json::from_str(legacy).expect("legacy shape must deserialize");
assert_eq!(entry.approve_scope, ApproveScope::None);
assert!(entry.approve_scope.confers_nothing());
}
#[test]
fn approve_scope_round_trips_on_the_wire() {
for scope in [
ApproveScope::None,
ApproveScope::All,
ApproveScope::Contexts(vec!["ctx-a".into(), "ctx-b".into()]),
] {
let e = AclEntry::new("did:key:zA", Role::Reader, "did:key:zC")
.with_approve_scope(scope.clone());
let json = serde_json::to_string(&e).unwrap();
let back: AclEntry = serde_json::from_str(&json).unwrap();
assert_eq!(back.approve_scope, scope);
}
}
#[test]
fn validate_approve_scope_grant_authority() {
validate_approve_scope_grant(&super_admin_claims(), &ApproveScope::All)
.expect("super admin may grant approve-all");
assert!(
validate_approve_scope_grant(&context_admin_claims(&["ctx-a"]), &ApproveScope::All)
.is_err(),
"a context admin must not grant approve-all"
);
let ctx_admin = context_admin_claims(&["ctx-a"]);
validate_approve_scope_grant(&ctx_admin, &ApproveScope::Contexts(vec!["ctx-a".into()]))
.expect("own context ok");
assert!(
validate_approve_scope_grant(&ctx_admin, &ApproveScope::Contexts(vec!["ctx-b".into()]))
.is_err(),
"foreign context must be rejected"
);
validate_approve_scope_grant(&ctx_admin, &ApproveScope::None).expect("none ok");
assert!(
validate_approve_scope_grant(&ctx_admin, &ApproveScope::Contexts(vec![])).is_err(),
"empty scope must name a context or use all"
);
}
}