Skip to main content

systemprompt_security/policy/
types.rs

1//! Shared types for the unified governance plane.
2//!
3//! These types support the governance chain ([`super::GovernancePolicy`]) and
4//! feed into the typed deny variants in [`crate::authz::types::DenyReason`].
5//! They live here (and not in `authz/types.rs`) because they describe the
6//! *governed-call* enforcement plane — secret scans, scope checks, blocklists,
7//! rate limits — which is orthogonal to the user→entity allow/deny resolver.
8//! What a governed call targets and carries lives in [`super::governed`].
9//!
10//! Copyright (c) systemprompt.io — Business Source License 1.1.
11//! See <https://systemprompt.io> for licensing details.
12
13use std::fmt;
14use std::str::FromStr;
15
16use serde::{Deserialize, Serialize};
17use systemprompt_identifiers::{CallId, PolicyId, SessionId, UserId};
18
19use super::governed::{GovernedInput, GovernedTarget};
20use super::secrets::{SecretFinding, SecretScanner};
21use crate::authz::error::AuthzError;
22use crate::authz::types::Decision;
23
24/// Where in a governed payload a secret-scanner finding was located.
25///
26/// [`GovernedInput::location_kind`] supplies `kind` for the governance chain,
27/// and `redacted` must already have the credential removed — it is rendered
28/// into [`crate::authz::types::DenyReason`] and reaches the audit log.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct SecretLocation {
31    pub kind: String,
32    pub path: String,
33    pub redacted: String,
34}
35
36impl SecretLocation {
37    pub fn new(
38        kind: impl Into<String>,
39        path: impl Into<String>,
40        redacted: impl Into<String>,
41    ) -> Self {
42        Self {
43            kind: kind.into(),
44            path: path.into(),
45            redacted: redacted.into(),
46        }
47    }
48}
49
50impl fmt::Display for SecretLocation {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        if self.path.is_empty() {
53            write!(f, "{} ({})", self.kind, self.redacted)
54        } else {
55            write!(f, "{}.{} ({})", self.kind, self.path, self.redacted)
56        }
57    }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct RateLimitWindow {
62    pub name: String,
63    pub seconds: u64,
64    pub limit: u64,
65}
66
67/// Scope of an agent invocation for governance evaluation. Agents may run
68/// either inside an authenticated user session or under a system/service
69/// identity (cron, replay, internal scheduler).
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(tag = "kind", rename_all = "snake_case")]
72pub enum AgentScope {
73    User { user_id: UserId },
74    System,
75}
76
77impl AgentScope {
78    #[must_use]
79    pub const fn user_id(&self) -> Option<&UserId> {
80        match self {
81            Self::User { user_id } => Some(user_id),
82            Self::System => None,
83        }
84    }
85}
86
87/// Permission tier carried alongside [`AgentScope`] in [`PolicyContext`].
88///
89/// `AgentScope` answers "who is acting" (user vs system process identity);
90/// `AccessScope` answers "what permission tier is granted to this invocation"
91/// (admin, plain user, unknown). The two are orthogonal — a system actor may
92/// have any tier, a user actor may be admin or plain — so they live as
93/// separate fields rather than a cartesian enum. `Unknown` is the fallback when
94/// an agent card declares no `oauth.scopes` entry.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
96#[sqlx(type_name = "TEXT", rename_all = "lowercase")]
97#[serde(rename_all = "lowercase")]
98pub enum AccessScope {
99    Admin,
100    User,
101    Unknown,
102}
103
104impl AccessScope {
105    #[must_use]
106    pub fn from_roles<S: AsRef<str>>(roles: &[S]) -> Self {
107        if roles.iter().any(|r| r.as_ref() == "admin") {
108            Self::Admin
109        } else if roles.iter().any(|r| r.as_ref() == "user") {
110            Self::User
111        } else {
112            Self::Unknown
113        }
114    }
115
116    #[must_use]
117    pub const fn as_str(self) -> &'static str {
118        match self {
119            Self::Admin => "admin",
120            Self::User => "user",
121            Self::Unknown => "unknown",
122        }
123    }
124}
125
126impl fmt::Display for AccessScope {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        f.write_str(self.as_str())
129    }
130}
131
132impl FromStr for AccessScope {
133    type Err = AuthzError;
134
135    fn from_str(s: &str) -> Result<Self, Self::Err> {
136        match s {
137            "admin" => Ok(Self::Admin),
138            "user" => Ok(Self::User),
139            "unknown" | "" => Ok(Self::Unknown),
140            other => Err(AuthzError::Validation(format!(
141                "unknown access scope: {other}"
142            ))),
143        }
144    }
145}
146
147#[derive(Debug)]
148pub struct PolicyContext<'a> {
149    pub target: GovernedTarget,
150    pub agent_scope: AgentScope,
151    pub access_scope: AccessScope,
152    pub session_id: &'a SessionId,
153    pub user_id: &'a UserId,
154    pub input: &'a GovernedInput,
155    pub call_id: &'a CallId,
156}
157
158impl PolicyContext<'_> {
159    #[must_use]
160    pub fn with_input<'b>(&'b self, input: &'b GovernedInput) -> PolicyContext<'b> {
161        PolicyContext {
162            target: self.target.clone(),
163            agent_scope: self.agent_scope.clone(),
164            access_scope: self.access_scope,
165            session_id: self.session_id,
166            user_id: self.user_id,
167            input,
168            call_id: self.call_id,
169        }
170    }
171}
172
173/// A unit of governance evaluation for one governed call — an MCP tool call or
174/// a submitted prompt, per [`PolicyContext::target`].
175///
176/// Implementations are pure-sync; auditing happens outside the chain.
177/// Traced first-deny-wins composition is provided by
178/// [`super::GovernanceEngine`].
179///
180/// `evaluate` must be **idempotent per [`PolicyContext::call_id`]**: evaluating
181/// one call twice yields the same [`Decision`] and leaves the same state behind
182/// as evaluating it once. A policy that counts calls therefore counts calls,
183/// not evaluations — the two diverge wherever enforcement points nest.
184///
185/// `prompt_secret_findings` returns `None` unless the policy can locate the
186/// credentials behind its own
187/// [`DenyReason::SecretLeak`][crate::authz::types::DenyReason::SecretLeak];
188/// only a policy that returns `Some` takes part in prompt recovery.
189pub trait GovernancePolicy: Send + Sync + fmt::Debug {
190    fn secret_scanner(&self) -> Option<&SecretScanner> {
191        None
192    }
193    fn id(&self) -> PolicyId;
194    fn name(&self) -> &'static str;
195    fn description(&self) -> &'static str;
196    fn evaluate(&self, ctx: &PolicyContext<'_>) -> Decision;
197    fn prompt_secret_findings(&self, _input: &GovernedInput) -> Option<Vec<SecretFinding>> {
198        None
199    }
200}