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