use crate::protocol::{ClientSessionId, WssMessage};
use crate::auth::AuthError;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::RwLock;
use thiserror::Error;
use chrono::Utc;
use std::collections::HashMap;
#[derive(Error, Debug)]
pub enum RevocationError {
#[error("Client not found: {0}")]
ClientNotFound(String),
#[error("File operation failed: {0}")]
FileError(String),
#[error("Migration failed: {0}")]
MigrationFailed(String),
#[error("Auth error: {0}")]
AuthError(#[from] AuthError),
}
pub type RevocationResult<T> = Result<T, RevocationError>;
#[derive(Clone, Debug)]
pub enum RevocationReason {
MaxAttemptsExceeded,
TokenExpired,
AdminRevocation,
PolicyViolation,
SuspiciousActivity,
}
impl RevocationReason {
pub fn as_str(&self) -> &'static str {
match self {
Self::MaxAttemptsExceeded => "max_attempts_exceeded",
Self::TokenExpired => "token_expired",
Self::AdminRevocation => "admin_revocation",
Self::PolicyViolation => "policy_violation",
Self::SuspiciousActivity => "suspicious_activity",
}
}
}
#[derive(Clone, Debug)]
pub struct RevokedClientInfo {
pub client_id: String,
pub tenant_id: String,
pub reason: RevocationReason,
pub revoked_at: String,
pub detail: String,
}
pub struct RevocationManager {
revoked_clients: Arc<RwLock<HashMap<ClientSessionId, RevokedClientInfo>>>,
migrations_in_progress: Arc<RwLock<HashMap<String, MigrationState>>>,
}
#[derive(Clone, Debug)]
pub struct MigrationState {
pub service_name: String,
pub old_path: PathBuf,
pub new_path: PathBuf,
pub clients_to_migrate: Vec<ClientSessionId>,
pub clients_migrated: Vec<ClientSessionId>,
pub started_at: String,
}
impl RevocationManager {
pub fn new() -> Self {
Self {
revoked_clients: Arc::new(RwLock::new(HashMap::new())),
migrations_in_progress: Arc::new(RwLock::new(HashMap::new())),
}
}
pub async fn revoke_permission(
&self,
client_id: String,
tenant_id: String,
reason: RevocationReason,
detail: String,
) -> RevocationResult<()> {
let revoked_info = RevokedClientInfo {
client_id: client_id.clone(),
tenant_id: tenant_id.clone(),
reason,
revoked_at: Utc::now().to_rfc3339(),
detail,
};
let mut revoked = self.revoked_clients.write().await;
revoked.insert(client_id, revoked_info);
Ok(())
}
pub async fn start_migration(
&self,
service_name: String,
old_path: PathBuf,
new_path: PathBuf,
clients_to_migrate: Vec<ClientSessionId>,
) -> RevocationResult<()> {
let migration = MigrationState {
service_name: service_name.clone(),
old_path,
new_path,
clients_to_migrate,
clients_migrated: Vec::new(),
started_at: Utc::now().to_rfc3339(),
};
let mut migrations = self.migrations_in_progress.write().await;
migrations.insert(service_name, migration);
Ok(())
}
pub async fn record_migration_ack(
&self,
service_name: &str,
client_session_id: ClientSessionId,
) -> RevocationResult<()> {
let mut migrations = self.migrations_in_progress.write().await;
if let Some(migration) = migrations.get_mut(service_name) {
if !migration.clients_migrated.contains(&client_session_id) {
migration.clients_migrated.push(client_session_id);
}
Ok(())
} else {
Err(RevocationError::MigrationFailed(
format!("No migration for service {}", service_name),
))
}
}
pub async fn is_migration_complete(&self, service_name: &str) -> RevocationResult<bool> {
let migrations = self.migrations_in_progress.read().await;
if let Some(migration) = migrations.get(service_name) {
Ok(migration.clients_migrated.len() == migration.clients_to_migrate.len())
} else {
Err(RevocationError::MigrationFailed(
format!("No migration for service {}", service_name),
))
}
}
pub async fn finalize_migration(
&self,
service_name: &str,
keep_honeypot: bool,
) -> RevocationResult<()> {
let mut migrations = self.migrations_in_progress.write().await;
if let Some(_migration) = migrations.remove(service_name) {
if !keep_honeypot {
} else {
}
Ok(())
} else {
Err(RevocationError::MigrationFailed(
format!("No migration for service {}", service_name),
))
}
}
pub fn create_migration_message(
old_path: &Path,
new_path: &Path,
service_name: &str,
reason: &str,
) -> WssMessage {
WssMessage::FileMigration {
old_service_path: old_path.to_string_lossy().to_string(),
new_service_path: new_path.to_string_lossy().to_string(),
service_name: service_name.to_string(),
reason: reason.to_string(),
}
}
pub fn create_revocation_message(reason: &str, detail: &str) -> WssMessage {
WssMessage::PermissionRevoked {
reason: reason.to_string(),
detail: detail.to_string(),
}
}
pub async fn is_client_revoked(&self, client_id: &str) -> bool {
let revoked = self.revoked_clients.read().await;
revoked.iter().any(|(_, info)| info.client_id == client_id)
}
pub async fn get_revoked_client_info(&self, client_id: &str) -> Option<RevokedClientInfo> {
let revoked = self.revoked_clients.read().await;
revoked
.iter()
.find(|(_, info)| info.client_id == client_id)
.map(|(_, info)| info.clone())
}
pub async fn get_migrations(&self) -> Vec<MigrationState> {
let migrations = self.migrations_in_progress.read().await;
migrations.values().cloned().collect()
}
pub async fn cleanup_old_revocations(&self, max_entries: usize) {
let mut revoked = self.revoked_clients.write().await;
if revoked.len() > max_entries {
let to_remove = revoked.len() - max_entries;
let keys: Vec<_> = revoked.keys().take(to_remove).cloned().collect();
for key in keys {
revoked.remove(&key);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_revoke_permission() {
let manager = RevocationManager::new();
manager
.revoke_permission(
"client1".to_string(),
"tenant1".to_string(),
RevocationReason::AdminRevocation,
"Test revocation".to_string(),
)
.await
.unwrap();
assert!(manager.is_client_revoked("client1").await);
}
#[tokio::test]
async fn test_is_client_revoked_false_for_unknown() {
let manager = RevocationManager::new();
assert!(!manager.is_client_revoked("nobody").await);
}
#[tokio::test]
async fn test_get_revoked_client_info() {
let manager = RevocationManager::new();
manager
.revoke_permission(
"alice".to_string(),
"t1".to_string(),
RevocationReason::TokenExpired,
"expired".to_string(),
)
.await
.unwrap();
let info = manager.get_revoked_client_info("alice").await;
assert!(info.is_some());
let info = info.unwrap();
assert_eq!(info.client_id, "alice");
assert_eq!(info.tenant_id, "t1");
}
#[tokio::test]
async fn test_get_revoked_client_info_none_for_unknown() {
let manager = RevocationManager::new();
assert!(manager.get_revoked_client_info("ghost").await.is_none());
}
#[tokio::test]
async fn test_record_migration_ack() {
let manager = RevocationManager::new();
let old = std::path::PathBuf::from("/old/svc.mem");
let new = std::path::PathBuf::from("/new/svc.mem");
manager
.start_migration(
"my_service".to_string(),
old,
new,
vec!["client1".to_string(), "client2".to_string()],
)
.await
.unwrap();
manager
.record_migration_ack("my_service", "client1".to_string())
.await
.unwrap();
assert!(!manager.is_migration_complete("my_service").await.unwrap());
manager
.record_migration_ack("my_service", "client2".to_string())
.await
.unwrap();
assert!(manager.is_migration_complete("my_service").await.unwrap());
}
#[tokio::test]
async fn test_record_migration_ack_deduplicates() {
let manager = RevocationManager::new();
manager
.start_migration(
"svc".to_string(),
std::path::PathBuf::from("/a"),
std::path::PathBuf::from("/b"),
vec!["c1".to_string()],
)
.await
.unwrap();
manager.record_migration_ack("svc", "c1".to_string()).await.unwrap();
manager.record_migration_ack("svc", "c1".to_string()).await.unwrap();
let complete = manager.is_migration_complete("svc").await.unwrap();
assert!(complete); }
#[tokio::test]
async fn test_finalize_migration_removes_migration() {
let manager = RevocationManager::new();
manager
.start_migration(
"svc".to_string(),
std::path::PathBuf::from("/old"),
std::path::PathBuf::from("/new"),
vec![],
)
.await
.unwrap();
manager.finalize_migration("svc", false).await.unwrap();
let result = manager.is_migration_complete("svc").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_create_migration_message() {
let msg = RevocationManager::create_migration_message(
std::path::Path::new("/old/svc.mem"),
std::path::Path::new("/new/svc.mem"),
"my_service",
"admin_revocation",
);
matches!(msg, crate::protocol::WssMessage::FileMigration { .. });
}
#[tokio::test]
async fn test_create_revocation_message() {
let msg = RevocationManager::create_revocation_message("token_expired", "token too old");
matches!(msg, crate::protocol::WssMessage::PermissionRevoked { .. });
}
#[tokio::test]
async fn test_get_migrations() {
let manager = RevocationManager::new();
manager
.start_migration(
"svc1".to_string(),
std::path::PathBuf::from("/a"),
std::path::PathBuf::from("/b"),
vec![],
)
.await
.unwrap();
manager
.start_migration(
"svc2".to_string(),
std::path::PathBuf::from("/c"),
std::path::PathBuf::from("/d"),
vec![],
)
.await
.unwrap();
let migrations = manager.get_migrations().await;
assert_eq!(migrations.len(), 2);
}
#[tokio::test]
async fn test_cleanup_old_revocations() {
let manager = RevocationManager::new();
for i in 0..5 {
manager
.revoke_permission(
format!("client{}", i),
"t1".to_string(),
RevocationReason::AdminRevocation,
"".to_string(),
)
.await
.unwrap();
}
manager.cleanup_old_revocations(2).await;
let mut remaining = 0;
for i in 0..5 {
if manager.is_client_revoked(&format!("client{}", i)).await {
remaining += 1;
}
}
assert!(remaining <= 2);
}
#[test]
fn test_revocation_reason_as_str() {
assert_eq!(RevocationReason::MaxAttemptsExceeded.as_str(), "max_attempts_exceeded");
assert_eq!(RevocationReason::TokenExpired.as_str(), "token_expired");
assert_eq!(RevocationReason::AdminRevocation.as_str(), "admin_revocation");
assert_eq!(RevocationReason::PolicyViolation.as_str(), "policy_violation");
assert_eq!(RevocationReason::SuspiciousActivity.as_str(), "suspicious_activity");
}
#[tokio::test]
async fn test_start_migration() {
let manager = RevocationManager::new();
let old_path = PathBuf::from("/tmp/old.mem");
let new_path = PathBuf::from("/tmp/new.mem");
manager
.start_migration(
"service1".to_string(),
old_path,
new_path,
vec!["client1".to_string()],
)
.await
.unwrap();
let migrations = manager.get_migrations().await;
assert_eq!(migrations.len(), 1);
assert_eq!(migrations[0].service_name, "service1");
}
#[tokio::test]
async fn test_migration_completion() {
let manager = RevocationManager::new();
let clients = vec!["client1".to_string(), "client2".to_string()];
manager
.start_migration(
"service1".to_string(),
PathBuf::from("/tmp/old.mem"),
PathBuf::from("/tmp/new.mem"),
clients.clone(),
)
.await
.unwrap();
manager
.record_migration_ack("service1", clients[0].clone())
.await
.unwrap();
assert!(!manager.is_migration_complete("service1").await.unwrap());
manager
.record_migration_ack("service1", clients[1].clone())
.await
.unwrap();
assert!(manager.is_migration_complete("service1").await.unwrap());
}
#[tokio::test]
async fn test_revoke_then_is_client_revoked_true() {
let manager = RevocationManager::new();
assert!(!manager.is_client_revoked("alice").await);
manager
.revoke_permission(
"alice".to_string(),
"tenant_a".to_string(),
RevocationReason::AdminRevocation,
"test revoke".to_string(),
)
.await
.unwrap();
assert!(
manager.is_client_revoked("alice").await,
"is_client_revoked must return true after revoke_permission was called"
);
}
#[tokio::test]
async fn test_is_migration_complete_unknown_service_returns_err() {
let manager = RevocationManager::new();
let result = manager.is_migration_complete("no_such_service").await;
assert!(
result.is_err(),
"is_migration_complete on a service with no migration must return Err"
);
match result.unwrap_err() {
RevocationError::MigrationFailed(_) => {}
e => panic!("Expected MigrationFailed, got {:?}", e),
}
}
}