Skip to main content

cloudillo_types/
roles.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Role hierarchy and expansion.
5//!
6//! Lives here rather than in `cloudillo-core` because both the core crate and the auth adapters
7//! must mint role strings the exact same way: login (`build_tenant_owner_roles` in
8//! auth-adapter-sqlite) and access-token refresh (`cloudillo_auth::handler`) produce the tenant
9//! owner's roles independently, and any divergence silently widens or narrows the site admin's
10//! authority depending on which issued their token. `cloudillo_core::roles` re-exports everything
11//! here, so core-side callers see no difference.
12
13/// Role hierarchy for profile-level permissions
14/// Higher roles inherit all permissions from lower roles
15pub const ROLE_HIERARCHY: &[&str] =
16	&["public", "follower", "supporter", "contributor", "moderator", "leader"];
17
18/// Hierarchy index of a single role, or None if unknown.
19pub fn role_level(role: &str) -> Option<usize> {
20	ROLE_HIERARCHY.iter().position(|&r| r == role)
21}
22
23/// Expands hierarchical roles from highest role to all inherited roles
24///
25/// Given a list of roles (typically just the highest one), this function
26/// returns a comma-separated string of all roles from "public" up to and
27/// including the highest role in the hierarchy.
28///
29/// # Examples
30/// ```
31/// use cloudillo_types::roles::expand_roles;
32/// assert_eq!(expand_roles(&["moderator".into()]), "public,follower,supporter,contributor,moderator");
33/// assert_eq!(expand_roles(&["contributor".into(), "moderator".into()]), "public,follower,supporter,contributor,moderator");
34/// assert_eq!(expand_roles(&[]), "");
35/// ```
36pub fn expand_roles(highest_roles: &[Box<str>]) -> String {
37	if highest_roles.is_empty() {
38		return String::new();
39	}
40
41	let mut highest_idx: Option<usize> = None;
42	for role in highest_roles {
43		if let Some(idx) = ROLE_HIERARCHY.iter().position(|&r| r == role.as_ref()) {
44			highest_idx = Some(highest_idx.map_or(idx, |h| h.max(idx)));
45		}
46	}
47
48	// Return comma-separated list of all roles up to highest, or empty if no valid roles found
49	match highest_idx {
50		Some(idx) => ROLE_HIERARCHY[..=idx].join(","),
51		None => String::new(),
52	}
53}
54
55/// Expand the hierarchy portion of `roles` and append any non-hierarchy roles verbatim.
56///
57/// [`expand_roles`] only emits entries of [`ROLE_HIERARCHY`], so alone it silently drops
58/// out-of-band roles such as `SADM`. This is the single implementation both the login path
59/// (`build_tenant_owner_roles`) and the token-refresh path go through.
60///
61/// # Examples
62/// ```
63/// use cloudillo_types::roles::expand_roles_preserving_extras;
64/// assert_eq!(expand_roles_preserving_extras(&["leader".into(), "SADM".into()]),
65///     "public,follower,supporter,contributor,moderator,leader,SADM");
66/// assert_eq!(expand_roles_preserving_extras(&["SADM".into()]), "SADM");
67/// ```
68pub fn expand_roles_preserving_extras(roles: &[Box<str>]) -> String {
69	let mut result = expand_roles(roles);
70	for role in roles {
71		if role_level(role).is_some() {
72			continue;
73		}
74		// The caller may pass the same extra twice (merged role sets).
75		if result.split(',').any(|r| r == role.as_ref()) {
76			continue;
77		}
78		if !result.is_empty() {
79			result.push(',');
80		}
81		result.push_str(role);
82	}
83	result
84}
85
86#[cfg(test)]
87mod tests {
88	use super::*;
89
90	const LEADER_EXPANDED: &str = "public,follower,supporter,contributor,moderator,leader";
91
92	#[test]
93	fn test_expand_roles_empty() {
94		assert_eq!(expand_roles(&[]), "");
95	}
96
97	#[test]
98	fn test_expand_roles_single() {
99		assert_eq!(expand_roles(&["public".into()]), "public");
100		assert_eq!(expand_roles(&["follower".into()]), "public,follower");
101		assert_eq!(
102			expand_roles(&["moderator".into()]),
103			"public,follower,supporter,contributor,moderator"
104		);
105		assert_eq!(expand_roles(&["leader".into()]), LEADER_EXPANDED);
106	}
107
108	#[test]
109	fn test_expand_roles_multiple() {
110		// Takes highest role
111		assert_eq!(
112			expand_roles(&["contributor".into(), "moderator".into()]),
113			"public,follower,supporter,contributor,moderator"
114		);
115		assert_eq!(expand_roles(&["public".into(), "leader".into()]), LEADER_EXPANDED);
116	}
117
118	#[test]
119	fn test_expand_roles_unknown() {
120		// Unknown roles are ignored
121		assert_eq!(expand_roles(&["unknown".into()]), "");
122		assert_eq!(
123			expand_roles(&["unknown".into(), "contributor".into()]),
124			"public,follower,supporter,contributor"
125		);
126	}
127
128	#[test]
129	fn test_expand_roles_preserving_extras() {
130		// `SADM` lives outside the hierarchy, so plain `expand_roles` drops it — and the site
131		// admin then fails every SADM-gated ref operation.
132		assert_eq!(expand_roles(&["leader".into(), "SADM".into()]), LEADER_EXPANDED);
133		assert_eq!(
134			expand_roles_preserving_extras(&["leader".into(), "SADM".into()]),
135			format!("{LEADER_EXPANDED},SADM")
136		);
137
138		// Hierarchy-only input is unchanged from `expand_roles`.
139		assert_eq!(
140			expand_roles_preserving_extras(&["moderator".into()]),
141			expand_roles(&["moderator".into()])
142		);
143
144		// Extras alone survive even with no hierarchy part to hang off.
145		assert_eq!(expand_roles_preserving_extras(&["SADM".into()]), "SADM");
146		assert_eq!(expand_roles_preserving_extras(&[]), "");
147
148		// Duplicates collapse, order of first appearance kept.
149		assert_eq!(
150			expand_roles_preserving_extras(&["SADM".into(), "SADM".into(), "OPS".into()]),
151			"SADM,OPS"
152		);
153		// A hierarchy role repeated as an extra is not appended twice.
154		assert_eq!(
155			expand_roles_preserving_extras(&["leader".into(), "leader".into()]),
156			LEADER_EXPANDED
157		);
158	}
159
160	#[test]
161	fn test_role_level() {
162		assert_eq!(role_level("public"), Some(0));
163		assert_eq!(role_level("follower"), Some(1));
164		assert_eq!(role_level("moderator"), Some(4));
165		assert_eq!(role_level("leader"), Some(5));
166		assert_eq!(role_level("unknown"), None);
167	}
168}
169
170// vim: ts=4