use std::collections::HashSet;
use indexmap::IndexSet;
use parse_rust_core::{ParseError, ParseValue, Principal};
use parse_rust_schema::default_schema;
use parse_rust_storage::{
join_schema, ClassSchema, Constraint, Query, QueryOptions, StorageAdapter,
};
const ROLE_CLASS: &str = "_Role";
const USERS_KEY: &str = "users";
const ROLES_KEY: &str = "roles";
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct RoleName(String);
impl RoleName {
pub fn new(name: impl Into<String>) -> Self {
Self(name.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn to_principal(&self) -> Principal {
Principal::Role(self.0.clone())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RolePrincipal<'a> {
Master,
Maintenance,
Anonymous,
User(&'a str),
}
pub async fn expand_roles<S: StorageAdapter>(
storage: &S,
principal: RolePrincipal<'_>,
) -> Result<Vec<RoleName>, ParseError> {
let user_object_id = match principal {
RolePrincipal::Master | RolePrincipal::Maintenance | RolePrincipal::Anonymous => {
return Ok(Vec::new())
}
RolePrincipal::User(id) => id,
};
let role_schema = default_schema(ROLE_CLASS);
let direct_ids = owning_ids(storage, USERS_KEY, &[user_object_id.to_string()]).await?;
if direct_ids.is_empty() {
return Ok(Vec::new());
}
let direct = fetch_roles(storage, &role_schema, &direct_ids).await?;
if direct.is_empty() {
return Ok(Vec::new());
}
let mut names: IndexSet<RoleName> = IndexSet::new();
let mut frontier: Vec<String> = Vec::new();
for role in direct {
if let Some(name) = role.name {
names.insert(RoleName(name));
}
frontier.push(role.object_id);
}
let mut queried: HashSet<String> = HashSet::new();
loop {
let ins: Vec<String> = frontier
.into_iter()
.filter(|id| queried.insert(id.clone()))
.collect();
if ins.is_empty() {
break;
}
let parent_ids = owning_ids(storage, ROLES_KEY, &ins).await?;
if parent_ids.is_empty() {
break;
}
let parents = fetch_roles(storage, &role_schema, &parent_ids).await?;
if parents.is_empty() {
break;
}
frontier = Vec::with_capacity(parents.len());
for role in parents {
if let Some(name) = role.name {
names.insert(RoleName(name));
}
frontier.push(role.object_id);
}
}
Ok(names.into_iter().collect())
}
struct Role {
object_id: String,
name: Option<String>,
}
async fn owning_ids<S: StorageAdapter>(
storage: &S,
key: &str,
related_ids: &[String],
) -> Result<Vec<String>, ParseError> {
if related_ids.is_empty() {
return Ok(Vec::new());
}
let schema = join_schema(ROLE_CLASS, key);
let query = Query::from_constraints(vec![Constraint::one_of(
"relatedId",
related_ids
.iter()
.map(|id| ParseValue::String(id.clone()))
.collect(),
)]);
let options = QueryOptions {
limit: None,
skip: None,
order: Vec::new(),
keys: Some(vec!["owningId".to_string()]),
case_insensitive: false,
};
let rows = storage.find(&schema, &query, &options).await?;
Ok(rows
.into_iter()
.filter_map(|row| match row.get("owningId") {
Some(ParseValue::String(id)) => Some(id.clone()),
_ => None,
})
.collect())
}
async fn fetch_roles<S: StorageAdapter>(
storage: &S,
schema: &ClassSchema,
object_ids: &[String],
) -> Result<Vec<Role>, ParseError> {
if object_ids.is_empty() {
return Ok(Vec::new());
}
let query = Query::from_constraints(vec![Constraint::one_of(
"objectId",
object_ids
.iter()
.map(|id| ParseValue::String(id.clone()))
.collect(),
)]);
let options = QueryOptions {
limit: None,
skip: None,
order: Vec::new(),
keys: None,
case_insensitive: false,
};
let rows = storage.find(schema, &query, &options).await?;
Ok(rows
.into_iter()
.filter_map(|row| {
let object_id = match row.get("objectId") {
Some(ParseValue::String(id)) => id.clone(),
_ => return None,
};
let name = match row.get("name") {
Some(ParseValue::String(name)) => Some(name.clone()),
_ => None,
};
Some(Role { object_id, name })
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::FakeStorage;
use parse_rust_storage::join_table_name;
fn graph(
roles: &[(&str, &str)],
members: &[(&str, &str)],
contains: &[(&str, &str)],
) -> FakeStorage {
let s = FakeStorage::new();
for (object_id, name) in roles {
s.insert_row(
"_Role",
vec![
("objectId", ParseValue::String((*object_id).into())),
("name", ParseValue::String((*name).into())),
],
);
}
for (user, role) in members {
s.insert_row(
&join_table_name("_Role", "users"),
vec![
("relatedId", ParseValue::String((*user).into())),
("owningId", ParseValue::String((*role).into())),
],
);
}
for (child, parent) in contains {
s.insert_row(
&join_table_name("_Role", "roles"),
vec![
("relatedId", ParseValue::String((*child).into())),
("owningId", ParseValue::String((*parent).into())),
],
);
}
s
}
fn names(roles: &[RoleName]) -> Vec<&str> {
roles.iter().map(RoleName::as_str).collect()
}
#[tokio::test]
async fn master_maintenance_and_anonymous_never_reach_storage() {
let s = graph(&[("r1", "Admins")], &[("u1", "r1")], &[]);
for principal in [
RolePrincipal::Master,
RolePrincipal::Maintenance,
RolePrincipal::Anonymous,
] {
s.reset_find_count();
let roles = expand_roles(&s, principal).await.expect("expand");
assert!(roles.is_empty(), "{principal:?} must expand to no roles");
assert_eq!(s.find_count(), 0, "{principal:?} must issue no query");
}
}
#[tokio::test]
async fn a_user_in_no_role_gets_an_empty_list() {
let s = graph(&[("r1", "Admins")], &[("u1", "r1")], &[]);
let roles = expand_roles(&s, RolePrincipal::User("u2"))
.await
.expect("expand");
assert!(roles.is_empty());
}
#[tokio::test]
async fn direct_membership_resolves_through_the_join_collection() {
let s = graph(
&[("r1", "Admins"), ("r2", "Editors"), ("r3", "Nobody")],
&[("u1", "r1"), ("u1", "r2"), ("u2", "r3")],
&[],
);
let roles = expand_roles(&s, RolePrincipal::User("u1"))
.await
.expect("expand");
assert_eq!(names(&roles), vec!["Admins", "Editors"]);
}
#[tokio::test]
async fn transitive_membership_walks_upward_not_downward() {
let s = graph(
&[("r1", "Members"), ("r2", "Moderators"), ("r3", "Admins")],
&[("member", "r1"), ("admin", "r3")],
&[("r1", "r2"), ("r2", "r3")],
);
let roles = expand_roles(&s, RolePrincipal::User("member"))
.await
.expect("expand");
assert_eq!(names(&roles), vec!["Members", "Moderators", "Admins"]);
let roles = expand_roles(&s, RolePrincipal::User("admin"))
.await
.expect("expand");
assert_eq!(
names(&roles),
vec!["Admins"],
"containment does not flow downward"
);
}
#[tokio::test]
async fn a_cycle_terminates_and_returns_both_names() {
let s = graph(
&[("a", "Alpha"), ("b", "Beta")],
&[("u1", "a")],
&[("a", "b"), ("b", "a")],
);
let roles = expand_roles(&s, RolePrincipal::User("u1"))
.await
.expect("expand");
assert_eq!(names(&roles), vec!["Alpha", "Beta"]);
}
#[tokio::test]
async fn a_self_referential_role_terminates() {
let s = graph(&[("a", "Alpha")], &[("u1", "a")], &[("a", "a")]);
let roles = expand_roles(&s, RolePrincipal::User("u1"))
.await
.expect("expand");
assert_eq!(names(&roles), vec!["Alpha"]);
}
#[tokio::test]
async fn more_than_a_hundred_roles_are_all_returned() {
let ids: Vec<String> = (0..250).map(|i| format!("role{i:04}")).collect();
let roles: Vec<(&str, &str)> = ids.iter().map(|id| (id.as_str(), id.as_str())).collect();
let members: Vec<(&str, &str)> = ids.iter().map(|id| ("u1", id.as_str())).collect();
let s = graph(&roles, &members, &[]);
let resolved = expand_roles(&s, RolePrincipal::User("u1"))
.await
.expect("expand");
assert_eq!(
resolved.len(),
250,
"the default page size of 100 must not reach the join or role reads"
);
}
#[tokio::test]
async fn the_query_count_is_per_level_not_per_role() {
let mut roles: Vec<(String, String)> = (0..20)
.map(|i| (format!("r{i:02}"), format!("Role{i:02}")))
.collect();
roles.push(("top".to_string(), "Top".to_string()));
let role_refs: Vec<(&str, &str)> = roles
.iter()
.map(|(a, b)| (a.as_str(), b.as_str()))
.collect();
let members: Vec<(&str, &str)> = roles[..20]
.iter()
.map(|(a, _)| ("u1", a.as_str()))
.collect();
let contains: Vec<(&str, &str)> = roles[..20]
.iter()
.map(|(a, _)| (a.as_str(), "top"))
.collect();
let s = graph(&role_refs, &members, &contains);
s.reset_find_count();
let resolved = expand_roles(&s, RolePrincipal::User("u1"))
.await
.expect("expand");
assert_eq!(resolved.len(), 21);
assert_eq!(s.find_count(), 5);
}
#[tokio::test]
async fn a_role_reachable_by_two_paths_appears_once() {
let s = graph(
&[("r1", "Editors"), ("r2", "Reviewers"), ("r3", "Staff")],
&[("u1", "r1"), ("u1", "r2")],
&[("r1", "r3"), ("r2", "r3")],
);
let roles = expand_roles(&s, RolePrincipal::User("u1"))
.await
.expect("expand");
assert_eq!(names(&roles), vec!["Editors", "Reviewers", "Staff"]);
}
#[tokio::test]
async fn a_role_row_with_no_name_contributes_its_ancestors_but_no_principal() {
let s = FakeStorage::new();
s.insert_row(
"_Role",
vec![("objectId", ParseValue::String("broken".into()))],
);
s.insert_row(
"_Role",
vec![
("objectId", ParseValue::String("parent".into())),
("name", ParseValue::String("Parent".into())),
],
);
s.insert_row(
&join_table_name("_Role", "users"),
vec![
("relatedId", ParseValue::String("u1".into())),
("owningId", ParseValue::String("broken".into())),
],
);
s.insert_row(
&join_table_name("_Role", "roles"),
vec![
("relatedId", ParseValue::String("broken".into())),
("owningId", ParseValue::String("parent".into())),
],
);
let roles = expand_roles(&s, RolePrincipal::User("u1"))
.await
.expect("expand");
assert_eq!(names(&roles), vec!["Parent"]);
}
#[test]
fn a_role_name_holds_the_bare_name_and_prefixes_only_on_request() {
let r = RoleName::new("Admins");
assert_eq!(r.as_str(), "Admins");
assert_eq!(r.to_principal(), Principal::Role("Admins".into()));
assert_eq!(r.to_principal().as_key(), "role:Admins");
}
#[test]
fn a_role_named_like_a_principal_is_not_unwrapped() {
let r = RoleName::new("role:Admins");
assert_eq!(r.as_str(), "role:Admins");
assert_eq!(r.to_principal().as_key(), "role:role:Admins");
}
}