1use crate::prelude::*;
13use cloudillo_types::auth_adapter::AuthCtx;
14use cloudillo_types::types::AccessLevel;
15use std::collections::HashMap;
16
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
30pub enum VisibilityLevel {
31 Public,
33 Verified,
35 SecondDegree,
37 Follower,
39 Connected,
41 Subscribed,
44 #[default]
46 Direct,
47}
48
49impl VisibilityLevel {
50 pub fn from_char(c: Option<char>) -> Self {
52 match c {
53 Some('P') => Self::Public,
54 Some('V') => Self::Verified,
55 Some('2') => Self::SecondDegree,
56 Some('F') => Self::Follower,
57 Some('C') => Self::Connected,
58 Some('S') => Self::Subscribed,
59 None | Some(_) => Self::Direct,
61 }
62 }
63
64 pub fn to_char(&self) -> Option<char> {
66 match self {
67 Self::Public => Some('P'),
68 Self::Verified => Some('V'),
69 Self::SecondDegree => Some('2'),
70 Self::Follower => Some('F'),
71 Self::Connected => Some('C'),
72 Self::Subscribed => Some('S'),
73 Self::Direct => None,
74 }
75 }
76
77 pub fn as_str(&self) -> &'static str {
79 match self {
80 Self::Public => "public",
81 Self::Verified => "verified",
82 Self::SecondDegree => "second_degree",
83 Self::Follower => "follower",
84 Self::Connected => "connected",
85 Self::Subscribed => "subscribed",
86 Self::Direct => "direct",
87 }
88 }
89}
90
91#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
98pub enum SubjectAccessLevel {
99 #[default]
101 None,
102 Public,
104 Verified,
106 SecondDegree,
108 Follower,
110 Connected,
112 Owner,
114}
115
116impl SubjectAccessLevel {
117 pub fn can_access(self, visibility: VisibilityLevel) -> bool {
119 match visibility {
120 VisibilityLevel::Public => true, VisibilityLevel::Verified => self >= Self::Verified,
122 VisibilityLevel::SecondDegree => self >= Self::SecondDegree,
123 VisibilityLevel::Follower => self >= Self::Follower,
124 VisibilityLevel::Connected => self >= Self::Connected,
125 VisibilityLevel::Subscribed | VisibilityLevel::Direct => self >= Self::Owner,
128 }
129 }
130
131 pub fn visible_levels(self) -> Option<&'static [char]> {
135 match self {
136 Self::None | Self::Public => Some(&['P']),
137 Self::Verified => Some(&['P', 'V']),
138 Self::SecondDegree => Some(&['P', 'V', '2']),
139 Self::Follower => Some(&['P', 'V', '2', 'F']),
140 Self::Connected => Some(&['P', 'V', '2', 'F', 'C']),
141 Self::Owner => None,
142 }
143 }
144}
145
146#[allow(clippy::fn_params_excessive_bools)]
163pub fn relationship_level(
164 is_owner: bool,
165 connected: bool,
166 following: bool,
167 is_real_auth: bool,
168) -> SubjectAccessLevel {
169 if is_owner {
170 SubjectAccessLevel::Owner
171 } else if connected {
172 SubjectAccessLevel::Connected
173 } else if following {
174 SubjectAccessLevel::Follower
175 } else if is_real_auth {
176 SubjectAccessLevel::Verified
177 } else {
178 SubjectAccessLevel::Public
179 }
180}
181
182pub struct ViewCheckContext<'a> {
184 pub subject_id_tag: &'a str,
185 pub is_authenticated: bool,
186 pub item_owner_id_tag: &'a str,
187 pub tenant_id_tag: &'a str,
188 pub visibility: Option<char>,
189 pub subject_following_owner: bool,
190 pub subject_connected_to_owner: bool,
191 pub audience_tags: Option<&'a [&'a str]>,
192}
193
194pub fn can_view_item(ctx: &ViewCheckContext<'_>) -> bool {
199 let visibility = VisibilityLevel::from_char(ctx.visibility);
200
201 let is_real_auth =
204 ctx.is_authenticated && !ctx.subject_id_tag.is_empty() && ctx.subject_id_tag != "guest";
205 let is_tenant = ctx.subject_id_tag == ctx.tenant_id_tag;
206 let access_level = if ctx.subject_id_tag == ctx.item_owner_id_tag || is_tenant {
207 SubjectAccessLevel::Owner } else if ctx.subject_connected_to_owner {
209 SubjectAccessLevel::Connected
210 } else if ctx.subject_following_owner {
211 SubjectAccessLevel::Follower
212 } else if is_real_auth {
213 SubjectAccessLevel::Verified
214 } else {
215 SubjectAccessLevel::Public
216 };
217
218 if access_level.can_access(visibility) {
220 return true;
221 }
222
223 if (visibility == VisibilityLevel::Direct || visibility == VisibilityLevel::Subscribed)
227 && let Some(tags) = ctx.audience_tags
228 {
229 return tags.contains(&ctx.subject_id_tag);
230 }
231
232 false
233}
234
235pub use cloudillo_types::abac::AttrSet;
237
238pub fn is_admin(auth: &AuthCtx) -> bool {
243 auth.roles.iter().any(|r| r.as_ref() == "SADM")
244}
245
246#[derive(Debug, Clone)]
248pub struct Environment {
249 pub time: Timestamp,
250 }
252
253impl Environment {
254 pub fn new() -> Self {
255 Self { time: Timestamp::now() }
256 }
257}
258
259impl Default for Environment {
260 fn default() -> Self {
261 Self::new()
262 }
263}
264
265#[derive(Debug, Clone)]
267pub struct Condition {
268 pub attribute: String,
269 pub operator: Operator,
270 pub value: serde_json::Value,
271}
272
273#[derive(Debug, Clone, Copy)]
274pub enum Operator {
275 Equals,
276 NotEquals,
277 Contains,
278 NotContains,
279 GreaterThan,
280 LessThan,
281 In, HasRole, }
284
285impl Condition {
286 pub fn evaluate(
288 &self,
289 subject: &AuthCtx,
290 action: &str,
291 object: &dyn AttrSet,
292 _environment: &Environment,
293 ) -> bool {
294 if let Some(obj_val) = object.get(&self.attribute) {
296 return self.compare_value(obj_val);
297 }
298
299 match self.attribute.as_str() {
301 "subject.id_tag" => self.compare_value(&subject.id_tag),
302 "subject.tn_id" => self.compare_value(&subject.tn_id.0.to_string()),
303 "subject.roles" | "role.admin" | "role.moderator" | "role.member" => {
304 if let Operator::HasRole = self.operator
306 && let Some(role) = self.value.as_str()
307 {
308 return subject.roles.iter().any(|r| r.as_ref() == role);
309 }
310 if self.attribute.starts_with("role.") {
312 let role_name = &self.attribute[5..];
313 return subject.roles.iter().any(|r| r.as_ref() == role_name);
314 }
315 false
316 }
317 "action" => self.compare_value(action),
318 _ => false,
319 }
320 }
321
322 fn compare_value(&self, actual: &str) -> bool {
323 match self.operator {
324 Operator::Equals => self.value.as_str() == Some(actual),
325 Operator::NotEquals => self.value.as_str() != Some(actual),
326 Operator::Contains => {
327 if let Some(needle) = self.value.as_str() {
328 actual.contains(needle)
329 } else {
330 false
331 }
332 }
333 Operator::NotContains => {
334 if let Some(needle) = self.value.as_str() {
335 !actual.contains(needle)
336 } else {
337 true
338 }
339 }
340 Operator::GreaterThan => {
341 if let (Some(threshold), Ok(val)) = (self.value.as_f64(), actual.parse::<f64>()) {
342 val > threshold
343 } else {
344 false
345 }
346 }
347 Operator::LessThan => {
348 if let (Some(threshold), Ok(val)) = (self.value.as_f64(), actual.parse::<f64>()) {
349 val < threshold
350 } else {
351 false
352 }
353 }
354 Operator::In | Operator::HasRole => false,
355 }
356 }
357}
358
359#[derive(Debug, Clone)]
361pub struct PolicyRule {
362 pub name: String,
363 pub conditions: Vec<Condition>,
364 pub effect: Effect,
365}
366
367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
368pub enum Effect {
369 Allow,
370 Deny,
371}
372
373impl PolicyRule {
374 pub fn evaluate(
376 &self,
377 subject: &AuthCtx,
378 action: &str,
379 object: &dyn AttrSet,
380 environment: &Environment,
381 ) -> Option<Effect> {
382 let all_match = self
384 .conditions
385 .iter()
386 .all(|cond| cond.evaluate(subject, action, object, environment));
387
388 if all_match { Some(self.effect) } else { None }
389 }
390}
391
392#[derive(Debug, Clone)]
394pub struct Policy {
395 pub name: String,
396 pub rules: Vec<PolicyRule>,
397}
398
399impl Policy {
400 pub fn evaluate(
402 &self,
403 subject: &AuthCtx,
404 action: &str,
405 object: &dyn AttrSet,
406 environment: &Environment,
407 ) -> Option<Effect> {
408 for rule in &self.rules {
409 if let Some(effect) = rule.evaluate(subject, action, object, environment) {
410 return Some(effect);
411 }
412 }
413 None
414 }
415}
416
417#[derive(Debug, Clone)]
419pub struct ProfilePolicy {
420 pub tn_id: TnId,
421 pub top_policy: Policy, pub bottom_policy: Policy, }
424
425#[derive(Debug, Clone)]
434pub struct CollectionPolicy {
435 pub resource_type: String, pub action: String, pub top_policy: Policy, pub bottom_policy: Policy, }
440
441pub struct PermissionChecker {
443 profile_policies: HashMap<TnId, ProfilePolicy>,
444 collection_policies: HashMap<String, CollectionPolicy>, }
446
447impl PermissionChecker {
448 pub fn new() -> Self {
449 Self { profile_policies: HashMap::new(), collection_policies: HashMap::new() }
450 }
451
452 pub fn load_policy(&mut self, policy: ProfilePolicy) {
454 self.profile_policies.insert(policy.tn_id, policy);
455 }
456
457 pub fn load_collection_policy(&mut self, policy: CollectionPolicy) {
459 let key = format!("{}:{}", policy.resource_type, policy.action);
460 self.collection_policies.insert(key, policy);
461 }
462
463 pub fn get_collection_policy(
465 &self,
466 resource_type: &str,
467 action: &str,
468 ) -> Option<&CollectionPolicy> {
469 let key = format!("{}:{}", resource_type, action);
470 self.collection_policies.get(&key)
471 }
472
473 pub fn has_permission(
475 &self,
476 subject: &AuthCtx,
477 action: &str,
478 object: &dyn AttrSet,
479 environment: &Environment,
480 ) -> bool {
481 if let Some(profile_policy) = self.profile_policies.get(&subject.tn_id) {
483 if let Some(Effect::Deny) =
484 profile_policy.top_policy.evaluate(subject, action, object, environment)
485 {
486 info!("TOP policy denied: tn_id={}, action={}", subject.tn_id.0, action);
487 return false;
488 }
489
490 if let Some(Effect::Allow) =
492 profile_policy.bottom_policy.evaluate(subject, action, object, environment)
493 {
494 info!("BOTTOM policy allowed: tn_id={}, action={}", subject.tn_id.0, action);
495 return true;
496 }
497 }
498
499 self.check_default_rules(subject, action, object, environment)
501 }
502
503 fn check_default_rules(
505 &self,
506 subject: &AuthCtx,
507 action: &str,
508 object: &dyn AttrSet,
509 _environment: &Environment,
510 ) -> bool {
511 use tracing::debug;
512
513 if subject.roles.iter().any(|r| r.as_ref() == "leader") {
515 debug!(subject = %subject.id_tag, action = action, "Leader role allows access");
516 return true;
517 }
518
519 let parts: Vec<&str> = action.split(':').collect();
521 if parts.len() != 2 {
522 debug!(subject = %subject.id_tag, action = action, "Invalid action format (expected resource:operation)");
523 return false;
524 }
525 let operation = parts[1];
526
527 if matches!(operation, "update" | "delete" | "write") {
529 if let Some(owner) = object.get("owner_id_tag")
530 && owner == subject.id_tag.as_ref()
531 {
532 debug!(subject = %subject.id_tag, action = action, owner = owner, "Owner access allowed for modify operation");
533 return true;
534 }
535 if let Some(al) = object.get("access_level")
538 && AccessLevel::from_str_name(al).is_some_and(AccessLevel::can_write)
539 {
540 debug!(subject = %subject.id_tag, action = action, "Write access level allows modify operation");
541 return true;
542 }
543 debug!(subject = %subject.id_tag, action = action, "Denied: not owner and no write access level");
544 return false;
545 }
546
547 if matches!(operation, "read") {
549 if let Some(al) = object.get("access_level")
552 && AccessLevel::from_str_name(al).is_some_and(AccessLevel::can_read)
553 {
554 return true;
555 }
556 return self.check_visibility(subject, object);
557 }
558
559 if operation == "create" {
561 debug!(subject = %subject.id_tag, action = action, "Create operation allowed");
562 return true; }
564
565 if operation == "admin" {
573 use crate::roles::{MODERATOR_LEVEL, highest_role_level};
574 if highest_role_level(&subject.roles) >= MODERATOR_LEVEL {
575 debug!(subject = %subject.id_tag, action = action, "Moderator+ role allows admin operation");
576 return true;
577 }
578 debug!(subject = %subject.id_tag, action = action, "Denied: admin operation requires moderator+");
579 return false;
580 }
581
582 debug!(subject = %subject.id_tag, action = action, "Default deny: no matching rules");
584 false
585 }
586
587 #[expect(clippy::unused_self, reason = "method may use self in future policy checks")]
592 fn check_visibility(&self, subject: &AuthCtx, object: &dyn AttrSet) -> bool {
593 use tracing::debug;
594
595 let visibility = if let Some(vis_char) = object.get("visibility_char") {
598 VisibilityLevel::from_char(vis_char.chars().next())
599 } else if let Some(vis_str) = object.get("visibility") {
600 match vis_str {
601 "public" | "P" => VisibilityLevel::Public,
602 "verified" | "V" => VisibilityLevel::Verified,
603 "second_degree" | "2" => VisibilityLevel::SecondDegree,
604 "follower" | "F" => VisibilityLevel::Follower,
605 "connected" | "C" => VisibilityLevel::Connected,
606 _ => VisibilityLevel::Direct,
608 }
609 } else {
610 VisibilityLevel::Direct };
612
613 let is_owner = object.get("owner_id_tag") == Some(subject.id_tag.as_ref());
615 let is_issuer = object.get("issuer_id_tag") == Some(subject.id_tag.as_ref());
616 let is_connected = object.get("connected") == Some("true");
617 let is_follower = object.get("following") == Some("true");
618 let in_audience = object.contains("audience_tag", subject.id_tag.as_ref());
619
620 let is_authenticated = !subject.id_tag.is_empty() && subject.id_tag.as_ref() != "guest";
623 let access_level = if is_owner || is_issuer {
624 SubjectAccessLevel::Owner
625 } else if is_connected {
626 SubjectAccessLevel::Connected
627 } else if is_follower {
628 SubjectAccessLevel::Follower
629 } else if is_authenticated {
630 SubjectAccessLevel::Verified
632 } else {
633 SubjectAccessLevel::Public
634 };
635
636 let allowed = access_level.can_access(visibility);
638
639 let allowed =
641 if visibility == VisibilityLevel::Direct { allowed || in_audience } else { allowed };
642
643 debug!(
644 subject = %subject.id_tag,
645 visibility = ?visibility,
646 access_level = ?access_level,
647 is_owner = is_owner,
648 is_issuer = is_issuer,
649 is_connected = is_connected,
650 is_follower = is_follower,
651 in_audience = in_audience,
652 allowed = allowed,
653 "Visibility check"
654 );
655
656 allowed
657 }
658
659 pub fn has_collection_permission(
664 &self,
665 subject: &AuthCtx,
666 subject_attrs: &dyn AttrSet,
667 resource_type: &str,
668 action: &str,
669 environment: &Environment,
670 ) -> bool {
671 use tracing::debug;
672
673 let Some(policy) = self.get_collection_policy(resource_type, action) else {
675 debug!(
677 subject = %subject.id_tag,
678 resource_type = resource_type,
679 action = action,
680 "No collection policy found - allowing by default"
681 );
682 return true;
683 };
684
685 if let Some(Effect::Deny) =
687 policy.top_policy.evaluate(subject, action, subject_attrs, environment)
688 {
689 debug!(
690 subject = %subject.id_tag,
691 resource_type = resource_type,
692 action = action,
693 "Collection TOP policy denied"
694 );
695 return false;
696 }
697
698 if let Some(Effect::Allow) =
700 policy.bottom_policy.evaluate(subject, action, subject_attrs, environment)
701 {
702 debug!(
703 subject = %subject.id_tag,
704 resource_type = resource_type,
705 action = action,
706 "Collection BOTTOM policy allowed"
707 );
708 return true;
709 }
710
711 debug!(
713 subject = %subject.id_tag,
714 resource_type = resource_type,
715 action = action,
716 "No matching collection policies - default deny"
717 );
718 false
719 }
720}
721
722impl Default for PermissionChecker {
723 fn default() -> Self {
724 Self::new()
725 }
726}
727
728#[cfg(test)]
729mod tests {
730 use super::*;
731
732 #[test]
733 fn test_environment_creation() {
734 let env = Environment::new();
735 assert!(env.time.0 > 0);
736 }
737
738 #[test]
739 fn test_permission_checker_creation() {
740 let checker = PermissionChecker::new();
741 assert_eq!(checker.profile_policies.len(), 0);
742 }
743
744 struct AccessLevelObject(&'static str);
747
748 impl AttrSet for AccessLevelObject {
749 fn get(&self, key: &str) -> Option<&str> {
750 match key {
751 "access_level" => Some(self.0),
752 _ => None,
753 }
754 }
755
756 fn get_list(&self, _key: &str) -> Option<Vec<&str>> {
757 None
758 }
759 }
760
761 fn plain_subject() -> AuthCtx {
762 AuthCtx {
763 tn_id: TnId(1),
764 id_tag: "alice.example.com".into(),
765 roles: Box::new([]),
766 scope: None,
767 anonymous: false,
768 }
769 }
770
771 #[test]
772 fn admin_access_level_allows_read_and_update() {
773 let checker = PermissionChecker::new();
776 let subject = plain_subject();
777 let env = Environment::new();
778 let object = AccessLevelObject("admin");
779
780 assert!(checker.has_permission(&subject, "file:read", &object, &env));
781 assert!(checker.has_permission(&subject, "file:update", &object, &env));
782 assert!(checker.has_permission(&subject, "file:delete", &object, &env));
783
784 let writer = AccessLevelObject("write");
786 assert!(checker.has_permission(&subject, "file:read", &writer, &env));
787 assert!(checker.has_permission(&subject, "file:update", &writer, &env));
788 let reader = AccessLevelObject("read");
789 assert!(checker.has_permission(&subject, "file:read", &reader, &env));
790 assert!(!checker.has_permission(&subject, "file:update", &reader, &env));
791
792 let bogus = AccessLevelObject("bogus");
794 assert!(!checker.has_permission(&subject, "file:read", &bogus, &env));
795 assert!(!checker.has_permission(&subject, "file:update", &bogus, &env));
796 let none = AccessLevelObject("none");
798 assert!(!checker.has_permission(&subject, "file:read", &none, &env));
799 assert!(!checker.has_permission(&subject, "file:update", &none, &env));
800 }
801
802 #[test]
803 fn test_subscribed_level_char_roundtrip() {
804 assert_eq!(VisibilityLevel::from_char(Some('S')), VisibilityLevel::Subscribed);
805 assert_eq!(VisibilityLevel::Subscribed.to_char(), Some('S'));
806 assert_eq!(VisibilityLevel::Subscribed.as_str(), "subscribed");
807 }
808
809 #[test]
810 fn test_subscribed_can_access_base_check() {
811 assert!(SubjectAccessLevel::Owner.can_access(VisibilityLevel::Subscribed));
814 assert!(!SubjectAccessLevel::Connected.can_access(VisibilityLevel::Subscribed));
815 assert!(!SubjectAccessLevel::Follower.can_access(VisibilityLevel::Subscribed));
816 assert!(!SubjectAccessLevel::Verified.can_access(VisibilityLevel::Subscribed));
817 assert!(!SubjectAccessLevel::Public.can_access(VisibilityLevel::Subscribed));
818 }
819
820 #[test]
821 fn test_subscribed_view_via_audience_bridge() {
822 let member = "alice.example.com";
825 let ctx = ViewCheckContext {
826 subject_id_tag: member,
827 is_authenticated: true,
828 item_owner_id_tag: "bob.example.com", tenant_id_tag: "home.example.com",
830 visibility: Some('S'),
831 subject_following_owner: false,
832 subject_connected_to_owner: false,
833 audience_tags: Some(&[member]),
834 };
835 assert!(can_view_item(&ctx));
836 }
837
838 #[test]
839 fn test_subscribed_denied_when_not_member() {
840 let ctx = ViewCheckContext {
842 subject_id_tag: "carol.example.com",
843 is_authenticated: true,
844 item_owner_id_tag: "bob.example.com",
845 tenant_id_tag: "home.example.com",
846 visibility: Some('S'),
847 subject_following_owner: false,
848 subject_connected_to_owner: false,
849 audience_tags: Some(&[]),
850 };
851 assert!(!can_view_item(&ctx));
852 }
853
854 #[test]
855 fn test_subscribed_owner_and_tenant_shortcut() {
856 let ctx = ViewCheckContext {
858 subject_id_tag: "bob.example.com",
859 is_authenticated: true,
860 item_owner_id_tag: "bob.example.com",
861 tenant_id_tag: "home.example.com",
862 visibility: Some('S'),
863 subject_following_owner: false,
864 subject_connected_to_owner: false,
865 audience_tags: Some(&[]),
866 };
867 assert!(can_view_item(&ctx));
868
869 let ctx_tenant = ViewCheckContext {
871 subject_id_tag: "home.example.com",
872 is_authenticated: true,
873 item_owner_id_tag: "bob.example.com",
874 tenant_id_tag: "home.example.com",
875 visibility: Some('S'),
876 subject_following_owner: false,
877 subject_connected_to_owner: false,
878 audience_tags: Some(&[]),
879 };
880 assert!(can_view_item(&ctx_tenant));
881 }
882}