use serde::{Deserialize, Serialize};
use crate::topic::TopicMatcher;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", content = "value", rename_all = "snake_case")]
pub enum Policy {
Public,
Authenticated,
Role(String),
UserId(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthRule {
pub pattern: String,
pub subscribe_policy: Policy,
pub publish_policy: Policy,
}
#[derive(Debug, Clone, Default)]
pub struct AuthContext {
pub user_id: Option<String>,
pub roles: Vec<String>,
pub is_authenticated: bool,
}
impl AuthContext {
pub fn anonymous() -> Self {
Self::default()
}
pub fn authenticated(user_id: impl Into<String>, roles: Vec<String>) -> Self {
Self {
user_id: Some(user_id.into()),
roles,
is_authenticated: true,
}
}
}
#[derive(Debug, Clone)]
pub struct TopicAuth {
rules: Vec<AuthRule>,
default_subscribe: Policy,
default_publish: Policy,
}
impl TopicAuth {
pub fn with_defaults() -> Self {
Self {
rules: vec![
AuthRule {
pattern: "system/#".to_string(),
subscribe_policy: Policy::Authenticated,
publish_policy: Policy::Role("__system_internal__".to_string()),
},
AuthRule {
pattern: "plugin/#".to_string(),
subscribe_policy: Policy::Public,
publish_policy: Policy::Authenticated,
},
AuthRule {
pattern: "custom/#".to_string(),
subscribe_policy: Policy::Public,
publish_policy: Policy::Authenticated,
},
],
default_subscribe: Policy::Public,
default_publish: Policy::Authenticated,
}
}
pub fn new() -> Self {
Self {
rules: Vec::new(),
default_subscribe: Policy::Public,
default_publish: Policy::Authenticated,
}
}
pub fn with_rules(rules: Vec<AuthRule>) -> Self {
Self {
rules,
default_subscribe: Policy::Public,
default_publish: Policy::Authenticated,
}
}
pub fn set_default_subscribe(&mut self, policy: Policy) {
self.default_subscribe = policy;
}
pub fn set_default_publish(&mut self, policy: Policy) {
self.default_publish = policy;
}
pub fn add_rule(&mut self, rule: AuthRule) {
self.rules.push(rule);
}
pub fn check_subscribe(&self, topic: &str, ctx: &AuthContext) -> bool {
let policy = self.find_subscribe_policy(topic);
Self::evaluate(policy, ctx)
}
pub fn check_publish(&self, topic: &str, ctx: &AuthContext) -> bool {
let policy = self.find_publish_policy(topic);
Self::evaluate(policy, ctx)
}
pub fn rules(&self) -> &[AuthRule] {
&self.rules
}
fn find_subscribe_policy(&self, topic: &str) -> &Policy {
for rule in &self.rules {
if TopicMatcher::matches(&rule.pattern, topic) {
return &rule.subscribe_policy;
}
}
&self.default_subscribe
}
fn find_publish_policy(&self, topic: &str) -> &Policy {
for rule in &self.rules {
if TopicMatcher::matches(&rule.pattern, topic) {
return &rule.publish_policy;
}
}
&self.default_publish
}
fn evaluate(policy: &Policy, ctx: &AuthContext) -> bool {
match policy {
Policy::Public => true,
Policy::Authenticated => ctx.is_authenticated,
Policy::Role(required_role) => ctx.roles.contains(required_role),
Policy::UserId(required_id) => ctx.user_id.as_deref() == Some(required_id.as_str()),
}
}
}
impl Default for TopicAuth {
fn default() -> Self {
Self::with_defaults()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn anonymous() -> AuthContext {
AuthContext::anonymous()
}
fn authenticated_user(user_id: &str) -> AuthContext {
AuthContext::authenticated(user_id, vec![])
}
fn user_with_role(user_id: &str, role: &str) -> AuthContext {
AuthContext::authenticated(user_id, vec![role.to_string()])
}
fn user_with_roles(user_id: &str, roles: Vec<&str>) -> AuthContext {
AuthContext::authenticated(user_id, roles.into_iter().map(String::from).collect())
}
#[test]
fn public_allows_anonymous() {
let auth = TopicAuth::with_rules(vec![AuthRule {
pattern: "test/#".to_string(),
subscribe_policy: Policy::Public,
publish_policy: Policy::Public,
}]);
assert!(auth.check_subscribe("test/foo", &anonymous()));
assert!(auth.check_publish("test/foo", &anonymous()));
}
#[test]
fn public_allows_authenticated() {
let auth = TopicAuth::with_rules(vec![AuthRule {
pattern: "test/#".to_string(),
subscribe_policy: Policy::Public,
publish_policy: Policy::Public,
}]);
let ctx = authenticated_user("alice");
assert!(auth.check_subscribe("test/foo", &ctx));
assert!(auth.check_publish("test/foo", &ctx));
}
#[test]
fn authenticated_denies_anonymous() {
let auth = TopicAuth::with_rules(vec![AuthRule {
pattern: "test/#".to_string(),
subscribe_policy: Policy::Authenticated,
publish_policy: Policy::Authenticated,
}]);
assert!(!auth.check_subscribe("test/foo", &anonymous()));
assert!(!auth.check_publish("test/foo", &anonymous()));
}
#[test]
fn authenticated_allows_logged_in() {
let auth = TopicAuth::with_rules(vec![AuthRule {
pattern: "test/#".to_string(),
subscribe_policy: Policy::Authenticated,
publish_policy: Policy::Authenticated,
}]);
let ctx = authenticated_user("bob");
assert!(auth.check_subscribe("test/foo", &ctx));
assert!(auth.check_publish("test/foo", &ctx));
}
#[test]
fn role_denies_wrong_role() {
let auth = TopicAuth::with_rules(vec![AuthRule {
pattern: "admin/#".to_string(),
subscribe_policy: Policy::Role("admin".to_string()),
publish_policy: Policy::Role("admin".to_string()),
}]);
let ctx = user_with_role("alice", "viewer");
assert!(!auth.check_subscribe("admin/settings", &ctx));
assert!(!auth.check_publish("admin/settings", &ctx));
}
#[test]
fn role_allows_correct_role() {
let auth = TopicAuth::with_rules(vec![AuthRule {
pattern: "admin/#".to_string(),
subscribe_policy: Policy::Role("admin".to_string()),
publish_policy: Policy::Role("admin".to_string()),
}]);
let ctx = user_with_role("alice", "admin");
assert!(auth.check_subscribe("admin/settings", &ctx));
assert!(auth.check_publish("admin/settings", &ctx));
}
#[test]
fn role_denies_anonymous() {
let auth = TopicAuth::with_rules(vec![AuthRule {
pattern: "admin/#".to_string(),
subscribe_policy: Policy::Role("admin".to_string()),
publish_policy: Policy::Role("admin".to_string()),
}]);
assert!(!auth.check_subscribe("admin/settings", &anonymous()));
}
#[test]
fn role_check_with_multiple_roles() {
let auth = TopicAuth::with_rules(vec![AuthRule {
pattern: "ops/#".to_string(),
subscribe_policy: Policy::Role("operator".to_string()),
publish_policy: Policy::Role("operator".to_string()),
}]);
let ctx = user_with_roles("bob", vec!["viewer", "operator"]);
assert!(auth.check_subscribe("ops/deploy", &ctx));
}
#[test]
fn userid_allows_matching_user() {
let auth = TopicAuth::with_rules(vec![AuthRule {
pattern: "user/alice/#".to_string(),
subscribe_policy: Policy::UserId("alice".to_string()),
publish_policy: Policy::UserId("alice".to_string()),
}]);
let ctx = authenticated_user("alice");
assert!(auth.check_subscribe("user/alice/inbox", &ctx));
assert!(auth.check_publish("user/alice/inbox", &ctx));
}
#[test]
fn userid_denies_different_user() {
let auth = TopicAuth::with_rules(vec![AuthRule {
pattern: "user/alice/#".to_string(),
subscribe_policy: Policy::UserId("alice".to_string()),
publish_policy: Policy::UserId("alice".to_string()),
}]);
let ctx = authenticated_user("bob");
assert!(!auth.check_subscribe("user/alice/inbox", &ctx));
assert!(!auth.check_publish("user/alice/inbox", &ctx));
}
#[test]
fn userid_denies_anonymous() {
let auth = TopicAuth::with_rules(vec![AuthRule {
pattern: "user/alice/#".to_string(),
subscribe_policy: Policy::UserId("alice".to_string()),
publish_policy: Policy::UserId("alice".to_string()),
}]);
assert!(!auth.check_subscribe("user/alice/inbox", &anonymous()));
}
#[test]
fn default_system_subscribe_requires_auth() {
let auth = TopicAuth::with_defaults();
assert!(!auth.check_subscribe("system/deploy", &anonymous()));
assert!(auth.check_subscribe("system/deploy", &authenticated_user("u")));
}
#[test]
fn default_system_publish_internal_only() {
let auth = TopicAuth::with_defaults();
assert!(!auth.check_publish("system/deploy", &authenticated_user("u")));
assert!(!auth.check_publish("system/health", &user_with_role("u", "admin")));
assert!(auth.check_publish(
"system/deploy",
&user_with_role("internal", "__system_internal__")
));
}
#[test]
fn default_plugin_subscribe_public() {
let auth = TopicAuth::with_defaults();
assert!(auth.check_subscribe("plugin/analytics/events", &anonymous()));
}
#[test]
fn default_plugin_publish_requires_auth() {
let auth = TopicAuth::with_defaults();
assert!(!auth.check_publish("plugin/analytics/events", &anonymous()));
assert!(auth.check_publish("plugin/analytics/events", &authenticated_user("u")));
}
#[test]
fn default_custom_subscribe_public() {
let auth = TopicAuth::with_defaults();
assert!(auth.check_subscribe("custom/chat", &anonymous()));
}
#[test]
fn default_custom_publish_requires_auth() {
let auth = TopicAuth::with_defaults();
assert!(!auth.check_publish("custom/chat", &anonymous()));
assert!(auth.check_publish("custom/chat", &authenticated_user("u")));
}
#[test]
fn unknown_topic_uses_default_subscribe_public() {
let auth = TopicAuth::with_defaults();
assert!(auth.check_subscribe("unknown/topic", &anonymous()));
}
#[test]
fn unknown_topic_uses_default_publish_authenticated() {
let auth = TopicAuth::with_defaults();
assert!(!auth.check_publish("unknown/topic", &anonymous()));
assert!(auth.check_publish("unknown/topic", &authenticated_user("u")));
}
#[test]
fn first_matching_rule_wins() {
let auth = TopicAuth::with_rules(vec![
AuthRule {
pattern: "data/secret".to_string(),
subscribe_policy: Policy::Role("admin".to_string()),
publish_policy: Policy::Role("admin".to_string()),
},
AuthRule {
pattern: "data/#".to_string(),
subscribe_policy: Policy::Public,
publish_policy: Policy::Public,
},
]);
assert!(!auth.check_subscribe("data/secret", &anonymous()));
assert!(auth.check_subscribe("data/secret", &user_with_role("u", "admin")));
assert!(auth.check_subscribe("data/other", &anonymous()));
}
#[test]
fn asymmetric_policies() {
let auth = TopicAuth::with_rules(vec![AuthRule {
pattern: "broadcast/#".to_string(),
subscribe_policy: Policy::Public,
publish_policy: Policy::Role("broadcaster".to_string()),
}]);
let viewer = anonymous();
let broadcaster = user_with_role("alice", "broadcaster");
assert!(auth.check_subscribe("broadcast/news", &viewer));
assert!(auth.check_subscribe("broadcast/news", &broadcaster));
assert!(!auth.check_publish("broadcast/news", &viewer));
assert!(auth.check_publish("broadcast/news", &broadcaster));
}
#[test]
fn add_rule_extends_rules() {
let mut auth = TopicAuth::new();
auth.add_rule(AuthRule {
pattern: "secret/#".to_string(),
subscribe_policy: Policy::Authenticated,
publish_policy: Policy::Authenticated,
});
assert!(!auth.check_subscribe("secret/data", &anonymous()));
assert!(auth.check_subscribe("secret/data", &authenticated_user("u")));
}
#[test]
fn set_default_subscribe_changes_fallthrough() {
let mut auth = TopicAuth::new();
auth.set_default_subscribe(Policy::Authenticated);
assert!(!auth.check_subscribe("anything", &anonymous()));
assert!(auth.check_subscribe("anything", &authenticated_user("u")));
}
#[test]
fn set_default_publish_changes_fallthrough() {
let mut auth = TopicAuth::new();
auth.set_default_publish(Policy::Public);
assert!(auth.check_publish("anything", &anonymous()));
}
#[test]
fn policy_roundtrip() {
let policies = vec![
Policy::Public,
Policy::Authenticated,
Policy::Role("admin".to_string()),
Policy::UserId("alice".to_string()),
];
for p in policies {
let json_str = serde_json::to_string(&p).unwrap();
let deserialized: Policy = serde_json::from_str(&json_str).unwrap();
assert_eq!(p, deserialized);
}
}
#[test]
fn auth_rule_roundtrip() {
let rule = AuthRule {
pattern: "test/#".to_string(),
subscribe_policy: Policy::Public,
publish_policy: Policy::Role("admin".to_string()),
};
let json_str = serde_json::to_string(&rule).unwrap();
let deserialized: AuthRule = serde_json::from_str(&json_str).unwrap();
assert_eq!(rule.pattern, deserialized.pattern);
}
#[test]
fn anonymous_context() {
let ctx = AuthContext::anonymous();
assert!(ctx.user_id.is_none());
assert!(ctx.roles.is_empty());
assert!(!ctx.is_authenticated);
}
#[test]
fn authenticated_context() {
let ctx = AuthContext::authenticated("alice", vec!["admin".to_string()]);
assert_eq!(ctx.user_id, Some("alice".to_string()));
assert_eq!(ctx.roles, vec!["admin"]);
assert!(ctx.is_authenticated);
}
}