Skip to main content

gatekeep_sqlx/
lib.rs

1//! `SQLx` support for gatekeep query lowering and durable decision audit.
2//!
3//! This crate lowers a `gatekeep::ResidualPolicy` into trusted SQL fragments
4//! that can be appended to a `sqlx::QueryBuilder`.
5//!
6//! It also provides Dovecote-backed `gatekeep::AuditSink` implementations for
7//! Postgres, `SQLite`, and `MySQL`. Run the selected Dovecote migration, call
8//! the adapter's `check_schema`, configure an application-owned absolute event
9//! source, and pass the sink to `gatekeep_axum::Gatekeeper`.
10//!
11//! Gatekeep does not maintain a SQL audit table. Each decision is serialized as
12//! one complete Dovecote event with a pending delivery. Use the concrete
13//! `record_decision_audit_in_transaction` method when application writes and
14//! the audit event need one caller-owned transaction.
15
16#![forbid(unsafe_code)]
17
18#[cfg(not(any(feature = "postgres", feature = "sqlite", feature = "mysql")))]
19compile_error!(
20    "gatekeep-sqlx requires at least one SQLx backend feature: postgres, sqlite, or mysql"
21);
22
23use std::marker::PhantomData;
24
25use gatekeep::{
26    Condition, Context, FactId, LowerError, Lowered, QueryLowering, ResidualPolicy,
27    ResidualPolicyBranch, ResidualPolicyNode,
28};
29
30mod audit;
31mod fragment;
32
33pub use audit::{
34    DECISION_AUDIT_CONTENT_TYPE, DECISION_AUDIT_EVENT_TYPE, DEFAULT_AUDIT_STREAM,
35    DecisionAuditConfig, DecisionAuditConfigError, DecisionAuditDecodeError,
36    DecisionAuditEventError, decode_decision_audit,
37};
38#[cfg(feature = "mysql")]
39pub use audit::{MySqlDovecoteAudit, MySqlDovecoteAuditError};
40#[cfg(feature = "postgres")]
41pub use audit::{PgDovecoteAudit, PgDovecoteAuditError};
42#[cfg(feature = "sqlite")]
43pub use audit::{SqliteDovecoteAudit, SqliteDovecoteAuditError};
44#[cfg(feature = "mysql")]
45pub use fragment::MySqlBackend;
46#[cfg(feature = "sqlite")]
47pub use fragment::SqliteBackend;
48pub use fragment::{
49    GatekeepSqlxBackend, SqlxDriver, SqlxDriverError, SqlxFragment, SqlxValue,
50    infer_enabled_driver_from_url, validate_database_url_for_backend,
51};
52#[cfg(feature = "postgres")]
53pub use fragment::{PgFragment, PgValue, PostgresBackend};
54
55/// Maps a residual fact to a trusted predicate over the candidate row.
56pub trait SqlxFactPredicates<B>
57where
58    B: GatekeepSqlxBackend,
59{
60    /// Returns a predicate for the given fact, or `None` when the fact cannot be
61    /// represented by this backend.
62    fn predicate(&self, fact: &FactId, cx: &Context) -> Option<SqlxFragment<B>>;
63}
64
65/// Maps a residual fact to a trusted Postgres predicate over the candidate row.
66#[cfg(feature = "postgres")]
67pub trait PgFactPredicates {
68    /// Returns a predicate for the given fact, or `None` when the fact cannot be
69    /// represented by this backend.
70    fn predicate(&self, fact: &FactId, cx: &Context) -> Option<PgFragment>;
71}
72
73#[cfg(feature = "postgres")]
74impl<T> SqlxFactPredicates<PostgresBackend> for T
75where
76    T: PgFactPredicates,
77{
78    fn predicate(&self, fact: &FactId, cx: &Context) -> Option<SqlxFragment<PostgresBackend>> {
79        PgFactPredicates::predicate(self, fact, cx)
80    }
81}
82
83/// Maps a policy outcome to a total-order SQL ordinal.
84pub trait SqlOutcome {
85    /// Returns the scalar ordinal used by SQL grade projection.
86    fn to_sql_ordinal(&self) -> i64;
87}
88
89impl SqlOutcome for () {
90    fn to_sql_ordinal(&self) -> i64 {
91        0
92    }
93}
94
95/// Projection strategy for turning outcomes into SQL fragments.
96pub trait OutcomeProjection<B, O>
97where
98    B: GatekeepSqlxBackend,
99{
100    /// Builds a SQL fragment for a constant outcome.
101    ///
102    /// # Errors
103    ///
104    /// Returns [`LowerError::NonTotalGrade`] when this projection cannot
105    /// represent the outcome lattice.
106    fn constant(&self, outcome: &O) -> Result<SqlxFragment<B>, LowerError>;
107}
108
109/// Outcome projection backed by [`SqlOutcome`].
110#[derive(Clone, Copy, Debug, Default)]
111pub struct OrdinalProjection;
112
113impl<B, O> OutcomeProjection<B, O> for OrdinalProjection
114where
115    B: GatekeepSqlxBackend,
116    O: SqlOutcome,
117{
118    fn constant(&self, outcome: &O) -> Result<SqlxFragment<B>, LowerError> {
119        Ok(SqlxFragment::bind(outcome.to_sql_ordinal()))
120    }
121}
122
123/// Projection that rejects grade lowering.
124#[derive(Clone, Copy, Debug, Default)]
125pub struct NoGradeProjection;
126
127impl<B, O> OutcomeProjection<B, O> for NoGradeProjection
128where
129    B: GatekeepSqlxBackend,
130{
131    fn constant(&self, _outcome: &O) -> Result<SqlxFragment<B>, LowerError> {
132        Err(LowerError::NonTotalGrade)
133    }
134}
135
136/// `SQLx` lowerer for gatekeep residual policies.
137#[derive(Clone, Debug)]
138pub struct SqlxLowerer<B, P, M = OrdinalProjection> {
139    predicates: P,
140    projection: M,
141    backend: PhantomData<fn() -> B>,
142}
143
144/// Postgres lowerer for gatekeep residual policies.
145#[cfg(feature = "postgres")]
146pub type PgLowerer<P, M = OrdinalProjection> = SqlxLowerer<PostgresBackend, P, M>;
147
148#[derive(Clone, Debug, PartialEq, Eq)]
149struct SqlxLowered<B> {
150    filter: SqlxFragment<B>,
151    grade: SqlxFragment<B>,
152}
153
154impl<B, P> SqlxLowerer<B, P, OrdinalProjection>
155where
156    B: GatekeepSqlxBackend,
157{
158    /// Builds a lowerer using ordinal grade projection.
159    #[must_use]
160    pub const fn new(predicates: P) -> Self {
161        Self::with_projection(predicates, OrdinalProjection)
162    }
163}
164
165impl<B, P, M> SqlxLowerer<B, P, M>
166where
167    B: GatekeepSqlxBackend,
168{
169    /// Builds a lowerer using a caller-supplied projection strategy.
170    #[must_use]
171    pub const fn with_projection(predicates: P, projection: M) -> Self {
172        Self {
173            predicates,
174            projection,
175            backend: PhantomData,
176        }
177    }
178
179    /// Lowers only the Boolean filter. This works for every outcome lattice.
180    ///
181    /// # Errors
182    ///
183    /// Returns [`LowerError::Unlowerable`] when a residual fact has no trusted
184    /// predicate mapping.
185    pub fn lower_filter<O>(
186        &self,
187        residual: &ResidualPolicy<O>,
188        cx: &Context,
189    ) -> Result<SqlxFragment<B>, LowerError>
190    where
191        P: SqlxFactPredicates<B>,
192    {
193        residual.try_fold_pruned(
194            &mut |branch| match branch {
195                ResidualPolicyBranch::OrElseFallback { fallback, .. } => {
196                    !fallback.carries_obligation()
197                }
198            },
199            &mut |node| self.lower_filter_node(node, cx),
200        )
201    }
202
203    fn lower_filter_node<O>(
204        &self,
205        node: ResidualPolicyNode<'_, O, SqlxFragment<B>>,
206        cx: &Context,
207    ) -> Result<SqlxFragment<B>, LowerError>
208    where
209        P: SqlxFactPredicates<B>,
210    {
211        match node {
212            ResidualPolicyNode::Permit(_) | ResidualPolicyNode::PermitWithTrace { .. } => {
213                Ok(SqlxFragment::trusted("TRUE"))
214            }
215            ResidualPolicyNode::Deny | ResidualPolicyNode::DenyWithTrace { .. } => {
216                Ok(SqlxFragment::trusted("FALSE"))
217            }
218            ResidualPolicyNode::Grant { condition, .. } => self.lower_condition(condition, cx),
219            ResidualPolicyNode::All { arms, .. } => Ok(fragment_set(arms, " AND ", "FALSE")),
220            ResidualPolicyNode::Any { arms, .. } => Ok(fragment_set(arms, " OR ", "FALSE")),
221            ResidualPolicyNode::OrElse {
222                fallback_policy,
223                primary,
224                fallback,
225                ..
226            } => {
227                if fallback_policy.carries_obligation() {
228                    Ok(primary)
229                } else {
230                    Ok(match fallback {
231                        Some(fallback) => SqlxFragment::binary(" OR ", vec![primary, fallback]),
232                        None => primary,
233                    })
234                }
235            }
236        }
237    }
238
239    fn lower_condition(
240        &self,
241        condition: &Condition,
242        cx: &Context,
243    ) -> Result<SqlxFragment<B>, LowerError>
244    where
245        P: SqlxFactPredicates<B>,
246    {
247        match condition {
248            Condition::Always => Ok(SqlxFragment::trusted("TRUE")),
249            Condition::Never => Ok(SqlxFragment::trusted("FALSE")),
250            Condition::Has(fact) => self
251                .predicates
252                .predicate(fact, cx)
253                .map(is_true)
254                .ok_or_else(|| LowerError::Unlowerable(fact.clone())),
255            Condition::Not(inner) => Ok(SqlxFragment::unary(
256                "NOT ",
257                self.lower_condition(inner, cx)?,
258            )),
259            Condition::All(conditions) => {
260                lower_condition_set(conditions, " AND ", "FALSE", |item| {
261                    self.lower_condition(item, cx)
262                })
263            }
264            Condition::Any(conditions) => {
265                lower_condition_set(conditions, " OR ", "FALSE", |item| {
266                    self.lower_condition(item, cx)
267                })
268            }
269        }
270    }
271
272    fn lower_policy<O>(
273        &self,
274        residual: &ResidualPolicy<O>,
275        cx: &Context,
276    ) -> Result<SqlxLowered<B>, LowerError>
277    where
278        P: SqlxFactPredicates<B>,
279        M: OutcomeProjection<B, O>,
280    {
281        residual.try_fold_pruned(
282            &mut |branch| match branch {
283                ResidualPolicyBranch::OrElseFallback { fallback, .. } => {
284                    !fallback.carries_obligation()
285                }
286            },
287            &mut |node| self.lower_node(node, cx),
288        )
289    }
290
291    fn lower_node<O>(
292        &self,
293        node: ResidualPolicyNode<'_, O, SqlxLowered<B>>,
294        cx: &Context,
295    ) -> Result<SqlxLowered<B>, LowerError>
296    where
297        P: SqlxFactPredicates<B>,
298        M: OutcomeProjection<B, O>,
299    {
300        match node {
301            ResidualPolicyNode::Permit(outcome)
302            | ResidualPolicyNode::PermitWithTrace { outcome, .. } => Ok(SqlxLowered {
303                filter: SqlxFragment::trusted("TRUE"),
304                grade: self.projection.constant(outcome)?,
305            }),
306            ResidualPolicyNode::Deny | ResidualPolicyNode::DenyWithTrace { .. } => {
307                Ok(SqlxLowered {
308                    filter: SqlxFragment::trusted("FALSE"),
309                    grade: SqlxFragment::trusted("NULL"),
310                })
311            }
312            ResidualPolicyNode::Grant {
313                outcome, condition, ..
314            } => {
315                let filter = self.lower_condition(condition, cx)?;
316                let outcome = self.projection.constant(outcome)?;
317                Ok(SqlxLowered {
318                    filter: filter.clone(),
319                    grade: case_when(filter, outcome, SqlxFragment::trusted("NULL")),
320                })
321            }
322            ResidualPolicyNode::All { arms, .. } => {
323                let (filters, grades) = unzip_lowered(arms);
324                Ok(SqlxLowered {
325                    filter: fragment_set(filters, " AND ", "FALSE"),
326                    grade: grade_set::<B>(grades, B::MIN_FUNCTION),
327                })
328            }
329            ResidualPolicyNode::Any { arms, .. } => {
330                let (filters, grades) = unzip_lowered(arms);
331                Ok(SqlxLowered {
332                    filter: fragment_set(filters, " OR ", "FALSE"),
333                    grade: grade_set::<B>(grades, B::MAX_FUNCTION),
334                })
335            }
336            ResidualPolicyNode::OrElse {
337                fallback_policy,
338                primary,
339                fallback,
340                ..
341            } => {
342                if fallback_policy.carries_obligation() {
343                    return Ok(primary);
344                }
345
346                Ok(match fallback {
347                    Some(fallback) => SqlxLowered {
348                        filter: SqlxFragment::binary(
349                            " OR ",
350                            vec![primary.filter.clone(), fallback.filter],
351                        ),
352                        grade: case_when(primary.filter, primary.grade, fallback.grade),
353                    },
354                    None => primary,
355                })
356            }
357        }
358    }
359}
360
361impl<O, B, P, M> QueryLowering<O> for SqlxLowerer<B, P, M>
362where
363    B: GatekeepSqlxBackend,
364    P: SqlxFactPredicates<B>,
365    M: OutcomeProjection<B, O>,
366{
367    type Filter = SqlxFragment<B>;
368    type Projection = SqlxFragment<B>;
369
370    fn lower(
371        &self,
372        residual: &ResidualPolicy<O>,
373        cx: &Context,
374    ) -> Result<Lowered<Self::Filter, Self::Projection>, LowerError> {
375        let lowered = self.lower_policy(residual, cx)?;
376        Ok(Lowered {
377            filter: lowered.filter,
378            grade: lowered.grade,
379        })
380    }
381}
382
383fn lower_condition_set<B>(
384    conditions: &[Condition],
385    separator: &str,
386    empty: &str,
387    lower: impl FnMut(&Condition) -> Result<SqlxFragment<B>, LowerError>,
388) -> Result<SqlxFragment<B>, LowerError> {
389    if conditions.is_empty() {
390        return Ok(SqlxFragment::trusted(empty));
391    }
392
393    let fragments = conditions
394        .iter()
395        .map(lower)
396        .collect::<Result<Vec<_>, _>>()?;
397    Ok(SqlxFragment::binary(separator, fragments))
398}
399
400fn fragment_set<B>(
401    fragments: Vec<SqlxFragment<B>>,
402    separator: &str,
403    empty: &str,
404) -> SqlxFragment<B> {
405    if fragments.is_empty() {
406        SqlxFragment::trusted(empty)
407    } else {
408        SqlxFragment::binary(separator, fragments)
409    }
410}
411
412fn grade_set<B>(grades: Vec<SqlxFragment<B>>, function: &str) -> SqlxFragment<B>
413where
414    B: GatekeepSqlxBackend,
415{
416    match grades.len() {
417        0 => SqlxFragment::trusted("NULL"),
418        1 => grades
419            .into_iter()
420            .next()
421            .unwrap_or_else(|| SqlxFragment::trusted("NULL")),
422        _ if B::GRADE_FUNCTION_PROPAGATES_NULL => {
423            let mut iter = grades.into_iter();
424            let mut combined = iter.next().unwrap_or_else(|| SqlxFragment::trusted("NULL"));
425            for grade in iter {
426                combined = null_safe_grade_pair(function, combined, grade);
427            }
428            combined
429        }
430        _ => SqlxFragment::function(function, grades),
431    }
432}
433
434fn null_safe_grade_pair<B>(
435    function: &str,
436    left: SqlxFragment<B>,
437    right: SqlxFragment<B>,
438) -> SqlxFragment<B> {
439    let mut fragment = SqlxFragment::trusted("CASE WHEN ");
440    fragment.push_fragment(left.clone().wrapped());
441    fragment.push_sql(" IS NULL THEN ");
442    fragment.push_fragment(right.clone());
443    fragment.push_sql(" WHEN ");
444    fragment.push_fragment(right.clone().wrapped());
445    fragment.push_sql(" IS NULL THEN ");
446    fragment.push_fragment(left.clone());
447    fragment.push_sql(" ELSE ");
448    fragment.push_fragment(SqlxFragment::function(function, vec![left, right]));
449    fragment.push_sql(" END");
450    fragment
451}
452
453fn unzip_lowered<B>(lowered: Vec<SqlxLowered<B>>) -> (Vec<SqlxFragment<B>>, Vec<SqlxFragment<B>>) {
454    lowered
455        .into_iter()
456        .map(|lowered| (lowered.filter, lowered.grade))
457        .unzip()
458}
459
460fn case_when<B>(
461    condition: SqlxFragment<B>,
462    then_expr: SqlxFragment<B>,
463    else_expr: SqlxFragment<B>,
464) -> SqlxFragment<B> {
465    let mut fragment = SqlxFragment::trusted("CASE WHEN ");
466    fragment.push_fragment(condition);
467    fragment.push_sql(" THEN ");
468    fragment.push_fragment(then_expr);
469    fragment.push_sql(" ELSE ");
470    fragment.push_fragment(else_expr);
471    fragment.push_sql(" END");
472    fragment
473}
474
475fn is_true<B>(predicate: SqlxFragment<B>) -> SqlxFragment<B> {
476    let mut fragment = SqlxFragment::trusted("(");
477    fragment.push_fragment(predicate);
478    fragment.push_sql(") IS TRUE");
479    fragment
480}