use serde::{Deserialize, Serialize};
use crate::capability::{CapabilityGroup, RosAccess};
use crate::error::PolicyError;
use crate::identity::PeerId;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Policy {
#[serde(default)]
pub capability_rules: Vec<CapabilityRule>,
#[serde(default)]
pub peer_rules: Vec<PeerRule>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapabilityRule {
pub group: String,
pub allowed_patterns: Vec<String>,
#[serde(default)]
pub max_access: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeerRule {
pub peer_id: String,
pub can_deploy: bool,
#[serde(default)]
pub allowed_capabilities: Vec<String>,
}
impl Policy {
pub fn load(path: &std::path::Path) -> Result<Self, PolicyError> {
if !path.exists() {
return Err(PolicyError::PolicyNotFound(path.display().to_string()));
}
let contents =
std::fs::read_to_string(path).map_err(|e| PolicyError::InvalidPolicy(e.to_string()))?;
toml::from_str(&contents).map_err(|e| PolicyError::InvalidPolicy(e.to_string()))
}
pub fn from_toml(toml_str: &str) -> Result<Self, PolicyError> {
toml::from_str(toml_str).map_err(|e| PolicyError::InvalidPolicy(e.to_string()))
}
pub fn permissive() -> Self {
Self {
capability_rules: vec![
CapabilityRule {
group: "ganglion:ros/interface".into(),
allowed_patterns: vec!["**".into()],
max_access: Some("read_write".into()),
},
CapabilityRule {
group: "ganglion:logs/stream".into(),
allowed_patterns: vec!["**".into()],
max_access: None,
},
CapabilityRule {
group: "ganglion:fs/bounded".into(),
allowed_patterns: vec!["/tmp/gang/**".into()],
max_access: None,
},
CapabilityRule {
group: "ganglion:diagnostics/collect".into(),
allowed_patterns: vec!["**".into()],
max_access: None,
},
CapabilityRule {
group: "ganglion:artifacts/publish".into(),
allowed_patterns: vec!["**".into()],
max_access: None,
},
CapabilityRule {
group: "ganglion:process/spawn".into(),
allowed_patterns: vec!["**".into()],
max_access: None,
},
CapabilityRule {
group: "ganglion:network/probe".into(),
allowed_patterns: vec!["**".into()],
max_access: None,
},
CapabilityRule {
group: "ganglion:metrics/emit".into(),
allowed_patterns: vec!["**".into()],
max_access: None,
},
],
peer_rules: vec![PeerRule {
peer_id: "*".into(),
can_deploy: true,
allowed_capabilities: Vec::new(),
}],
}
}
pub fn evaluate(
&self,
declared: &[CapabilityGroup],
deployer: &PeerId,
) -> Result<(), PolicyError> {
self.check_peer_authorized(deployer)?;
for cap in declared {
self.check_capability_permitted(cap)?;
}
Ok(())
}
fn check_peer_authorized(&self, peer: &PeerId) -> Result<(), PolicyError> {
if self.peer_rules.is_empty() {
return Err(PolicyError::PeerNotAuthorized {
peer: peer.to_string(),
});
}
let authorized = self
.peer_rules
.iter()
.any(|rule| rule.peer_id == "*" || rule.peer_id == peer.as_str())
&& self.peer_rules.iter().any(|rule| {
(rule.peer_id == "*" || rule.peer_id == peer.as_str()) && rule.can_deploy
});
if !authorized {
return Err(PolicyError::PeerNotAuthorized {
peer: peer.to_string(),
});
}
Ok(())
}
fn check_capability_permitted(&self, cap: &CapabilityGroup) -> Result<(), PolicyError> {
let group_name = cap.name();
let rule = self.capability_rules.iter().find(|r| r.group == group_name);
let rule = match rule {
Some(r) => r,
None => {
return Err(PolicyError::CapabilityDenied {
capability: cap.qualified_name(),
});
}
};
match cap {
CapabilityGroup::RosInterface { patterns, .. } => {
for pattern in patterns {
if !pattern_matches_any(&pattern.pattern, &rule.allowed_patterns) {
return Err(PolicyError::PatternExceedsPolicy {
capability: group_name.into(),
pattern: pattern.pattern.clone(),
});
}
if pattern.access == RosAccess::ReadWrite {
if let Some(max) = &rule.max_access {
if max == "read_only" {
return Err(PolicyError::PatternExceedsPolicy {
capability: group_name.into(),
pattern: format!(
"{} (read_write exceeds max read_only)",
pattern.pattern
),
});
}
}
}
}
}
CapabilityGroup::LogStream { patterns, .. } => {
for pattern in patterns {
if !pattern_matches_any(pattern, &rule.allowed_patterns) {
return Err(PolicyError::PatternExceedsPolicy {
capability: group_name.into(),
pattern: pattern.clone(),
});
}
}
}
CapabilityGroup::FsBounded { paths, .. } => {
for path in paths {
if !pattern_matches_any(&path.pattern, &rule.allowed_patterns) {
return Err(PolicyError::PatternExceedsPolicy {
capability: group_name.into(),
pattern: path.pattern.clone(),
});
}
}
}
CapabilityGroup::DiagnosticsCollect { .. } => {
}
CapabilityGroup::ArtifactsPublish { .. } => {
}
CapabilityGroup::ProcessSpawn {
allowed_commands, ..
} => {
for cmd in allowed_commands {
if !pattern_matches_any(cmd, &rule.allowed_patterns) {
return Err(PolicyError::PatternExceedsPolicy {
capability: group_name.into(),
pattern: cmd.clone(),
});
}
}
}
CapabilityGroup::NetworkProbe { .. } => {
}
CapabilityGroup::MetricsEmit { .. } => {
}
}
Ok(())
}
}
fn pattern_matches_any(requested: &str, allowed: &[String]) -> bool {
for allowed_pattern in allowed {
if allowed_pattern == "**" {
return true;
}
if glob_match::glob_match(allowed_pattern, requested) {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capability::AccessPattern;
use crate::identity::Keypair;
#[test]
fn permissive_policy_allows_everything() {
let policy = Policy::permissive();
let peer = Keypair::generate().peer_id();
let caps = vec![
CapabilityGroup::RosInterface {
version: "1.0".into(),
patterns: vec![AccessPattern {
pattern: "/diagnostics".into(),
access: RosAccess::ReadOnly,
}],
},
CapabilityGroup::DiagnosticsCollect {
version: "1.0".into(),
},
];
assert!(policy.evaluate(&caps, &peer).is_ok());
}
#[test]
fn empty_policy_denies_everything() {
let policy = Policy::default();
let peer = Keypair::generate().peer_id();
let caps = vec![CapabilityGroup::DiagnosticsCollect {
version: "1.0".into(),
}];
assert!(policy.evaluate(&caps, &peer).is_err());
}
#[test]
fn pattern_based_ros_access() {
let policy = Policy {
capability_rules: vec![CapabilityRule {
group: "ganglion:ros/interface".into(),
allowed_patterns: vec!["/diagnostics/**".into(), "/rosout".into()],
max_access: Some("read_only".into()),
}],
peer_rules: vec![PeerRule {
peer_id: "*".into(),
can_deploy: true,
allowed_capabilities: Vec::new(),
}],
};
let peer = Keypair::generate().peer_id();
let caps = vec![CapabilityGroup::RosInterface {
version: "1.0".into(),
patterns: vec![AccessPattern {
pattern: "/diagnostics/cpu".into(),
access: RosAccess::ReadOnly,
}],
}];
assert!(policy.evaluate(&caps, &peer).is_ok());
let caps = vec![CapabilityGroup::RosInterface {
version: "1.0".into(),
patterns: vec![AccessPattern {
pattern: "/cmd_vel".into(),
access: RosAccess::ReadOnly,
}],
}];
assert!(policy.evaluate(&caps, &peer).is_err());
let caps = vec![CapabilityGroup::RosInterface {
version: "1.0".into(),
patterns: vec![AccessPattern {
pattern: "/diagnostics/cpu".into(),
access: RosAccess::ReadWrite,
}],
}];
assert!(policy.evaluate(&caps, &peer).is_err());
}
#[test]
fn specific_peer_authorization() {
let allowed = Keypair::generate();
let denied = Keypair::generate();
let policy = Policy {
capability_rules: vec![CapabilityRule {
group: "ganglion:diagnostics/collect".into(),
allowed_patterns: vec!["**".into()],
max_access: None,
}],
peer_rules: vec![PeerRule {
peer_id: allowed.peer_id().as_str().to_string(),
can_deploy: true,
allowed_capabilities: Vec::new(),
}],
};
let caps = vec![CapabilityGroup::DiagnosticsCollect {
version: "1.0".into(),
}];
assert!(policy.evaluate(&caps, &allowed.peer_id()).is_ok());
assert!(policy.evaluate(&caps, &denied.peer_id()).is_err());
}
#[test]
fn read_write_required_for_param_set() {
let policy = Policy {
capability_rules: vec![CapabilityRule {
group: "ganglion:ros/interface".into(),
allowed_patterns: vec!["**".into()],
max_access: Some("read_only".into()),
}],
peer_rules: vec![PeerRule {
peer_id: "*".into(),
can_deploy: true,
allowed_capabilities: Vec::new(),
}],
};
let peer = Keypair::generate().peer_id();
let caps = vec![CapabilityGroup::RosInterface {
version: "1.0".into(),
patterns: vec![AccessPattern {
pattern: "/my_node/max_speed".into(),
access: RosAccess::ReadWrite,
}],
}];
assert!(policy.evaluate(&caps, &peer).is_err());
let caps = vec![CapabilityGroup::RosInterface {
version: "1.0".into(),
patterns: vec![AccessPattern {
pattern: "/my_node/max_speed".into(),
access: RosAccess::ReadOnly,
}],
}];
assert!(policy.evaluate(&caps, &peer).is_ok());
let rw_policy = Policy {
capability_rules: vec![CapabilityRule {
group: "ganglion:ros/interface".into(),
allowed_patterns: vec!["**".into()],
max_access: Some("read_write".into()),
}],
peer_rules: vec![PeerRule {
peer_id: "*".into(),
can_deploy: true,
allowed_capabilities: Vec::new(),
}],
};
let caps = vec![CapabilityGroup::RosInterface {
version: "1.0".into(),
patterns: vec![AccessPattern {
pattern: "/my_node/max_speed".into(),
access: RosAccess::ReadWrite,
}],
}];
assert!(rw_policy.evaluate(&caps, &peer).is_ok());
}
#[test]
fn policy_toml_roundtrip() {
let policy = Policy::permissive();
let toml_str = toml::to_string_pretty(&policy).unwrap();
let loaded = Policy::from_toml(&toml_str).unwrap();
assert_eq!(loaded.capability_rules.len(), policy.capability_rules.len());
}
}