diff --git a/.sqlx/query-3846628765bd34ea2ce01e15a0db732121ce473c5c542d0e971ad29b2b421193.json b/.sqlx/query-3846628765bd34ea2ce01e15a0db732121ce473c5c542d0e971ad29b2b421193.json new file mode 100644 index 0000000000..f5540107d6 --- /dev/null +++ b/.sqlx/query-3846628765bd34ea2ce01e15a0db732121ce473c5c542d0e971ad29b2b421193.json @@ -0,0 +1,146 @@ +{ + "db_name": "MySQL", + "query": "\nSELECT\n sal.id,\n sal.token as `token: StaffAuditLogToken`,\n sal.audit_action,\n sal.maybe_entity_type,\n sal.maybe_entity_token,\n target_user.username as maybe_target_username,\n target_user.display_name as maybe_target_display_name,\n sal.staff_user_token as `staff_user_token: UserToken`,\n staff_user.username as staff_username,\n staff_user.display_name as staff_display_name,\n sal.staff_ip_address,\n sal.created_at\nFROM staff_audit_logs sal\nLEFT JOIN users target_user ON target_user.token = sal.maybe_entity_token\nLEFT JOIN users staff_user ON staff_user.token = sal.staff_user_token\nORDER BY sal.id DESC\nLIMIT ?\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": { + "type": "LongLong", + "flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT", + "char_set": 63, + "max_size": 20 + } + }, + { + "ordinal": 1, + "name": "token: StaffAuditLogToken", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 2, + "name": "audit_action", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 3, + "name": "maybe_entity_type", + "type_info": { + "type": "VarString", + "flags": "MULTIPLE_KEY | BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 4, + "name": "maybe_entity_token", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 5, + "name": "maybe_target_username", + "type_info": { + "type": "VarString", + "flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 80 + } + }, + { + "ordinal": 6, + "name": "maybe_target_display_name", + "type_info": { + "type": "VarString", + "flags": "BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 80 + } + }, + { + "ordinal": 7, + "name": "staff_user_token: UserToken", + "type_info": { + "type": "VarString", + "flags": "MULTIPLE_KEY | BINARY", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 8, + "name": "staff_username", + "type_info": { + "type": "VarString", + "flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 80 + } + }, + { + "ordinal": 9, + "name": "staff_display_name", + "type_info": { + "type": "VarString", + "flags": "BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 80 + } + }, + { + "ordinal": 10, + "name": "staff_ip_address", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 160 + } + }, + { + "ordinal": 11, + "name": "created_at", + "type_info": { + "type": "Timestamp", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP", + "char_set": 63, + "max_size": 19 + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + true, + false, + true, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "3846628765bd34ea2ce01e15a0db732121ce473c5c542d0e971ad29b2b421193" +} diff --git a/.sqlx/query-9132f832752ea40736bedf4729c76af7fe019cc69ed5cc280fce87e68c3f4846.json b/.sqlx/query-9132f832752ea40736bedf4729c76af7fe019cc69ed5cc280fce87e68c3f4846.json new file mode 100644 index 0000000000..8d3507f04b --- /dev/null +++ b/.sqlx/query-9132f832752ea40736bedf4729c76af7fe019cc69ed5cc280fce87e68c3f4846.json @@ -0,0 +1,146 @@ +{ + "db_name": "MySQL", + "query": "\nSELECT\n sal.id,\n sal.token as `token: StaffAuditLogToken`,\n sal.audit_action,\n sal.maybe_entity_type,\n sal.maybe_entity_token,\n target_user.username as maybe_target_username,\n target_user.display_name as maybe_target_display_name,\n sal.staff_user_token as `staff_user_token: UserToken`,\n staff_user.username as staff_username,\n staff_user.display_name as staff_display_name,\n sal.staff_ip_address,\n sal.created_at\nFROM staff_audit_logs sal\nLEFT JOIN users target_user ON target_user.token = sal.maybe_entity_token\nLEFT JOIN users staff_user ON staff_user.token = sal.staff_user_token\nWHERE sal.id < ?\nORDER BY sal.id DESC\nLIMIT ?\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": { + "type": "LongLong", + "flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT", + "char_set": 63, + "max_size": 20 + } + }, + { + "ordinal": 1, + "name": "token: StaffAuditLogToken", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 2, + "name": "audit_action", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 3, + "name": "maybe_entity_type", + "type_info": { + "type": "VarString", + "flags": "MULTIPLE_KEY | BINARY", + "char_set": 224, + "max_size": 64 + } + }, + { + "ordinal": 4, + "name": "maybe_entity_token", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 5, + "name": "maybe_target_username", + "type_info": { + "type": "VarString", + "flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 80 + } + }, + { + "ordinal": 6, + "name": "maybe_target_display_name", + "type_info": { + "type": "VarString", + "flags": "BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 80 + } + }, + { + "ordinal": 7, + "name": "staff_user_token: UserToken", + "type_info": { + "type": "VarString", + "flags": "MULTIPLE_KEY | BINARY", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 8, + "name": "staff_username", + "type_info": { + "type": "VarString", + "flags": "UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 80 + } + }, + { + "ordinal": 9, + "name": "staff_display_name", + "type_info": { + "type": "VarString", + "flags": "BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 80 + } + }, + { + "ordinal": 10, + "name": "staff_ip_address", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 160 + } + }, + { + "ordinal": 11, + "name": "created_at", + "type_info": { + "type": "Timestamp", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP", + "char_set": 63, + "max_size": 19 + } + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + true, + false, + true, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "9132f832752ea40736bedf4729c76af7fe019cc69ed5cc280fce87e68c3f4846" +} diff --git a/crates/schema/database/mysql_queries/src/queries/staff_audit_logs/list_staff_audit_logs.rs b/crates/schema/database/mysql_queries/src/queries/staff_audit_logs/list_staff_audit_logs.rs new file mode 100644 index 0000000000..bd2a9fc61c --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/staff_audit_logs/list_staff_audit_logs.rs @@ -0,0 +1,96 @@ +use chrono::{DateTime, Utc}; +use sqlx::MySqlPool; + +use tokens::tokens::staff_audit_logs::StaffAuditLogToken; +use tokens::tokens::users::UserToken; + +pub struct StaffAuditLogListItem { + pub id: u64, + pub token: StaffAuditLogToken, + pub audit_action: String, + pub maybe_entity_type: Option, + pub maybe_entity_token: Option, + pub maybe_target_username: Option, + pub maybe_target_display_name: Option, + pub staff_user_token: Option, + pub staff_username: Option, + pub staff_display_name: Option, + pub staff_ip_address: String, + pub created_at: DateTime, +} + +pub struct ListStaffAuditLogsArgs<'a> { + pub maybe_cursor_id: Option, + pub limit: u32, + pub mysql_pool: &'a MySqlPool, +} + +pub async fn list_staff_audit_logs( + args: ListStaffAuditLogsArgs<'_>, +) -> Result, sqlx::Error> { + + let limit = args.limit as i64; + + let items = match args.maybe_cursor_id { + Some(cursor_id) => { + sqlx::query_as!( + StaffAuditLogListItem, + r#" +SELECT + sal.id, + sal.token as `token: StaffAuditLogToken`, + sal.audit_action, + sal.maybe_entity_type, + sal.maybe_entity_token, + target_user.username as maybe_target_username, + target_user.display_name as maybe_target_display_name, + sal.staff_user_token as `staff_user_token: UserToken`, + staff_user.username as staff_username, + staff_user.display_name as staff_display_name, + sal.staff_ip_address, + sal.created_at +FROM staff_audit_logs sal +LEFT JOIN users target_user ON target_user.token = sal.maybe_entity_token +LEFT JOIN users staff_user ON staff_user.token = sal.staff_user_token +WHERE sal.id < ? +ORDER BY sal.id DESC +LIMIT ? + "#, + cursor_id as u64, + limit, + ) + .fetch_all(args.mysql_pool) + .await? + } + None => { + sqlx::query_as!( + StaffAuditLogListItem, + r#" +SELECT + sal.id, + sal.token as `token: StaffAuditLogToken`, + sal.audit_action, + sal.maybe_entity_type, + sal.maybe_entity_token, + target_user.username as maybe_target_username, + target_user.display_name as maybe_target_display_name, + sal.staff_user_token as `staff_user_token: UserToken`, + staff_user.username as staff_username, + staff_user.display_name as staff_display_name, + sal.staff_ip_address, + sal.created_at +FROM staff_audit_logs sal +LEFT JOIN users target_user ON target_user.token = sal.maybe_entity_token +LEFT JOIN users staff_user ON staff_user.token = sal.staff_user_token +ORDER BY sal.id DESC +LIMIT ? + "#, + limit, + ) + .fetch_all(args.mysql_pool) + .await? + } + }; + + Ok(items) +} diff --git a/crates/schema/database/mysql_queries/src/queries/staff_audit_logs/mod.rs b/crates/schema/database/mysql_queries/src/queries/staff_audit_logs/mod.rs index ffe2649494..662b1cca95 100644 --- a/crates/schema/database/mysql_queries/src/queries/staff_audit_logs/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/staff_audit_logs/mod.rs @@ -1 +1,2 @@ pub mod insert_staff_audit_log; +pub mod list_staff_audit_logs; diff --git a/crates/service/web/storyteller_web/src/docs/api_doc.rs b/crates/service/web/storyteller_web/src/docs/api_doc.rs index 444a6d5304..5923fd3c74 100644 --- a/crates/service/web/storyteller_web/src/docs/api_doc.rs +++ b/crates/service/web/storyteller_web/src/docs/api_doc.rs @@ -86,6 +86,7 @@ use crate::http_server::endpoints::media_files::upsert_upload::write_error::Medi use crate::http_server::endpoints::media_files::upsert_upload::write_scene_file::write_scene_file_media_file_handler::*; use crate::http_server::endpoints::model_download::enqueue_gptsovits_model_download_handler::*; use crate::http_server::endpoints::moderation::user_feature_flags::edit_user_feature_flags_handler::*; +use crate::http_server::endpoints::moderation::staff_audit_logs::moderator_list_staff_audit_logs_handler::*; use crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_for_user_handler::*; use crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_handler::*; use crate::http_server::endpoints::moderation::user_sessions::moderator_user_session_impersonation_request_handler::*; @@ -510,6 +511,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux crate::http_server::endpoints::moderation::wallets::moderator_add_banked_balance_to_wallet_handler::moderator_add_banked_balance_to_wallet_handler, crate::http_server::endpoints::moderation::wallets::moderator_create_wallet_for_user_handler::moderator_create_wallet_for_user_handler, crate::http_server::endpoints::moderation::wallets::moderator_get_wallet_handler::moderator_get_wallet_handler, + crate::http_server::endpoints::moderation::staff_audit_logs::moderator_list_staff_audit_logs_handler::moderator_list_staff_audit_logs_handler, crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_for_user_handler::moderator_list_user_session_impersonation_requests_for_user_handler, crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_handler::moderator_list_user_session_impersonation_requests_handler, crate::http_server::endpoints::moderation::user_sessions::moderator_user_session_impersonation_request_handler::moderator_user_session_impersonation_request_handler, @@ -895,6 +897,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux ListAllImpersonationRequestsQueryParams, ListAllImpersonationRequestsSuccessResponse, ListImpersonationRequestsPathInfo, + ListStaffAuditLogsQueryParams, + ListStaffAuditLogsSuccessResponse, ListImpersonationRequestsQueryParams, ListUserImpersonationRequestsSuccessResponse, ListMediaFilesByBatchPathInfo, @@ -960,6 +964,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux RenameMediaFileRequest, RequestDetailsResponse, ResultDetailsResponse, + StaffAuditLogResponse, SearchFeaturedMediaFileListItem, SearchFeaturedMediaFilesError, SearchFeaturedMediaFilesQueryParams, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/mod.rs index 7cac116f84..beafc6be82 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/mod.rs @@ -2,6 +2,7 @@ pub mod alerts; pub mod info; pub mod ip_bans; pub mod jobs; +pub mod staff_audit_logs; pub mod user; pub mod user_bans; pub mod user_feature_flags; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/staff_audit_logs/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/staff_audit_logs/mod.rs new file mode 100644 index 0000000000..fa0357625c --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/staff_audit_logs/mod.rs @@ -0,0 +1 @@ +pub mod moderator_list_staff_audit_logs_handler; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/staff_audit_logs/moderator_list_staff_audit_logs_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/staff_audit_logs/moderator_list_staff_audit_logs_handler.rs new file mode 100644 index 0000000000..bc1ab58f42 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/staff_audit_logs/moderator_list_staff_audit_logs_handler.rs @@ -0,0 +1,146 @@ +use std::sync::Arc; + +use actix_web::web::{Json, Query}; +use actix_web::{web, HttpRequest}; +use chrono::{DateTime, Utc}; +use log::warn; +use utoipa::{IntoParams, ToSchema}; + +use mysql_queries::queries::staff_audit_logs::list_staff_audit_logs::{ + list_staff_audit_logs, ListStaffAuditLogsArgs, +}; +use tokens::tokens::staff_audit_logs::StaffAuditLogToken; +use tokens::tokens::users::UserToken; + +use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError; +use crate::http_server::web_utils::user_session::require_moderator::{ + require_moderator, UseDatabase, +}; +use crate::state::server_state::ServerState; + +const CURSOR_NAME: &str = "modstfaud"; +const DEFAULT_LIMIT: u32 = 50; +const MAX_LIMIT: u32 = 1000; + +// --- Request --- + +#[derive(Deserialize, ToSchema, IntoParams)] +pub struct ListStaffAuditLogsQueryParams { + pub cursor: Option, + pub limit: Option, +} + +// --- Response --- + +#[derive(Serialize, ToSchema)] +pub struct ListStaffAuditLogsSuccessResponse { + pub success: bool, + pub audit_logs: Vec, + pub maybe_cursor: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct StaffAuditLogResponse { + pub token: StaffAuditLogToken, + pub audit_action: String, + pub maybe_entity_type: Option, + pub maybe_entity_token: Option, + pub maybe_target_username: Option, + pub maybe_target_display_name: Option, + pub staff_user_token: Option, + pub staff_username: Option, + pub staff_display_name: Option, + pub staff_ip_address: String, + pub created_at: DateTime, +} + +// --- Handler --- + +/// List all staff audit logs. Moderators only. +#[utoipa::path( + get, + tag = "Moderation", + path = "/v1/moderation/staff_audit_logs/list", + params( + ListStaffAuditLogsQueryParams, + ), + responses( + (status = 200, description = "Success", body = ListStaffAuditLogsSuccessResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Server error"), + ), +)] +pub async fn moderator_list_staff_audit_logs_handler( + http_request: HttpRequest, + query: Query, + server_state: web::Data>, +) -> Result, AdvancedCommonWebError> { + + let _user_session = require_moderator( + &http_request, + &server_state, + UseDatabase::GrabNewConnection, + ).await.map_err(|err| { + warn!("Moderator check failed: {:?}", err); + AdvancedCommonWebError::NotAuthorized + })?; + + let limit = query.limit + .unwrap_or(DEFAULT_LIMIT) + .min(MAX_LIMIT); + + let maybe_cursor_id = match &query.cursor { + None => None, + Some(cursor_str) => { + let decoded = server_state.opaque_cursors + .decode_cursor_expecting_name(CURSOR_NAME, cursor_str) + .map_err(|err| { + warn!("Failed to decode cursor: {:?}", err); + AdvancedCommonWebError::BadInputWithSimpleMessage( + "Invalid cursor".to_string()) + })?; + decoded.last_id + } + }; + + let records = list_staff_audit_logs( + ListStaffAuditLogsArgs { + maybe_cursor_id, + limit, + mysql_pool: &server_state.mysql_pool, + }, + ).await.map_err(|err| { + warn!("Failed to list staff audit logs: {:?}", err); + AdvancedCommonWebError::from_error(err) + })?; + + let maybe_cursor = records.last().map(|last| { + server_state.opaque_cursors + .encode_last_id_cursor(CURSOR_NAME, last.id) + }).transpose().map_err(|err| { + warn!("Failed to encode cursor: {:?}", err); + AdvancedCommonWebError::server_error_with_message("Failed to encode cursor") + })?; + + let audit_logs = records.into_iter().map(|r| { + StaffAuditLogResponse { + token: r.token, + audit_action: r.audit_action, + maybe_entity_type: r.maybe_entity_type, + maybe_entity_token: r.maybe_entity_token, + maybe_target_username: r.maybe_target_username, + maybe_target_display_name: r.maybe_target_display_name, + staff_user_token: r.staff_user_token, + staff_username: r.staff_username, + staff_display_name: r.staff_display_name, + staff_ip_address: r.staff_ip_address, + created_at: r.created_at, + } + }).collect(); + + Ok(Json(ListStaffAuditLogsSuccessResponse { + success: true, + audit_logs, + maybe_cursor, + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/moderation_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/moderation_routes.rs index 99a411ed91..d83a7132af 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/moderation_routes.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/moderation_routes.rs @@ -35,6 +35,7 @@ use crate::http_server::endpoints::moderation::user::moderator_list_users_by_sig use crate::http_server::endpoints::moderation::user::moderator_user_lookup_by_stripe_customer_id_handler::moderator_user_lookup_by_stripe_customer_id_handler; use crate::http_server::endpoints::moderation::user::moderator_user_lookup_handler::moderator_user_lookup_handler; use crate::http_server::endpoints::moderation::alerts::moderation_send_alert_handler::moderation_send_alert_handler; +use crate::http_server::endpoints::moderation::staff_audit_logs::moderator_list_staff_audit_logs_handler::moderator_list_staff_audit_logs_handler; use crate::http_server::endpoints::moderation::user_feature_flags::edit_user_feature_flags_handler::edit_user_feature_flags_handler; use crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_for_user_handler::moderator_list_user_session_impersonation_requests_for_user_handler; use crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_handler::moderator_list_user_session_impersonation_requests_handler; @@ -106,6 +107,12 @@ pub fn add_moderator_routes (app: App) -> App .route(web::head().to(|| HttpResponse::Ok())) ) ) + .service(web::scope("/staff_audit_logs") + .service(web::resource("/list") + .route(web::get().to(moderator_list_staff_audit_logs_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) + ) .service(web::scope("/user_sessions") .service(web::resource("/impersonate") .route(web::post().to(moderator_user_session_impersonation_request_handler))