diff --git a/crates/dtrack-perms/src/lib.rs b/crates/dtrack-perms/src/lib.rs new file mode 100644 index 0000000..d91d268 --- /dev/null +++ b/crates/dtrack-perms/src/lib.rs @@ -0,0 +1,258 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use anyhow::{bail, Context, Result}; +use serde::Deserialize; + +/// RBAC-Konfiguration, deserialisierbar aus TOML. +/// +/// Modell: benannte Rollen mit Allow-/Deny-Tool-Listen, und Clients, die einen +/// Bearer-Token einer Rolle zuordnen. Semantik: **Deny schlaegt Allow**, und +/// `"*"` in `allow` heisst "alle Tools". +/// +/// ```toml +/// [[roles]] +/// name = "readonly" +/// allow = ["*"] +/// deny = ["project_findings"] # optional +/// +/// [[clients]] +/// name = "claude-desktop" +/// token = "geheimer-bearer-token" +/// role = "readonly" +/// ``` +#[derive(Debug, Clone, Default, Deserialize)] +pub struct RbacConfig { + #[serde(default)] + pub roles: Vec, + #[serde(default)] + pub clients: Vec, +} + +/// Eine Rollendefinition. +#[derive(Debug, Clone, Deserialize)] +pub struct RoleConfig { + pub name: String, + /// Erlaubte Tools; `"*"` = alle. + #[serde(default)] + pub allow: Vec, + /// Verbotene Tools; schlagen Allow. + #[serde(default)] + pub deny: Vec, +} + +/// Ein Client: Bearer-Token -> Rolle. +#[derive(Debug, Clone, Deserialize)] +pub struct ClientConfig { + pub name: String, + pub token: String, + pub role: String, +} + +/// Aufgeloeste Identitaet eines Tokens (kompiliert fuer schnellen Lookup). +#[derive(Debug, Clone)] +pub struct Identity { + pub client: String, + pub role: String, + allow_all: bool, + allow: HashSet, + deny: HashSet, +} + +impl Identity { + /// Darf dieser Client das Tool nutzen? Deny schlaegt Allow. + pub fn can_use(&self, tool: &str) -> bool { + if self.deny.contains(tool) { + return false; + } + self.allow_all || self.allow.contains(tool) + } + + /// Filtert die Gesamtmenge aller Tools auf die fuer diesen Client sichtbaren. + pub fn visible_tools(&self, all: &[&str]) -> Vec { + all.iter() + .copied() + .filter(|&t| self.can_use(t)) + .map(str::to_owned) + .collect() + } +} + +/// Kompilierte Policy: Token -> Identitaet. +#[derive(Debug, Clone, Default)] +pub struct Policy { + by_token: HashMap, +} + +impl Policy { + /// Kompiliert eine [`RbacConfig`] in eine schnell abfragbare Policy. + pub fn compile(cfg: RbacConfig) -> Result { + let mut roles: HashMap<&str, &RoleConfig> = HashMap::new(); + for role in &cfg.roles { + if roles.insert(role.name.as_str(), role).is_some() { + bail!("doppelte Rolle: {}", role.name); + } + } + + let mut by_token = HashMap::new(); + for client in &cfg.clients { + let role = roles.get(client.role.as_str()).with_context(|| { + format!( + "Client '{}' verweist auf unbekannte Rolle '{}'", + client.name, client.role + ) + })?; + + let allow_all = role.allow.iter().any(|t| t.as_str() == "*"); + let identity = Identity { + client: client.name.clone(), + role: client.role.clone(), + allow_all, + allow: role + .allow + .iter() + .filter(|t| t.as_str() != "*") + .cloned() + .collect(), + deny: role.deny.iter().cloned().collect(), + }; + + if by_token.insert(client.token.clone(), identity).is_some() { + bail!("doppelter Token bei Client '{}'", client.name); + } + } + + Ok(Self { by_token }) + } + + /// Liest und kompiliert eine Policy aus einer TOML-Datei. + pub fn load(path: impl AsRef) -> Result { + let path = path.as_ref(); + let raw = std::fs::read_to_string(path) + .with_context(|| format!("RBAC-Config nicht lesbar: {}", path.display()))?; + let cfg: RbacConfig = toml::from_str(&raw).context("RBAC-TOML ungueltig")?; + Self::compile(cfg) + } + + /// Identitaet zu einem Bearer-Token (None = unbekannter Token). + pub fn identify(&self, token: &str) -> Option<&Identity> { + self.by_token.get(token) + } + + /// Anzahl konfigurierter Clients. + pub fn client_count(&self) -> usize { + self.by_token.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> Policy { + let toml = r#" + [[roles]] + name = "full" + allow = ["*"] + + [[roles]] + name = "metrics" + allow = ["list_projects", "project_metrics"] + + [[roles]] + name = "full-no-findings" + allow = ["*"] + deny = ["project_findings"] + + [[clients]] + name = "admin-client" + token = "tok-full" + role = "full" + + [[clients]] + name = "dash" + token = "tok-metrics" + role = "metrics" + + [[clients]] + name = "limited" + token = "tok-nofind" + role = "full-no-findings" + "#; + let cfg: RbacConfig = toml::from_str(toml).unwrap(); + Policy::compile(cfg).unwrap() + } + + #[test] + fn wildcard_allows_all() { + let p = sample(); + let id = p.identify("tok-full").unwrap(); + assert!(id.can_use("list_projects")); + assert!(id.can_use("project_findings")); + assert!(id.can_use("whatever")); + } + + #[test] + fn explicit_allow_list() { + let p = sample(); + let id = p.identify("tok-metrics").unwrap(); + assert!(id.can_use("list_projects")); + assert!(id.can_use("project_metrics")); + assert!(!id.can_use("project_findings")); + } + + #[test] + fn deny_overrides_wildcard() { + let p = sample(); + let id = p.identify("tok-nofind").unwrap(); + assert!(id.can_use("list_projects")); + assert!(!id.can_use("project_findings")); + } + + #[test] + fn unknown_token_is_none() { + let p = sample(); + assert!(p.identify("nope").is_none()); + } + + #[test] + fn visible_tools_filters_in_order() { + let p = sample(); + let id = p.identify("tok-metrics").unwrap(); + let all = ["list_projects", "project_findings", "project_metrics"]; + assert_eq!(id.visible_tools(&all), vec!["list_projects", "project_metrics"]); + } + + #[test] + fn unknown_role_fails_compile() { + let toml = r#" + [[clients]] + name = "x" + token = "t" + role = "ghost" + "#; + let cfg: RbacConfig = toml::from_str(toml).unwrap(); + assert!(Policy::compile(cfg).is_err()); + } + + #[test] + fn duplicate_token_fails_compile() { + let toml = r#" + [[roles]] + name = "r" + allow = ["*"] + + [[clients]] + name = "a" + token = "dup" + role = "r" + + [[clients]] + name = "b" + token = "dup" + role = "r" + "#; + let cfg: RbacConfig = toml::from_str(toml).unwrap(); + assert!(Policy::compile(cfg).is_err()); + } +}