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