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