1use crate::prelude::*;
10use cloudillo_types::auth_adapter::AuthCtx;
11use std::collections::HashMap;
12
13#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
26pub enum VisibilityLevel {
27 Public,
29 Verified,
31 SecondDegree,
33 Follower,
35 Connected,
37 #[default]
39 Direct,
40}
41
42impl VisibilityLevel {
43 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, _ => Self::Direct, }
54 }
55
56 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 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
88pub enum SubjectAccessLevel {
89 #[default]
91 None,
92 Public,
94 Verified,
96 SecondDegree,
98 Follower,
100 Connected,
102 Owner,
104}
105
106impl SubjectAccessLevel {
107 pub fn can_access(self, visibility: VisibilityLevel) -> bool {
109 match visibility {
110 VisibilityLevel::Public => true, 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, }
117 }
118}
119
120#[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 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 } 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 if access_level.can_access(visibility) {
165 return true;
166 }
167
168 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
178pub use cloudillo_types::abac::AttrSet;
180
181#[derive(Debug, Clone)]
183pub struct Environment {
184 pub time: Timestamp,
185 }
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#[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, HasRole, }
219
220impl Condition {
221 pub fn evaluate(
223 &self,
224 subject: &AuthCtx,
225 action: &str,
226 object: &dyn AttrSet,
227 _environment: &Environment,
228 ) -> bool {
229 if let Some(obj_val) = object.get(&self.attribute) {
231 return self.compare_value(obj_val);
232 }
233
234 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 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 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#[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 pub fn evaluate(
311 &self,
312 subject: &AuthCtx,
313 action: &str,
314 object: &dyn AttrSet,
315 environment: &Environment,
316 ) -> Option<Effect> {
317 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#[derive(Debug, Clone)]
333pub struct Policy {
334 pub name: String,
335 pub rules: Vec<PolicyRule>,
336}
337
338impl Policy {
339 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#[derive(Debug, Clone)]
358pub struct ProfilePolicy {
359 pub tn_id: TnId,
360 pub top_policy: Policy, pub bottom_policy: Policy, }
363
364#[derive(Debug, Clone)]
373pub struct CollectionPolicy {
374 pub resource_type: String, pub action: String, pub top_policy: Policy, pub bottom_policy: Policy, }
379
380pub struct PermissionChecker {
382 profile_policies: HashMap<TnId, ProfilePolicy>,
383 collection_policies: HashMap<String, CollectionPolicy>, }
385
386impl PermissionChecker {
387 pub fn new() -> Self {
388 Self { profile_policies: HashMap::new(), collection_policies: HashMap::new() }
389 }
390
391 pub fn load_policy(&mut self, policy: ProfilePolicy) {
393 self.profile_policies.insert(policy.tn_id, policy);
394 }
395
396 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 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 pub fn has_permission(
414 &self,
415 subject: &AuthCtx,
416 action: &str,
417 object: &dyn AttrSet,
418 environment: &Environment,
419 ) -> bool {
420 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 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 self.check_default_rules(subject, action, object, environment)
440 }
441
442 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 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 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 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 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 if matches!(operation, "read") {
487 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 if operation == "create" {
498 debug!(subject = %subject.id_tag, action = action, "Create operation allowed");
499 return true; }
501
502 debug!(subject = %subject.id_tag, action = action, "Default deny: no matching rules");
504 false
505 }
506
507 fn check_visibility(&self, subject: &AuthCtx, object: &dyn AttrSet) -> bool {
512 use tracing::debug;
513
514 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, }
528 } else {
529 VisibilityLevel::Direct };
531
532 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 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 SubjectAccessLevel::Verified
551 } else {
552 SubjectAccessLevel::Public
553 };
554
555 let allowed = access_level.can_access(visibility);
557
558 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 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 let policy = match self.get_collection_policy(resource_type, action) {
594 Some(p) => p,
595 None => {
596 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 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 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 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}