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/// 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					&& let Some(role) = self.value.as_str()
252				{
253					return subject.roles.iter().any(|r| r.as_ref() == role);
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 { Some(self.effect) } else { None }
334	}
335}
336
337/// ABAC Policy (collection of rules)
338#[derive(Debug, Clone)]
339pub struct Policy {
340	pub name: String,
341	pub rules: Vec<PolicyRule>,
342}
343
344impl Policy {
345	/// Evaluate policy - returns Effect if any rule matches
346	pub fn evaluate(
347		&self,
348		subject: &AuthCtx,
349		action: &str,
350		object: &dyn AttrSet,
351		environment: &Environment,
352	) -> Option<Effect> {
353		for rule in &self.rules {
354			if let Some(effect) = rule.evaluate(subject, action, object, environment) {
355				return Some(effect);
356			}
357		}
358		None
359	}
360}
361
362/// Profile-level policy configuration (TOP + BOTTOM)
363#[derive(Debug, Clone)]
364pub struct ProfilePolicy {
365	pub tn_id: TnId,
366	pub top_policy: Policy,    // Maximum permissions (constraints)
367	pub bottom_policy: Policy, // Minimum permissions (guarantees)
368}
369
370/// Collection-level policy configuration
371///
372/// Used for CREATE operations where no specific object exists yet.
373/// Evaluates permissions based on subject attributes only.
374///
375/// Example: User wants to upload a file
376///   - Can evaluate "can user create files?" without the file existing
377///   - Checks: quota_remaining > 0, role == "creator", !banned, email_verified
378#[derive(Debug, Clone)]
379pub struct CollectionPolicy {
380	pub resource_type: String, // "files", "actions", "profiles"
381	pub action: String,        // "create", "list"
382	pub top_policy: Policy,    // Denials/constraints
383	pub bottom_policy: Policy, // Guarantees
384}
385
386/// Main permission checker
387pub struct PermissionChecker {
388	profile_policies: HashMap<TnId, ProfilePolicy>,
389	collection_policies: HashMap<String, CollectionPolicy>, // key: "resource:action"
390}
391
392impl PermissionChecker {
393	pub fn new() -> Self {
394		Self { profile_policies: HashMap::new(), collection_policies: HashMap::new() }
395	}
396
397	/// Load profile policy for tenant (called during bootstrap)
398	pub fn load_policy(&mut self, policy: ProfilePolicy) {
399		self.profile_policies.insert(policy.tn_id, policy);
400	}
401
402	/// Load collection policy for resource type + action
403	pub fn load_collection_policy(&mut self, policy: CollectionPolicy) {
404		let key = format!("{}:{}", policy.resource_type, policy.action);
405		self.collection_policies.insert(key, policy);
406	}
407
408	/// Get collection policy for resource type and action
409	pub fn get_collection_policy(
410		&self,
411		resource_type: &str,
412		action: &str,
413	) -> Option<&CollectionPolicy> {
414		let key = format!("{}:{}", resource_type, action);
415		self.collection_policies.get(&key)
416	}
417
418	/// Core permission check function
419	pub fn has_permission(
420		&self,
421		subject: &AuthCtx,
422		action: &str,
423		object: &dyn AttrSet,
424		environment: &Environment,
425	) -> bool {
426		// Step 1: Check TOP policy (constraints)
427		if let Some(profile_policy) = self.profile_policies.get(&subject.tn_id) {
428			if let Some(Effect::Deny) =
429				profile_policy.top_policy.evaluate(subject, action, object, environment)
430			{
431				info!("TOP policy denied: tn_id={}, action={}", subject.tn_id.0, action);
432				return false;
433			}
434
435			// Step 2: Check BOTTOM policy (guarantees)
436			if let Some(Effect::Allow) =
437				profile_policy.bottom_policy.evaluate(subject, action, object, environment)
438			{
439				info!("BOTTOM policy allowed: tn_id={}, action={}", subject.tn_id.0, action);
440				return true;
441			}
442		}
443
444		// Step 3: Default permission rules (ownership, visibility, etc.)
445		self.check_default_rules(subject, action, object, environment)
446	}
447
448	/// Default permission rules (when policies don't match)
449	fn check_default_rules(
450		&self,
451		subject: &AuthCtx,
452		action: &str,
453		object: &dyn AttrSet,
454		_environment: &Environment,
455	) -> bool {
456		use tracing::debug;
457
458		// Leader override - leaders can do everything
459		if subject.roles.iter().any(|r| r.as_ref() == "leader") {
460			debug!(subject = %subject.id_tag, action = action, "Leader role allows access");
461			return true;
462		}
463
464		// Parse action into resource:operation
465		let parts: Vec<&str> = action.split(':').collect();
466		if parts.len() != 2 {
467			debug!(subject = %subject.id_tag, action = action, "Invalid action format (expected resource:operation)");
468			return false;
469		}
470		let operation = parts[1];
471
472		// Ownership check for modify operations
473		if matches!(operation, "update" | "delete" | "write") {
474			if let Some(owner) = object.get("owner_id_tag")
475				&& owner == subject.id_tag.as_ref()
476			{
477				debug!(subject = %subject.id_tag, action = action, owner = owner, "Owner access allowed for modify operation");
478				return true;
479			}
480			// Check pre-computed access level (community roles, FSHR shares, scoped tokens)
481			if let Some(al) = object.get("access_level")
482				&& al == "write"
483			{
484				debug!(subject = %subject.id_tag, action = action, "Write access level allows modify operation");
485				return true;
486			}
487			debug!(subject = %subject.id_tag, action = action, "Denied: not owner and no write access level");
488			return false;
489		}
490
491		// Visibility check for read operations
492		if matches!(operation, "read") {
493			// Check explicit access grants (e.g., FSHR file shares, scoped tokens)
494			if let Some(al) = object.get("access_level")
495				&& matches!(al, "read" | "comment" | "write")
496			{
497				return true;
498			}
499			return self.check_visibility(subject, object);
500		}
501
502		// Create operations - check quota/limits in future
503		if operation == "create" {
504			debug!(subject = %subject.id_tag, action = action, "Create operation allowed");
505			return true; // Allow for now
506		}
507
508		// Default deny
509		debug!(subject = %subject.id_tag, action = action, "Default deny: no matching rules");
510		false
511	}
512
513	/// Check visibility-based access using the new VisibilityLevel enum
514	///
515	/// Determines subject's access level and checks against resource visibility.
516	/// Supports both char-based visibility (from DB) and string-based (legacy).
517	#[expect(clippy::unused_self, reason = "method may use self in future policy checks")]
518	fn check_visibility(&self, subject: &AuthCtx, object: &dyn AttrSet) -> bool {
519		use tracing::debug;
520
521		// Parse visibility from object attributes
522		// Try char-based first (from "visibility_char"), then fall back to string
523		let visibility = if let Some(vis_char) = object.get("visibility_char") {
524			VisibilityLevel::from_char(vis_char.chars().next())
525		} else if let Some(vis_str) = object.get("visibility") {
526			match vis_str {
527				"public" | "P" => VisibilityLevel::Public,
528				"verified" | "V" => VisibilityLevel::Verified,
529				"second_degree" | "2" => VisibilityLevel::SecondDegree,
530				"follower" | "F" => VisibilityLevel::Follower,
531				"connected" | "C" => VisibilityLevel::Connected,
532				// "direct" or unknown = Direct (secure by default)
533				_ => VisibilityLevel::Direct,
534			}
535		} else {
536			VisibilityLevel::Direct // No visibility = Direct (most restrictive)
537		};
538
539		// Determine subject's access level based on relationship with resource
540		let is_owner = object.get("owner_id_tag") == Some(subject.id_tag.as_ref());
541		let is_issuer = object.get("issuer_id_tag") == Some(subject.id_tag.as_ref());
542		let is_connected = object.get("connected") == Some("true");
543		let is_follower = object.get("following") == Some("true");
544		let in_audience = object.contains("audience_tag", subject.id_tag.as_ref());
545
546		// Calculate subject's effective access level
547		// Note: "guest" id_tag is used for unauthenticated users - treat as Public
548		let is_authenticated = !subject.id_tag.is_empty() && subject.id_tag.as_ref() != "guest";
549		let access_level = if is_owner || is_issuer {
550			SubjectAccessLevel::Owner
551		} else if is_connected {
552			SubjectAccessLevel::Connected
553		} else if is_follower {
554			SubjectAccessLevel::Follower
555		} else if is_authenticated {
556			// Authenticated user without specific relationship
557			SubjectAccessLevel::Verified
558		} else {
559			SubjectAccessLevel::Public
560		};
561
562		// Check if access level meets visibility requirement
563		let allowed = access_level.can_access(visibility);
564
565		// For Direct visibility, also check explicit audience
566		let allowed =
567			if visibility == VisibilityLevel::Direct { allowed || in_audience } else { allowed };
568
569		debug!(
570			subject = %subject.id_tag,
571			visibility = ?visibility,
572			access_level = ?access_level,
573			is_owner = is_owner,
574			is_issuer = is_issuer,
575			is_connected = is_connected,
576			is_follower = is_follower,
577			in_audience = in_audience,
578			allowed = allowed,
579			"Visibility check"
580		);
581
582		allowed
583	}
584
585	/// Evaluate collection policy (for CREATE operations)
586	///
587	/// Collection policies check subject attributes without an object existing.
588	/// Used for operations like "can user upload files?" or "can user create posts?"
589	pub fn has_collection_permission(
590		&self,
591		subject: &AuthCtx,
592		subject_attrs: &dyn AttrSet,
593		resource_type: &str,
594		action: &str,
595		environment: &Environment,
596	) -> bool {
597		use tracing::debug;
598
599		// Get collection policy
600		let Some(policy) = self.get_collection_policy(resource_type, action) else {
601			// No policy defined → allow by default
602			debug!(
603				subject = %subject.id_tag,
604				resource_type = resource_type,
605				action = action,
606				"No collection policy found - allowing by default"
607			);
608			return true;
609		};
610
611		// Step 1: Check TOP policy (denials/constraints)
612		if let Some(Effect::Deny) =
613			policy.top_policy.evaluate(subject, action, subject_attrs, environment)
614		{
615			debug!(
616				subject = %subject.id_tag,
617				resource_type = resource_type,
618				action = action,
619				"Collection TOP policy denied"
620			);
621			return false;
622		}
623
624		// Step 2: Check BOTTOM policy (guarantees)
625		if let Some(Effect::Allow) =
626			policy.bottom_policy.evaluate(subject, action, subject_attrs, environment)
627		{
628			debug!(
629				subject = %subject.id_tag,
630				resource_type = resource_type,
631				action = action,
632				"Collection BOTTOM policy allowed"
633			);
634			return true;
635		}
636
637		// Step 3: Default deny (no policies matched)
638		debug!(
639			subject = %subject.id_tag,
640			resource_type = resource_type,
641			action = action,
642			"No matching collection policies - default deny"
643		);
644		false
645	}
646}
647
648impl Default for PermissionChecker {
649	fn default() -> Self {
650		Self::new()
651	}
652}
653
654#[cfg(test)]
655mod tests {
656	use super::*;
657
658	#[test]
659	fn test_environment_creation() {
660		let env = Environment::new();
661		assert!(env.time.0 > 0);
662	}
663
664	#[test]
665	fn test_permission_checker_creation() {
666		let checker = PermissionChecker::new();
667		assert_eq!(checker.profile_policies.len(), 0);
668	}
669}