| use axum::{ |
| extract::{Path, State}, |
| http::StatusCode, |
| Json, |
| }; |
| use serde::{Deserialize, Serialize}; |
| use sqlx::Row; |
|
|
| use crate::{ |
| app_state::AppState, |
| jwt_auth::{self, AuthClaims}, |
| }; |
|
|
| #[derive(Debug, Serialize)] |
| pub struct AdminUserRecord { |
| pub user_id: String, |
| pub email: String, |
| pub username: String, |
| pub display_name: String, |
| pub role: String, |
| pub status: String, |
| pub created_at: String, |
| pub last_login_at: Option<String>, |
| } |
|
|
| #[derive(Debug, Serialize)] |
| pub struct AdminSessionRecord { |
| pub session_id: String, |
| pub user_id: String, |
| pub username: String, |
| pub display_name: String, |
| pub role: String, |
| pub ip_address: Option<String>, |
| pub user_agent: Option<String>, |
| pub created_at: String, |
| pub expires_at: String, |
| pub revoked_at: Option<String>, |
| } |
|
|
| #[derive(Debug, Serialize)] |
| pub struct CredentialAuditRecord { |
| pub id: String, |
| pub camera_id: String, |
| pub action: String, |
| pub actor_principal_type: Option<String>, |
| pub actor_principal_id: Option<String>, |
| pub encryption_key_id: Option<String>, |
| pub metadata: serde_json::Value, |
| pub created_at: String, |
| } |
|
|
| #[derive(Debug, Deserialize)] |
| pub struct CreateUserRequest { |
| pub email: String, |
| pub username: String, |
| pub display_name: String, |
| pub password: String, |
| pub role: String, |
| } |
|
|
| #[derive(Debug, Serialize)] |
| pub struct AdminUsersResponse { |
| pub summary: String, |
| pub users: Vec<AdminUserRecord>, |
| } |
|
|
| #[derive(Debug, Serialize)] |
| pub struct AdminSessionsResponse { |
| pub summary: String, |
| pub sessions: Vec<AdminSessionRecord>, |
| } |
|
|
| #[derive(Debug, Serialize)] |
| pub struct CredentialAuditResponse { |
| pub summary: String, |
| pub items: Vec<CredentialAuditRecord>, |
| } |
|
|
| pub async fn list_users( |
| State(state): State<AppState>, |
| claims: axum::extract::Extension<AuthClaims>, |
| ) -> Result<Json<AdminUsersResponse>, (StatusCode, Json<serde_json::Value>)> { |
| ensure_owner(&claims.0)?; |
| let pool = pool(&state)?; |
| let rows = sqlx::query( |
| r#" |
| SELECT id, email, username, display_name, role::text AS role, status::text AS status, |
| created_at, last_login_at |
| FROM users |
| WHERE deleted_at IS NULL |
| ORDER BY created_at DESC |
| "#, |
| ) |
| .fetch_all(pool) |
| .await |
| .map_err(internal_error)?; |
|
|
| let users = rows |
| .into_iter() |
| .map(|row| AdminUserRecord { |
| user_id: row.get::<uuid::Uuid, _>("id").to_string(), |
| email: row.get("email"), |
| username: row.get("username"), |
| display_name: row.get("display_name"), |
| role: row.get("role"), |
| status: row.get("status"), |
| created_at: row.get::<chrono::DateTime<chrono::Utc>, _>("created_at").to_rfc3339(), |
| last_login_at: row |
| .get::<Option<chrono::DateTime<chrono::Utc>>, _>("last_login_at") |
| .map(|value| value.to_rfc3339()), |
| }) |
| .collect::<Vec<_>>(); |
|
|
| Ok(Json(AdminUsersResponse { |
| summary: format!("Atrasti {} lietotāji.", users.len()), |
| users, |
| })) |
| } |
|
|
| pub async fn create_user( |
| State(state): State<AppState>, |
| claims: axum::extract::Extension<AuthClaims>, |
| Json(req): Json<CreateUserRequest>, |
| ) -> Result<Json<AdminUserRecord>, (StatusCode, Json<serde_json::Value>)> { |
| ensure_owner(&claims.0)?; |
| validate_created_role(&req.role)?; |
| let pool = pool(&state)?; |
| let password_hash = jwt_auth::hash_password(&req.password) |
| .map_err(|err| error(StatusCode::BAD_REQUEST, &err.to_string()))?; |
| let row = sqlx::query( |
| r#" |
| INSERT INTO users (email, username, display_name, password_hash, role) |
| VALUES ($1, $2, $3, $4, $5::user_role) |
| RETURNING id, email, username, display_name, role::text AS role, status::text AS status, created_at, last_login_at |
| "#, |
| ) |
| .bind(req.email.trim()) |
| .bind(req.username.trim()) |
| .bind(req.display_name.trim()) |
| .bind(password_hash) |
| .bind(req.role.trim()) |
| .fetch_one(pool) |
| .await |
| .map_err(internal_error)?; |
|
|
| Ok(Json(AdminUserRecord { |
| user_id: row.get::<uuid::Uuid, _>("id").to_string(), |
| email: row.get("email"), |
| username: row.get("username"), |
| display_name: row.get("display_name"), |
| role: row.get("role"), |
| status: row.get("status"), |
| created_at: row.get::<chrono::DateTime<chrono::Utc>, _>("created_at").to_rfc3339(), |
| last_login_at: row |
| .get::<Option<chrono::DateTime<chrono::Utc>>, _>("last_login_at") |
| .map(|value| value.to_rfc3339()), |
| })) |
| } |
|
|
| pub async fn list_sessions( |
| State(state): State<AppState>, |
| claims: axum::extract::Extension<AuthClaims>, |
| ) -> Result<Json<AdminSessionsResponse>, (StatusCode, Json<serde_json::Value>)> { |
| ensure_owner(&claims.0)?; |
| let pool = pool(&state)?; |
| let rows = sqlx::query( |
| r#" |
| SELECT auth_sessions.id, auth_sessions.user_id, auth_sessions.ip_address::TEXT AS ip_address, auth_sessions.user_agent, |
| auth_sessions.created_at, auth_sessions.expires_at, auth_sessions.revoked_at, |
| users.username, users.display_name, users.role::text AS role |
| FROM auth_sessions |
| JOIN users ON users.id = auth_sessions.user_id |
| WHERE users.deleted_at IS NULL |
| ORDER BY auth_sessions.created_at DESC |
| LIMIT 200 |
| "#, |
| ) |
| .fetch_all(pool) |
| .await |
| .map_err(internal_error)?; |
|
|
| let sessions = rows |
| .into_iter() |
| .map(|row| AdminSessionRecord { |
| session_id: row.get::<uuid::Uuid, _>("id").to_string(), |
| user_id: row.get::<uuid::Uuid, _>("user_id").to_string(), |
| username: row.get("username"), |
| display_name: row.get("display_name"), |
| role: row.get("role"), |
| ip_address: row.get("ip_address"), |
| user_agent: row.get("user_agent"), |
| created_at: row.get::<chrono::DateTime<chrono::Utc>, _>("created_at").to_rfc3339(), |
| expires_at: row.get::<chrono::DateTime<chrono::Utc>, _>("expires_at").to_rfc3339(), |
| revoked_at: row |
| .get::<Option<chrono::DateTime<chrono::Utc>>, _>("revoked_at") |
| .map(|value| value.to_rfc3339()), |
| }) |
| .collect::<Vec<_>>(); |
|
|
| Ok(Json(AdminSessionsResponse { |
| summary: format!("Atrastas {} sesijas.", sessions.len()), |
| sessions, |
| })) |
| } |
|
|
| pub async fn revoke_session( |
| State(state): State<AppState>, |
| claims: axum::extract::Extension<AuthClaims>, |
| Path(session_id): Path<String>, |
| ) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> { |
| ensure_owner(&claims.0)?; |
| let pool = pool(&state)?; |
| jwt_auth::revoke_session(pool, &session_id) |
| .await |
| .map_err(internal_error)?; |
| Ok(StatusCode::NO_CONTENT) |
| } |
|
|
| pub async fn list_credential_audit( |
| State(state): State<AppState>, |
| claims: axum::extract::Extension<AuthClaims>, |
| ) -> Result<Json<CredentialAuditResponse>, (StatusCode, Json<serde_json::Value>)> { |
| ensure_owner(&claims.0)?; |
| let pool = pool(&state)?; |
| let rows = sqlx::query( |
| r#" |
| SELECT id, camera_id, action, actor_principal_type, actor_principal_id, encryption_key_id, metadata_json, created_at |
| FROM vision_camera_credential_audit |
| ORDER BY created_at DESC |
| LIMIT 100 |
| "#, |
| ) |
| .fetch_all(pool) |
| .await |
| .map_err(internal_error)?; |
|
|
| let items = rows |
| .into_iter() |
| .map(|row| CredentialAuditRecord { |
| id: row.get::<uuid::Uuid, _>("id").to_string(), |
| camera_id: row.get("camera_id"), |
| action: row.get("action"), |
| actor_principal_type: row.get("actor_principal_type"), |
| actor_principal_id: row.get("actor_principal_id"), |
| encryption_key_id: row.get("encryption_key_id"), |
| metadata: row.get("metadata_json"), |
| created_at: row.get::<chrono::DateTime<chrono::Utc>, _>("created_at").to_rfc3339(), |
| }) |
| .collect::<Vec<_>>(); |
|
|
| Ok(Json(CredentialAuditResponse { |
| summary: format!("Atrasti {} credential audit ieraksti.", items.len()), |
| items, |
| })) |
| } |
|
|
| fn ensure_owner(claims: &AuthClaims) -> Result<(), (StatusCode, Json<serde_json::Value>)> { |
| if claims.role == "owner" && claims.owner { |
| return Ok(()); |
| } |
| Err(error( |
| StatusCode::FORBIDDEN, |
| "Admin vadība pieejama tikai konfigurētajam Maris īpašniekam", |
| )) |
| } |
|
|
| fn validate_created_role(role: &str) -> Result<(), (StatusCode, Json<serde_json::Value>)> { |
| match role.trim() { |
| "member" | "service" => Ok(()), |
| _ => Err(error( |
| StatusCode::BAD_REQUEST, |
| "Var izveidot tikai member vai service lietotājus. Vienīgais admin ir konfigurētais īpašnieks.", |
| )), |
| } |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use super::validate_created_role; |
|
|
| #[test] |
| fn rejects_privileged_roles_for_new_users() { |
| assert!(validate_created_role("member").is_ok()); |
| assert!(validate_created_role("service").is_ok()); |
| assert!(validate_created_role("admin").is_err()); |
| assert!(validate_created_role("owner").is_err()); |
| } |
| } |
|
|
| fn pool(state: &AppState) -> Result<&sqlx::PgPool, (StatusCode, Json<serde_json::Value>)> { |
| state |
| .postgres |
| .as_ref() |
| .ok_or_else(|| error(StatusCode::SERVICE_UNAVAILABLE, "PostgreSQL nav pieejams")) |
| } |
|
|
| fn internal_error(err: impl std::fmt::Display) -> (StatusCode, Json<serde_json::Value>) { |
| error(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string()) |
| } |
|
|
| fn error(status: StatusCode, message: &str) -> (StatusCode, Json<serde_json::Value>) { |
| (status, Json(serde_json::json!({ "error": message }))) |
| } |
|
|