use std::collections::{HashMap, HashSet};
use std::sync::RwLock;
use tracing::info;
use crate::control::security::catalog::{StoredScopeGrant, SystemCatalog};
use crate::control::security::time::now_secs;
#[derive(Debug, Clone)]
pub struct ScopeGrant {
pub scope_name: String,
pub grantee_type: String,
pub grantee_id: String,
pub granted_by: String,
pub granted_at: u64,
pub expires_at: u64,
pub grace_period_secs: u64,
pub on_expire_action: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScopeStatus {
Active,
Grace,
Expired,
None,
}
impl std::fmt::Display for ScopeStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Active => write!(f, "active"),
Self::Grace => write!(f, "grace"),
Self::Expired => write!(f, "expired"),
Self::None => write!(f, "none"),
}
}
}
impl ScopeGrant {
pub fn status(&self) -> ScopeStatus {
if self.expires_at == 0 {
return ScopeStatus::Active; }
let now = now_secs();
if now < self.expires_at {
ScopeStatus::Active
} else if now < self.expires_at + self.grace_period_secs {
ScopeStatus::Grace
} else {
ScopeStatus::Expired
}
}
pub fn is_effective(&self) -> bool {
matches!(self.status(), ScopeStatus::Active | ScopeStatus::Grace)
}
fn from_stored(s: &StoredScopeGrant) -> Self {
Self {
scope_name: s.scope_name.clone(),
grantee_type: s.grantee_type.clone(),
grantee_id: s.grantee_id.clone(),
granted_by: s.granted_by.clone(),
granted_at: s.granted_at,
expires_at: s.expires_at,
grace_period_secs: s.grace_period_secs,
on_expire_action: s.on_expire_action.clone(),
}
}
fn to_stored(&self) -> StoredScopeGrant {
StoredScopeGrant {
scope_name: self.scope_name.clone(),
grantee_type: self.grantee_type.clone(),
grantee_id: self.grantee_id.clone(),
granted_by: self.granted_by.clone(),
granted_at: self.granted_at,
expires_at: self.expires_at,
grace_period_secs: self.grace_period_secs,
on_expire_action: self.on_expire_action.clone(),
}
}
}
pub struct ScopeGrantStore {
grants: RwLock<HashMap<String, ScopeGrant>>,
catalog: Option<SystemCatalog>,
}
impl ScopeGrantStore {
pub fn new() -> Self {
Self {
grants: RwLock::new(HashMap::new()),
catalog: None,
}
}
pub fn open(catalog: SystemCatalog) -> crate::Result<Self> {
let stored = catalog.load_all_scope_grants()?;
let mut grants = HashMap::with_capacity(stored.len());
for s in &stored {
let key = grant_key(&s.scope_name, &s.grantee_type, &s.grantee_id);
grants.insert(key, ScopeGrant::from_stored(s));
}
if !grants.is_empty() {
info!(count = grants.len(), "scope grants loaded from catalog");
}
Ok(Self {
grants: RwLock::new(grants),
catalog: Some(catalog),
})
}
#[allow(clippy::too_many_arguments)]
pub fn grant(
&self,
scope_name: &str,
grantee_type: &str,
grantee_id: &str,
granted_by: &str,
expires_at: u64,
grace_period_secs: u64,
on_expire_action: &str,
) -> crate::Result<()> {
let record = ScopeGrant {
scope_name: scope_name.into(),
grantee_type: grantee_type.into(),
grantee_id: grantee_id.into(),
granted_by: granted_by.into(),
granted_at: now_secs(),
expires_at,
grace_period_secs,
on_expire_action: on_expire_action.into(),
};
if let Some(ref catalog) = self.catalog {
catalog.put_scope_grant(&record.to_stored())?;
}
let key = grant_key(scope_name, grantee_type, grantee_id);
let mut grants = self.grants.write().unwrap_or_else(|p| p.into_inner());
grants.insert(key, record);
info!(scope = %scope_name, grantee_type, grantee_id, "scope granted");
Ok(())
}
pub fn revoke(
&self,
scope_name: &str,
grantee_type: &str,
grantee_id: &str,
) -> crate::Result<bool> {
if let Some(ref catalog) = self.catalog {
catalog.delete_scope_grant(scope_name, grantee_type, grantee_id)?;
}
let key = grant_key(scope_name, grantee_type, grantee_id);
let mut grants = self.grants.write().unwrap_or_else(|p| p.into_inner());
Ok(grants.remove(&key).is_some())
}
pub fn scopes_for(&self, grantee_type: &str, grantee_id: &str) -> Vec<String> {
let grants = self.grants.read().unwrap_or_else(|p| p.into_inner());
grants
.values()
.filter(|g| {
g.grantee_type == grantee_type && g.grantee_id == grantee_id && g.is_effective()
})
.map(|g| g.scope_name.clone())
.collect()
}
pub fn scope_status(
&self,
scope_name: &str,
grantee_type: &str,
grantee_id: &str,
) -> ScopeStatus {
let key = grant_key(scope_name, grantee_type, grantee_id);
let grants = self.grants.read().unwrap_or_else(|p| p.into_inner());
grants
.get(&key)
.map(|g| g.status())
.unwrap_or(ScopeStatus::None)
}
pub fn scope_expires_at(&self, scope_name: &str, grantee_type: &str, grantee_id: &str) -> u64 {
let key = grant_key(scope_name, grantee_type, grantee_id);
let grants = self.grants.read().unwrap_or_else(|p| p.into_inner());
grants.get(&key).map(|g| g.expires_at).unwrap_or(0)
}
pub fn renew(
&self,
scope_name: &str,
grantee_type: &str,
grantee_id: &str,
extend_secs: u64,
) -> crate::Result<bool> {
let key = grant_key(scope_name, grantee_type, grantee_id);
let mut grants = self.grants.write().unwrap_or_else(|p| p.into_inner());
if let Some(g) = grants.get_mut(&key) {
if g.expires_at == 0 {
return Ok(true); }
let now = now_secs();
let base = g.expires_at.max(now);
g.expires_at = base + extend_secs;
if let Some(ref catalog) = self.catalog {
let _ = catalog.put_scope_grant(&g.to_stored());
}
info!(scope = %scope_name, grantee_type, grantee_id, new_expires = g.expires_at, "scope renewed");
Ok(true)
} else {
Ok(false)
}
}
pub fn expiring_within(&self, window_secs: u64) -> Vec<ScopeGrant> {
let now = now_secs();
let deadline = now + window_secs;
let grants = self.grants.read().unwrap_or_else(|p| p.into_inner());
grants
.values()
.filter(|g| g.expires_at > 0 && g.expires_at <= deadline && g.is_effective())
.cloned()
.collect()
}
pub fn effective_scopes(&self, user_id: &str, org_ids: &[String]) -> HashSet<String> {
let grants = self.grants.read().unwrap_or_else(|p| p.into_inner());
let mut effective = HashSet::new();
for g in grants.values() {
if !g.is_effective() {
continue; }
if g.grantee_type == "user" && g.grantee_id == user_id {
effective.insert(g.scope_name.clone());
}
if g.grantee_type == "org" && org_ids.contains(&g.grantee_id) {
effective.insert(g.scope_name.clone());
}
}
effective
}
pub fn has_scope(&self, user_id: &str, org_ids: &[String], scope_name: &str) -> bool {
self.effective_scopes(user_id, org_ids).contains(scope_name)
}
pub fn list(&self, scope_filter: Option<&str>) -> Vec<ScopeGrant> {
let grants = self.grants.read().unwrap_or_else(|p| p.into_inner());
grants
.values()
.filter(|g| scope_filter.is_none_or(|s| g.scope_name == s))
.cloned()
.collect()
}
pub fn count(&self) -> usize {
self.grants.read().unwrap_or_else(|p| p.into_inner()).len()
}
}
impl Default for ScopeGrantStore {
fn default() -> Self {
Self::new()
}
}
fn grant_key(scope: &str, grantee_type: &str, grantee_id: &str) -> String {
format!("{scope}:{grantee_type}:{grantee_id}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn grant_and_check() {
let store = ScopeGrantStore::new();
store
.grant("profile:read", "user", "u1", "admin", 0, 0, "")
.unwrap();
assert!(store.has_scope("u1", &[], "profile:read"));
assert!(!store.has_scope("u1", &[], "orders:write"));
assert!(!store.has_scope("u2", &[], "profile:read"));
}
#[test]
fn org_scope_inheritance() {
let store = ScopeGrantStore::new();
store
.grant("pro:all", "org", "acme", "admin", 0, 0, "")
.unwrap();
assert!(store.has_scope("u1", &["acme".into()], "pro:all"));
assert!(!store.has_scope("u2", &[], "pro:all"));
}
#[test]
fn effective_scopes_union() {
let store = ScopeGrantStore::new();
store
.grant("scope_a", "user", "u1", "admin", 0, 0, "")
.unwrap();
store
.grant("scope_b", "org", "acme", "admin", 0, 0, "")
.unwrap();
store
.grant("scope_c", "org", "beta", "admin", 0, 0, "")
.unwrap();
let effective = store.effective_scopes("u1", &["acme".into()]);
assert!(effective.contains("scope_a")); assert!(effective.contains("scope_b")); assert!(!effective.contains("scope_c")); }
#[test]
fn revoke_removes_grant() {
let store = ScopeGrantStore::new();
store.grant("s1", "user", "u1", "admin", 0, 0, "").unwrap();
assert!(store.has_scope("u1", &[], "s1"));
store.revoke("s1", "user", "u1").unwrap();
assert!(!store.has_scope("u1", &[], "s1"));
}
}