Skip to main content

parse_rust_rest/
acl.rs

1//! ACL enforcement: the boundary between the `ACL` field a client sees and the `_rperm`/`_wperm`
2//! columns storage holds.
3//!
4//! **The rule that must not be got wrong: absent permission columns mean public.**
5//! `addReadACL` emits `_rperm: {$in: [null, '*', ...acl]}` and `null` in a Mongo `$in` matches a
6//! document where the field is *missing*, which is how a row saved without an ACL stays readable.
7//! Omitting the null silently hides every such row, and there is no error to notice.
8//!
9//! **Two known differences from upstream live in [`lower_acl`], both recorded as deliberate
10//! differences and both deferred rather than fixed here.**
11//!
12//! It reads principals from a map and nothing else, so an `ACL` that is an *array* takes the
13//! truthy-non-object path below: two **empty** columns, which is a master-only row rather than a
14//! column-less public one. Upstream enumerates the array, so `[{"read":true}]` grants principal
15//! `"0"` there, an index being a property name. **That does change who may read the row**, in the
16//! restrictive direction: a principal upstream grants is granted nothing here.
17//!
18//! And the columns come out in wire order, where upstream enumerates a JavaScript object and puts
19//! integer-like keys first. That one grants the same rights to the same principals and is visible
20//! only to a client preserving map order, or to a mixed fleet comparing stored rows.
21
22use parse_rust_core::{Acl, ErrorCode, ParseError, ParseMap, ParseValue, Permissions, Principal};
23use parse_rust_storage::{Comparison, Constraint};
24
25/// Who a request is acting as, for ACL purposes.
26///
27/// An enum rather than an `Option<String>` so that "no ACL constraint at all" cannot be reached
28/// by forgetting to set a field. `acl === undefined` as a master sentinel is the upstream shape
29/// this deliberately does not copy.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum AclScope {
32    /// Master or maintenance: no ACL constraint is applied at all.
33    Unrestricted,
34    /// A caller acting as nobody in particular.
35    Anonymous,
36    /// A logged-in user, with the transitive closure of their roles.
37    ///
38    /// **Roles are bare names here, with no `role:` prefix.** The prefix is added by
39    /// [`AclScope::acl_group`] and by `AclScope::principals`, so there is exactly one place
40    /// that knows the wire spelling. Construct through [`AclScope::user`] rather than by
41    /// literal, so that a `role:`-prefixed objectId cannot reach `object_id`.
42    User {
43        object_id: String,
44        roles: Vec<String>,
45    },
46}
47
48impl AclScope {
49    /// Build a user scope, refusing a `role:`-prefixed objectId.
50    ///
51    /// A user whose objectId began with `role:` would be granted that role by every ACL and CLP
52    /// check, because the entity namespace is one flat string space on the wire. Upstream guards
53    /// it at two session-resolution sites with the same code and message (`Auth.js:195`, `:237`);
54    /// here the guard is at the one place a scope can be built.
55    pub fn user(object_id: impl Into<String>, roles: Vec<String>) -> Result<Self, ParseError> {
56        let object_id = object_id.into();
57        if object_id.starts_with("role:") {
58            return Err(ParseError::new(
59                ErrorCode::InternalServerError,
60                "Invalid object ID.",
61            ));
62        }
63        Ok(AclScope::User { object_id, roles })
64    }
65
66    pub fn is_master(&self) -> bool {
67        matches!(self, AclScope::Unrestricted)
68    }
69
70    pub fn user_id(&self) -> Option<&str> {
71        match self {
72            AclScope::User { object_id, .. } => Some(object_id),
73            _ => None,
74        }
75    }
76
77    /// Does the caller hold this role? The name is bare, with no `role:` prefix.
78    pub fn has_role(&self, name: &str) -> bool {
79        match self {
80            AclScope::User { roles, .. } => roles.iter().any(|r| r == name),
81            _ => false,
82        }
83    }
84
85    /// Upstream's `aclGroup`: `['*']`, then every role as `role:<name>`, then the user's objectId
86    /// (`RestWrite.js:184`, `RestQuery.js:427`, both `['*'].concat(roles, [user.id])`).
87    ///
88    /// Master is the empty list, because upstream never reaches a caller that consumes an
89    /// `aclGroup` without first branching on `isMaster`.
90    ///
91    /// Order matters twice over. `addPointerPermissions` extracts the single user id by filtering
92    /// out `role:` and `*` (`DatabaseController.js:1745-1747`), and the compiled `$in` array is
93    /// snapshot-compared.
94    pub fn acl_group(&self) -> Vec<String> {
95        match self {
96            AclScope::Unrestricted => Vec::new(),
97            AclScope::Anonymous => vec!["*".to_string()],
98            AclScope::User { object_id, roles } => {
99                let mut out = Vec::with_capacity(roles.len() + 2);
100                out.push("*".to_string());
101                out.extend(roles.iter().map(|r| format!("role:{r}")));
102                // Defensive, and unreachable through `AclScope::user`. A `role:`-prefixed
103                // objectId that arrived by literal construction is dropped rather than emitted,
104                // which costs the caller access to their own rows and grants nothing.
105                if !object_id.starts_with("role:") {
106                    out.push(object_id.clone());
107                }
108                out
109            }
110        }
111    }
112
113    /// The principals this caller matches in an `_rperm`/`_wperm` lookup.
114    ///
115    /// `null` first, then optionally a literal `'*'`, then the `aclGroup`
116    /// (`DatabaseController.js:81`, `:88`).
117    fn principals(&self, seed_public: bool) -> Vec<ParseValue> {
118        // `null` matches a row with no permission column, i.e. a public row.
119        let mut out = vec![ParseValue::Null];
120        if seed_public {
121            out.push(ParseValue::String("*".to_string()));
122        }
123        out.extend(self.acl_group().into_iter().map(ParseValue::String));
124        out
125    }
126
127    /// The constraint to add to a read.
128    ///
129    /// `None` for [`AclScope::Unrestricted`], which is the only case where no constraint is
130    /// applied. Returning `Option` makes the master case explicit at every call site instead of
131    /// being the absence of a step.
132    ///
133    /// UPSTREAM-QUIRK: the emitted list carries `'*'` twice, once seeded by `addReadACL`
134    /// (`DatabaseController.js:88`) and once already present in the `aclGroup`
135    /// (`RestQuery.js:427`). A duplicate in an `$in` changes nothing, and removing it would make
136    /// the compiled query differ from upstream's for no gain.
137    pub fn read_constraint(&self) -> Option<Constraint> {
138        match self {
139            AclScope::Unrestricted => None,
140            _ => Some(Constraint {
141                field: "_rperm".to_string(),
142                comparison: Comparison::In(self.principals(true)),
143            }),
144        }
145    }
146
147    /// The constraint to add to a write.
148    ///
149    /// Note the asymmetry with reads: `addWriteACL` omits `'*'` from the injected list, because
150    /// `getUserAndRoleACL` already seeds it for every non-master caller. Reproduced rather than
151    /// unified, since the two functions are not symmetric upstream and a caller path that builds
152    /// its own list would behave differently.
153    pub fn write_constraint(&self) -> Option<Constraint> {
154        match self {
155            AclScope::Unrestricted => None,
156            _ => Some(Constraint {
157                field: "_wperm".to_string(),
158                comparison: Comparison::In(self.principals(false)),
159            }),
160        }
161    }
162}
163
164/// Resolve a class's declared default ACL into the value a create should carry.
165///
166/// `RestWrite.js:385-391`. The declared block is copied, and if it names `currentUser` then the
167/// caller's objectId gains a copy of that entry and the `currentUser` key is removed.
168///
169/// Three details are load-bearing and each fails silently if it is got wrong.
170///
171/// **`currentUser` is resolved, never stored.** An ACL containing the literal string
172/// `currentUser` as a principal matches nobody, so the row is unreadable by everyone including
173/// the user it was meant for, and the configuration reads as though it worked.
174///
175/// **An anonymous caller loses the entry rather than keeping it.** Upstream's `delete` is outside
176/// the `if (this.auth.user?.id)` guard, so with no caller there is no substitute id and the key
177/// simply goes. A class whose only declared entry is `currentUser` therefore produces an ACL with
178/// no entries at all for an anonymous create, which is a row only master can read. That is
179/// upstream's behavior and it is the restrictive direction.
180///
181/// **Key order is preserved, and it is not upstream's order.** The `_rperm` and `_wperm` arrays
182/// are built by walking the ACL, so their element order comes from here, and a mixed fleet compares
183/// stored rows. Substituting the caller's id in place of `currentUser` rather than appending would
184/// reorder them, so the substitution appends as upstream's assignment does.
185///
186/// That is where the resemblance stops. **Upstream enumerates a JavaScript object, so an
187/// integer-like key sorts ahead of every string key regardless of insertion order**, and an
188/// objectId of `1234567890` is integer-like. parse-rust preserves wire order throughout, so the
189/// stored arrays differ for any ACL naming such a principal. Measured, and not fixed here: it has
190/// no authorization consequence and the fix belongs in `lower_acl` with the array case.
191pub fn default_acl_for_create(declared: &ParseValue, caller: Option<&str>) -> ParseValue {
192    let ParseValue::Object(map) = declared else {
193        // A truthy non-object is assigned verbatim upstream and lowered by the same rule any
194        // client-supplied non-object ACL is: two empty columns, a master-only row.
195        return declared.clone();
196    };
197    let mut acl = map.clone();
198    let Some(current_user) = acl.get("currentUser").cloned() else {
199        return ParseValue::Object(acl);
200    };
201    // `if (acl.currentUser)`: a falsy entry is left in place and not resolved, because upstream's
202    // guard is truthiness rather than presence.
203    if !parse_rust_core::is_js_truthy(&current_user) {
204        return ParseValue::Object(acl);
205    }
206    if let Some(caller) = caller {
207        acl.insert(caller.to_string(), current_user);
208    }
209    acl.shift_remove("currentUser");
210    ParseValue::Object(acl)
211}
212
213/// Split an `ACL` field out of a row into the two storage columns.
214///
215/// Returns the row with `ACL` removed and the columns added. A row with no `ACL` gets no columns,
216/// which is what makes it public.
217///
218/// **The test upstream applies is falsiness, not "is it an object"** (`DatabaseController.js:94-96`,
219/// literally `if (!ACL) return result`). Everything truthy falls through to a `for...in` that reads
220/// `.read` and `.write` off each entry, so a string, a number or an array yields no principals but
221/// **still writes both columns as empty arrays**, which is a master-only row. Skipping the columns
222/// instead writes a row with no `_rperm`/`_wperm` at all, and an absent column is public.
223///
224/// Getting this wrong is not a cosmetic divergence. Nothing type-checks `ACL` on either side, by
225/// design (`SchemaController.js:1312-1315`), so `{"ACL":"x"}` reaches here from any client. The
226/// consequential class is `_Role`: its required-column check tests presence and truthiness only, so
227/// a non-object `ACL` would satisfy it and then produce a world-writable role that any caller can
228/// add itself to.
229///
230/// The update path applies the same test, in `lower_acl_into_update`. It did not until a review:
231/// it tested for `null` alone, so `false`, `0` and `""` fell through and cleared both columns on a
232/// row that already had permissions. Both paths now branch on truthiness, and the tests on each
233/// side loop over the falsy values rather than checking one, because checking one is what let the
234/// other three through.
235pub fn lower_acl(mut row: ParseMap) -> ParseMap {
236    let Some(acl_value) = row.shift_remove("ACL") else {
237        return row;
238    };
239    if !parse_rust_core::is_js_truthy(&acl_value) {
240        return row;
241    }
242    // `None` here is a truthy non-object, which upstream's loop walks and takes nothing from.
243    let acl = acl_from_value(&acl_value).unwrap_or_default();
244    let (rperm, wperm) = acl.to_perms();
245    row.insert(
246        "_rperm".to_string(),
247        ParseValue::Array(rperm.into_iter().map(ParseValue::String).collect()),
248    );
249    row.insert(
250        "_wperm".to_string(),
251        ParseValue::Array(wperm.into_iter().map(ParseValue::String).collect()),
252    );
253    row
254}
255
256/// Rebuild the `ACL` field from the two storage columns, then drop them.
257///
258/// Reproduces `untransformObjectACL` exactly, including that both columns absent produce **no
259/// `ACL` key at all** rather than `null` or `{}`.
260pub fn raise_acl(mut row: ParseMap) -> ParseMap {
261    let rperm = take_string_array(&mut row, "_rperm");
262    let wperm = take_string_array(&mut row, "_wperm");
263
264    let Some(acl) = Acl::from_perms(rperm.as_deref(), wperm.as_deref()) else {
265        return row;
266    };
267
268    let mut map = ParseMap::new();
269    for (principal, perms) in acl.iter() {
270        if perms.is_empty() {
271            continue;
272        }
273        let mut entry = ParseMap::new();
274        // Only true flags are emitted. UPSTREAM-QUIRK, see `parse_rust_core::acl`.
275        if perms.read {
276            entry.insert("read".to_string(), ParseValue::Bool(true));
277        }
278        if perms.write {
279            entry.insert("write".to_string(), ParseValue::Bool(true));
280        }
281        map.insert(principal.as_key(), ParseValue::Object(entry));
282    }
283    row.insert("ACL".to_string(), ParseValue::Object(map));
284    row
285}
286
287fn take_string_array(row: &mut ParseMap, key: &str) -> Option<Vec<String>> {
288    match row.shift_remove(key) {
289        Some(ParseValue::Array(items)) => Some(
290            items
291                .into_iter()
292                .filter_map(|v| match v {
293                    ParseValue::String(s) => Some(s),
294                    _ => None,
295                })
296                .collect(),
297        ),
298        _ => None,
299    }
300}
301
302/// Read a client-supplied `ACL` value.
303fn acl_from_value(value: &ParseValue) -> Option<Acl> {
304    let ParseValue::Object(map) = value else {
305        return None;
306    };
307    let mut acl = Acl::new();
308    for (key, entry) in map {
309        let ParseValue::Object(flags) = entry else {
310            continue;
311        };
312        let flag = |name: &str| matches!(flags.get(name), Some(ParseValue::Bool(true)));
313        acl.set(
314            Principal::parse(key),
315            Permissions {
316                read: flag("read"),
317                write: flag("write"),
318            },
319        );
320    }
321    Some(acl)
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    fn row(pairs: Vec<(&str, ParseValue)>) -> ParseMap {
329        let mut m = ParseMap::new();
330        for (k, v) in pairs {
331            m.insert(k.to_string(), v);
332        }
333        m
334    }
335
336    /// The single most important assertion in this module.
337    #[test]
338    fn the_read_constraint_includes_null_so_public_rows_stay_visible() {
339        let c = AclScope::Anonymous
340            .read_constraint()
341            .expect("anonymous is constrained");
342        assert_eq!(c.field, "_rperm");
343        match c.comparison {
344            Comparison::In(values) => {
345                assert!(
346                    values.iter().any(|v| matches!(v, ParseValue::Null)),
347                    "null must be in the list, or every row saved without an ACL becomes invisible"
348                );
349                assert!(values
350                    .iter()
351                    .any(|v| matches!(v, ParseValue::String(s) if s == "*")));
352            }
353            other => panic!("expected In, got {other:?}"),
354        }
355    }
356
357    /// **The array case that is a known divergence, pinned so the eventual fix is visible.**
358    ///
359    /// The test above uses `[]` and `["*"]`, neither of which carries a permission, so it passes
360    /// whether or not arrays are enumerated. `[{"read":true}]` is the case that separates the two:
361    /// upstream's `for...in` grants principal `"0"`, because an array index is a property name, and
362    /// `lower_acl` reads principals from a map only, so it writes two empty columns and grants
363    /// nobody. Measured at the pin, on signup, on a `_User` update and as a CLP-declared default.
364    ///
365    /// This asserts today's behavior rather than upstream's. When `lower_acl` learns to enumerate
366    /// an array, this test fails and is the reminder to move the divergence row with it.
367    #[test]
368    fn a_permission_bearing_array_currently_grants_nobody() {
369        let mut entry = ParseMap::new();
370        entry.insert("read".into(), ParseValue::Bool(true));
371        let lowered = lower_acl(row(vec![(
372            "ACL",
373            ParseValue::Array(vec![ParseValue::Object(entry)]),
374        )]));
375        for column in ["_rperm", "_wperm"] {
376            assert!(
377                matches!(lowered.get(column), Some(ParseValue::Array(a)) if a.is_empty()),
378                "upstream grants principal \"0\" here; parse-rust grants nobody, and the row \
379                 records it. Got {:?} for {column}",
380                lowered.get(column)
381            );
382        }
383    }
384
385    /// The falsy-versus-not-an-object distinction, which is the whole of `lower_acl`'s contract.
386    ///
387    /// A truthy non-object must produce two empty arrays, which is a master-only row. Producing no
388    /// columns instead is a public row, and on `_Role` that is a world-writable role any caller can
389    /// add itself to, because the required-column check tests truthiness and stops there.
390    #[test]
391    fn a_truthy_non_object_acl_writes_empty_columns_rather_than_none() {
392        for value in [
393            ParseValue::String("x".into()),
394            ParseValue::Number(1.0),
395            ParseValue::Array(vec![]),
396            ParseValue::Array(vec![ParseValue::String("*".into())]),
397            ParseValue::Bool(true),
398            ParseValue::Object(ParseMap::new()),
399        ] {
400            let lowered = lower_acl(row(vec![("ACL", value.clone())]));
401            for column in ["_rperm", "_wperm"] {
402                assert!(
403                    matches!(lowered.get(column), Some(ParseValue::Array(a)) if a.is_empty()),
404                    "a truthy ACL must write an empty {column}, got {:?} for {value:?}",
405                    lowered.get(column)
406                );
407            }
408            assert!(!lowered.contains_key("ACL"));
409        }
410    }
411
412    /// The other half. Falsy means no columns, which is a public row, and that is upstream's
413    /// `if (!ACL) return result`.
414    #[test]
415    fn a_falsy_or_absent_acl_writes_no_columns() {
416        for value in [
417            ParseValue::Null,
418            ParseValue::Bool(false),
419            ParseValue::Number(0.0),
420            ParseValue::String(String::new()),
421        ] {
422            let lowered = lower_acl(row(vec![("ACL", value.clone())]));
423            assert!(!lowered.contains_key("_rperm"), "falsy ACL: {value:?}");
424            assert!(!lowered.contains_key("_wperm"), "falsy ACL: {value:?}");
425        }
426        let untouched = lower_acl(row(vec![("title", ParseValue::String("x".into()))]));
427        assert!(!untouched.contains_key("_rperm"));
428        assert!(!untouched.contains_key("_wperm"));
429    }
430
431    #[test]
432    fn master_applies_no_constraint_at_all() {
433        assert!(AclScope::Unrestricted.read_constraint().is_none());
434        assert!(AclScope::Unrestricted.write_constraint().is_none());
435    }
436
437    #[test]
438    fn a_user_scope_carries_its_object_id() {
439        let c = AclScope::user("u1", vec![])
440            .expect("plain id")
441            .read_constraint()
442            .expect("constrained");
443        match c.comparison {
444            Comparison::In(values) => assert!(values
445                .iter()
446                .any(|v| matches!(v, ParseValue::String(s) if s == "u1"))),
447            other => panic!("expected In, got {other:?}"),
448        }
449    }
450
451    /// 0.1.0 emitted no `role:` entry at all, so a `role:Admins` entry in `_rperm` matched
452    /// nobody and every role-protected row was invisible to its own members.
453    #[test]
454    fn a_role_entry_now_matches() {
455        let scope = AclScope::user("u1", vec!["Admins".into(), "Editors".into()]).expect("scope");
456        let c = scope.read_constraint().expect("constrained");
457        match c.comparison {
458            Comparison::In(values) => {
459                let strings: Vec<&str> = values
460                    .iter()
461                    .filter_map(|v| match v {
462                        ParseValue::String(s) => Some(s.as_str()),
463                        _ => None,
464                    })
465                    .collect();
466                assert!(strings.contains(&"role:Admins"), "{strings:?}");
467                assert!(strings.contains(&"role:Editors"), "{strings:?}");
468                assert!(strings.contains(&"u1"));
469            }
470            other => panic!("expected In, got {other:?}"),
471        }
472    }
473
474    /// Upstream order: `['*']`, then roles, then the user id. `addPointerPermissions` recovers
475    /// the single user id by filtering the first two out, so the shape is load bearing.
476    #[test]
477    fn the_acl_group_is_star_then_roles_then_the_user() {
478        let scope = AclScope::user("u1", vec!["A".into()]).expect("scope");
479        assert_eq!(scope.acl_group(), vec!["*", "role:A", "u1"]);
480        assert_eq!(AclScope::Anonymous.acl_group(), vec!["*"]);
481        assert!(AclScope::Unrestricted.acl_group().is_empty());
482    }
483
484    #[test]
485    fn accessors_answer_for_every_variant() {
486        let scope = AclScope::user("u1", vec!["A".into()]).expect("scope");
487        assert!(scope.has_role("A"));
488        assert!(!scope.has_role("role:A"), "roles are stored bare");
489        assert!(!scope.has_role("B"));
490        assert_eq!(scope.user_id(), Some("u1"));
491        assert!(!scope.is_master());
492        assert!(AclScope::Unrestricted.is_master());
493        assert_eq!(AclScope::Anonymous.user_id(), None);
494        assert!(!AclScope::Anonymous.has_role("A"));
495    }
496
497    /// The `role:` objectId collision, guarded at the one place a scope can be built.
498    #[test]
499    fn a_role_prefixed_object_id_is_refused() {
500        let e = AclScope::user("role:Admins", vec![]).unwrap_err();
501        assert_eq!(e.code, parse_rust_core::ErrorCode::InternalServerError);
502        assert_eq!(e.message, "Invalid object ID.");
503    }
504
505    #[test]
506    fn acl_lowers_to_two_columns_and_raises_back() {
507        let mut acl_map = ParseMap::new();
508        let mut public = ParseMap::new();
509        public.insert("read".into(), ParseValue::Bool(true));
510        acl_map.insert("*".into(), ParseValue::Object(public));
511        let mut owner = ParseMap::new();
512        owner.insert("read".into(), ParseValue::Bool(true));
513        owner.insert("write".into(), ParseValue::Bool(true));
514        acl_map.insert("u1".into(), ParseValue::Object(owner));
515
516        let lowered = lower_acl(row(vec![
517            ("title", ParseValue::String("x".into())),
518            ("ACL", ParseValue::Object(acl_map)),
519        ]));
520        assert!(
521            lowered.get("ACL").is_none(),
522            "ACL must not be stored as a field"
523        );
524        assert!(matches!(lowered.get("_rperm"), Some(ParseValue::Array(a)) if a.len() == 2));
525        assert!(matches!(lowered.get("_wperm"), Some(ParseValue::Array(a)) if a.len() == 1));
526
527        let raised = raise_acl(lowered);
528        assert!(raised.get("_rperm").is_none() && raised.get("_wperm").is_none());
529        let ParseValue::Object(acl) = raised.get("ACL").expect("ACL restored") else {
530            panic!("ACL should be an object");
531        };
532        assert!(acl.contains_key("*") && acl.contains_key("u1"));
533    }
534
535    /// UPSTREAM-QUIRK, reproduced end to end.
536    #[test]
537    fn a_false_flag_disappears_on_the_round_trip() {
538        let mut entry = ParseMap::new();
539        entry.insert("read".into(), ParseValue::Bool(true));
540        entry.insert("write".into(), ParseValue::Bool(false));
541        let mut acl_map = ParseMap::new();
542        acl_map.insert("*".into(), ParseValue::Object(entry));
543
544        let raised = raise_acl(lower_acl(row(vec![("ACL", ParseValue::Object(acl_map))])));
545        let ParseValue::Object(acl) = raised.get("ACL").expect("ACL") else {
546            panic!()
547        };
548        let ParseValue::Object(star) = acl.get("*").expect("*") else {
549            panic!()
550        };
551        assert!(star.contains_key("read"));
552        assert!(
553            !star.contains_key("write"),
554            "the false key is dropped, matching untransformObjectACL"
555        );
556    }
557
558    // -----------------------------------------------------------------------------------------
559    // The CLP-declared default ACL
560    // -----------------------------------------------------------------------------------------
561
562    fn declared(json: &str) -> ParseValue {
563        parse_rust_core::decode::classify(
564            serde_json::from_str(json).expect("test literal must be valid JSON"),
565        )
566        .expect("classify")
567    }
568
569    /// What the resolved ACL looks like on the wire, after a round trip through the two columns.
570    /// Asserted this way rather than on the intermediate map, because the columns are what the
571    /// row actually carries and an entry that survives resolution but not lowering grants nothing.
572    fn principals(acl: ParseValue) -> (Vec<String>, Vec<String>) {
573        let mut carrier = ParseMap::new();
574        carrier.insert("ACL".to_string(), acl);
575        let lowered = lower_acl(carrier);
576        let read = take_string_array(&mut lowered.clone(), "_rperm").unwrap_or_default();
577        let write = take_string_array(&mut lowered.clone(), "_wperm").unwrap_or_default();
578        (read, write)
579    }
580
581    /// The headline case, and the one the release is named for: a class declared private, an
582    /// object created by user A, and the caller's own id in both columns.
583    #[test]
584    fn current_user_resolves_to_the_callers_object_id() {
585        let acl = default_acl_for_create(
586            &declared(r#"{"currentUser":{"read":true,"write":true}}"#),
587            Some("userA"),
588        );
589        let ParseValue::Object(map) = &acl else {
590            panic!("expected an object")
591        };
592        assert!(
593            !map.contains_key("currentUser"),
594            "the literal key matches nobody and must not be stored"
595        );
596        assert_eq!(
597            principals(acl),
598            (vec!["userA".to_string()], vec!["userA".to_string()])
599        );
600    }
601
602    /// **Both columns, not just `_rperm`.** They are written separately, so an implementation that
603    /// resolved the read entry and dropped the write one would hide the row from user B and pass a
604    /// read-only test while leaving it writable by everybody.
605    #[test]
606    fn a_read_only_declaration_produces_a_read_only_row() {
607        let acl = default_acl_for_create(&declared(r#"{"currentUser":{"read":true}}"#), Some("u1"));
608        assert_eq!(principals(acl), (vec!["u1".to_string()], Vec::new()));
609    }
610
611    /// With no caller there is no substitute id, and upstream's `delete` is outside the guard, so
612    /// the entry goes. A class whose only declared entry is `currentUser` therefore yields an ACL
613    /// with no principals at all for an anonymous create: readable by master and nobody else.
614    #[test]
615    fn an_anonymous_create_loses_the_current_user_entry_rather_than_keeping_it() {
616        let acl = default_acl_for_create(
617            &declared(r#"{"currentUser":{"read":true,"write":true}}"#),
618            None,
619        );
620        let ParseValue::Object(map) = &acl else {
621            panic!("expected an object")
622        };
623        assert!(map.is_empty(), "the literal key must not survive: {map:?}");
624        assert_eq!(principals(acl), (Vec::new(), Vec::new()));
625    }
626
627    /// Entries other than `currentUser` are carried through untouched, and the caller's own entry
628    /// is appended after them, which is where a JS property assignment puts a new key. The column
629    /// order is observable in a stored row.
630    #[test]
631    fn other_entries_survive_and_the_caller_is_appended() {
632        let acl = default_acl_for_create(
633            &declared(
634                r#"{"role:Admins":{"read":true,"write":true},"currentUser":{"read":true},"*":{"read":true}}"#,
635            ),
636            Some("u1"),
637        );
638        let (read, write) = principals(acl);
639        assert_eq!(read, vec!["role:Admins", "*", "u1"]);
640        assert_eq!(write, vec!["role:Admins"]);
641    }
642
643    /// An id already present is updated in place rather than moved to the end, which is what a
644    /// JavaScript assignment to an existing key does.
645    #[test]
646    fn a_caller_already_named_keeps_its_position() {
647        let acl = default_acl_for_create(
648            &declared(
649                r#"{"u1":{"read":true},"*":{"read":true},"currentUser":{"read":true,"write":true}}"#,
650            ),
651            Some("u1"),
652        );
653        let (read, write) = principals(acl);
654        assert_eq!(read, vec!["u1", "*"]);
655        assert_eq!(write, vec!["u1"], "the currentUser entry replaced it");
656    }
657
658    /// `if (acl.currentUser)` is truthiness, so a falsy entry is neither resolved nor deleted.
659    #[test]
660    fn a_falsy_current_user_entry_is_left_alone() {
661        let acl = default_acl_for_create(&declared(r#"{"currentUser":null}"#), Some("u1"));
662        let ParseValue::Object(map) = &acl else {
663            panic!("expected an object")
664        };
665        assert!(map.contains_key("currentUser"));
666        assert!(!map.contains_key("u1"));
667    }
668
669    /// A truthy non-object is assigned verbatim and lowered like any other truthy non-object ACL:
670    /// two empty columns, which is a master-only row rather than a public one.
671    #[test]
672    fn a_truthy_non_object_declaration_yields_a_master_only_row() {
673        let acl = default_acl_for_create(&declared(r#""nonsense""#), Some("u1"));
674        assert!(matches!(&acl, ParseValue::String(s) if s == "nonsense"));
675        let mut carrier = ParseMap::new();
676        carrier.insert("ACL".to_string(), acl);
677        let lowered = lower_acl(carrier);
678        for column in ["_rperm", "_wperm"] {
679            assert!(matches!(lowered.get(column), Some(ParseValue::Array(a)) if a.is_empty()));
680        }
681    }
682
683    #[test]
684    fn a_row_with_no_acl_gets_no_columns_and_no_acl_key_back() {
685        let lowered = lower_acl(row(vec![("title", ParseValue::String("x".into()))]));
686        assert!(lowered.get("_rperm").is_none());
687        let raised = raise_acl(lowered);
688        assert!(
689            raised.get("ACL").is_none(),
690            "absent columns produce no ACL key at all, not null and not an empty object"
691        );
692    }
693}