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