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 if let Some(tags) = ctx.audience_tags {
181 return tags.contains(&ctx.subject_id_tag);
182 }
183 }
184
185 false
186}
187
188pub use cloudillo_types::abac::AttrSet;
190
191#[derive(Debug, Clone)]
193pub struct Environment {
194 pub time: Timestamp,
195 }
197
198impl Environment {
199 pub fn new() -> Self {
200 Self { time: Timestamp::now() }
201 }
202}
203
204impl Default for Environment {
205 fn default() -> Self {
206 Self::new()
207 }
208}
209
210#[derive(Debug, Clone)]
212pub struct Condition {
213 pub attribute: String,
214 pub operator: Operator,
215 pub value: serde_json::Value,
216}
217
218#[derive(Debug, Clone, Copy)]
219pub enum Operator {
220 Equals,
221 NotEquals,
222 Contains,
223 NotContains,
224 GreaterThan,
225 LessThan,
226 In, HasRole, }
229
230impl Condition {
231 pub fn evaluate(
233 &self,
234 subject: &AuthCtx,
235 action: &str,
236 object: &dyn AttrSet,
237 _environment: &Environment,
238 ) -> bool {
239 if let Some(obj_val) = object.get(&self.attribute) {
241 return self.compare_value(obj_val);
242 }
243
244 match self.attribute.as_str() {
246 "subject.id_tag" => self.compare_value(&subject.id_tag),
247 "subject.tn_id" => self.compare_value(&subject.tn_id.0.to_string()),
248 "subject.roles" | "role.admin" | "role.moderator" | "role.member" => {
249 if let Operator::HasRole = self.operator {
251 if let Some(role) = self.value.as_str() {
252 return subject.roles.iter().any(|r| r.as_ref() == role);
253 }
254 }
255 if self.attribute.starts_with("role.") {
257 let role_name = &self.attribute[5..];
258 return subject.roles.iter().any(|r| r.as_ref() == role_name);
259 }
260 false
261 }
262 "action" => self.compare_value(action),
263 _ => false,
264 }
265 }
266
267 fn compare_value(&self, actual: &str) -> bool {
268 match self.operator {
269 Operator::Equals => self.value.as_str() == Some(actual),
270 Operator::NotEquals => self.value.as_str() != Some(actual),
271 Operator::Contains => {
272 if let Some(needle) = self.value.as_str() {
273 actual.contains(needle)
274 } else {
275 false
276 }
277 }
278 Operator::NotContains => {
279 if let Some(needle) = self.value.as_str() {
280 !actual.contains(needle)
281 } else {
282 true
283 }
284 }
285 Operator::GreaterThan => {
286 if let (Some(threshold), Ok(val)) = (self.value.as_f64(), actual.parse::<f64>()) {
287 val > threshold
288 } else {
289 false
290 }
291 }
292 Operator::LessThan => {
293 if let (Some(threshold), Ok(val)) = (self.value.as_f64(), actual.parse::<f64>()) {
294 val < threshold
295 } else {
296 false
297 }
298 }
299 Operator::In | Operator::HasRole => false,
300 }
301 }
302}
303
304#[derive(Debug, Clone)]
306pub struct PolicyRule {
307 pub name: String,
308 pub conditions: Vec<Condition>,
309 pub effect: Effect,
310}
311
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub enum Effect {
314 Allow,
315 Deny,
316}
317
318impl PolicyRule {
319 pub fn evaluate(
321 &self,
322 subject: &AuthCtx,
323 action: &str,
324 object: &dyn AttrSet,
325 environment: &Environment,
326 ) -> Option<Effect> {
327 let all_match = self
329 .conditions
330 .iter()
331 .all(|cond| cond.evaluate(subject, action, object, environment));
332
333 if all_match {
334 Some(self.effect)
335 } else {
336 None
337 }
338 }
339}
340
341#[derive(Debug, Clone)]
343pub struct Policy {
344 pub name: String,
345 pub rules: Vec<PolicyRule>,
346}
347
348impl Policy {
349 pub fn evaluate(
351 &self,
352 subject: &AuthCtx,
353 action: &str,
354 object: &dyn AttrSet,
355 environment: &Environment,
356 ) -> Option<Effect> {
357 for rule in &self.rules {
358 if let Some(effect) = rule.evaluate(subject, action, object, environment) {
359 return Some(effect);
360 }
361 }
362 None
363 }
364}
365
366#[derive(Debug, Clone)]
368pub struct ProfilePolicy {
369 pub tn_id: TnId,
370 pub top_policy: Policy, pub bottom_policy: Policy, }
373
374#[derive(Debug, Clone)]
383pub struct CollectionPolicy {
384 pub resource_type: String, pub action: String, pub top_policy: Policy, pub bottom_policy: Policy, }
389
390pub struct PermissionChecker {
392 profile_policies: HashMap<TnId, ProfilePolicy>,
393 collection_policies: HashMap<String, CollectionPolicy>, }
395
396impl PermissionChecker {
397 pub fn new() -> Self {
398 Self { profile_policies: HashMap::new(), collection_policies: HashMap::new() }
399 }
400
401 pub fn load_policy(&mut self, policy: ProfilePolicy) {
403 self.profile_policies.insert(policy.tn_id, policy);
404 }
405
406 pub fn load_collection_policy(&mut self, policy: CollectionPolicy) {
408 let key = format!("{}:{}", policy.resource_type, policy.action);
409 self.collection_policies.insert(key, policy);
410 }
411
412 pub fn get_collection_policy(
414 &self,
415 resource_type: &str,
416 action: &str,
417 ) -> Option<&CollectionPolicy> {
418 let key = format!("{}:{}", resource_type, action);
419 self.collection_policies.get(&key)
420 }
421
422 pub fn has_permission(
424 &self,
425 subject: &AuthCtx,
426 action: &str,
427 object: &dyn AttrSet,
428 environment: &Environment,
429 ) -> bool {
430 if let Some(profile_policy) = self.profile_policies.get(&subject.tn_id) {
432 if let Some(Effect::Deny) =
433 profile_policy.top_policy.evaluate(subject, action, object, environment)
434 {
435 info!("TOP policy denied: tn_id={}, action={}", subject.tn_id.0, action);
436 return false;
437 }
438
439 if let Some(Effect::Allow) =
441 profile_policy.bottom_policy.evaluate(subject, action, object, environment)
442 {
443 info!("BOTTOM policy allowed: tn_id={}, action={}", subject.tn_id.0, action);
444 return true;
445 }
446 }
447
448 self.check_default_rules(subject, action, object, environment)
450 }
451
452 fn check_default_rules(
454 &self,
455 subject: &AuthCtx,
456 action: &str,
457 object: &dyn AttrSet,
458 _environment: &Environment,
459 ) -> bool {
460 use tracing::debug;
461
462 if subject.roles.iter().any(|r| r.as_ref() == "leader") {
464 debug!(subject = %subject.id_tag, action = action, "Leader role allows access");
465 return true;
466 }
467
468 let parts: Vec<&str> = action.split(':').collect();
470 if parts.len() != 2 {
471 debug!(subject = %subject.id_tag, action = action, "Invalid action format (expected resource:operation)");
472 return false;
473 }
474 let operation = parts[1];
475
476 if matches!(operation, "update" | "delete" | "write") {
478 if let Some(owner) = object.get("owner_id_tag") {
479 if owner == subject.id_tag.as_ref() {
480 debug!(subject = %subject.id_tag, action = action, owner = owner, "Owner access allowed for modify operation");
481 return true;
482 }
483 }
484 if let Some(al) = object.get("access_level") {
486 if al == "write" {
487 debug!(subject = %subject.id_tag, action = action, "Write access level allows modify operation");
488 return true;
489 }
490 }
491 debug!(subject = %subject.id_tag, action = action, "Denied: not owner and no write access level");
492 return false;
493 }
494
495 if matches!(operation, "read") {
497 if let Some(al) = object.get("access_level") {
499 if matches!(al, "read" | "comment" | "write") {
500 return true;
501 }
502 }
503 return self.check_visibility(subject, object);
504 }
505
506 if operation == "create" {
508 debug!(subject = %subject.id_tag, action = action, "Create operation allowed");
509 return true; }
511
512 debug!(subject = %subject.id_tag, action = action, "Default deny: no matching rules");
514 false
515 }
516
517 #[expect(clippy::unused_self, reason = "method may use self in future policy checks")]
522 fn check_visibility(&self, subject: &AuthCtx, object: &dyn AttrSet) -> bool {
523 use tracing::debug;
524
525 let visibility = if let Some(vis_char) = object.get("visibility_char") {
528 VisibilityLevel::from_char(vis_char.chars().next())
529 } else if let Some(vis_str) = object.get("visibility") {
530 match vis_str {
531 "public" | "P" => VisibilityLevel::Public,
532 "verified" | "V" => VisibilityLevel::Verified,
533 "second_degree" | "2" => VisibilityLevel::SecondDegree,
534 "follower" | "F" => VisibilityLevel::Follower,
535 "connected" | "C" => VisibilityLevel::Connected,
536 _ => VisibilityLevel::Direct,
538 }
539 } else {
540 VisibilityLevel::Direct };
542
543 let is_owner = object.get("owner_id_tag") == Some(subject.id_tag.as_ref());
545 let is_issuer = object.get("issuer_id_tag") == Some(subject.id_tag.as_ref());
546 let is_connected = object.get("connected") == Some("true");
547 let is_follower = object.get("following") == Some("true");
548 let in_audience = object.contains("audience_tag", subject.id_tag.as_ref());
549
550 let is_authenticated = !subject.id_tag.is_empty() && subject.id_tag.as_ref() != "guest";
553 let access_level = if is_owner || is_issuer {
554 SubjectAccessLevel::Owner
555 } else if is_connected {
556 SubjectAccessLevel::Connected
557 } else if is_follower {
558 SubjectAccessLevel::Follower
559 } else if is_authenticated {
560 SubjectAccessLevel::Verified
562 } else {
563 SubjectAccessLevel::Public
564 };
565
566 let allowed = access_level.can_access(visibility);
568
569 let allowed =
571 if visibility == VisibilityLevel::Direct { allowed || in_audience } else { allowed };
572
573 debug!(
574 subject = %subject.id_tag,
575 visibility = ?visibility,
576 access_level = ?access_level,
577 is_owner = is_owner,
578 is_issuer = is_issuer,
579 is_connected = is_connected,
580 is_follower = is_follower,
581 in_audience = in_audience,
582 allowed = allowed,
583 "Visibility check"
584 );
585
586 allowed
587 }
588
589 pub fn has_collection_permission(
594 &self,
595 subject: &AuthCtx,
596 subject_attrs: &dyn AttrSet,
597 resource_type: &str,
598 action: &str,
599 environment: &Environment,
600 ) -> bool {
601 use tracing::debug;
602
603 let Some(policy) = self.get_collection_policy(resource_type, action) else {
605 debug!(
607 subject = %subject.id_tag,
608 resource_type = resource_type,
609 action = action,
610 "No collection policy found - allowing by default"
611 );
612 return true;
613 };
614
615 if let Some(Effect::Deny) =
617 policy.top_policy.evaluate(subject, action, subject_attrs, environment)
618 {
619 debug!(
620 subject = %subject.id_tag,
621 resource_type = resource_type,
622 action = action,
623 "Collection TOP policy denied"
624 );
625 return false;
626 }
627
628 if let Some(Effect::Allow) =
630 policy.bottom_policy.evaluate(subject, action, subject_attrs, environment)
631 {
632 debug!(
633 subject = %subject.id_tag,
634 resource_type = resource_type,
635 action = action,
636 "Collection BOTTOM policy allowed"
637 );
638 return true;
639 }
640
641 debug!(
643 subject = %subject.id_tag,
644 resource_type = resource_type,
645 action = action,
646 "No matching collection policies - default deny"
647 );
648 false
649 }
650}
651
652impl Default for PermissionChecker {
653 fn default() -> Self {
654 Self::new()
655 }
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661
662 #[test]
663 fn test_environment_creation() {
664 let env = Environment::new();
665 assert!(env.time.0 > 0);
666 }
667
668 #[test]
669 fn test_permission_checker_creation() {
670 let checker = PermissionChecker::new();
671 assert_eq!(checker.profile_policies.len(), 0);
672 }
673}