use std::collections::BTreeMap;
use chrono::{DateTime, TimeZone, Utc};
use serde::{Deserialize, Serialize};
use crate::server::acl::{AclEntry as LocalAclEntry, Role};
use crate::server::domain::DomainScope;
use crate::server::error::AppError;
use crate::server::trust_tasks::ext::{WEBVH_EXT_KEY, WebvhAclEntryExt, WebvhQuota};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct SpecAclEntry {
pub subject: String,
pub role: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scopes: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ext: Option<BTreeMap<String, serde_json::Value>>,
}
impl SpecAclEntry {
pub fn from_local(entry: &LocalAclEntry) -> Self {
let webvh = WebvhAclEntryExt {
quota: WebvhQuota {
max_total_size: entry.max_total_size,
max_did_count: entry.max_did_count,
},
domains: entry.domains.clone(),
};
let mut ext = BTreeMap::new();
ext.insert(
WEBVH_EXT_KEY.to_string(),
serde_json::to_value(&webvh).expect("WebvhAclEntryExt serialises"),
);
Self {
subject: entry.did.clone(),
role: entry.role.to_string(),
scopes: Vec::new(),
label: entry.label.clone(),
created_at: epoch_to_datetime(entry.created_at),
created_by: None,
updated_at: None,
updated_by: None,
expires_at: None,
ext: Some(ext),
}
}
pub fn into_local(self, created_at_fallback: u64) -> Result<LocalAclEntry, AppError> {
let role: Role = self.role.parse()?;
let webvh = match self
.ext
.as_ref()
.and_then(|m| m.get(WEBVH_EXT_KEY))
.cloned()
{
Some(v) => Some(serde_json::from_value::<WebvhAclEntryExt>(v).map_err(|e| {
AppError::Validation(format!(
"ext.{WEBVH_EXT_KEY} did not deserialise as WebvhAclEntryExt: {e}"
))
})?),
None => None,
};
let (max_total_size, max_did_count, domains) = match webvh {
Some(w) => (w.quota.max_total_size, w.quota.max_did_count, w.domains),
None if matches!(role, Role::Admin | Role::Service) => (None, None, DomainScope::All),
None => {
return Err(AppError::Validation(format!(
"ext.{WEBVH_EXT_KEY} is required on Owner entries (carries `domains` scope)"
)));
}
};
let created_at = self
.created_at
.map(|dt| dt.timestamp().max(0) as u64)
.unwrap_or(created_at_fallback);
Ok(LocalAclEntry {
did: self.subject,
role,
label: self.label,
created_at,
max_total_size,
max_did_count,
domains,
})
}
}
fn epoch_to_datetime(epoch_secs: u64) -> Option<DateTime<Utc>> {
if epoch_secs == 0 {
return None;
}
Utc.timestamp_opt(epoch_secs as i64, 0).single()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn owner_entry() -> LocalAclEntry {
LocalAclEntry {
did: "did:web:alice.example".into(),
role: Role::Owner,
label: Some("Alice".into()),
created_at: 1_700_000_000,
max_total_size: Some(1_048_576),
max_did_count: Some(50),
domains: DomainScope::AllowedWithDefault {
domains: vec!["a.example".into()],
default: "a.example".into(),
},
}
}
#[test]
fn round_trip_owner_entry() {
let local = owner_entry();
let spec = SpecAclEntry::from_local(&local);
let value = serde_json::to_value(&spec).unwrap();
assert_eq!(value["subject"], "did:web:alice.example");
assert_eq!(value["role"], "owner");
assert_eq!(value["label"], "Alice");
assert_eq!(
value["ext"][WEBVH_EXT_KEY]["domains"]["kind"],
"allowed_with_default"
);
assert_eq!(
value["ext"][WEBVH_EXT_KEY]["quota"]["maxTotalSize"],
1_048_576
);
let parsed: SpecAclEntry = serde_json::from_value(value).unwrap();
let back = parsed.into_local( 0).unwrap();
assert_eq!(back.did, local.did);
assert_eq!(back.role, local.role);
assert_eq!(back.label, local.label);
assert_eq!(back.created_at, local.created_at);
assert_eq!(back.max_total_size, local.max_total_size);
assert_eq!(back.max_did_count, local.max_did_count);
assert_eq!(back.domains, local.domains);
}
#[test]
fn admin_without_webvh_ext_defaults_to_all() {
let bare = json!({
"subject": "did:web:root.example",
"role": "admin"
});
let parsed: SpecAclEntry = serde_json::from_value(bare).unwrap();
let local = parsed.into_local(1_700_000_001).unwrap();
assert_eq!(local.role, Role::Admin);
assert!(matches!(local.domains, DomainScope::All));
assert_eq!(local.created_at, 1_700_000_001);
}
#[test]
fn owner_without_webvh_ext_is_rejected() {
let bare = json!({
"subject": "did:web:bob.example",
"role": "owner"
});
let parsed: SpecAclEntry = serde_json::from_value(bare).unwrap();
let err = parsed.into_local(0).expect_err("Owner needs domains");
match err {
AppError::Validation(msg) => assert!(msg.contains(WEBVH_EXT_KEY)),
other => panic!("expected Validation, got {other:?}"),
}
}
#[test]
fn unknown_role_is_rejected() {
let bare = json!({
"subject": "did:web:x.example",
"role": "superuser"
});
let parsed: SpecAclEntry = serde_json::from_value(bare).unwrap();
assert!(parsed.into_local(0).is_err());
}
#[test]
fn preserves_unknown_ext_namespace_on_round_trip() {
let with_other = json!({
"subject": "did:web:carol.example",
"role": "admin",
"ext": {
"vnd.affinidi.webvh": {
"domains": { "kind": "all" }
},
"vnd.example.other": { "anything": true }
}
});
let parsed: SpecAclEntry = serde_json::from_value(with_other).unwrap();
assert!(
parsed
.ext
.as_ref()
.unwrap()
.contains_key("vnd.example.other")
);
let _local = parsed.into_local(1).unwrap();
}
#[test]
fn spec_acl_entry_round_trips_through_every_codegen_type() {
use trust_tasks_rs::specs::acl::{
change_role::v0_1 as change_role, grant::v0_1 as grant, list::v0_1 as list,
revoke::v0_1 as revoke, show::v0_1 as show,
};
let local = owner_entry();
let spec = SpecAclEntry::from_local(&local);
let value = serde_json::to_value(&spec).expect("SpecAclEntry serialises");
let _: grant::AclEntry =
serde_json::from_value(value.clone()).expect("grant::AclEntry accepts SpecAclEntry");
let _: revoke::AclEntry =
serde_json::from_value(value.clone()).expect("revoke::AclEntry accepts SpecAclEntry");
let _: change_role::AclEntry = serde_json::from_value(value.clone())
.expect("change_role::AclEntry accepts SpecAclEntry");
let _: show::AclEntry =
serde_json::from_value(value.clone()).expect("show::AclEntry accepts SpecAclEntry");
let _: list::AclEntry =
serde_json::from_value(value).expect("list::AclEntry accepts SpecAclEntry");
}
#[test]
fn epoch_zero_serialises_as_no_created_at() {
let mut local = owner_entry();
local.created_at = 0;
let spec = SpecAclEntry::from_local(&local);
let value = serde_json::to_value(&spec).unwrap();
assert!(value.get("createdAt").is_none());
}
}