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