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 systemprompt_identifiers::RuleId;
10
11use super::{AccessControlRepository, ExportRuleRow, UpsertRuleParams};
12use crate::authz::error::AuthzResult;
13use crate::authz::types::{Access, AccessRule, EntityKind, RuleType};
14
15impl AccessControlRepository {
16    pub async fn list_role_rules_for_export(&self) -> AuthzResult<Vec<ExportRuleRow>> {
17        let rows = sqlx::query_as!(
18            ExportRuleRow,
19            r#"
20            SELECT entity_type, entity_id, rule_type, rule_value, access, justification
21            FROM access_control_rules
22            WHERE rule_type = 'role'
23            ORDER BY entity_type, entity_id, access, rule_type, rule_value
24            "#,
25        )
26        .fetch_all(&*self.pool)
27        .await?;
28        Ok(rows)
29    }
30
31    pub async fn list_rules_for_entity(
32        &self,
33        entity_type: EntityKind,
34        entity_id: &str,
35    ) -> AuthzResult<Vec<AccessRule>> {
36        let rows = sqlx::query!(
37            r#"
38            SELECT id, rule_type, rule_value, access, justification
39            FROM access_control_rules
40            WHERE entity_type = $1 AND entity_id = $2
41            ORDER BY rule_type, rule_value
42            "#,
43            entity_type.as_str(),
44            entity_id,
45        )
46        .fetch_all(&*self.pool)
47        .await?;
48
49        let mut out = Vec::with_capacity(rows.len());
50        for row in rows {
51            out.push(AccessRule {
52                id: RuleId::new(row.id),
53                rule_type: RuleType::from_str(&row.rule_type)?,
54                rule_value: row.rule_value,
55                access: Access::from_str(&row.access)?,
56                justification: row.justification,
57            });
58        }
59        Ok(out)
60    }
61
62    pub async fn list_rules_bulk(
63        &self,
64        entity_type: EntityKind,
65        entity_ids: &[String],
66    ) -> AuthzResult<HashMap<String, Vec<AccessRule>>> {
67        let mut out: HashMap<String, Vec<AccessRule>> = HashMap::with_capacity(entity_ids.len());
68        for id in entity_ids {
69            out.entry(id.clone()).or_default();
70        }
71        if entity_ids.is_empty() {
72            return Ok(out);
73        }
74
75        let rows = sqlx::query!(
76            r#"
77            SELECT entity_id, id, rule_type, rule_value, access, justification
78            FROM access_control_rules
79            WHERE entity_type = $1 AND entity_id = ANY($2)
80            ORDER BY entity_id, rule_type, rule_value
81            "#,
82            entity_type.as_str(),
83            entity_ids,
84        )
85        .fetch_all(&*self.pool)
86        .await?;
87
88        for row in rows {
89            let rule = AccessRule {
90                id: RuleId::new(row.id),
91                rule_type: RuleType::from_str(&row.rule_type)?,
92                rule_value: row.rule_value,
93                access: Access::from_str(&row.access)?,
94                justification: row.justification,
95            };
96            out.entry(row.entity_id).or_default().push(rule);
97        }
98        Ok(out)
99    }
100
101    /// Fails with a foreign-key violation if no entity catalog row exists for
102    /// `(entity_type, entity_id)` — register the entity via
103    /// [`Self::upsert_entity`] first.
104    pub async fn upsert_rule(&self, params: UpsertRuleParams<'_>) -> AuthzResult<AccessRule> {
105        let id = RuleId::generate();
106        let rule_type_str = params.rule_type.to_string();
107        let access_str = params.access.to_string();
108        let row = sqlx::query!(
109            r#"
110            INSERT INTO access_control_rules
111                (id, entity_type, entity_id, rule_type, rule_value, access, justification)
112            VALUES ($1, $2, $3, $4, $5, $6, $7)
113            ON CONFLICT (entity_type, entity_id, rule_type, rule_value)
114            DO UPDATE SET
115                access = EXCLUDED.access,
116                justification = COALESCE(EXCLUDED.justification, access_control_rules.justification),
117                updated_at = NOW()
118            RETURNING id, rule_type, rule_value, access, justification
119            "#,
120            id.as_str(),
121            params.entity_type.as_str(),
122            params.entity_id,
123            rule_type_str,
124            params.rule_value,
125            access_str,
126            params.justification,
127        )
128        .fetch_one(&*self.write_pool)
129        .await?;
130
131        Ok(AccessRule {
132            id: RuleId::new(row.id),
133            rule_type: RuleType::from_str(&row.rule_type)?,
134            rule_value: row.rule_value,
135            access: Access::from_str(&row.access)?,
136            justification: row.justification,
137        })
138    }
139
140    /// `None` clears the operator note.
141    pub async fn set_justification(
142        &self,
143        rule_id: &RuleId,
144        justification: Option<&str>,
145    ) -> AuthzResult<bool> {
146        let result = sqlx::query!(
147            r#"UPDATE access_control_rules SET justification = $2, updated_at = NOW() WHERE id = $1"#,
148            rule_id.as_str(),
149            justification,
150        )
151        .execute(&*self.write_pool)
152        .await?;
153        Ok(result.rows_affected() > 0)
154    }
155
156    pub async fn delete_rule(&self, rule_id: &RuleId) -> AuthzResult<bool> {
157        let result = sqlx::query!(
158            r#"DELETE FROM access_control_rules WHERE id = $1"#,
159            rule_id.as_str(),
160        )
161        .execute(&*self.write_pool)
162        .await?;
163        Ok(result.rows_affected() > 0)
164    }
165}