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    fn constant(&self, outcome: &O) -> Result<SqlxFragment<B>, LowerError>;
99}
100
101/// Outcome projection backed by [`SqlOutcome`].
102#[derive(Clone, Copy, Debug, Default)]
103pub struct OrdinalProjection;
104
105impl<B, O> OutcomeProjection<B, O> for OrdinalProjection
106where
107    B: GatekeepSqlxBackend,
108    O: SqlOutcome,
109{
110    fn constant(&self, outcome: &O) -> Result<SqlxFragment<B>, LowerError> {
111        Ok(SqlxFragment::bind(outcome.to_sql_ordinal()))
112    }
113}
114
115/// Projection that rejects grade lowering.
116#[derive(Clone, Copy, Debug, Default)]
117pub struct NoGradeProjection;
118
119impl<B, O> OutcomeProjection<B, O> for NoGradeProjection
120where
121    B: GatekeepSqlxBackend,
122{
123    fn constant(&self, _outcome: &O) -> Result<SqlxFragment<B>, LowerError> {
124        Err(LowerError::NonTotalGrade)
125    }
126}
127
128/// `SQLx` lowerer for gatekeep residual policies.
129#[derive(Clone, Debug)]
130pub struct SqlxLowerer<B, P, M = OrdinalProjection> {
131    predicates: P,
132    projection: M,
133    backend: PhantomData<fn() -> B>,
134}
135
136/// Postgres lowerer for gatekeep residual policies.
137#[cfg(feature = "postgres")]
138pub type PgLowerer<P, M = OrdinalProjection> = SqlxLowerer<PostgresBackend, P, M>;
139
140#[derive(Clone, Debug, PartialEq, Eq)]
141struct SqlxLowered<B> {
142    filter: SqlxFragment<B>,
143    grade: SqlxFragment<B>,
144}
145
146impl<B, P> SqlxLowerer<B, P, OrdinalProjection>
147where
148    B: GatekeepSqlxBackend,
149{
150    /// Builds a lowerer using ordinal grade projection.
151    #[must_use]
152    pub const fn new(predicates: P) -> Self {
153        Self::with_projection(predicates, OrdinalProjection)
154    }
155}
156
157impl<B, P, M> SqlxLowerer<B, P, M>
158where
159    B: GatekeepSqlxBackend,
160{
161    /// Builds a lowerer using a caller-supplied projection strategy.
162    #[must_use]
163    pub const fn with_projection(predicates: P, projection: M) -> Self {
164        Self {
165            predicates,
166            projection,
167            backend: PhantomData,
168        }
169    }
170
171    /// Lowers only the Boolean filter. This works for every outcome lattice.
172    pub fn lower_filter<O>(
173        &self,
174        residual: &ResidualPolicy<O>,
175        cx: &Context,
176    ) -> Result<SqlxFragment<B>, LowerError>
177    where
178        P: SqlxFactPredicates<B>,
179    {
180        residual.try_fold_pruned(
181            &mut |branch| match branch {
182                ResidualPolicyBranch::OrElseFallback { fallback, .. } => {
183                    !fallback.carries_obligation()
184                }
185            },
186            &mut |node| self.lower_filter_node(node, cx),
187        )
188    }
189
190    fn lower_filter_node<O>(
191        &self,
192        node: ResidualPolicyNode<'_, O, SqlxFragment<B>>,
193        cx: &Context,
194    ) -> Result<SqlxFragment<B>, LowerError>
195    where
196        P: SqlxFactPredicates<B>,
197    {
198        match node {
199            ResidualPolicyNode::Permit(_) | ResidualPolicyNode::PermitWithTrace { .. } => {
200                Ok(SqlxFragment::trusted("TRUE"))
201            }
202            ResidualPolicyNode::Deny | ResidualPolicyNode::DenyWithTrace { .. } => {
203                Ok(SqlxFragment::trusted("FALSE"))
204            }
205            ResidualPolicyNode::Grant { condition, .. } => self.lower_condition(condition, cx),
206            ResidualPolicyNode::All { arms, .. } => Ok(fragment_set(arms, " AND ", "FALSE")),
207            ResidualPolicyNode::Any { arms, .. } => Ok(fragment_set(arms, " OR ", "FALSE")),
208            ResidualPolicyNode::OrElse {
209                fallback_policy,
210                primary,
211                fallback,
212                ..
213            } => {
214                if fallback_policy.carries_obligation() {
215                    Ok(primary)
216                } else {
217                    Ok(match fallback {
218                        Some(fallback) => SqlxFragment::binary(" OR ", vec![primary, fallback]),
219                        None => primary,
220                    })
221                }
222            }
223        }
224    }
225
226    fn lower_condition(
227        &self,
228        condition: &Condition,
229        cx: &Context,
230    ) -> Result<SqlxFragment<B>, LowerError>
231    where
232        P: SqlxFactPredicates<B>,
233    {
234        match condition {
235            Condition::Always => Ok(SqlxFragment::trusted("TRUE")),
236            Condition::Never => Ok(SqlxFragment::trusted("FALSE")),
237            Condition::Has(fact) => self
238                .predicates
239                .predicate(fact, cx)
240                .map(is_true)
241                .ok_or_else(|| LowerError::Unlowerable(fact.clone())),
242            Condition::Not(inner) => Ok(SqlxFragment::unary(
243                "NOT ",
244                self.lower_condition(inner, cx)?,
245            )),
246            Condition::All(conditions) => {
247                lower_condition_set(conditions, " AND ", "FALSE", |item| {
248                    self.lower_condition(item, cx)
249                })
250            }
251            Condition::Any(conditions) => {
252                lower_condition_set(conditions, " OR ", "FALSE", |item| {
253                    self.lower_condition(item, cx)
254                })
255            }
256        }
257    }
258
259    fn lower_policy<O>(
260        &self,
261        residual: &ResidualPolicy<O>,
262        cx: &Context,
263    ) -> Result<SqlxLowered<B>, LowerError>
264    where
265        P: SqlxFactPredicates<B>,
266        M: OutcomeProjection<B, O>,
267    {
268        residual.try_fold_pruned(
269            &mut |branch| match branch {
270                ResidualPolicyBranch::OrElseFallback { fallback, .. } => {
271                    !fallback.carries_obligation()
272                }
273            },
274            &mut |node| self.lower_node(node, cx),
275        )
276    }
277
278    fn lower_node<O>(
279        &self,
280        node: ResidualPolicyNode<'_, O, SqlxLowered<B>>,
281        cx: &Context,
282    ) -> Result<SqlxLowered<B>, LowerError>
283    where
284        P: SqlxFactPredicates<B>,
285        M: OutcomeProjection<B, O>,
286    {
287        match node {
288            ResidualPolicyNode::Permit(outcome)
289            | ResidualPolicyNode::PermitWithTrace { outcome, .. } => Ok(SqlxLowered {
290                filter: SqlxFragment::trusted("TRUE"),
291                grade: self.projection.constant(outcome)?,
292            }),
293            ResidualPolicyNode::Deny | ResidualPolicyNode::DenyWithTrace { .. } => {
294                Ok(SqlxLowered {
295                    filter: SqlxFragment::trusted("FALSE"),
296                    grade: SqlxFragment::trusted("NULL"),
297                })
298            }
299            ResidualPolicyNode::Grant {
300                outcome, condition, ..
301            } => {
302                let filter = self.lower_condition(condition, cx)?;
303                let outcome = self.projection.constant(outcome)?;
304                Ok(SqlxLowered {
305                    filter: filter.clone(),
306                    grade: case_when(filter, outcome, SqlxFragment::trusted("NULL")),
307                })
308            }
309            ResidualPolicyNode::All { arms, .. } => {
310                let (filters, grades) = unzip_lowered(arms);
311                Ok(SqlxLowered {
312                    filter: fragment_set(filters, " AND ", "FALSE"),
313                    grade: grade_set::<B>(grades, B::MIN_FUNCTION),
314                })
315            }
316            ResidualPolicyNode::Any { arms, .. } => {
317                let (filters, grades) = unzip_lowered(arms);
318                Ok(SqlxLowered {
319                    filter: fragment_set(filters, " OR ", "FALSE"),
320                    grade: grade_set::<B>(grades, B::MAX_FUNCTION),
321                })
322            }
323            ResidualPolicyNode::OrElse {
324                fallback_policy,
325                primary,
326                fallback,
327                ..
328            } => {
329                if fallback_policy.carries_obligation() {
330                    return Ok(primary);
331                }
332
333                Ok(match fallback {
334                    Some(fallback) => SqlxLowered {
335                        filter: SqlxFragment::binary(
336                            " OR ",
337                            vec![primary.filter.clone(), fallback.filter],
338                        ),
339                        grade: case_when(primary.filter, primary.grade, fallback.grade),
340                    },
341                    None => primary,
342                })
343            }
344        }
345    }
346}
347
348impl<O, B, P, M> QueryLowering<O> for SqlxLowerer<B, P, M>
349where
350    B: GatekeepSqlxBackend,
351    P: SqlxFactPredicates<B>,
352    M: OutcomeProjection<B, O>,
353{
354    type Filter = SqlxFragment<B>;
355    type Projection = SqlxFragment<B>;
356
357    fn lower(
358        &self,
359        residual: &ResidualPolicy<O>,
360        cx: &Context,
361    ) -> Result<Lowered<Self::Filter, Self::Projection>, LowerError> {
362        let lowered = self.lower_policy(residual, cx)?;
363        Ok(Lowered {
364            filter: lowered.filter,
365            grade: lowered.grade,
366        })
367    }
368}
369
370fn lower_condition_set<B>(
371    conditions: &[Condition],
372    separator: &str,
373    empty: &str,
374    lower: impl FnMut(&Condition) -> Result<SqlxFragment<B>, LowerError>,
375) -> Result<SqlxFragment<B>, LowerError> {
376    if conditions.is_empty() {
377        return Ok(SqlxFragment::trusted(empty));
378    }
379    let fragments = conditions
380        .iter()
381        .map(lower)
382        .collect::<Result<Vec<_>, _>>()?;
383    Ok(SqlxFragment::binary(separator, fragments))
384}
385
386fn fragment_set<B>(
387    fragments: Vec<SqlxFragment<B>>,
388    separator: &str,
389    empty: &str,
390) -> SqlxFragment<B> {
391    if fragments.is_empty() {
392        SqlxFragment::trusted(empty)
393    } else {
394        SqlxFragment::binary(separator, fragments)
395    }
396}
397
398fn grade_set<B>(grades: Vec<SqlxFragment<B>>, function: &str) -> SqlxFragment<B>
399where
400    B: GatekeepSqlxBackend,
401{
402    match grades.len() {
403        0 => SqlxFragment::trusted("NULL"),
404        1 => grades
405            .into_iter()
406            .next()
407            .unwrap_or_else(|| SqlxFragment::trusted("NULL")),
408        _ if B::GRADE_FUNCTION_PROPAGATES_NULL => {
409            let mut iter = grades.into_iter();
410            let mut combined = iter.next().unwrap_or_else(|| SqlxFragment::trusted("NULL"));
411            for grade in iter {
412                combined = null_safe_grade_pair(function, combined, grade);
413            }
414            combined
415        }
416        _ => SqlxFragment::function(function, grades),
417    }
418}
419
420fn null_safe_grade_pair<B>(
421    function: &str,
422    left: SqlxFragment<B>,
423    right: SqlxFragment<B>,
424) -> SqlxFragment<B> {
425    let mut fragment = SqlxFragment::trusted("CASE WHEN ");
426    fragment.push_fragment(left.clone().wrapped());
427    fragment.push_sql(" IS NULL THEN ");
428    fragment.push_fragment(right.clone());
429    fragment.push_sql(" WHEN ");
430    fragment.push_fragment(right.clone().wrapped());
431    fragment.push_sql(" IS NULL THEN ");
432    fragment.push_fragment(left.clone());
433    fragment.push_sql(" ELSE ");
434    fragment.push_fragment(SqlxFragment::function(function, vec![left, right]));
435    fragment.push_sql(" END");
436    fragment
437}
438
439fn unzip_lowered<B>(lowered: Vec<SqlxLowered<B>>) -> (Vec<SqlxFragment<B>>, Vec<SqlxFragment<B>>) {
440    lowered
441        .into_iter()
442        .map(|lowered| (lowered.filter, lowered.grade))
443        .unzip()
444}
445
446fn case_when<B>(
447    condition: SqlxFragment<B>,
448    then_expr: SqlxFragment<B>,
449    else_expr: SqlxFragment<B>,
450) -> SqlxFragment<B> {
451    let mut fragment = SqlxFragment::trusted("CASE WHEN ");
452    fragment.push_fragment(condition);
453    fragment.push_sql(" THEN ");
454    fragment.push_fragment(then_expr);
455    fragment.push_sql(" ELSE ");
456    fragment.push_fragment(else_expr);
457    fragment.push_sql(" END");
458    fragment
459}
460
461fn is_true<B>(predicate: SqlxFragment<B>) -> SqlxFragment<B> {
462    let mut fragment = SqlxFragment::trusted("(");
463    fragment.push_fragment(predicate);
464    fragment.push_sql(") IS TRUE");
465    fragment
466}