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    ATTEMPT_AUDIT_EVENT_TYPE, AttemptAuditEventError, DECISION_AUDIT_CONTENT_TYPE,
35    DECISION_AUDIT_EVENT_TYPE, DEFAULT_AUDIT_STREAM, DecisionAuditConfig, DecisionAuditConfigError,
36    DecisionAuditDecodeError, DecisionAuditEventError, LegacyDecisionAuditDecodeError,
37    decode_authorization_attempt, decode_decision_audit, 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    /// Lists all query-deferred facts without a trusted SQL mapping.
184    /// Request facts already resolved by partial evaluation are not reported.
185    #[must_use]
186    pub fn unmapped_facts<O>(&self, residual: &ResidualPolicy<O>, cx: &Context) -> Vec<FactId>
187    where
188        P: SqlxFactPredicates<B>,
189    {
190        gatekeep::required_residual_facts(residual)
191            .into_iter()
192            .filter(|fact| self.predicates.predicate(fact, cx).is_none())
193            .collect()
194    }
195
196    /// Lowers a partial-evaluation result with tenant guards on filter and grade.
197    ///
198    /// Both resolved and pending policies use this path. Apply the filter before
199    /// counting or paginating. SQL lowering does not produce per-row audit traces.
200    ///
201    /// # Errors
202    /// Returns missing-mapping or non-total-grade errors. Obligations remain
203    /// application-owned prerequisites; a SQL projection does not execute them.
204    pub fn lower_result<O>(
205        &self,
206        result: &gatekeep::Residual<O>,
207        cx: &Context,
208    ) -> Result<Lowered<SqlxFragment<B>, SqlxFragment<B>>, LowerError>
209    where
210        P: SqlxFactPredicates<B>,
211        M: OutcomeProjection<B, O>,
212    {
213        match result {
214            gatekeep::Residual::Pending { residual, .. } => self.lower(residual, cx),
215            gatekeep::Residual::Resolved(decision) => {
216                let (filter, grade) = match &decision.effect {
217                    gatekeep::Effect::Permit(outcome) => (
218                        SqlxFragment::trusted("TRUE"),
219                        self.projection.constant(outcome)?,
220                    ),
221                    gatekeep::Effect::Deny => (
222                        SqlxFragment::trusted("FALSE"),
223                        SqlxFragment::trusted("NULL"),
224                    ),
225                };
226                Ok(Lowered {
227                    filter: self.enforce_tenant_filter(filter, cx),
228                    grade: self.enforce_tenant_projection(grade, cx),
229                })
230            }
231        }
232    }
233
234    /// Lowers only the Boolean filter. This works for every outcome lattice.
235    ///
236    /// # Errors
237    ///
238    /// Returns [`LowerError::Unlowerable`] when a residual fact has no trusted
239    /// predicate mapping.
240    pub fn lower_filter<O>(
241        &self,
242        residual: &ResidualPolicy<O>,
243        cx: &Context,
244    ) -> Result<SqlxFragment<B>, LowerError>
245    where
246        P: SqlxFactPredicates<B>,
247    {
248        let policy = residual.try_fold_pruned(
249            &mut |branch| match branch {
250                ResidualPolicyBranch::OrElseFallback { fallback, .. } => {
251                    !fallback.carries_obligation()
252                }
253            },
254            &mut |node| self.lower_filter_node(node, cx),
255        )?;
256        Ok(self.enforce_tenant_filter(policy, cx))
257    }
258
259    fn lower_filter_node<O>(
260        &self,
261        node: ResidualPolicyNode<'_, O, SqlxFragment<B>>,
262        cx: &Context,
263    ) -> Result<SqlxFragment<B>, LowerError>
264    where
265        P: SqlxFactPredicates<B>,
266    {
267        match node {
268            ResidualPolicyNode::Permit(_) | ResidualPolicyNode::PermitWithTrace { .. } => {
269                Ok(SqlxFragment::trusted("TRUE"))
270            }
271            ResidualPolicyNode::Deny | ResidualPolicyNode::DenyWithTrace { .. } => {
272                Ok(SqlxFragment::trusted("FALSE"))
273            }
274            ResidualPolicyNode::Grant { condition, .. } => self.lower_condition(condition, cx),
275            ResidualPolicyNode::All { arms, .. } => Ok(fragment_set(arms, " AND ", "FALSE")),
276            ResidualPolicyNode::Any { arms, .. } => Ok(fragment_set(arms, " OR ", "FALSE")),
277            ResidualPolicyNode::OrElse {
278                fallback_policy,
279                primary,
280                fallback,
281                ..
282            } => {
283                if fallback_policy.carries_obligation() {
284                    Ok(primary)
285                } else {
286                    Ok(match fallback {
287                        Some(fallback) => SqlxFragment::binary(" OR ", vec![primary, fallback]),
288                        None => primary,
289                    })
290                }
291            }
292        }
293    }
294
295    fn lower_condition(
296        &self,
297        condition: &Condition,
298        cx: &Context,
299    ) -> Result<SqlxFragment<B>, LowerError>
300    where
301        P: SqlxFactPredicates<B>,
302    {
303        match condition {
304            Condition::Always => Ok(SqlxFragment::trusted("TRUE")),
305            Condition::Never => Ok(SqlxFragment::trusted("FALSE")),
306            Condition::Has(fact) => self
307                .predicates
308                .predicate(fact, cx)
309                .map(is_true)
310                .ok_or_else(|| LowerError::Unlowerable(fact.clone())),
311            Condition::Not(inner) => Ok(SqlxFragment::unary(
312                "NOT ",
313                self.lower_condition(inner, cx)?,
314            )),
315            Condition::All(conditions) => {
316                lower_condition_set(conditions, " AND ", "FALSE", |item| {
317                    self.lower_condition(item, cx)
318                })
319            }
320            Condition::Any(conditions) => {
321                lower_condition_set(conditions, " OR ", "FALSE", |item| {
322                    self.lower_condition(item, cx)
323                })
324            }
325        }
326    }
327
328    /// Adds the mandatory typed tenant predicate to an already-built filter.
329    ///
330    /// `lower` and `lower_filter` call this automatically. This method is
331    /// public for applications that have already resolved a policy in memory
332    /// and need to combine its constant filter with the same tenant safety
333    /// contract.
334    #[must_use]
335    pub fn enforce_tenant_filter(&self, policy: SqlxFragment<B>, cx: &Context) -> SqlxFragment<B> {
336        let mut tenant = SqlxFragment::trusted(self.tenant_column.qualified());
337        tenant.push_sql(" = ");
338        tenant.push_fragment(SqlxFragment::bind(cx.tenant().as_str()));
339        SqlxFragment::binary(" AND ", [tenant, policy])
340    }
341
342    /// Adds the mandatory tenant guard to a manually constructed grade
343    /// projection. Callers that consume a [`gatekeep::Lowered`] from an
344    /// in-memory resolved branch must apply this before selecting the grade;
345    /// [`Self::lower`] applies it automatically.
346    #[must_use]
347    pub fn enforce_tenant_projection(
348        &self,
349        grade: SqlxFragment<B>,
350        cx: &Context,
351    ) -> SqlxFragment<B> {
352        let tenant = self.enforce_tenant_filter(SqlxFragment::trusted("TRUE"), cx);
353        case_when(tenant, grade, SqlxFragment::trusted("NULL"))
354    }
355
356    fn lower_policy<O>(
357        &self,
358        residual: &ResidualPolicy<O>,
359        cx: &Context,
360    ) -> Result<SqlxLowered<B>, LowerError>
361    where
362        P: SqlxFactPredicates<B>,
363        M: OutcomeProjection<B, O>,
364    {
365        residual.try_fold_pruned(
366            &mut |branch| match branch {
367                ResidualPolicyBranch::OrElseFallback { fallback, .. } => {
368                    !fallback.carries_obligation()
369                }
370            },
371            &mut |node| self.lower_node(node, cx),
372        )
373    }
374
375    fn lower_node<O>(
376        &self,
377        node: ResidualPolicyNode<'_, O, SqlxLowered<B>>,
378        cx: &Context,
379    ) -> Result<SqlxLowered<B>, LowerError>
380    where
381        P: SqlxFactPredicates<B>,
382        M: OutcomeProjection<B, O>,
383    {
384        match node {
385            ResidualPolicyNode::Permit(outcome)
386            | ResidualPolicyNode::PermitWithTrace { outcome, .. } => Ok(SqlxLowered {
387                filter: SqlxFragment::trusted("TRUE"),
388                grade: self.projection.constant(outcome)?,
389            }),
390            ResidualPolicyNode::Deny | ResidualPolicyNode::DenyWithTrace { .. } => {
391                Ok(SqlxLowered {
392                    filter: SqlxFragment::trusted("FALSE"),
393                    grade: SqlxFragment::trusted("NULL"),
394                })
395            }
396            ResidualPolicyNode::Grant {
397                outcome, condition, ..
398            } => {
399                let filter = self.lower_condition(condition, cx)?;
400                let outcome = self.projection.constant(outcome)?;
401                Ok(SqlxLowered {
402                    filter: filter.clone(),
403                    grade: case_when(filter, outcome, SqlxFragment::trusted("NULL")),
404                })
405            }
406            ResidualPolicyNode::All { arms, .. } => {
407                let (filters, grades) = unzip_lowered(arms);
408                Ok(SqlxLowered {
409                    filter: fragment_set(filters, " AND ", "FALSE"),
410                    grade: grade_set::<B>(grades, B::MIN_FUNCTION),
411                })
412            }
413            ResidualPolicyNode::Any { arms, .. } => {
414                let (filters, grades) = unzip_lowered(arms);
415                Ok(SqlxLowered {
416                    filter: fragment_set(filters, " OR ", "FALSE"),
417                    grade: grade_set::<B>(grades, B::MAX_FUNCTION),
418                })
419            }
420            ResidualPolicyNode::OrElse {
421                fallback_policy,
422                primary,
423                fallback,
424                ..
425            } => {
426                if fallback_policy.carries_obligation() {
427                    return Ok(primary);
428                }
429
430                Ok(match fallback {
431                    Some(fallback) => SqlxLowered {
432                        filter: SqlxFragment::binary(
433                            " OR ",
434                            vec![primary.filter.clone(), fallback.filter],
435                        ),
436                        grade: case_when(primary.filter, primary.grade, fallback.grade),
437                    },
438                    None => primary,
439                })
440            }
441        }
442    }
443}
444
445impl<O, B, P, M> QueryLowering<O> for SqlxLowerer<B, P, M>
446where
447    B: GatekeepSqlxBackend,
448    P: SqlxFactPredicates<B>,
449    M: OutcomeProjection<B, O>,
450{
451    type Filter = SqlxFragment<B>;
452    type Projection = SqlxFragment<B>;
453
454    fn lower(
455        &self,
456        residual: &ResidualPolicy<O>,
457        cx: &Context,
458    ) -> Result<Lowered<Self::Filter, Self::Projection>, LowerError> {
459        let mut lowered = self.lower_policy(residual, cx)?;
460        lowered.filter = self.enforce_tenant_filter(lowered.filter, cx);
461        lowered.grade = self.enforce_tenant_projection(lowered.grade, cx);
462        Ok(Lowered {
463            filter: lowered.filter,
464            grade: lowered.grade,
465        })
466    }
467}
468
469fn lower_condition_set<B>(
470    conditions: &[Condition],
471    separator: &str,
472    empty: &str,
473    lower: impl FnMut(&Condition) -> Result<SqlxFragment<B>, LowerError>,
474) -> Result<SqlxFragment<B>, LowerError> {
475    if conditions.is_empty() {
476        return Ok(SqlxFragment::trusted(empty));
477    }
478
479    let fragments = conditions
480        .iter()
481        .map(lower)
482        .collect::<Result<Vec<_>, _>>()?;
483    Ok(SqlxFragment::binary(separator, fragments))
484}
485
486fn fragment_set<B>(
487    fragments: Vec<SqlxFragment<B>>,
488    separator: &str,
489    empty: &str,
490) -> SqlxFragment<B> {
491    if fragments.is_empty() {
492        SqlxFragment::trusted(empty)
493    } else {
494        SqlxFragment::binary(separator, fragments)
495    }
496}
497
498fn grade_set<B>(grades: Vec<SqlxFragment<B>>, function: &str) -> SqlxFragment<B>
499where
500    B: GatekeepSqlxBackend,
501{
502    match grades.len() {
503        0 => SqlxFragment::trusted("NULL"),
504        1 => grades
505            .into_iter()
506            .next()
507            .unwrap_or_else(|| SqlxFragment::trusted("NULL")),
508        _ if B::GRADE_FUNCTION_PROPAGATES_NULL => {
509            let mut iter = grades.into_iter();
510            let mut combined = iter.next().unwrap_or_else(|| SqlxFragment::trusted("NULL"));
511            for grade in iter {
512                combined = null_safe_grade_pair(function, combined, grade);
513            }
514            combined
515        }
516        _ => SqlxFragment::function(function, grades),
517    }
518}
519
520fn null_safe_grade_pair<B>(
521    function: &str,
522    left: SqlxFragment<B>,
523    right: SqlxFragment<B>,
524) -> SqlxFragment<B> {
525    let mut fragment = SqlxFragment::trusted("CASE WHEN ");
526    fragment.push_fragment(left.clone().wrapped());
527    fragment.push_sql(" IS NULL THEN ");
528    fragment.push_fragment(right.clone());
529    fragment.push_sql(" WHEN ");
530    fragment.push_fragment(right.clone().wrapped());
531    fragment.push_sql(" IS NULL THEN ");
532    fragment.push_fragment(left.clone());
533    fragment.push_sql(" ELSE ");
534    fragment.push_fragment(SqlxFragment::function(function, vec![left, right]));
535    fragment.push_sql(" END");
536    fragment
537}
538
539fn unzip_lowered<B>(lowered: Vec<SqlxLowered<B>>) -> (Vec<SqlxFragment<B>>, Vec<SqlxFragment<B>>) {
540    lowered
541        .into_iter()
542        .map(|lowered| (lowered.filter, lowered.grade))
543        .unzip()
544}
545
546fn case_when<B>(
547    condition: SqlxFragment<B>,
548    then_expr: SqlxFragment<B>,
549    else_expr: SqlxFragment<B>,
550) -> SqlxFragment<B> {
551    let mut fragment = SqlxFragment::trusted("CASE WHEN ");
552    fragment.push_fragment(condition);
553    fragment.push_sql(" THEN ");
554    fragment.push_fragment(then_expr);
555    fragment.push_sql(" ELSE ");
556    fragment.push_fragment(else_expr);
557    fragment.push_sql(" END");
558    fragment
559}
560
561fn is_true<B>(predicate: SqlxFragment<B>) -> SqlxFragment<B> {
562    let mut fragment = SqlxFragment::trusted("(");
563    fragment.push_fragment(predicate);
564    fragment.push_sql(") IS TRUE");
565    fragment
566}