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
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 && let Some(role) = self.value.as_str()
252 {
253 return subject.roles.iter().any(|r| r.as_ref() == role);
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 { Some(self.effect) } else { None }
334 }
335}
336
337#[derive(Debug, Clone)]
339pub struct Policy {
340 pub name: String,
341 pub rules: Vec<PolicyRule>,
342}
343
344impl Policy {
345 pub fn evaluate(
347 &self,
348 subject: &AuthCtx,
349 action: &str,
350 object: &dyn AttrSet,
351 environment: &Environment,
352 ) -> Option<Effect> {
353 for rule in &self.rules {
354 if let Some(effect) = rule.evaluate(subject, action, object, environment) {
355 return Some(effect);
356 }
357 }
358 None
359 }
360}
361
362#[derive(Debug, Clone)]
364pub struct ProfilePolicy {
365 pub tn_id: TnId,
366 pub top_policy: Policy, pub bottom_policy: Policy, }
369
370#[derive(Debug, Clone)]
379pub struct CollectionPolicy {
380 pub resource_type: String, pub action: String, pub top_policy: Policy, pub bottom_policy: Policy, }
385
386pub struct PermissionChecker {
388 profile_policies: HashMap<TnId, ProfilePolicy>,
389 collection_policies: HashMap<String, CollectionPolicy>, }
391
392impl PermissionChecker {
393 pub fn new() -> Self {
394 Self { profile_policies: HashMap::new(), collection_policies: HashMap::new() }
395 }
396
397 pub fn load_policy(&mut self, policy: ProfilePolicy) {
399 self.profile_policies.insert(policy.tn_id, policy);
400 }
401
402 pub fn load_collection_policy(&mut self, policy: CollectionPolicy) {
404 let key = format!("{}:{}", policy.resource_type, policy.action);
405 self.collection_policies.insert(key, policy);
406 }
407
408 pub fn get_collection_policy(
410 &self,
411 resource_type: &str,
412 action: &str,
413 ) -> Option<&CollectionPolicy> {
414 let key = format!("{}:{}", resource_type, action);
415 self.collection_policies.get(&key)
416 }
417
418 pub fn has_permission(
420 &self,
421 subject: &AuthCtx,
422 action: &str,
423 object: &dyn AttrSet,
424 environment: &Environment,
425 ) -> bool {
426 if let Some(profile_policy) = self.profile_policies.get(&subject.tn_id) {
428 if let Some(Effect::Deny) =
429 profile_policy.top_policy.evaluate(subject, action, object, environment)
430 {
431 info!("TOP policy denied: tn_id={}, action={}", subject.tn_id.0, action);
432 return false;
433 }
434
435 if let Some(Effect::Allow) =
437 profile_policy.bottom_policy.evaluate(subject, action, object, environment)
438 {
439 info!("BOTTOM policy allowed: tn_id={}, action={}", subject.tn_id.0, action);
440 return true;
441 }
442 }
443
444 self.check_default_rules(subject, action, object, environment)
446 }
447
448 fn check_default_rules(
450 &self,
451 subject: &AuthCtx,
452 action: &str,
453 object: &dyn AttrSet,
454 _environment: &Environment,
455 ) -> bool {
456 use tracing::debug;
457
458 if subject.roles.iter().any(|r| r.as_ref() == "leader") {
460 debug!(subject = %subject.id_tag, action = action, "Leader role allows access");
461 return true;
462 }
463
464 let parts: Vec<&str> = action.split(':').collect();
466 if parts.len() != 2 {
467 debug!(subject = %subject.id_tag, action = action, "Invalid action format (expected resource:operation)");
468 return false;
469 }
470 let operation = parts[1];
471
472 if matches!(operation, "update" | "delete" | "write") {
474 if let Some(owner) = object.get("owner_id_tag")
475 && owner == subject.id_tag.as_ref()
476 {
477 debug!(subject = %subject.id_tag, action = action, owner = owner, "Owner access allowed for modify operation");
478 return true;
479 }
480 if let Some(al) = object.get("access_level")
482 && al == "write"
483 {
484 debug!(subject = %subject.id_tag, action = action, "Write access level allows modify operation");
485 return true;
486 }
487 debug!(subject = %subject.id_tag, action = action, "Denied: not owner and no write access level");
488 return false;
489 }
490
491 if matches!(operation, "read") {
493 if let Some(al) = object.get("access_level")
495 && matches!(al, "read" | "comment" | "write")
496 {
497 return true;
498 }
499 return self.check_visibility(subject, object);
500 }
501
502 if operation == "create" {
504 debug!(subject = %subject.id_tag, action = action, "Create operation allowed");
505 return true; }
507
508 debug!(subject = %subject.id_tag, action = action, "Default deny: no matching rules");
510 false
511 }
512
513 #[expect(clippy::unused_self, reason = "method may use self in future policy checks")]
518 fn check_visibility(&self, subject: &AuthCtx, object: &dyn AttrSet) -> bool {
519 use tracing::debug;
520
521 let visibility = if let Some(vis_char) = object.get("visibility_char") {
524 VisibilityLevel::from_char(vis_char.chars().next())
525 } else if let Some(vis_str) = object.get("visibility") {
526 match vis_str {
527 "public" | "P" => VisibilityLevel::Public,
528 "verified" | "V" => VisibilityLevel::Verified,
529 "second_degree" | "2" => VisibilityLevel::SecondDegree,
530 "follower" | "F" => VisibilityLevel::Follower,
531 "connected" | "C" => VisibilityLevel::Connected,
532 _ => VisibilityLevel::Direct,
534 }
535 } else {
536 VisibilityLevel::Direct };
538
539 let is_owner = object.get("owner_id_tag") == Some(subject.id_tag.as_ref());
541 let is_issuer = object.get("issuer_id_tag") == Some(subject.id_tag.as_ref());
542 let is_connected = object.get("connected") == Some("true");
543 let is_follower = object.get("following") == Some("true");
544 let in_audience = object.contains("audience_tag", subject.id_tag.as_ref());
545
546 let is_authenticated = !subject.id_tag.is_empty() && subject.id_tag.as_ref() != "guest";
549 let access_level = if is_owner || is_issuer {
550 SubjectAccessLevel::Owner
551 } else if is_connected {
552 SubjectAccessLevel::Connected
553 } else if is_follower {
554 SubjectAccessLevel::Follower
555 } else if is_authenticated {
556 SubjectAccessLevel::Verified
558 } else {
559 SubjectAccessLevel::Public
560 };
561
562 let allowed = access_level.can_access(visibility);
564
565 let allowed =
567 if visibility == VisibilityLevel::Direct { allowed || in_audience } else { allowed };
568
569 debug!(
570 subject = %subject.id_tag,
571 visibility = ?visibility,
572 access_level = ?access_level,
573 is_owner = is_owner,
574 is_issuer = is_issuer,
575 is_connected = is_connected,
576 is_follower = is_follower,
577 in_audience = in_audience,
578 allowed = allowed,
579 "Visibility check"
580 );
581
582 allowed
583 }
584
585 pub fn has_collection_permission(
590 &self,
591 subject: &AuthCtx,
592 subject_attrs: &dyn AttrSet,
593 resource_type: &str,
594 action: &str,
595 environment: &Environment,
596 ) -> bool {
597 use tracing::debug;
598
599 let Some(policy) = self.get_collection_policy(resource_type, action) else {
601 debug!(
603 subject = %subject.id_tag,
604 resource_type = resource_type,
605 action = action,
606 "No collection policy found - allowing by default"
607 );
608 return true;
609 };
610
611 if let Some(Effect::Deny) =
613 policy.top_policy.evaluate(subject, action, subject_attrs, environment)
614 {
615 debug!(
616 subject = %subject.id_tag,
617 resource_type = resource_type,
618 action = action,
619 "Collection TOP policy denied"
620 );
621 return false;
622 }
623
624 if let Some(Effect::Allow) =
626 policy.bottom_policy.evaluate(subject, action, subject_attrs, environment)
627 {
628 debug!(
629 subject = %subject.id_tag,
630 resource_type = resource_type,
631 action = action,
632 "Collection BOTTOM policy allowed"
633 );
634 return true;
635 }
636
637 debug!(
639 subject = %subject.id_tag,
640 resource_type = resource_type,
641 action = action,
642 "No matching collection policies - default deny"
643 );
644 false
645 }
646}
647
648impl Default for PermissionChecker {
649 fn default() -> Self {
650 Self::new()
651 }
652}
653
654#[cfg(test)]
655mod tests {
656 use super::*;
657
658 #[test]
659 fn test_environment_creation() {
660 let env = Environment::new();
661 assert!(env.time.0 > 0);
662 }
663
664 #[test]
665 fn test_permission_checker_creation() {
666 let checker = PermissionChecker::new();
667 assert_eq!(checker.profile_policies.len(), 0);
668 }
669}