use std::collections::HashMap;
use std::sync::RwLock;
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::info;
use crate::control::security::auth_context::AuthStatus;
use crate::control::security::catalog::{StoredAuthUser, SystemCatalog};
#[derive(Debug, Clone)]
pub struct AuthUserRecord {
pub id: String,
pub username: String,
pub email: String,
pub tenant_id: u32,
pub provider: String,
pub first_seen: u64,
pub last_seen: u64,
pub is_active: bool,
pub status: AuthStatus,
pub is_external: bool,
pub synced_claims: HashMap<String, String>,
}
impl AuthUserRecord {
pub fn from_stored(s: &StoredAuthUser) -> Self {
Self {
id: s.id.clone(),
username: s.username.clone(),
email: s.email.clone(),
tenant_id: s.tenant_id,
provider: s.provider.clone(),
first_seen: s.first_seen,
last_seen: s.last_seen,
is_active: s.is_active,
status: s.status.parse().unwrap_or_default(),
is_external: s.is_external,
synced_claims: s.synced_claims.clone(),
}
}
pub fn to_stored(&self) -> StoredAuthUser {
StoredAuthUser {
id: self.id.clone(),
username: self.username.clone(),
email: self.email.clone(),
tenant_id: self.tenant_id,
provider: self.provider.clone(),
first_seen: self.first_seen,
last_seen: self.last_seen,
is_active: self.is_active,
status: self.status.to_string(),
is_external: self.is_external,
synced_claims: self.synced_claims.clone(),
}
}
}
pub struct AuthUserStore {
users: RwLock<HashMap<String, AuthUserRecord>>,
catalog: Option<SystemCatalog>,
}
impl AuthUserStore {
pub fn new() -> Self {
Self {
users: RwLock::new(HashMap::new()),
catalog: None,
}
}
pub fn open(catalog: SystemCatalog) -> crate::Result<Self> {
let stored = catalog.load_all_auth_users()?;
let mut users = HashMap::with_capacity(stored.len());
for s in &stored {
let record = AuthUserRecord::from_stored(s);
users.insert(record.id.clone(), record);
}
if !users.is_empty() {
info!(count = users.len(), "auth users loaded from catalog");
}
Ok(Self {
users: RwLock::new(users),
catalog: Some(catalog),
})
}
pub fn get(&self, id: &str) -> Option<AuthUserRecord> {
let users = self.users.read().unwrap_or_else(|p| p.into_inner());
users.get(id).cloned()
}
pub fn is_active(&self, id: &str) -> bool {
self.get(id).is_some_and(|u| u.is_active)
}
pub fn get_status(&self, id: &str) -> Option<AuthStatus> {
self.get(id).map(|u| u.status)
}
pub fn upsert(&self, record: AuthUserRecord) -> crate::Result<()> {
if let Some(ref catalog) = self.catalog {
catalog.put_auth_user(&record.to_stored())?;
}
let mut users = self.users.write().unwrap_or_else(|p| p.into_inner());
users.insert(record.id.clone(), record);
Ok(())
}
pub fn touch(&self, id: &str) -> crate::Result<()> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut users = self.users.write().unwrap_or_else(|p| p.into_inner());
if let Some(user) = users.get_mut(id) {
user.last_seen = now;
if let Some(ref catalog) = self.catalog {
let _ = catalog.put_auth_user(&user.to_stored());
}
}
Ok(())
}
pub fn deactivate(&self, id: &str) -> crate::Result<bool> {
let mut users = self.users.write().unwrap_or_else(|p| p.into_inner());
if let Some(user) = users.get_mut(id) {
user.is_active = false;
user.status = AuthStatus::Suspended;
if let Some(ref catalog) = self.catalog {
catalog.put_auth_user(&user.to_stored())?;
}
info!(user_id = %id, "auth user deactivated");
Ok(true)
} else {
Ok(false)
}
}
pub fn set_status(&self, id: &str, status: AuthStatus) -> crate::Result<bool> {
let mut users = self.users.write().unwrap_or_else(|p| p.into_inner());
if let Some(user) = users.get_mut(id) {
user.status = status;
user.is_active = matches!(
status,
AuthStatus::Active | AuthStatus::Restricted | AuthStatus::ReadOnly
);
if let Some(ref catalog) = self.catalog {
catalog.put_auth_user(&user.to_stored())?;
}
info!(user_id = %id, status = %status, "auth user status changed");
Ok(true)
} else {
Ok(false)
}
}
pub fn list(&self, active_only: bool) -> Vec<AuthUserRecord> {
let users = self.users.read().unwrap_or_else(|p| p.into_inner());
users
.values()
.filter(|u| !active_only || u.is_active)
.cloned()
.collect()
}
pub fn purge_inactive(&self, inactive_before_secs: u64) -> crate::Result<usize> {
let to_purge: Vec<String> = {
let users = self.users.read().unwrap_or_else(|p| p.into_inner());
users
.values()
.filter(|u| !u.is_active && u.last_seen < inactive_before_secs)
.map(|u| u.id.clone())
.collect()
};
let count = to_purge.len();
if count > 0 {
let mut users = self.users.write().unwrap_or_else(|p| p.into_inner());
for id in &to_purge {
users.remove(id);
if let Some(ref catalog) = self.catalog {
let _ = catalog.delete_auth_user(id);
}
}
info!(purged = count, "inactive auth users purged");
}
Ok(count)
}
pub fn count(&self) -> usize {
let users = self.users.read().unwrap_or_else(|p| p.into_inner());
users.len()
}
pub fn catalog(&self) -> Option<&SystemCatalog> {
self.catalog.as_ref()
}
}
impl Default for AuthUserStore {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_user(id: &str) -> AuthUserRecord {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
AuthUserRecord {
id: id.into(),
username: format!("user_{id}"),
email: format!("{id}@example.com"),
tenant_id: 1,
provider: "test".into(),
first_seen: now,
last_seen: now,
is_active: true,
status: AuthStatus::Active,
is_external: true,
synced_claims: HashMap::new(),
}
}
#[test]
fn upsert_and_get() {
let store = AuthUserStore::new();
store.upsert(test_user("u1")).unwrap();
let user = store.get("u1").unwrap();
assert_eq!(user.username, "user_u1");
assert!(user.is_active);
}
#[test]
fn deactivate_blocks_user() {
let store = AuthUserStore::new();
store.upsert(test_user("u1")).unwrap();
assert!(store.is_active("u1"));
store.deactivate("u1").unwrap();
assert!(!store.is_active("u1"));
assert_eq!(store.get_status("u1"), Some(AuthStatus::Suspended));
}
#[test]
fn set_status() {
let store = AuthUserStore::new();
store.upsert(test_user("u1")).unwrap();
store.set_status("u1", AuthStatus::ReadOnly).unwrap();
assert_eq!(store.get_status("u1"), Some(AuthStatus::ReadOnly));
assert!(store.is_active("u1"));
store.set_status("u1", AuthStatus::Banned).unwrap();
assert!(!store.is_active("u1"));
}
#[test]
fn list_filters_active() {
let store = AuthUserStore::new();
store.upsert(test_user("u1")).unwrap();
store.upsert(test_user("u2")).unwrap();
store.deactivate("u2").unwrap();
assert_eq!(store.list(true).len(), 1);
assert_eq!(store.list(false).len(), 2);
}
#[test]
fn nonexistent_user_returns_none() {
let store = AuthUserStore::new();
assert!(store.get("nonexistent").is_none());
assert!(!store.is_active("nonexistent"));
}
}