use chrono::{DateTime, Utc};
use mockforge_core::security::access_review::{ApiTokenInfo, PrivilegedAccessInfo, UserAccessInfo};
use mockforge_core::security::{
ApiTokenStorage, JustificationStorage, MfaStorage, UserDataProvider,
};
use mockforge_core::Error;
use sqlx::{Pool, Sqlite};
use std::sync::Arc;
use uuid::Uuid;
pub struct CollabUserDataProvider {
db: Pool<Sqlite>,
user_service: Arc<crate::user::UserService>,
_workspace_service: Arc<crate::workspace::WorkspaceService>,
token_storage: Option<Arc<dyn ApiTokenStorage>>,
mfa_storage: Option<Arc<dyn MfaStorage>>,
justification_storage: Option<Arc<dyn JustificationStorage>>,
}
impl CollabUserDataProvider {
#[must_use]
pub fn new(
db: Pool<Sqlite>,
user_service: Arc<crate::user::UserService>,
workspace_service: Arc<crate::workspace::WorkspaceService>,
) -> Self {
Self {
db,
user_service,
_workspace_service: workspace_service,
token_storage: None,
mfa_storage: None,
justification_storage: None,
}
}
#[must_use]
pub fn with_storage(
db: Pool<Sqlite>,
user_service: Arc<crate::user::UserService>,
workspace_service: Arc<crate::workspace::WorkspaceService>,
token_storage: Option<Arc<dyn ApiTokenStorage>>,
mfa_storage: Option<Arc<dyn MfaStorage>>,
justification_storage: Option<Arc<dyn JustificationStorage>>,
) -> Self {
Self {
db,
user_service,
_workspace_service: workspace_service,
token_storage,
mfa_storage,
justification_storage,
}
}
}
#[async_trait::async_trait]
impl UserDataProvider for CollabUserDataProvider {
async fn get_all_users(&self) -> Result<Vec<UserAccessInfo>, Error> {
let users = sqlx::query_as!(
crate::models::User,
r#"
SELECT id as "id: Uuid", username, email, password_hash, display_name, avatar_url,
created_at as "created_at: chrono::DateTime<chrono::Utc>",
updated_at as "updated_at: chrono::DateTime<chrono::Utc>",
is_active as "is_active: bool"
FROM users
WHERE is_active = TRUE
ORDER BY created_at
"#,
)
.fetch_all(&self.db)
.await
.map_err(|e| Error::Generic(format!("Failed to fetch users: {e}")))?;
let mut user_access_list = Vec::new();
for user in users {
let memberships = sqlx::query_as!(
crate::models::WorkspaceMember,
r#"
SELECT
id as "id: Uuid",
workspace_id as "workspace_id: Uuid",
user_id as "user_id: Uuid",
role as "role: crate::models::UserRole",
joined_at as "joined_at: chrono::DateTime<chrono::Utc>",
last_activity as "last_activity: chrono::DateTime<chrono::Utc>"
FROM workspace_members
WHERE user_id = ?
ORDER BY last_activity DESC
"#,
user.id
)
.fetch_all(&self.db)
.await
.map_err(|e| Error::Generic(format!("Failed to fetch memberships: {e}")))?;
let roles: Vec<String> = memberships.iter().map(|m| format!("{:?}", m.role)).collect();
let permissions: Vec<String> = memberships
.iter()
.flat_map(|m| {
crate::permissions::RolePermissions::get_permissions(m.role)
.into_iter()
.map(|p| format!("{p:?}"))
})
.collect();
let last_activity = memberships.iter().map(|m| m.last_activity).max();
let days_inactive = last_activity.map(|activity| {
let duration = Utc::now() - activity;
u64::try_from(duration.num_days()).unwrap_or(0)
});
let access_granted =
memberships.iter().map(|m| m.joined_at).min().unwrap_or(user.created_at);
user_access_list.push(UserAccessInfo {
user_id: user.id,
username: user.username,
email: user.email,
roles,
permissions,
last_login: last_activity, access_granted,
days_inactive,
is_active: user.is_active,
});
}
Ok(user_access_list)
}
async fn get_privileged_users(&self) -> Result<Vec<PrivilegedAccessInfo>, Error> {
let admin_members = sqlx::query_as!(
crate::models::WorkspaceMember,
r#"
SELECT
id as "id: Uuid",
workspace_id as "workspace_id: Uuid",
user_id as "user_id: Uuid",
role as "role: crate::models::UserRole",
joined_at as "joined_at: chrono::DateTime<chrono::Utc>",
last_activity as "last_activity: chrono::DateTime<chrono::Utc>"
FROM workspace_members
WHERE role = 'admin'
ORDER BY last_activity DESC
"#,
)
.fetch_all(&self.db)
.await
.map_err(|e| Error::Generic(format!("Failed to fetch privileged users: {e}")))?;
use std::collections::HashMap;
let mut user_roles: HashMap<Uuid, Vec<String>> = HashMap::new();
let mut user_activities: HashMap<Uuid, Vec<DateTime<Utc>>> = HashMap::new();
for member in &admin_members {
user_roles.entry(member.user_id).or_default().push(format!("{:?}", member.role));
user_activities.entry(member.user_id).or_default().push(member.last_activity);
}
let mut privileged_list = Vec::new();
for (user_id, roles) in user_roles {
let user = self
.user_service
.get_user(user_id)
.await
.map_err(|e| Error::Generic(format!("Failed to get user {user_id}: {e}")))?;
let activities = user_activities.get(&user_id).cloned().unwrap_or_default();
let last_activity = activities.iter().max().copied();
let mfa_enabled = if let Some(ref mfa_storage) = self.mfa_storage {
mfa_storage
.get_mfa_status(user_id)
.await
.ok()
.flatten()
.is_some_and(|s| s.enabled)
} else {
false
};
let (justification, justification_expires) =
if let Some(ref just_storage) = self.justification_storage {
just_storage
.get_justification(user_id)
.await
.ok()
.flatten()
.map_or((None, None), |j| (Some(j.justification), j.expires_at))
} else {
(None, None)
};
privileged_list.push(PrivilegedAccessInfo {
user_id,
username: user.username,
roles,
mfa_enabled,
justification,
justification_expires,
recent_actions_count: activities.len() as u64,
last_privileged_action: last_activity,
});
}
Ok(privileged_list)
}
async fn get_api_tokens(&self) -> Result<Vec<ApiTokenInfo>, Error> {
if let Some(ref storage) = self.token_storage {
storage.get_all_tokens().await
} else {
Ok(Vec::new())
}
}
async fn get_user(&self, user_id: Uuid) -> Result<Option<UserAccessInfo>, Error> {
let Ok(user) = self.user_service.get_user(user_id).await else {
return Ok(None);
};
let memberships = sqlx::query_as!(
crate::models::WorkspaceMember,
r#"
SELECT
id as "id: Uuid",
workspace_id as "workspace_id: Uuid",
user_id as "user_id: Uuid",
role as "role: crate::models::UserRole",
joined_at as "joined_at: chrono::DateTime<chrono::Utc>",
last_activity as "last_activity: chrono::DateTime<chrono::Utc>"
FROM workspace_members
WHERE user_id = ?
"#,
user_id
)
.fetch_all(&self.db)
.await
.map_err(|e| Error::Generic(format!("Failed to fetch memberships: {e}")))?;
let roles: Vec<String> = memberships.iter().map(|m| format!("{:?}", m.role)).collect();
let permissions: Vec<String> = memberships
.iter()
.flat_map(|m| match m.role {
crate::models::UserRole::Admin => vec![
"WorkspaceCreate".to_string(),
"WorkspaceRead".to_string(),
"WorkspaceUpdate".to_string(),
"WorkspaceDelete".to_string(),
"WorkspaceManageMembers".to_string(),
"MockCreate".to_string(),
"MockRead".to_string(),
"MockUpdate".to_string(),
"MockDelete".to_string(),
],
crate::models::UserRole::Editor => vec![
"MockCreate".to_string(),
"MockRead".to_string(),
"MockUpdate".to_string(),
"MockDelete".to_string(),
],
crate::models::UserRole::Viewer => vec!["MockRead".to_string()],
})
.collect();
let last_activity = memberships.iter().map(|m| m.last_activity).max();
let days_inactive = last_activity.map(|activity| {
let duration = Utc::now() - activity;
u64::try_from(duration.num_days()).unwrap_or(0)
});
let access_granted =
memberships.iter().map(|m| m.joined_at).min().unwrap_or(user.created_at);
Ok(Some(UserAccessInfo {
user_id: user.id,
username: user.username,
email: user.email,
roles,
permissions,
last_login: last_activity,
access_granted,
days_inactive,
is_active: user.is_active,
}))
}
async fn get_last_login(&self, user_id: Uuid) -> Result<Option<DateTime<Utc>>, Error> {
let result = sqlx::query!(
r#"
SELECT MAX(last_activity) as "last_activity: chrono::DateTime<chrono::Utc>"
FROM workspace_members
WHERE user_id = ?
"#,
user_id
)
.fetch_optional(&self.db)
.await
.map_err(|e| Error::Generic(format!("Failed to get last login: {e}")))?;
Ok(result.and_then(|r| r.last_activity))
}
async fn revoke_user_access(&self, user_id: Uuid, reason: String) -> Result<(), Error> {
self.user_service
.deactivate_user(user_id)
.await
.map_err(|e| Error::Generic(format!("Failed to revoke access: {e}")))?;
tracing::info!("Revoked access for user {}: {}", user_id, reason);
Ok(())
}
async fn update_user_permissions(
&self,
user_id: Uuid,
roles: Vec<String>,
permissions: Vec<String>,
) -> Result<(), Error> {
let memberships = sqlx::query_as!(
crate::models::WorkspaceMember,
r#"
SELECT
id as "id: Uuid",
workspace_id as "workspace_id: Uuid",
user_id as "user_id: Uuid",
role as "role: crate::models::UserRole",
joined_at as "joined_at: chrono::DateTime<chrono::Utc>",
last_activity as "last_activity: chrono::DateTime<chrono::Utc>"
FROM workspace_members
WHERE user_id = ?
"#,
user_id
)
.fetch_all(&self.db)
.await
.map_err(|e| Error::Generic(format!("Failed to fetch memberships: {e}")))?;
if memberships.is_empty() {
tracing::warn!("No workspace memberships found for user {}", user_id);
return Ok(());
}
let target_role = if roles.iter().any(|r| r.eq_ignore_ascii_case("admin")) {
crate::models::UserRole::Admin
} else if roles.iter().any(|r| r.eq_ignore_ascii_case("editor")) {
crate::models::UserRole::Editor
} else if roles.iter().any(|r| r.eq_ignore_ascii_case("viewer")) {
crate::models::UserRole::Viewer
} else {
tracing::warn!(
"No valid role found in provided roles: {:?}, keeping existing roles",
roles
);
return Ok(());
};
for membership in &memberships {
if membership.role == target_role {
continue;
}
sqlx::query(
r"
UPDATE workspace_members
SET role = ?
WHERE workspace_id = ? AND user_id = ?
",
)
.bind(target_role)
.bind(membership.workspace_id)
.bind(user_id)
.execute(&self.db)
.await
.map_err(|e| {
Error::Generic(format!(
"Failed to update role for workspace {}: {e}",
membership.workspace_id
))
})?;
tracing::info!(
"Updated user {} role to {:?} in workspace {}",
user_id,
target_role,
membership.workspace_id
);
}
tracing::info!(
"Updated permissions for user {}: roles={:?}, permissions={:?}",
user_id,
roles,
permissions
);
Ok(())
}
}