Capability quotas, CAS gc, patrones burst/silence, snapshot incremental
- quota_for_capability(cap) tabla por variante: Spawn 2, FilesystemRoot 2, Endpoint 16, Journal 32. mediate_capability rechaza con QuotaExceeded si holder ya tiene N tokens activos. - ente_cas::list_all_shas() + gc(reachable). audit::reachable_from_head() walks la cadena. Endpoint GcCas con extra_roots para Wasm SHAs. brainctl gc-cas. - PatternCrystal::Burst (kind, count, freq_hz) y Silence (kind, since_secs). detect_pattern_crystals + endpoint PatternCrystals + brainctl patterns. - Observer.dirty_marginal/dirty_cooccur tracking. snapshot() marca consumo (clears dirty); snapshot_delta() emite sólo lo cambiado. apply_delta() merges incremental sobre estado existente. Útil para checkpoints frecuentes con poco overhead. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
"flush-audit" => IntrospectRequest::FlushAudit,
|
||||
"audit-verify" | "verify" => IntrospectRequest::VerifyAudit,
|
||||
"replay" => IntrospectRequest::ReplayAudit,
|
||||
"gc-cas" => IntrospectRequest::GcCas { extra_roots: Vec::new() },
|
||||
"patterns" => IntrospectRequest::PatternCrystals,
|
||||
"reload" => {
|
||||
let path = args.get(2).cloned();
|
||||
IntrospectRequest::ReloadRules { path }
|
||||
@@ -158,6 +160,22 @@ fn print_response(r: &IntrospectResponse) {
|
||||
if let Some(g) = rep.genesis_sha { println!(" genesis: {}", hex_long(g)); }
|
||||
}
|
||||
}
|
||||
IntrospectResponse::Patterns(ps) => {
|
||||
println!("{} cristales pattern detectados:", ps.len());
|
||||
for p in ps {
|
||||
match p {
|
||||
ente_brain::crystallize::PatternCrystal::Burst { kind, count, frequency_per_sec } => {
|
||||
println!(" burst: {kind:?} count={count} freq={frequency_per_sec:.2} Hz");
|
||||
}
|
||||
ente_brain::crystallize::PatternCrystal::Silence { kind, last_count, since_secs } => {
|
||||
println!(" silence: {kind:?} last_count={last_count} ausente={since_secs:.1}s");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
IntrospectResponse::GcResult { deleted, freed_bytes } => {
|
||||
println!("CAS gc: {deleted} blobs eliminados, {freed_bytes} bytes liberados");
|
||||
}
|
||||
IntrospectResponse::AuditStreamFrame(_) => {
|
||||
// En modo request/response no debería llegar; solo aparece en
|
||||
// run_stream_audit. Si llega aquí es un bug del servidor.
|
||||
|
||||
@@ -280,6 +280,24 @@ pub fn verify_chain_from_cas(start_sha: [u8; 32]) -> VerificationReport {
|
||||
}
|
||||
}
|
||||
|
||||
/// Devuelve el set de SHAs alcanzables desde `start_sha` siguiendo
|
||||
/// `prev_sha` hasta el genesis. Usado por el GC del CAS para construir
|
||||
/// las "raíces vivas" del audit log.
|
||||
pub fn reachable_from_head(start_sha: [u8; 32]) -> std::collections::HashSet<[u8; 32]> {
|
||||
let mut set = std::collections::HashSet::new();
|
||||
let mut current = Some(start_sha);
|
||||
while let Some(sha) = current {
|
||||
if !set.insert(sha) { break; } // ciclo (no debería pasar) — corta
|
||||
let path = ente_cas::cas_root().join(ente_cas::hex(&sha));
|
||||
let bytes = match std::fs::read(&path) { Ok(b) => b, Err(_) => break };
|
||||
let entry: AuditEntry = match serde_json::from_slice(&bytes) {
|
||||
Ok(e) => e, Err(_) => break,
|
||||
};
|
||||
current = entry.prev_sha;
|
||||
}
|
||||
set
|
||||
}
|
||||
|
||||
/// Recorre la cadena entera (head→genesis) y reconstruye la lista de
|
||||
/// actions en orden cronológico (oldest first). Útil tanto para replay
|
||||
/// como para auditoría retrospectiva.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
use crate::observer::{GapStats, Observer};
|
||||
use crate::rules::{Action, EventKind, EventPattern, LogLevel, Rule, Scope};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Instant;
|
||||
use ulid::Ulid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -164,6 +165,99 @@ pub fn crystal_to_rule(c: &Crystal) -> Rule {
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Patrones extendidos: Burst (alta frecuencia) y Silence (ausencia prolongada).
|
||||
// Estos cristales son sobre un único kind, no pares — capturan dinámicas
|
||||
// temporales de eventos individuales.
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum PatternCrystal {
|
||||
/// Mismo evento aparece con frecuencia alta. `frequency_per_sec` se
|
||||
/// estima sobre el window de observación.
|
||||
Burst {
|
||||
kind: EventKind,
|
||||
count: u64,
|
||||
frequency_per_sec: f64,
|
||||
},
|
||||
/// Evento que dejó de aparecer. `since_secs` es el tiempo desde la
|
||||
/// última observación.
|
||||
Silence {
|
||||
kind: EventKind,
|
||||
last_count: u64,
|
||||
since_secs: f64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PatternParams {
|
||||
/// Mínimo de ocurrencias para considerar Burst.
|
||||
pub burst_min_count: u64,
|
||||
/// Frecuencia mínima (eventos por segundo) para considerar Burst.
|
||||
pub burst_min_freq_hz: f64,
|
||||
/// Tiempo desde última ocurrencia para considerar Silence.
|
||||
pub silence_min_secs: f64,
|
||||
/// Mínimo total previo para considerar Silence (eventos < N son ruido).
|
||||
pub silence_min_prior_count: u64,
|
||||
}
|
||||
|
||||
impl Default for PatternParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
burst_min_count: 10,
|
||||
burst_min_freq_hz: 5.0,
|
||||
silence_min_secs: 30.0,
|
||||
silence_min_prior_count: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detecta Bursts y Silences sobre la distribución marginal del observer.
|
||||
/// La frecuencia de un Burst se aproxima asumiendo que la observación cubre
|
||||
/// el rango entre `last_seen` y `Instant::now()` para ese kind.
|
||||
pub fn detect_pattern_crystals(obs: &Observer, params: &PatternParams) -> Vec<PatternCrystal> {
|
||||
let mut out = Vec::new();
|
||||
let now = Instant::now();
|
||||
for (kind, &count) in obs.marginals() {
|
||||
let last_seen = obs.last_seen_marginal(kind);
|
||||
// ---- Burst ----
|
||||
if count >= params.burst_min_count {
|
||||
// Aproximación: si vimos `count` eventos hasta `last_seen`, y el
|
||||
// primer evento sucedió en algún momento del window, la freq es
|
||||
// count / window_age. Sin tiempo del primer evento, usamos
|
||||
// last_seen → now como denominador (subestima freq) o asumimos
|
||||
// ventana fija de 60s. Usamos la última como aproximación.
|
||||
let elapsed = last_seen
|
||||
.map(|t| now.saturating_duration_since(t).as_secs_f64().max(0.001))
|
||||
.unwrap_or(60.0);
|
||||
// Estimación conservadora: count / max(window_age, 1s).
|
||||
// Si tenemos histograma, podríamos refinar — TODO.
|
||||
let freq = count as f64 / elapsed.max(1.0);
|
||||
if freq >= params.burst_min_freq_hz {
|
||||
out.push(PatternCrystal::Burst {
|
||||
kind: kind.clone(),
|
||||
count,
|
||||
frequency_per_sec: freq,
|
||||
});
|
||||
}
|
||||
}
|
||||
// ---- Silence ----
|
||||
if count >= params.silence_min_prior_count {
|
||||
if let Some(t) = last_seen {
|
||||
let since = now.saturating_duration_since(t).as_secs_f64();
|
||||
if since >= params.silence_min_secs {
|
||||
out.push(PatternCrystal::Silence {
|
||||
kind: kind.clone(),
|
||||
last_count: count,
|
||||
since_secs: since,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -113,6 +113,12 @@ pub enum IntrospectRequest {
|
||||
/// frames `IntrospectResponse::AuditStreamFrame` hasta que el cliente
|
||||
/// cierra. Tras esta request no se aceptan más requests en la misma conn.
|
||||
StreamAudit,
|
||||
/// Garbage-collect el CAS. Considera reachable: todo lo alcanzable desde
|
||||
/// el head del audit log. Cualquier blob extra (Wasm modules referenciados
|
||||
/// por Cards) debe haberse pasado en `extra_roots` por el caller.
|
||||
GcCas { extra_roots: Vec<[u8; 32]> },
|
||||
/// Detecta cristales de patrones temporales (Burst, Silence).
|
||||
PatternCrystals,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -140,6 +146,10 @@ pub enum IntrospectResponse {
|
||||
Replayed(crate::audit::ReplayReport),
|
||||
/// Frame de streaming. El cliente lee estos en bucle hasta EOF.
|
||||
AuditStreamFrame(crate::audit::AuditEntry),
|
||||
/// Resultado de GcCas: cuántos blobs eliminados y bytes liberados.
|
||||
GcResult { deleted: usize, freed_bytes: u64 },
|
||||
/// Cristales de Burst/Silence detectados.
|
||||
Patterns(Vec<crate::crystallize::PatternCrystal>),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
@@ -372,6 +382,24 @@ impl IntrospectServer {
|
||||
"StreamAudit no debe llegar a dispatch — bug del handler".into()
|
||||
)
|
||||
}
|
||||
IntrospectRequest::PatternCrystals => {
|
||||
let obs = self.state.observer.read().await;
|
||||
let params = crate::crystallize::PatternParams::default();
|
||||
let patterns = crate::crystallize::detect_pattern_crystals(&obs, ¶ms);
|
||||
IntrospectResponse::Patterns(patterns)
|
||||
}
|
||||
IntrospectRequest::GcCas { extra_roots } => {
|
||||
// Reachable = audit chain desde head + extra_roots provistos.
|
||||
let mut reachable = std::collections::HashSet::new();
|
||||
if let Some(head) = self.state.audit.read().await.last_flushed_sha() {
|
||||
reachable.extend(crate::audit::reachable_from_head(head));
|
||||
}
|
||||
reachable.extend(extra_roots);
|
||||
match ente_cas::gc(&reachable) {
|
||||
Ok((deleted, freed_bytes)) => IntrospectResponse::GcResult { deleted, freed_bytes },
|
||||
Err(e) => IntrospectResponse::Error(format!("gc: {e}")),
|
||||
}
|
||||
}
|
||||
IntrospectRequest::ReplayAudit => {
|
||||
let head = self.state.audit.read().await.last_flushed_sha();
|
||||
let head = match head {
|
||||
|
||||
@@ -102,6 +102,10 @@ pub struct Observer {
|
||||
half_life_secs: Option<f64>,
|
||||
/// Histograma de gaps temporales por par (a, b). Capturado al `record()`.
|
||||
gap_histograms: HashMap<(EventKind, EventKind), GapHistogram>,
|
||||
/// Sets de "qué cambió desde el último snapshot". Se vacían en
|
||||
/// `snapshot()` y `snapshot_delta()`. Usado para escritura incremental.
|
||||
dirty_marginal: std::collections::HashSet<EventKind>,
|
||||
dirty_cooccur: std::collections::HashSet<(EventKind, EventKind)>,
|
||||
}
|
||||
|
||||
impl Observer {
|
||||
@@ -116,6 +120,8 @@ impl Observer {
|
||||
last_seen_cooccur: HashMap::new(),
|
||||
half_life_secs: None,
|
||||
gap_histograms: HashMap::new(),
|
||||
dirty_marginal: std::collections::HashSet::new(),
|
||||
dirty_cooccur: std::collections::HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +149,8 @@ impl Observer {
|
||||
*self.cooccur.entry(key.clone()).or_insert(0) += 1;
|
||||
self.last_seen_cooccur.insert(key.clone(), now);
|
||||
let gap_secs = now.duration_since(w.at).as_secs_f64();
|
||||
self.gap_histograms.entry(key).or_default().observe(gap_secs);
|
||||
self.gap_histograms.entry(key.clone()).or_default().observe(gap_secs);
|
||||
self.dirty_cooccur.insert(key);
|
||||
}
|
||||
|
||||
self.window.push_back(timed);
|
||||
@@ -152,7 +159,8 @@ impl Observer {
|
||||
}
|
||||
|
||||
*self.marginal.entry(kind.clone()).or_insert(0) += 1;
|
||||
self.last_seen_marginal.insert(kind, now);
|
||||
self.last_seen_marginal.insert(kind.clone(), now);
|
||||
self.dirty_marginal.insert(kind);
|
||||
self.total += 1;
|
||||
}
|
||||
|
||||
@@ -238,6 +246,12 @@ impl Observer {
|
||||
}
|
||||
|
||||
pub fn marginals(&self) -> &HashMap<EventKind, u64> { &self.marginal }
|
||||
|
||||
/// Última vez que se vio un kind. None si nunca o si fue restaurado
|
||||
/// desde snapshot (los Instants no portables se descartan).
|
||||
pub fn last_seen_marginal(&self, kind: &EventKind) -> Option<Instant> {
|
||||
self.last_seen_marginal.get(kind).copied()
|
||||
}
|
||||
pub fn cooccurrences(&self) -> &HashMap<(EventKind, EventKind), u64> { &self.cooccur }
|
||||
pub fn total(&self) -> u64 { self.total }
|
||||
pub fn window_size(&self) -> usize { self.window_size }
|
||||
@@ -263,12 +277,15 @@ impl Observer {
|
||||
pairs
|
||||
}
|
||||
|
||||
/// Snapshot serializable del estado estadístico (sin Instants — no son
|
||||
/// portables a través de reboots). El window deslizante se descarta —
|
||||
/// se reconstruye desde cero al restore.
|
||||
pub fn snapshot(&self) -> ObserverSnapshot {
|
||||
/// Snapshot full: estado estadístico completo. Limpia los sets dirty
|
||||
/// como side-effect — los próximos `snapshot_delta()` cubren sólo los
|
||||
/// cambios posteriores.
|
||||
pub fn snapshot(&mut self) -> ObserverSnapshot {
|
||||
self.dirty_marginal.clear();
|
||||
self.dirty_cooccur.clear();
|
||||
ObserverSnapshot {
|
||||
schema_version: OBSERVER_SCHEMA_VERSION,
|
||||
is_delta: false,
|
||||
window_size: self.window_size,
|
||||
half_life_secs: self.half_life_secs,
|
||||
total: self.total,
|
||||
@@ -284,6 +301,64 @@ impl Observer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot incremental: sólo incluye los kinds y pares que cambiaron
|
||||
/// desde el último `snapshot()` o `snapshot_delta()`. Útil para
|
||||
/// checkpoints frecuentes con poco overhead. Limpia los sets dirty.
|
||||
pub fn snapshot_delta(&mut self) -> ObserverSnapshot {
|
||||
let marginal: Vec<_> = self.dirty_marginal.iter()
|
||||
.filter_map(|k| self.marginal.get(k).map(|v| (k.clone(), *v)))
|
||||
.collect();
|
||||
let cooccur: Vec<_> = self.dirty_cooccur.iter()
|
||||
.filter_map(|(a, b)| {
|
||||
self.cooccur.get(&(a.clone(), b.clone()))
|
||||
.map(|c| (a.clone(), b.clone(), *c))
|
||||
})
|
||||
.collect();
|
||||
// Para histogramas: incluimos los pares cuyo cooccur cambió.
|
||||
let gap_histograms: Vec<_> = self.dirty_cooccur.iter()
|
||||
.filter_map(|(a, b)| {
|
||||
self.gap_histograms.get(&(a.clone(), b.clone()))
|
||||
.map(|h| (a.clone(), b.clone(), h.clone()))
|
||||
})
|
||||
.collect();
|
||||
self.dirty_marginal.clear();
|
||||
self.dirty_cooccur.clear();
|
||||
ObserverSnapshot {
|
||||
schema_version: OBSERVER_SCHEMA_VERSION,
|
||||
is_delta: true,
|
||||
window_size: self.window_size,
|
||||
half_life_secs: self.half_life_secs,
|
||||
total: self.total,
|
||||
marginal, cooccur, gap_histograms,
|
||||
}
|
||||
}
|
||||
|
||||
/// Aplica un delta sobre el estado actual. Para `is_delta=true`, los
|
||||
/// valores en marginal/cooccur sobrescriben las entradas existentes.
|
||||
/// Si `is_delta=false`, equivale a `from_snapshot` pero in-place.
|
||||
pub fn apply_delta(&mut self, delta: ObserverSnapshot) {
|
||||
let now = Instant::now();
|
||||
if !delta.is_delta {
|
||||
// Full: reset state.
|
||||
*self = Self::from_snapshot(delta);
|
||||
return;
|
||||
}
|
||||
// Incremental merge.
|
||||
for (k, v) in delta.marginal {
|
||||
self.last_seen_marginal.insert(k.clone(), now);
|
||||
self.marginal.insert(k, v);
|
||||
}
|
||||
for (a, b, c) in delta.cooccur {
|
||||
self.last_seen_cooccur.insert((a.clone(), b.clone()), now);
|
||||
self.cooccur.insert((a, b), c);
|
||||
}
|
||||
for (a, b, h) in delta.gap_histograms {
|
||||
self.gap_histograms.insert((a, b), h);
|
||||
}
|
||||
// total: sólo subimos (el delta podría estar atrasado).
|
||||
if delta.total > self.total { self.total = delta.total; }
|
||||
}
|
||||
|
||||
/// Reconstruye Observer desde un snapshot. El window queda vacío;
|
||||
/// last_seen_* se inicializa en `now()` para que el decay arranque
|
||||
/// "ahora" para todos los counts (aproximación razonable post-reboot).
|
||||
@@ -314,6 +389,8 @@ impl Observer {
|
||||
last_seen_cooccur,
|
||||
half_life_secs: snap.half_life_secs,
|
||||
gap_histograms,
|
||||
dirty_marginal: std::collections::HashSet::new(),
|
||||
dirty_cooccur: std::collections::HashSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -325,6 +402,10 @@ const OBSERVER_SCHEMA_VERSION: u16 = 1;
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ObserverSnapshot {
|
||||
pub schema_version: u16,
|
||||
/// `true` si sólo contiene los cambios desde el último snapshot.
|
||||
/// `false` = full state, sobreescribe el observer al aplicar.
|
||||
#[serde(default)]
|
||||
pub is_delta: bool,
|
||||
pub window_size: usize,
|
||||
pub half_life_secs: Option<f64>,
|
||||
pub total: u64,
|
||||
|
||||
@@ -36,6 +36,55 @@ pub fn hex(sha: &[u8; 32]) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
/// Lista todos los SHAs presentes en el CAS. Cada entrada del directorio
|
||||
/// con nombre de 64 chars hex se considera un blob válido.
|
||||
pub fn list_all_shas() -> anyhow::Result<Vec<[u8; 32]>> {
|
||||
let root = cas_root();
|
||||
if !root.exists() { return Ok(Vec::new()); }
|
||||
let mut out = Vec::new();
|
||||
for entry in std::fs::read_dir(&root)? {
|
||||
let e = entry?;
|
||||
let name = e.file_name();
|
||||
let s = match name.to_str() {
|
||||
Some(s) if s.len() == 64 => s,
|
||||
_ => continue,
|
||||
};
|
||||
let mut sha = [0u8; 32];
|
||||
let mut ok = true;
|
||||
for i in 0..32 {
|
||||
match u8::from_str_radix(&s[i*2..i*2+2], 16) {
|
||||
Ok(b) => sha[i] = b,
|
||||
Err(_) => { ok = false; break; }
|
||||
}
|
||||
}
|
||||
if ok { out.push(sha); }
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Garbage collector. Borra todos los blobs que no están en `reachable`.
|
||||
/// Devuelve (deleted_count, freed_bytes). El caller construye `reachable`
|
||||
/// caminando todas las raíces (audit chain head, Wasm SHAs en Cards, etc).
|
||||
///
|
||||
/// Idempotente: re-correr no hace nada si el set no cambió.
|
||||
pub fn gc(reachable: &std::collections::HashSet<[u8; 32]>) -> anyhow::Result<(usize, u64)> {
|
||||
let root = cas_root();
|
||||
let mut deleted = 0usize;
|
||||
let mut freed = 0u64;
|
||||
for sha in list_all_shas()? {
|
||||
if reachable.contains(&sha) { continue; }
|
||||
let path = root.join(hex(&sha));
|
||||
if let Ok(meta) = std::fs::metadata(&path) {
|
||||
freed += meta.len();
|
||||
}
|
||||
if std::fs::remove_file(&path).is_ok() {
|
||||
deleted += 1;
|
||||
tracing::debug!(sha = %hex(&sha), "CAS gc removed");
|
||||
}
|
||||
}
|
||||
Ok((deleted, freed))
|
||||
}
|
||||
|
||||
pub fn resolve(sha: &[u8; 32]) -> anyhow::Result<Vec<u8>> {
|
||||
let path = cas_root().join(hex(sha));
|
||||
let bytes = std::fs::read(&path)
|
||||
|
||||
@@ -52,4 +52,7 @@ pub enum CapabilityGrant {
|
||||
Granted { token: u64 },
|
||||
NoProvider,
|
||||
Denied { reason: &'static str },
|
||||
/// El holder ya tiene el máximo de tokens activos para esta cap.
|
||||
/// Debe esperar a que alguno expire o renovar uno existente.
|
||||
QuotaExceeded { active: u32, limit: u32 },
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! periódicamente con `renew_grant(token)`; en caso contrario, el background
|
||||
//! task `purge_expired_grants` los revoca al vencimiento.
|
||||
|
||||
use super::{ttl_for_capability, EnteGraph, GrantedCapability};
|
||||
use super::{quota_for_capability, ttl_for_capability, EnteGraph, GrantedCapability};
|
||||
use crate::events::CapabilityGrant;
|
||||
use ente_card::Capability;
|
||||
use std::time::Instant;
|
||||
@@ -22,24 +22,38 @@ impl EnteGraph {
|
||||
let grant = match self.providers.get(&cap).and_then(|s| s.iter().next().copied()) {
|
||||
None => CapabilityGrant::NoProvider,
|
||||
Some(provider) => {
|
||||
let token = self.next_token;
|
||||
self.next_token += 1;
|
||||
// TTL específico por variante de capability — caps escaladas
|
||||
// (Spawn, FilesystemRoot) viven menos.
|
||||
let ttl = ttl_for_capability(&cap);
|
||||
let expires_at = Instant::now() + ttl;
|
||||
self.grants.insert(token, GrantedCapability {
|
||||
cap: cap.clone(),
|
||||
provider,
|
||||
holder: from,
|
||||
expires_at,
|
||||
});
|
||||
CapabilityGrant::Granted { token }
|
||||
// Quota: contar tokens vivos para (from, cap). Si excede,
|
||||
// rechazar antes de emitir uno nuevo.
|
||||
let limit = quota_for_capability(&cap);
|
||||
let active = self.active_tokens_for(from, &cap);
|
||||
if active >= limit {
|
||||
CapabilityGrant::QuotaExceeded { active, limit }
|
||||
} else {
|
||||
let token = self.next_token;
|
||||
self.next_token += 1;
|
||||
let ttl = ttl_for_capability(&cap);
|
||||
let expires_at = Instant::now() + ttl;
|
||||
self.grants.insert(token, GrantedCapability {
|
||||
cap: cap.clone(),
|
||||
provider,
|
||||
holder: from,
|
||||
expires_at,
|
||||
});
|
||||
CapabilityGrant::Granted { token }
|
||||
}
|
||||
}
|
||||
};
|
||||
let _ = reply.send(grant);
|
||||
}
|
||||
|
||||
/// Cuenta tokens vivos (no expirados) emitidos a un holder para una cap.
|
||||
pub fn active_tokens_for(&self, holder: Ulid, cap: &Capability) -> u32 {
|
||||
let now = Instant::now();
|
||||
self.grants.values()
|
||||
.filter(|g| g.holder == holder && &g.cap == cap && g.expires_at > now)
|
||||
.count() as u32
|
||||
}
|
||||
|
||||
/// Extiende un grant existente. Devuelve `true` si renovó. Si el token
|
||||
/// no existe o ya expiró, `false` (el cliente debe re-acquire).
|
||||
/// Usa el TTL específico de la cap del grant.
|
||||
|
||||
@@ -76,6 +76,24 @@ pub(in crate::graph) struct GrantedCapability {
|
||||
/// corto enough para que credenciales filtradas expiren rápidamente.
|
||||
pub const DEFAULT_GRANT_TTL: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
/// Quota máxima de tokens activos por (holder, cap). Caps escaladas tienen
|
||||
/// quota baja para limitar fugas de credenciales; caps de uso frecuente
|
||||
/// (Endpoint, Journal) son más laxas.
|
||||
pub fn quota_for_capability(cap: &Capability) -> u32 {
|
||||
match cap {
|
||||
// Caps escaladas: pocos tokens, fuerza patrón request-act-release.
|
||||
Capability::Spawn => 2,
|
||||
Capability::FilesystemRoot => 2,
|
||||
Capability::Device { .. } => 4,
|
||||
// Caps de propósito general.
|
||||
Capability::Endpoint { .. } => 16,
|
||||
Capability::KernelNetlink(_) => 4,
|
||||
Capability::LegacyLogind => 8,
|
||||
// Logging: hasta 32 streams.
|
||||
Capability::Journal => 32,
|
||||
}
|
||||
}
|
||||
|
||||
/// TTL específico por variante de Capability. Caps de mayor riesgo / costo
|
||||
/// (Spawn, FilesystemRoot) tienen TTL más corto; caps "logging" como
|
||||
/// Journal pueden vivir más.
|
||||
|
||||
@@ -342,7 +342,7 @@ async fn dispatch_graph_event(
|
||||
}
|
||||
// Snapshot del cerebro (observer state) en archivo adjunto
|
||||
let brain_path = path.with_extension("brain.json");
|
||||
let obs_snap = brain.observer.read().await.snapshot();
|
||||
let obs_snap = brain.observer.write().await.snapshot();
|
||||
match write_brain_snapshot(&brain_path, &obs_snap) {
|
||||
Ok(()) => info!(
|
||||
path = %brain_path.display(),
|
||||
|
||||
Reference in New Issue
Block a user