Skip to main content

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}