Skip to main content

cloudillo_core/
roles.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Role hierarchy and expansion logic
5//!
6//! This module defines the built-in role hierarchy and provides utilities
7//! for expanding hierarchical roles.
8
9/// Role hierarchy for profile-level permissions
10/// Higher roles inherit all permissions from lower roles
11pub const ROLE_HIERARCHY: &[&str] =
12	&["public", "follower", "supporter", "contributor", "moderator", "leader"];
13
14/// Expands hierarchical roles from highest role to all inherited roles
15///
16/// Given a list of roles (typically just the highest one), this function
17/// returns a comma-separated string of all roles from "public" up to and
18/// including the highest role in the hierarchy.
19///
20/// # Examples
21/// ```
22/// use cloudillo_core::roles::expand_roles;
23/// assert_eq!(expand_roles(&["moderator".into()]), "public,follower,supporter,contributor,moderator");
24/// assert_eq!(expand_roles(&["contributor".into(), "moderator".into()]), "public,follower,supporter,contributor,moderator");
25/// assert_eq!(expand_roles(&[]), "");
26/// ```
27pub fn expand_roles(highest_roles: &[Box<str>]) -> String {
28	if highest_roles.is_empty() {
29		return String::new();
30	}
31
32	let mut highest_idx: Option<usize> = None;
33	for role in highest_roles {
34		if let Some(idx) = ROLE_HIERARCHY.iter().position(|&r| r == role.as_ref()) {
35			highest_idx = Some(highest_idx.map_or(idx, |h| h.max(idx)));
36		}
37	}
38
39	// Return comma-separated list of all roles up to highest, or empty if no valid roles found
40	match highest_idx {
41		Some(idx) => ROLE_HIERARCHY[..=idx].join(","),
42		None => String::new(),
43	}
44}
45
46/// Hierarchy index of a single role, or None if unknown.
47pub fn role_level(role: &str) -> Option<usize> {
48	ROLE_HIERARCHY.iter().position(|&r| r == role)
49}
50
51/// Highest hierarchy level among the given roles; unknown roles are ignored.
52/// Empty / all-unknown ⇒ 0 (public).
53pub fn highest_role_level(roles: &[Box<str>]) -> usize {
54	roles.iter().filter_map(|r| role_level(r)).max().unwrap_or(0)
55}
56
57/// Lowest hierarchy level permitted to manage (remove / re-role) other members.
58pub const MODERATOR_LEVEL: usize = 4;
59/// Hierarchy level of the "leader" role.
60pub const LEADER_LEVEL: usize = 5;
61
62/// Whether an actor at `actor_level` may manage (remove or re-role) a member at
63/// `target_level`. Rule: the actor must be moderator+ and strictly outrank the
64/// target — except leaders, who may also manage peer leaders.
65pub fn can_manage_member(actor_level: usize, target_level: usize) -> bool {
66	actor_level >= MODERATOR_LEVEL && (actor_level > target_level || actor_level == LEADER_LEVEL)
67}
68
69/// Whether an actor with `actor_roles` may manage (remove / re-role) a member
70/// with `target_roles`. Convenience over `can_manage_member` + `highest_role_level`.
71pub fn can_manage_member_by_roles(actor_roles: &[Box<str>], target_roles: &[Box<str>]) -> bool {
72	can_manage_member(highest_role_level(actor_roles), highest_role_level(target_roles))
73}
74
75/// Whether an actor at `actor_level` may *assign* `role`. Leaders may assign any
76/// known role; everyone else is capped strictly below their own level. Unknown
77/// roles are never assignable.
78pub fn can_assign_role(role: &str, actor_level: usize) -> bool {
79	match role_level(role) {
80		Some(new_level) => actor_level >= LEADER_LEVEL || new_level < actor_level,
81		None => false,
82	}
83}
84
85#[cfg(test)]
86mod tests {
87	// These pure helpers are the security-critical decision points the auth guards
88	// route through: `can_manage_member_by_roles` (manage authority) and
89	// `can_assign_role` (assignment cap). The handlers compose them with
90	// field-level rules that remain in `update.rs` (name/status leader-only, and the
91	// self-role-change block), which are not exercised here.
92	use super::*;
93
94	#[test]
95	fn test_expand_roles_empty() {
96		assert_eq!(expand_roles(&[]), "");
97	}
98
99	#[test]
100	fn test_expand_roles_single() {
101		assert_eq!(expand_roles(&["public".into()]), "public");
102		assert_eq!(expand_roles(&["follower".into()]), "public,follower");
103		assert_eq!(
104			expand_roles(&["moderator".into()]),
105			"public,follower,supporter,contributor,moderator"
106		);
107		assert_eq!(
108			expand_roles(&["leader".into()]),
109			"public,follower,supporter,contributor,moderator,leader"
110		);
111	}
112
113	#[test]
114	fn test_expand_roles_multiple() {
115		// Takes highest role
116		assert_eq!(
117			expand_roles(&["contributor".into(), "moderator".into()]),
118			"public,follower,supporter,contributor,moderator"
119		);
120		assert_eq!(
121			expand_roles(&["public".into(), "leader".into()]),
122			"public,follower,supporter,contributor,moderator,leader"
123		);
124	}
125
126	#[test]
127	fn test_expand_roles_unknown() {
128		// Unknown roles are ignored
129		assert_eq!(expand_roles(&["unknown".into()]), "");
130		assert_eq!(
131			expand_roles(&["unknown".into(), "contributor".into()]),
132			"public,follower,supporter,contributor"
133		);
134	}
135
136	#[test]
137	fn test_role_level() {
138		assert_eq!(role_level("public"), Some(0));
139		assert_eq!(role_level("follower"), Some(1));
140		assert_eq!(role_level("moderator"), Some(4));
141		assert_eq!(role_level("leader"), Some(5));
142		assert_eq!(role_level("unknown"), None);
143	}
144
145	#[test]
146	fn test_level_consts_match_hierarchy() {
147		assert_eq!(role_level("moderator"), Some(MODERATOR_LEVEL));
148		assert_eq!(role_level("leader"), Some(LEADER_LEVEL));
149	}
150
151	#[test]
152	fn test_can_manage_member() {
153		// moderator (4) outranks contributor (3)
154		assert!(can_manage_member(4, 3));
155		// moderator cannot manage a peer moderator
156		assert!(!can_manage_member(4, 4));
157		// leader (5) may manage a peer leader
158		assert!(can_manage_member(5, 5));
159		// contributor (3) is below moderator → cannot manage anyone
160		assert!(!can_manage_member(3, 0));
161	}
162
163	#[test]
164	fn test_highest_role_level() {
165		// Empty / all-unknown ⇒ 0 (public)
166		assert_eq!(highest_role_level(&[]), 0);
167		assert_eq!(highest_role_level(&["unknown".into()]), 0);
168		// Takes the highest known role, ignoring unknowns
169		assert_eq!(highest_role_level(&["follower".into()]), 1);
170		assert_eq!(highest_role_level(&["moderator".into()]), 4);
171		assert_eq!(highest_role_level(&["leader".into()]), 5);
172		assert_eq!(highest_role_level(&["contributor".into(), "moderator".into()]), 4);
173		assert_eq!(highest_role_level(&["unknown".into(), "leader".into()]), 5);
174	}
175
176	#[test]
177	fn test_can_manage_member_by_roles() {
178		// moderator outranks contributor
179		assert!(can_manage_member_by_roles(&["moderator".into()], &["contributor".into()]));
180		// moderator cannot manage a peer moderator
181		assert!(!can_manage_member_by_roles(&["moderator".into()], &["moderator".into()]));
182		// leader may manage a peer leader
183		assert!(can_manage_member_by_roles(&["leader".into()], &["leader".into()]));
184		// contributor is below moderator → cannot manage anyone
185		assert!(!can_manage_member_by_roles(&["contributor".into()], &["public".into()]));
186		// empty actor roles (level 0) cannot manage anyone
187		assert!(!can_manage_member_by_roles(&[], &["public".into()]));
188		// unknown roles are ignored when computing levels
189		assert!(can_manage_member_by_roles(
190			&["unknown".into(), "moderator".into()],
191			&["contributor".into()]
192		));
193	}
194
195	#[test]
196	fn test_can_assign_role() {
197		// leader (5) may assign any known role, including peer leader
198		assert!(can_assign_role("leader", LEADER_LEVEL));
199		assert!(can_assign_role("moderator", LEADER_LEVEL));
200		assert!(can_assign_role("contributor", LEADER_LEVEL));
201		// moderator (4) may assign strictly-below roles only
202		assert!(can_assign_role("contributor", MODERATOR_LEVEL));
203		assert!(!can_assign_role("moderator", MODERATOR_LEVEL));
204		assert!(!can_assign_role("leader", MODERATOR_LEVEL));
205		// unknown roles are never assignable, even by a leader
206		assert!(!can_assign_role("unknown", LEADER_LEVEL));
207	}
208}
209
210// vim: ts=4