Stage 3: RBAC-Policy (Rollen/Clients, Deny-overrides-Allow, Tool-Filter) + Tests
CI / test (push) Successful in 13m19s

This commit is contained in:
2026-06-24 07:30:22 +00:00
parent c0780a53dc
commit f7dfcc87c3
+258
View File
@@ -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<RoleConfig>,
#[serde(default)]
pub clients: Vec<ClientConfig>,
}
/// Eine Rollendefinition.
#[derive(Debug, Clone, Deserialize)]
pub struct RoleConfig {
pub name: String,
/// Erlaubte Tools; `"*"` = alle.
#[serde(default)]
pub allow: Vec<String>,
/// Verbotene Tools; schlagen Allow.
#[serde(default)]
pub deny: Vec<String>,
}
/// 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<String>,
deny: HashSet<String>,
}
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<String> {
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<String, Identity>,
}
impl Policy {
/// Kompiliert eine [`RbacConfig`] in eine schnell abfragbare Policy.
pub fn compile(cfg: RbacConfig) -> Result<Self> {
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<Path>) -> Result<Self> {
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());
}
}