use std::collections::{BTreeSet, HashMap};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[cfg(feature = "acl")]
use crate::{Error, Result};
pub const CAP_RPC: &str = "rpc";
pub const CAP_IPFS: &str = "ipfs";
pub const CAP_READ: &str = "read";
pub const CAP_CREATE: &str = "create";
pub const CAP_UPDATE: &str = "update";
pub const CAP_DELETE: &str = "delete";
pub const CAP_OWNER: &str = "owner";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CapabilityEntry {
Deny,
Allow(BTreeSet<String>),
}
impl CapabilityEntry {
pub fn from_caps<I, S>(caps: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self::Allow(caps.into_iter().map(Into::into).collect())
}
pub fn has(&self, cap: &str) -> bool {
match self {
Self::Deny => false,
Self::Allow(caps) => caps.contains(cap),
}
}
pub fn is_deny(&self) -> bool {
matches!(self, Self::Deny)
}
}
impl Serialize for CapabilityEntry {
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
match self {
Self::Deny => serializer.serialize_none(),
Self::Allow(caps) => {
use serde::ser::SerializeSeq;
let mut seq = serializer.serialize_seq(Some(caps.len()))?;
for cap in caps {
seq.serialize_element(cap)?;
}
seq.end()
}
}
}
}
impl<'de> Deserialize<'de> for CapabilityEntry {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
let opt: Option<Vec<String>> = Option::deserialize(deserializer)?;
match opt {
None => Ok(Self::Deny),
Some(v) if v.is_empty() => Ok(Self::Deny),
Some(v) => Ok(Self::Allow(v.into_iter().collect())),
}
}
}
pub type AclMap = HashMap<String, CapabilityEntry>;
#[cfg(feature = "acl")]
pub fn check_cap(acl: &AclMap, caller: &str, cap: &str) -> Result<()> {
let normalized = normalize_principal(caller);
if let Some(direct) = acl.get(normalized) {
return match direct {
CapabilityEntry::Deny => Err(Error::Acl(format!("operation denied for {caller}"))),
CapabilityEntry::Allow(caps) if caps.contains(cap) => Ok(()),
CapabilityEntry::Allow(_) => Err(Error::Acl(format!(
"capability '{cap}' denied for {caller}"
))),
};
}
match acl.get("*") {
None => Err(Error::Acl(format!("no ACL entry for {caller}"))),
Some(CapabilityEntry::Deny) => Err(Error::Acl(format!("operation denied for {caller}"))),
Some(CapabilityEntry::Allow(caps)) if caps.contains(cap) => Ok(()),
Some(CapabilityEntry::Allow(_)) => Err(Error::Acl(format!(
"capability '{cap}' denied for {caller}"
))),
}
}
pub fn is_valid_acl_key(key: &str) -> bool {
key == "*"
|| (key.starts_with("did:") && !key.contains('#'))
|| (key.starts_with('#') && key.len() > 1)
|| is_valid_group_key(key)
}
fn is_valid_group_key(key: &str) -> bool {
if let Some(rest) = key.strip_prefix("group:") {
if let Some(dot) = rest.find('.') {
let handle = &rest[..dot];
let name = &rest[dot + 1..];
return !handle.is_empty() && !name.is_empty();
}
}
false
}
#[cfg(feature = "acl")]
pub fn validate_acl_map(acl: &AclMap) -> Result<()> {
for key in acl.keys() {
if !is_valid_acl_key(key) {
return Err(Error::Acl(format!(
"invalid ACL key {key:?}: must be \"*\", a bare DID (\"did:ma:\u{2026}\"), \
a local entity (\"#name\"), or a group (\"group:<handle>.<name>\")"
)));
}
}
Ok(())
}
pub fn normalize_principal(did: &str) -> &str {
if did.starts_with("did:") {
if let Some(pos) = did.find('#') {
return &did[..pos];
}
}
did
}
#[cfg(test)]
mod tests {
use super::*;
fn allow(caps: &[&str]) -> CapabilityEntry {
CapabilityEntry::from_caps(caps.iter().copied())
}
fn m(entries: &[(&str, CapabilityEntry)]) -> AclMap {
entries
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect()
}
#[test]
fn wildcard_rpc_allows_rpc() {
let acl = m(&[("*", allow(&[CAP_RPC]))]);
assert!(check_cap(&acl, "did:ma:alice", CAP_RPC).is_ok());
}
#[test]
fn wildcard_rpc_denies_ipfs() {
let acl = m(&[("*", allow(&[CAP_RPC]))]);
assert!(check_cap(&acl, "did:ma:alice", CAP_IPFS).is_err());
}
#[test]
fn explicit_deny_wins_over_wildcard_allow() {
let acl = m(&[
("*", allow(&[CAP_RPC, CAP_IPFS])),
("did:ma:bandit", CapabilityEntry::Deny),
]);
assert!(check_cap(&acl, "did:ma:bandit", CAP_RPC).is_err());
}
#[test]
fn exact_match_restricts_below_wildcard() {
let acl = m(&[
("*", allow(&[CAP_RPC, CAP_IPFS])),
("did:ma:bob", allow(&[CAP_RPC])),
]);
assert!(check_cap(&acl, "did:ma:bob", CAP_RPC).is_ok());
assert!(check_cap(&acl, "did:ma:bob", CAP_IPFS).is_err());
}
#[test]
fn did_url_caller_is_normalized() {
let acl = m(&[("did:ma:alice", allow(&[CAP_RPC, CAP_IPFS]))]);
assert!(check_cap(&acl, "did:ma:alice#sign", CAP_RPC).is_ok());
}
#[test]
fn no_entry_default_deny() {
assert!(check_cap(&AclMap::new(), "did:ma:anyone", CAP_RPC).is_err());
}
#[test]
fn wildcard_deny_blocks_all() {
let acl = m(&[("*", CapabilityEntry::Deny)]);
assert!(check_cap(&acl, "did:ma:anyone", CAP_RPC).is_err());
}
#[test]
fn local_entity_key_allowed() {
let acl = m(&[("#agent", allow(&[CAP_RPC]))]);
assert!(check_cap(&acl, "#agent", CAP_RPC).is_ok());
assert!(check_cap(&acl, "#other", CAP_RPC).is_err());
}
#[test]
fn arbitrary_capability_works() {
let acl = m(&[("did:ma:alice", allow(&["emote", "reply"]))]);
assert!(check_cap(&acl, "did:ma:alice", "emote").is_ok());
assert!(check_cap(&acl, "did:ma:alice", "reply").is_ok());
assert!(check_cap(&acl, "did:ma:alice", "admin").is_err());
}
#[test]
fn owner_capability_is_just_a_string() {
let acl = m(&[("did:ma:alice", allow(&[CAP_OWNER]))]);
assert!(check_cap(&acl, "did:ma:alice", CAP_OWNER).is_ok());
assert!(check_cap(&acl, "did:ma:alice", CAP_RPC).is_err());
}
#[test]
fn normalize_strips_fragment() {
assert_eq!(normalize_principal("did:ma:foo#bar"), "did:ma:foo");
assert_eq!(normalize_principal("did:ma:foo"), "did:ma:foo");
assert_eq!(normalize_principal("#local"), "#local");
assert_eq!(normalize_principal("*"), "*");
}
#[test]
fn valid_acl_keys() {
assert!(is_valid_acl_key("*"));
assert!(is_valid_acl_key("did:ma:Qmfoo"));
assert!(is_valid_acl_key("#agent"));
assert!(is_valid_acl_key("group:alice.venner"));
assert!(is_valid_acl_key("group:runtime.admins"));
assert!(!is_valid_acl_key("did:ma:Qmfoo#sign"));
assert!(!is_valid_acl_key("#"));
assert!(!is_valid_acl_key(""));
assert!(!is_valid_acl_key("group:noname"));
assert!(!is_valid_acl_key("group:.nohandle"));
assert!(!is_valid_acl_key("group:handle."));
}
#[cfg(feature = "acl")]
#[test]
fn capability_serde_roundtrip() {
let acl: AclMap = [
(
"*".to_string(),
CapabilityEntry::from_caps(["rpc", "create"]),
),
("did:ma:bandit".to_string(), CapabilityEntry::Deny),
]
.into_iter()
.collect();
let yaml = serde_yaml::to_string(&acl).unwrap();
let roundtrip: AclMap = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(acl, roundtrip);
}
#[cfg(feature = "acl")]
#[test]
fn yaml_null_deserializes_to_deny() {
let yaml = "'did:ma:x': ~\n'*':\n- rpc\n- create\n";
let acl: AclMap = serde_yaml::from_str(yaml).unwrap();
assert_eq!(acl.get("did:ma:x"), Some(&CapabilityEntry::Deny));
assert_eq!(
acl.get("*"),
Some(&CapabilityEntry::from_caps(["rpc", "create"]))
);
}
}