Sustituye la política hardcodeada por una declarativa (SDD 07 §4).
hammer-core/src/caps.rs:
- AgentCapsConfig { default, rule[] }; CapRule { uid?, gid?, caps }.
- caps_for(uid, gid): primera regla que casa (todos los campos
declarados deben coincidir) o default. load(path) → Ok(None) si falta.
- 8 tests: orden de reglas, match uid+gid, fallback, regla vacía ignorada.
hammerd:
- bus::policy_from_config(cfg) construye la CapsPolicy desde la config.
- main: --agent-caps (default /etc/hammer/agent-caps.toml). Con fichero
usa la config; sin fichero o con fichero inválido cae a default_policy
(avisando). +1 test del policy_from_config.
examples/agent-caps.toml: plantilla comentada.
22 binarios de test verdes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
180 lines
5.3 KiB
Rust
180 lines
5.3 KiB
Rust
//! Política de capacidades del bus de agente, declarativa. Ver `docs/07-agent-bus.md` §4 y
|
|
//! `docs/09-trust-model.md`.
|
|
//!
|
|
//! El daemon concede caps a cada conexión según su `Peer` (UID/GID ya autenticados vía
|
|
//! `SO_PEERCRED`). Hasta ahora esa política vivía hardcodeada en código; este módulo la lee de
|
|
//! `/etc/hammer/agent-caps.toml` para que el operador la ajuste sin recompilar. El peer NUNCA
|
|
//! elige sus caps: las decide el daemon a partir de esta config.
|
|
//!
|
|
//! ```toml
|
|
//! # Caps para cualquier UID/GID no cubierto por una regla.
|
|
//! default = ["query"]
|
|
//!
|
|
//! # Reglas evaluadas en orden; gana la PRIMERA que casa. Una regla casa si todos los campos
|
|
//! # que declara (`uid` y/o `gid`) coinciden con el peer.
|
|
//! [[rule]]
|
|
//! uid = 0
|
|
//! caps = ["query", "compile", "inject", "inject-real", "init"]
|
|
//!
|
|
//! [[rule]]
|
|
//! gid = 994 # p. ej. grupo `hammer`
|
|
//! caps = ["query", "compile", "inject", "init"]
|
|
//! ```
|
|
|
|
use serde::Deserialize;
|
|
|
|
use crate::proto::Cap;
|
|
|
|
/// Config declarativa de la política de capacidades del bus.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct AgentCapsConfig {
|
|
/// Caps para peers que no casan ninguna regla.
|
|
#[serde(default = "default_caps")]
|
|
pub default: Vec<Cap>,
|
|
/// Reglas, evaluadas en orden (la primera que casa gana).
|
|
#[serde(default)]
|
|
pub rule: Vec<CapRule>,
|
|
}
|
|
|
|
/// Una regla: a quién aplica (por `uid` y/o `gid`) y qué caps concede.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct CapRule {
|
|
#[serde(default)]
|
|
pub uid: Option<u32>,
|
|
#[serde(default)]
|
|
pub gid: Option<u32>,
|
|
pub caps: Vec<Cap>,
|
|
}
|
|
|
|
fn default_caps() -> Vec<Cap> {
|
|
vec![Cap::Query]
|
|
}
|
|
|
|
impl Default for AgentCapsConfig {
|
|
fn default() -> Self {
|
|
AgentCapsConfig {
|
|
default: default_caps(),
|
|
rule: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl CapRule {
|
|
/// ¿Casa esta regla con `(uid, gid)`? Casa si todos los campos declarados coinciden. Una
|
|
/// regla sin `uid` ni `gid` NO casa (sería un catch-all redundante con `default`, y casi
|
|
/// siempre un error de config); se ignora con cuidado en `caps_for`.
|
|
fn matches(&self, uid: u32, gid: u32) -> bool {
|
|
if self.uid.is_none() && self.gid.is_none() {
|
|
return false;
|
|
}
|
|
self.uid.map(|u| u == uid).unwrap_or(true) && self.gid.map(|g| g == gid).unwrap_or(true)
|
|
}
|
|
}
|
|
|
|
impl AgentCapsConfig {
|
|
pub fn from_toml(s: &str) -> crate::Result<AgentCapsConfig> {
|
|
toml::from_str(s).map_err(|e| crate::Error::Serde(e.to_string()))
|
|
}
|
|
|
|
/// Carga la config desde `path`. Devuelve `Ok(None)` si el fichero no existe — el daemon
|
|
/// decide entonces su política por defecto.
|
|
pub fn load(path: &std::path::Path) -> crate::Result<Option<AgentCapsConfig>> {
|
|
match std::fs::read_to_string(path) {
|
|
Ok(s) => Ok(Some(Self::from_toml(&s)?)),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
|
Err(e) => Err(e.into()),
|
|
}
|
|
}
|
|
|
|
/// Caps concedidas a un peer `(uid, gid)`: la primera regla que casa, o `default`.
|
|
pub fn caps_for(&self, uid: u32, gid: u32) -> Vec<Cap> {
|
|
for r in &self.rule {
|
|
if r.matches(uid, gid) {
|
|
return r.caps.clone();
|
|
}
|
|
}
|
|
self.default.clone()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const CFG: &str = r#"
|
|
default = ["query"]
|
|
|
|
[[rule]]
|
|
uid = 0
|
|
caps = ["query", "compile", "inject", "inject-real", "init"]
|
|
|
|
[[rule]]
|
|
gid = 994
|
|
caps = ["query", "compile", "inject", "init"]
|
|
|
|
[[rule]]
|
|
uid = 1000
|
|
gid = 1000
|
|
caps = ["query", "compile"]
|
|
"#;
|
|
|
|
#[test]
|
|
fn root_gets_everything() {
|
|
let c = AgentCapsConfig::from_toml(CFG).unwrap();
|
|
assert_eq!(
|
|
c.caps_for(0, 0),
|
|
vec![Cap::Query, Cap::Compile, Cap::Inject, Cap::InjectReal, Cap::Init]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn first_matching_rule_wins() {
|
|
let c = AgentCapsConfig::from_toml(CFG).unwrap();
|
|
// uid 1000 + gid 994: la regla del gid 994 viene ANTES que la de uid+gid 1000,
|
|
// así que gana la del grupo.
|
|
assert_eq!(
|
|
c.caps_for(1000, 994),
|
|
vec![Cap::Query, Cap::Compile, Cap::Inject, Cap::Init]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn both_fields_must_match() {
|
|
let c = AgentCapsConfig::from_toml(CFG).unwrap();
|
|
// uid 1000 + gid 1000: no casa root, no casa gid 994; casa la regla uid=1000,gid=1000.
|
|
assert_eq!(c.caps_for(1000, 1000), vec![Cap::Query, Cap::Compile]);
|
|
}
|
|
|
|
#[test]
|
|
fn unmatched_falls_back_to_default() {
|
|
let c = AgentCapsConfig::from_toml(CFG).unwrap();
|
|
assert_eq!(c.caps_for(31337, 31337), vec![Cap::Query]);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_config_defaults_to_query() {
|
|
let c = AgentCapsConfig::from_toml("").unwrap();
|
|
assert_eq!(c.caps_for(0, 0), vec![Cap::Query]);
|
|
}
|
|
|
|
#[test]
|
|
fn rule_without_uid_or_gid_is_ignored() {
|
|
let c = AgentCapsConfig::from_toml(
|
|
r#"
|
|
default = ["query"]
|
|
[[rule]]
|
|
caps = ["init", "inject-real"]
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
// La regla vacía no casa a nadie ⇒ cae al default.
|
|
assert_eq!(c.caps_for(0, 0), vec![Cap::Query]);
|
|
}
|
|
|
|
#[test]
|
|
fn load_missing_file_is_none() {
|
|
let got = AgentCapsConfig::load(std::path::Path::new("/no/existe.toml")).unwrap();
|
|
assert!(got.is_none());
|
|
}
|
|
}
|