Skip to main content

cloudillo_core/
abac.rs

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