parse_rust_core/clp.rs
1//! Class-level permissions: the model, and nothing that evaluates it.
2//!
3//! CLP lives here rather than in `parse-rust-schema` for the same reason [`crate::acl`] does. It is
4//! an authorization data model with a JSON encoding and no I/O, and both the schema crate (which
5//! validates it) and the REST crate (which enforces it) need the type. Validation messages and
6//! evaluation both live above this crate.
7//!
8//! Three shapes here are load-bearing, and each one fails as a security defect rather than as a
9//! test failure if it is modelled the obvious way instead of upstream's way.
10//!
11//! **CLP is default-open.** `testPermissions` allows when `classPermissions[operation]` is
12//! *falsy* (`SchemaController.js:1365-1382`): an absent operation entry means unrestricted, not
13//! denied. That is why [`ClassLevelPermissions::op`] returns `Option<&OpPerm>` with `None`
14//! meaning unrestricted, and why **[`OpPerm`] deliberately has no `Default` impl**. A `Default`
15//! would be an empty entity set, which is deny-all, so a refactor that reached for
16//! `unwrap_or_default()` would invert the rule and lock every existing database out.
17//!
18//! **The two entity grammars are not interchangeable.** Operations and `addField` accept
19//! `pointerFields`, `*`, `requiresAuthentication`, `role:<name>` and an objectId
20//! (`validatePermissionKey`, `SchemaController.js:218-235`). `protectedFields` accepts
21//! `userField:<name>`, `*`, `authenticated`, `role:<name>` and an objectId
22//! (`validateProtectedFieldsKey`, `:237-254`). One shared enum gets this wrong in both
23//! directions, so there are two: [`OpEntity`] and [`PfEntity`].
24//!
25//! **The raw block is kept verbatim.** parse-rust must never rewrite a key it does not
26//! understand back out of `_metadata.class_permissions`, because a parse-server node reading the
27//! same database would see the key vanish. [`ClassLevelPermissions::raw`] is what gets written;
28//! the parsed view is only ever read.
29
30use indexmap::IndexMap;
31
32use crate::value::{ParseMap, ParseValue};
33
34/// The seven operations a CLP can restrict.
35///
36/// `addField` is one of them, and it is checked on every non-master write that introduces a key
37/// the schema does not have (`DatabaseController.js:970-998`).
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum Operation {
40 Find,
41 Count,
42 Get,
43 Create,
44 Update,
45 Delete,
46 AddField,
47}
48
49impl Operation {
50 /// The key as it appears in the CLP object, and in
51 /// `Permission denied for action <op> on class <Class>.`
52 pub fn as_key(self) -> &'static str {
53 match self {
54 Operation::Find => "find",
55 Operation::Count => "count",
56 Operation::Get => "get",
57 Operation::Create => "create",
58 Operation::Update => "update",
59 Operation::Delete => "delete",
60 Operation::AddField => "addField",
61 }
62 }
63
64 /// Which of the two class-wide pointer-field arrays applies to this operation.
65 ///
66 /// `permissionField = ['get','find','count'].indexOf(operation) > -1 ? 'readUserFields' :
67 /// 'writeUserFields'` (`SchemaController.js:1425-1429`). Note `addField` falls on the write
68 /// side, which is what makes the create lockdown reachable.
69 pub fn user_fields_key(self) -> UserFieldsKey {
70 match self {
71 Operation::Get | Operation::Find | Operation::Count => UserFieldsKey::Read,
72 _ => UserFieldsKey::Write,
73 }
74 }
75
76 pub const ALL: [Operation; 7] = [
77 Operation::Find,
78 Operation::Count,
79 Operation::Get,
80 Operation::Create,
81 Operation::Update,
82 Operation::Delete,
83 Operation::AddField,
84 ];
85
86 pub fn from_key(key: &str) -> Option<Operation> {
87 Operation::ALL.into_iter().find(|op| op.as_key() == key)
88 }
89}
90
91/// Which class-wide pointer-field array an operation consults.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum UserFieldsKey {
94 Read,
95 Write,
96}
97
98/// An entity key inside an operation's permission object.
99///
100/// `pointerFields` is not here: it is a sibling key whose value is an array of field names rather
101/// than an entity granted `true`, and conflating the two is how a permission object stops being a
102/// map from principal to grant.
103#[derive(Debug, Clone, PartialEq, Eq, Hash)]
104pub enum OpEntity {
105 /// `"*"`
106 Public,
107 /// `"requiresAuthentication"`. Not a principal: a predicate over the caller.
108 RequiresAuthentication,
109 /// `"role:<name>"`, stored without the prefix.
110 Role(String),
111 /// An objectId.
112 User(String),
113}
114
115impl OpEntity {
116 /// Total, so a `role:`-prefixed string can never end up in the `User` arm.
117 ///
118 /// That totality is the mitigation for the `role:` objectId collision: upstream has to guard
119 /// it at two call sites with an explicit `startsWith` check (`Auth.js:195`, `:237`) precisely
120 /// because its entity namespace is one flat untyped string space.
121 pub fn parse(key: &str) -> Self {
122 if key == "*" {
123 OpEntity::Public
124 } else if key == "requiresAuthentication" {
125 OpEntity::RequiresAuthentication
126 } else if let Some(name) = key.strip_prefix("role:") {
127 OpEntity::Role(name.to_string())
128 } else {
129 OpEntity::User(key.to_string())
130 }
131 }
132
133 pub fn as_key(&self) -> String {
134 match self {
135 OpEntity::Public => "*".to_string(),
136 OpEntity::RequiresAuthentication => "requiresAuthentication".to_string(),
137 OpEntity::Role(name) => format!("role:{name}"),
138 OpEntity::User(id) => id.clone(),
139 }
140 }
141}
142
143/// An entity key inside `protectedFields`.
144///
145/// Note what is *not* shared with [`OpEntity`]: there is no `requiresAuthentication` (the
146/// spelling here is `authenticated`) and no `pointerFields` (the spelling here is
147/// `userField:<name>`). Upstream validates the two with two different functions and two different
148/// key sets.
149#[derive(Debug, Clone, PartialEq, Eq, Hash)]
150pub enum PfEntity {
151 /// `"*"`. Always applicable.
152 Public,
153 /// `"authenticated"`. Applicable only when the caller is a logged-in user.
154 Authenticated,
155 /// `"userField:<name>"`. Applicable when the named field on the *row* points at the caller,
156 /// which cannot be known until the row has been read.
157 UserField(String),
158 /// `"role:<name>"`, stored without the prefix.
159 Role(String),
160 /// An objectId.
161 User(String),
162}
163
164impl PfEntity {
165 pub fn parse(key: &str) -> Self {
166 if key == "*" {
167 PfEntity::Public
168 } else if key == "authenticated" {
169 PfEntity::Authenticated
170 } else if let Some(field) = key.strip_prefix("userField:") {
171 PfEntity::UserField(field.to_string())
172 } else if let Some(name) = key.strip_prefix("role:") {
173 PfEntity::Role(name.to_string())
174 } else {
175 PfEntity::User(key.to_string())
176 }
177 }
178
179 pub fn as_key(&self) -> String {
180 match self {
181 PfEntity::Public => "*".to_string(),
182 PfEntity::Authenticated => "authenticated".to_string(),
183 PfEntity::UserField(f) => format!("userField:{f}"),
184 PfEntity::Role(name) => format!("role:{name}"),
185 PfEntity::User(id) => id.clone(),
186 }
187 }
188}
189
190/// One operation's permission object.
191///
192/// **No `Default` impl, on purpose.** See the module note: an empty `OpPerm` is deny-all, and
193/// absent is unrestricted, so the two must never be reachable from one another by accident.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct OpPerm {
196 /// Entities granted the operation. Only a literal `true` counts upstream
197 /// (`SchemaController.js:367-396`); `false`, `0` and `"true"` are all `INVALID_JSON` at
198 /// validation, so anything reaching here is a grant.
199 pub entities: Vec<OpEntity>,
200 /// `pointerFields`: the caller must be the value of one of these fields on the row.
201 pub pointer_fields: Vec<String>,
202}
203
204impl OpPerm {
205 // No `Default` impl, and the lint asking for one is wrong here. An empty `OpPerm` is
206 // deny-all, while an *absent* one is unrestricted, so a `Default` would put deny-all one
207 // `unwrap_or_default()` away from every call site that means unrestricted. That is the
208 // inversion this module exists to prevent.
209 #[allow(clippy::new_without_default)]
210 pub fn new() -> Self {
211 Self {
212 entities: Vec::new(),
213 pointer_fields: Vec::new(),
214 }
215 }
216
217 pub fn grants(&self, entity: &OpEntity) -> bool {
218 self.entities.contains(entity)
219 }
220}
221
222/// Class-level permissions.
223///
224/// Constructed from the stored `_metadata.class_permissions` object or from a
225/// `POST`/`PUT /schemas` body. Both paths keep [`Self::raw`] verbatim so that writing the block
226/// back cannot drop a key parse-rust does not model.
227///
228/// No `PartialEq`, because [`Self::raw`] holds [`ParseValue`]s and a derived comparison would
229/// inherit that type's float hazards. Compare the rendered JSON if two blocks need comparing.
230#[derive(Debug, Clone)]
231pub struct ClassLevelPermissions {
232 raw: ParseMap,
233 ops: Vec<(Operation, OpPerm)>,
234 protected_fields: IndexMap<PfEntity, Vec<String>>,
235 read_user_fields: Vec<String>,
236 write_user_fields: Vec<String>,
237}
238
239impl ClassLevelPermissions {
240 /// Parse a CLP block. Unknown keys are preserved in [`Self::raw`] and otherwise ignored;
241 /// rejecting them is the schema API's job, not the model's, because a block already in the
242 /// database has to be readable even if it would fail validation today.
243 pub fn from_map(raw: ParseMap) -> Self {
244 let mut ops = Vec::new();
245 for op in Operation::ALL {
246 match raw.get(op.as_key()) {
247 Some(ParseValue::Object(entry)) => ops.push((op, parse_op_perm(entry))),
248 // A present but malformed entry denies. `testPermissions` allows only when
249 // `classPermissions[operation]` is *falsy* (`SchemaController.js:1365-1382`), so
250 // `{"find": true}`, `{"find": "x"}` and `{"find": []}` all deny upstream: the
251 // value is truthy, so the short-circuit does not fire, and none of the lookups
252 // that follow finds a grant on a non-object.
253 //
254 // Recording an empty `OpPerm` rather than skipping the key is what reproduces
255 // that. Skipping would make the entry absent, which is unrestricted, and the
256 // failure direction there is open. Not reachable through `PUT /schemas`, which
257 // refuses a non-object operation value, but a block written straight into
258 // `_SCHEMA` has to be read the way parse-server reads it.
259 Some(other) if is_js_truthy(other) => ops.push((op, OpPerm::new())),
260 // Falsy, so `!classPermissions[operation]` holds and the operation is
261 // unrestricted, exactly as an absent key is.
262 _ => {}
263 }
264 }
265
266 let mut protected_fields = IndexMap::new();
267 if let Some(ParseValue::Object(pf)) = raw.get("protectedFields") {
268 for (key, value) in pf {
269 if let ParseValue::Array(items) = value {
270 protected_fields.insert(PfEntity::parse(key), string_array(items));
271 }
272 }
273 }
274
275 Self {
276 read_user_fields: raw
277 .get("readUserFields")
278 .map(string_array_of)
279 .unwrap_or_default(),
280 write_user_fields: raw
281 .get("writeUserFields")
282 .map(string_array_of)
283 .unwrap_or_default(),
284 ops,
285 protected_fields,
286 raw,
287 }
288 }
289
290 /// The block exactly as it will be stored. Never derived from the parsed view.
291 pub fn raw(&self) -> &ParseMap {
292 &self.raw
293 }
294
295 /// The permission object for one operation.
296 ///
297 /// **`None` means unrestricted, not denied.** Every caller has to spell that out; the reason
298 /// it cannot be `unwrap_or_default()` is the whole point of this module.
299 pub fn op(&self, operation: Operation) -> Option<&OpPerm> {
300 self.ops
301 .iter()
302 .find(|(o, _)| *o == operation)
303 .map(|(_, p)| p)
304 }
305
306 pub fn protected_fields(&self) -> &IndexMap<PfEntity, Vec<String>> {
307 &self.protected_fields
308 }
309
310 /// The class-wide pointer-field array for one operation, per
311 /// `SchemaController.js:1425-1429`.
312 pub fn user_fields(&self, operation: Operation) -> &[String] {
313 match operation.user_fields_key() {
314 UserFieldsKey::Read => &self.read_user_fields,
315 UserFieldsKey::Write => &self.write_user_fields,
316 }
317 }
318
319 pub fn read_user_fields(&self) -> &[String] {
320 &self.read_user_fields
321 }
322
323 pub fn write_user_fields(&self) -> &[String] {
324 &self.write_user_fields
325 }
326
327 /// Every pointer field that applies to an operation, per-op first then class-wide, deduped.
328 ///
329 /// Order is upstream's (`DatabaseController.js:1749-1764`) and matters, because the clauses
330 /// are composed into an `$or` whose element order is observable in a compiled query.
331 pub fn applicable_pointer_fields(&self, operation: Operation) -> Vec<String> {
332 let mut out: Vec<String> = Vec::new();
333 if let Some(perm) = self.op(operation) {
334 for f in &perm.pointer_fields {
335 if !out.contains(f) {
336 out.push(f.clone());
337 }
338 }
339 }
340 for f in self.user_fields(operation) {
341 if !out.contains(f) {
342 out.push(f.clone());
343 }
344 }
345 out
346 }
347}
348
349/// JavaScript truthiness, which is what `!classPermissions[operation]` tests.
350///
351/// The trap is `Array`: an empty array is **truthy** in JavaScript, and empty-is-falsy is the
352/// reflex a Rust reader brings. Everything with a `__type` envelope is an object on the JS side
353/// and therefore truthy too, including empty `Bytes`.
354///
355/// Public because it is not a CLP concern in particular. Any port of an upstream `if (value)` or
356/// `!value` needs it, and re-deriving the rule per call site is how the `Array` trap gets missed.
357pub fn is_js_truthy(value: &ParseValue) -> bool {
358 match value {
359 ParseValue::Null => false,
360 ParseValue::Bool(b) => *b,
361 // `NaN`, `0` and `-0` are the falsy numbers.
362 ParseValue::Number(n) => *n != 0.0 && !n.is_nan(),
363 ParseValue::String(s) => !s.is_empty(),
364 ParseValue::Array(_)
365 | ParseValue::Object(_)
366 | ParseValue::Date(_)
367 | ParseValue::Pointer { .. }
368 | ParseValue::GeoPoint { .. }
369 | ParseValue::Bytes(_)
370 | ParseValue::File { .. }
371 | ParseValue::Polygon(_)
372 | ParseValue::Relation { .. } => true,
373 }
374}
375
376fn parse_op_perm(entry: &ParseMap) -> OpPerm {
377 let mut perm = OpPerm::new();
378 for (key, value) in entry {
379 if key == "pointerFields" {
380 if let ParseValue::Array(items) = value {
381 perm.pointer_fields = string_array(items);
382 }
383 continue;
384 }
385 if matches!(value, ParseValue::Bool(true)) {
386 perm.entities.push(OpEntity::parse(key));
387 }
388 }
389 perm
390}
391
392fn string_array(items: &[ParseValue]) -> Vec<String> {
393 items
394 .iter()
395 .filter_map(|v| match v {
396 ParseValue::String(s) => Some(s.clone()),
397 _ => None,
398 })
399 .collect()
400}
401
402fn string_array_of(value: &ParseValue) -> Vec<String> {
403 match value {
404 ParseValue::Array(items) => string_array(items),
405 _ => Vec::new(),
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 fn clp(json: &str) -> ClassLevelPermissions {
414 let value = crate::decode::classify(
415 serde_json::from_str(json).expect("test literal must be valid JSON"),
416 )
417 .expect("classify");
418 match value {
419 ParseValue::Object(m) => ClassLevelPermissions::from_map(m),
420 _ => panic!("expected an object"),
421 }
422 }
423
424 /// The single most consequential CLP semantic. If this ever asserts "denied", every existing
425 /// database is locked out on upgrade.
426 #[test]
427 fn an_absent_operation_is_unrestricted_not_denied() {
428 let c = clp(r#"{"find":{"*":true}}"#);
429 assert!(c.op(Operation::Find).is_some());
430 assert!(
431 c.op(Operation::Update).is_none(),
432 "absent means unrestricted, and the caller must be forced to say so"
433 );
434 }
435
436 /// The other half of the same rule: present-but-empty is deny-all, and it must not be
437 /// reachable from the absent case.
438 #[test]
439 fn a_present_but_empty_operation_grants_nobody() {
440 let c = clp(r#"{"find":{}}"#);
441 let perm = c.op(Operation::Find).expect("present");
442 assert!(perm.entities.is_empty());
443 assert!(!perm.grants(&OpEntity::Public));
444 }
445
446 /// The fail-open this closes. A block written straight into `_SCHEMA` can carry a non-object
447 /// operation value, and upstream denies on every truthy one, because `testPermissions` only
448 /// short-circuits to allow when the value is falsy.
449 #[test]
450 fn a_truthy_non_object_operation_denies_rather_than_being_absent() {
451 for json in [
452 r#"{"find":true}"#,
453 r#"{"find":"x"}"#,
454 r#"{"find":[]}"#,
455 r#"{"find":["role:A"]}"#,
456 r#"{"find":1}"#,
457 r#"{"find":{"__type":"Date","iso":"2020-01-01T00:00:00.000Z"}}"#,
458 ] {
459 let c = clp(json);
460 let perm = c
461 .op(Operation::Find)
462 .unwrap_or_else(|| panic!("{json} must be present, not absent"));
463 assert!(perm.entities.is_empty(), "{json} must grant nobody");
464 assert!(!perm.grants(&OpEntity::Public), "{json}");
465 }
466 }
467
468 /// The other half, and the reason this cannot just be "anything non-object denies": a falsy
469 /// value is what `!classPermissions[operation]` is testing for, so it is unrestricted.
470 #[test]
471 fn a_falsy_operation_value_is_unrestricted_like_an_absent_key() {
472 for json in [
473 r#"{"find":false}"#,
474 r#"{"find":null}"#,
475 r#"{"find":0}"#,
476 r#"{"find":""}"#,
477 ] {
478 assert!(
479 clp(json).op(Operation::Find).is_none(),
480 "{json} must read as unrestricted"
481 );
482 }
483 }
484
485 /// The trap inside the trap. Rust reads an empty array as empty; JavaScript reads it as
486 /// truthy, and the two answers are opposite permission decisions.
487 #[test]
488 fn js_truthiness_is_not_rust_emptiness() {
489 assert!(is_js_truthy(&ParseValue::Array(Vec::new())));
490 assert!(is_js_truthy(&ParseValue::Bytes(Vec::new())));
491 assert!(is_js_truthy(&ParseValue::String("0".into())));
492 assert!(!is_js_truthy(&ParseValue::String(String::new())));
493 assert!(!is_js_truthy(&ParseValue::Number(0.0)));
494 assert!(!is_js_truthy(&ParseValue::Number(-0.0)));
495 assert!(!is_js_truthy(&ParseValue::Number(f64::NAN)));
496 assert!(is_js_truthy(&ParseValue::Number(-1.0)));
497 }
498
499 #[test]
500 fn only_literal_true_is_a_grant() {
501 let c = clp(r#"{"find":{"*":false,"role:A":true,"abc":0,"def":"true"}}"#);
502 let perm = c.op(Operation::Find).expect("present");
503 assert_eq!(perm.entities, vec![OpEntity::Role("A".into())]);
504 }
505
506 #[test]
507 fn pointer_fields_is_not_an_entity() {
508 let c = clp(r#"{"find":{"pointerFields":["owner"],"*":true}}"#);
509 let perm = c.op(Operation::Find).expect("present");
510 assert_eq!(perm.pointer_fields, vec!["owner".to_string()]);
511 assert_eq!(perm.entities, vec![OpEntity::Public]);
512 }
513
514 #[test]
515 fn the_two_entity_grammars_do_not_overlap() {
516 // `requiresAuthentication` is an operation key and is an ordinary objectId under the
517 // protectedFields grammar; `authenticated` is the reverse.
518 assert_eq!(
519 OpEntity::parse("requiresAuthentication"),
520 OpEntity::RequiresAuthentication
521 );
522 assert_eq!(
523 PfEntity::parse("requiresAuthentication"),
524 PfEntity::User("requiresAuthentication".into())
525 );
526 assert_eq!(PfEntity::parse("authenticated"), PfEntity::Authenticated);
527 assert_eq!(
528 OpEntity::parse("authenticated"),
529 OpEntity::User("authenticated".into())
530 );
531 assert_eq!(
532 PfEntity::parse("userField:owner"),
533 PfEntity::UserField("owner".into())
534 );
535 assert_eq!(
536 OpEntity::parse("userField:owner"),
537 OpEntity::User("userField:owner".into())
538 );
539 }
540
541 /// The mitigation for the `role:` objectId collision: a `role:`-prefixed string cannot land
542 /// in the `User` arm, so no ACL check can be tricked into granting a role.
543 #[test]
544 fn a_role_prefixed_key_can_never_parse_as_a_user() {
545 assert_eq!(
546 OpEntity::parse("role:Admin"),
547 OpEntity::Role("Admin".into())
548 );
549 assert_eq!(
550 PfEntity::parse("role:Admin"),
551 PfEntity::Role("Admin".into())
552 );
553 for key in ["role:Admin", "role:", "role:with:colons"] {
554 assert!(!matches!(OpEntity::parse(key), OpEntity::User(_)), "{key}");
555 assert!(!matches!(PfEntity::parse(key), PfEntity::User(_)), "{key}");
556 }
557 }
558
559 #[test]
560 fn entity_keys_round_trip() {
561 for key in ["*", "requiresAuthentication", "role:A", "abc123"] {
562 assert_eq!(OpEntity::parse(key).as_key(), key);
563 }
564 for key in ["*", "authenticated", "userField:owner", "role:A", "abc123"] {
565 assert_eq!(PfEntity::parse(key).as_key(), key);
566 }
567 }
568
569 #[test]
570 fn user_fields_split_by_operation_and_add_field_is_a_write() {
571 let c = clp(r#"{"readUserFields":["r"],"writeUserFields":["w"]}"#);
572 for op in [Operation::Get, Operation::Find, Operation::Count] {
573 assert_eq!(c.user_fields(op), ["r".to_string()], "{op:?}");
574 }
575 for op in [
576 Operation::Create,
577 Operation::Update,
578 Operation::Delete,
579 Operation::AddField,
580 ] {
581 assert_eq!(c.user_fields(op), ["w".to_string()], "{op:?}");
582 }
583 }
584
585 #[test]
586 fn applicable_pointer_fields_is_per_op_then_class_wide_deduped() {
587 let c = clp(r#"{"find":{"pointerFields":["owner","a"]},"readUserFields":["a","b"]}"#);
588 assert_eq!(
589 c.applicable_pointer_fields(Operation::Find),
590 vec!["owner".to_string(), "a".to_string(), "b".to_string()]
591 );
592 }
593
594 /// The rule that keeps a mixed fleet from losing configuration: a key parse-rust does not
595 /// model survives the round trip.
596 #[test]
597 fn unmodelled_keys_survive_in_the_raw_block() {
598 let c = clp(r#"{"find":{"*":true},"someFutureKey":{"x":1}}"#);
599 assert!(c.raw().contains_key("someFutureKey"));
600 assert!(c.raw().contains_key("find"));
601 }
602
603 #[test]
604 fn protected_fields_parse_per_entity() {
605 let c = clp(r#"{"protectedFields":{"*":["email"],"role:A":["email","phone"]}}"#);
606 assert_eq!(
607 c.protected_fields().get(&PfEntity::Public),
608 Some(&vec!["email".to_string()])
609 );
610 assert_eq!(
611 c.protected_fields()
612 .get(&PfEntity::Role("A".into()))
613 .map(Vec::len),
614 Some(2)
615 );
616 }
617}