Skip to main content

systemprompt_security/policy/
audit.rs

1//! Audit blob for governed-call decisions.
2//!
3//! [`DecisionAudit`] is the typed shape serialized whole into
4//! `governance_decisions.evaluated_rules`; the flat columns (`decision`,
5//! `reason`, `policy`) are derived from it by [`record_decision`], which
6//! delegates to the canonical
7//! [`insert_governance_decision`] writer. The serialized shape is a persisted
8//! contract rendered by dashboards
9//! — field renames here are schema changes.
10//!
11//! Copyright (c) systemprompt.io — Business Source License 1.1.
12//! See <https://systemprompt.io> for licensing details.
13
14use serde::Serialize;
15use sqlx::PgPool;
16use systemprompt_identifiers::{Actor, AgentId, ContextId, PluginId, PolicyId, SessionId, UserId};
17
18use super::types::AccessScope;
19use crate::authz::types::{Decision, DecisionTag};
20use crate::authz::{GovernanceDecisionRecord, insert_governance_decision};
21
22#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
23#[serde(tag = "result", rename_all = "lowercase")]
24pub enum ChainEntryResult {
25    Pass,
26    Fail,
27    Disabled,
28    Skip,
29}
30
31/// One traced chain entry: which policy, what it decided, and what it cost.
32#[derive(Debug, Serialize, Clone)]
33pub struct ChainEntryOutcome {
34    pub policy_id: PolicyId,
35    #[serde(flatten)]
36    pub result: ChainEntryResult,
37    pub detail: String,
38    pub duration_ms: f64,
39}
40
41#[derive(Debug, Serialize, Clone)]
42pub struct PrincipalSnapshot {
43    pub user_id: UserId,
44    pub session_id: SessionId,
45    pub agent_session: Option<SessionId>,
46    pub agent_id: Option<AgentId>,
47    pub agent_scope: AccessScope,
48}
49
50#[derive(Debug, Serialize, Clone)]
51pub struct AuditTarget {
52    pub tool_name: String,
53    pub plugin_id: Option<PluginId>,
54}
55
56#[derive(Debug, Serialize, Clone)]
57pub struct ApproverStamp {
58    pub user_id: UserId,
59    pub username: String,
60    pub decided_at: chrono::DateTime<chrono::Utc>,
61    pub action: &'static str,
62}
63
64/// Whether an audit row is the first judgement of a call or a later
65/// enforcement point re-verifying it.
66#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
67#[serde(rename_all = "snake_case")]
68pub enum AuditOrigin {
69    Governed,
70    Reverified,
71}
72
73#[derive(Debug, Serialize, Clone)]
74pub struct DecisionAudit {
75    pub id: String,
76    pub call_id: String,
77    pub origin: AuditOrigin,
78    pub decision: Decision,
79    pub principal: PrincipalSnapshot,
80    pub target: AuditTarget,
81    pub chain: Vec<ChainEntryOutcome>,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub approver: Option<ApproverStamp>,
84    #[serde(skip_serializing_if = "Vec::is_empty")]
85    pub act_chain: Vec<Actor>,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub context_id: Option<String>,
88}
89
90// Why: an allow because nothing ran and an allow because everything passed are
91// the same `Decision`, and the flat `policy` column is what operational queries
92// filter on. Collapsing both to `default_allow` would make an unguarded
93// installation indistinguishable from a healthy one.
94fn allow_policy_label(chain: &[ChainEntryOutcome]) -> &'static str {
95    if !chain.is_empty() && chain.iter().all(|e| e.result == ChainEntryResult::Disabled) {
96        return "governance_disabled";
97    }
98    "default_allow"
99}
100
101pub async fn record_decision(pool: &PgPool, audit: &DecisionAudit) -> Result<(), sqlx::Error> {
102    let actor = Actor::from_tool_name(
103        audit.principal.user_id.clone(),
104        audit.principal.agent_id.as_ref().map(AgentId::as_str),
105        &audit.target.tool_name,
106    );
107    let (decision_tag, reason_str, policy_str) = match &audit.decision {
108        Decision::Allow { .. } => (
109            DecisionTag::Allow,
110            String::new(),
111            allow_policy_label(&audit.chain).to_owned(),
112        ),
113        Decision::Deny { reason } => {
114            let policy_str = audit
115                .chain
116                .iter()
117                .find(|e| e.result == ChainEntryResult::Fail)
118                .map_or_else(|| "unknown".to_owned(), |e| e.policy_id.as_str().to_owned());
119            (DecisionTag::Deny, reason.to_string(), policy_str)
120        },
121    };
122    let evaluated_rules = serde_json::to_value(audit).unwrap_or_else(|e| {
123        tracing::error!(
124            error = %e,
125            tool_name = %audit.target.tool_name,
126            "could not serialise the governance evaluation trace; recording the decision \
127             without it"
128        );
129        serde_json::Value::Null
130    });
131
132    let context_id = audit
133        .context_id
134        .as_deref()
135        .and_then(|s| ContextId::try_new(s).ok())
136        .unwrap_or_else(|| ContextId::derived_from_session(&audit.principal.session_id));
137    let record = GovernanceDecisionRecord {
138        id: &audit.id,
139        actor: &actor,
140        session_id: audit.principal.session_id.as_str(),
141        tool_name: &audit.target.tool_name,
142        agent_id: audit.principal.agent_id.as_ref().map(AgentId::as_str),
143        agent_scope: Some(audit.principal.agent_scope),
144        decision: decision_tag,
145        policy: &policy_str,
146        reason: &reason_str,
147        evaluated_rules: &evaluated_rules,
148        plugin_id: audit.target.plugin_id.as_ref().map(PluginId::as_str),
149        act_chain: &audit.act_chain,
150        context_id: context_id.as_str(),
151        task_id: None,
152    };
153
154    insert_governance_decision(pool, &record).await
155}