parse_rust_auth/roles.rs
1//! The role graph.
2//!
3//! A user's roles are the roles they belong to directly, plus every role that transitively
4//! contains one of those. Upstream computes it in `Auth.prototype._loadRoles`
5//! (`Auth.js:297-336`) and `_getAllRolesNamesForRoleIds` (`Auth.js:393-428`), and this is a port
6//! of that, not a reimplementation of the idea.
7//!
8//! **Unauthenticated against storage, deliberately.** Upstream queries `_Role` under
9//! `master(this.config)` in both directions (`Auth.js:283`, `:383`). It has to: the role names
10//! are an input to every later ACL and CLP decision, so gating them on one would be circular.
11//! What keeps that narrow is that nothing here takes a client query. The inputs are a user
12//! objectId and a set of role objectIds this module produced itself, and the output is a list of
13//! role names.
14//!
15//! **No cache, on purpose.** Upstream caches the expanded list per user with a 5 second TTL and
16//! clears the whole role cache on any `_Role` write (`RestWrite.js:1565-1570`). The invalidation
17//! is the load-bearing half: a cache that keeps the TTL and drops the invalidation serves stale
18//! *authorization* for up to five seconds after a role membership is revoked, which is worse than
19//! not caching at all. Adding one is 0.3.0 work and it lands with the invalidation or not at all.
20//! Until then a request with deep role nesting issues two queries per level of the graph.
21//!
22//! **Relation reads go straight to the join collections.** `_Role.users` and `_Role.roles` are
23//! `Relation` fields, which have no column at all: membership lives in `_Join:users:_Role` and
24//! `_Join:roles:_Role`, whose documents are exactly `{relatedId, owningId}`
25//! (`DatabaseController.js:418-420`, `:794-806`) and which have **no `_SCHEMA` row**. That is why
26//! the schema comes from [`join_schema`] rather than from storage, and why nothing here ever
27//! writes one.
28
29use std::collections::HashSet;
30
31use indexmap::IndexSet;
32
33use parse_rust_core::{ParseError, ParseValue, Principal};
34use parse_rust_schema::default_schema;
35use parse_rust_storage::{
36 join_schema, ClassSchema, Constraint, Query, QueryOptions, StorageAdapter,
37};
38
39const ROLE_CLASS: &str = "_Role";
40/// `_Role.users`, backed by `_Join:users:_Role`.
41const USERS_KEY: &str = "users";
42/// `_Role.roles`, backed by `_Join:roles:_Role`.
43const ROLES_KEY: &str = "roles";
44
45/// A role name, **without** the `role:` prefix.
46///
47/// The prefix is the whole reason this is a newtype. Upstream's `_loadRoles` returns
48/// `'role:' + r` (`Auth.js:329-331`) while `getRolesForUser` deals in bare names, so a
49/// `Vec<String>` crossing between them carries no evidence of which form it holds. Getting that
50/// wrong in the unprefixing direction is how a user whose objectId begins with `role:` comes to
51/// match a role ACL entry, which is the collision upstream guards against separately at
52/// `Auth.js:195` and `:237`.
53///
54/// This type holds the bare name. [`RoleName::to_principal`] is the only way to get the prefixed
55/// form, and it produces a [`Principal`] rather than a `String`, so the prefixed spelling exists
56/// in exactly one place.
57#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
58pub struct RoleName(String);
59
60impl RoleName {
61 pub fn new(name: impl Into<String>) -> Self {
62 Self(name.into())
63 }
64
65 /// The bare name, as stored in `_Role.name`.
66 pub fn as_str(&self) -> &str {
67 &self.0
68 }
69
70 /// The ACL principal this role grants: `role:<name>`.
71 pub fn to_principal(&self) -> Principal {
72 Principal::Role(self.0.clone())
73 }
74}
75
76/// Who is asking.
77///
78/// Upstream's short-circuit is `if (this.isMaster || this.isMaintenance || !this.user) return []`
79/// (`Auth.js:253-256`). Modelling it as an argument rather than leaving it to the caller means
80/// there is no way to call this "for the master key" and get a real role list back: every variant
81/// is matched here, and three of them never reach storage.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum RolePrincipal<'a> {
84 /// The master key. Bypasses roles entirely, because it bypasses everything roles feed.
85 Master,
86 /// The maintenance key. Same.
87 Maintenance,
88 /// No session. Note this is *not* Parse's anonymous auth provider, which produces a real
89 /// `_User` and takes the `User` arm; it is the absence of a user.
90 Anonymous,
91 User(&'a str),
92}
93
94/// Every role a principal holds, direct and transitive, deduplicated.
95///
96/// The order is upstream's: direct role names first, in the order the `_Role` rows came back,
97/// then each level of ancestors in turn. Duplicates are dropped on first sight, which is what
98/// `[...new Set(names)]` does (`Auth.js:402`).
99///
100/// **The traversal cuts cycles, it does not reject them.** Upstream marks each role objectId into
101/// `queriedRoles` as it builds the next frontier and filters the frontier against it
102/// (`Auth.js:393-398`), so `A -> B -> A` terminates with both names and no error. A cycle check
103/// that raised would be a behavior change, and no cycle check at all is an infinite loop. The
104/// marking is the whole mechanism; see `a_cycle_terminates_and_returns_both_names`.
105///
106/// **Depth is unbounded and the cost is per level, not per role.** Each level is one read of the
107/// join collection followed by one read of `_Role`, regardless of how wide the frontier is. A
108/// per-role query would turn a wide role graph into a query storm.
109pub async fn expand_roles<S: StorageAdapter>(
110 storage: &S,
111 principal: RolePrincipal<'_>,
112) -> Result<Vec<RoleName>, ParseError> {
113 let user_object_id = match principal {
114 RolePrincipal::Master | RolePrincipal::Maintenance | RolePrincipal::Anonymous => {
115 return Ok(Vec::new())
116 }
117 RolePrincipal::User(id) => id,
118 };
119
120 let role_schema = default_schema(ROLE_CLASS);
121
122 // Direct membership: `getRolesForUser` queries `_Role` for `{users: <user pointer>}`
123 // (`Auth.js:267-294`). An equal-to-pointer constraint on a Relation field is not a column
124 // read; `reduceInRelation` turns it into `owningIds(className, key, [userId])`
125 // (`DatabaseController.js:1050`, `:1036-1044`), which is a read of the join collection.
126 let direct_ids = owning_ids(storage, USERS_KEY, &[user_object_id.to_string()]).await?;
127 if direct_ids.is_empty() {
128 return Ok(Vec::new());
129 }
130 let direct = fetch_roles(storage, &role_schema, &direct_ids).await?;
131 if direct.is_empty() {
132 return Ok(Vec::new());
133 }
134
135 let mut names: IndexSet<RoleName> = IndexSet::new();
136 let mut frontier: Vec<String> = Vec::new();
137 for role in direct {
138 if let Some(name) = role.name {
139 names.insert(RoleName(name));
140 }
141 frontier.push(role.object_id);
142 }
143
144 // `_getAllRolesNamesForRoleIds`. `queried` is upstream's `queriedRoles`, and filtering the
145 // frontier against it is what makes a cyclic graph terminate.
146 let mut queried: HashSet<String> = HashSet::new();
147 loop {
148 let ins: Vec<String> = frontier
149 .into_iter()
150 .filter(|id| queried.insert(id.clone()))
151 .collect();
152 if ins.is_empty() {
153 break;
154 }
155
156 // `getRolesByIds` queries `_Role` for `{roles: {$in: <role pointers>}}` (`Auth.js:377`),
157 // meaning the roles that CONTAIN these roles. Same reduction as above, against the other
158 // join collection.
159 let parent_ids = owning_ids(storage, ROLES_KEY, &ins).await?;
160 if parent_ids.is_empty() {
161 break;
162 }
163 let parents = fetch_roles(storage, &role_schema, &parent_ids).await?;
164 if parents.is_empty() {
165 break;
166 }
167
168 frontier = Vec::with_capacity(parents.len());
169 for role in parents {
170 if let Some(name) = role.name {
171 names.insert(RoleName(name));
172 }
173 frontier.push(role.object_id);
174 }
175 }
176
177 Ok(names.into_iter().collect())
178}
179
180/// A `_Role` row reduced to the two fields the traversal needs.
181///
182/// `name` is optional because a stored row can lack one; see [`fetch_roles`].
183struct Role {
184 object_id: String,
185 name: Option<String>,
186}
187
188/// `DatabaseController.owningIds` (`:1036-1044`): the owning ids of every join document whose
189/// `relatedId` is in the given set.
190///
191/// **No limit.** `QueryOptions::default()` carries Parse's 100-row page size, which is correct
192/// for a client query and wrong here: a role with more than a hundred members would silently lose
193/// the rest, and a silently short role list is an under-grant that looks exactly like a correct
194/// deny. Upstream reads the join collection through the adapter directly, below the REST limit,
195/// and iterates the `_Role` side with `query.each` (`RestQuery.js:318-348`) so neither side is
196/// capped.
197async fn owning_ids<S: StorageAdapter>(
198 storage: &S,
199 key: &str,
200 related_ids: &[String],
201) -> Result<Vec<String>, ParseError> {
202 if related_ids.is_empty() {
203 return Ok(Vec::new());
204 }
205 let schema = join_schema(ROLE_CLASS, key);
206 let query = Query::from_constraints(vec![Constraint::one_of(
207 "relatedId",
208 related_ids
209 .iter()
210 .map(|id| ParseValue::String(id.clone()))
211 .collect(),
212 )]);
213 let options = QueryOptions {
214 limit: None,
215 skip: None,
216 order: Vec::new(),
217 keys: Some(vec!["owningId".to_string()]),
218 case_insensitive: false,
219 };
220
221 let rows = storage.find(&schema, &query, &options).await?;
222 Ok(rows
223 .into_iter()
224 .filter_map(|row| match row.get("owningId") {
225 Some(ParseValue::String(id)) => Some(id.clone()),
226 _ => None,
227 })
228 .collect())
229}
230
231/// Fetch `_Role` rows by objectId.
232///
233/// The schema is `default_schema("_Role")` rather than the stored one. Both fields read here,
234/// `objectId` and `name`, are default columns of `_Role` (`SchemaController.js:64-69`) that a
235/// client cannot redefine, and neither `users` nor `roles` has a column to raise. A `_Role` class
236/// carrying extra application fields still reads correctly, because the stored form is
237/// self-describing everywhere it matters.
238///
239/// A row whose `name` is missing or not a string contributes **its id but not a name**. Upstream
240/// would push `undefined` and later produce the principal `role:undefined`, which is a phantom
241/// role that an ACL could name. Dropping the name rather than reproducing that is a deliberate
242/// difference, and it is the safe direction: keeping the id means no ancestor of such a row is
243/// lost, so nothing is under-granted. The row is not reachable through any Parse API, because
244/// `name` is a required write column of `_Role`.
245async fn fetch_roles<S: StorageAdapter>(
246 storage: &S,
247 schema: &ClassSchema,
248 object_ids: &[String],
249) -> Result<Vec<Role>, ParseError> {
250 if object_ids.is_empty() {
251 return Ok(Vec::new());
252 }
253 let query = Query::from_constraints(vec![Constraint::one_of(
254 "objectId",
255 object_ids
256 .iter()
257 .map(|id| ParseValue::String(id.clone()))
258 .collect(),
259 )]);
260 let options = QueryOptions {
261 limit: None,
262 skip: None,
263 order: Vec::new(),
264 keys: None,
265 case_insensitive: false,
266 };
267
268 let rows = storage.find(schema, &query, &options).await?;
269 Ok(rows
270 .into_iter()
271 .filter_map(|row| {
272 let object_id = match row.get("objectId") {
273 Some(ParseValue::String(id)) => id.clone(),
274 _ => return None,
275 };
276 let name = match row.get("name") {
277 Some(ParseValue::String(name)) => Some(name.clone()),
278 _ => None,
279 };
280 Some(Role { object_id, name })
281 })
282 .collect())
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use crate::testing::FakeStorage;
289 use parse_rust_storage::join_table_name;
290
291 /// Build a role graph. `members` is user objectIds in `_Join:users:_Role`; `contains` is
292 /// `(child, parent)` pairs, meaning the parent role's `roles` relation holds the child.
293 fn graph(
294 roles: &[(&str, &str)],
295 members: &[(&str, &str)],
296 contains: &[(&str, &str)],
297 ) -> FakeStorage {
298 let s = FakeStorage::new();
299 for (object_id, name) in roles {
300 s.insert_row(
301 "_Role",
302 vec![
303 ("objectId", ParseValue::String((*object_id).into())),
304 ("name", ParseValue::String((*name).into())),
305 ],
306 );
307 }
308 for (user, role) in members {
309 s.insert_row(
310 &join_table_name("_Role", "users"),
311 vec![
312 ("relatedId", ParseValue::String((*user).into())),
313 ("owningId", ParseValue::String((*role).into())),
314 ],
315 );
316 }
317 for (child, parent) in contains {
318 s.insert_row(
319 &join_table_name("_Role", "roles"),
320 vec![
321 ("relatedId", ParseValue::String((*child).into())),
322 ("owningId", ParseValue::String((*parent).into())),
323 ],
324 );
325 }
326 s
327 }
328
329 fn names(roles: &[RoleName]) -> Vec<&str> {
330 roles.iter().map(RoleName::as_str).collect()
331 }
332
333 #[tokio::test]
334 async fn master_maintenance_and_anonymous_never_reach_storage() {
335 let s = graph(&[("r1", "Admins")], &[("u1", "r1")], &[]);
336 for principal in [
337 RolePrincipal::Master,
338 RolePrincipal::Maintenance,
339 RolePrincipal::Anonymous,
340 ] {
341 s.reset_find_count();
342 let roles = expand_roles(&s, principal).await.expect("expand");
343 assert!(roles.is_empty(), "{principal:?} must expand to no roles");
344 assert_eq!(s.find_count(), 0, "{principal:?} must issue no query");
345 }
346 }
347
348 #[tokio::test]
349 async fn a_user_in_no_role_gets_an_empty_list() {
350 let s = graph(&[("r1", "Admins")], &[("u1", "r1")], &[]);
351 let roles = expand_roles(&s, RolePrincipal::User("u2"))
352 .await
353 .expect("expand");
354 assert!(roles.is_empty());
355 }
356
357 #[tokio::test]
358 async fn direct_membership_resolves_through_the_join_collection() {
359 let s = graph(
360 &[("r1", "Admins"), ("r2", "Editors"), ("r3", "Nobody")],
361 &[("u1", "r1"), ("u1", "r2"), ("u2", "r3")],
362 &[],
363 );
364 let roles = expand_roles(&s, RolePrincipal::User("u1"))
365 .await
366 .expect("expand");
367 assert_eq!(names(&roles), vec!["Admins", "Editors"]);
368 }
369
370 /// The direction is upward: a member of the inner role gains the names of every role that
371 /// contains it. Getting this backwards grants the wrong set and is not caught by a
372 /// single-level test.
373 #[tokio::test]
374 async fn transitive_membership_walks_upward_not_downward() {
375 // Admins contains Moderators contains Members. A Member is only a Member.
376 let s = graph(
377 &[("r1", "Members"), ("r2", "Moderators"), ("r3", "Admins")],
378 &[("member", "r1"), ("admin", "r3")],
379 &[("r1", "r2"), ("r2", "r3")],
380 );
381
382 let roles = expand_roles(&s, RolePrincipal::User("member"))
383 .await
384 .expect("expand");
385 assert_eq!(names(&roles), vec!["Members", "Moderators", "Admins"]);
386
387 let roles = expand_roles(&s, RolePrincipal::User("admin"))
388 .await
389 .expect("expand");
390 assert_eq!(
391 names(&roles),
392 vec!["Admins"],
393 "containment does not flow downward"
394 );
395 }
396
397 /// The cycle break. Upstream marks ids into `queriedRoles` and filters the next frontier
398 /// against it, so this terminates with both names and no error. A cycle check that raised
399 /// would be a behavior change; a missing one would hang this test forever.
400 #[tokio::test]
401 async fn a_cycle_terminates_and_returns_both_names() {
402 let s = graph(
403 &[("a", "Alpha"), ("b", "Beta")],
404 &[("u1", "a")],
405 &[("a", "b"), ("b", "a")],
406 );
407 let roles = expand_roles(&s, RolePrincipal::User("u1"))
408 .await
409 .expect("expand");
410 assert_eq!(names(&roles), vec!["Alpha", "Beta"]);
411 }
412
413 #[tokio::test]
414 async fn a_self_referential_role_terminates() {
415 let s = graph(&[("a", "Alpha")], &[("u1", "a")], &[("a", "a")]);
416 let roles = expand_roles(&s, RolePrincipal::User("u1"))
417 .await
418 .expect("expand");
419 assert_eq!(names(&roles), vec!["Alpha"]);
420 }
421
422 /// The failure this exists to catch is a silent under-grant: `QueryOptions::default()` caps
423 /// at 100 rows, so a user in more than a hundred roles would lose the rest and the request
424 /// would look like a correct deny.
425 #[tokio::test]
426 async fn more_than_a_hundred_roles_are_all_returned() {
427 let ids: Vec<String> = (0..250).map(|i| format!("role{i:04}")).collect();
428 let roles: Vec<(&str, &str)> = ids.iter().map(|id| (id.as_str(), id.as_str())).collect();
429 let members: Vec<(&str, &str)> = ids.iter().map(|id| ("u1", id.as_str())).collect();
430 let s = graph(&roles, &members, &[]);
431
432 let resolved = expand_roles(&s, RolePrincipal::User("u1"))
433 .await
434 .expect("expand");
435 assert_eq!(
436 resolved.len(),
437 250,
438 "the default page size of 100 must not reach the join or role reads"
439 );
440 }
441
442 /// One read of the join collection and one of `_Role` per level, regardless of how many roles
443 /// are in the frontier. A per-role query would turn a wide graph into a query storm.
444 #[tokio::test]
445 async fn the_query_count_is_per_level_not_per_role() {
446 // Twenty roles at the bottom, all contained by one role at the top.
447 let mut roles: Vec<(String, String)> = (0..20)
448 .map(|i| (format!("r{i:02}"), format!("Role{i:02}")))
449 .collect();
450 roles.push(("top".to_string(), "Top".to_string()));
451 let role_refs: Vec<(&str, &str)> = roles
452 .iter()
453 .map(|(a, b)| (a.as_str(), b.as_str()))
454 .collect();
455 let members: Vec<(&str, &str)> = roles[..20]
456 .iter()
457 .map(|(a, _)| ("u1", a.as_str()))
458 .collect();
459 let contains: Vec<(&str, &str)> = roles[..20]
460 .iter()
461 .map(|(a, _)| (a.as_str(), "top"))
462 .collect();
463
464 let s = graph(&role_refs, &members, &contains);
465 s.reset_find_count();
466 let resolved = expand_roles(&s, RolePrincipal::User("u1"))
467 .await
468 .expect("expand");
469 assert_eq!(resolved.len(), 21);
470 // Level 0: join read + role read. Level 1: join read + role read. Level 2: join read
471 // returns nothing and stops. Nothing scales with the twenty-role frontier.
472 assert_eq!(s.find_count(), 5);
473 }
474
475 #[tokio::test]
476 async fn a_role_reachable_by_two_paths_appears_once() {
477 // Both Editors and Reviewers are contained by Staff.
478 let s = graph(
479 &[("r1", "Editors"), ("r2", "Reviewers"), ("r3", "Staff")],
480 &[("u1", "r1"), ("u1", "r2")],
481 &[("r1", "r3"), ("r2", "r3")],
482 );
483 let roles = expand_roles(&s, RolePrincipal::User("u1"))
484 .await
485 .expect("expand");
486 assert_eq!(names(&roles), vec!["Editors", "Reviewers", "Staff"]);
487 }
488
489 #[tokio::test]
490 async fn a_role_row_with_no_name_contributes_its_ancestors_but_no_principal() {
491 let s = FakeStorage::new();
492 s.insert_row(
493 "_Role",
494 vec![("objectId", ParseValue::String("broken".into()))],
495 );
496 s.insert_row(
497 "_Role",
498 vec![
499 ("objectId", ParseValue::String("parent".into())),
500 ("name", ParseValue::String("Parent".into())),
501 ],
502 );
503 s.insert_row(
504 &join_table_name("_Role", "users"),
505 vec![
506 ("relatedId", ParseValue::String("u1".into())),
507 ("owningId", ParseValue::String("broken".into())),
508 ],
509 );
510 s.insert_row(
511 &join_table_name("_Role", "roles"),
512 vec![
513 ("relatedId", ParseValue::String("broken".into())),
514 ("owningId", ParseValue::String("parent".into())),
515 ],
516 );
517
518 let roles = expand_roles(&s, RolePrincipal::User("u1"))
519 .await
520 .expect("expand");
521 // No phantom principal from the nameless row, and the ancestor is still found.
522 assert_eq!(names(&roles), vec!["Parent"]);
523 }
524
525 #[test]
526 fn a_role_name_holds_the_bare_name_and_prefixes_only_on_request() {
527 let r = RoleName::new("Admins");
528 assert_eq!(r.as_str(), "Admins");
529 assert_eq!(r.to_principal(), Principal::Role("Admins".into()));
530 assert_eq!(r.to_principal().as_key(), "role:Admins");
531 }
532
533 /// A role whose name itself begins with `role:` must not double-prefix or unprefix. The
534 /// newtype holds the bare name, so `role:role:x` is the only correct rendering.
535 #[test]
536 fn a_role_named_like_a_principal_is_not_unwrapped() {
537 let r = RoleName::new("role:Admins");
538 assert_eq!(r.as_str(), "role:Admins");
539 assert_eq!(r.to_principal().as_key(), "role:role:Admins");
540 }
541}