Trusty: provenance library workspace (initial import)
This commit is contained in:
262
crates/adapters/trusty-policy-audit/src/lib.rs
Normal file
262
crates/adapters/trusty-policy-audit/src/lib.rs
Normal file
@@ -0,0 +1,262 @@
|
||||
//! Trusty-backed `PolicyAuditor`.
|
||||
//!
|
||||
//! Closes the policy <-> chain loop: every `PolicyEvent` emitted by
|
||||
//! `trusty_policy::PolicyEngine::evaluate` (Allow OR Deny) is appended
|
||||
//! to a `Trusty` chain. The `entity_id` of the chain row is the policy
|
||||
//! scope (e.g. `agent.executor.v1`), so each registered FSM gets its
|
||||
//! own per-entity chain. Tamper-evidence, ordering, and external
|
||||
//! verifiability are inherited from `trusty-sqlite`.
|
||||
//!
|
||||
//! # Why a separate crate
|
||||
//!
|
||||
//! `trusty-policy` deliberately has no dependency on `trusty-core` /
|
||||
//! `trusty-sqlite` (library-not-platform rule). Wiring the two
|
||||
//! together lives here so consumers who want only the policy gate
|
||||
//! without an audit chain pay nothing.
|
||||
//!
|
||||
//! # Quickstart
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use trusty_policy::{Fsm, PolicyEngine, ScopeKey, Transition};
|
||||
//! use trusty_sqlite::Trusty;
|
||||
//! use trusty_core::SecretKey;
|
||||
//! use trusty_policy_audit::TrustyAuditor;
|
||||
//!
|
||||
//! let trusty = Trusty::open_in_memory(SecretKey::new([7u8; 16])).unwrap();
|
||||
//! let auditor = TrustyAuditor::new(trusty);
|
||||
//!
|
||||
//! let scope = ScopeKey::new("agent.executor.v1");
|
||||
//! let mut fsm: Fsm<&'static str, &'static str> = Fsm::new("idle");
|
||||
//! fsm.allow(Transition::new("idle", "claim", "claimed"));
|
||||
//!
|
||||
//! let mut engine = PolicyEngine::new(auditor);
|
||||
//! engine.register(scope.clone(), fsm).unwrap();
|
||||
//! let _ = engine.evaluate(&scope, &"idle", &"claim", Some("agent_001"));
|
||||
//! // The chain row for entity_id="agent.executor.v1" now contains
|
||||
//! // the PolicyEvent JSON.
|
||||
//! ```
|
||||
|
||||
#![deny(clippy::all)]
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use trusty_policy::{PolicyAuditor, PolicyEvent};
|
||||
use trusty_sqlite::Trusty;
|
||||
|
||||
/// Source-URL marker stamped on every chained policy row. Consumers
|
||||
/// can filter by this to separate policy events from other domain
|
||||
/// events that may share a chain.
|
||||
pub const POLICY_SOURCE_URL: &str = "policy://trusty-policy/event";
|
||||
|
||||
/// Extraction-method tag used when calling `Trusty::append_with_method`.
|
||||
/// Mirrors the existing trusty convention; consumers reading the chain
|
||||
/// can recognize policy rows by `extraction_method = "policy"`.
|
||||
pub const POLICY_EXTRACTION_METHOD: &str = "policy";
|
||||
|
||||
/// `PolicyAuditor` that appends each `PolicyEvent` to a `Trusty` chain.
|
||||
/// The `Trusty` handle is held behind `Arc<Mutex<_>>` so the auditor
|
||||
/// is `Send + 'static` (the trait bound `PolicyEngine::new` requires)
|
||||
/// while still allowing the caller to share the same chain across
|
||||
/// other writers if needed.
|
||||
///
|
||||
/// On audit failure (rare: disk full, schema corruption) the auditor
|
||||
/// stores the most recent error in `last_error` and silently drops
|
||||
/// the row. Callers who need hard-stop semantics on audit failure
|
||||
/// should poll `last_error()` or wrap with their own checking layer.
|
||||
/// We deliberately do NOT panic: a misbehaving audit sink must not
|
||||
/// take down the policy engine during a deny path.
|
||||
pub struct TrustyAuditor {
|
||||
trusty: Arc<Mutex<Trusty>>,
|
||||
last_error: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
impl TrustyAuditor {
|
||||
/// Wrap a `Trusty` handle. The auditor takes ownership; if you
|
||||
/// need shared access, build the auditor with [`Self::from_shared`].
|
||||
pub fn new(trusty: Trusty) -> Self {
|
||||
Self {
|
||||
trusty: Arc::new(Mutex::new(trusty)),
|
||||
last_error: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap an already-shared `Trusty` so the same chain can be
|
||||
/// appended-to by other writers.
|
||||
pub fn from_shared(trusty: Arc<Mutex<Trusty>>) -> Self {
|
||||
Self {
|
||||
trusty,
|
||||
last_error: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Most recent audit error, if any. Cleared by [`Self::clear_error`].
|
||||
pub fn last_error(&self) -> Option<String> {
|
||||
self.last_error.lock().ok().and_then(|g| g.clone())
|
||||
}
|
||||
|
||||
pub fn clear_error(&self) {
|
||||
if let Ok(mut g) = self.last_error.lock() {
|
||||
*g = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow the inner shared handle. Useful for the consumer to
|
||||
/// run integrity / verification queries after the engine has
|
||||
/// emitted events.
|
||||
pub fn shared(&self) -> Arc<Mutex<Trusty>> {
|
||||
Arc::clone(&self.trusty)
|
||||
}
|
||||
}
|
||||
|
||||
impl PolicyAuditor for TrustyAuditor {
|
||||
fn audit(&mut self, event: &PolicyEvent) {
|
||||
// Serialize the event as JSON. The chain row's `data` column
|
||||
// is the canonical record; downstream verifiers re-parse it.
|
||||
let payload = match serde_json::to_value(event) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
self.record_error(format!("serialize PolicyEvent: {e}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// entity_id = scope. One chain per FSM scope.
|
||||
let entity_id = event.scope.as_str();
|
||||
|
||||
let mut guard = match self.trusty.lock() {
|
||||
Ok(g) => g,
|
||||
Err(p) => {
|
||||
self.record_error(format!("trusty mutex poisoned: {p}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = guard.append_with_method(
|
||||
entity_id,
|
||||
&payload,
|
||||
POLICY_SOURCE_URL,
|
||||
POLICY_EXTRACTION_METHOD,
|
||||
) {
|
||||
self.record_error(format!("append_with_method: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TrustyAuditor {
|
||||
fn record_error(&self, msg: String) {
|
||||
if let Ok(mut g) = self.last_error.lock() {
|
||||
*g = Some(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use trusty_core::SecretKey;
|
||||
use trusty_policy::{Fsm, PolicyEngine, ScopeKey, Transition};
|
||||
|
||||
fn key() -> SecretKey {
|
||||
SecretKey::new([7u8; 16])
|
||||
}
|
||||
|
||||
fn pipeline_fsm() -> Fsm<&'static str, &'static str> {
|
||||
let mut f = Fsm::new("idle");
|
||||
f.allow(Transition::new("idle", "claim", "claimed"))
|
||||
.allow(Transition::new("claimed", "execute", "running"))
|
||||
.allow(Transition::new("running", "finish", "done"));
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_emits_chain_row() {
|
||||
let trusty = Trusty::open_in_memory(key()).unwrap();
|
||||
let auditor = TrustyAuditor::new(trusty);
|
||||
let shared = auditor.shared();
|
||||
|
||||
let scope = ScopeKey::new("agent.executor.v1");
|
||||
let mut engine: PolicyEngine<&'static str, &'static str> = PolicyEngine::new(auditor);
|
||||
engine.register(scope.clone(), pipeline_fsm()).unwrap();
|
||||
|
||||
let r = engine.evaluate(&scope, &"idle", &"claim", Some("agent_001"));
|
||||
assert!(r.is_allow());
|
||||
|
||||
// Read back the chain via the shared handle. raw_payload is
|
||||
// the full envelope; the PolicyEvent JSON lives at .data.
|
||||
let guard = shared.lock().unwrap();
|
||||
let history = guard.history("agent.executor.v1", 100).unwrap();
|
||||
assert_eq!(history.len(), 1, "exactly one chained policy row");
|
||||
let raw = history[0].raw_payload.as_ref().expect("payload present");
|
||||
let envelope: serde_json::Value = serde_json::from_str(raw).unwrap();
|
||||
let payload = &envelope["data"];
|
||||
assert_eq!(payload["scope"], "agent.executor.v1");
|
||||
assert_eq!(payload["from"], "\"idle\"");
|
||||
assert_eq!(payload["action"], "\"claim\"");
|
||||
assert_eq!(payload["actor"], "agent_001");
|
||||
assert!(payload["result"].get("Allow").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_also_emits_chain_row() {
|
||||
let trusty = Trusty::open_in_memory(key()).unwrap();
|
||||
let auditor = TrustyAuditor::new(trusty);
|
||||
let shared = auditor.shared();
|
||||
|
||||
let scope = ScopeKey::new("agent.executor.v1");
|
||||
let mut engine: PolicyEngine<&'static str, &'static str> = PolicyEngine::new(auditor);
|
||||
engine.register(scope.clone(), pipeline_fsm()).unwrap();
|
||||
|
||||
// illegal: idle -> finish is undeclared
|
||||
let r = engine.evaluate(&scope, &"idle", &"finish", None);
|
||||
assert!(!r.is_allow());
|
||||
|
||||
let guard = shared.lock().unwrap();
|
||||
let history = guard.history("agent.executor.v1", 100).unwrap();
|
||||
assert_eq!(history.len(), 1, "deny is also chained");
|
||||
let envelope: serde_json::Value =
|
||||
serde_json::from_str(history[0].raw_payload.as_ref().unwrap()).unwrap();
|
||||
assert!(envelope["data"]["result"].get("Deny").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_scopes_get_separate_chains() {
|
||||
let trusty = Trusty::open_in_memory(key()).unwrap();
|
||||
let auditor = TrustyAuditor::new(trusty);
|
||||
let shared = auditor.shared();
|
||||
|
||||
let s_exec = ScopeKey::new("agent.executor.v1");
|
||||
let s_audit = ScopeKey::new("agent.auditor.v1");
|
||||
|
||||
let mut engine: PolicyEngine<&'static str, &'static str> = PolicyEngine::new(auditor);
|
||||
engine.register(s_exec.clone(), pipeline_fsm()).unwrap();
|
||||
engine.register(s_audit.clone(), pipeline_fsm()).unwrap();
|
||||
|
||||
let _ = engine.evaluate(&s_exec, &"idle", &"claim", None);
|
||||
let _ = engine.evaluate(&s_audit, &"idle", &"claim", None);
|
||||
|
||||
let guard = shared.lock().unwrap();
|
||||
assert_eq!(guard.history("agent.executor.v1", 100).unwrap().len(), 1);
|
||||
assert_eq!(guard.history("agent.auditor.v1", 100).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chain_is_tamper_evident() {
|
||||
let trusty = Trusty::open_in_memory(key()).unwrap();
|
||||
let auditor = TrustyAuditor::new(trusty);
|
||||
let shared = auditor.shared();
|
||||
|
||||
let scope = ScopeKey::new("agent.executor.v1");
|
||||
let mut engine: PolicyEngine<&'static str, &'static str> = PolicyEngine::new(auditor);
|
||||
engine.register(scope.clone(), pipeline_fsm()).unwrap();
|
||||
|
||||
let _ = engine.evaluate(&scope, &"idle", &"claim", None);
|
||||
let _ = engine.evaluate(&scope, &"claimed", &"finish", None); // deny
|
||||
let _ = engine.evaluate(&scope, &"claimed", &"execute", None);
|
||||
let _ = engine.evaluate(&scope, &"running", &"finish", None);
|
||||
|
||||
let guard = shared.lock().unwrap();
|
||||
let report = guard.integrity().unwrap();
|
||||
assert!(report.ok, "chain integrity must hold: {:?}", report);
|
||||
assert_eq!(guard.history("agent.executor.v1", 100).unwrap().len(), 4);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user