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(¤t_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}