Skip to main content

qail_core/migrate/
policy.rs

1//! RLS Policy Definition (AST-native)
2//!
3//! Defines PostgreSQL Row-Level Security policies using typed AST
4//! expressions — not raw SQL strings. QAIL speaks AST.
5//!
6//! # Example
7//! ```
8//! use qail_core::migrate::policy::{RlsPolicy, PolicyTarget};
9//! use qail_core::ast::{Expr, BinaryOp, Value};
10//!
11//! // operator_id = current_setting('app.current_operator_id')::uuid
12//! let tenant_check = Expr::Binary {
13//!     left: Box::new(Expr::Named("operator_id".into())),
14//!     op: BinaryOp::Eq,
15//!     right: Box::new(Expr::Cast {
16//!         expr: Box::new(Expr::FunctionCall {
17//!             name: "current_setting".into(),
18//!             args: vec![Expr::Literal(Value::String("app.current_operator_id".into()))],
19//!             alias: None,
20//!         }),
21//!         target_type: "uuid".into(),
22//!         alias: None,
23//!     }),
24//!     alias: None,
25//! };
26//!
27//! let policy = RlsPolicy::create("orders_operator_isolation", "orders")
28//!     .for_all()
29//!     .using(tenant_check.clone())
30//!     .with_check(tenant_check);
31//! ```
32
33use crate::ast::Expr;
34use serde::{Serialize, Deserialize};
35
36/// What the policy applies to (SELECT, INSERT, UPDATE, DELETE, or ALL).
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38pub enum PolicyTarget {
39    All,
40    Select,
41    Insert,
42    Update,
43    Delete,
44}
45
46impl std::fmt::Display for PolicyTarget {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            PolicyTarget::All => write!(f, "ALL"),
50            PolicyTarget::Select => write!(f, "SELECT"),
51            PolicyTarget::Insert => write!(f, "INSERT"),
52            PolicyTarget::Update => write!(f, "UPDATE"),
53            PolicyTarget::Delete => write!(f, "DELETE"),
54        }
55    }
56}
57
58/// Whether this is permissive (default) or restrictive.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60pub enum PolicyPermissiveness {
61    /// Rows matching ANY permissive policy are visible (OR).
62    Permissive,
63    /// Rows must also match ALL restrictive policies (AND).
64    Restrictive,
65}
66
67impl std::fmt::Display for PolicyPermissiveness {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        match self {
70            PolicyPermissiveness::Permissive => write!(f, "PERMISSIVE"),
71            PolicyPermissiveness::Restrictive => write!(f, "RESTRICTIVE"),
72        }
73    }
74}
75
76/// AST-native RLS policy definition.
77///
78/// All expressions use typed `Expr` nodes — no raw SQL strings.
79/// The transpiler converts these to `CREATE POLICY ... USING (...) WITH CHECK (...)`.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct RlsPolicy {
82    /// Policy name (e.g., "orders_operator_isolation")
83    pub name: String,
84    /// Table this policy applies to
85    pub table: String,
86    /// Target command(s): ALL, SELECT, INSERT, UPDATE, DELETE
87    pub target: PolicyTarget,
88    /// Permissive (default) or Restrictive
89    pub permissiveness: PolicyPermissiveness,
90    /// USING expression — controls which existing rows are visible.
91    /// Applied to SELECT, UPDATE (read), DELETE.
92    pub using: Option<Expr>,
93    /// WITH CHECK expression — controls which new rows can be written.
94    /// Applied to INSERT, UPDATE (write).
95    pub with_check: Option<Expr>,
96    /// Role this policy applies to (default: PUBLIC)
97    pub role: Option<String>,
98}
99
100impl RlsPolicy {
101    /// Create a new policy builder.
102    ///
103    /// ```
104    /// use qail_core::migrate::policy::RlsPolicy;
105    /// let policy = RlsPolicy::create("tenant_isolation", "orders");
106    /// ```
107    pub fn create(name: impl Into<String>, table: impl Into<String>) -> Self {
108        Self {
109            name: name.into(),
110            table: table.into(),
111            target: PolicyTarget::All,
112            permissiveness: PolicyPermissiveness::Permissive,
113            using: None,
114            with_check: None,
115            role: None,
116        }
117    }
118
119    /// Set policy target to ALL (SELECT + INSERT + UPDATE + DELETE).
120    pub fn for_all(mut self) -> Self {
121        self.target = PolicyTarget::All;
122        self
123    }
124
125    /// Set policy target to SELECT only.
126    pub fn for_select(mut self) -> Self {
127        self.target = PolicyTarget::Select;
128        self
129    }
130
131    /// Set policy target to INSERT only.
132    pub fn for_insert(mut self) -> Self {
133        self.target = PolicyTarget::Insert;
134        self
135    }
136
137    /// Set policy target to UPDATE only.
138    pub fn for_update(mut self) -> Self {
139        self.target = PolicyTarget::Update;
140        self
141    }
142
143    /// Set policy target to DELETE only.
144    pub fn for_delete(mut self) -> Self {
145        self.target = PolicyTarget::Delete;
146        self
147    }
148
149    /// Make this policy restrictive (AND with other policies).
150    pub fn restrictive(mut self) -> Self {
151        self.permissiveness = PolicyPermissiveness::Restrictive;
152        self
153    }
154
155    /// Set the USING expression (visibility filter for existing rows).
156    /// This is an AST expression, not a raw SQL string.
157    pub fn using(mut self, expr: Expr) -> Self {
158        self.using = Some(expr);
159        self
160    }
161
162    /// Set the WITH CHECK expression (write filter for new rows).
163    /// This is an AST expression, not a raw SQL string.
164    pub fn with_check(mut self, expr: Expr) -> Self {
165        self.with_check = Some(expr);
166        self
167    }
168
169    /// Restrict policy to a specific role.
170    pub fn to_role(mut self, role: impl Into<String>) -> Self {
171        self.role = Some(role.into());
172        self
173    }
174}
175
176/// Helper: build the standard tenant isolation expression.
177///
178/// Generates: `column = current_setting('app.session_var')::cast_type`
179///
180/// This is the most common RLS pattern and deserves a first-class helper.
181///
182/// # Example
183/// ```
184/// use qail_core::migrate::policy::tenant_check;
185///
186/// let expr = tenant_check("operator_id", "app.current_operator_id", "uuid");
187/// // Equivalent to: operator_id = current_setting('app.current_operator_id')::uuid
188/// ```
189pub fn tenant_check(
190    column: impl Into<String>,
191    session_var: impl Into<String>,
192    cast_type: impl Into<String>,
193) -> Expr {
194    use crate::ast::{BinaryOp, Value};
195
196    Expr::Binary {
197        left: Box::new(Expr::Named(column.into())),
198        op: BinaryOp::Eq,
199        right: Box::new(Expr::Cast {
200            expr: Box::new(Expr::FunctionCall {
201                name: "current_setting".into(),
202                args: vec![Expr::Literal(Value::String(session_var.into()))],
203                alias: None,
204            }),
205            target_type: cast_type.into(),
206            alias: None,
207        }),
208        alias: None,
209    }
210}
211
212/// Helper: build a boolean session variable check.
213///
214/// Generates: `current_setting('app.session_var')::boolean = true`
215///
216/// Used for super admin bypass policies.
217///
218/// # Example
219/// ```
220/// use qail_core::migrate::policy::session_bool_check;
221///
222/// let expr = session_bool_check("app.is_super_admin");
223/// // Equivalent to: current_setting('app.is_super_admin')::boolean = true
224/// ```
225pub fn session_bool_check(session_var: impl Into<String>) -> Expr {
226    use crate::ast::{BinaryOp, Value};
227
228    Expr::Binary {
229        left: Box::new(Expr::Cast {
230            expr: Box::new(Expr::FunctionCall {
231                name: "current_setting".into(),
232                args: vec![Expr::Literal(Value::String(session_var.into()))],
233                alias: None,
234            }),
235            target_type: "boolean".into(),
236            alias: None,
237        }),
238        op: BinaryOp::Eq,
239        right: Box::new(Expr::Literal(Value::Bool(true))),
240        alias: None,
241    }
242}
243
244/// Helper: combine two expressions with OR.
245///
246/// Useful for: `tenant_check OR super_admin_bypass`
247pub fn or(left: Expr, right: Expr) -> Expr {
248    use crate::ast::BinaryOp;
249
250    Expr::Binary {
251        left: Box::new(left),
252        op: BinaryOp::Or,
253        right: Box::new(right),
254        alias: None,
255    }
256}
257
258/// Helper: combine two expressions with AND.
259pub fn and(left: Expr, right: Expr) -> Expr {
260    use crate::ast::BinaryOp;
261
262    Expr::Binary {
263        left: Box::new(left),
264        op: BinaryOp::And,
265        right: Box::new(right),
266        alias: None,
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use crate::ast::BinaryOp;
274
275    #[test]
276    fn test_policy_builder() {
277        let policy = RlsPolicy::create("orders_isolation", "orders")
278            .for_all()
279            .using(tenant_check("operator_id", "app.current_operator_id", "uuid"))
280            .with_check(tenant_check("operator_id", "app.current_operator_id", "uuid"));
281
282        assert_eq!(policy.name, "orders_isolation");
283        assert_eq!(policy.table, "orders");
284        assert_eq!(policy.target, PolicyTarget::All);
285        assert!(policy.using.is_some());
286        assert!(policy.with_check.is_some());
287    }
288
289    #[test]
290    fn test_policy_restrictive() {
291        let policy = RlsPolicy::create("admin_only", "secrets")
292            .for_select()
293            .restrictive()
294            .to_role("app_user");
295
296        assert_eq!(policy.target, PolicyTarget::Select);
297        assert_eq!(policy.permissiveness, PolicyPermissiveness::Restrictive);
298        assert_eq!(policy.role.as_deref(), Some("app_user"));
299    }
300
301    #[test]
302    fn test_tenant_check_helper() {
303        let expr = tenant_check("operator_id", "app.current_operator_id", "uuid");
304
305        match &expr {
306            Expr::Binary { left, op, right, .. } => {
307                assert_eq!(*op, BinaryOp::Eq);
308                match left.as_ref() {
309                    Expr::Named(n) => assert_eq!(n, "operator_id"),
310                    _ => panic!("Expected Named"),
311                }
312                match right.as_ref() {
313                    Expr::Cast { expr, target_type, .. } => {
314                        assert_eq!(target_type, "uuid");
315                        match expr.as_ref() {
316                            Expr::FunctionCall { name, args, .. } => {
317                                assert_eq!(name, "current_setting");
318                                assert_eq!(args.len(), 1);
319                            }
320                            _ => panic!("Expected FunctionCall"),
321                        }
322                    }
323                    _ => panic!("Expected Cast"),
324                }
325            }
326            _ => panic!("Expected Binary"),
327        }
328    }
329
330    #[test]
331    fn test_super_admin_bypass() {
332        let expr = or(
333            tenant_check("operator_id", "app.current_operator_id", "uuid"),
334            session_bool_check("app.is_super_admin"),
335        );
336
337        match &expr {
338            Expr::Binary { op, .. } => assert_eq!(*op, BinaryOp::Or),
339            _ => panic!("Expected Binary OR"),
340        }
341    }
342
343    #[test]
344    fn test_and_combinator() {
345        let expr = and(
346            tenant_check("operator_id", "app.current_operator_id", "uuid"),
347            tenant_check("agent_id", "app.current_agent_id", "uuid"),
348        );
349
350        match &expr {
351            Expr::Binary { op, .. } => assert_eq!(*op, BinaryOp::And),
352            _ => panic!("Expected Binary AND"),
353        }
354    }
355
356    #[test]
357    fn test_policy_target_display() {
358        assert_eq!(PolicyTarget::All.to_string(), "ALL");
359        assert_eq!(PolicyTarget::Select.to_string(), "SELECT");
360        assert_eq!(PolicyTarget::Insert.to_string(), "INSERT");
361        assert_eq!(PolicyTarget::Update.to_string(), "UPDATE");
362        assert_eq!(PolicyTarget::Delete.to_string(), "DELETE");
363    }
364}