Skip to main content

cloudillo_core/
abac.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Attribute-Based Access Control (ABAC) system for Cloudillo
5//!
6//! Implements classic ABAC with 4-object model:
7//! - Subject: Authenticated user (AuthCtx)
8//! - Action: Operation being performed (string like "file:read")
9//! - Object: Resource being accessed (implements AttrSet trait)
10//! - Environment: Context (time, etc.)
11
12use crate::prelude::*;
13use cloudillo_types::auth_adapter::AuthCtx;
14use cloudillo_types::types::AccessLevel;
15use std::collections::HashMap;
16
17/// Visibility levels for resources (files, actions, profile fields)
18///
19/// Stored as single char in database:
20/// - None/NULL = Direct (most restrictive, owner + explicit audience only)
21/// - 'P' = Public (anyone, including unauthenticated)
22/// - 'V' = Verified (any authenticated user from any federated instance)
23/// - '2' = 2nd degree (friend of friend, reserved for future voucher token system)
24/// - 'F' = Follower (authenticated user who follows the owner)
25/// - 'C' = Connected (authenticated user who is connected/mutual with owner)
26///
27/// Hierarchy (from most permissive to most restrictive):
28/// Public > Verified > 2nd Degree > Follower > Connected > Direct
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
30pub enum VisibilityLevel {
31	/// Anyone can access, including unauthenticated users
32	Public,
33	/// Any authenticated user from any federated instance
34	Verified,
35	/// Friend of friend (2nd degree connection) - reserved for voucher token system
36	SecondDegree,
37	/// Authenticated user who follows the owner
38	Follower,
39	/// Authenticated user who is connected (mutual) with owner
40	Connected,
41	/// Readable by any active subscriber of the action's container (group members).
42	/// Owner/tenant pass the base check; subscribers pass via the audience bridge.
43	Subscribed,
44	/// Most restrictive - only owner and explicit audience
45	#[default]
46	Direct,
47}
48
49impl VisibilityLevel {
50	/// Parse from database char value
51	pub fn from_char(c: Option<char>) -> Self {
52		match c {
53			Some('P') => Self::Public,
54			Some('V') => Self::Verified,
55			Some('2') => Self::SecondDegree,
56			Some('F') => Self::Follower,
57			Some('C') => Self::Connected,
58			Some('S') => Self::Subscribed,
59			// NULL or unknown = Direct (most restrictive, secure by default)
60			None | Some(_) => Self::Direct,
61		}
62	}
63
64	/// Convert to database char value (inverse of from_char)
65	pub fn to_char(&self) -> Option<char> {
66		match self {
67			Self::Public => Some('P'),
68			Self::Verified => Some('V'),
69			Self::SecondDegree => Some('2'),
70			Self::Follower => Some('F'),
71			Self::Connected => Some('C'),
72			Self::Subscribed => Some('S'),
73			Self::Direct => None,
74		}
75	}
76
77	/// Convert to string for attribute lookup
78	pub fn as_str(&self) -> &'static str {
79		match self {
80			Self::Public => "public",
81			Self::Verified => "verified",
82			Self::SecondDegree => "second_degree",
83			Self::Follower => "follower",
84			Self::Connected => "connected",
85			Self::Subscribed => "subscribed",
86			Self::Direct => "direct",
87		}
88	}
89}
90
91/// Subject's access level to a resource based on their relationship with the owner
92///
93/// Used to determine if a subject meets the visibility requirements.
94/// Higher levels grant access to more restrictive visibility settings.
95///
96/// Hierarchy: Owner > Connected > Follower > SecondDegree > Verified > Public > None
97#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
98pub enum SubjectAccessLevel {
99	/// No authentication or relationship
100	#[default]
101	None,
102	/// Unauthenticated but public access requested
103	Public,
104	/// Authenticated user (has valid JWT from any federated instance)
105	Verified,
106	/// Has voucher token proving 2nd degree connection (future)
107	SecondDegree,
108	/// Follows the resource owner
109	Follower,
110	/// Connected (mutual) with resource owner
111	Connected,
112	/// Is the resource owner
113	Owner,
114}
115
116impl SubjectAccessLevel {
117	/// Check if this access level can view content with given visibility
118	pub fn can_access(self, visibility: VisibilityLevel) -> bool {
119		match visibility {
120			VisibilityLevel::Public => true, // Everyone can access public
121			VisibilityLevel::Verified => self >= Self::Verified,
122			VisibilityLevel::SecondDegree => self >= Self::SecondDegree,
123			VisibilityLevel::Follower => self >= Self::Follower,
124			VisibilityLevel::Connected => self >= Self::Connected,
125			// Both admit only owner/tenant here; subscribers/explicit-audience readers
126			// pass via the audience bridge in `can_view_item`.
127			VisibilityLevel::Subscribed | VisibilityLevel::Direct => self >= Self::Owner,
128		}
129	}
130
131	/// Return the visibility level chars this access level can see.
132	/// Returns `None` for Owner (sees everything including NULL/Direct).
133	/// Used to push visibility filtering into SQL for correct pagination.
134	pub fn visible_levels(self) -> Option<&'static [char]> {
135		match self {
136			Self::None | Self::Public => Some(&['P']),
137			Self::Verified => Some(&['P', 'V']),
138			Self::SecondDegree => Some(&['P', 'V', '2']),
139			Self::Follower => Some(&['P', 'V', '2', 'F']),
140			Self::Connected => Some(&['P', 'V', '2', 'F', 'C']),
141			Self::Owner => None,
142		}
143	}
144}
145
146/// The one relationship ladder, shared so the endpoints that list and the
147/// endpoint that searches cannot drift apart.
148///
149/// Callers compute the four inputs themselves, because *how* you become the
150/// owner or a real authenticated caller differs by endpoint — `GET /api/search`
151/// demotes every scoped caller to `Public` before asking (a file scope is handed
152/// to an untrusted app and carries no ambient authority), while `GET /api/files`
153/// resolves a scope through its own share lookup. What must never differ is the
154/// ordering below.
155///
156/// [`can_view_item`] carries a third copy of the ladder with a different
157/// signature: it folds the item owner into the owner test and an
158/// `is_authenticated` flag into `is_real_auth`.
159// Four bools on purpose: they are the ladder's rungs, and a struct would put a
160// name between each caller and the ordering this function exists to fix in one
161// place. Both call sites pass them in the order the doc above states.
162#[allow(clippy::fn_params_excessive_bools)]
163pub fn relationship_level(
164	is_owner: bool,
165	connected: bool,
166	following: bool,
167	is_real_auth: bool,
168) -> SubjectAccessLevel {
169	if is_owner {
170		SubjectAccessLevel::Owner
171	} else if connected {
172		SubjectAccessLevel::Connected
173	} else if following {
174		SubjectAccessLevel::Follower
175	} else if is_real_auth {
176		SubjectAccessLevel::Verified
177	} else {
178		SubjectAccessLevel::Public
179	}
180}
181
182/// Context for checking whether a subject can view an item
183pub struct ViewCheckContext<'a> {
184	pub subject_id_tag: &'a str,
185	pub is_authenticated: bool,
186	pub item_owner_id_tag: &'a str,
187	pub tenant_id_tag: &'a str,
188	pub visibility: Option<char>,
189	pub subject_following_owner: bool,
190	pub subject_connected_to_owner: bool,
191	pub audience_tags: Option<&'a [&'a str]>,
192}
193
194/// Check if subject can view an item based on visibility and relationship
195///
196/// This is a standalone function for use in list filtering where we don't
197/// have full ABAC context. It evaluates visibility rules directly.
198pub fn can_view_item(ctx: &ViewCheckContext<'_>) -> bool {
199	let visibility = VisibilityLevel::from_char(ctx.visibility);
200
201	// Determine subject's access level
202	// Note: "guest" id_tag is used for unauthenticated users - treat as Public
203	let is_real_auth =
204		ctx.is_authenticated && !ctx.subject_id_tag.is_empty() && ctx.subject_id_tag != "guest";
205	let is_tenant = ctx.subject_id_tag == ctx.tenant_id_tag;
206	let access_level = if ctx.subject_id_tag == ctx.item_owner_id_tag || is_tenant {
207		SubjectAccessLevel::Owner // Tenant has same access as owner
208	} else if ctx.subject_connected_to_owner {
209		SubjectAccessLevel::Connected
210	} else if ctx.subject_following_owner {
211		SubjectAccessLevel::Follower
212	} else if is_real_auth {
213		SubjectAccessLevel::Verified
214	} else {
215		SubjectAccessLevel::Public
216	};
217
218	// Check basic access
219	if access_level.can_access(visibility) {
220		return true;
221	}
222
223	// Direct and Subscribed also check explicit audience. For Subscribed,
224	// `filter.rs` injects the reader into `audience_tags` when they hold an active
225	// SUBS to the container (group membership).
226	if (visibility == VisibilityLevel::Direct || visibility == VisibilityLevel::Subscribed)
227		&& let Some(tags) = ctx.audience_tags
228	{
229		return tags.contains(&ctx.subject_id_tag);
230	}
231
232	false
233}
234
235// Re-export AttrSet from cloudillo-types (canonical definition)
236pub use cloudillo_types::abac::AttrSet;
237
238/// True iff `auth` carries the site-admin (SADM) role.
239///
240/// Single source of truth for the role-name string; callers must not
241/// compare role strings inline.
242pub fn is_admin(auth: &AuthCtx) -> bool {
243	auth.roles.iter().any(|r| r.as_ref() == "SADM")
244}
245
246/// Environment attributes (environmental context)
247#[derive(Debug, Clone)]
248pub struct Environment {
249	pub time: Timestamp,
250	// Future: ip_address, user_agent, etc.
251}
252
253impl Environment {
254	pub fn new() -> Self {
255		Self { time: Timestamp::now() }
256	}
257}
258
259impl Default for Environment {
260	fn default() -> Self {
261		Self::new()
262	}
263}
264
265/// Policy rule condition
266#[derive(Debug, Clone)]
267pub struct Condition {
268	pub attribute: String,
269	pub operator: Operator,
270	pub value: serde_json::Value,
271}
272
273#[derive(Debug, Clone, Copy)]
274pub enum Operator {
275	Equals,
276	NotEquals,
277	Contains,
278	NotContains,
279	GreaterThan,
280	LessThan,
281	In,      // Subject attr in object list
282	HasRole, // Subject has specific role
283}
284
285impl Condition {
286	/// Evaluate condition against subject, action, object, environment
287	pub fn evaluate(
288		&self,
289		subject: &AuthCtx,
290		action: &str,
291		object: &dyn AttrSet,
292		_environment: &Environment,
293	) -> bool {
294		// First, try to get value from object
295		if let Some(obj_val) = object.get(&self.attribute) {
296			return self.compare_value(obj_val);
297		}
298
299		// Then try subject attributes
300		match self.attribute.as_str() {
301			"subject.id_tag" => self.compare_value(&subject.id_tag),
302			"subject.tn_id" => self.compare_value(&subject.tn_id.0.to_string()),
303			"subject.roles" | "role.admin" | "role.moderator" | "role.member" => {
304				// Special handling for role checks
305				if let Operator::HasRole = self.operator
306					&& let Some(role) = self.value.as_str()
307				{
308					return subject.roles.iter().any(|r| r.as_ref() == role);
309				}
310				// For dotted notation like "role.admin"
311				if self.attribute.starts_with("role.") {
312					let role_name = &self.attribute[5..];
313					return subject.roles.iter().any(|r| r.as_ref() == role_name);
314				}
315				false
316			}
317			"action" => self.compare_value(action),
318			_ => false,
319		}
320	}
321
322	fn compare_value(&self, actual: &str) -> bool {
323		match self.operator {
324			Operator::Equals => self.value.as_str() == Some(actual),
325			Operator::NotEquals => self.value.as_str() != Some(actual),
326			Operator::Contains => {
327				if let Some(needle) = self.value.as_str() {
328					actual.contains(needle)
329				} else {
330					false
331				}
332			}
333			Operator::NotContains => {
334				if let Some(needle) = self.value.as_str() {
335					!actual.contains(needle)
336				} else {
337					true
338				}
339			}
340			Operator::GreaterThan => {
341				if let (Some(threshold), Ok(val)) = (self.value.as_f64(), actual.parse::<f64>()) {
342					val > threshold
343				} else {
344					false
345				}
346			}
347			Operator::LessThan => {
348				if let (Some(threshold), Ok(val)) = (self.value.as_f64(), actual.parse::<f64>()) {
349					val < threshold
350				} else {
351					false
352				}
353			}
354			Operator::In | Operator::HasRole => false,
355		}
356	}
357}
358
359/// Policy rule
360#[derive(Debug, Clone)]
361pub struct PolicyRule {
362	pub name: String,
363	pub conditions: Vec<Condition>,
364	pub effect: Effect,
365}
366
367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
368pub enum Effect {
369	Allow,
370	Deny,
371}
372
373impl PolicyRule {
374	/// Evaluate rule against subject, action, object, environment
375	pub fn evaluate(
376		&self,
377		subject: &AuthCtx,
378		action: &str,
379		object: &dyn AttrSet,
380		environment: &Environment,
381	) -> Option<Effect> {
382		// All conditions must match for rule to apply
383		let all_match = self
384			.conditions
385			.iter()
386			.all(|cond| cond.evaluate(subject, action, object, environment));
387
388		if all_match { Some(self.effect) } else { None }
389	}
390}
391
392/// ABAC Policy (collection of rules)
393#[derive(Debug, Clone)]
394pub struct Policy {
395	pub name: String,
396	pub rules: Vec<PolicyRule>,
397}
398
399impl Policy {
400	/// Evaluate policy - returns Effect if any rule matches
401	pub fn evaluate(
402		&self,
403		subject: &AuthCtx,
404		action: &str,
405		object: &dyn AttrSet,
406		environment: &Environment,
407	) -> Option<Effect> {
408		for rule in &self.rules {
409			if let Some(effect) = rule.evaluate(subject, action, object, environment) {
410				return Some(effect);
411			}
412		}
413		None
414	}
415}
416
417/// Profile-level policy configuration (TOP + BOTTOM)
418#[derive(Debug, Clone)]
419pub struct ProfilePolicy {
420	pub tn_id: TnId,
421	pub top_policy: Policy,    // Maximum permissions (constraints)
422	pub bottom_policy: Policy, // Minimum permissions (guarantees)
423}
424
425/// Collection-level policy configuration
426///
427/// Used for CREATE operations where no specific object exists yet.
428/// Evaluates permissions based on subject attributes only.
429///
430/// Example: User wants to upload a file
431///   - Can evaluate "can user create files?" without the file existing
432///   - Checks: quota_remaining > 0, role == "creator", !banned, email_verified
433#[derive(Debug, Clone)]
434pub struct CollectionPolicy {
435	pub resource_type: String, // "files", "actions", "profiles"
436	pub action: String,        // "create", "list"
437	pub top_policy: Policy,    // Denials/constraints
438	pub bottom_policy: Policy, // Guarantees
439}
440
441/// Main permission checker
442pub struct PermissionChecker {
443	profile_policies: HashMap<TnId, ProfilePolicy>,
444	collection_policies: HashMap<String, CollectionPolicy>, // key: "resource:action"
445}
446
447impl PermissionChecker {
448	pub fn new() -> Self {
449		Self { profile_policies: HashMap::new(), collection_policies: HashMap::new() }
450	}
451
452	/// Load profile policy for tenant (called during bootstrap)
453	pub fn load_policy(&mut self, policy: ProfilePolicy) {
454		self.profile_policies.insert(policy.tn_id, policy);
455	}
456
457	/// Load collection policy for resource type + action
458	pub fn load_collection_policy(&mut self, policy: CollectionPolicy) {
459		let key = format!("{}:{}", policy.resource_type, policy.action);
460		self.collection_policies.insert(key, policy);
461	}
462
463	/// Get collection policy for resource type and action
464	pub fn get_collection_policy(
465		&self,
466		resource_type: &str,
467		action: &str,
468	) -> Option<&CollectionPolicy> {
469		let key = format!("{}:{}", resource_type, action);
470		self.collection_policies.get(&key)
471	}
472
473	/// Core permission check function
474	pub fn has_permission(
475		&self,
476		subject: &AuthCtx,
477		action: &str,
478		object: &dyn AttrSet,
479		environment: &Environment,
480	) -> bool {
481		// Step 1: Check TOP policy (constraints)
482		if let Some(profile_policy) = self.profile_policies.get(&subject.tn_id) {
483			if let Some(Effect::Deny) =
484				profile_policy.top_policy.evaluate(subject, action, object, environment)
485			{
486				info!("TOP policy denied: tn_id={}, action={}", subject.tn_id.0, action);
487				return false;
488			}
489
490			// Step 2: Check BOTTOM policy (guarantees)
491			if let Some(Effect::Allow) =
492				profile_policy.bottom_policy.evaluate(subject, action, object, environment)
493			{
494				info!("BOTTOM policy allowed: tn_id={}, action={}", subject.tn_id.0, action);
495				return true;
496			}
497		}
498
499		// Step 3: Default permission rules (ownership, visibility, etc.)
500		self.check_default_rules(subject, action, object, environment)
501	}
502
503	/// Default permission rules (when policies don't match)
504	fn check_default_rules(
505		&self,
506		subject: &AuthCtx,
507		action: &str,
508		object: &dyn AttrSet,
509		_environment: &Environment,
510	) -> bool {
511		use tracing::debug;
512
513		// Leader override - leaders can do everything
514		if subject.roles.iter().any(|r| r.as_ref() == "leader") {
515			debug!(subject = %subject.id_tag, action = action, "Leader role allows access");
516			return true;
517		}
518
519		// Parse action into resource:operation
520		let parts: Vec<&str> = action.split(':').collect();
521		if parts.len() != 2 {
522			debug!(subject = %subject.id_tag, action = action, "Invalid action format (expected resource:operation)");
523			return false;
524		}
525		let operation = parts[1];
526
527		// Ownership check for modify operations
528		if matches!(operation, "update" | "delete" | "write") {
529			if let Some(owner) = object.get("owner_id_tag")
530				&& owner == subject.id_tag.as_ref()
531			{
532				debug!(subject = %subject.id_tag, action = action, owner = owner, "Owner access allowed for modify operation");
533				return true;
534			}
535			// Pre-computed access level (community roles, FSHR shares, scoped tokens). Ordering
536			// comes from `AccessLevel` itself; an unrecognized name parses to `None` and denies.
537			if let Some(al) = object.get("access_level")
538				&& AccessLevel::from_str_name(al).is_some_and(AccessLevel::can_write)
539			{
540				debug!(subject = %subject.id_tag, action = action, "Write access level allows modify operation");
541				return true;
542			}
543			debug!(subject = %subject.id_tag, action = action, "Denied: not owner and no write access level");
544			return false;
545		}
546
547		// Visibility check for read operations
548		if matches!(operation, "read") {
549			// Explicit access grants (FSHR file shares, scoped tokens), same `AccessLevel`
550			// ordering as the modify branch above.
551			if let Some(al) = object.get("access_level")
552				&& AccessLevel::from_str_name(al).is_some_and(AccessLevel::can_read)
553			{
554				return true;
555			}
556			return self.check_visibility(subject, object);
557		}
558
559		// Create operations - check quota/limits in future
560		if operation == "create" {
561			debug!(subject = %subject.id_tag, action = action, "Create operation allowed");
562			return true; // Allow for now
563		}
564
565		// Admin operations (e.g. `profile:admin`) — community moderators and above
566		// pass the gate. Leaders were already allowed by the override at the top of
567		// this function; this admits moderators so they can manage lower-ranked
568		// members. The finer-grained target-rank and field-level rules (a moderator
569		// may only re-role members strictly below them, never rename or change
570		// status) are enforced in the handler — see `patch_profile_admin`'s
571		// role-hierarchy guard in `cloudillo-profile/src/update.rs`.
572		if operation == "admin" {
573			use crate::roles::{MODERATOR_LEVEL, highest_role_level};
574			if highest_role_level(&subject.roles) >= MODERATOR_LEVEL {
575				debug!(subject = %subject.id_tag, action = action, "Moderator+ role allows admin operation");
576				return true;
577			}
578			debug!(subject = %subject.id_tag, action = action, "Denied: admin operation requires moderator+");
579			return false;
580		}
581
582		// Default deny
583		debug!(subject = %subject.id_tag, action = action, "Default deny: no matching rules");
584		false
585	}
586
587	/// Check visibility-based access using the new VisibilityLevel enum
588	///
589	/// Determines subject's access level and checks against resource visibility.
590	/// Supports both char-based visibility (from DB) and string-based (legacy).
591	#[expect(clippy::unused_self, reason = "method may use self in future policy checks")]
592	fn check_visibility(&self, subject: &AuthCtx, object: &dyn AttrSet) -> bool {
593		use tracing::debug;
594
595		// Parse visibility from object attributes
596		// Try char-based first (from "visibility_char"), then fall back to string
597		let visibility = if let Some(vis_char) = object.get("visibility_char") {
598			VisibilityLevel::from_char(vis_char.chars().next())
599		} else if let Some(vis_str) = object.get("visibility") {
600			match vis_str {
601				"public" | "P" => VisibilityLevel::Public,
602				"verified" | "V" => VisibilityLevel::Verified,
603				"second_degree" | "2" => VisibilityLevel::SecondDegree,
604				"follower" | "F" => VisibilityLevel::Follower,
605				"connected" | "C" => VisibilityLevel::Connected,
606				// "direct" or unknown = Direct (secure by default)
607				_ => VisibilityLevel::Direct,
608			}
609		} else {
610			VisibilityLevel::Direct // No visibility = Direct (most restrictive)
611		};
612
613		// Determine subject's access level based on relationship with resource
614		let is_owner = object.get("owner_id_tag") == Some(subject.id_tag.as_ref());
615		let is_issuer = object.get("issuer_id_tag") == Some(subject.id_tag.as_ref());
616		let is_connected = object.get("connected") == Some("true");
617		let is_follower = object.get("following") == Some("true");
618		let in_audience = object.contains("audience_tag", subject.id_tag.as_ref());
619
620		// Calculate subject's effective access level
621		// Note: "guest" id_tag is used for unauthenticated users - treat as Public
622		let is_authenticated = !subject.id_tag.is_empty() && subject.id_tag.as_ref() != "guest";
623		let access_level = if is_owner || is_issuer {
624			SubjectAccessLevel::Owner
625		} else if is_connected {
626			SubjectAccessLevel::Connected
627		} else if is_follower {
628			SubjectAccessLevel::Follower
629		} else if is_authenticated {
630			// Authenticated user without specific relationship
631			SubjectAccessLevel::Verified
632		} else {
633			SubjectAccessLevel::Public
634		};
635
636		// Check if access level meets visibility requirement
637		let allowed = access_level.can_access(visibility);
638
639		// For Direct visibility, also check explicit audience
640		let allowed =
641			if visibility == VisibilityLevel::Direct { allowed || in_audience } else { allowed };
642
643		debug!(
644			subject = %subject.id_tag,
645			visibility = ?visibility,
646			access_level = ?access_level,
647			is_owner = is_owner,
648			is_issuer = is_issuer,
649			is_connected = is_connected,
650			is_follower = is_follower,
651			in_audience = in_audience,
652			allowed = allowed,
653			"Visibility check"
654		);
655
656		allowed
657	}
658
659	/// Evaluate collection policy (for CREATE operations)
660	///
661	/// Collection policies check subject attributes without an object existing.
662	/// Used for operations like "can user upload files?" or "can user create posts?"
663	pub fn has_collection_permission(
664		&self,
665		subject: &AuthCtx,
666		subject_attrs: &dyn AttrSet,
667		resource_type: &str,
668		action: &str,
669		environment: &Environment,
670	) -> bool {
671		use tracing::debug;
672
673		// Get collection policy
674		let Some(policy) = self.get_collection_policy(resource_type, action) else {
675			// No policy defined → allow by default
676			debug!(
677				subject = %subject.id_tag,
678				resource_type = resource_type,
679				action = action,
680				"No collection policy found - allowing by default"
681			);
682			return true;
683		};
684
685		// Step 1: Check TOP policy (denials/constraints)
686		if let Some(Effect::Deny) =
687			policy.top_policy.evaluate(subject, action, subject_attrs, environment)
688		{
689			debug!(
690				subject = %subject.id_tag,
691				resource_type = resource_type,
692				action = action,
693				"Collection TOP policy denied"
694			);
695			return false;
696		}
697
698		// Step 2: Check BOTTOM policy (guarantees)
699		if let Some(Effect::Allow) =
700			policy.bottom_policy.evaluate(subject, action, subject_attrs, environment)
701		{
702			debug!(
703				subject = %subject.id_tag,
704				resource_type = resource_type,
705				action = action,
706				"Collection BOTTOM policy allowed"
707			);
708			return true;
709		}
710
711		// Step 3: Default deny (no policies matched)
712		debug!(
713			subject = %subject.id_tag,
714			resource_type = resource_type,
715			action = action,
716			"No matching collection policies - default deny"
717		);
718		false
719	}
720}
721
722impl Default for PermissionChecker {
723	fn default() -> Self {
724		Self::new()
725	}
726}
727
728#[cfg(test)]
729mod tests {
730	use super::*;
731
732	#[test]
733	fn test_environment_creation() {
734		let env = Environment::new();
735		assert!(env.time.0 > 0);
736	}
737
738	#[test]
739	fn test_permission_checker_creation() {
740		let checker = PermissionChecker::new();
741		assert_eq!(checker.profile_policies.len(), 0);
742	}
743
744	/// Only the pre-computed `access_level`, no owner — so only the access-level branch can allow
745	/// anything.
746	struct AccessLevelObject(&'static str);
747
748	impl AttrSet for AccessLevelObject {
749		fn get(&self, key: &str) -> Option<&str> {
750			match key {
751				"access_level" => Some(self.0),
752				_ => None,
753			}
754		}
755
756		fn get_list(&self, _key: &str) -> Option<Vec<&str>> {
757			None
758		}
759	}
760
761	fn plain_subject() -> AuthCtx {
762		AuthCtx {
763			tn_id: TnId(1),
764			id_tag: "alice.example.com".into(),
765			roles: Box::new([]),
766			scope: None,
767			anonymous: false,
768		}
769	}
770
771	#[test]
772	fn admin_access_level_allows_read_and_update() {
773		// The owner resolves to `AccessLevel::Admin`, so "admin" must satisfy both the modify and
774		// the read branch.
775		let checker = PermissionChecker::new();
776		let subject = plain_subject();
777		let env = Environment::new();
778		let object = AccessLevelObject("admin");
779
780		assert!(checker.has_permission(&subject, "file:read", &object, &env));
781		assert!(checker.has_permission(&subject, "file:update", &object, &env));
782		assert!(checker.has_permission(&subject, "file:delete", &object, &env));
783
784		// Levels below admin are unaffected.
785		let writer = AccessLevelObject("write");
786		assert!(checker.has_permission(&subject, "file:read", &writer, &env));
787		assert!(checker.has_permission(&subject, "file:update", &writer, &env));
788		let reader = AccessLevelObject("read");
789		assert!(checker.has_permission(&subject, "file:read", &reader, &env));
790		assert!(!checker.has_permission(&subject, "file:update", &reader, &env));
791
792		// A string outside the `AccessLevel::as_str` vocabulary must not read as authority.
793		let bogus = AccessLevelObject("bogus");
794		assert!(!checker.has_permission(&subject, "file:read", &bogus, &env));
795		assert!(!checker.has_permission(&subject, "file:update", &bogus, &env));
796		// ...and "none" is a recognized level that still grants nothing.
797		let none = AccessLevelObject("none");
798		assert!(!checker.has_permission(&subject, "file:read", &none, &env));
799		assert!(!checker.has_permission(&subject, "file:update", &none, &env));
800	}
801
802	#[test]
803	fn test_subscribed_level_char_roundtrip() {
804		assert_eq!(VisibilityLevel::from_char(Some('S')), VisibilityLevel::Subscribed);
805		assert_eq!(VisibilityLevel::Subscribed.to_char(), Some('S'));
806		assert_eq!(VisibilityLevel::Subscribed.as_str(), "subscribed");
807	}
808
809	#[test]
810	fn test_subscribed_can_access_base_check() {
811		// Only owner/tenant pass the base check; everyone else must go through the
812		// audience/subscription bridge in `can_view_item`.
813		assert!(SubjectAccessLevel::Owner.can_access(VisibilityLevel::Subscribed));
814		assert!(!SubjectAccessLevel::Connected.can_access(VisibilityLevel::Subscribed));
815		assert!(!SubjectAccessLevel::Follower.can_access(VisibilityLevel::Subscribed));
816		assert!(!SubjectAccessLevel::Verified.can_access(VisibilityLevel::Subscribed));
817		assert!(!SubjectAccessLevel::Public.can_access(VisibilityLevel::Subscribed));
818	}
819
820	#[test]
821	fn test_subscribed_view_via_audience_bridge() {
822		// A member injected into audience_tags (by filter.rs) can view a Subscribed row
823		// issued by a co-member, despite having no follow/connect relationship.
824		let member = "alice.example.com";
825		let ctx = ViewCheckContext {
826			subject_id_tag: member,
827			is_authenticated: true,
828			item_owner_id_tag: "bob.example.com", // a different member
829			tenant_id_tag: "home.example.com",
830			visibility: Some('S'),
831			subject_following_owner: false,
832			subject_connected_to_owner: false,
833			audience_tags: Some(&[member]),
834		};
835		assert!(can_view_item(&ctx));
836	}
837
838	#[test]
839	fn test_subscribed_denied_when_not_member() {
840		// A non-member (not in audience_tags) cannot view a Subscribed row.
841		let ctx = ViewCheckContext {
842			subject_id_tag: "carol.example.com",
843			is_authenticated: true,
844			item_owner_id_tag: "bob.example.com",
845			tenant_id_tag: "home.example.com",
846			visibility: Some('S'),
847			subject_following_owner: false,
848			subject_connected_to_owner: false,
849			audience_tags: Some(&[]),
850		};
851		assert!(!can_view_item(&ctx));
852	}
853
854	#[test]
855	fn test_subscribed_owner_and_tenant_shortcut() {
856		// The issuer views their own Subscribed row without the audience bridge.
857		let ctx = ViewCheckContext {
858			subject_id_tag: "bob.example.com",
859			is_authenticated: true,
860			item_owner_id_tag: "bob.example.com",
861			tenant_id_tag: "home.example.com",
862			visibility: Some('S'),
863			subject_following_owner: false,
864			subject_connected_to_owner: false,
865			audience_tags: Some(&[]),
866		};
867		assert!(can_view_item(&ctx));
868
869		// The node tenant reads any Subscribed row hosted on their own node.
870		let ctx_tenant = ViewCheckContext {
871			subject_id_tag: "home.example.com",
872			is_authenticated: true,
873			item_owner_id_tag: "bob.example.com",
874			tenant_id_tag: "home.example.com",
875			visibility: Some('S'),
876			subject_following_owner: false,
877			subject_connected_to_owner: false,
878			audience_tags: Some(&[]),
879		};
880		assert!(can_view_item(&ctx_tenant));
881	}
882}