use crate::prelude::*;
use cloudillo_types::auth_adapter::AuthCtx;
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
pub enum VisibilityLevel {
Public,
Verified,
SecondDegree,
Follower,
Connected,
#[default]
Direct,
}
impl VisibilityLevel {
pub fn from_char(c: Option<char>) -> Self {
match c {
Some('P') => Self::Public,
Some('V') => Self::Verified,
Some('2') => Self::SecondDegree,
Some('F') => Self::Follower,
Some('C') => Self::Connected,
None | Some(_) => Self::Direct,
}
}
pub fn to_char(&self) -> Option<char> {
match self {
Self::Public => Some('P'),
Self::Verified => Some('V'),
Self::SecondDegree => Some('2'),
Self::Follower => Some('F'),
Self::Connected => Some('C'),
Self::Direct => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Public => "public",
Self::Verified => "verified",
Self::SecondDegree => "second_degree",
Self::Follower => "follower",
Self::Connected => "connected",
Self::Direct => "direct",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
pub enum SubjectAccessLevel {
#[default]
None,
Public,
Verified,
SecondDegree,
Follower,
Connected,
Owner,
}
impl SubjectAccessLevel {
pub fn can_access(self, visibility: VisibilityLevel) -> bool {
match visibility {
VisibilityLevel::Public => true, VisibilityLevel::Verified => self >= Self::Verified,
VisibilityLevel::SecondDegree => self >= Self::SecondDegree,
VisibilityLevel::Follower => self >= Self::Follower,
VisibilityLevel::Connected => self >= Self::Connected,
VisibilityLevel::Direct => self >= Self::Owner, }
}
pub fn visible_levels(self) -> Option<&'static [char]> {
match self {
Self::None | Self::Public => Some(&['P']),
Self::Verified => Some(&['P', 'V']),
Self::SecondDegree => Some(&['P', 'V', '2']),
Self::Follower => Some(&['P', 'V', '2', 'F']),
Self::Connected => Some(&['P', 'V', '2', 'F', 'C']),
Self::Owner => None,
}
}
}
pub struct ViewCheckContext<'a> {
pub subject_id_tag: &'a str,
pub is_authenticated: bool,
pub item_owner_id_tag: &'a str,
pub tenant_id_tag: &'a str,
pub visibility: Option<char>,
pub subject_following_owner: bool,
pub subject_connected_to_owner: bool,
pub audience_tags: Option<&'a [&'a str]>,
}
pub fn can_view_item(ctx: &ViewCheckContext<'_>) -> bool {
let visibility = VisibilityLevel::from_char(ctx.visibility);
let is_real_auth =
ctx.is_authenticated && !ctx.subject_id_tag.is_empty() && ctx.subject_id_tag != "guest";
let is_tenant = ctx.subject_id_tag == ctx.tenant_id_tag;
let access_level = if ctx.subject_id_tag == ctx.item_owner_id_tag || is_tenant {
SubjectAccessLevel::Owner } else if ctx.subject_connected_to_owner {
SubjectAccessLevel::Connected
} else if ctx.subject_following_owner {
SubjectAccessLevel::Follower
} else if is_real_auth {
SubjectAccessLevel::Verified
} else {
SubjectAccessLevel::Public
};
if access_level.can_access(visibility) {
return true;
}
if visibility == VisibilityLevel::Direct
&& let Some(tags) = ctx.audience_tags
{
return tags.contains(&ctx.subject_id_tag);
}
false
}
pub use cloudillo_types::abac::AttrSet;
pub fn is_admin(auth: &AuthCtx) -> bool {
auth.roles.iter().any(|r| r.as_ref() == "SADM")
}
#[derive(Debug, Clone)]
pub struct Environment {
pub time: Timestamp,
}
impl Environment {
pub fn new() -> Self {
Self { time: Timestamp::now() }
}
}
impl Default for Environment {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct Condition {
pub attribute: String,
pub operator: Operator,
pub value: serde_json::Value,
}
#[derive(Debug, Clone, Copy)]
pub enum Operator {
Equals,
NotEquals,
Contains,
NotContains,
GreaterThan,
LessThan,
In, HasRole, }
impl Condition {
pub fn evaluate(
&self,
subject: &AuthCtx,
action: &str,
object: &dyn AttrSet,
_environment: &Environment,
) -> bool {
if let Some(obj_val) = object.get(&self.attribute) {
return self.compare_value(obj_val);
}
match self.attribute.as_str() {
"subject.id_tag" => self.compare_value(&subject.id_tag),
"subject.tn_id" => self.compare_value(&subject.tn_id.0.to_string()),
"subject.roles" | "role.admin" | "role.moderator" | "role.member" => {
if let Operator::HasRole = self.operator
&& let Some(role) = self.value.as_str()
{
return subject.roles.iter().any(|r| r.as_ref() == role);
}
if self.attribute.starts_with("role.") {
let role_name = &self.attribute[5..];
return subject.roles.iter().any(|r| r.as_ref() == role_name);
}
false
}
"action" => self.compare_value(action),
_ => false,
}
}
fn compare_value(&self, actual: &str) -> bool {
match self.operator {
Operator::Equals => self.value.as_str() == Some(actual),
Operator::NotEquals => self.value.as_str() != Some(actual),
Operator::Contains => {
if let Some(needle) = self.value.as_str() {
actual.contains(needle)
} else {
false
}
}
Operator::NotContains => {
if let Some(needle) = self.value.as_str() {
!actual.contains(needle)
} else {
true
}
}
Operator::GreaterThan => {
if let (Some(threshold), Ok(val)) = (self.value.as_f64(), actual.parse::<f64>()) {
val > threshold
} else {
false
}
}
Operator::LessThan => {
if let (Some(threshold), Ok(val)) = (self.value.as_f64(), actual.parse::<f64>()) {
val < threshold
} else {
false
}
}
Operator::In | Operator::HasRole => false,
}
}
}
#[derive(Debug, Clone)]
pub struct PolicyRule {
pub name: String,
pub conditions: Vec<Condition>,
pub effect: Effect,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Effect {
Allow,
Deny,
}
impl PolicyRule {
pub fn evaluate(
&self,
subject: &AuthCtx,
action: &str,
object: &dyn AttrSet,
environment: &Environment,
) -> Option<Effect> {
let all_match = self
.conditions
.iter()
.all(|cond| cond.evaluate(subject, action, object, environment));
if all_match { Some(self.effect) } else { None }
}
}
#[derive(Debug, Clone)]
pub struct Policy {
pub name: String,
pub rules: Vec<PolicyRule>,
}
impl Policy {
pub fn evaluate(
&self,
subject: &AuthCtx,
action: &str,
object: &dyn AttrSet,
environment: &Environment,
) -> Option<Effect> {
for rule in &self.rules {
if let Some(effect) = rule.evaluate(subject, action, object, environment) {
return Some(effect);
}
}
None
}
}
#[derive(Debug, Clone)]
pub struct ProfilePolicy {
pub tn_id: TnId,
pub top_policy: Policy, pub bottom_policy: Policy, }
#[derive(Debug, Clone)]
pub struct CollectionPolicy {
pub resource_type: String, pub action: String, pub top_policy: Policy, pub bottom_policy: Policy, }
pub struct PermissionChecker {
profile_policies: HashMap<TnId, ProfilePolicy>,
collection_policies: HashMap<String, CollectionPolicy>, }
impl PermissionChecker {
pub fn new() -> Self {
Self { profile_policies: HashMap::new(), collection_policies: HashMap::new() }
}
pub fn load_policy(&mut self, policy: ProfilePolicy) {
self.profile_policies.insert(policy.tn_id, policy);
}
pub fn load_collection_policy(&mut self, policy: CollectionPolicy) {
let key = format!("{}:{}", policy.resource_type, policy.action);
self.collection_policies.insert(key, policy);
}
pub fn get_collection_policy(
&self,
resource_type: &str,
action: &str,
) -> Option<&CollectionPolicy> {
let key = format!("{}:{}", resource_type, action);
self.collection_policies.get(&key)
}
pub fn has_permission(
&self,
subject: &AuthCtx,
action: &str,
object: &dyn AttrSet,
environment: &Environment,
) -> bool {
if let Some(profile_policy) = self.profile_policies.get(&subject.tn_id) {
if let Some(Effect::Deny) =
profile_policy.top_policy.evaluate(subject, action, object, environment)
{
info!("TOP policy denied: tn_id={}, action={}", subject.tn_id.0, action);
return false;
}
if let Some(Effect::Allow) =
profile_policy.bottom_policy.evaluate(subject, action, object, environment)
{
info!("BOTTOM policy allowed: tn_id={}, action={}", subject.tn_id.0, action);
return true;
}
}
self.check_default_rules(subject, action, object, environment)
}
fn check_default_rules(
&self,
subject: &AuthCtx,
action: &str,
object: &dyn AttrSet,
_environment: &Environment,
) -> bool {
use tracing::debug;
if subject.roles.iter().any(|r| r.as_ref() == "leader") {
debug!(subject = %subject.id_tag, action = action, "Leader role allows access");
return true;
}
let parts: Vec<&str> = action.split(':').collect();
if parts.len() != 2 {
debug!(subject = %subject.id_tag, action = action, "Invalid action format (expected resource:operation)");
return false;
}
let operation = parts[1];
if matches!(operation, "update" | "delete" | "write") {
if let Some(owner) = object.get("owner_id_tag")
&& owner == subject.id_tag.as_ref()
{
debug!(subject = %subject.id_tag, action = action, owner = owner, "Owner access allowed for modify operation");
return true;
}
if let Some(al) = object.get("access_level")
&& al == "write"
{
debug!(subject = %subject.id_tag, action = action, "Write access level allows modify operation");
return true;
}
debug!(subject = %subject.id_tag, action = action, "Denied: not owner and no write access level");
return false;
}
if matches!(operation, "read") {
if let Some(al) = object.get("access_level")
&& matches!(al, "read" | "comment" | "write")
{
return true;
}
return self.check_visibility(subject, object);
}
if operation == "create" {
debug!(subject = %subject.id_tag, action = action, "Create operation allowed");
return true; }
if operation == "admin" {
use crate::roles::{MODERATOR_LEVEL, highest_role_level};
if highest_role_level(&subject.roles) >= MODERATOR_LEVEL {
debug!(subject = %subject.id_tag, action = action, "Moderator+ role allows admin operation");
return true;
}
debug!(subject = %subject.id_tag, action = action, "Denied: admin operation requires moderator+");
return false;
}
debug!(subject = %subject.id_tag, action = action, "Default deny: no matching rules");
false
}
#[expect(clippy::unused_self, reason = "method may use self in future policy checks")]
fn check_visibility(&self, subject: &AuthCtx, object: &dyn AttrSet) -> bool {
use tracing::debug;
let visibility = if let Some(vis_char) = object.get("visibility_char") {
VisibilityLevel::from_char(vis_char.chars().next())
} else if let Some(vis_str) = object.get("visibility") {
match vis_str {
"public" | "P" => VisibilityLevel::Public,
"verified" | "V" => VisibilityLevel::Verified,
"second_degree" | "2" => VisibilityLevel::SecondDegree,
"follower" | "F" => VisibilityLevel::Follower,
"connected" | "C" => VisibilityLevel::Connected,
_ => VisibilityLevel::Direct,
}
} else {
VisibilityLevel::Direct };
let is_owner = object.get("owner_id_tag") == Some(subject.id_tag.as_ref());
let is_issuer = object.get("issuer_id_tag") == Some(subject.id_tag.as_ref());
let is_connected = object.get("connected") == Some("true");
let is_follower = object.get("following") == Some("true");
let in_audience = object.contains("audience_tag", subject.id_tag.as_ref());
let is_authenticated = !subject.id_tag.is_empty() && subject.id_tag.as_ref() != "guest";
let access_level = if is_owner || is_issuer {
SubjectAccessLevel::Owner
} else if is_connected {
SubjectAccessLevel::Connected
} else if is_follower {
SubjectAccessLevel::Follower
} else if is_authenticated {
SubjectAccessLevel::Verified
} else {
SubjectAccessLevel::Public
};
let allowed = access_level.can_access(visibility);
let allowed =
if visibility == VisibilityLevel::Direct { allowed || in_audience } else { allowed };
debug!(
subject = %subject.id_tag,
visibility = ?visibility,
access_level = ?access_level,
is_owner = is_owner,
is_issuer = is_issuer,
is_connected = is_connected,
is_follower = is_follower,
in_audience = in_audience,
allowed = allowed,
"Visibility check"
);
allowed
}
pub fn has_collection_permission(
&self,
subject: &AuthCtx,
subject_attrs: &dyn AttrSet,
resource_type: &str,
action: &str,
environment: &Environment,
) -> bool {
use tracing::debug;
let Some(policy) = self.get_collection_policy(resource_type, action) else {
debug!(
subject = %subject.id_tag,
resource_type = resource_type,
action = action,
"No collection policy found - allowing by default"
);
return true;
};
if let Some(Effect::Deny) =
policy.top_policy.evaluate(subject, action, subject_attrs, environment)
{
debug!(
subject = %subject.id_tag,
resource_type = resource_type,
action = action,
"Collection TOP policy denied"
);
return false;
}
if let Some(Effect::Allow) =
policy.bottom_policy.evaluate(subject, action, subject_attrs, environment)
{
debug!(
subject = %subject.id_tag,
resource_type = resource_type,
action = action,
"Collection BOTTOM policy allowed"
);
return true;
}
debug!(
subject = %subject.id_tag,
resource_type = resource_type,
action = action,
"No matching collection policies - default deny"
);
false
}
}
impl Default for PermissionChecker {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_environment_creation() {
let env = Environment::new();
assert!(env.time.0 > 0);
}
#[test]
fn test_permission_checker_creation() {
let checker = PermissionChecker::new();
assert_eq!(checker.profile_policies.len(), 0);
}
}