Stage 3: RBAC-Policy (Rollen/Clients, Deny-overrides-Allow, Tool-Filter) + Tests
CI / test (push) Successful in 13m19s
CI / test (push) Successful in 13m19s
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user