Skip to main content

nodedb_types/
rls_write_check.rs

1// SPDX-License-Identifier: BUSL-1.1
2
3//! The compiled row-level-security WRITE predicate carried on a write plan.
4//!
5//! A bare `Vec<u8>` could not separate "no policy restricts this write" from
6//! "the predicate was lost in transit". Both read as empty, and the second
7//! silently disables the policy. Each value here names which case it is.
8//!
9//! No `Default`: a write plan cannot carry an unexplained empty check.
10
11use serde::{Deserialize, Serialize};
12
13/// What the Data Plane gate must do with one write's policy check.
14///
15/// Exhaustive, so a caller cannot handle the admit cases and forget the deny.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum WriteGateDecision<'a> {
18    /// Admit every row without evaluating anything.
19    AdmitAll,
20    /// Evaluate these compiled predicate bytes against each row image.
21    Evaluate(&'a [u8]),
22    /// Deny. The plan reached the gate before RLS injection ran over it.
23    /// This is a bug in the write path, not a policy outcome.
24    DenyNotInjected,
25}
26
27/// The write-policy check attached to one write operation.
28#[derive(
29    Debug,
30    Clone,
31    PartialEq,
32    Eq,
33    Serialize,
34    Deserialize,
35    zerompk::ToMessagePack,
36    zerompk::FromMessagePack,
37)]
38pub enum RlsWriteCheck {
39    /// Compiled predicate bytes, decoded as `Vec<ScanFilter>` and evaluated
40    /// against each row image.
41    Predicate(Vec<u8>),
42
43    /// Injection ran and found no write policy for this identity here.
44    NoPolicyApplies,
45
46    /// Follower apply, or WAL replay after a crash.
47    ///
48    /// The authoring identity is absent, so re-deciding could deny a write the
49    /// leader already committed and diverge the replicas. Admits on purpose.
50    ///
51    /// Only follower-apply and replay code may build this. Never build it where
52    /// a live writing identity exists.
53    AlreadyDecidedElsewhere,
54
55    /// This request already ran the policy over this exact row image, with a
56    /// live identity. That live identity separates it from
57    /// [`RlsWriteCheck::AlreadyDecidedElsewhere`].
58    ///
59    /// Only build this where the earlier decision provably covered the same
60    /// image. If the op can touch a row that check did not see, it is not this.
61    DecidedEarlierInRequest,
62
63    /// A collection NodeDB maintains for itself, which no user creates and no
64    /// policy attaches to — rate-limit bookkeeping, for example.
65    ///
66    /// These never run injection, so they cannot claim `NoPolicyApplies`.
67    SystemInternalCollection,
68
69    /// Built, but injection has not run yet. Transient, Control Plane only.
70    ///
71    /// If it reaches the gate, the write is DENIED — a missed injection fails
72    /// loudly instead of quietly disabling the policy.
73    PendingInjection,
74}
75
76impl RlsWriteCheck {
77    /// Build the check from the policy compiler's output.
78    ///
79    /// Empty bytes become [`RlsWriteCheck::NoPolicyApplies`], never an empty
80    /// predicate, so "no policy" cannot later read as "predicate lost".
81    ///
82    /// Call this only from the RLS injection pass.
83    pub fn from_injected(predicate_bytes: Vec<u8>) -> Self {
84        if predicate_bytes.is_empty() {
85            RlsWriteCheck::NoPolicyApplies
86        } else {
87            RlsWriteCheck::Predicate(predicate_bytes)
88        }
89    }
90
91    /// Bypass for follower apply or WAL replay.
92    ///
93    /// Never use it to silence a compile error where a live writing identity
94    /// exists — [`RlsWriteCheck::pending_injection`] fails closed there.
95    pub fn already_decided_elsewhere() -> Self {
96        RlsWriteCheck::AlreadyDecidedElsewhere
97    }
98
99    /// Bypass for rows this same request already admitted. Only when the
100    /// earlier check covered the same row image.
101    pub fn decided_earlier_in_request() -> Self {
102        RlsWriteCheck::DecidedEarlierInRequest
103    }
104
105    /// Bypass for a write to one of NodeDB's own internal collections.
106    pub fn system_internal_collection() -> Self {
107        RlsWriteCheck::SystemInternalCollection
108    }
109
110    /// Placeholder between plan construction and injection.
111    ///
112    /// Safe wherever the right value is not yet known: it fails closed, and the
113    /// dispatch boundary rejects any write plan still holding it.
114    pub fn pending_injection() -> Self {
115        RlsWriteCheck::PendingInjection
116    }
117
118    /// What the Data Plane gate must do with this check.
119    pub fn decision(&self) -> WriteGateDecision<'_> {
120        match self {
121            RlsWriteCheck::Predicate(bytes) => WriteGateDecision::Evaluate(bytes),
122            RlsWriteCheck::NoPolicyApplies
123            | RlsWriteCheck::AlreadyDecidedElsewhere
124            | RlsWriteCheck::DecidedEarlierInRequest
125            | RlsWriteCheck::SystemInternalCollection => WriteGateDecision::AdmitAll,
126            RlsWriteCheck::PendingInjection => WriteGateDecision::DenyNotInjected,
127        }
128    }
129
130    /// True only when a compiled predicate is attached — "does a policy
131    /// restrict this identity here". Not a gate decision; see
132    /// [`RlsWriteCheck::decision`].
133    pub fn has_predicate(&self) -> bool {
134        matches!(self, RlsWriteCheck::Predicate(_))
135    }
136
137    /// True while the plan has not been through injection. The dispatch
138    /// boundary uses it to refuse un-injected writes.
139    pub fn is_pending_injection(&self) -> bool {
140        matches!(self, RlsWriteCheck::PendingInjection)
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn empty_compiler_output_is_no_policy_not_an_empty_predicate() {
150        assert_eq!(
151            RlsWriteCheck::from_injected(Vec::new()),
152            RlsWriteCheck::NoPolicyApplies
153        );
154        assert!(!RlsWriteCheck::from_injected(Vec::new()).has_predicate());
155    }
156
157    #[test]
158    fn compiled_bytes_become_a_predicate_the_gate_evaluates() {
159        let check = RlsWriteCheck::from_injected(vec![1, 2, 3]);
160        assert!(check.has_predicate());
161        assert_eq!(check.decision(), WriteGateDecision::Evaluate(&[1, 2, 3]));
162    }
163
164    #[test]
165    fn every_bypass_admits_every_row() {
166        for check in [
167            RlsWriteCheck::NoPolicyApplies,
168            RlsWriteCheck::already_decided_elsewhere(),
169            RlsWriteCheck::decided_earlier_in_request(),
170            RlsWriteCheck::system_internal_collection(),
171        ] {
172            assert_eq!(
173                check.decision(),
174                WriteGateDecision::AdmitAll,
175                "{check:?} must admit"
176            );
177            assert!(!check.has_predicate(), "{check:?} carries no predicate");
178        }
179    }
180
181    /// The whole point of the type: a plan that never went through injection
182    /// must be denied, not admitted the way a bare empty `Vec<u8>` would be.
183    #[test]
184    fn an_uninjected_plan_is_denied_rather_than_admitted() {
185        let check = RlsWriteCheck::pending_injection();
186        assert_eq!(check.decision(), WriteGateDecision::DenyNotInjected);
187        assert!(check.is_pending_injection());
188        assert!(!check.has_predicate());
189    }
190}