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 #[default]
42 Direct,
43}
44
45impl VisibilityLevel {
46 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 None | Some(_) => Self::Direct,
56 }
57 }
58
59 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 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
91pub enum SubjectAccessLevel {
92 #[default]
94 None,
95 Public,
97 Verified,
99 SecondDegree,
101 Follower,
103 Connected,
105 Owner,
107}
108
109impl SubjectAccessLevel {
110 pub fn can_access(self, visibility: VisibilityLevel) -> bool {
112 match visibility {
113 VisibilityLevel::Public => true, 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, }
120 }
121
122 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
137pub 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
149pub fn can_view_item(ctx: &ViewCheckContext<'_>) -> bool {
154 let visibility = VisibilityLevel::from_char(ctx.visibility);
155
156 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 } 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 if access_level.can_access(visibility) {
175 return true;
176 }
177
178 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
188pub use cloudillo_types::abac::AttrSet;
190
191pub fn is_admin(auth: &AuthCtx) -> bool {
196 auth.roles.iter().any(|r| r.as_ref() == "SADM")
197}
198
199#[derive(Debug, Clone)]
201pub struct Environment {
202 pub time: Timestamp,
203 }
205
206impl Environment {
207 pub fn new() -> Self {
208 Self { time: Timestamp::now() }
209 }
210}
211
212impl Default for Environment {
213 fn default() -> Self {
214 Self::new()
215 }
216}
217
218#[derive(Debug, Clone)]
220pub struct Condition {
221 pub attribute: String,
222 pub operator: Operator,
223 pub value: serde_json::Value,
224}
225
226#[derive(Debug, Clone, Copy)]
227pub enum Operator {
228 Equals,
229 NotEquals,
230 Contains,
231 NotContains,
232 GreaterThan,
233 LessThan,
234 In, HasRole, }
237
238impl Condition {
239 pub fn evaluate(
241 &self,
242 subject: &AuthCtx,
243 action: &str,
244 object: &dyn AttrSet,
245 _environment: &Environment,
246 ) -> bool {
247 if let Some(obj_val) = object.get(&self.attribute) {
249 return self.compare_value(obj_val);
250 }
251
252 match self.attribute.as_str() {
254 "subject.id_tag" => self.compare_value(&subject.id_tag),
255 "subject.tn_id" => self.compare_value(&subject.tn_id.0.to_string()),
256 "subject.roles" | "role.admin" | "role.moderator" | "role.member" => {
257 if let Operator::HasRole = self.operator
259 && let Some(role) = self.value.as_str()
260 {
261 return subject.roles.iter().any(|r| r.as_ref() == role);
262 }
263 if self.attribute.starts_with("role.") {
265 let role_name = &self.attribute[5..];
266 return subject.roles.iter().any(|r| r.as_ref() == role_name);
267 }
268 false
269 }
270 "action" => self.compare_value(action),
271 _ => false,
272 }
273 }
274
275 fn compare_value(&self, actual: &str) -> bool {
276 match self.operator {
277 Operator::Equals => self.value.as_str() == Some(actual),
278 Operator::NotEquals => self.value.as_str() != Some(actual),
279 Operator::Contains => {
280 if let Some(needle) = self.value.as_str() {
281 actual.contains(needle)
282 } else {
283 false
284 }
285 }
286 Operator::NotContains => {
287 if let Some(needle) = self.value.as_str() {
288 !actual.contains(needle)
289 } else {
290 true
291 }
292 }
293 Operator::GreaterThan => {
294 if let (Some(threshold), Ok(val)) = (self.value.as_f64(), actual.parse::<f64>()) {
295 val > threshold
296 } else {
297 false
298 }
299 }
300 Operator::LessThan => {
301 if let (Some(threshold), Ok(val)) = (self.value.as_f64(), actual.parse::<f64>()) {
302 val < threshold
303 } else {
304 false
305 }
306 }
307 Operator::In | Operator::HasRole => false,
308 }
309 }
310}
311
312#[derive(Debug, Clone)]
314pub struct PolicyRule {
315 pub name: String,
316 pub conditions: Vec<Condition>,
317 pub effect: Effect,
318}
319
320#[derive(Debug, Clone, Copy, PartialEq, Eq)]
321pub enum Effect {
322 Allow,
323 Deny,
324}
325
326impl PolicyRule {
327 pub fn evaluate(
329 &self,
330 subject: &AuthCtx,
331 action: &str,
332 object: &dyn AttrSet,
333 environment: &Environment,
334 ) -> Option<Effect> {
335 let all_match = self
337 .conditions
338 .iter()
339 .all(|cond| cond.evaluate(subject, action, object, environment));
340
341 if all_match { Some(self.effect) } else { None }
342 }
343}
344
345#[derive(Debug, Clone)]
347pub struct Policy {
348 pub name: String,
349 pub rules: Vec<PolicyRule>,
350}
351
352impl Policy {
353 pub fn evaluate(
355 &self,
356 subject: &AuthCtx,
357 action: &str,
358 object: &dyn AttrSet,
359 environment: &Environment,
360 ) -> Option<Effect> {
361 for rule in &self.rules {
362 if let Some(effect) = rule.evaluate(subject, action, object, environment) {
363 return Some(effect);
364 }
365 }
366 None
367 }
368}
369
370#[derive(Debug, Clone)]
372pub struct ProfilePolicy {
373 pub tn_id: TnId,
374 pub top_policy: Policy, pub bottom_policy: Policy, }
377
378#[derive(Debug, Clone)]
387pub struct CollectionPolicy {
388 pub resource_type: String, pub action: String, pub top_policy: Policy, pub bottom_policy: Policy, }
393
394pub struct PermissionChecker {
396 profile_policies: HashMap<TnId, ProfilePolicy>,
397 collection_policies: HashMap<String, CollectionPolicy>, }
399
400impl PermissionChecker {
401 pub fn new() -> Self {
402 Self { profile_policies: HashMap::new(), collection_policies: HashMap::new() }
403 }
404
405 pub fn load_policy(&mut self, policy: ProfilePolicy) {
407 self.profile_policies.insert(policy.tn_id, policy);
408 }
409
410 pub fn load_collection_policy(&mut self, policy: CollectionPolicy) {
412 let key = format!("{}:{}", policy.resource_type, policy.action);
413 self.collection_policies.insert(key, policy);
414 }
415
416 pub fn get_collection_policy(
418 &self,
419 resource_type: &str,
420 action: &str,
421 ) -> Option<&CollectionPolicy> {
422 let key = format!("{}:{}", resource_type, action);
423 self.collection_policies.get(&key)
424 }
425
426 pub fn has_permission(
428 &self,
429 subject: &AuthCtx,
430 action: &str,
431 object: &dyn AttrSet,
432 environment: &Environment,
433 ) -> bool {
434 if let Some(profile_policy) = self.profile_policies.get(&subject.tn_id) {
436 if let Some(Effect::Deny) =
437 profile_policy.top_policy.evaluate(subject, action, object, environment)
438 {
439 info!("TOP policy denied: tn_id={}, action={}", subject.tn_id.0, action);
440 return false;
441 }
442
443 if let Some(Effect::Allow) =
445 profile_policy.bottom_policy.evaluate(subject, action, object, environment)
446 {
447 info!("BOTTOM policy allowed: tn_id={}, action={}", subject.tn_id.0, action);
448 return true;
449 }
450 }
451
452 self.check_default_rules(subject, action, object, environment)
454 }
455
456 fn check_default_rules(
458 &self,
459 subject: &AuthCtx,
460 action: &str,
461 object: &dyn AttrSet,
462 _environment: &Environment,
463 ) -> bool {
464 use tracing::debug;
465
466 if subject.roles.iter().any(|r| r.as_ref() == "leader") {
468 debug!(subject = %subject.id_tag, action = action, "Leader role allows access");
469 return true;
470 }
471
472 let parts: Vec<&str> = action.split(':').collect();
474 if parts.len() != 2 {
475 debug!(subject = %subject.id_tag, action = action, "Invalid action format (expected resource:operation)");
476 return false;
477 }
478 let operation = parts[1];
479
480 if matches!(operation, "update" | "delete" | "write") {
482 if let Some(owner) = object.get("owner_id_tag")
483 && owner == subject.id_tag.as_ref()
484 {
485 debug!(subject = %subject.id_tag, action = action, owner = owner, "Owner access allowed for modify operation");
486 return true;
487 }
488 if let Some(al) = object.get("access_level")
490 && al == "write"
491 {
492 debug!(subject = %subject.id_tag, action = action, "Write access level allows modify operation");
493 return true;
494 }
495 debug!(subject = %subject.id_tag, action = action, "Denied: not owner and no write access level");
496 return false;
497 }
498
499 if matches!(operation, "read") {
501 if let Some(al) = object.get("access_level")
503 && matches!(al, "read" | "comment" | "write")
504 {
505 return true;
506 }
507 return self.check_visibility(subject, object);
508 }
509
510 if operation == "create" {
512 debug!(subject = %subject.id_tag, action = action, "Create operation allowed");
513 return true; }
515
516 if operation == "admin" {
524 use crate::roles::{MODERATOR_LEVEL, highest_role_level};
525 if highest_role_level(&subject.roles) >= MODERATOR_LEVEL {
526 debug!(subject = %subject.id_tag, action = action, "Moderator+ role allows admin operation");
527 return true;
528 }
529 debug!(subject = %subject.id_tag, action = action, "Denied: admin operation requires moderator+");
530 return false;
531 }
532
533 debug!(subject = %subject.id_tag, action = action, "Default deny: no matching rules");
535 false
536 }
537
538 #[expect(clippy::unused_self, reason = "method may use self in future policy checks")]
543 fn check_visibility(&self, subject: &AuthCtx, object: &dyn AttrSet) -> bool {
544 use tracing::debug;
545
546 let visibility = if let Some(vis_char) = object.get("visibility_char") {
549 VisibilityLevel::from_char(vis_char.chars().next())
550 } else if let Some(vis_str) = object.get("visibility") {
551 match vis_str {
552 "public" | "P" => VisibilityLevel::Public,
553 "verified" | "V" => VisibilityLevel::Verified,
554 "second_degree" | "2" => VisibilityLevel::SecondDegree,
555 "follower" | "F" => VisibilityLevel::Follower,
556 "connected" | "C" => VisibilityLevel::Connected,
557 _ => VisibilityLevel::Direct,
559 }
560 } else {
561 VisibilityLevel::Direct };
563
564 let is_owner = object.get("owner_id_tag") == Some(subject.id_tag.as_ref());
566 let is_issuer = object.get("issuer_id_tag") == Some(subject.id_tag.as_ref());
567 let is_connected = object.get("connected") == Some("true");
568 let is_follower = object.get("following") == Some("true");
569 let in_audience = object.contains("audience_tag", subject.id_tag.as_ref());
570
571 let is_authenticated = !subject.id_tag.is_empty() && subject.id_tag.as_ref() != "guest";
574 let access_level = if is_owner || is_issuer {
575 SubjectAccessLevel::Owner
576 } else if is_connected {
577 SubjectAccessLevel::Connected
578 } else if is_follower {
579 SubjectAccessLevel::Follower
580 } else if is_authenticated {
581 SubjectAccessLevel::Verified
583 } else {
584 SubjectAccessLevel::Public
585 };
586
587 let allowed = access_level.can_access(visibility);
589
590 let allowed =
592 if visibility == VisibilityLevel::Direct { allowed || in_audience } else { allowed };
593
594 debug!(
595 subject = %subject.id_tag,
596 visibility = ?visibility,
597 access_level = ?access_level,
598 is_owner = is_owner,
599 is_issuer = is_issuer,
600 is_connected = is_connected,
601 is_follower = is_follower,
602 in_audience = in_audience,
603 allowed = allowed,
604 "Visibility check"
605 );
606
607 allowed
608 }
609
610 pub fn has_collection_permission(
615 &self,
616 subject: &AuthCtx,
617 subject_attrs: &dyn AttrSet,
618 resource_type: &str,
619 action: &str,
620 environment: &Environment,
621 ) -> bool {
622 use tracing::debug;
623
624 let Some(policy) = self.get_collection_policy(resource_type, action) else {
626 debug!(
628 subject = %subject.id_tag,
629 resource_type = resource_type,
630 action = action,
631 "No collection policy found - allowing by default"
632 );
633 return true;
634 };
635
636 if let Some(Effect::Deny) =
638 policy.top_policy.evaluate(subject, action, subject_attrs, environment)
639 {
640 debug!(
641 subject = %subject.id_tag,
642 resource_type = resource_type,
643 action = action,
644 "Collection TOP policy denied"
645 );
646 return false;
647 }
648
649 if let Some(Effect::Allow) =
651 policy.bottom_policy.evaluate(subject, action, subject_attrs, environment)
652 {
653 debug!(
654 subject = %subject.id_tag,
655 resource_type = resource_type,
656 action = action,
657 "Collection BOTTOM policy allowed"
658 );
659 return true;
660 }
661
662 debug!(
664 subject = %subject.id_tag,
665 resource_type = resource_type,
666 action = action,
667 "No matching collection policies - default deny"
668 );
669 false
670 }
671}
672
673impl Default for PermissionChecker {
674 fn default() -> Self {
675 Self::new()
676 }
677}
678
679#[cfg(test)]
680mod tests {
681 use super::*;
682
683 #[test]
684 fn test_environment_creation() {
685 let env = Environment::new();
686 assert!(env.time.0 > 0);
687 }
688
689 #[test]
690 fn test_permission_checker_creation() {
691 let checker = PermissionChecker::new();
692 assert_eq!(checker.profile_policies.len(), 0);
693 }
694}