use alloc::collections::BTreeMap;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use crate::sasl::SaslMechanism;
pub mod class_ids {
pub const SASL_USERNAME: &str = "zerodds:Auth:SASL-Username:1.0";
pub const ANONYMOUS: &str = "zerodds:Auth:Anonymous:1.0";
pub const SCRAM_SHA256: &str = "zerodds:Auth:SASL-SCRAM-SHA256:1.0";
pub const PKI_DH: &str = "DDS:Auth:PKI-DH:1.0";
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IdentityToken {
pub class_id: String,
pub subject_name: String,
pub certificate: Option<Vec<u8>>,
}
#[must_use]
pub fn build_identity_token(input: &SaslSubject) -> IdentityToken {
match input {
SaslSubject::Plain { authcid } => IdentityToken {
class_id: class_ids::SASL_USERNAME.to_string(),
subject_name: alloc::format!("CN={authcid}"),
certificate: None,
},
SaslSubject::Anonymous => IdentityToken {
class_id: class_ids::ANONYMOUS.to_string(),
subject_name: "CN=ANONYMOUS".to_string(),
certificate: None,
},
SaslSubject::External {
certificate,
subject_dn,
} => IdentityToken {
class_id: class_ids::PKI_DH.to_string(),
subject_name: subject_dn.clone(),
certificate: Some(certificate.clone()),
},
SaslSubject::ScramSha256 { authcid } => IdentityToken {
class_id: class_ids::SCRAM_SHA256.to_string(),
subject_name: alloc::format!("CN={authcid}"),
certificate: None,
},
}
}
#[derive(Debug, Clone)]
pub enum SaslSubject {
Plain {
authcid: String,
},
Anonymous,
External {
certificate: Vec<u8>,
subject_dn: String,
},
ScramSha256 {
authcid: String,
},
}
impl SaslSubject {
#[must_use]
pub const fn mechanism(&self) -> SaslMechanism {
match self {
Self::Plain { .. } => SaslMechanism::Plain,
Self::ScramSha256 { .. } => SaslMechanism::ScramSha256,
Self::Anonymous => SaslMechanism::Anonymous,
Self::External { .. } => SaslMechanism::External,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum AccessOp {
AttachSender,
AttachReceiver,
SendSample,
ReceiveSample,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AccessDecision {
Allow,
Deny,
}
pub trait AccessControlPlugin {
fn check(&self, identity: &IdentityToken, address: &str, op: AccessOp) -> AccessDecision;
}
#[derive(Debug, Default)]
pub struct AllowAll;
impl AccessControlPlugin for AllowAll {
fn check(&self, _identity: &IdentityToken, _address: &str, _op: AccessOp) -> AccessDecision {
AccessDecision::Allow
}
}
#[derive(Debug, Default)]
pub struct StaticAllowList {
allow: BTreeMap<String, Vec<(String, AccessOp)>>,
}
impl StaticAllowList {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn allow(&mut self, subject_name: &str, address: &str, op: AccessOp) {
self.allow
.entry(subject_name.to_string())
.or_default()
.push((address.to_string(), op));
}
}
impl AccessControlPlugin for StaticAllowList {
fn check(&self, identity: &IdentityToken, address: &str, op: AccessOp) -> AccessDecision {
if let Some(entries) = self.allow.get(&identity.subject_name) {
if entries
.iter()
.any(|(addr, allowed_op)| addr == address && *allowed_op == op)
{
return AccessDecision::Allow;
}
}
AccessDecision::Deny
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GovernanceRule {
pub topic_pattern: String,
pub enable_discovery: bool,
pub enable_liveliness: bool,
pub data_protection_kind: DataProtectionKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DataProtectionKind {
None,
SignOnly,
SignAndEncrypt,
}
#[derive(Debug, Default)]
pub struct GovernanceDocument {
rules: Vec<GovernanceRule>,
}
impl GovernanceDocument {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add_rule(&mut self, rule: GovernanceRule) {
self.rules.push(rule);
}
#[must_use]
pub fn resolve(&self, topic: &str) -> Option<&GovernanceRule> {
self.rules
.iter()
.find(|r| match_pattern(&r.topic_pattern, topic))
}
}
fn match_pattern(pattern: &str, topic: &str) -> bool {
if pattern == "*" {
return true;
}
if let Some(rest) = pattern.strip_suffix('*') {
return topic.starts_with(rest);
}
if let Some(rest) = pattern.strip_prefix('*') {
return topic.ends_with(rest);
}
pattern == topic
}
#[derive(Debug, Clone)]
pub struct LinkGovernance {
pub identity: IdentityToken,
pub address: String,
pub rule: Option<GovernanceRule>,
pub cached_decisions: BTreeMap<AccessOp, AccessDecision>,
}
impl LinkGovernance {
#[must_use]
pub fn new(identity: IdentityToken, address: String, rule: Option<GovernanceRule>) -> Self {
Self {
identity,
address,
rule,
cached_decisions: BTreeMap::new(),
}
}
pub fn evaluate<P: AccessControlPlugin>(&mut self, plugin: &P, op: AccessOp) -> AccessDecision {
if let Some(d) = self.cached_decisions.get(&op) {
return d.clone();
}
let d = plugin.check(&self.identity, &self.address, op);
self.cached_decisions.insert(op, d.clone());
d
}
}
#[derive(Debug, Clone)]
pub struct DualIdentity {
pub broker_identity: IdentityToken,
pub dds_identity: IdentityToken,
}
impl DualIdentity {
#[must_use]
pub fn new(broker_identity: IdentityToken, dds_identity: IdentityToken) -> Self {
Self {
broker_identity,
dds_identity,
}
}
#[must_use]
pub fn for_dds(&self) -> &IdentityToken {
&self.dds_identity
}
#[must_use]
pub fn for_broker(&self) -> &IdentityToken {
&self.broker_identity
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn plain_yields_sasl_username_class_id() {
let t = build_identity_token(&SaslSubject::Plain {
authcid: "alice".into(),
});
assert_eq!(t.class_id, class_ids::SASL_USERNAME);
assert_eq!(t.subject_name, "CN=alice");
assert!(t.certificate.is_none());
}
#[test]
fn anonymous_yields_anonymous_class_id() {
let t = build_identity_token(&SaslSubject::Anonymous);
assert_eq!(t.class_id, class_ids::ANONYMOUS);
assert_eq!(t.subject_name, "CN=ANONYMOUS");
}
#[test]
fn external_yields_pki_dh_class_id_with_cert() {
let t = build_identity_token(&SaslSubject::External {
certificate: alloc::vec![1, 2, 3],
subject_dn: "CN=Bridge-1,O=ZeroDDS".to_string(),
});
assert_eq!(t.class_id, class_ids::PKI_DH);
assert_eq!(t.subject_name, "CN=Bridge-1,O=ZeroDDS");
assert_eq!(t.certificate, Some(alloc::vec![1u8, 2, 3]));
}
#[test]
fn scram_yields_scram_sha256_class_id() {
let t = build_identity_token(&SaslSubject::ScramSha256 {
authcid: "bob".into(),
});
assert_eq!(t.class_id, class_ids::SCRAM_SHA256);
assert_eq!(t.subject_name, "CN=bob");
}
#[test]
fn class_id_strings_match_spec_table() {
assert_eq!(class_ids::SASL_USERNAME, "zerodds:Auth:SASL-Username:1.0");
assert_eq!(class_ids::ANONYMOUS, "zerodds:Auth:Anonymous:1.0");
assert_eq!(
class_ids::SCRAM_SHA256,
"zerodds:Auth:SASL-SCRAM-SHA256:1.0"
);
assert_eq!(class_ids::PKI_DH, "DDS:Auth:PKI-DH:1.0");
}
#[test]
fn allow_all_returns_allow() {
let p = AllowAll;
let id = build_identity_token(&SaslSubject::Plain {
authcid: "x".into(),
});
assert_eq!(
p.check(&id, "T", AccessOp::AttachSender),
AccessDecision::Allow
);
}
#[test]
fn static_allow_list_per_op() {
let mut p = StaticAllowList::new();
let id = build_identity_token(&SaslSubject::Plain {
authcid: "alice".into(),
});
p.allow("CN=alice", "Sensor", AccessOp::AttachSender);
assert_eq!(
p.check(&id, "Sensor", AccessOp::AttachSender),
AccessDecision::Allow
);
assert_eq!(
p.check(&id, "Sensor", AccessOp::AttachReceiver),
AccessDecision::Deny
);
assert_eq!(
p.check(&id, "OtherTopic", AccessOp::AttachSender),
AccessDecision::Deny
);
let id2 = build_identity_token(&SaslSubject::Plain {
authcid: "eve".into(),
});
assert_eq!(
p.check(&id2, "Sensor", AccessOp::AttachSender),
AccessDecision::Deny
);
}
#[test]
fn governance_resolves_exact_match() {
let mut g = GovernanceDocument::new();
g.add_rule(GovernanceRule {
topic_pattern: "Sensor".to_string(),
enable_discovery: true,
enable_liveliness: true,
data_protection_kind: DataProtectionKind::SignOnly,
});
let r = g.resolve("Sensor").unwrap();
assert_eq!(r.data_protection_kind, DataProtectionKind::SignOnly);
assert!(g.resolve("Other").is_none());
}
#[test]
fn governance_resolves_prefix_glob() {
let mut g = GovernanceDocument::new();
g.add_rule(GovernanceRule {
topic_pattern: "Sensor*".to_string(),
enable_discovery: true,
enable_liveliness: true,
data_protection_kind: DataProtectionKind::None,
});
assert!(g.resolve("SensorTemperature").is_some());
assert!(g.resolve("Actuator").is_none());
}
#[test]
fn governance_resolves_suffix_glob() {
let mut g = GovernanceDocument::new();
g.add_rule(GovernanceRule {
topic_pattern: "*Cmd".to_string(),
enable_discovery: false,
enable_liveliness: false,
data_protection_kind: DataProtectionKind::SignAndEncrypt,
});
assert!(g.resolve("MotorCmd").is_some());
assert!(g.resolve("Status").is_none());
}
#[test]
fn governance_wildcard_matches_all() {
let mut g = GovernanceDocument::new();
g.add_rule(GovernanceRule {
topic_pattern: "*".to_string(),
enable_discovery: true,
enable_liveliness: true,
data_protection_kind: DataProtectionKind::None,
});
assert!(g.resolve("Anything").is_some());
}
#[test]
fn link_governance_caches_decision() {
let id = build_identity_token(&SaslSubject::Plain {
authcid: "alice".into(),
});
let mut lg = LinkGovernance::new(id, "Sensor".to_string(), None);
struct Counting<'a> {
count: &'a core::cell::Cell<u32>,
}
impl AccessControlPlugin for Counting<'_> {
fn check(&self, _: &IdentityToken, _: &str, _: AccessOp) -> AccessDecision {
self.count.set(self.count.get() + 1);
AccessDecision::Allow
}
}
let count = core::cell::Cell::new(0);
let p = Counting { count: &count };
assert_eq!(lg.evaluate(&p, AccessOp::SendSample), AccessDecision::Allow);
assert_eq!(count.get(), 1);
assert_eq!(lg.evaluate(&p, AccessOp::SendSample), AccessDecision::Allow);
assert_eq!(count.get(), 1);
assert_eq!(
lg.evaluate(&p, AccessOp::ReceiveSample),
AccessDecision::Allow
);
assert_eq!(count.get(), 2);
}
#[test]
fn dual_identity_keeps_broker_and_dds_separate() {
let broker = build_identity_token(&SaslSubject::Plain {
authcid: "Alice".into(),
});
let dds = build_identity_token(&SaslSubject::External {
certificate: alloc::vec![0xCA],
subject_dn: "CN=Bridge-1".to_string(),
});
let dual = DualIdentity::new(broker.clone(), dds.clone());
assert_eq!(dual.for_dds().subject_name, "CN=Bridge-1");
assert_eq!(dual.for_broker().subject_name, "CN=Alice");
assert_ne!(dual.for_dds().subject_name, dual.for_broker().subject_name);
}
#[test]
fn dual_identity_for_dds_does_not_carry_broker_credential() {
let broker = build_identity_token(&SaslSubject::Plain {
authcid: "Alice".into(),
});
let dds = build_identity_token(&SaslSubject::External {
certificate: alloc::vec![0x42],
subject_dn: "CN=Bridge-1".to_string(),
});
let dual = DualIdentity::new(broker, dds);
let ac = AllowAll;
assert_eq!(
ac.check(dual.for_dds(), "X", AccessOp::AttachSender),
AccessDecision::Allow
);
assert_eq!(dual.for_dds().subject_name, "CN=Bridge-1");
}
}