1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
use std::sync::Arc;
use anyhow::Result;
use reblessive::tree::Stk;
use tracing::instrument;
use crate::catalog::{FieldDefinition, Permission};
use crate::ctx::{Context, FrozenContext};
use crate::dbs::Options;
use crate::doc::{CursorDoc, Document};
use crate::exe::FlowResultExt as _;
use crate::iam::{Action, AuthLimit};
impl Document {
/// Checks if reduction is required
///
/// This function checks if reduction is required for a document based on the following
/// criteria:
/// - The document has an ID and is not a temporary document
/// - The current actor needs permission to be processed
#[inline]
pub(crate) fn reduction_required(&self, ctx: &FrozenContext, opt: &Options) -> Result<bool> {
// Check if this record exists
if self.id.is_none() {
return Ok(false);
}
// Are permissions being skipped?
if !ctx.check_perms(opt, Action::View)? {
return Ok(false);
}
// Reduction is required
Ok(true)
}
/// Reduces `self.current` based on field-level select permissions and
/// returns a reference to the materialised reduced view. Caches the
/// result in `self.current_reduced` so repeated calls in the same
/// pipeline are cheap; callers that mutate `self.current.doc` are
/// responsible for invalidating the cache (e.g.
/// `process_record_data` clears it after applying the data clause).
///
/// When reduction is not required (owner sessions / perms disabled)
/// the function short-circuits and returns `&mut self.current` so
/// callers can use one borrow regardless of whether reduction kicked
/// in.
pub(crate) async fn reduce_current(
&mut self,
stk: &mut Stk,
ctx: &FrozenContext,
opt: &Options,
) -> Result<&mut CursorDoc> {
// Check if we need to reduce the document
if self.reduction_required(ctx, opt)? {
// Compute the reduced document if not yet done
if self.current_reduced.is_none() {
self.current_reduced =
Some(self.reduce_document(stk, ctx, opt, &self.current).await?);
}
// Return the reduced document which was just populated above
self.current_reduced
.as_mut()
.ok_or_else(|| anyhow::anyhow!("current_reduced should be set"))
} else {
Ok(&mut self.current)
}
}
/// Reduces `self.initial` based on field-level select permissions and
/// returns a reference to the materialised reduced view. Mirrors
/// [`Self::reduce_current`] but for the pre-mutation document — used
/// by `RETURN BEFORE` / `RETURN DIFF` / `RETURN <fields>` output
/// paths, and by the LIVE-query DELETE branch where the visible
/// payload is the pre-delete record.
///
/// Caches the result in `self.initial_reduced`. `self.initial` is
/// immutable after the document is constructed, so callers don't
/// need to invalidate this cache once it's been populated for the
/// row.
pub(crate) async fn reduce_initial(
&mut self,
stk: &mut Stk,
ctx: &FrozenContext,
opt: &Options,
) -> Result<&mut CursorDoc> {
// Check if we need to reduce the document
if self.reduction_required(ctx, opt)? {
// Compute the reduced document if not yet done
if self.initial_reduced.is_none() {
self.initial_reduced =
Some(self.reduce_document(stk, ctx, opt, &self.initial).await?);
}
// Return the reduced document which was just populated above
self.initial_reduced
.as_mut()
.ok_or_else(|| anyhow::anyhow!("initial_reduced should be set"))
} else {
Ok(&mut self.initial)
}
}
/// Reduces an arbitrary [`CursorDoc`] (typically a fresh local — not
/// one of `self.initial` / `self.current`) and returns the owned
/// reduced view. When reduction is not required this just clones
/// `full`, so callers can write
///
/// ```ignore
/// let local = self.reduce_to_owned(stk, ctx, opt, &self.current).await?;
/// // …mutate `local` without touching the cache…
/// ```
///
/// without having to repeat the `reduction_required` check at every
/// call site. Used by `lq_compute` (live-query payload projection),
/// which needs a transient reduced view that does not pollute
/// `self.{current,initial}_reduced` (the per-subscription doc is
/// then mutated independently of the cached views).
pub(crate) async fn reduce_to_owned(
&self,
stk: &mut Stk,
ctx: &FrozenContext,
opt: &Options,
full: &CursorDoc,
) -> Result<CursorDoc> {
if self.reduction_required(ctx, opt)? {
self.reduce_document(stk, ctx, opt, full).await
} else {
Ok(full.clone())
}
}
/// Apply the COMPUTED fields' own `PERMISSIONS FOR select` to every
/// permission-reduced view this document has materialised.
///
/// The reduce kernel cannot do this itself — it runs before any computed
/// field exists — so every site that populates computed fields on a
/// reduced view has to call this before handing that view to a
/// user-supplied expression. Without it the caller reads a value their
/// permissions deny, and a write clause can copy that value into a field
/// they are allowed to select.
///
/// Unreduced views are left alone: one is only ever handed to a session
/// that bypasses field-level permissions entirely.
pub(super) async fn filter_reduced_computed_fields(
&mut self,
stk: &mut Stk,
ctx: &FrozenContext,
opt: &Options,
) -> Result<()> {
// Nothing to filter when neither reduced view was materialised
if self.current_reduced.is_none() && self.initial_reduced.is_none() {
return Ok(());
}
// Cloning the handle ends the borrow on `self` before the views below
// are borrowed mutably
let fields = Arc::clone(self.doc_ctx.fd()?);
// Each reduced view is filtered against the record it was reduced from,
// so a permission predicate reads the whole record rather than the view
// its own clause is about to cut from.
if let Some(doc) = self.current_reduced.as_mut() {
Document::filter_computed_field_permissions(stk, ctx, opt, &fields, &self.current, doc)
.await?;
}
if let Some(doc) = self.initial_reduced.as_mut() {
Document::filter_computed_field_permissions(stk, ctx, opt, &fields, &self.initial, doc)
.await?;
}
Ok(())
}
/// Apply `PERMISSIONS FOR select` to the COMPUTED fields of `doc`
/// after they have been populated by
/// [`Document::computed_fields_inner`]. The reduce kernel
/// ([`Self::reduce_document`]) runs *before* computed fields exist,
/// so without this step a reader without permission to read a computed
/// field still receives its value: in a LIVE notification (and, for
/// `StoredSubscriptionFields::Diff`, in the patch ops), in a projection
/// that renames it, or in whatever a write clause copies it into.
/// Stored fields are intentionally skipped here — they were already
/// filtered by `reduce_document`.
///
/// `full` is the record `doc` was reduced from, and every `PERMISSIONS FOR
/// select` predicate is evaluated against it. SECURITY: a predicate must
/// read the record, not the reduced view — `doc` is missing the fields the
/// caller may not select, so a clause like `WHERE secret = NONE` would hold
/// on it for a record whose `secret` is set, and pass a computed field the
/// caller was meant to be denied. This mirrors `reduce_document`, which
/// evaluates a stored field's clause against `full` for the same reason.
/// Only the field's own value and the paths it occupies come from `doc`,
/// which is the sole view a computed field exists on.
#[instrument(level = "trace", target = "surrealdb::core::doc::reduce", skip_all)]
pub(crate) async fn filter_computed_field_permissions(
stk: &mut Stk,
ctx: &FrozenContext,
opt: &Options,
fields: &[FieldDefinition],
full: &CursorDoc,
doc: &mut CursorDoc,
) -> Result<()> {
// If permissions are disabled, nothing to do.
if !ctx.check_perms(opt, Action::View)? {
return Ok(());
}
// Skip unless some computed field actually restricts reads; avoids the
// `doc.clone()` and the field-iteration on wide schemas, and on the
// ones whose computed fields are all readable. A field with no
// `COMPUTED` clause was already filtered by `reduce_document`.
if !fields
.iter()
.any(|fd| fd.computed.is_some() && !matches!(fd.select_permission, Permission::Full))
{
return Ok(());
}
// Snapshot once; cuts accumulate on `doc`, but `each` and the `$value`
// pick both read from the snapshot so later cuts don't perturb earlier
// evaluations. The predicates themselves read `full`, which no cut here
// touches.
let original = doc.clone();
for fd in fields.iter() {
// Only filter computed fields here; stored fields were
// already handled by `reduce_document`.
if fd.computed.is_none() {
continue;
}
// SECURITY: apply the field's AUTH LIMIT before evaluating
// PERMISSIONS FOR select so the predicate runs under the
// definer's downgraded auth, not the caller's.
let opt = opt.limited_by(&AuthLimit::try_from(&fd.auth_limit)?);
// SECURITY: `each` yields paths in ascending array-index order, and
// `Value::cut` removes an element via `Vec::remove`, which shifts
// every later index down. Cutting in forward order would make each
// removal invalidate the remaining indices, leaking the odd-indexed
// elements (issue #7356). Iterate in reverse so a higher index is
// always removed before any lower index that is still pending.
// Predicates read `full` and `original`, both immutable here, so
// evaluation order is irrelevant.
match &fd.select_permission {
Permission::Full => (),
Permission::None => {
for k in original.doc.as_ref().each(&fd.name).iter().rev() {
doc.doc.to_mut().cut(k);
}
}
Permission::Specific(e) => {
for k in original.doc.as_ref().each(&fd.name).iter().rev() {
// Disable permission recursion and block side effects
let opt = &opt.new_for_permission_predicate();
// Get the computed value, which only the reduced view holds
let val = Arc::new(original.doc.as_ref().pick(k));
// Configure the context
let mut child_ctx = Context::new_child(ctx);
child_ctx.add_value("value", val);
let child_ctx = child_ctx.freeze();
// Process the PERMISSION clause against the whole record
if !stk
.run(|stk| {
crate::legacy::expr_compute(e, stk, &child_ctx, opt, Some(full))
})
.await
.catch_return()?
.is_truthy()
{
doc.doc.to_mut().cut(k);
}
}
}
}
}
Ok(())
}
/// Reduce-the-fields kernel. Iterates the table's field definitions
/// and cuts each field from a clone of `full.doc` based on its
/// `PERMISSIONS FOR select` clause. The four public entry points
/// ([`Self::reduce_current`], [`Self::reduce_initial`],
/// [`Self::reduce_to_owned`]) layer caching / source-picking on top.
async fn reduce_document(
&self,
stk: &mut Stk,
ctx: &FrozenContext,
opt: &Options,
full: &CursorDoc,
) -> Result<CursorDoc> {
// The document to be reduced
let mut reduced = full.doc.clone();
// Loop over each field in document
for fd in self.doc_ctx.fd()?.iter() {
// SECURITY: apply the field's AUTH LIMIT before evaluating
// PERMISSIONS FOR select so the predicate runs under the
// definer's downgraded auth, not the caller's. Mirrors the
// pluck.rs / field.rs paths.
let opt = opt.limited_by(&AuthLimit::try_from(&fd.auth_limit)?);
// Process the field permissions. SECURITY: iterate in reverse —
// `each` yields ascending array indices and `Value::cut` removes via
// `Vec::remove` (shifting later indices down), so a forward pass
// would let each removal invalidate the pending indices and leak the
// odd-indexed elements (issue #7356). Removing higher indices first
// keeps the not-yet-processed lower indices valid; predicates read
// from the immutable `full`, so evaluation order is irrelevant.
match &fd.select_permission {
Permission::Full => (),
Permission::None => {
for k in reduced.as_ref().each(&fd.name).iter().rev() {
reduced.to_mut().cut(k);
}
}
Permission::Specific(e) => {
for k in reduced.as_ref().each(&fd.name).iter().rev() {
// Disable permission recursion and block side effects
let opt = &opt.new_for_permission_predicate();
// Get the initial value
let val = Arc::new(full.doc.as_ref().pick(k));
// Configure the context
let mut ctx = Context::new_child(ctx);
ctx.add_value("value", val);
let ctx = ctx.freeze();
// Process the PERMISSION clause
if !stk
.run(|stk| crate::legacy::expr_compute(e, stk, &ctx, opt, Some(full)))
.await
.catch_return()?
.is_truthy()
{
reduced.to_mut().cut(k);
}
}
}
}
}
// Ok
Ok(CursorDoc::new(full.rid.clone(), full.ir.clone(), reduced))
}
}