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, MAX_TENANT_IDENTIFIER_BYTES, SqlxDriver, SqlxDriverError, SqlxFragment,
50    SqlxValue, TenantColumn, TenantColumnError, TenantIdentifierPart,
51    infer_enabled_driver_from_url, validate_database_url_for_backend,
52};
53#[cfg(feature = "postgres")]
54pub use fragment::{PgFragment, PgValue, PostgresBackend};
55
56/// Maps a residual fact to a trusted predicate over the candidate row.
57pub trait SqlxFactPredicates<B>
58where
59    B: GatekeepSqlxBackend,
60{
61    /// Returns a predicate for the given fact, or `None` when the fact cannot be
62    /// represented by this backend.
63    fn predicate(&self, fact: &FactId, cx: &Context) -> Option<SqlxFragment<B>>;
64}
65
66/// Maps a residual fact to a trusted Postgres predicate over the candidate row.
67#[cfg(feature = "postgres")]
68pub trait PgFactPredicates {
69    /// Returns a predicate for the given fact, or `None` when the fact cannot be
70    /// represented by this backend.
71    fn predicate(&self, fact: &FactId, cx: &Context) -> Option<PgFragment>;
72}
73
74#[cfg(feature = "postgres")]
75impl<T> SqlxFactPredicates<PostgresBackend> for T
76where
77    T: PgFactPredicates,
78{
79    fn predicate(&self, fact: &FactId, cx: &Context) -> Option<SqlxFragment<PostgresBackend>> {
80        PgFactPredicates::predicate(self, fact, cx)
81    }
82}
83
84/// Maps a policy outcome to a total-order SQL ordinal.
85pub trait SqlOutcome {
86    /// Returns the scalar ordinal used by SQL grade projection.
87    fn to_sql_ordinal(&self) -> i64;
88}
89
90impl SqlOutcome for () {
91    fn to_sql_ordinal(&self) -> i64 {
92        0
93    }
94}
95
96/// Projection strategy for turning outcomes into SQL fragments.
97pub trait OutcomeProjection<B, O>
98where
99    B: GatekeepSqlxBackend,
100{
101    /// Builds a SQL fragment for a constant outcome.
102    ///
103    /// # Errors
104    ///
105    /// Returns [`LowerError::NonTotalGrade`] when this projection cannot
106    /// represent the outcome lattice.
107    fn constant(&self, outcome: &O) -> Result<SqlxFragment<B>, LowerError>;
108}
109
110/// Outcome projection backed by [`SqlOutcome`].
111#[derive(Clone, Copy, Debug, Default)]
112pub struct OrdinalProjection;
113
114impl<B, O> OutcomeProjection<B, O> for OrdinalProjection
115where
116    B: GatekeepSqlxBackend,
117    O: SqlOutcome,
118{
119    fn constant(&self, outcome: &O) -> Result<SqlxFragment<B>, LowerError> {
120        Ok(SqlxFragment::bind(outcome.to_sql_ordinal()))
121    }
122}
123
124/// Projection that rejects grade lowering.
125#[derive(Clone, Copy, Debug, Default)]
126pub struct NoGradeProjection;
127
128impl<B, O> OutcomeProjection<B, O> for NoGradeProjection
129where
130    B: GatekeepSqlxBackend,
131{
132    fn constant(&self, _outcome: &O) -> Result<SqlxFragment<B>, LowerError> {
133        Err(LowerError::NonTotalGrade)
134    }
135}
136
137/// `SQLx` lowerer for gatekeep residual policies.
138#[derive(Clone, Debug)]
139pub struct SqlxLowerer<B, P, M = OrdinalProjection> {
140    predicates: P,
141    projection: M,
142    tenant_column: TenantColumn,
143    backend: PhantomData<fn() -> B>,
144}
145
146/// Postgres lowerer for gatekeep residual policies.
147#[cfg(feature = "postgres")]
148pub type PgLowerer<P, M = OrdinalProjection> = SqlxLowerer<PostgresBackend, P, M>;
149
150#[derive(Clone, Debug, PartialEq, Eq)]
151struct SqlxLowered<B> {
152    filter: SqlxFragment<B>,
153    grade: SqlxFragment<B>,
154}
155
156impl<B, P> SqlxLowerer<B, P, OrdinalProjection>
157where
158    B: GatekeepSqlxBackend,
159{
160    /// Builds a lowerer using ordinal grade projection.
161    #[must_use]
162    pub fn new(predicates: P, tenant_column: TenantColumn) -> Self {
163        Self::with_projection(predicates, OrdinalProjection, tenant_column)
164    }
165}
166
167impl<B, P, M> SqlxLowerer<B, P, M>
168where
169    B: GatekeepSqlxBackend,
170{
171    /// Builds a lowerer using a caller-supplied projection strategy.
172    #[must_use]
173    pub fn with_projection(predicates: P, projection: M, tenant_column: TenantColumn) -> Self {
174        Self {
175            predicates,
176            projection,
177            tenant_column,
178            backend: PhantomData,
179        }
180    }
181
182    /// Lowers only the Boolean filter. This works for every outcome lattice.
183    ///
184    /// # Errors
185    ///
186    /// Returns [`LowerError::Unlowerable`] when a residual fact has no trusted
187    /// predicate mapping.
188    pub fn lower_filter<O>(
189        &self,
190        residual: &ResidualPolicy<O>,
191        cx: &Context,
192    ) -> Result<SqlxFragment<B>, LowerError>
193    where
194        P: SqlxFactPredicates<B>,
195    {
196        let policy = residual.try_fold_pruned(
197            &mut |branch| match branch {
198                ResidualPolicyBranch::OrElseFallback { fallback, .. } => {
199                    !fallback.carries_obligation()
200                }
201            },
202            &mut |node| self.lower_filter_node(node, cx),
203        )?;
204        Ok(self.enforce_tenant_filter(policy, cx))
205    }
206
207    fn lower_filter_node<O>(
208        &self,
209        node: ResidualPolicyNode<'_, O, SqlxFragment<B>>,
210        cx: &Context,
211    ) -> Result<SqlxFragment<B>, LowerError>
212    where
213        P: SqlxFactPredicates<B>,
214    {
215        match node {
216            ResidualPolicyNode::Permit(_) | ResidualPolicyNode::PermitWithTrace { .. } => {
217                Ok(SqlxFragment::trusted("TRUE"))
218            }
219            ResidualPolicyNode::Deny | ResidualPolicyNode::DenyWithTrace { .. } => {
220                Ok(SqlxFragment::trusted("FALSE"))
221            }
222            ResidualPolicyNode::Grant { condition, .. } => self.lower_condition(condition, cx),
223            ResidualPolicyNode::All { arms, .. } => Ok(fragment_set(arms, " AND ", "FALSE")),
224            ResidualPolicyNode::Any { arms, .. } => Ok(fragment_set(arms, " OR ", "FALSE")),
225            ResidualPolicyNode::OrElse {
226                fallback_policy,
227                primary,
228                fallback,
229                ..
230            } => {
231                if fallback_policy.carries_obligation() {
232                    Ok(primary)
233                } else {
234                    Ok(match fallback {
235                        Some(fallback) => SqlxFragment::binary(" OR ", vec![primary, fallback]),
236                        None => primary,
237                    })
238                }
239            }
240        }
241    }
242
243    fn lower_condition(
244        &self,
245        condition: &Condition,
246        cx: &Context,
247    ) -> Result<SqlxFragment<B>, LowerError>
248    where
249        P: SqlxFactPredicates<B>,
250    {
251        match condition {
252            Condition::Always => Ok(SqlxFragment::trusted("TRUE")),
253            Condition::Never => Ok(SqlxFragment::trusted("FALSE")),
254            Condition::Has(fact) => self
255                .predicates
256                .predicate(fact, cx)
257                .map(is_true)
258                .ok_or_else(|| LowerError::Unlowerable(fact.clone())),
259            Condition::Not(inner) => Ok(SqlxFragment::unary(
260                "NOT ",
261                self.lower_condition(inner, cx)?,
262            )),
263            Condition::All(conditions) => {
264                lower_condition_set(conditions, " AND ", "FALSE", |item| {
265                    self.lower_condition(item, cx)
266                })
267            }
268            Condition::Any(conditions) => {
269                lower_condition_set(conditions, " OR ", "FALSE", |item| {
270                    self.lower_condition(item, cx)
271                })
272            }
273        }
274    }
275
276    /// Adds the mandatory typed tenant predicate to an already-built filter.
277    ///
278    /// `lower` and `lower_filter` call this automatically. This method is
279    /// public for applications that have already resolved a policy in memory
280    /// and need to combine its constant filter with the same tenant safety
281    /// contract.
282    #[must_use]
283    pub fn enforce_tenant_filter(&self, policy: SqlxFragment<B>, cx: &Context) -> SqlxFragment<B> {
284        let mut tenant = SqlxFragment::trusted(self.tenant_column.qualified());
285        tenant.push_sql(" = ");
286        tenant.push_fragment(SqlxFragment::bind(cx.tenant().as_str()));
287        SqlxFragment::binary(" AND ", [tenant, policy])
288    }
289
290    /// Adds the mandatory tenant guard to a manually constructed grade
291    /// projection. Callers that consume a [`gatekeep::Lowered`] from an
292    /// in-memory resolved branch must apply this before selecting the grade;
293    /// [`Self::lower`] applies it automatically.
294    #[must_use]
295    pub fn enforce_tenant_projection(
296        &self,
297        grade: SqlxFragment<B>,
298        cx: &Context,
299    ) -> SqlxFragment<B> {
300        let tenant = self.enforce_tenant_filter(SqlxFragment::trusted("TRUE"), cx);
301        case_when(tenant, grade, SqlxFragment::trusted("NULL"))
302    }
303
304    fn lower_policy<O>(
305        &self,
306        residual: &ResidualPolicy<O>,
307        cx: &Context,
308    ) -> Result<SqlxLowered<B>, LowerError>
309    where
310        P: SqlxFactPredicates<B>,
311        M: OutcomeProjection<B, O>,
312    {
313        residual.try_fold_pruned(
314            &mut |branch| match branch {
315                ResidualPolicyBranch::OrElseFallback { fallback, .. } => {
316                    !fallback.carries_obligation()
317                }
318            },
319            &mut |node| self.lower_node(node, cx),
320        )
321    }
322
323    fn lower_node<O>(
324        &self,
325        node: ResidualPolicyNode<'_, O, SqlxLowered<B>>,
326        cx: &Context,
327    ) -> Result<SqlxLowered<B>, LowerError>
328    where
329        P: SqlxFactPredicates<B>,
330        M: OutcomeProjection<B, O>,
331    {
332        match node {
333            ResidualPolicyNode::Permit(outcome)
334            | ResidualPolicyNode::PermitWithTrace { outcome, .. } => Ok(SqlxLowered {
335                filter: SqlxFragment::trusted("TRUE"),
336                grade: self.projection.constant(outcome)?,
337            }),
338            ResidualPolicyNode::Deny | ResidualPolicyNode::DenyWithTrace { .. } => {
339                Ok(SqlxLowered {
340                    filter: SqlxFragment::trusted("FALSE"),
341                    grade: SqlxFragment::trusted("NULL"),
342                })
343            }
344            ResidualPolicyNode::Grant {
345                outcome, condition, ..
346            } => {
347                let filter = self.lower_condition(condition, cx)?;
348                let outcome = self.projection.constant(outcome)?;
349                Ok(SqlxLowered {
350                    filter: filter.clone(),
351                    grade: case_when(filter, outcome, SqlxFragment::trusted("NULL")),
352                })
353            }
354            ResidualPolicyNode::All { arms, .. } => {
355                let (filters, grades) = unzip_lowered(arms);
356                Ok(SqlxLowered {
357                    filter: fragment_set(filters, " AND ", "FALSE"),
358                    grade: grade_set::<B>(grades, B::MIN_FUNCTION),
359                })
360            }
361            ResidualPolicyNode::Any { arms, .. } => {
362                let (filters, grades) = unzip_lowered(arms);
363                Ok(SqlxLowered {
364                    filter: fragment_set(filters, " OR ", "FALSE"),
365                    grade: grade_set::<B>(grades, B::MAX_FUNCTION),
366                })
367            }
368            ResidualPolicyNode::OrElse {
369                fallback_policy,
370                primary,
371                fallback,
372                ..
373            } => {
374                if fallback_policy.carries_obligation() {
375                    return Ok(primary);
376                }
377
378                Ok(match fallback {
379                    Some(fallback) => SqlxLowered {
380                        filter: SqlxFragment::binary(
381                            " OR ",
382                            vec![primary.filter.clone(), fallback.filter],
383                        ),
384                        grade: case_when(primary.filter, primary.grade, fallback.grade),
385                    },
386                    None => primary,
387                })
388            }
389        }
390    }
391}
392
393impl<O, B, P, M> QueryLowering<O> for SqlxLowerer<B, P, M>
394where
395    B: GatekeepSqlxBackend,
396    P: SqlxFactPredicates<B>,
397    M: OutcomeProjection<B, O>,
398{
399    type Filter = SqlxFragment<B>;
400    type Projection = SqlxFragment<B>;
401
402    fn lower(
403        &self,
404        residual: &ResidualPolicy<O>,
405        cx: &Context,
406    ) -> Result<Lowered<Self::Filter, Self::Projection>, LowerError> {
407        let mut lowered = self.lower_policy(residual, cx)?;
408        lowered.filter = self.enforce_tenant_filter(lowered.filter, cx);
409        lowered.grade = self.enforce_tenant_projection(lowered.grade, cx);
410        Ok(Lowered {
411            filter: lowered.filter,
412            grade: lowered.grade,
413        })
414    }
415}
416
417fn lower_condition_set<B>(
418    conditions: &[Condition],
419    separator: &str,
420    empty: &str,
421    lower: impl FnMut(&Condition) -> Result<SqlxFragment<B>, LowerError>,
422) -> Result<SqlxFragment<B>, LowerError> {
423    if conditions.is_empty() {
424        return Ok(SqlxFragment::trusted(empty));
425    }
426
427    let fragments = conditions
428        .iter()
429        .map(lower)
430        .collect::<Result<Vec<_>, _>>()?;
431    Ok(SqlxFragment::binary(separator, fragments))
432}
433
434fn fragment_set<B>(
435    fragments: Vec<SqlxFragment<B>>,
436    separator: &str,
437    empty: &str,
438) -> SqlxFragment<B> {
439    if fragments.is_empty() {
440        SqlxFragment::trusted(empty)
441    } else {
442        SqlxFragment::binary(separator, fragments)
443    }
444}
445
446fn grade_set<B>(grades: Vec<SqlxFragment<B>>, function: &str) -> SqlxFragment<B>
447where
448    B: GatekeepSqlxBackend,
449{
450    match grades.len() {
451        0 => SqlxFragment::trusted("NULL"),
452        1 => grades
453            .into_iter()
454            .next()
455            .unwrap_or_else(|| SqlxFragment::trusted("NULL")),
456        _ if B::GRADE_FUNCTION_PROPAGATES_NULL => {
457            let mut iter = grades.into_iter();
458            let mut combined = iter.next().unwrap_or_else(|| SqlxFragment::trusted("NULL"));
459            for grade in iter {
460                combined = null_safe_grade_pair(function, combined, grade);
461            }
462            combined
463        }
464        _ => SqlxFragment::function(function, grades),
465    }
466}
467
468fn null_safe_grade_pair<B>(
469    function: &str,
470    left: SqlxFragment<B>,
471    right: SqlxFragment<B>,
472) -> SqlxFragment<B> {
473    let mut fragment = SqlxFragment::trusted("CASE WHEN ");
474    fragment.push_fragment(left.clone().wrapped());
475    fragment.push_sql(" IS NULL THEN ");
476    fragment.push_fragment(right.clone());
477    fragment.push_sql(" WHEN ");
478    fragment.push_fragment(right.clone().wrapped());
479    fragment.push_sql(" IS NULL THEN ");
480    fragment.push_fragment(left.clone());
481    fragment.push_sql(" ELSE ");
482    fragment.push_fragment(SqlxFragment::function(function, vec![left, right]));
483    fragment.push_sql(" END");
484    fragment
485}
486
487fn unzip_lowered<B>(lowered: Vec<SqlxLowered<B>>) -> (Vec<SqlxFragment<B>>, Vec<SqlxFragment<B>>) {
488    lowered
489        .into_iter()
490        .map(|lowered| (lowered.filter, lowered.grade))
491        .unzip()
492}
493
494fn case_when<B>(
495    condition: SqlxFragment<B>,
496    then_expr: SqlxFragment<B>,
497    else_expr: SqlxFragment<B>,
498) -> SqlxFragment<B> {
499    let mut fragment = SqlxFragment::trusted("CASE WHEN ");
500    fragment.push_fragment(condition);
501    fragment.push_sql(" THEN ");
502    fragment.push_fragment(then_expr);
503    fragment.push_sql(" ELSE ");
504    fragment.push_fragment(else_expr);
505    fragment.push_sql(" END");
506    fragment
507}
508
509fn is_true<B>(predicate: SqlxFragment<B>) -> SqlxFragment<B> {
510    let mut fragment = SqlxFragment::trusted("(");
511    fragment.push_fragment(predicate);
512    fragment.push_sql(") IS TRUE");
513    fragment
514}