use std::collections::HashMap;
use std::sync::RwLock;
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::{info, warn};
use crate::control::security::catalog::{StoredBlacklistEntry, SystemCatalog};
#[derive(Debug, Clone)]
pub struct BlacklistEntry {
pub key: String,
pub kind: String,
pub reason: String,
pub created_by: String,
pub created_at: u64,
pub expires_at: u64,
}
impl BlacklistEntry {
pub fn is_expired(&self) -> bool {
if self.expires_at == 0 {
return false; }
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
now >= self.expires_at
}
fn from_stored(s: &StoredBlacklistEntry) -> Self {
Self {
key: s.key.clone(),
kind: s.kind.clone(),
reason: s.reason.clone(),
created_by: s.created_by.clone(),
created_at: s.created_at,
expires_at: s.expires_at,
}
}
fn to_stored(&self) -> StoredBlacklistEntry {
StoredBlacklistEntry {
key: self.key.clone(),
kind: self.kind.clone(),
reason: self.reason.clone(),
created_by: self.created_by.clone(),
created_at: self.created_at,
expires_at: self.expires_at,
}
}
}
pub struct BlacklistStore {
entries: RwLock<HashMap<String, BlacklistEntry>>,
catalog: Option<SystemCatalog>,
blocked_statuses: Vec<String>,
status_claim: Option<String>,
}
impl BlacklistStore {
pub fn new() -> Self {
Self {
entries: RwLock::new(HashMap::new()),
catalog: None,
blocked_statuses: Vec::new(),
status_claim: None,
}
}
pub fn set_claim_blocking(
&mut self,
status_claim: Option<String>,
blocked_statuses: Vec<String>,
) {
self.status_claim = status_claim;
self.blocked_statuses = blocked_statuses;
}
pub fn check_jwt_status(
&self,
auth_ctx: &super::super::auth_context::AuthContext,
) -> Option<String> {
let claim = self.status_claim.as_deref()?;
if self.blocked_statuses.is_empty() {
return None;
}
let val = auth_ctx.metadata.get(claim).cloned().or_else(|| {
if claim == "status" {
Some(auth_ctx.status.to_string())
} else {
None
}
})?;
if self.blocked_statuses.contains(&val) {
Some(val)
} else {
None
}
}
pub fn load_from(&self, catalog: &SystemCatalog) -> crate::Result<()> {
let stored = catalog.load_all_blacklist_entries()?;
let mut expired_keys = Vec::new();
let mut loaded = Vec::new();
for s in &stored {
let entry = BlacklistEntry::from_stored(s);
if entry.is_expired() {
expired_keys.push(s.key.clone());
} else {
loaded.push(entry);
}
}
for key in &expired_keys {
let _ = catalog.delete_blacklist_entry(key);
}
if !loaded.is_empty() {
let mut entries = self.entries.write().unwrap_or_else(|p| p.into_inner());
for entry in &loaded {
entries.insert(entry.key.clone(), entry.clone());
}
info!(
active = loaded.len(),
expired_cleaned = expired_keys.len(),
"blacklist loaded from catalog"
);
}
Ok(())
}
pub fn check_user(&self, user_id: &str) -> Option<BlacklistEntry> {
let key = format!("user:{user_id}");
self.check(&key)
}
pub fn check_ip(&self, ip: &str) -> Option<BlacklistEntry> {
let key = format!("ip:{ip}");
self.check(&key)
}
fn check(&self, key: &str) -> Option<BlacklistEntry> {
let entries = self.entries.read().unwrap_or_else(|p| p.into_inner());
let entry = entries.get(key)?;
if entry.is_expired() {
drop(entries);
self.remove_entry(key);
None
} else {
Some(entry.clone())
}
}
pub fn blacklist_user(
&self,
user_id: &str,
reason: &str,
created_by: &str,
expires_at: u64,
) -> crate::Result<()> {
let key = format!("user:{user_id}");
self.add_entry(key, "user", reason, created_by, expires_at)
}
pub fn blacklist_ip(
&self,
addr: &str,
reason: &str,
created_by: &str,
expires_at: u64,
) -> crate::Result<()> {
let key = format!("ip:{addr}");
self.add_entry(key, "ip", reason, created_by, expires_at)
}
fn add_entry(
&self,
key: String,
kind: &str,
reason: &str,
created_by: &str,
expires_at: u64,
) -> crate::Result<()> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let entry = BlacklistEntry {
key: key.clone(),
kind: kind.into(),
reason: reason.into(),
created_by: created_by.into(),
created_at: now,
expires_at,
};
if let Some(ref catalog) = self.catalog {
catalog.put_blacklist_entry(&entry.to_stored())?;
}
let mut entries = self.entries.write().unwrap_or_else(|p| p.into_inner());
info!(key = %key, reason = %reason, expires_at, "blacklist entry added");
entries.insert(key, entry);
Ok(())
}
pub fn remove_entry(&self, key: &str) -> bool {
if let Some(ref catalog) = self.catalog
&& let Err(e) = catalog.delete_blacklist_entry(key)
{
warn!(key = %key, error = %e, "failed to delete blacklist entry from catalog");
}
let mut entries = self.entries.write().unwrap_or_else(|p| p.into_inner());
entries.remove(key).is_some()
}
pub fn unblacklist_user(&self, user_id: &str) -> bool {
self.remove_entry(&format!("user:{user_id}"))
}
pub fn unblacklist_ip(&self, addr: &str) -> bool {
self.remove_entry(&format!("ip:{addr}"))
}
pub fn list(&self, kind_filter: Option<&str>) -> Vec<BlacklistEntry> {
let entries = self.entries.read().unwrap_or_else(|p| p.into_inner());
entries
.values()
.filter(|e| !e.is_expired() && kind_filter.map(|k| e.kind == k).unwrap_or(true))
.cloned()
.collect()
}
pub fn count(&self) -> usize {
let entries = self.entries.read().unwrap_or_else(|p| p.into_inner());
entries.values().filter(|e| !e.is_expired()).count()
}
pub fn catalog(&self) -> Option<&SystemCatalog> {
self.catalog.as_ref()
}
}
impl Default for BlacklistStore {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blacklist_user_and_check() {
let store = BlacklistStore::new();
store.blacklist_user("user_42", "spam", "admin", 0).unwrap();
assert!(store.check_user("user_42").is_some());
assert!(store.check_user("user_99").is_none());
}
#[test]
fn blacklist_ip_and_check() {
let store = BlacklistStore::new();
store
.blacklist_ip("192.168.1.100", "abuse", "admin", 0)
.unwrap();
assert!(store.check_ip("192.168.1.100").is_some());
assert!(store.check_ip("10.0.0.1").is_none());
}
#[test]
fn expired_entry_not_returned() {
let store = BlacklistStore::new();
let past = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
- 1;
store
.blacklist_user("user_expired", "test", "admin", past)
.unwrap();
assert!(store.check_user("user_expired").is_none());
}
#[test]
fn unblacklist_removes_entry() {
let store = BlacklistStore::new();
store.blacklist_user("user_42", "spam", "admin", 0).unwrap();
assert!(store.check_user("user_42").is_some());
store.unblacklist_user("user_42");
assert!(store.check_user("user_42").is_none());
}
#[test]
fn list_filters_by_kind() {
let store = BlacklistStore::new();
store.blacklist_user("u1", "spam", "admin", 0).unwrap();
store.blacklist_ip("1.2.3.4", "abuse", "admin", 0).unwrap();
assert_eq!(store.list(Some("user")).len(), 1);
assert_eq!(store.list(Some("ip")).len(), 1);
assert_eq!(store.list(None).len(), 2);
}
}