Skip to main content

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    /// The class's declared default ACL, if it has one that upstream would stamp on a create.
328    ///
329    /// `None` covers three cases that upstream's condition collapses (`RestWrite.js:379-384`):
330    /// the `ACL` key is absent; its value is falsy, which is `schema?.classLevelPermissions?.ACL`
331    /// failing its own truthiness test; or it is exactly the public ACL, which upstream skips
332    /// because stamping `{"*": {"read": true, "write": true}}` on a row would only reproduce what
333    /// an absent ACL already means.
334    ///
335    /// **That last comparison is `JSON.stringify` equality upstream, so it is key-order
336    /// sensitive**, and [`is_the_public_acl`] reproduces the ordering rather than comparing
337    /// structurally. A block whose keys arrived in the other order is *not* the public ACL as far
338    /// as upstream is concerned, and it gets stamped.
339    ///
340    /// A truthy non-object is returned rather than filtered: upstream clones and assigns whatever
341    /// it finds, and the resulting `ACL` value is then lowered by the same rule any client-supplied
342    /// one is.
343    pub fn default_acl(&self) -> Option<&ParseValue> {
344        let acl = self.raw.get("ACL")?;
345        if !is_js_truthy(acl) || is_the_public_acl(acl) {
346            return None;
347        }
348        Some(acl)
349    }
350
351    /// Every pointer field that applies to an operation, per-op first then class-wide, deduped.
352    ///
353    /// Order is upstream's (`DatabaseController.js:1749-1764`) and matters, because the clauses
354    /// are composed into an `$or` whose element order is observable in a compiled query.
355    pub fn applicable_pointer_fields(&self, operation: Operation) -> Vec<String> {
356        let mut out: Vec<String> = Vec::new();
357        if let Some(perm) = self.op(operation) {
358            for f in &perm.pointer_fields {
359                if !out.contains(f) {
360                    out.push(f.clone());
361                }
362            }
363        }
364        for f in self.user_fields(operation) {
365            if !out.contains(f) {
366                out.push(f.clone());
367            }
368        }
369        out
370    }
371}
372
373/// Is this value what `JSON.stringify` would render as `{"*":{"read":true,"write":true}}`?
374///
375/// The one comparison upstream makes by stringifying both sides
376/// (`RestWrite.js:382-383`), which makes it **sensitive to key order**: a block spelled
377/// `{"*":{"write":true,"read":true}}` stringifies differently and is therefore not the public ACL,
378/// so upstream stamps it onto every new object. The observable result is the same permissions
379/// either way, but the row carries `_rperm` and `_wperm` in one case and neither in the other, and
380/// a mixed fleet has to agree on which.
381///
382/// Written as an ordered structural test rather than by building a JSON string. For this one
383/// literal the two are the same predicate: a value stringifies to it exactly when it is an object
384/// of one key `*` whose value is an object of two keys, `read` then `write`, both `true`. Anything
385/// else, including `{"*":{"read":true,"write":true,"x":1}}` or `read: 1` instead of `read: true`,
386/// renders a different string and is correctly not the public ACL.
387fn is_the_public_acl(value: &ParseValue) -> bool {
388    let ParseValue::Object(entries) = value else {
389        return false;
390    };
391    let mut entries = entries.iter();
392    let (Some(("*", ParseValue::Object(flags))), None) =
393        (entries.next().map(|(k, v)| (k.as_str(), v)), entries.next())
394    else {
395        return false;
396    };
397    let mut flags = flags.iter();
398    matches!(
399        (
400            flags.next().map(|(k, v)| (k.as_str(), v)),
401            flags.next().map(|(k, v)| (k.as_str(), v)),
402            flags.next(),
403        ),
404        (
405            Some(("read", ParseValue::Bool(true))),
406            Some(("write", ParseValue::Bool(true))),
407            None,
408        )
409    )
410}
411
412/// JavaScript truthiness, which is what `!classPermissions[operation]` tests.
413///
414/// The trap is `Array`: an empty array is **truthy** in JavaScript, and empty-is-falsy is the
415/// reflex a Rust reader brings. Everything with a `__type` envelope is an object on the JS side
416/// and therefore truthy too, including empty `Bytes`.
417///
418/// Public because it is not a CLP concern in particular. Any port of an upstream `if (value)` or
419/// `!value` needs it, and re-deriving the rule per call site is how the `Array` trap gets missed.
420pub fn is_js_truthy(value: &ParseValue) -> bool {
421    match value {
422        ParseValue::Null => false,
423        ParseValue::Bool(b) => *b,
424        // `NaN`, `0` and `-0` are the falsy numbers.
425        ParseValue::Number(n) => *n != 0.0 && !n.is_nan(),
426        ParseValue::String(s) => !s.is_empty(),
427        ParseValue::Array(_)
428        | ParseValue::Object(_)
429        | ParseValue::Date(_)
430        | ParseValue::Pointer { .. }
431        | ParseValue::GeoPoint { .. }
432        | ParseValue::Bytes(_)
433        | ParseValue::File { .. }
434        | ParseValue::Polygon(_)
435        | ParseValue::Relation { .. } => true,
436    }
437}
438
439fn parse_op_perm(entry: &ParseMap) -> OpPerm {
440    let mut perm = OpPerm::new();
441    for (key, value) in entry {
442        if key == "pointerFields" {
443            if let ParseValue::Array(items) = value {
444                perm.pointer_fields = string_array(items);
445            }
446            continue;
447        }
448        if matches!(value, ParseValue::Bool(true)) {
449            perm.entities.push(OpEntity::parse(key));
450        }
451    }
452    perm
453}
454
455fn string_array(items: &[ParseValue]) -> Vec<String> {
456    items
457        .iter()
458        .filter_map(|v| match v {
459            ParseValue::String(s) => Some(s.clone()),
460            _ => None,
461        })
462        .collect()
463}
464
465fn string_array_of(value: &ParseValue) -> Vec<String> {
466    match value {
467        ParseValue::Array(items) => string_array(items),
468        _ => Vec::new(),
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    fn clp(json: &str) -> ClassLevelPermissions {
477        let value = crate::decode::classify(
478            serde_json::from_str(json).expect("test literal must be valid JSON"),
479        )
480        .expect("classify");
481        match value {
482            ParseValue::Object(m) => ClassLevelPermissions::from_map(m),
483            _ => panic!("expected an object"),
484        }
485    }
486
487    /// The single most consequential CLP semantic. If this ever asserts "denied", every existing
488    /// database is locked out on upgrade.
489    #[test]
490    fn an_absent_operation_is_unrestricted_not_denied() {
491        let c = clp(r#"{"find":{"*":true}}"#);
492        assert!(c.op(Operation::Find).is_some());
493        assert!(
494            c.op(Operation::Update).is_none(),
495            "absent means unrestricted, and the caller must be forced to say so"
496        );
497    }
498
499    /// The other half of the same rule: present-but-empty is deny-all, and it must not be
500    /// reachable from the absent case.
501    #[test]
502    fn a_present_but_empty_operation_grants_nobody() {
503        let c = clp(r#"{"find":{}}"#);
504        let perm = c.op(Operation::Find).expect("present");
505        assert!(perm.entities.is_empty());
506        assert!(!perm.grants(&OpEntity::Public));
507    }
508
509    /// The fail-open this closes. A block written straight into `_SCHEMA` can carry a non-object
510    /// operation value, and upstream denies on every truthy one, because `testPermissions` only
511    /// short-circuits to allow when the value is falsy.
512    #[test]
513    fn a_truthy_non_object_operation_denies_rather_than_being_absent() {
514        for json in [
515            r#"{"find":true}"#,
516            r#"{"find":"x"}"#,
517            r#"{"find":[]}"#,
518            r#"{"find":["role:A"]}"#,
519            r#"{"find":1}"#,
520            r#"{"find":{"__type":"Date","iso":"2020-01-01T00:00:00.000Z"}}"#,
521        ] {
522            let c = clp(json);
523            let perm = c
524                .op(Operation::Find)
525                .unwrap_or_else(|| panic!("{json} must be present, not absent"));
526            assert!(perm.entities.is_empty(), "{json} must grant nobody");
527            assert!(!perm.grants(&OpEntity::Public), "{json}");
528        }
529    }
530
531    /// The other half, and the reason this cannot just be "anything non-object denies": a falsy
532    /// value is what `!classPermissions[operation]` is testing for, so it is unrestricted.
533    #[test]
534    fn a_falsy_operation_value_is_unrestricted_like_an_absent_key() {
535        for json in [
536            r#"{"find":false}"#,
537            r#"{"find":null}"#,
538            r#"{"find":0}"#,
539            r#"{"find":""}"#,
540        ] {
541            assert!(
542                clp(json).op(Operation::Find).is_none(),
543                "{json} must read as unrestricted"
544            );
545        }
546    }
547
548    /// The trap inside the trap. Rust reads an empty array as empty; JavaScript reads it as
549    /// truthy, and the two answers are opposite permission decisions.
550    #[test]
551    fn js_truthiness_is_not_rust_emptiness() {
552        assert!(is_js_truthy(&ParseValue::Array(Vec::new())));
553        assert!(is_js_truthy(&ParseValue::Bytes(Vec::new())));
554        assert!(is_js_truthy(&ParseValue::String("0".into())));
555        assert!(!is_js_truthy(&ParseValue::String(String::new())));
556        assert!(!is_js_truthy(&ParseValue::Number(0.0)));
557        assert!(!is_js_truthy(&ParseValue::Number(-0.0)));
558        assert!(!is_js_truthy(&ParseValue::Number(f64::NAN)));
559        assert!(is_js_truthy(&ParseValue::Number(-1.0)));
560    }
561
562    #[test]
563    fn only_literal_true_is_a_grant() {
564        let c = clp(r#"{"find":{"*":false,"role:A":true,"abc":0,"def":"true"}}"#);
565        let perm = c.op(Operation::Find).expect("present");
566        assert_eq!(perm.entities, vec![OpEntity::Role("A".into())]);
567    }
568
569    #[test]
570    fn pointer_fields_is_not_an_entity() {
571        let c = clp(r#"{"find":{"pointerFields":["owner"],"*":true}}"#);
572        let perm = c.op(Operation::Find).expect("present");
573        assert_eq!(perm.pointer_fields, vec!["owner".to_string()]);
574        assert_eq!(perm.entities, vec![OpEntity::Public]);
575    }
576
577    #[test]
578    fn the_two_entity_grammars_do_not_overlap() {
579        // `requiresAuthentication` is an operation key and is an ordinary objectId under the
580        // protectedFields grammar; `authenticated` is the reverse.
581        assert_eq!(
582            OpEntity::parse("requiresAuthentication"),
583            OpEntity::RequiresAuthentication
584        );
585        assert_eq!(
586            PfEntity::parse("requiresAuthentication"),
587            PfEntity::User("requiresAuthentication".into())
588        );
589        assert_eq!(PfEntity::parse("authenticated"), PfEntity::Authenticated);
590        assert_eq!(
591            OpEntity::parse("authenticated"),
592            OpEntity::User("authenticated".into())
593        );
594        assert_eq!(
595            PfEntity::parse("userField:owner"),
596            PfEntity::UserField("owner".into())
597        );
598        assert_eq!(
599            OpEntity::parse("userField:owner"),
600            OpEntity::User("userField:owner".into())
601        );
602    }
603
604    /// The mitigation for the `role:` objectId collision: a `role:`-prefixed string cannot land
605    /// in the `User` arm, so no ACL check can be tricked into granting a role.
606    #[test]
607    fn a_role_prefixed_key_can_never_parse_as_a_user() {
608        assert_eq!(
609            OpEntity::parse("role:Admin"),
610            OpEntity::Role("Admin".into())
611        );
612        assert_eq!(
613            PfEntity::parse("role:Admin"),
614            PfEntity::Role("Admin".into())
615        );
616        for key in ["role:Admin", "role:", "role:with:colons"] {
617            assert!(!matches!(OpEntity::parse(key), OpEntity::User(_)), "{key}");
618            assert!(!matches!(PfEntity::parse(key), PfEntity::User(_)), "{key}");
619        }
620    }
621
622    #[test]
623    fn entity_keys_round_trip() {
624        for key in ["*", "requiresAuthentication", "role:A", "abc123"] {
625            assert_eq!(OpEntity::parse(key).as_key(), key);
626        }
627        for key in ["*", "authenticated", "userField:owner", "role:A", "abc123"] {
628            assert_eq!(PfEntity::parse(key).as_key(), key);
629        }
630    }
631
632    #[test]
633    fn user_fields_split_by_operation_and_add_field_is_a_write() {
634        let c = clp(r#"{"readUserFields":["r"],"writeUserFields":["w"]}"#);
635        for op in [Operation::Get, Operation::Find, Operation::Count] {
636            assert_eq!(c.user_fields(op), ["r".to_string()], "{op:?}");
637        }
638        for op in [
639            Operation::Create,
640            Operation::Update,
641            Operation::Delete,
642            Operation::AddField,
643        ] {
644            assert_eq!(c.user_fields(op), ["w".to_string()], "{op:?}");
645        }
646    }
647
648    #[test]
649    fn applicable_pointer_fields_is_per_op_then_class_wide_deduped() {
650        let c = clp(r#"{"find":{"pointerFields":["owner","a"]},"readUserFields":["a","b"]}"#);
651        assert_eq!(
652            c.applicable_pointer_fields(Operation::Find),
653            vec!["owner".to_string(), "a".to_string(), "b".to_string()]
654        );
655    }
656
657    /// The rule that keeps a mixed fleet from losing configuration: a key parse-rust does not
658    /// model survives the round trip.
659    #[test]
660    fn unmodelled_keys_survive_in_the_raw_block() {
661        let c = clp(r#"{"find":{"*":true},"someFutureKey":{"x":1}}"#);
662        assert!(c.raw().contains_key("someFutureKey"));
663        assert!(c.raw().contains_key("find"));
664    }
665
666    #[test]
667    fn protected_fields_parse_per_entity() {
668        let c = clp(r#"{"protectedFields":{"*":["email"],"role:A":["email","phone"]}}"#);
669        assert_eq!(
670            c.protected_fields().get(&PfEntity::Public),
671            Some(&vec!["email".to_string()])
672        );
673        assert_eq!(
674            c.protected_fields()
675                .get(&PfEntity::Role("A".into()))
676                .map(Vec::len),
677            Some(2)
678        );
679    }
680
681    // -----------------------------------------------------------------------------------------
682    // The declared default ACL
683    // -----------------------------------------------------------------------------------------
684
685    #[test]
686    fn a_declared_acl_is_readable_and_an_absent_one_is_none() {
687        assert!(clp(r#"{"find":{"*":true}}"#).default_acl().is_none());
688        let c = clp(r#"{"ACL":{"currentUser":{"read":true,"write":true}}}"#);
689        let ParseValue::Object(acl) = c.default_acl().expect("declared") else {
690            panic!("expected an object");
691        };
692        assert!(acl.contains_key("currentUser"));
693    }
694
695    /// Upstream tests `schema?.classLevelPermissions?.ACL` for truthiness, so a falsy value is not
696    /// a default ACL at all. Returning it instead would reach `lower_acl`, where a falsy value is
697    /// dropped and the row is public anyway, but the two paths differ for `_Role`, whose ACL is a
698    /// required column.
699    #[test]
700    fn a_falsy_declared_acl_is_not_a_default() {
701        for literal in [
702            r#"{"ACL":null}"#,
703            r#"{"ACL":false}"#,
704            r#"{"ACL":0}"#,
705            r#"{"ACL":""}"#,
706        ] {
707            assert!(clp(literal).default_acl().is_none(), "{literal}");
708        }
709    }
710
711    /// The public ACL is skipped, because stamping it would only restate what an absent ACL
712    /// already means: a row every caller can read and write.
713    #[test]
714    fn the_public_acl_is_not_stamped() {
715        assert!(clp(r#"{"ACL":{"*":{"read":true,"write":true}}}"#)
716            .default_acl()
717            .is_none());
718    }
719
720    /// **The comparison is `JSON.stringify` equality upstream and therefore key-order sensitive.**
721    /// Every literal here grants exactly the same permissions as the public ACL, and upstream
722    /// stamps every one of them, because none stringifies to the same bytes. Comparing
723    /// structurally would skip them all and write no ACL columns where parse-server writes two,
724    /// which a mixed fleet reading the same rows can see.
725    #[test]
726    fn a_reordered_or_extended_public_acl_is_still_stamped() {
727        for literal in [
728            r#"{"ACL":{"*":{"write":true,"read":true}}}"#,
729            r#"{"ACL":{"*":{"read":true,"write":true,"delete":true}}}"#,
730            r#"{"ACL":{"*":{"read":true}}}"#,
731            r#"{"ACL":{"*":{"read":true,"write":true},"role:A":{"read":true}}}"#,
732            r#"{"ACL":{"role:A":{"read":true},"*":{"read":true,"write":true}}}"#,
733        ] {
734            assert!(
735                clp(literal).default_acl().is_some(),
736                "{literal} does not stringify to the public ACL and must be stamped"
737            );
738        }
739    }
740}