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 | Some(_) => Self::Direct,
53 }
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 pub fn visible_levels(self) -> Option<&'static [char]> {
123 match self {
124 Self::None | Self::Public => Some(&['P']),
125 Self::Verified => Some(&['P', 'V']),
126 Self::SecondDegree => Some(&['P', 'V', '2']),
127 Self::Follower => Some(&['P', 'V', '2', 'F']),
128 Self::Connected => Some(&['P', 'V', '2', 'F', 'C']),
129 Self::Owner => None,
130 }
131 }
132}
133
134pub struct ViewCheckContext<'a> {
136 pub subject_id_tag: &'a str,
137 pub is_authenticated: bool,
138 pub item_owner_id_tag: &'a str,
139 pub tenant_id_tag: &'a str,
140 pub visibility: Option<char>,
141 pub subject_following_owner: bool,
142 pub subject_connected_to_owner: bool,
143 pub audience_tags: Option<&'a [&'a str]>,
144}
145
146pub fn can_view_item(ctx: &ViewCheckContext<'_>) -> bool {
151 let visibility = VisibilityLevel::from_char(ctx.visibility);
152
153 let is_real_auth =
156 ctx.is_authenticated && !ctx.subject_id_tag.is_empty() && ctx.subject_id_tag != "guest";
157 let is_tenant = ctx.subject_id_tag == ctx.tenant_id_tag;
158 let access_level = if ctx.subject_id_tag == ctx.item_owner_id_tag || is_tenant {
159 SubjectAccessLevel::Owner } else if ctx.subject_connected_to_owner {
161 SubjectAccessLevel::Connected
162 } else if ctx.subject_following_owner {
163 SubjectAccessLevel::Follower
164 } else if is_real_auth {
165 SubjectAccessLevel::Verified
166 } else {
167 SubjectAccessLevel::Public
168 };
169
170 if access_level.can_access(visibility) {
172 return true;
173 }
174
175 if visibility == VisibilityLevel::Direct {
177 if let Some(tags) = ctx.audience_tags {
178 return tags.contains(&ctx.subject_id_tag);
179 }
180 }
181
182 false
183}
184
185pub use cloudillo_types::abac::AttrSet;
187
188#[derive(Debug, Clone)]
190pub struct Environment {
191 pub time: Timestamp,
192 }
194
195impl Environment {
196 pub fn new() -> Self {
197 Self { time: Timestamp::now() }
198 }
199}
200
201impl Default for Environment {
202 fn default() -> Self {
203 Self::new()
204 }
205}
206
207#[derive(Debug, Clone)]
209pub struct Condition {
210 pub attribute: String,
211 pub operator: Operator,
212 pub value: serde_json::Value,
213}
214
215#[derive(Debug, Clone, Copy)]
216pub enum Operator {
217 Equals,
218 NotEquals,
219 Contains,
220 NotContains,
221 GreaterThan,
222 LessThan,
223 In, HasRole, }
226
227impl Condition {
228 pub fn evaluate(
230 &self,
231 subject: &AuthCtx,
232 action: &str,
233 object: &dyn AttrSet,
234 _environment: &Environment,
235 ) -> bool {
236 if let Some(obj_val) = object.get(&self.attribute) {
238 return self.compare_value(obj_val);
239 }
240
241 match self.attribute.as_str() {
243 "subject.id_tag" => self.compare_value(&subject.id_tag),
244 "subject.tn_id" => self.compare_value(&subject.tn_id.0.to_string()),
245 "subject.roles" | "role.admin" | "role.moderator" | "role.member" => {
246 if let Operator::HasRole = self.operator {
248 if let Some(role) = self.value.as_str() {
249 return subject.roles.iter().any(|r| r.as_ref() == role);
250 }
251 }
252 if self.attribute.starts_with("role.") {
254 let role_name = &self.attribute[5..];
255 return subject.roles.iter().any(|r| r.as_ref() == role_name);
256 }
257 false
258 }
259 "action" => self.compare_value(action),
260 _ => false,
261 }
262 }
263
264 fn compare_value(&self, actual: &str) -> bool {
265 match self.operator {
266 Operator::Equals => self.value.as_str() == Some(actual),
267 Operator::NotEquals => self.value.as_str() != Some(actual),
268 Operator::Contains => {
269 if let Some(needle) = self.value.as_str() {
270 actual.contains(needle)
271 } else {
272 false
273 }
274 }
275 Operator::NotContains => {
276 if let Some(needle) = self.value.as_str() {
277 !actual.contains(needle)
278 } else {
279 true
280 }
281 }
282 Operator::GreaterThan => {
283 if let (Some(threshold), Ok(val)) = (self.value.as_f64(), actual.parse::<f64>()) {
284 val > threshold
285 } else {
286 false
287 }
288 }
289 Operator::LessThan => {
290 if let (Some(threshold), Ok(val)) = (self.value.as_f64(), actual.parse::<f64>()) {
291 val < threshold
292 } else {
293 false
294 }
295 }
296 Operator::In | Operator::HasRole => false,
297 }
298 }
299}
300
301#[derive(Debug, Clone)]
303pub struct PolicyRule {
304 pub name: String,
305 pub conditions: Vec<Condition>,
306 pub effect: Effect,
307}
308
309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310pub enum Effect {
311 Allow,
312 Deny,
313}
314
315impl PolicyRule {
316 pub fn evaluate(
318 &self,
319 subject: &AuthCtx,
320 action: &str,
321 object: &dyn AttrSet,
322 environment: &Environment,
323 ) -> Option<Effect> {
324 let all_match = self
326 .conditions
327 .iter()
328 .all(|cond| cond.evaluate(subject, action, object, environment));
329
330 if all_match {
331 Some(self.effect)
332 } else {
333 None
334 }
335 }
336}
337
338#[derive(Debug, Clone)]
340pub struct Policy {
341 pub name: String,
342 pub rules: Vec<PolicyRule>,
343}
344
345impl Policy {
346 pub fn evaluate(
348 &self,
349 subject: &AuthCtx,
350 action: &str,
351 object: &dyn AttrSet,
352 environment: &Environment,
353 ) -> Option<Effect> {
354 for rule in &self.rules {
355 if let Some(effect) = rule.evaluate(subject, action, object, environment) {
356 return Some(effect);
357 }
358 }
359 None
360 }
361}
362
363#[derive(Debug, Clone)]
365pub struct ProfilePolicy {
366 pub tn_id: TnId,
367 pub top_policy: Policy, pub bottom_policy: Policy, }
370
371#[derive(Debug, Clone)]
380pub struct CollectionPolicy {
381 pub resource_type: String, pub action: String, pub top_policy: Policy, pub bottom_policy: Policy, }
386
387pub struct PermissionChecker {
389 profile_policies: HashMap<TnId, ProfilePolicy>,
390 collection_policies: HashMap<String, CollectionPolicy>, }
392
393impl PermissionChecker {
394 pub fn new() -> Self {
395 Self { profile_policies: HashMap::new(), collection_policies: HashMap::new() }
396 }
397
398 pub fn load_policy(&mut self, policy: ProfilePolicy) {
400 self.profile_policies.insert(policy.tn_id, policy);
401 }
402
403 pub fn load_collection_policy(&mut self, policy: CollectionPolicy) {
405 let key = format!("{}:{}", policy.resource_type, policy.action);
406 self.collection_policies.insert(key, policy);
407 }
408
409 pub fn get_collection_policy(
411 &self,
412 resource_type: &str,
413 action: &str,
414 ) -> Option<&CollectionPolicy> {
415 let key = format!("{}:{}", resource_type, action);
416 self.collection_policies.get(&key)
417 }
418
419 pub fn has_permission(
421 &self,
422 subject: &AuthCtx,
423 action: &str,
424 object: &dyn AttrSet,
425 environment: &Environment,
426 ) -> bool {
427 if let Some(profile_policy) = self.profile_policies.get(&subject.tn_id) {
429 if let Some(Effect::Deny) =
430 profile_policy.top_policy.evaluate(subject, action, object, environment)
431 {
432 info!("TOP policy denied: tn_id={}, action={}", subject.tn_id.0, action);
433 return false;
434 }
435
436 if let Some(Effect::Allow) =
438 profile_policy.bottom_policy.evaluate(subject, action, object, environment)
439 {
440 info!("BOTTOM policy allowed: tn_id={}, action={}", subject.tn_id.0, action);
441 return true;
442 }
443 }
444
445 self.check_default_rules(subject, action, object, environment)
447 }
448
449 fn check_default_rules(
451 &self,
452 subject: &AuthCtx,
453 action: &str,
454 object: &dyn AttrSet,
455 _environment: &Environment,
456 ) -> bool {
457 use tracing::debug;
458
459 if subject.roles.iter().any(|r| r.as_ref() == "leader") {
461 debug!(subject = %subject.id_tag, action = action, "Leader role allows access");
462 return true;
463 }
464
465 let parts: Vec<&str> = action.split(':').collect();
467 if parts.len() != 2 {
468 debug!(subject = %subject.id_tag, action = action, "Invalid action format (expected resource:operation)");
469 return false;
470 }
471 let operation = parts[1];
472
473 if matches!(operation, "update" | "delete" | "write") {
475 if let Some(owner) = object.get("owner_id_tag") {
476 if owner == subject.id_tag.as_ref() {
477 debug!(subject = %subject.id_tag, action = action, owner = owner, "Owner access allowed for modify operation");
478 return true;
479 }
480 }
481 if let Some(al) = object.get("access_level") {
483 if al == "write" {
484 debug!(subject = %subject.id_tag, action = action, "Write access level allows modify operation");
485 return true;
486 }
487 }
488 debug!(subject = %subject.id_tag, action = action, "Denied: not owner and no write access level");
489 return false;
490 }
491
492 if matches!(operation, "read") {
494 if let Some(al) = object.get("access_level") {
496 if matches!(al, "read" | "write") {
497 return true;
498 }
499 }
500 return self.check_visibility(subject, object);
501 }
502
503 if operation == "create" {
505 debug!(subject = %subject.id_tag, action = action, "Create operation allowed");
506 return true; }
508
509 debug!(subject = %subject.id_tag, action = action, "Default deny: no matching rules");
511 false
512 }
513
514 #[expect(clippy::unused_self, reason = "method may use self in future policy checks")]
519 fn check_visibility(&self, subject: &AuthCtx, object: &dyn AttrSet) -> bool {
520 use tracing::debug;
521
522 let visibility = if let Some(vis_char) = object.get("visibility_char") {
525 VisibilityLevel::from_char(vis_char.chars().next())
526 } else if let Some(vis_str) = object.get("visibility") {
527 match vis_str {
528 "public" | "P" => VisibilityLevel::Public,
529 "verified" | "V" => VisibilityLevel::Verified,
530 "second_degree" | "2" => VisibilityLevel::SecondDegree,
531 "follower" | "F" => VisibilityLevel::Follower,
532 "connected" | "C" => VisibilityLevel::Connected,
533 _ => VisibilityLevel::Direct,
535 }
536 } else {
537 VisibilityLevel::Direct };
539
540 let is_owner = object.get("owner_id_tag") == Some(subject.id_tag.as_ref());
542 let is_issuer = object.get("issuer_id_tag") == Some(subject.id_tag.as_ref());
543 let is_connected = object.get("connected") == Some("true");
544 let is_follower = object.get("following") == Some("true");
545 let in_audience = object.contains("audience_tag", subject.id_tag.as_ref());
546
547 let is_authenticated = !subject.id_tag.is_empty() && subject.id_tag.as_ref() != "guest";
550 let access_level = if is_owner || is_issuer {
551 SubjectAccessLevel::Owner
552 } else if is_connected {
553 SubjectAccessLevel::Connected
554 } else if is_follower {
555 SubjectAccessLevel::Follower
556 } else if is_authenticated {
557 SubjectAccessLevel::Verified
559 } else {
560 SubjectAccessLevel::Public
561 };
562
563 let allowed = access_level.can_access(visibility);
565
566 let allowed =
568 if visibility == VisibilityLevel::Direct { allowed || in_audience } else { allowed };
569
570 debug!(
571 subject = %subject.id_tag,
572 visibility = ?visibility,
573 access_level = ?access_level,
574 is_owner = is_owner,
575 is_issuer = is_issuer,
576 is_connected = is_connected,
577 is_follower = is_follower,
578 in_audience = in_audience,
579 allowed = allowed,
580 "Visibility check"
581 );
582
583 allowed
584 }
585
586 pub fn has_collection_permission(
591 &self,
592 subject: &AuthCtx,
593 subject_attrs: &dyn AttrSet,
594 resource_type: &str,
595 action: &str,
596 environment: &Environment,
597 ) -> bool {
598 use tracing::debug;
599
600 let Some(policy) = self.get_collection_policy(resource_type, action) else {
602 debug!(
604 subject = %subject.id_tag,
605 resource_type = resource_type,
606 action = action,
607 "No collection policy found - allowing by default"
608 );
609 return true;
610 };
611
612 if let Some(Effect::Deny) =
614 policy.top_policy.evaluate(subject, action, subject_attrs, environment)
615 {
616 debug!(
617 subject = %subject.id_tag,
618 resource_type = resource_type,
619 action = action,
620 "Collection TOP policy denied"
621 );
622 return false;
623 }
624
625 if let Some(Effect::Allow) =
627 policy.bottom_policy.evaluate(subject, action, subject_attrs, environment)
628 {
629 debug!(
630 subject = %subject.id_tag,
631 resource_type = resource_type,
632 action = action,
633 "Collection BOTTOM policy allowed"
634 );
635 return true;
636 }
637
638 debug!(
640 subject = %subject.id_tag,
641 resource_type = resource_type,
642 action = action,
643 "No matching collection policies - default deny"
644 );
645 false
646 }
647}
648
649impl Default for PermissionChecker {
650 fn default() -> Self {
651 Self::new()
652 }
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658
659 #[test]
660 fn test_environment_creation() {
661 let env = Environment::new();
662 assert!(env.time.0 > 0);
663 }
664
665 #[test]
666 fn test_permission_checker_creation() {
667 let checker = PermissionChecker::new();
668 assert_eq!(checker.profile_policies.len(), 0);
669 }
670}