use super::caller::{Caller, Scope};
use crate::error::{ServiceError, ERROR_FORBIDDEN, ERROR_NOT_ALLOWED};
use crate::record::Allowlist;
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Transport {
Local,
Remote,
Both,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MethodPolicy {
transport: Transport,
apps: BTreeSet<String>,
scopes: BTreeSet<Scope>,
}
impl MethodPolicy {
pub fn local<I, S>(apps: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
transport: Transport::Local,
apps: apps.into_iter().map(Into::into).collect(),
scopes: BTreeSet::new(),
}
}
pub fn remote<I, S>(scopes: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<Scope>,
{
Self {
transport: Transport::Remote,
apps: BTreeSet::new(),
scopes: scopes.into_iter().map(Into::into).collect(),
}
}
pub fn and_local<I, S>(mut self, apps: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.apps.extend(apps.into_iter().map(Into::into));
self.transport = match self.transport {
Transport::Remote => Transport::Both,
t => t,
};
self
}
pub fn and_remote<I, S>(mut self, scopes: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<Scope>,
{
self.scopes.extend(scopes.into_iter().map(Into::into));
self.transport = match self.transport {
Transport::Local => Transport::Both,
t => t,
};
self
}
pub fn transport(&self) -> Transport {
self.transport
}
fn permits_scopes(&self, held: &BTreeSet<Scope>) -> bool {
self.transport != Transport::Local && self.scopes.iter().any(|s| held.contains(s))
}
fn device_scopes_named(&self) -> bool {
!self.scopes.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TableError {
EmptyMethod,
BadMethod(String),
NoDeviceScopes(String),
}
impl std::fmt::Display for TableError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TableError::EmptyMethod => f.write_str("method id is empty"),
TableError::BadMethod(m) => write!(f, "method id {m:?} is not a plain method name"),
TableError::NoDeviceScopes(m) => write!(
f,
"method {m:?} is reachable by devices but names no scope (an empty scope set admits no device)"
),
}
}
}
impl std::error::Error for TableError {}
pub fn validate_method_id(id: &str) -> Result<(), TableError> {
if id.is_empty() {
return Err(TableError::EmptyMethod);
}
let bad = |c: char| {
c.is_whitespace()
|| c.is_control()
|| matches!(
c,
'*' | '?' | '[' | ']' | '{' | '}' | '/' | '%' | '#' | '\\'
)
};
if id.chars().any(bad) {
return Err(TableError::BadMethod(id.to_string()));
}
Ok(())
}
pub(crate) fn authorize_allowlist(
list: &Allowlist,
caller: &Caller,
method: &str,
) -> Result<(), ServiceError> {
let ok = match caller {
Caller::LocalApp { app, .. } => list.permits(app, method),
Caller::Device { .. } => method == "ping",
};
if ok {
Ok(())
} else {
Err(ServiceError::new(
ERROR_NOT_ALLOWED,
format!("{} may not call {method}", caller.label()),
))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MethodTable {
methods: BTreeMap<String, MethodPolicy>,
}
impl MethodTable {
pub fn new() -> Self {
Self::default()
}
pub fn try_method(
mut self,
id: impl Into<String>,
policy: MethodPolicy,
) -> Result<Self, TableError> {
let id = id.into();
validate_method_id(&id)?;
if policy.transport != Transport::Local && !policy.device_scopes_named() {
return Err(TableError::NoDeviceScopes(id));
}
self.methods.insert(id, policy);
Ok(self)
}
#[track_caller]
pub fn method(self, id: impl Into<String>, policy: MethodPolicy) -> Self {
match self.try_method(id, policy) {
Ok(t) => t,
Err(e) => panic!("MethodTable::method: {e}"),
}
}
pub fn get(&self, id: &str) -> Option<&MethodPolicy> {
self.methods.get(id)
}
pub fn ids(&self) -> impl Iterator<Item = &str> {
self.methods.keys().map(String::as_str)
}
pub fn allowlist(&self) -> Allowlist {
let mut a = Allowlist::new();
for (id, p) in &self.methods {
if p.transport == Transport::Remote {
continue;
}
for app in &p.apps {
a = a.allow(app, [id.clone()]);
}
}
a
}
pub fn authorize_device_call(&self, caller: &Caller, method: &str) -> Result<(), ServiceError> {
let both = self
.methods
.get(method)
.is_some_and(|p| p.transport == Transport::Both);
if method != "ping" && !both {
return Err(ServiceError::new(
ERROR_FORBIDDEN,
format!("{} may not call {method}", caller.label()),
));
}
self.authorize(caller, method)
}
pub fn authorize(&self, caller: &Caller, method: &str) -> Result<(), ServiceError> {
if method == "ping" {
return Ok(());
}
let policy = self.methods.get(method);
match caller {
Caller::LocalApp { .. } => authorize_allowlist(&self.allowlist(), caller, method),
Caller::Device { id, scopes } => {
if policy.is_some_and(|p| p.permits_scopes(scopes)) {
Ok(())
} else {
Err(ServiceError::new(
ERROR_FORBIDDEN,
format!("{id} may not call {method}"),
))
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::peer::PeerIdentity;
fn table() -> MethodTable {
MethodTable::new()
.method("echo", MethodPolicy::local(["*"]))
.method("both", MethodPolicy::local(["a"]).and_remote(["s1"]))
.method("up", MethodPolicy::remote(["s2"]))
}
#[test]
fn allowlist_is_derived_and_skips_remote_only() {
let a = table().allowlist();
assert!(a.permits("any", "echo"));
assert!(a.permits("a", "both"));
assert!(!a.permits("b", "both"));
assert!(!a.permits("a", "up"));
assert!(a.permits("b", "ping"));
}
#[test]
fn an_empty_scope_set_admits_no_device() {
let p = MethodPolicy::remote(Vec::<String>::new());
let held: BTreeSet<Scope> = ["s1".to_string(), "any".to_string()].into();
assert!(!p.permits_scopes(&held));
assert!(!p.permits_scopes(&BTreeSet::new()));
let q = MethodPolicy::local(["a"]).and_remote(Vec::<String>::new());
assert!(!q.permits_scopes(&held));
}
#[test]
fn device_call_needs_both_transports() {
let t = table();
let dev = Caller::device("dev_1", ["s1", "s2"]);
assert!(t.authorize_device_call(&dev, "both").is_ok());
for m in ["up", "echo", "missing"] {
assert_eq!(
t.authorize_device_call(&dev, m).unwrap_err().code,
ERROR_FORBIDDEN,
"{m}"
);
}
let no_scope = Caller::device("dev_2", ["other"]);
assert_eq!(
t.authorize_device_call(&no_scope, "both").unwrap_err().code,
ERROR_FORBIDDEN
);
}
#[test]
fn authorize_by_caller() {
let t = table();
let app = |n: &str| Caller::local_app(n, PeerIdentity::default());
assert!(t.authorize(&app("a"), "both").is_ok());
assert_eq!(
t.authorize(&app("b"), "both").unwrap_err().code,
ERROR_NOT_ALLOWED
);
assert_eq!(
t.authorize(&app("a"), "up").unwrap_err().code,
ERROR_NOT_ALLOWED
);
let dev = Caller::device("dev_1", ["s1"]);
assert!(t.authorize(&dev, "both").is_ok());
assert_eq!(t.authorize(&dev, "up").unwrap_err().code, ERROR_FORBIDDEN);
assert_eq!(t.authorize(&dev, "echo").unwrap_err().code, ERROR_FORBIDDEN);
assert_eq!(
t.authorize(&dev, "missing").unwrap_err().code,
ERROR_FORBIDDEN
);
assert!(t.authorize(&dev, "ping").is_ok());
}
}