use std::fmt;
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use super::error::AppError;
use super::store::KeyspaceHandle;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub enum Role {
Admin,
Owner,
Service,
MetricsReader,
}
impl fmt::Display for Role {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Role::Admin => write!(f, "admin"),
Role::Owner => write!(f, "owner"),
Role::Service => write!(f, "service"),
Role::MetricsReader => write!(f, "metricsReader"),
}
}
}
impl std::str::FromStr for Role {
type Err = AppError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"admin" => Ok(Role::Admin),
"owner" => Ok(Role::Owner),
"service" => Ok(Role::Service),
"metricsReader" => Ok(Role::MetricsReader),
_ => Err(AppError::Validation(format!("unknown role: {s}"))),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AclEntry {
pub did: String,
pub role: Role,
pub label: Option<String>,
pub created_at: u64,
#[serde(default)]
pub max_total_size: Option<u64>,
#[serde(default)]
pub max_did_count: Option<u64>,
#[serde(default)]
pub domains: super::domain::DomainScope,
}
#[derive(Debug, Deserialize)]
pub struct CreateAclRequest {
pub did: String,
pub role: Role,
pub label: Option<String>,
#[serde(default)]
pub max_total_size: Option<u64>,
#[serde(default)]
pub max_did_count: Option<u64>,
#[serde(default)]
pub domains: Option<super::domain::DomainScope>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateAclRequest {
pub role: Option<Role>,
pub label: Option<String>,
pub max_total_size: Option<u64>,
pub max_did_count: Option<u64>,
#[serde(default)]
pub domains: Option<super::domain::DomainScope>,
}
#[derive(Debug, Serialize)]
pub struct AclEntryResponse {
pub did: String,
pub role: Role,
pub label: Option<String>,
pub created_at: u64,
pub max_total_size: Option<u64>,
pub max_did_count: Option<u64>,
pub domains: super::domain::DomainScope,
}
impl From<AclEntry> for AclEntryResponse {
fn from(e: AclEntry) -> Self {
Self {
did: e.did,
role: e.role,
label: e.label,
created_at: e.created_at,
max_total_size: e.max_total_size,
max_did_count: e.max_did_count,
domains: e.domains,
}
}
}
#[derive(Debug, Serialize)]
pub struct AclListResponse {
pub entries: Vec<AclEntryResponse>,
}
impl AclEntry {
pub fn effective_max_total_size(&self, global_default: u64) -> u64 {
self.max_total_size.unwrap_or(global_default)
}
pub fn effective_max_did_count(&self, global_default: u64) -> u64 {
self.max_did_count.unwrap_or(global_default)
}
}
fn acl_key(did: &str) -> String {
format!("acl:{did}")
}
pub const MAX_DID_LEN: usize = 2048;
pub fn validate_did_format(did: &str) -> Result<String, AppError> {
let trimmed = did.trim();
if trimmed.is_empty() {
return Err(AppError::Validation("DID must not be empty".into()));
}
if trimmed.len() > MAX_DID_LEN {
return Err(AppError::Validation(format!(
"DID exceeds maximum length of {MAX_DID_LEN} characters"
)));
}
if !trimmed.starts_with("did:") {
return Err(AppError::Validation(
"DID must start with 'did:' scheme".into(),
));
}
if trimmed.chars().any(|c| c.is_ascii_control()) {
return Err(AppError::Validation(
"DID must not contain ASCII control characters".into(),
));
}
Ok(trimmed.to_string())
}
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
}
pub fn stage_acl_entry(
batch: &mut super::store::WriteBatch,
acl: &KeyspaceHandle,
entry: &AclEntry,
) -> Result<(), AppError> {
batch.insert(acl, acl_key(&entry.did), entry)
}
pub async fn delete_acl_entry(acl: &KeyspaceHandle, did: &str) -> Result<(), AppError> {
acl.remove(acl_key(did)).await
}
pub async fn list_acl_entries(acl: &KeyspaceHandle) -> Result<Vec<AclEntry>, AppError> {
let raw = acl.prefix_iter_raw("acl:").await?;
raw.into_iter()
.map(|(_, v)| serde_json::from_slice(&v).map_err(AppError::from))
.collect()
}
pub async fn count_all_scoped_owners(acl: &KeyspaceHandle) -> Result<u64, AppError> {
let entries = list_acl_entries(acl).await?;
Ok(entries
.into_iter()
.filter(|e| {
matches!(e.role, Role::Owner)
&& matches!(e.domains, crate::server::domain::DomainScope::All)
})
.count() as u64)
}
pub async fn seed_provisioning_vta_acl(
acl: &KeyspaceHandle,
vta_did: &str,
) -> Result<bool, AppError> {
if get_acl_entry(acl, vta_did).await?.is_some() {
return Ok(false);
}
let entry = AclEntry {
did: vta_did.to_string(),
role: Role::Admin,
label: Some("provisioning VTA".into()),
created_at: super::auth::session::now_epoch(),
max_total_size: None,
max_did_count: None,
domains: super::domain::DomainScope::All,
};
store_acl_entry(acl, &entry).await?;
Ok(true)
}
pub async fn check_acl(acl: &KeyspaceHandle, did: &str) -> Result<Role, AppError> {
if did.len() > 512 {
return Err(AppError::Validation("DID exceeds maximum length".into()));
}
match get_acl_entry(acl, did).await? {
Some(entry) => {
debug!(did = %did, role = %entry.role, "ACL check passed");
Ok(entry.role)
}
None => {
warn!(did = %did, "ACL check denied: DID not in ACL");
Err(AppError::Forbidden(format!("DID not in ACL: {did}")))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
fn make_entry(max_total_size: Option<u64>, max_did_count: Option<u64>) -> AclEntry {
AclEntry {
did: "did:example:test".into(),
role: Role::Owner,
label: None,
created_at: 0,
max_total_size,
max_did_count,
domains: crate::server::domain::DomainScope::All,
}
}
#[test]
fn legacy_acl_entry_deserialises_with_domains_default_all() {
let legacy = r#"{
"did": "did:example:legacy",
"role": "owner",
"label": null,
"created_at": 1700000000,
"max_total_size": null,
"max_did_count": null
}"#;
let entry: AclEntry = serde_json::from_str(legacy).unwrap();
assert_eq!(entry.did, "did:example:legacy");
assert!(matches!(
entry.domains,
crate::server::domain::DomainScope::All
));
}
#[test]
fn entry_with_explicit_allowed_scope_round_trips() {
let original = AclEntry {
did: "did:example:scoped".into(),
role: Role::Owner,
label: None,
created_at: 0,
max_total_size: None,
max_did_count: None,
domains: crate::server::domain::DomainScope::Allowed {
domains: vec!["a.example".into(), "b.example".into()],
},
};
let json = serde_json::to_string(&original).unwrap();
let back: AclEntry = serde_json::from_str(&json).unwrap();
assert!(matches!(
back.domains,
crate::server::domain::DomainScope::Allowed { ref domains }
if domains == &["a.example", "b.example"]
));
}
#[test]
fn role_from_str_admin() {
assert_eq!(Role::from_str("admin").unwrap(), Role::Admin);
}
#[test]
fn role_from_str_owner() {
assert_eq!(Role::from_str("owner").unwrap(), Role::Owner);
}
#[test]
fn role_from_str_service() {
assert_eq!(Role::from_str("service").unwrap(), Role::Service);
}
#[test]
fn role_from_str_metricsreader() {
assert_eq!(
Role::from_str("metricsReader").unwrap(),
Role::MetricsReader
);
}
#[test]
fn role_from_str_unknown_returns_error() {
assert!(Role::from_str("superuser").is_err());
}
#[test]
fn role_display() {
assert_eq!(Role::Admin.to_string(), "admin");
assert_eq!(Role::Owner.to_string(), "owner");
assert_eq!(Role::Service.to_string(), "service");
assert_eq!(Role::MetricsReader.to_string(), "metricsReader");
}
#[test]
fn metricsreader_display_fromstr_serde_agree() {
let serialized = serde_json::to_string(&Role::MetricsReader).unwrap();
assert_eq!(serialized, "\"metricsReader\"");
assert_eq!(Role::MetricsReader.to_string(), "metricsReader");
assert_eq!(
Role::from_str(&Role::MetricsReader.to_string()).unwrap(),
Role::MetricsReader
);
let back: Role = serde_json::from_str(&serialized).unwrap();
assert_eq!(back, Role::MetricsReader);
}
#[test]
fn effective_max_total_size_uses_override_when_set() {
let entry = make_entry(Some(500_000), None);
assert_eq!(entry.effective_max_total_size(1_000_000), 500_000);
}
#[test]
fn effective_max_total_size_falls_back_to_global() {
let entry = make_entry(None, None);
assert_eq!(entry.effective_max_total_size(1_000_000), 1_000_000);
}
#[test]
fn effective_max_total_size_override_zero_is_respected() {
let entry = make_entry(Some(0), None);
assert_eq!(entry.effective_max_total_size(1_000_000), 0);
}
#[test]
fn effective_max_did_count_uses_override_when_set() {
let entry = make_entry(None, Some(5));
assert_eq!(entry.effective_max_did_count(20), 5);
}
#[test]
fn effective_max_did_count_falls_back_to_global() {
let entry = make_entry(None, None);
assert_eq!(entry.effective_max_did_count(20), 20);
}
#[test]
fn effective_max_did_count_override_zero_is_respected() {
let entry = make_entry(None, Some(0));
assert_eq!(entry.effective_max_did_count(20), 0);
}
#[test]
fn acl_entry_deserialize_without_limit_fields() {
let json = r#"{"did":"did:example:old","role":"admin","label":null,"created_at":100}"#;
let entry: AclEntry = serde_json::from_str(json).unwrap();
assert_eq!(entry.did, "did:example:old");
assert_eq!(entry.role, Role::Admin);
assert!(entry.max_total_size.is_none());
assert!(entry.max_did_count.is_none());
}
#[test]
fn acl_entry_deserialize_with_limit_fields() {
let json = r#"{"did":"did:example:new","role":"owner","label":"test","created_at":200,"max_total_size":500000,"max_did_count":10}"#;
let entry: AclEntry = serde_json::from_str(json).unwrap();
assert_eq!(entry.max_total_size, Some(500_000));
assert_eq!(entry.max_did_count, Some(10));
}
#[test]
fn acl_entry_roundtrip_serialization() {
let entry = make_entry(Some(1_000_000), Some(50));
let json = serde_json::to_string(&entry).unwrap();
let deserialized: AclEntry = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.max_total_size, Some(1_000_000));
assert_eq!(deserialized.max_did_count, Some(50));
}
#[test]
fn validate_did_format_accepts_canonical() {
let did = "did:webvh:scid:host.example:tenant";
assert_eq!(validate_did_format(did).unwrap(), did);
}
#[test]
fn validate_did_format_trims_whitespace() {
let trimmed = validate_did_format(" did:web:tenant\n").unwrap();
assert_eq!(trimmed, "did:web:tenant");
}
#[test]
fn validate_did_format_rejects_empty() {
assert!(validate_did_format("").is_err());
assert!(validate_did_format(" ").is_err());
}
#[test]
fn validate_did_format_rejects_missing_did_prefix() {
let err = validate_did_format("example.com").unwrap_err();
assert!(matches!(err, AppError::Validation(_)));
}
#[test]
fn validate_did_format_rejects_oversized() {
let did = format!("did:web:{}", "a".repeat(MAX_DID_LEN));
assert!(validate_did_format(&did).is_err());
}
#[test]
fn validate_did_format_accepts_realistic_did_peer() {
let did = format!(
"did:peer:2.Vz6Mk{}.Ez6LS{}",
"x".repeat(300),
"y".repeat(300)
);
assert!(did.len() < MAX_DID_LEN);
assert!(validate_did_format(&did).is_ok());
}
#[test]
fn validate_did_format_rejects_control_chars() {
for ch in ["\n", "\t", "\0", "\x07"] {
let did = format!("did:web:tenant{ch}rest");
assert!(
validate_did_format(&did).is_err(),
"control char {:?} should be rejected",
ch
);
}
}
use crate::server::config::StoreConfig;
use crate::server::domain::DomainScope;
use crate::server::store::{KS_ACL, Store};
async fn fjall_store() -> Store {
let dir = tempfile::tempdir().expect("tempdir");
let cfg = StoreConfig {
data_dir: dir.path().to_path_buf(),
..StoreConfig::default()
};
std::mem::forget(dir);
Store::open(&cfg).await.expect("open fjall")
}
fn entry(did: &str, role: Role, domains: DomainScope) -> AclEntry {
AclEntry {
did: did.into(),
role,
label: None,
created_at: 0,
max_total_size: None,
max_did_count: None,
domains,
}
}
#[tokio::test]
async fn count_all_scoped_owners_counts_only_owner_all() {
let store = fjall_store().await;
let ks = store.keyspace(KS_ACL).unwrap();
store_acl_entry(&ks, &entry("did:e:o1", Role::Owner, DomainScope::All))
.await
.unwrap();
store_acl_entry(&ks, &entry("did:e:o2", Role::Owner, DomainScope::All))
.await
.unwrap();
store_acl_entry(
&ks,
&entry(
"did:e:o3",
Role::Owner,
DomainScope::Allowed {
domains: vec!["a.example".into()],
},
),
)
.await
.unwrap();
store_acl_entry(&ks, &entry("did:e:a1", Role::Admin, DomainScope::All))
.await
.unwrap();
store_acl_entry(&ks, &entry("did:e:s1", Role::Service, DomainScope::All))
.await
.unwrap();
let count = count_all_scoped_owners(&ks).await.unwrap();
assert_eq!(
count, 2,
"must count only Owner+DomainScope::All; Admin / Service / scoped owners excluded"
);
}
#[tokio::test]
async fn count_all_scoped_owners_empty_acl_returns_zero() {
let store = fjall_store().await;
let ks = store.keyspace(KS_ACL).unwrap();
assert_eq!(count_all_scoped_owners(&ks).await.unwrap(), 0);
}
#[tokio::test]
async fn seed_provisioning_vta_acl_creates_admin_entry() {
let store = fjall_store().await;
let ks = store.keyspace(KS_ACL).unwrap();
let vta = "did:webvh:vta.example.com";
let created = seed_provisioning_vta_acl(&ks, vta).await.unwrap();
assert!(created, "a fresh entry must be written");
let entry = get_acl_entry(&ks, vta)
.await
.unwrap()
.expect("entry stored");
assert_eq!(entry.role, Role::Admin, "VTA must be Admin to publish");
assert_eq!(entry.label.as_deref(), Some("provisioning VTA"));
assert!(matches!(entry.domains, DomainScope::All));
assert_eq!(check_acl(&ks, vta).await.unwrap(), Role::Admin);
}
#[tokio::test]
async fn seed_provisioning_vta_acl_is_idempotent() {
let store = fjall_store().await;
let ks = store.keyspace(KS_ACL).unwrap();
let vta = "did:webvh:vta.example.com";
assert!(seed_provisioning_vta_acl(&ks, vta).await.unwrap());
let created_at = get_acl_entry(&ks, vta).await.unwrap().unwrap().created_at;
assert!(
!seed_provisioning_vta_acl(&ks, vta).await.unwrap(),
"re-seeding an existing entry must be a no-op"
);
let entry = get_acl_entry(&ks, vta).await.unwrap().unwrap();
assert_eq!(
entry.created_at, created_at,
"existing entry left untouched"
);
assert_eq!(entry.role, Role::Admin);
}
#[tokio::test]
async fn seed_provisioning_vta_acl_respects_existing_operator_entry() {
let store = fjall_store().await;
let ks = store.keyspace(KS_ACL).unwrap();
let vta = "did:webvh:vta.example.com";
store_acl_entry(&ks, &entry(vta, Role::Owner, DomainScope::All))
.await
.unwrap();
assert!(
!seed_provisioning_vta_acl(&ks, vta).await.unwrap(),
"must not overwrite an operator-set entry"
);
let entry = get_acl_entry(&ks, vta).await.unwrap().unwrap();
assert_eq!(
entry.role,
Role::Owner,
"operator's role choice must be preserved"
);
}
}