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