Skip to main content

cloudillo_core/
profile_visibility.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Per-section profile visibility filtering.
5//!
6//! Profile sections are gated via `<field>.vis` markers in the tenant's
7//! extension (`x`) map. This module supplies the pure logic for parsing those
8//! markers and deciding whether a particular caller may view each section.
9//!
10//! The `cloudillo-profile` crate uses these primitives to strip gated sections
11//! from `/api/me` and `/api/me/full` responses.
12
13/// Community role labels recognised in `<field>.vis` markers and in
14/// `AuthCtx.roles`. Ordered: `Supporter < Contributor < Moderator < Leader`.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
16pub enum CommunityRole {
17	Supporter = 1,
18	Contributor = 2,
19	Moderator = 3,
20	Leader = 4,
21}
22
23impl CommunityRole {
24	/// Canonical role labels. Any new role must be added here so that writers
25	/// (community DSL, hooks, admin endpoints) and the visibility gate stay in
26	/// sync — a typo on the write side silently fails-closed otherwise.
27	pub const ALL: &'static [&'static str] = &["supporter", "contributor", "moderator", "leader"];
28
29	/// Parse a role label. Unknown labels return `None`.
30	pub fn parse(s: &str) -> Option<Self> {
31		match s {
32			"supporter" => Some(Self::Supporter),
33			"contributor" => Some(Self::Contributor),
34			"moderator" => Some(Self::Moderator),
35			"leader" => Some(Self::Leader),
36			_ => None,
37		}
38	}
39}
40
41/// Required visibility level for a profile section, parsed from `<field>.vis`.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum SectionVisibility {
44	Public,
45	Verified,
46	Follower,
47	Connected,
48	Role(CommunityRole),
49}
50
51impl SectionVisibility {
52	/// Parse a visibility marker. Unknown labels return `None`; callers
53	/// should treat that as "hide" (secure by default).
54	pub fn parse(s: &str) -> Option<Self> {
55		match s {
56			"public" | "world" => Some(Self::Public),
57			"verified" => Some(Self::Verified),
58			"follower" => Some(Self::Follower),
59			"connected" => Some(Self::Connected),
60			other => CommunityRole::parse(other).map(Self::Role),
61		}
62	}
63}
64
65/// Caller's relationship to the tenant being viewed.
66#[allow(clippy::struct_excessive_bools)]
67#[derive(Debug, Clone, Copy)]
68pub struct RequesterTier {
69	pub is_owner: bool,
70	pub is_authenticated: bool,
71	/// True iff the caller follows the tenant.
72	pub follows_tenant: bool,
73	/// True iff caller and tenant are mutually connected.
74	pub connected_to_tenant: bool,
75	/// Highest community role the caller holds in this tenant.
76	pub max_role: Option<CommunityRole>,
77}
78
79impl RequesterTier {
80	/// Anonymous caller — no auth, no relationship, no roles.
81	pub fn anonymous() -> Self {
82		Self {
83			is_owner: false,
84			is_authenticated: false,
85			follows_tenant: false,
86			connected_to_tenant: false,
87			max_role: None,
88		}
89	}
90
91	/// Decide whether this tier may view a section gated by `required`.
92	pub fn can_view(self, required: SectionVisibility) -> bool {
93		if self.is_owner {
94			return true;
95		}
96		match required {
97			SectionVisibility::Public => true,
98			SectionVisibility::Verified => self.is_authenticated,
99			SectionVisibility::Follower => self.follows_tenant || self.connected_to_tenant,
100			SectionVisibility::Connected => self.connected_to_tenant,
101			SectionVisibility::Role(r) => {
102				self.is_authenticated && self.max_role.is_some_and(|m| m >= r)
103			}
104		}
105	}
106}
107
108#[cfg(test)]
109mod tests {
110	use super::*;
111
112	#[test]
113	fn parse_known_labels() {
114		assert_eq!(SectionVisibility::parse("public"), Some(SectionVisibility::Public));
115		assert_eq!(SectionVisibility::parse("world"), Some(SectionVisibility::Public));
116		assert_eq!(SectionVisibility::parse("verified"), Some(SectionVisibility::Verified));
117		assert_eq!(SectionVisibility::parse("follower"), Some(SectionVisibility::Follower));
118		assert_eq!(SectionVisibility::parse("connected"), Some(SectionVisibility::Connected));
119		assert_eq!(
120			SectionVisibility::parse("supporter"),
121			Some(SectionVisibility::Role(CommunityRole::Supporter)),
122		);
123		assert_eq!(
124			SectionVisibility::parse("contributor"),
125			Some(SectionVisibility::Role(CommunityRole::Contributor)),
126		);
127		assert_eq!(
128			SectionVisibility::parse("moderator"),
129			Some(SectionVisibility::Role(CommunityRole::Moderator)),
130		);
131		assert_eq!(
132			SectionVisibility::parse("leader"),
133			Some(SectionVisibility::Role(CommunityRole::Leader)),
134		);
135	}
136
137	#[test]
138	fn parse_unknown_returns_none() {
139		assert_eq!(SectionVisibility::parse(""), None);
140		assert_eq!(SectionVisibility::parse("banana"), None);
141		assert_eq!(SectionVisibility::parse("Public"), None);
142		assert_eq!(SectionVisibility::parse("LEADER"), None);
143	}
144
145	#[test]
146	fn parse_role_known_and_unknown() {
147		assert_eq!(CommunityRole::parse("supporter"), Some(CommunityRole::Supporter));
148		assert_eq!(CommunityRole::parse("leader"), Some(CommunityRole::Leader));
149		assert_eq!(CommunityRole::parse("admin"), None);
150		assert_eq!(CommunityRole::parse(""), None);
151	}
152
153	fn tier_anon() -> RequesterTier {
154		RequesterTier::anonymous()
155	}
156
157	fn tier_verified() -> RequesterTier {
158		RequesterTier { is_authenticated: true, ..RequesterTier::anonymous() }
159	}
160
161	fn tier_follower() -> RequesterTier {
162		RequesterTier { is_authenticated: true, follows_tenant: true, ..RequesterTier::anonymous() }
163	}
164
165	fn tier_connected() -> RequesterTier {
166		RequesterTier {
167			is_authenticated: true,
168			connected_to_tenant: true,
169			..RequesterTier::anonymous()
170		}
171	}
172
173	fn tier_role(role: CommunityRole) -> RequesterTier {
174		RequesterTier { is_authenticated: true, max_role: Some(role), ..RequesterTier::anonymous() }
175	}
176
177	fn tier_owner() -> RequesterTier {
178		RequesterTier { is_owner: true, ..RequesterTier::anonymous() }
179	}
180
181	#[test]
182	fn anonymous_can_only_see_public() {
183		let t = tier_anon();
184		assert!(t.can_view(SectionVisibility::Public));
185		assert!(!t.can_view(SectionVisibility::Verified));
186		assert!(!t.can_view(SectionVisibility::Follower));
187		assert!(!t.can_view(SectionVisibility::Connected));
188		assert!(!t.can_view(SectionVisibility::Role(CommunityRole::Supporter)));
189		assert!(!t.can_view(SectionVisibility::Role(CommunityRole::Leader)));
190	}
191
192	#[test]
193	fn verified_no_role_blocked_from_role_gates() {
194		let t = tier_verified();
195		assert!(t.can_view(SectionVisibility::Public));
196		assert!(t.can_view(SectionVisibility::Verified));
197		assert!(!t.can_view(SectionVisibility::Follower));
198		assert!(!t.can_view(SectionVisibility::Connected));
199		assert!(!t.can_view(SectionVisibility::Role(CommunityRole::Supporter)));
200		assert!(!t.can_view(SectionVisibility::Role(CommunityRole::Contributor)));
201	}
202
203	#[test]
204	fn follower_satisfies_follower_only() {
205		let t = tier_follower();
206		assert!(t.can_view(SectionVisibility::Follower));
207		assert!(!t.can_view(SectionVisibility::Connected));
208		assert!(t.can_view(SectionVisibility::Verified));
209		assert!(!t.can_view(SectionVisibility::Role(CommunityRole::Supporter)));
210	}
211
212	#[test]
213	fn connected_satisfies_follower_and_connected() {
214		let t = tier_connected();
215		assert!(t.can_view(SectionVisibility::Follower));
216		assert!(t.can_view(SectionVisibility::Connected));
217		assert!(t.can_view(SectionVisibility::Verified));
218	}
219
220	#[test]
221	fn supporter_can_see_supporter_only() {
222		let t = tier_role(CommunityRole::Supporter);
223		assert!(t.can_view(SectionVisibility::Role(CommunityRole::Supporter)));
224		assert!(!t.can_view(SectionVisibility::Role(CommunityRole::Contributor)));
225		assert!(!t.can_view(SectionVisibility::Role(CommunityRole::Moderator)));
226		assert!(!t.can_view(SectionVisibility::Role(CommunityRole::Leader)));
227	}
228
229	#[test]
230	fn contributor_meets_contributor_and_below() {
231		let t = tier_role(CommunityRole::Contributor);
232		assert!(t.can_view(SectionVisibility::Role(CommunityRole::Supporter)));
233		assert!(t.can_view(SectionVisibility::Role(CommunityRole::Contributor)));
234		assert!(!t.can_view(SectionVisibility::Role(CommunityRole::Moderator)));
235		assert!(!t.can_view(SectionVisibility::Role(CommunityRole::Leader)));
236	}
237
238	#[test]
239	fn moderator_meets_moderator_and_below() {
240		let t = tier_role(CommunityRole::Moderator);
241		assert!(t.can_view(SectionVisibility::Role(CommunityRole::Supporter)));
242		assert!(t.can_view(SectionVisibility::Role(CommunityRole::Contributor)));
243		assert!(t.can_view(SectionVisibility::Role(CommunityRole::Moderator)));
244		assert!(!t.can_view(SectionVisibility::Role(CommunityRole::Leader)));
245	}
246
247	#[test]
248	fn leader_meets_all_roles() {
249		let t = tier_role(CommunityRole::Leader);
250		assert!(t.can_view(SectionVisibility::Role(CommunityRole::Supporter)));
251		assert!(t.can_view(SectionVisibility::Role(CommunityRole::Contributor)));
252		assert!(t.can_view(SectionVisibility::Role(CommunityRole::Moderator)));
253		assert!(t.can_view(SectionVisibility::Role(CommunityRole::Leader)));
254	}
255
256	#[test]
257	fn owner_sees_everything() {
258		let t = tier_owner();
259		assert!(t.can_view(SectionVisibility::Public));
260		assert!(t.can_view(SectionVisibility::Verified));
261		assert!(t.can_view(SectionVisibility::Follower));
262		assert!(t.can_view(SectionVisibility::Connected));
263		assert!(t.can_view(SectionVisibility::Role(CommunityRole::Supporter)));
264		assert!(t.can_view(SectionVisibility::Role(CommunityRole::Leader)));
265	}
266}
267
268// vim: ts=4