use ed25519_dalek::VerifyingKey;
use std::collections::HashMap;
use crate::{NetError, identity::parse_public};
const WILDCARD: &str = "*";
const ACL_CLASSES: [MsgClass; 3] = [MsgClass::Any, MsgClass::Note, MsgClass::Control];
#[derive(Debug, Clone)]
pub struct RoleAcl {
pub name: String,
pub key: VerifyingKey,
pub admin: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AclEdge {
pub from: String,
pub to: String,
pub class: MsgClass,
pub admin: bool,
}
#[derive(Debug, Clone, Default)]
pub struct AclTable {
roles: HashMap<String, RoleAcl>,
edges: HashMap<String, Vec<EdgeRow>>,
}
#[derive(Debug, Clone)]
struct EdgeRow {
to: String,
class: MsgClass,
admin: bool,
}
impl AclTable {
pub fn new(
roles: impl IntoIterator<Item = (String, String, bool)>,
edges: impl IntoIterator<Item = AclEdge>,
) -> Result<Self, NetError> {
let mut table = AclTable::default();
for (name, key, admin) in roles {
if table.roles.contains_key(&name) {
return Err(NetError::MalformedKey(format!("duplicate role {name}")));
}
let key = parse_public(&key)?;
table
.roles
.insert(name.clone(), RoleAcl { name, key, admin });
}
for edge in edges {
table.push_edge(edge)?;
}
Ok(table)
}
pub fn insert_role(&mut self, name: String, key: String, admin: bool) -> Result<(), NetError> {
if self.roles.contains_key(&name) {
return Err(NetError::MalformedKey(format!("duplicate role {name}")));
}
let key = parse_public(&key)?;
self.roles
.insert(name.clone(), RoleAcl { name, key, admin });
Ok(())
}
pub fn insert_edge(&mut self, edge: AclEdge) -> Result<(), NetError> {
self.push_edge(edge)
}
fn push_edge(&mut self, edge: AclEdge) -> Result<(), NetError> {
let AclEdge {
from,
to,
class,
admin,
} = edge;
for endpoint in [&from, &to] {
if endpoint == WILDCARD {
return Err(NetError::MalformedKey(format!(
"acl edge {from} -> {to} carries the wildcard {WILDCARD:?}; expand it in onlyne-config first"
)));
}
}
let rows = self.edges.entry(from).or_default();
if rows.iter().any(|row| row.to == to && row.class == class) {
return Ok(());
}
rows.push(EdgeRow { to, class, admin });
Ok(())
}
pub fn get(&self, name: &str) -> Option<&RoleAcl> {
self.roles.get(name)
}
pub fn roles(&self) -> impl Iterator<Item = &RoleAcl> {
self.roles.values()
}
pub fn permits(&self, from: &str, to: &str, class: MsgClass) -> bool {
self.edges
.get(from)
.is_some_and(|rows| rows.iter().any(|row| row.to == to && row.class == class))
}
}
pub fn table_from(
entries: impl IntoIterator<Item = (String, String, bool, Vec<String>, Vec<String>)>,
) -> Result<AclTable, NetError> {
let entries: Vec<_> = entries.into_iter().collect();
let mut table = AclTable::default();
for (name, key, admin, senders, targets) in &entries {
for (side, list) in [("allowed_senders", senders), ("allowed_targets", targets)] {
if list.iter().any(|endpoint| endpoint == WILDCARD) {
return Err(NetError::MalformedKey(format!(
"role {name} list {side} carries the wildcard {WILDCARD:?}; expand it in onlyne-config first"
)));
}
}
table.insert_role(name.clone(), key.clone(), *admin)?;
}
for (name, _, admin, _, targets) in &entries {
for to in targets {
let accepted = entries.iter().any(|(other, _, _, senders, _)| {
other == to && senders.iter().any(|sender| sender == name)
});
if !accepted {
continue;
}
for class in ACL_CLASSES {
table.insert_edge(AclEdge {
from: name.clone(),
to: to.clone(),
class,
admin: *admin,
})?;
}
}
}
Ok(table)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MsgClass {
Any,
Note,
Control,
}
impl MsgClass {
pub fn name(self) -> &'static str {
match self {
MsgClass::Any => "any",
MsgClass::Note => "note",
MsgClass::Control => "control",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AclDenyReason {
UnknownRole,
SenderNotAllowed,
TargetNotAllowed,
AdminRequired,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AclDeny {
pub reason: AclDenyReason,
pub field: &'static str,
pub detail: String,
}
pub fn acl_allows(
table: &AclTable,
from: &str,
to: &str,
class: MsgClass,
owner: Option<&str>,
) -> Result<(), AclDeny> {
if from == OPERATOR_ROLE && !table.roles.contains_key(from) {
return table.roles.get(to).map(|_| ()).ok_or_else(|| AclDeny {
reason: AclDenyReason::UnknownRole,
field: "to.role",
detail: format!("unknown target role {to}"),
});
}
if to == OPERATOR_ROLE && !table.roles.contains_key(to) {
return table.roles.get(from).map(|_| ()).ok_or_else(|| AclDeny {
reason: AclDenyReason::UnknownRole,
field: "from.role",
detail: format!("unknown sender role {from}"),
});
}
table.roles.get(from).ok_or_else(|| AclDeny {
reason: AclDenyReason::UnknownRole,
field: "from.role",
detail: format!("unknown sender role {from}"),
})?;
table.roles.get(to).ok_or_else(|| AclDeny {
reason: AclDenyReason::UnknownRole,
field: "to.role",
detail: format!("unknown target role {to}"),
})?;
let edge = table
.edges
.get(from)
.and_then(|rows| rows.iter().find(|row| row.to == to && row.class == class))
.ok_or_else(|| AclDeny {
reason: AclDenyReason::TargetNotAllowed,
field: "to.role",
detail: format!(
"role {from} may not reach role {to} with a {} message",
class.name()
),
})?;
if class == MsgClass::Control && owner != Some(from) && !edge.admin {
return Err(AclDeny {
reason: AclDenyReason::AdminRequired,
field: "admin",
detail: format!("role {from} is not an administrator or task owner"),
});
}
Ok(())
}
pub const OPERATOR_ROLE: &str = "_supervisor";
#[cfg(test)]
mod tests {
use super::*;
const KEY: &str = "ed25519/AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=";
#[test]
fn the_undeclared_operator_reaches_every_registered_role() {
let table = AclTable::new(
[
("planner".to_string(), KEY.to_string(), false),
("scriber".to_string(), KEY.to_string(), false),
],
[AclEdge {
from: "planner".to_string(),
to: "scriber".to_string(),
class: MsgClass::Any,
admin: false,
}],
)
.unwrap();
for class in [MsgClass::Any, MsgClass::Note, MsgClass::Control] {
assert!(acl_allows(&table, "_supervisor", "planner", class, None).is_ok());
}
for class in [MsgClass::Any, MsgClass::Note, MsgClass::Control] {
assert!(acl_allows(&table, "planner", "_supervisor", class, None).is_ok());
}
let deny = acl_allows(&table, "ghost", "_supervisor", MsgClass::Any, None).unwrap_err();
assert_eq!(deny.reason, AclDenyReason::UnknownRole);
let deny = acl_allows(&table, "_supervisor", "ghost", MsgClass::Any, None).unwrap_err();
assert_eq!(deny.reason, AclDenyReason::UnknownRole);
let deny = acl_allows(&table, "scriber", "planner", MsgClass::Any, None).unwrap_err();
assert_eq!(deny.reason, AclDenyReason::TargetNotAllowed);
}
#[test]
fn a_declared_operator_keeps_its_declared_reach() {
let table = AclTable::new(
[
("_supervisor".to_string(), KEY.to_string(), true),
("planner".to_string(), KEY.to_string(), false),
("scriber".to_string(), KEY.to_string(), false),
],
[AclEdge {
from: "_supervisor".to_string(),
to: "planner".to_string(),
class: MsgClass::Any,
admin: true,
}],
)
.unwrap();
assert!(acl_allows(&table, "_supervisor", "planner", MsgClass::Any, None).is_ok());
let deny = acl_allows(&table, "_supervisor", "scriber", MsgClass::Any, None).unwrap_err();
assert_eq!(deny.reason, AclDenyReason::TargetNotAllowed);
}
}