Skip to main content

cratestack_sqlx/record_policy/
open_check.rs

1//! The open-time half of a subscription's read policy
2//! (`docs/design/subscriptions-websocket.md` ยง5.1): before a feed opens,
3//! decide whether this caller could read *any* row at all. If no row could
4//! pass, the subscription is refused (`unauthenticated` for an anonymous
5//! caller, else `permission_denied`) instead of opening a feed that will
6//! never deliver anything.
7//!
8//! Each predicate is reduced to the set of truth values it can take over all
9//! rows: a predicate on the caller alone (`auth() != null`, `hasRole(..)`) has
10//! exactly one, decided now; a predicate on a column has all three, unless it
11//! compares the column with an `auth()` claim the caller lacks, which the SQL
12//! path renders as `FALSE` for every row. The sets combine pairwise under
13//! Kleene's `and`/`or`. Treating predicates as independent over-approximates
14//! what is possible, so this can open a feed that will deliver nothing; it
15//! never refuses one that could deliver something.
16
17use cratestack_core::{CratestackContext, CratestackError};
18
19use crate::query::{Truth, auth_value_to_sql, evaluate_input_truth};
20use crate::{PolicyExpr, ReadPolicy, ReadPredicate};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23struct Possible(u8);
24
25impl Possible {
26    const TRUE: u8 = 1;
27    const FALSE: u8 = 2;
28    const UNKNOWN: u8 = 4;
29    const ANY: Self = Self(Self::TRUE | Self::FALSE | Self::UNKNOWN);
30
31    fn of(truth: Truth) -> Self {
32        Self(match truth {
33            Truth::True => Self::TRUE,
34            Truth::False => Self::FALSE,
35            Truth::Unknown => Self::UNKNOWN,
36        })
37    }
38
39    fn values(self) -> impl Iterator<Item = Truth> {
40        [
41            (Self::TRUE, Truth::True),
42            (Self::FALSE, Truth::False),
43            (Self::UNKNOWN, Truth::Unknown),
44        ]
45        .into_iter()
46        .filter(move |(bit, _)| self.0 & bit != 0)
47        .map(|(_, truth)| truth)
48    }
49
50    fn combine(self, other: Self, op: fn(Truth, Truth) -> Truth) -> Self {
51        let mut out = 0;
52        for a in self.values() {
53            for b in other.values() {
54                out |= Self::of(op(a, b)).0;
55            }
56        }
57        Self(out)
58    }
59
60    fn can_be(self, truth: Truth) -> bool {
61        self.0 & Self::of(truth).0 != 0
62    }
63}
64
65fn possible(expr: PolicyExpr, ctx: &CratestackContext) -> Possible {
66    match expr {
67        PolicyExpr::Predicate(predicate) => predicate_possible(predicate, ctx),
68        PolicyExpr::And(exprs) => exprs.iter().fold(Possible::of(Truth::True), |acc, expr| {
69            acc.combine(possible(*expr, ctx), Truth::and)
70        }),
71        PolicyExpr::Or(exprs) => exprs.iter().fold(Possible::of(Truth::False), |acc, expr| {
72            acc.combine(possible(*expr, ctx), Truth::or)
73        }),
74    }
75}
76
77fn predicate_possible(predicate: ReadPredicate, ctx: &CratestackContext) -> Possible {
78    match predicate {
79        ReadPredicate::FieldEqAuth { auth_field, .. }
80        | ReadPredicate::FieldNeAuth { auth_field, .. } => {
81            if auth_value_to_sql(ctx, auth_field).is_some() {
82                Possible::ANY
83            } else {
84                Possible::of(Truth::False)
85            }
86        }
87        // `EXISTS` is never NULL.
88        ReadPredicate::Relation { .. } => Possible(Possible::TRUE | Possible::FALSE),
89        ReadPredicate::FieldIsTrue { .. }
90        | ReadPredicate::FieldEqLiteral { .. }
91        | ReadPredicate::FieldNeLiteral { .. }
92        | ReadPredicate::FieldInLiterals { .. }
93        | ReadPredicate::FieldNotInLiterals { .. } => Possible::ANY,
94        ReadPredicate::AuthNotNull
95        | ReadPredicate::AuthIsNull
96        | ReadPredicate::AuthIsSystem
97        | ReadPredicate::HasRole { .. }
98        | ReadPredicate::InTenant { .. }
99        | ReadPredicate::AuthFieldEqLiteral { .. }
100        | ReadPredicate::AuthFieldNeLiteral { .. } => {
101            Possible::of(evaluate_input_truth(predicate, &[], ctx))
102        }
103    }
104}
105
106/// Whether some row could pass `allow`/`deny` for `ctx`: an `@allow` can be
107/// `True`, and no `@deny` fires on every row (it fires on anything but
108/// `False`). An empty allow list denies, as on every read.
109pub fn may_read_some_row(
110    allow: &[ReadPolicy],
111    deny: &[ReadPolicy],
112    ctx: &CratestackContext,
113) -> bool {
114    let allowed = allow
115        .iter()
116        .any(|policy| possible(policy.expr, ctx).can_be(Truth::True));
117    let always_denied = deny
118        .iter()
119        .any(|policy| !possible(policy.expr, ctx).can_be(Truth::False));
120    allowed && !always_denied
121}
122
123/// Refuses a subscription to `model` that could never deliver a row.
124pub fn refuse_unreadable_subscription(
125    model: &str,
126    allow: &[ReadPolicy],
127    deny: &[ReadPolicy],
128    ctx: &CratestackContext,
129) -> Result<(), CratestackError> {
130    if may_read_some_row(allow, deny, ctx) {
131        Ok(())
132    } else if !ctx.is_authenticated() {
133        Err(CratestackError::Unauthorized(format!(
134            "subscribing to `{model}` requires authentication"
135        )))
136    } else {
137        Err(CratestackError::Forbidden(format!(
138            "no `{model}` record is readable by this caller"
139        )))
140    }
141}
142
143#[cfg(test)]
144mod tests;