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, 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    Skip,
28}
29
30/// One traced chain entry: which policy, what it decided, and what it cost.
31#[derive(Debug, Serialize, Clone)]
32pub struct ChainEntryOutcome {
33    pub policy_id: PolicyId,
34    #[serde(flatten)]
35    pub result: ChainEntryResult,
36    pub detail: String,
37    /// Wall-clock cost of evaluating this policy. Zero for entries that never
38    /// ran (disabled, skipped-after-deny, or synthesized outcomes).
39    pub duration_ms: f64,
40}
41
42#[derive(Debug, Serialize, Clone)]
43pub struct PrincipalSnapshot {
44    pub user_id: UserId,
45    /// The credential's session, attested against `user_sessions` — the same
46    /// class of evidence as `ai_requests.session_id`, so the inference and
47    /// tool-call halves of the audit spine join on comparable ids. Prefixed
48    /// `unattested_` when the lookup failed.
49    pub session_id: SessionId,
50    /// The `session_id` the hook payload carried: the agent's own local
51    /// conversation label. Useful for correlating one agent run, but the
52    /// server never issued it, so it is recorded here rather than in the
53    /// attested column.
54    pub agent_session: Option<SessionId>,
55    pub agent_id: Option<AgentId>,
56    pub agent_scope: AccessScope,
57}
58
59#[derive(Debug, Serialize, Clone)]
60pub struct AuditTarget {
61    pub tool_name: String,
62    pub plugin_id: Option<PluginId>,
63}
64
65// Why: the human who answered an approval gate is a distinct actor from the
66// session principal, stamped with the click instant rather than the
67// audit-write instant.
68#[derive(Debug, Serialize, Clone)]
69pub struct ApproverStamp {
70    pub user_id: UserId,
71    pub username: String,
72    pub decided_at: chrono::DateTime<chrono::Utc>,
73    /// `"approved"` or `"denied"`.
74    pub action: &'static str,
75}
76
77/// Whether an audit row is the first judgement of a call or a later
78/// enforcement point re-verifying it.
79#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
80#[serde(rename_all = "snake_case")]
81pub enum AuditOrigin {
82    Governed,
83    Reverified,
84}
85
86#[derive(Debug, Serialize, Clone)]
87pub struct DecisionAudit {
88    /// The `governance_decisions.id` this blob will land under. Minted by the
89    /// caller (not the repository) so surfaces that saw the decision live can
90    /// hand out the same id as a trace link.
91    pub id: String,
92    /// Identity of the call this row judged, shared by every row that judged
93    /// the same one. `id` distinguishes evaluations; this groups them.
94    pub call_id: String,
95    pub origin: AuditOrigin,
96    pub decision: Decision,
97    pub principal: PrincipalSnapshot,
98    pub target: AuditTarget,
99    pub chain: Vec<ChainEntryOutcome>,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub approver: Option<ApproverStamp>,
102    /// RFC 8693 delegation lineage in outermost-first order. Empty for
103    /// direct (non-delegated) tokens.
104    #[serde(skip_serializing_if = "Vec::is_empty")]
105    pub act_chain: Vec<Actor>,
106}
107
108/// Persist one governed-call decision: derive the flat columns (`policy` is
109/// the first [`ChainEntryResult::Fail`] entry) and write the blob through the
110/// canonical `governance_decisions` insert.
111pub async fn record_decision(pool: &PgPool, audit: &DecisionAudit) -> Result<(), sqlx::Error> {
112    let actor = Actor::from_tool_name(
113        audit.principal.user_id.clone(),
114        audit.principal.agent_id.as_ref().map(AgentId::as_str),
115        &audit.target.tool_name,
116    );
117    let (decision_tag, reason_str, policy_str) = match &audit.decision {
118        Decision::Allow { .. } => (
119            DecisionTag::Allow,
120            String::new(),
121            "default_allow".to_owned(),
122        ),
123        Decision::Deny { reason } => {
124            let policy_str = audit
125                .chain
126                .iter()
127                .find(|e| e.result == ChainEntryResult::Fail)
128                .map_or_else(|| "unknown".to_owned(), |e| e.policy_id.as_str().to_owned());
129            (DecisionTag::Deny, reason.to_string(), policy_str)
130        },
131    };
132    let evaluated_rules = serde_json::to_value(audit).unwrap_or_else(|e| {
133        tracing::error!(
134            error = %e,
135            tool_name = %audit.target.tool_name,
136            "could not serialise the governance evaluation trace; recording the decision \
137             without it"
138        );
139        serde_json::Value::Null
140    });
141
142    let record = GovernanceDecisionRecord {
143        id: &audit.id,
144        actor: &actor,
145        session_id: audit.principal.session_id.as_str(),
146        tool_name: &audit.target.tool_name,
147        agent_id: audit.principal.agent_id.as_ref().map(AgentId::as_str),
148        agent_scope: Some(audit.principal.agent_scope),
149        decision: decision_tag,
150        policy: &policy_str,
151        reason: &reason_str,
152        evaluated_rules: &evaluated_rules,
153        plugin_id: audit.target.plugin_id.as_ref().map(PluginId::as_str),
154        act_chain: &audit.act_chain,
155        context_id: None,
156        task_id: None,
157    };
158
159    insert_governance_decision(pool, &record).await
160}