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