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}