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::{
17    Actor, AgentId, ClientId, ContextId, PluginId, PolicyId, SessionId, UserId,
18};
19
20use super::types::AccessScope;
21use crate::authz::types::{Decision, DecisionTag};
22use crate::authz::{GovernanceDecisionRecord, insert_governance_decision};
23
24#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
25#[serde(tag = "result", rename_all = "lowercase")]
26pub enum ChainEntryResult {
27    Pass,
28    Fail,
29    // Why: the policy found what it would normally refuse but runs in
30    // `mode: warn`, so the chain continued. Kept distinct from `Fail` so a
31    // warn-mode installation cannot be misread as an enforcing one.
32    Warn,
33    Disabled,
34    Skip,
35    Hold,
36}
37
38/// One traced chain entry: which policy, what it decided, and what it cost.
39#[derive(Debug, Serialize, Clone)]
40pub struct ChainEntryOutcome {
41    pub policy_id: PolicyId,
42    #[serde(flatten)]
43    pub result: ChainEntryResult,
44    pub detail: String,
45    pub duration_ms: f64,
46}
47
48/// Who the decision was made for, as verified from the credential.
49///
50/// `agent_id` is a verified delegate identity and lands in the `agent_id`
51/// column; `claimed` is whatever the caller *said* about itself (a hook
52/// payload's subagent id, for instance) and is kept in the audit blob only —
53/// it is never an input to a decision and never written to an identity
54/// column.
55#[derive(Debug, Serialize, Clone)]
56pub struct PrincipalSnapshot {
57    pub user_id: UserId,
58    pub session_id: SessionId,
59    pub agent_session: Option<SessionId>,
60    pub agent_id: Option<AgentId>,
61    pub agent_scope: AccessScope,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub client_id: Option<ClientId>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub claimed: Option<ClaimedAgent>,
66}
67
68#[derive(Debug, Serialize, Clone)]
69pub struct ClaimedAgent {
70    pub agent_id: String,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub agent_type: Option<String>,
73}
74
75#[derive(Debug, Serialize, Clone)]
76pub struct AuditTarget {
77    pub tool_name: String,
78    pub plugin_id: Option<PluginId>,
79}
80
81#[derive(Debug, Serialize, Clone)]
82pub struct ApproverStamp {
83    pub user_id: UserId,
84    pub username: String,
85    pub decided_at: chrono::DateTime<chrono::Utc>,
86    pub action: &'static str,
87}
88
89/// Whether an audit row is the first judgement of a call or a later
90/// enforcement point re-verifying it.
91#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
92#[serde(rename_all = "snake_case")]
93pub enum AuditOrigin {
94    Governed,
95    Reverified,
96}
97
98#[derive(Debug, Serialize, Clone)]
99pub struct DecisionAudit {
100    pub id: String,
101    pub call_id: String,
102    pub origin: AuditOrigin,
103    pub decision: Decision,
104    pub principal: PrincipalSnapshot,
105    pub target: AuditTarget,
106    pub chain: Vec<ChainEntryOutcome>,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub approver: Option<ApproverStamp>,
109    #[serde(skip_serializing_if = "Vec::is_empty")]
110    pub act_chain: Vec<Actor>,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub context_id: Option<String>,
113    // Why: persisted to the `trace_id` column so the trace explorer joins on
114    // a real key.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub trace_id: Option<String>,
117}
118
119// Why: an allow because nothing ran and an allow because everything passed are
120// the same `Decision`, and the flat `policy` column is what operational queries
121// filter on. Collapsing both to `default_allow` would make an unguarded
122// installation indistinguishable from a healthy one.
123fn allow_policy_label(chain: &[ChainEntryOutcome]) -> &'static str {
124    if !chain.is_empty() && chain.iter().all(|e| e.result == ChainEntryResult::Disabled) {
125        return "governance_disabled";
126    }
127    "default_allow"
128}
129
130// Why: by the same argument, an allow because a *human authorised it* is a
131// third thing again, and the one an audit reader most needs to tell apart. It
132// carries an approver, so the policy that held it is named rather than
133// collapsed into `default_allow` — otherwise an approved call is reported as
134// though nothing enforced it.
135fn approved_policy_label(audit: &DecisionAudit) -> Option<String> {
136    audit.approver.as_ref()?;
137    audit
138        .chain
139        .iter()
140        .find(|e| e.result == ChainEntryResult::Pass)
141        .map(|e| e.policy_id.as_str().to_owned())
142}
143
144pub async fn record_decision(pool: &PgPool, audit: &DecisionAudit) -> Result<(), sqlx::Error> {
145    let actor = Actor::from_tool_name(
146        audit.principal.user_id.clone(),
147        audit.principal.agent_id.as_ref().map(AgentId::as_str),
148        &audit.target.tool_name,
149    );
150    let (decision_tag, reason_str, policy_str) = match &audit.decision {
151        Decision::Allow { .. } => (
152            DecisionTag::Allow,
153            String::new(),
154            approved_policy_label(audit)
155                .unwrap_or_else(|| allow_policy_label(&audit.chain).to_owned()),
156        ),
157        Decision::Deny { reason } => {
158            let policy_str = audit
159                .chain
160                .iter()
161                .find(|e| e.result == ChainEntryResult::Fail)
162                .map_or_else(|| "unknown".to_owned(), |e| e.policy_id.as_str().to_owned());
163            (DecisionTag::Deny, reason.to_string(), policy_str)
164        },
165        // Why: the `policy` column names the first policy that warned, not
166        // `default_allow`. A warn row whose policy read `default_allow` would
167        // be useless to the report the mode exists to feed.
168        Decision::Warn { reason } => {
169            let policy_str = audit
170                .chain
171                .iter()
172                .find(|e| e.result == ChainEntryResult::Warn)
173                .map_or_else(|| "unknown".to_owned(), |e| e.policy_id.as_str().to_owned());
174            (DecisionTag::Warn, reason.to_string(), policy_str)
175        },
176        Decision::Pending { reason } => {
177            let policy_str = audit
178                .chain
179                .iter()
180                .find(|e| e.result == ChainEntryResult::Hold)
181                .map_or_else(|| "unknown".to_owned(), |e| e.policy_id.as_str().to_owned());
182            (DecisionTag::Pending, reason.to_string(), policy_str)
183        },
184    };
185    let evaluated_rules = serde_json::to_value(audit).unwrap_or_else(|e| {
186        tracing::error!(
187            error = %e,
188            tool_name = %audit.target.tool_name,
189            "could not serialise the governance evaluation trace; recording the decision \
190             without it"
191        );
192        serde_json::Value::Null
193    });
194
195    let context_id = audit
196        .context_id
197        .as_deref()
198        .and_then(|s| ContextId::try_new(s).ok())
199        .unwrap_or_else(|| ContextId::derived_from_session(&audit.principal.session_id));
200    let record = GovernanceDecisionRecord {
201        id: &audit.id,
202        actor: &actor,
203        session_id: audit.principal.session_id.as_str(),
204        tool_name: &audit.target.tool_name,
205        agent_id: audit.principal.agent_id.as_ref().map(AgentId::as_str),
206        agent_scope: Some(audit.principal.agent_scope),
207        decision: decision_tag,
208        policy: &policy_str,
209        reason: &reason_str,
210        evaluated_rules: &evaluated_rules,
211        plugin_id: audit.target.plugin_id.as_ref().map(PluginId::as_str),
212        act_chain: &audit.act_chain,
213        context_id: context_id.as_str(),
214        task_id: None,
215        trace_id: audit.trace_id.as_deref(),
216        client_id: audit.principal.client_id.as_ref().map(ClientId::as_str),
217    };
218
219    insert_governance_decision(pool, &record).await
220}