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 crate::authz::error::AuthzError;
21use crate::authz::types::Decision;
22
23/// Where in a governed payload a secret-scanner finding was located.
24///
25/// [`GovernedInput::location_kind`] supplies `kind` for the governance chain,
26/// and `redacted` must already have the credential removed — it is rendered
27/// into [`crate::authz::types::DenyReason`] and reaches the audit log.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct SecretLocation {
30    pub kind: String,
31    pub path: String,
32    pub redacted: String,
33}
34
35impl SecretLocation {
36    pub fn new(
37        kind: impl Into<String>,
38        path: impl Into<String>,
39        redacted: impl Into<String>,
40    ) -> Self {
41        Self {
42            kind: kind.into(),
43            path: path.into(),
44            redacted: redacted.into(),
45        }
46    }
47}
48
49impl fmt::Display for SecretLocation {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        if self.path.is_empty() {
52            write!(f, "{} ({})", self.kind, self.redacted)
53        } else {
54            write!(f, "{}.{} ({})", self.kind, self.path, self.redacted)
55        }
56    }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct RateLimitWindow {
61    pub name: String,
62    pub seconds: u64,
63    pub limit: u64,
64}
65
66/// Scope of an agent invocation for governance evaluation. Agents may run
67/// either inside an authenticated user session or under a system/service
68/// identity (cron, replay, internal scheduler).
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(tag = "kind", rename_all = "snake_case")]
71pub enum AgentScope {
72    User { user_id: UserId },
73    System,
74}
75
76impl AgentScope {
77    #[must_use]
78    pub const fn user_id(&self) -> Option<&UserId> {
79        match self {
80            Self::User { user_id } => Some(user_id),
81            Self::System => None,
82        }
83    }
84}
85
86/// Permission tier carried alongside [`AgentScope`] in [`PolicyContext`].
87///
88/// `AgentScope` answers "who is acting" (user vs system process identity);
89/// `AccessScope` answers "what permission tier is granted to this invocation"
90/// (admin, plain user, unknown). The two are orthogonal — a system actor may
91/// have any tier, a user actor may be admin or plain — so they live as
92/// separate fields rather than a cartesian enum. `Unknown` is the fallback when
93/// an agent card declares no `oauth.scopes` entry.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
95#[sqlx(type_name = "TEXT", rename_all = "lowercase")]
96#[serde(rename_all = "lowercase")]
97pub enum AccessScope {
98    Admin,
99    User,
100    Unknown,
101}
102
103impl AccessScope {
104    // Why: role strings are the same vocabulary the authz webhook resolves
105    // from the database, so a token-derived scope and a DB-derived one agree.
106    // An unrecognised set is `Unknown` — deny-shaped rather than privileged.
107    #[must_use]
108    pub fn from_roles<S: AsRef<str>>(roles: &[S]) -> Self {
109        if roles.iter().any(|r| r.as_ref() == "admin") {
110            Self::Admin
111        } else if roles.iter().any(|r| r.as_ref() == "user") {
112            Self::User
113        } else {
114            Self::Unknown
115        }
116    }
117
118    #[must_use]
119    pub const fn as_str(self) -> &'static str {
120        match self {
121            Self::Admin => "admin",
122            Self::User => "user",
123            Self::Unknown => "unknown",
124        }
125    }
126}
127
128impl fmt::Display for AccessScope {
129    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        f.write_str(self.as_str())
131    }
132}
133
134impl FromStr for AccessScope {
135    type Err = AuthzError;
136
137    fn from_str(s: &str) -> Result<Self, Self::Err> {
138        match s {
139            "admin" => Ok(Self::Admin),
140            "user" => Ok(Self::User),
141            "unknown" | "" => Ok(Self::Unknown),
142            other => Err(AuthzError::Validation(format!(
143                "unknown access scope: {other}"
144            ))),
145        }
146    }
147}
148
149#[derive(Debug)]
150pub struct PolicyContext<'a> {
151    pub target: GovernedTarget,
152    pub agent_scope: AgentScope,
153    pub access_scope: AccessScope,
154    pub session_id: &'a SessionId,
155    pub user_id: &'a UserId,
156    pub input: &'a GovernedInput,
157    pub call_id: &'a CallId,
158}
159
160/// A unit of governance evaluation for one governed call — an MCP tool call or
161/// a submitted prompt, per [`PolicyContext::target`].
162///
163/// Implementations are pure-sync; auditing happens outside the chain.
164/// Traced first-deny-wins composition is provided by
165/// [`super::GovernanceEngine`].
166///
167/// `evaluate` must be **idempotent per [`PolicyContext::call_id`]**: evaluating
168/// one call twice yields the same [`Decision`] and leaves the same state behind
169/// as evaluating it once. A policy that counts calls therefore counts calls,
170/// not evaluations — the two diverge wherever enforcement points nest.
171pub trait GovernancePolicy: Send + Sync + fmt::Debug {
172    fn id(&self) -> PolicyId;
173    fn name(&self) -> &'static str;
174    fn description(&self) -> &'static str;
175    fn evaluate(&self, ctx: &PolicyContext<'_>) -> Decision;
176}