Skip to main content

systemprompt_security/authz/repository/
rules.rs

1//! Access-control rule persistence.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use std::collections::HashMap;
7use std::str::FromStr;
8
9use chrono::{DateTime, Utc};
10use systemprompt_identifiers::RuleId;
11
12use super::{AccessControlRepository, ExportRuleRow, UpsertRuleParams};
13use crate::authz::error::AuthzResult;
14use crate::authz::types::{Access, AccessRule, EntityKind, RuleType};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct ChainFingerprint {
18    pub rule_count: i64,
19    pub rules_updated_at: Option<DateTime<Utc>>,
20    pub entity_count: i64,
21    pub entities_updated_at: Option<DateTime<Utc>>,
22}
23
24impl AccessControlRepository {
25    pub async fn chain_fingerprint(&self) -> AuthzResult<ChainFingerprint> {
26        let row = sqlx::query!(
27            r#"
28            SELECT
29                (SELECT COUNT(*) FROM access_control_rules) AS "rule_count!",
30                (SELECT MAX(updated_at) FROM access_control_rules) AS "rules_updated_at?",
31                (SELECT COUNT(*) FROM access_control_entities) AS "entity_count!",
32                (SELECT MAX(updated_at) FROM access_control_entities) AS "entities_updated_at?"
33            "#,
34        )
35        .fetch_one(&*self.pool)
36        .await?;
37        Ok(ChainFingerprint {
38            rule_count: row.rule_count,
39            rules_updated_at: row.rules_updated_at,
40            entity_count: row.entity_count,
41            entities_updated_at: row.entities_updated_at,
42        })
43    }
44
45    pub async fn list_role_rules_for_export(&self) -> AuthzResult<Vec<ExportRuleRow>> {
46        let rows = sqlx::query_as!(
47            ExportRuleRow,
48            r#"
49            SELECT entity_type, entity_id, rule_type, rule_value, access, justification
50            FROM access_control_rules
51            WHERE rule_type = 'role'
52            ORDER BY entity_type, entity_id, access, rule_type, rule_value
53            "#,
54        )
55        .fetch_all(&*self.pool)
56        .await?;
57        Ok(rows)
58    }
59
60    pub async fn list_rules_for_entity(
61        &self,
62        entity_type: EntityKind,
63        entity_id: &str,
64    ) -> AuthzResult<Vec<AccessRule>> {
65        let rows = sqlx::query!(
66            r#"
67            SELECT id, rule_type, rule_value, access, justification
68            FROM access_control_rules
69            WHERE entity_type = $1 AND entity_id = $2
70            ORDER BY rule_type, rule_value
71            "#,
72            entity_type.as_str(),
73            entity_id,
74        )
75        .fetch_all(&*self.pool)
76        .await?;
77
78        let mut out = Vec::with_capacity(rows.len());
79        for row in rows {
80            out.push(AccessRule {
81                id: RuleId::new(row.id),
82                rule_type: RuleType::from(row.rule_type.as_str()),
83                rule_value: row.rule_value,
84                access: Access::from_str(&row.access)?,
85                justification: row.justification,
86            });
87        }
88        Ok(out)
89    }
90
91    pub async fn list_rules_bulk(
92        &self,
93        entity_type: EntityKind,
94        entity_ids: &[String],
95    ) -> AuthzResult<HashMap<String, Vec<AccessRule>>> {
96        let mut out: HashMap<String, Vec<AccessRule>> = HashMap::with_capacity(entity_ids.len());
97        for id in entity_ids {
98            out.entry(id.clone()).or_default();
99        }
100        if entity_ids.is_empty() {
101            return Ok(out);
102        }
103
104        let rows = sqlx::query!(
105            r#"
106            SELECT entity_id, id, rule_type, rule_value, access, justification
107            FROM access_control_rules
108            WHERE entity_type = $1 AND entity_id = ANY($2)
109            ORDER BY entity_id, rule_type, rule_value
110            "#,
111            entity_type.as_str(),
112            entity_ids,
113        )
114        .fetch_all(&*self.pool)
115        .await?;
116
117        for row in rows {
118            let rule = AccessRule {
119                id: RuleId::new(row.id),
120                rule_type: RuleType::from(row.rule_type.as_str()),
121                rule_value: row.rule_value,
122                access: Access::from_str(&row.access)?,
123                justification: row.justification,
124            };
125            out.entry(row.entity_id).or_default().push(rule);
126        }
127        Ok(out)
128    }
129
130    pub async fn upsert_rule(&self, params: UpsertRuleParams<'_>) -> AuthzResult<AccessRule> {
131        let id = RuleId::generate();
132        let rule_type_str = params.rule_type.to_string();
133        let access_str = params.access.to_string();
134        let row = sqlx::query!(
135            r#"
136            INSERT INTO access_control_rules
137                (id, entity_type, entity_id, rule_type, rule_value, access, justification)
138            VALUES ($1, $2, $3, $4, $5, $6, $7)
139            ON CONFLICT (entity_type, entity_id, rule_type, rule_value)
140            DO UPDATE SET
141                access = EXCLUDED.access,
142                justification = COALESCE(EXCLUDED.justification, access_control_rules.justification),
143                updated_at = NOW()
144            RETURNING id, rule_type, rule_value, access, justification
145            "#,
146            id.as_str(),
147            params.entity_type.as_str(),
148            params.entity_id,
149            rule_type_str,
150            params.rule_value,
151            access_str,
152            params.justification,
153        )
154        .fetch_one(&*self.write_pool)
155        .await?;
156
157        Ok(AccessRule {
158            id: RuleId::new(row.id),
159            rule_type: RuleType::from(row.rule_type.as_str()),
160            rule_value: row.rule_value,
161            access: Access::from_str(&row.access)?,
162            justification: row.justification,
163        })
164    }
165
166    pub async fn set_justification(
167        &self,
168        rule_id: &RuleId,
169        justification: Option<&str>,
170    ) -> AuthzResult<bool> {
171        let result = sqlx::query!(
172            r#"UPDATE access_control_rules SET justification = $2, updated_at = NOW() WHERE id = $1"#,
173            rule_id.as_str(),
174            justification,
175        )
176        .execute(&*self.write_pool)
177        .await?;
178        Ok(result.rows_affected() > 0)
179    }
180
181    pub async fn delete_rule(&self, rule_id: &RuleId) -> AuthzResult<bool> {
182        let result = sqlx::query!(
183            r#"DELETE FROM access_control_rules WHERE id = $1"#,
184            rule_id.as_str(),
185        )
186        .execute(&*self.write_pool)
187        .await?;
188        Ok(result.rows_affected() > 0)
189    }
190}