1use crate::prelude::*;
13use cloudillo_types::auth_adapter::AuthCtx;
14use std::collections::HashMap;
15
16#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
29pub enum VisibilityLevel {
30 Public,
32 Verified,
34 SecondDegree,
36 Follower,
38 Connected,
40 Subscribed,
43 #[default]
45 Direct,
46}
47
48impl VisibilityLevel {
49 pub fn from_char(c: Option<char>) -> Self {
51 match c {
52 Some('P') => Self::Public,
53 Some('V') => Self::Verified,
54 Some('2') => Self::SecondDegree,
55 Some('F') => Self::Follower,
56 Some('C') => Self::Connected,
57 Some('S') => Self::Subscribed,
58 None | Some(_) => Self::Direct,
60 }
61 }
62
63 pub fn to_char(&self) -> Option<char> {
65 match self {
66 Self::Public => Some('P'),
67 Self::Verified => Some('V'),
68 Self::SecondDegree => Some('2'),
69 Self::Follower => Some('F'),
70 Self::Connected => Some('C'),
71 Self::Subscribed => Some('S'),
72 Self::Direct => None,
73 }
74 }
75
76 pub fn as_str(&self) -> &'static str {
78 match self {
79 Self::Public => "public",
80 Self::Verified => "verified",
81 Self::SecondDegree => "second_degree",
82 Self::Follower => "follower",
83 Self::Connected => "connected",
84 Self::Subscribed => "subscribed",
85 Self::Direct => "direct",
86 }
87 }
88}
89
90#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
97pub enum SubjectAccessLevel {
98 #[default]
100 None,
101 Public,
103 Verified,
105 SecondDegree,
107 Follower,
109 Connected,
111 Owner,
113}
114
115impl SubjectAccessLevel {
116 pub fn can_access(self, visibility: VisibilityLevel) -> bool {
118 match visibility {
119 VisibilityLevel::Public => true, VisibilityLevel::Verified => self >= Self::Verified,
121 VisibilityLevel::SecondDegree => self >= Self::SecondDegree,
122 VisibilityLevel::Follower => self >= Self::Follower,
123 VisibilityLevel::Connected => self >= Self::Connected,
124 VisibilityLevel::Subscribed | VisibilityLevel::Direct => self >= Self::Owner,
127 }
128 }
129
130 pub fn visible_levels(self) -> Option<&'static [char]> {
134 match self {
135 Self::None | Self::Public => Some(&['P']),
136 Self::Verified => Some(&['P', 'V']),
137 Self::SecondDegree => Some(&['P', 'V', '2']),
138 Self::Follower => Some(&['P', 'V', '2', 'F']),
139 Self::Connected => Some(&['P', 'V', '2', 'F', 'C']),
140 Self::Owner => None,
141 }
142 }
143}
144
145pub struct ViewCheckContext<'a> {
147 pub subject_id_tag: &'a str,
148 pub is_authenticated: bool,
149 pub item_owner_id_tag: &'a str,
150 pub tenant_id_tag: &'a str,
151 pub visibility: Option<char>,
152 pub subject_following_owner: bool,
153 pub subject_connected_to_owner: bool,
154 pub audience_tags: Option<&'a [&'a str]>,
155}
156
157pub fn can_view_item(ctx: &ViewCheckContext<'_>) -> bool {
162 let visibility = VisibilityLevel::from_char(ctx.visibility);
163
164 let is_real_auth =
167 ctx.is_authenticated && !ctx.subject_id_tag.is_empty() && ctx.subject_id_tag != "guest";
168 let is_tenant = ctx.subject_id_tag == ctx.tenant_id_tag;
169 let access_level = if ctx.subject_id_tag == ctx.item_owner_id_tag || is_tenant {
170 SubjectAccessLevel::Owner } else if ctx.subject_connected_to_owner {
172 SubjectAccessLevel::Connected
173 } else if ctx.subject_following_owner {
174 SubjectAccessLevel::Follower
175 } else if is_real_auth {
176 SubjectAccessLevel::Verified
177 } else {
178 SubjectAccessLevel::Public
179 };
180
181 if access_level.can_access(visibility) {
183 return true;
184 }
185
186 if (visibility == VisibilityLevel::Direct || visibility == VisibilityLevel::Subscribed)
190 && let Some(tags) = ctx.audience_tags
191 {
192 return tags.contains(&ctx.subject_id_tag);
193 }
194
195 false
196}
197
198pub use cloudillo_types::abac::AttrSet;
200
201pub fn is_admin(auth: &AuthCtx) -> bool {
206 auth.roles.iter().any(|r| r.as_ref() == "SADM")
207}
208
209#[derive(Debug, Clone)]
211pub struct Environment {
212 pub time: Timestamp,
213 }
215
216impl Environment {
217 pub fn new() -> Self {
218 Self { time: Timestamp::now() }
219 }
220}
221
222impl Default for Environment {
223 fn default() -> Self {
224 Self::new()
225 }
226}
227
228#[derive(Debug, Clone)]
230pub struct Condition {
231 pub attribute: String,
232 pub operator: Operator,
233 pub value: serde_json::Value,
234}
235
236#[derive(Debug, Clone, Copy)]
237pub enum Operator {
238 Equals,
239 NotEquals,
240 Contains,
241 NotContains,
242 GreaterThan,
243 LessThan,
244 In, HasRole, }
247
248impl Condition {
249 pub fn evaluate(
251 &self,
252 subject: &AuthCtx,
253 action: &str,
254 object: &dyn AttrSet,
255 _environment: &Environment,
256 ) -> bool {
257 if let Some(obj_val) = object.get(&self.attribute) {
259 return self.compare_value(obj_val);
260 }
261
262 match self.attribute.as_str() {
264 "subject.id_tag" => self.compare_value(&subject.id_tag),
265 "subject.tn_id" => self.compare_value(&subject.tn_id.0.to_string()),
266 "subject.roles" | "role.admin" | "role.moderator" | "role.member" => {
267 if let Operator::HasRole = self.operator
269 && let Some(role) = self.value.as_str()
270 {
271 return subject.roles.iter().any(|r| r.as_ref() == role);
272 }
273 if self.attribute.starts_with("role.") {
275 let role_name = &self.attribute[5..];
276 return subject.roles.iter().any(|r| r.as_ref() == role_name);
277 }
278 false
279 }
280 "action" => self.compare_value(action),
281 _ => false,
282 }
283 }
284
285 fn compare_value(&self, actual: &str) -> bool {
286 match self.operator {
287 Operator::Equals => self.value.as_str() == Some(actual),
288 Operator::NotEquals => self.value.as_str() != Some(actual),
289 Operator::Contains => {
290 if let Some(needle) = self.value.as_str() {
291 actual.contains(needle)
292 } else {
293 false
294 }
295 }
296 Operator::NotContains => {
297 if let Some(needle) = self.value.as_str() {
298 !actual.contains(needle)
299 } else {
300 true
301 }
302 }
303 Operator::GreaterThan => {
304 if let (Some(threshold), Ok(val)) = (self.value.as_f64(), actual.parse::<f64>()) {
305 val > threshold
306 } else {
307 false
308 }
309 }
310 Operator::LessThan => {
311 if let (Some(threshold), Ok(val)) = (self.value.as_f64(), actual.parse::<f64>()) {
312 val < threshold
313 } else {
314 false
315 }
316 }
317 Operator::In | Operator::HasRole => false,
318 }
319 }
320}
321
322#[derive(Debug, Clone)]
324pub struct PolicyRule {
325 pub name: String,
326 pub conditions: Vec<Condition>,
327 pub effect: Effect,
328}
329
330#[derive(Debug, Clone, Copy, PartialEq, Eq)]
331pub enum Effect {
332 Allow,
333 Deny,
334}
335
336impl PolicyRule {
337 pub fn evaluate(
339 &self,
340 subject: &AuthCtx,
341 action: &str,
342 object: &dyn AttrSet,
343 environment: &Environment,
344 ) -> Option<Effect> {
345 let all_match = self
347 .conditions
348 .iter()
349 .all(|cond| cond.evaluate(subject, action, object, environment));
350
351 if all_match { Some(self.effect) } else { None }
352 }
353}
354
355#[derive(Debug, Clone)]
357pub struct Policy {
358 pub name: String,
359 pub rules: Vec<PolicyRule>,
360}
361
362impl Policy {
363 pub fn evaluate(
365 &self,
366 subject: &AuthCtx,
367 action: &str,
368 object: &dyn AttrSet,
369 environment: &Environment,
370 ) -> Option<Effect> {
371 for rule in &self.rules {
372 if let Some(effect) = rule.evaluate(subject, action, object, environment) {
373 return Some(effect);
374 }
375 }
376 None
377 }
378}
379
380#[derive(Debug, Clone)]
382pub struct ProfilePolicy {
383 pub tn_id: TnId,
384 pub top_policy: Policy, pub bottom_policy: Policy, }
387
388#[derive(Debug, Clone)]
397pub struct CollectionPolicy {
398 pub resource_type: String, pub action: String, pub top_policy: Policy, pub bottom_policy: Policy, }
403
404pub struct PermissionChecker {
406 profile_policies: HashMap<TnId, ProfilePolicy>,
407 collection_policies: HashMap<String, CollectionPolicy>, }
409
410impl PermissionChecker {
411 pub fn new() -> Self {
412 Self { profile_policies: HashMap::new(), collection_policies: HashMap::new() }
413 }
414
415 pub fn load_policy(&mut self, policy: ProfilePolicy) {
417 self.profile_policies.insert(policy.tn_id, policy);
418 }
419
420 pub fn load_collection_policy(&mut self, policy: CollectionPolicy) {
422 let key = format!("{}:{}", policy.resource_type, policy.action);
423 self.collection_policies.insert(key, policy);
424 }
425
426 pub fn get_collection_policy(
428 &self,
429 resource_type: &str,
430 action: &str,
431 ) -> Option<&CollectionPolicy> {
432 let key = format!("{}:{}", resource_type, action);
433 self.collection_policies.get(&key)
434 }
435
436 pub fn has_permission(
438 &self,
439 subject: &AuthCtx,
440 action: &str,
441 object: &dyn AttrSet,
442 environment: &Environment,
443 ) -> bool {
444 if let Some(profile_policy) = self.profile_policies.get(&subject.tn_id) {
446 if let Some(Effect::Deny) =
447 profile_policy.top_policy.evaluate(subject, action, object, environment)
448 {
449 info!("TOP policy denied: tn_id={}, action={}", subject.tn_id.0, action);
450 return false;
451 }
452
453 if let Some(Effect::Allow) =
455 profile_policy.bottom_policy.evaluate(subject, action, object, environment)
456 {
457 info!("BOTTOM policy allowed: tn_id={}, action={}", subject.tn_id.0, action);
458 return true;
459 }
460 }
461
462 self.check_default_rules(subject, action, object, environment)
464 }
465
466 fn check_default_rules(
468 &self,
469 subject: &AuthCtx,
470 action: &str,
471 object: &dyn AttrSet,
472 _environment: &Environment,
473 ) -> bool {
474 use tracing::debug;
475
476 if subject.roles.iter().any(|r| r.as_ref() == "leader") {
478 debug!(subject = %subject.id_tag, action = action, "Leader role allows access");
479 return true;
480 }
481
482 let parts: Vec<&str> = action.split(':').collect();
484 if parts.len() != 2 {
485 debug!(subject = %subject.id_tag, action = action, "Invalid action format (expected resource:operation)");
486 return false;
487 }
488 let operation = parts[1];
489
490 if matches!(operation, "update" | "delete" | "write") {
492 if let Some(owner) = object.get("owner_id_tag")
493 && owner == subject.id_tag.as_ref()
494 {
495 debug!(subject = %subject.id_tag, action = action, owner = owner, "Owner access allowed for modify operation");
496 return true;
497 }
498 if let Some(al) = object.get("access_level")
500 && al == "write"
501 {
502 debug!(subject = %subject.id_tag, action = action, "Write access level allows modify operation");
503 return true;
504 }
505 debug!(subject = %subject.id_tag, action = action, "Denied: not owner and no write access level");
506 return false;
507 }
508
509 if matches!(operation, "read") {
511 if let Some(al) = object.get("access_level")
513 && matches!(al, "read" | "comment" | "write")
514 {
515 return true;
516 }
517 return self.check_visibility(subject, object);
518 }
519
520 if operation == "create" {
522 debug!(subject = %subject.id_tag, action = action, "Create operation allowed");
523 return true; }
525
526 if operation == "admin" {
534 use crate::roles::{MODERATOR_LEVEL, highest_role_level};
535 if highest_role_level(&subject.roles) >= MODERATOR_LEVEL {
536 debug!(subject = %subject.id_tag, action = action, "Moderator+ role allows admin operation");
537 return true;
538 }
539 debug!(subject = %subject.id_tag, action = action, "Denied: admin operation requires moderator+");
540 return false;
541 }
542
543 debug!(subject = %subject.id_tag, action = action, "Default deny: no matching rules");
545 false
546 }
547
548 #[expect(clippy::unused_self, reason = "method may use self in future policy checks")]
553 fn check_visibility(&self, subject: &AuthCtx, object: &dyn AttrSet) -> bool {
554 use tracing::debug;
555
556 let visibility = if let Some(vis_char) = object.get("visibility_char") {
559 VisibilityLevel::from_char(vis_char.chars().next())
560 } else if let Some(vis_str) = object.get("visibility") {
561 match vis_str {
562 "public" | "P" => VisibilityLevel::Public,
563 "verified" | "V" => VisibilityLevel::Verified,
564 "second_degree" | "2" => VisibilityLevel::SecondDegree,
565 "follower" | "F" => VisibilityLevel::Follower,
566 "connected" | "C" => VisibilityLevel::Connected,
567 _ => VisibilityLevel::Direct,
569 }
570 } else {
571 VisibilityLevel::Direct };
573
574 let is_owner = object.get("owner_id_tag") == Some(subject.id_tag.as_ref());
576 let is_issuer = object.get("issuer_id_tag") == Some(subject.id_tag.as_ref());
577 let is_connected = object.get("connected") == Some("true");
578 let is_follower = object.get("following") == Some("true");
579 let in_audience = object.contains("audience_tag", subject.id_tag.as_ref());
580
581 let is_authenticated = !subject.id_tag.is_empty() && subject.id_tag.as_ref() != "guest";
584 let access_level = if is_owner || is_issuer {
585 SubjectAccessLevel::Owner
586 } else if is_connected {
587 SubjectAccessLevel::Connected
588 } else if is_follower {
589 SubjectAccessLevel::Follower
590 } else if is_authenticated {
591 SubjectAccessLevel::Verified
593 } else {
594 SubjectAccessLevel::Public
595 };
596
597 let allowed = access_level.can_access(visibility);
599
600 let allowed =
602 if visibility == VisibilityLevel::Direct { allowed || in_audience } else { allowed };
603
604 debug!(
605 subject = %subject.id_tag,
606 visibility = ?visibility,
607 access_level = ?access_level,
608 is_owner = is_owner,
609 is_issuer = is_issuer,
610 is_connected = is_connected,
611 is_follower = is_follower,
612 in_audience = in_audience,
613 allowed = allowed,
614 "Visibility check"
615 );
616
617 allowed
618 }
619
620 pub fn has_collection_permission(
625 &self,
626 subject: &AuthCtx,
627 subject_attrs: &dyn AttrSet,
628 resource_type: &str,
629 action: &str,
630 environment: &Environment,
631 ) -> bool {
632 use tracing::debug;
633
634 let Some(policy) = self.get_collection_policy(resource_type, action) else {
636 debug!(
638 subject = %subject.id_tag,
639 resource_type = resource_type,
640 action = action,
641 "No collection policy found - allowing by default"
642 );
643 return true;
644 };
645
646 if let Some(Effect::Deny) =
648 policy.top_policy.evaluate(subject, action, subject_attrs, environment)
649 {
650 debug!(
651 subject = %subject.id_tag,
652 resource_type = resource_type,
653 action = action,
654 "Collection TOP policy denied"
655 );
656 return false;
657 }
658
659 if let Some(Effect::Allow) =
661 policy.bottom_policy.evaluate(subject, action, subject_attrs, environment)
662 {
663 debug!(
664 subject = %subject.id_tag,
665 resource_type = resource_type,
666 action = action,
667 "Collection BOTTOM policy allowed"
668 );
669 return true;
670 }
671
672 debug!(
674 subject = %subject.id_tag,
675 resource_type = resource_type,
676 action = action,
677 "No matching collection policies - default deny"
678 );
679 false
680 }
681}
682
683impl Default for PermissionChecker {
684 fn default() -> Self {
685 Self::new()
686 }
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692
693 #[test]
694 fn test_environment_creation() {
695 let env = Environment::new();
696 assert!(env.time.0 > 0);
697 }
698
699 #[test]
700 fn test_permission_checker_creation() {
701 let checker = PermissionChecker::new();
702 assert_eq!(checker.profile_policies.len(), 0);
703 }
704
705 #[test]
706 fn test_subscribed_level_char_roundtrip() {
707 assert_eq!(VisibilityLevel::from_char(Some('S')), VisibilityLevel::Subscribed);
708 assert_eq!(VisibilityLevel::Subscribed.to_char(), Some('S'));
709 assert_eq!(VisibilityLevel::Subscribed.as_str(), "subscribed");
710 }
711
712 #[test]
713 fn test_subscribed_can_access_base_check() {
714 assert!(SubjectAccessLevel::Owner.can_access(VisibilityLevel::Subscribed));
717 assert!(!SubjectAccessLevel::Connected.can_access(VisibilityLevel::Subscribed));
718 assert!(!SubjectAccessLevel::Follower.can_access(VisibilityLevel::Subscribed));
719 assert!(!SubjectAccessLevel::Verified.can_access(VisibilityLevel::Subscribed));
720 assert!(!SubjectAccessLevel::Public.can_access(VisibilityLevel::Subscribed));
721 }
722
723 #[test]
724 fn test_subscribed_view_via_audience_bridge() {
725 let member = "alice.example.com";
728 let ctx = ViewCheckContext {
729 subject_id_tag: member,
730 is_authenticated: true,
731 item_owner_id_tag: "bob.example.com", tenant_id_tag: "home.example.com",
733 visibility: Some('S'),
734 subject_following_owner: false,
735 subject_connected_to_owner: false,
736 audience_tags: Some(&[member]),
737 };
738 assert!(can_view_item(&ctx));
739 }
740
741 #[test]
742 fn test_subscribed_denied_when_not_member() {
743 let ctx = ViewCheckContext {
745 subject_id_tag: "carol.example.com",
746 is_authenticated: true,
747 item_owner_id_tag: "bob.example.com",
748 tenant_id_tag: "home.example.com",
749 visibility: Some('S'),
750 subject_following_owner: false,
751 subject_connected_to_owner: false,
752 audience_tags: Some(&[]),
753 };
754 assert!(!can_view_item(&ctx));
755 }
756
757 #[test]
758 fn test_subscribed_owner_and_tenant_shortcut() {
759 let ctx = ViewCheckContext {
761 subject_id_tag: "bob.example.com",
762 is_authenticated: true,
763 item_owner_id_tag: "bob.example.com",
764 tenant_id_tag: "home.example.com",
765 visibility: Some('S'),
766 subject_following_owner: false,
767 subject_connected_to_owner: false,
768 audience_tags: Some(&[]),
769 };
770 assert!(can_view_item(&ctx));
771
772 let ctx_tenant = ViewCheckContext {
774 subject_id_tag: "home.example.com",
775 is_authenticated: true,
776 item_owner_id_tag: "bob.example.com",
777 tenant_id_tag: "home.example.com",
778 visibility: Some('S'),
779 subject_following_owner: false,
780 subject_connected_to_owner: false,
781 audience_tags: Some(&[]),
782 };
783 assert!(can_view_item(&ctx_tenant));
784 }
785}