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