Skip to main content

systemprompt_security/authz/audit/
repository.rs

1//! `governance_decisions` insert primitive.
2//!
3//! Single canonical writer for the table. Both the extension's
4//! `POST /govern/authz` handler (for resolved decisions) and core's
5//! [`DbAuditSink`](super::DbAuditSink) (for webhook-fault, default-deny, and
6//! unrestricted-allow decisions) call this repository so there is exactly one
7//! SQL statement that knows the column layout.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use sqlx::PgPool;
13use systemprompt_identifiers::Actor;
14
15use crate::authz::types::DecisionTag;
16use crate::policy::types::AccessScope;
17
18pub const AUDIT_WRITE_FAILED_TOTAL: &str = "governance_audit_write_failed_total";
19
20#[derive(Debug)]
21pub struct GovernanceDecisionRecord<'a> {
22    pub id: &'a str,
23    pub actor: &'a Actor,
24    pub session_id: &'a str,
25    pub tool_name: &'a str,
26    pub agent_id: Option<&'a str>,
27    pub agent_scope: Option<AccessScope>,
28    pub decision: DecisionTag,
29    pub policy: &'a str,
30    pub reason: &'a str,
31    // JSON: governance audit blob — typed `DecisionAudit` on the writing side;
32    // payload shape is documented in CHANGELOG and rendered by the dashboard.
33    pub evaluated_rules: &'a serde_json::Value,
34    pub plugin_id: Option<&'a str>,
35    pub act_chain: &'a [Actor],
36    pub context_id: &'a str,
37    pub task_id: Option<&'a str>,
38    // Why: the request-plane correlator gets its own field so the trace join
39    // never depends on `session_id` carrying it.
40    pub trace_id: Option<&'a str>,
41}
42
43#[derive(Debug, Clone)]
44pub struct GovernanceDecisionRepository {
45    pool: std::sync::Arc<PgPool>,
46}
47
48impl GovernanceDecisionRepository {
49    pub const fn from_pool(pool: std::sync::Arc<PgPool>) -> Self {
50        Self { pool }
51    }
52
53    pub fn pool(&self) -> &PgPool {
54        &self.pool
55    }
56
57    pub async fn insert(&self, record: &GovernanceDecisionRecord<'_>) -> Result<(), sqlx::Error> {
58        insert_governance_decision(&self.pool, record).await
59    }
60}
61
62pub async fn insert_governance_decision(
63    pool: &PgPool,
64    record: &GovernanceDecisionRecord<'_>,
65) -> Result<(), sqlx::Error> {
66    let actor_kind = record.actor.kind.tag();
67    let actor_id = record.actor.kind.actor_id(&record.actor.user_id);
68    let act_chain =
69        serde_json::to_value(record.act_chain).unwrap_or_else(|_| serde_json::json!([]));
70    let result = sqlx::query!(
71        "INSERT INTO governance_decisions (id, user_id, session_id, tool_name, agent_id, \
72         agent_scope, decision, policy, reason, evaluated_rules, plugin_id, actor_kind, actor_id, \
73         act_chain, context_id, task_id, trace_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, \
74         $10, $11, $12, $13, $14, $15, $16, $17)",
75        record.id,
76        record.actor.user_id.as_str(),
77        record.session_id,
78        record.tool_name,
79        record.agent_id,
80        record.agent_scope.map(AccessScope::as_str),
81        record.decision.as_str(),
82        record.policy,
83        record.reason,
84        record.evaluated_rules,
85        record.plugin_id,
86        actor_kind.as_str(),
87        actor_id,
88        act_chain,
89        record.context_id,
90        record.task_id,
91        record.trace_id,
92    )
93    .execute(pool)
94    .await;
95    if let Err(error) = &result {
96        tracing::error!(
97            error = %error,
98            actor_kind = actor_kind.as_str(),
99            actor_id,
100            policy = record.policy,
101            decision = record.decision.as_str(),
102            session_id = record.session_id,
103            "governance_decisions insert failed; audit row dropped"
104        );
105        metrics::counter!(
106            AUDIT_WRITE_FAILED_TOTAL,
107            "actor_kind" => actor_kind.as_str(),
108            "decision" => record.decision.as_str(),
109            "policy" => record.policy.to_owned(),
110        )
111        .increment(1);
112    }
113    result.map(|_| ())
114}