diff --git a/.claude/rules/code-style.md b/.claude/rules/code-style.md index 4e1b5f8b48..2afc76a48b 100644 --- a/.claude/rules/code-style.md +++ b/.claude/rules/code-style.md @@ -6,7 +6,8 @@ - No minimum supported Rust version; use latest stable features freely - `#[macro_use] extern crate serde_derive;` is used in binary crates; library crates use `use serde_derive::{Serialize, Deserialize};` - Never use `println!` or `eprintln!` outside of tests; use `log` crate macros (`info!`, `warn!`, `error!`, `debug!`, `trace!`) instead -- Use `anyhow::Result` / `AnyhowResult` for fallible functions in application code +- Use `anyhow::Result` / `AnyhowResult` for fallible functions in application code; new database queries should use `Result` instead +- SQLx queries: prefer `sqlx::query!` and `sqlx::query_as!` compile-time checked macros over runtime `sqlx::query()` / `sqlx::query_as()`. The macros catch SQL typos, column mismatches, and type errors at build time. Use runtime queries only when the table isn't in the offline cache yet. - Enum Display/Debug: use `impl_enum_display_and_debug_using_to_str!` macro, not `derive_more::Display` (CLion doesn't understand it) - Fields that are optional use the `maybe_` prefix: `maybe_creator_user_token`, `maybe_prompt_token` - When implementing `fmt::Display` for error types, use the pattern: `write!(f, "{:?}", self)` diff --git a/.sqlx/query-9dc83df0b7f1cf2716352e947b6b7fcb6c455fb0c12d72e584612f35366831f4.json b/.sqlx/query-9dc83df0b7f1cf2716352e947b6b7fcb6c455fb0c12d72e584612f35366831f4.json new file mode 100644 index 0000000000..e418b32e9e --- /dev/null +++ b/.sqlx/query-9dc83df0b7f1cf2716352e947b6b7fcb6c455fb0c12d72e584612f35366831f4.json @@ -0,0 +1,80 @@ +{ + "db_name": "MySQL", + "query": "\nSELECT\n token,\n impersonated_user_token as `impersonated_user_token: UserToken`,\n impersonator_user_token as `impersonator_user_token: UserToken`,\n user_impersonation_token,\n is_redeemed as `is_redeemed: bool`,\n expires_at\nFROM user_impersonation_requests\nWHERE user_impersonation_token = ?\nLIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 1, + "name": "impersonated_user_token: UserToken", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 2, + "name": "impersonator_user_token: UserToken", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 3, + "name": "user_impersonation_token", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 4, + "name": "is_redeemed: bool", + "type_info": { + "type": "Tiny", + "flags": "NOT_NULL", + "char_set": 63, + "max_size": 1 + } + }, + { + "ordinal": 5, + "name": "expires_at", + "type_info": { + "type": "Timestamp", + "flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE", + "char_set": 63, + "max_size": 19 + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "9dc83df0b7f1cf2716352e947b6b7fcb6c455fb0c12d72e584612f35366831f4" +} diff --git a/.sqlx/query-ba5b0a542d4b95b781077b8895529d222dd682eb28c9d00031a820ec6f37e095.json b/.sqlx/query-ba5b0a542d4b95b781077b8895529d222dd682eb28c9d00031a820ec6f37e095.json new file mode 100644 index 0000000000..cb253b66d0 --- /dev/null +++ b/.sqlx/query-ba5b0a542d4b95b781077b8895529d222dd682eb28c9d00031a820ec6f37e095.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "\nINSERT INTO user_sessions (\n token,\n user_token,\n maybe_impersonation_user_token,\n ip_address_creation,\n expires_at\n)\nVALUES (?, ?, ?, ?, ?)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "ba5b0a542d4b95b781077b8895529d222dd682eb28c9d00031a820ec6f37e095" +} diff --git a/.sqlx/query-c39800423a5fe0aa324e117e624015f85d039c3ca6833cf6865471345ab0768e.json b/.sqlx/query-c39800423a5fe0aa324e117e624015f85d039c3ca6833cf6865471345ab0768e.json new file mode 100644 index 0000000000..f41cc27f30 --- /dev/null +++ b/.sqlx/query-c39800423a5fe0aa324e117e624015f85d039c3ca6833cf6865471345ab0768e.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "\nINSERT INTO staff_audit_logs\nSET\n token = ?,\n audit_action = ?,\n maybe_entity_type = ?,\n maybe_entity_token = ?,\n staff_user_token = ?,\n staff_ip_address = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "c39800423a5fe0aa324e117e624015f85d039c3ca6833cf6865471345ab0768e" +} diff --git a/.sqlx/query-cc782c807dadcaaaad08f469935f61dc2e9f3217aa01532d6602b8e37cedf92d.json b/.sqlx/query-cc782c807dadcaaaad08f469935f61dc2e9f3217aa01532d6602b8e37cedf92d.json new file mode 100644 index 0000000000..ef3e6664cc --- /dev/null +++ b/.sqlx/query-cc782c807dadcaaaad08f469935f61dc2e9f3217aa01532d6602b8e37cedf92d.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "\nINSERT INTO user_impersonation_requests\nSET\n token = ?,\n impersonated_user_token = ?,\n impersonator_user_token = ?,\n user_impersonation_token = ?,\n ip_address_creation = ?,\n expires_at = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "cc782c807dadcaaaad08f469935f61dc2e9f3217aa01532d6602b8e37cedf92d" +} diff --git a/.sqlx/query-cf4be26ce3a48c7108640faa6c9fc68a48136327a9d374c52c16848c006f0984.json b/.sqlx/query-cf4be26ce3a48c7108640faa6c9fc68a48136327a9d374c52c16848c006f0984.json new file mode 100644 index 0000000000..f9e993bb50 --- /dev/null +++ b/.sqlx/query-cf4be26ce3a48c7108640faa6c9fc68a48136327a9d374c52c16848c006f0984.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "\nUPDATE user_impersonation_requests\nSET\n is_redeemed = TRUE,\n ip_address_redemption = ?\nWHERE user_impersonation_token = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "cf4be26ce3a48c7108640faa6c9fc68a48136327a9d374c52c16848c006f0984" +} diff --git a/CLAUDE.md b/CLAUDE.md index 3c05924808..8b91a4cb30 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,7 @@ artcraft/ - Rust with no minimum supported version - Actix-web for HTTP services -- SQLx for MySQL and SQLite +- SQLx for MySQL and SQLite; prefer `sqlx::query!` / `sqlx::query_as!` compile-time checked macros over runtime `sqlx::query()` whenever possible - A mix of wreq and reqwest for Rust HTTP clients - Never use `println!` or `eprintln!` outside of tests; use `log` crate macros instead - When two crates export the same type name, alias imports with a suffix: `use foo::Bar as BarFoo;` diff --git a/_database/sql/migrations/2026-04-13-224821-0000_staff_audit_logs/down.sql b/_database/sql/migrations/2026-04-13-224821-0000_staff_audit_logs/down.sql new file mode 100644 index 0000000000..bb527f6d6e --- /dev/null +++ b/_database/sql/migrations/2026-04-13-224821-0000_staff_audit_logs/down.sql @@ -0,0 +1,5 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +DROP TABLE IF EXISTS staff_audit_logs; diff --git a/_database/sql/migrations/2026-04-13-224821-0000_staff_audit_logs/up.sql b/_database/sql/migrations/2026-04-13-224821-0000_staff_audit_logs/up.sql new file mode 100644 index 0000000000..cc55e5823d --- /dev/null +++ b/_database/sql/migrations/2026-04-13-224821-0000_staff_audit_logs/up.sql @@ -0,0 +1,39 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +CREATE TABLE staff_audit_logs ( + + -- Not used for anything except replication. + id BIGINT(20) NOT NULL AUTO_INCREMENT, + + -- Effective "primary key" of the audit log item (in case we add a UI or notes) + token VARCHAR(32) NOT NULL, + + -- The type of action taken + audit_action VARCHAR(32) NOT NULL, + + -- The type of entity acted upon (if any) + maybe_entity_type VARCHAR(16) DEFAULT NULL, + + -- The token of the entity mutated (if any) + maybe_entity_token VARCHAR(32) NOT NULL, + + -- The staff user who performed the action. + staff_user_token VARCHAR(32) DEFAULT NULL, + + -- For abuse tracking. + -- Wide enough for IPv4/6 + staff_ip_address VARCHAR(40) NOT NULL, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- INDICES -- + PRIMARY KEY (id), + UNIQUE KEY (token), + KEY index_maybe_entity_type (maybe_entity_type), + KEY index_maybe_entity_token (maybe_entity_token), + KEY index_staff_user_token (staff_user_token), + KEY index_created_at (created_at) + +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; diff --git a/_database/sql/migrations/2026-04-13-230305-0000_alter_user_sessions_impersonation/down.sql b/_database/sql/migrations/2026-04-13-230305-0000_alter_user_sessions_impersonation/down.sql new file mode 100644 index 0000000000..d1928f8746 --- /dev/null +++ b/_database/sql/migrations/2026-04-13-230305-0000_alter_user_sessions_impersonation/down.sql @@ -0,0 +1,6 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +ALTER TABLE user_sessions + DROP COLUMN maybe_impersonation_user_token; diff --git a/_database/sql/migrations/2026-04-13-230305-0000_alter_user_sessions_impersonation/up.sql b/_database/sql/migrations/2026-04-13-230305-0000_alter_user_sessions_impersonation/up.sql new file mode 100644 index 0000000000..e7d9b57160 --- /dev/null +++ b/_database/sql/migrations/2026-04-13-230305-0000_alter_user_sessions_impersonation/up.sql @@ -0,0 +1,7 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +ALTER TABLE user_sessions + ADD COLUMN maybe_impersonation_user_token VARCHAR(32) DEFAULT NULL + AFTER user_token; diff --git a/_database/sql/migrations/2026-04-13-231638-0000_user_impersonation_requests/down.sql b/_database/sql/migrations/2026-04-13-231638-0000_user_impersonation_requests/down.sql new file mode 100644 index 0000000000..3df7a18d86 --- /dev/null +++ b/_database/sql/migrations/2026-04-13-231638-0000_user_impersonation_requests/down.sql @@ -0,0 +1,5 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +DROP TABLE IF EXISTS user_impersonation_requests; diff --git a/_database/sql/migrations/2026-04-13-231638-0000_user_impersonation_requests/up.sql b/_database/sql/migrations/2026-04-13-231638-0000_user_impersonation_requests/up.sql new file mode 100644 index 0000000000..bd8dbc4958 --- /dev/null +++ b/_database/sql/migrations/2026-04-13-231638-0000_user_impersonation_requests/up.sql @@ -0,0 +1,44 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +CREATE TABLE user_impersonation_requests ( + + -- Not used for anything except replication. + id BIGINT(20) NOT NULL AUTO_INCREMENT, + + -- Internal "primary key" + token VARCHAR(32) NOT NULL, + + -- The user being impersonated. + impersonated_user_token VARCHAR(32) NOT NULL, + + -- The staff user who initiated the impersonation. + impersonator_user_token VARCHAR(32) NOT NULL, + + -- The actual secret that must be presented to redeem the impersonation. + user_impersonation_token VARCHAR(32) NOT NULL, + + -- Whether this request has been redeemed (used to create a session). + is_redeemed BOOLEAN NOT NULL DEFAULT FALSE, + + -- IP address of the staff user who created the request. + ip_address_creation VARCHAR(40) NOT NULL, + + -- IP address from which the request was redeemed, if redeemed. + ip_address_redemption VARCHAR(40) DEFAULT NULL, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + -- The request expires after this time if not redeemed. + expires_at TIMESTAMP NOT NULL, + + -- INDICES -- + PRIMARY KEY (id), + UNIQUE KEY (token), + UNIQUE KEY (user_impersonation_token), + KEY index_impersonated_user_token (impersonated_user_token), + KEY index_impersonator_user_token (impersonator_user_token) + +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; diff --git a/_database/sql/migrations_squashed/staff_audit_logs.sql b/_database/sql/migrations_squashed/staff_audit_logs.sql new file mode 100644 index 0000000000..cc55e5823d --- /dev/null +++ b/_database/sql/migrations_squashed/staff_audit_logs.sql @@ -0,0 +1,39 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +CREATE TABLE staff_audit_logs ( + + -- Not used for anything except replication. + id BIGINT(20) NOT NULL AUTO_INCREMENT, + + -- Effective "primary key" of the audit log item (in case we add a UI or notes) + token VARCHAR(32) NOT NULL, + + -- The type of action taken + audit_action VARCHAR(32) NOT NULL, + + -- The type of entity acted upon (if any) + maybe_entity_type VARCHAR(16) DEFAULT NULL, + + -- The token of the entity mutated (if any) + maybe_entity_token VARCHAR(32) NOT NULL, + + -- The staff user who performed the action. + staff_user_token VARCHAR(32) DEFAULT NULL, + + -- For abuse tracking. + -- Wide enough for IPv4/6 + staff_ip_address VARCHAR(40) NOT NULL, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- INDICES -- + PRIMARY KEY (id), + UNIQUE KEY (token), + KEY index_maybe_entity_type (maybe_entity_type), + KEY index_maybe_entity_token (maybe_entity_token), + KEY index_staff_user_token (staff_user_token), + KEY index_created_at (created_at) + +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; diff --git a/_database/sql/migrations_squashed/user_tables/user_impersonation_requests.sql b/_database/sql/migrations_squashed/user_tables/user_impersonation_requests.sql new file mode 100644 index 0000000000..bd8dbc4958 --- /dev/null +++ b/_database/sql/migrations_squashed/user_tables/user_impersonation_requests.sql @@ -0,0 +1,44 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +CREATE TABLE user_impersonation_requests ( + + -- Not used for anything except replication. + id BIGINT(20) NOT NULL AUTO_INCREMENT, + + -- Internal "primary key" + token VARCHAR(32) NOT NULL, + + -- The user being impersonated. + impersonated_user_token VARCHAR(32) NOT NULL, + + -- The staff user who initiated the impersonation. + impersonator_user_token VARCHAR(32) NOT NULL, + + -- The actual secret that must be presented to redeem the impersonation. + user_impersonation_token VARCHAR(32) NOT NULL, + + -- Whether this request has been redeemed (used to create a session). + is_redeemed BOOLEAN NOT NULL DEFAULT FALSE, + + -- IP address of the staff user who created the request. + ip_address_creation VARCHAR(40) NOT NULL, + + -- IP address from which the request was redeemed, if redeemed. + ip_address_redemption VARCHAR(40) DEFAULT NULL, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + -- The request expires after this time if not redeemed. + expires_at TIMESTAMP NOT NULL, + + -- INDICES -- + PRIMARY KEY (id), + UNIQUE KEY (token), + UNIQUE KEY (user_impersonation_token), + KEY index_impersonated_user_token (impersonated_user_token), + KEY index_impersonator_user_token (impersonator_user_token) + +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; diff --git a/_database/sql/migrations_squashed/user_tables/user_sessions.sql b/_database/sql/migrations_squashed/user_tables/user_sessions.sql new file mode 100644 index 0000000000..7aabe7ca52 --- /dev/null +++ b/_database/sql/migrations_squashed/user_tables/user_sessions.sql @@ -0,0 +1,50 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +CREATE TABLE user_sessions ( + -- Not used for anything except replication. + id BIGINT(20) NOT NULL AUTO_INCREMENT, + + -- Session primary key + token VARCHAR(32) NOT NULL, + + -- The user that the session belongs to. + user_token VARCHAR(32) NOT NULL, + + -- NEVER IMPERSONATE A USER'S ACCOUNT WITHOUT THEIR CONSENT! + -- + -- For support requests and inquiries, we can have staff log into user accounts + -- to verify and debug possible broken states. The user is always told about + -- this if we are to use this for support cases. + -- + -- Every action performed by impersonated sessions is logged for auditing. + -- + -- If this session is impersonating another user, this is the token of + -- the staff user who initiated the impersonation. + maybe_impersonation_user_token VARCHAR(32) DEFAULT NULL, + + -- Track each session's creation IP. + -- Wide enough for IPv4/6 + ip_address_creation VARCHAR(40) NOT NULL, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + -- Session termination time. + -- This must be set by the server code, or the session is invalid + expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- deletion = session termination + -- Typically these are destroyed by users, but if in the future we allow mods to + -- delete them, it doesn't really matter who did the deletion: sessions are not + -- designed to be recoverable. + deleted_at TIMESTAMP NULL, + + -- INDICES -- + PRIMARY KEY (id), + UNIQUE KEY (token), + KEY fk_user_token (user_token), + KEY index_ip_address_creation (ip_address_creation) + +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; diff --git a/_tools/postman/collections/ArtCraft API/moderation/Impersonate User Request.request.yaml b/_tools/postman/collections/ArtCraft API/moderation/Impersonate User Request.request.yaml new file mode 100644 index 0000000000..841a3df91c --- /dev/null +++ b/_tools/postman/collections/ArtCraft API/moderation/Impersonate User Request.request.yaml @@ -0,0 +1,11 @@ +$kind: http-request +name: Impersonate User Request +url: "{{api_host}}/v1/moderation/user_sessions/impersonate" +method: POST +body: + type: json + content: |- + { + "username": "newuser3" + } +order: 4318829221021218 diff --git a/crates/schema/database/mysql_queries/src/queries/mod.rs b/crates/schema/database/mysql_queries/src/queries/mod.rs index 1d30e9f6c6..314cf56020 100644 --- a/crates/schema/database/mysql_queries/src/queries/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/mod.rs @@ -27,12 +27,14 @@ pub mod model_weights; pub mod prompt_context_items; pub mod prompts; pub mod public_event_feed; +pub mod staff_audit_logs; pub mod stats; pub mod tags; pub mod trending_model_analytics; pub mod tts; pub mod twitch; pub mod unsubscribe_reason; +pub mod user_impersonation_requests; pub mod users; pub mod vocoder; pub mod voice_clone_requests; diff --git a/crates/schema/database/mysql_queries/src/queries/staff_audit_logs/insert_staff_audit_log.rs b/crates/schema/database/mysql_queries/src/queries/staff_audit_logs/insert_staff_audit_log.rs new file mode 100644 index 0000000000..0021ff3b17 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/staff_audit_logs/insert_staff_audit_log.rs @@ -0,0 +1,54 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; + +use enums::by_table::staff_audit_logs::staff_audit_action::StaffAuditAction; +use enums::by_table::staff_audit_logs::staff_audit_entity_type::StaffAuditEntityType; +use tokens::tokens::staff_audit_logs::StaffAuditLogToken; +use tokens::tokens::users::UserToken; + +pub struct InsertStaffAuditLogArgs<'a, 'c: 'a, E> + where E: 'a + Executor<'c, Database = MySql> +{ + pub audit_action: StaffAuditAction, + + pub maybe_entity_type: Option, + pub maybe_entity_token: Option<&'a str>, + + pub staff_user_token: &'a UserToken, + pub actor_ip_address: &'a str, + + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +pub async fn insert_staff_audit_log<'a, 'c, E>( + args: InsertStaffAuditLogArgs<'a, 'c, E>, +) -> Result + where E: 'a + Executor<'c, Database = MySql> +{ + let token = StaffAuditLogToken::generate(); + + sqlx::query!( + r#" +INSERT INTO staff_audit_logs +SET + token = ?, + audit_action = ?, + maybe_entity_type = ?, + maybe_entity_token = ?, + staff_user_token = ?, + staff_ip_address = ? + "#, + token.as_str(), + args.audit_action.to_str(), + args.maybe_entity_type.map(|ty| ty.to_str()), + args.maybe_entity_token, + args.staff_user_token.as_str(), + args.actor_ip_address, + ) + .execute(args.mysql_executor) + .await?; + + Ok(token) +} 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 new file mode 100644 index 0000000000..ffe2649494 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/staff_audit_logs/mod.rs @@ -0,0 +1 @@ +pub mod insert_staff_audit_log; diff --git a/crates/schema/database/mysql_queries/src/queries/user_impersonation_requests/insert_user_impersonation_request.rs b/crates/schema/database/mysql_queries/src/queries/user_impersonation_requests/insert_user_impersonation_request.rs new file mode 100644 index 0000000000..284a68bd81 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/user_impersonation_requests/insert_user_impersonation_request.rs @@ -0,0 +1,51 @@ +use std::marker::PhantomData; + +use chrono::{DateTime, Utc}; +use sqlx::{Executor, MySql}; + +use tokens::tokens::user_impersonation_requests::UserImpersonationRequestToken; +use tokens::tokens::users::UserToken; + +pub struct InsertUserImpersonationRequestArgs<'a, 'c: 'a, E> + where E: 'a + Executor<'c, Database = MySql> +{ + pub impersonated_user_token: &'a UserToken, + pub impersonator_user_token: &'a UserToken, + pub user_impersonation_token: &'a str, + pub ip_address_creation: &'a str, + pub expires_at: DateTime, + + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +pub async fn insert_user_impersonation_request<'a, 'c, E>( + args: InsertUserImpersonationRequestArgs<'a, 'c, E>, +) -> Result + where E: 'a + Executor<'c, Database = MySql> +{ + let token = UserImpersonationRequestToken::generate(); + + sqlx::query!( + r#" +INSERT INTO user_impersonation_requests +SET + token = ?, + impersonated_user_token = ?, + impersonator_user_token = ?, + user_impersonation_token = ?, + ip_address_creation = ?, + expires_at = ? + "#, + token.as_str(), + args.impersonated_user_token.as_str(), + args.impersonator_user_token.as_str(), + args.user_impersonation_token, + args.ip_address_creation, + args.expires_at, + ) + .execute(args.mysql_executor) + .await?; + + Ok(token) +} diff --git a/crates/schema/database/mysql_queries/src/queries/user_impersonation_requests/lookup_user_impersonation_request.rs b/crates/schema/database/mysql_queries/src/queries/user_impersonation_requests/lookup_user_impersonation_request.rs new file mode 100644 index 0000000000..a40c4d63de --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/user_impersonation_requests/lookup_user_impersonation_request.rs @@ -0,0 +1,40 @@ +use chrono::{DateTime, Utc}; +use sqlx::MySqlPool; + +use tokens::tokens::users::UserToken; + +pub struct UserImpersonationRequestRecord { + pub token: String, + pub impersonated_user_token: UserToken, + pub impersonator_user_token: UserToken, + pub user_impersonation_token: String, + pub is_redeemed: bool, + pub expires_at: DateTime, +} + +/// Look up a user impersonation request by the public impersonation token (the "password"). +pub async fn lookup_user_impersonation_request( + user_impersonation_token: &str, + mysql_pool: &MySqlPool, +) -> Result, sqlx::Error> { + let maybe_record = sqlx::query_as!( + UserImpersonationRequestRecord, + r#" +SELECT + token, + impersonated_user_token as `impersonated_user_token: UserToken`, + impersonator_user_token as `impersonator_user_token: UserToken`, + user_impersonation_token, + is_redeemed as `is_redeemed: bool`, + expires_at +FROM user_impersonation_requests +WHERE user_impersonation_token = ? +LIMIT 1 + "#, + user_impersonation_token, + ) + .fetch_optional(mysql_pool) + .await?; + + Ok(maybe_record) +} diff --git a/crates/schema/database/mysql_queries/src/queries/user_impersonation_requests/mark_impersonation_token_as_redeemed.rs b/crates/schema/database/mysql_queries/src/queries/user_impersonation_requests/mark_impersonation_token_as_redeemed.rs new file mode 100644 index 0000000000..dd92f21dfe --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/user_impersonation_requests/mark_impersonation_token_as_redeemed.rs @@ -0,0 +1,35 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; + +pub struct MarkImpersonationTokenAsRedeemedArgs<'a, 'c: 'a, E> + where E: 'a + Executor<'c, Database = MySql> +{ + pub user_impersonation_token: &'a str, + pub ip_address_redemption: &'a str, + + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +pub async fn mark_impersonation_token_as_redeemed<'a, 'c, E>( + args: MarkImpersonationTokenAsRedeemedArgs<'a, 'c, E>, +) -> Result<(), sqlx::Error> + where E: 'a + Executor<'c, Database = MySql> +{ + sqlx::query!( + r#" +UPDATE user_impersonation_requests +SET + is_redeemed = TRUE, + ip_address_redemption = ? +WHERE user_impersonation_token = ? + "#, + args.ip_address_redemption, + args.user_impersonation_token, + ) + .execute(args.mysql_executor) + .await?; + + Ok(()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/user_impersonation_requests/mod.rs b/crates/schema/database/mysql_queries/src/queries/user_impersonation_requests/mod.rs new file mode 100644 index 0000000000..72dbbdfac9 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/user_impersonation_requests/mod.rs @@ -0,0 +1,3 @@ +pub mod insert_user_impersonation_request; +pub mod lookup_user_impersonation_request; +pub mod mark_impersonation_token_as_redeemed; diff --git a/crates/schema/database/mysql_queries/src/queries/users/user_sessions/create_impersonated_user_session.rs b/crates/schema/database/mysql_queries/src/queries/users/user_sessions/create_impersonated_user_session.rs new file mode 100644 index 0000000000..0da5e6e8d0 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/users/user_sessions/create_impersonated_user_session.rs @@ -0,0 +1,53 @@ +use std::marker::PhantomData; + +use chrono::{DateTime, Utc}; +use sqlx::{Executor, MySql}; + +use tokens::tokens::user_sessions::UserSessionToken; +use tokens::tokens::users::UserToken; + +pub struct CreateImpersonatedUserSessionArgs<'a, 'c: 'a, E> + where E: 'a + Executor<'c, Database = MySql> +{ + /// The user being impersonated (the session will act as this user). + pub user_token: &'a UserToken, + + /// The staff/moderator user who initiated the impersonation. + pub impersonator_user_token: &'a UserToken, + + pub ip_address: &'a str, + pub expires_at: DateTime, + + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +pub async fn create_impersonated_user_session<'a, 'c, E>( + args: CreateImpersonatedUserSessionArgs<'a, 'c, E>, +) -> Result + where E: 'a + Executor<'c, Database = MySql> +{ + let session_token = UserSessionToken::generate(); + + sqlx::query!( + r#" +INSERT INTO user_sessions ( + token, + user_token, + maybe_impersonation_user_token, + ip_address_creation, + expires_at +) +VALUES (?, ?, ?, ?, ?) + "#, + session_token.as_str(), + args.user_token.as_str(), + args.impersonator_user_token.as_str(), + args.ip_address, + args.expires_at, + ) + .execute(args.mysql_executor) + .await?; + + Ok(session_token) +} diff --git a/crates/schema/database/mysql_queries/src/queries/users/user_sessions/mod.rs b/crates/schema/database/mysql_queries/src/queries/users/user_sessions/mod.rs index a591ae7554..bf2073a9d1 100644 --- a/crates/schema/database/mysql_queries/src/queries/users/user_sessions/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/users/user_sessions/mod.rs @@ -1,3 +1,4 @@ +pub mod create_impersonated_user_session; pub mod create_user_session; pub mod create_user_session_with_transactor; pub mod delete_user_session; diff --git a/crates/schema/public/enums/src/by_table/mod.rs b/crates/schema/public/enums/src/by_table/mod.rs index 6e48fec55b..b18d8fff27 100644 --- a/crates/schema/public/enums/src/by_table/mod.rs +++ b/crates/schema/public/enums/src/by_table/mod.rs @@ -16,6 +16,7 @@ pub mod model_categories; pub mod model_weights; pub mod prompt_context_items; pub mod prompts; +pub mod staff_audit_logs; pub mod tag_uses; pub mod trending_model_analytics; pub mod tts_models; diff --git a/crates/schema/public/enums/src/by_table/staff_audit_logs/mod.rs b/crates/schema/public/enums/src/by_table/staff_audit_logs/mod.rs new file mode 100644 index 0000000000..b7eaa8b495 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/staff_audit_logs/mod.rs @@ -0,0 +1,2 @@ +pub mod staff_audit_action; +pub mod staff_audit_entity_type; diff --git a/crates/schema/public/enums/src/by_table/staff_audit_logs/staff_audit_action.rs b/crates/schema/public/enums/src/by_table/staff_audit_logs/staff_audit_action.rs new file mode 100644 index 0000000000..486a079c9b --- /dev/null +++ b/crates/schema/public/enums/src/by_table/staff_audit_logs/staff_audit_action.rs @@ -0,0 +1,115 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the `staff_audit_logs` table in `VARCHAR(32)` field `entity_action`. +/// +/// The type of staff action that was performed. +/// +/// YOU CAN ADD NEW VALUES, BUT DO NOT CHANGE EXISTING VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize)] +pub enum StaffAuditAction { + /// Staff initiated an impersonation request for a user. + #[serde(rename = "impersonate_user_request")] + ImpersonateUserRequest, + + /// Staff redeemed an impersonation request, creating a session. + #[serde(rename = "impersonate_user_redeem")] + ImpersonateUserRedeem, +} + +impl_enum_display_and_debug_using_to_str!(StaffAuditAction); +impl_mysql_enum_coders!(StaffAuditAction); +impl_mysql_from_row!(StaffAuditAction); + +impl StaffAuditAction { + pub fn to_str(&self) -> &'static str { + match self { + Self::ImpersonateUserRequest => "impersonate_user_request", + Self::ImpersonateUserRedeem => "impersonate_user_redeem", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "impersonate_user_request" => Ok(Self::ImpersonateUserRequest), + "impersonate_user_redeem" => Ok(Self::ImpersonateUserRedeem), + _ => Err(format!("invalid StaffAuditAction value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::ImpersonateUserRequest, + Self::ImpersonateUserRedeem, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::staff_audit_logs::staff_audit_action::StaffAuditAction; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(StaffAuditAction::ImpersonateUserRequest, "impersonate_user_request"); + assert_serialization(StaffAuditAction::ImpersonateUserRedeem, "impersonate_user_redeem"); + } + + #[test] + fn to_str() { + assert_eq!(StaffAuditAction::ImpersonateUserRequest.to_str(), "impersonate_user_request"); + assert_eq!(StaffAuditAction::ImpersonateUserRedeem.to_str(), "impersonate_user_redeem"); + } + + #[test] + fn from_str() { + assert_eq!(StaffAuditAction::from_str("impersonate_user_request").unwrap(), StaffAuditAction::ImpersonateUserRequest); + assert_eq!(StaffAuditAction::from_str("impersonate_user_redeem").unwrap(), StaffAuditAction::ImpersonateUserRedeem); + assert!(StaffAuditAction::from_str("invalid").is_err()); + } + + #[test] + fn all_variants() { + const EXPECTED_COUNT: usize = 2; + assert_eq!(StaffAuditAction::all_variants().len(), EXPECTED_COUNT); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(StaffAuditAction::all_variants().len(), StaffAuditAction::iter().len()); + } + + #[test] + fn round_trip() { + for variant in StaffAuditAction::all_variants() { + assert_eq!(variant, StaffAuditAction::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, StaffAuditAction::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, StaffAuditAction::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH: usize = 32; + for variant in StaffAuditAction::all_variants() { + let serialized = variant.to_str(); + assert!(!serialized.is_empty(), "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long for VARCHAR({})", variant, MAX_LENGTH); + } + } + } +} diff --git a/crates/schema/public/enums/src/by_table/staff_audit_logs/staff_audit_entity_type.rs b/crates/schema/public/enums/src/by_table/staff_audit_logs/staff_audit_entity_type.rs new file mode 100644 index 0000000000..9d11787f92 --- /dev/null +++ b/crates/schema/public/enums/src/by_table/staff_audit_logs/staff_audit_entity_type.rs @@ -0,0 +1,105 @@ +use std::collections::BTreeSet; + +#[cfg(test)] +use strum::EnumCount; +#[cfg(test)] +use strum::EnumIter; + +/// Used in the `staff_audit_logs` table in `VARCHAR(32)` field `entity_type`. +/// +/// The type of entity that a staff action was performed on. +/// +/// YOU CAN ADD NEW VALUES, BUT DO NOT CHANGE EXISTING VALUES WITHOUT A MIGRATION STRATEGY. +#[cfg_attr(test, derive(EnumIter, EnumCount))] +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize)] +pub enum StaffAuditEntityType { + /// A user account. + #[serde(rename = "user")] + User, +} + +impl_enum_display_and_debug_using_to_str!(StaffAuditEntityType); +impl_mysql_enum_coders!(StaffAuditEntityType); +impl_mysql_from_row!(StaffAuditEntityType); + +impl StaffAuditEntityType { + pub fn to_str(&self) -> &'static str { + match self { + Self::User => "user", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "user" => Ok(Self::User), + _ => Err(format!("invalid StaffAuditEntityType value: {:?}", value)), + } + } + + pub fn all_variants() -> BTreeSet { + BTreeSet::from([ + Self::User, + ]) + } +} + +#[cfg(test)] +mod tests { + use crate::by_table::staff_audit_logs::staff_audit_entity_type::StaffAuditEntityType; + use crate::test_helpers::assert_serialization; + + mod explicit_checks { + use super::*; + + #[test] + fn test_serialization() { + assert_serialization(StaffAuditEntityType::User, "user"); + } + + #[test] + fn to_str() { + assert_eq!(StaffAuditEntityType::User.to_str(), "user"); + } + + #[test] + fn from_str() { + assert_eq!(StaffAuditEntityType::from_str("user").unwrap(), StaffAuditEntityType::User); + assert!(StaffAuditEntityType::from_str("invalid").is_err()); + } + + #[test] + fn all_variants() { + const EXPECTED_COUNT: usize = 1; + assert_eq!(StaffAuditEntityType::all_variants().len(), EXPECTED_COUNT); + } + } + + mod mechanical_checks { + use super::*; + + #[test] + fn variant_length() { + use strum::IntoEnumIterator; + assert_eq!(StaffAuditEntityType::all_variants().len(), StaffAuditEntityType::iter().len()); + } + + #[test] + fn round_trip() { + for variant in StaffAuditEntityType::all_variants() { + assert_eq!(variant, StaffAuditEntityType::from_str(variant.to_str()).unwrap()); + assert_eq!(variant, StaffAuditEntityType::from_str(&format!("{}", variant)).unwrap()); + assert_eq!(variant, StaffAuditEntityType::from_str(&format!("{:?}", variant)).unwrap()); + } + } + + #[test] + fn serialized_length_ok_for_database() { + const MAX_LENGTH: usize = 32; + for variant in StaffAuditEntityType::all_variants() { + let serialized = variant.to_str(); + assert!(!serialized.is_empty(), "variant {:?} is too short", variant); + assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long for VARCHAR({})", variant, MAX_LENGTH); + } + } + } +} diff --git a/crates/schema/public/tokens/src/prefixes.rs b/crates/schema/public/tokens/src/prefixes.rs index b1cf8630e8..5e1091aea0 100644 --- a/crates/schema/public/tokens/src/prefixes.rs +++ b/crates/schema/public/tokens/src/prefixes.rs @@ -40,9 +40,11 @@ pub(crate) enum TokenPrefix { NewsStory, // NB: aichatbot / sqlite PasswordReset, Prompt, + StaffAuditLog, Tag, TtsRenderTask, // NB: aichatbot / sqlite User, + UserImpersonationRequest, UserBookmark, UserSession, UserSubscription, @@ -124,9 +126,11 @@ impl PrefixGenerator for TokenPrefix { Self::NewsStory => "news_story_", Self::PasswordReset => "pw_reset_", Self::Prompt => "prompt_", + Self::StaffAuditLog => "stfaud_", Self::Tag => "tag_", Self::TtsRenderTask => "tts_task_", Self::User => "user_", // NB: Previously "U:" + Self::UserImpersonationRequest => "uimpr_", Self::UserBookmark => "ub_", Self::UserSession => "session_", Self::UserSubscription => "subscription_", // NB: Previously "SUB:" diff --git a/crates/schema/public/tokens/src/tokens/mod.rs b/crates/schema/public/tokens/src/tokens/mod.rs index 95709879e4..8268d6e425 100644 --- a/crates/schema/public/tokens/src/tokens/mod.rs +++ b/crates/schema/public/tokens/src/tokens/mod.rs @@ -20,6 +20,7 @@ pub mod model_weights; pub mod password_reset; pub mod prompts; pub mod sqlite; +pub mod staff_audit_logs; pub mod tags; pub mod tts_inference_jobs; pub mod tts_model_upload_jobs; @@ -29,6 +30,7 @@ pub mod twitch_event_rule; pub mod twitch_oauth_tokens_grouping; pub mod twitch_oauth_tokens_internal; pub mod user_bookmarks; +pub mod user_impersonation_requests; pub mod user_sessions; pub mod user_subscriptions; pub mod users; diff --git a/crates/schema/public/tokens/src/tokens/staff_audit_logs.rs b/crates/schema/public/tokens/src/tokens/staff_audit_logs.rs new file mode 100644 index 0000000000..49d1446c68 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/staff_audit_logs.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for Staff Audit Logs +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct StaffAuditLogToken(pub String); + +impl_string_token!(StaffAuditLogToken); +impl_mysql_token_from_row!(StaffAuditLogToken); +impl_crockford_generator!(StaffAuditLogToken, 32usize, TokenPrefix::StaffAuditLog, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/user_impersonation_requests.rs b/crates/schema/public/tokens/src/tokens/user_impersonation_requests.rs new file mode 100644 index 0000000000..f7535ccfc0 --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/user_impersonation_requests.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for User Impersonation Requests +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct UserImpersonationRequestToken(pub String); + +impl_string_token!(UserImpersonationRequestToken); +impl_mysql_token_from_row!(UserImpersonationRequestToken); +impl_crockford_generator!(UserImpersonationRequestToken, 32usize, TokenPrefix::UserImpersonationRequest, CrockfordLower); 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 784d6f2da0..7cac116f84 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 @@ -5,5 +5,6 @@ pub mod jobs; pub mod user; pub mod user_bans; pub mod user_feature_flags; +pub mod user_sessions; pub mod wallet_ledger_entries; pub mod wallets; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_sessions/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_sessions/mod.rs new file mode 100644 index 0000000000..2dac44a86f --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_sessions/mod.rs @@ -0,0 +1 @@ +pub mod moderator_user_session_impersonation_request_handler; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_sessions/moderator_user_session_impersonation_request_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_sessions/moderator_user_session_impersonation_request_handler.rs new file mode 100644 index 0000000000..c0696e428f --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/moderation/user_sessions/moderator_user_session_impersonation_request_handler.rs @@ -0,0 +1,273 @@ +use std::marker::PhantomData; +use std::sync::Arc; + +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; +use chrono::{Duration, Utc}; +use log::{info, warn}; +use utoipa::ToSchema; + +use crockford::crockford_entropy_lower; +use enums::by_table::staff_audit_logs::staff_audit_action::StaffAuditAction; +use enums::by_table::staff_audit_logs::staff_audit_entity_type::StaffAuditEntityType; +use http_server_common::request::get_request_ip::get_request_ip; +use mysql_queries::queries::staff_audit_logs::insert_staff_audit_log::{ + insert_staff_audit_log, InsertStaffAuditLogArgs, +}; +use mysql_queries::queries::user_impersonation_requests::insert_user_impersonation_request::{ + insert_user_impersonation_request, InsertUserImpersonationRequestArgs, +}; +use mysql_queries::queries::users::user::get::lookup_user_for_moderation::{ + lookup_user_for_moderation_by_email, + lookup_user_for_moderation_by_token, + lookup_user_for_moderation_by_username, + LookupUserForModerationResult, +}; + +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; + +// --- Request --- + +#[derive(Deserialize, ToSchema)] +pub struct ModerationImpersonateRequest { + pub username: Option, + pub user_token: Option, + pub email_address: Option, + pub username_email_or_token: Option, +} + +// --- Response --- + +#[derive(Serialize, ToSchema)] +pub struct ModerationImpersonateSuccessResponse { + pub success: bool, + pub password_token: String, +} + +// --- Handler --- + +/// Create a user impersonation request. Moderators only. +#[utoipa::path( + post, + tag = "Moderation", + path = "/v1/moderation/user_sessions/impersonate", + request_body = ModerationImpersonateRequest, + responses( + (status = 200, description = "Impersonation request created", body = ModerationImpersonateSuccessResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "User not found"), + (status = 500, description = "Server error"), + ), +)] +pub async fn moderator_user_session_impersonation_request_handler( + http_request: HttpRequest, + request: Json, + server_state: web::Data>, +) -> Result, AdvancedCommonWebError> { + + // 1. Require moderator session. + let user_session = require_moderator( + &http_request, + &server_state, + UseDatabase::GrabNewConnection, + ).await.map_err(|err| { + warn!("Moderator check failed: {:?}", err); + AdvancedCommonWebError::NotAuthorized + })?; + + if user_session.is_banned { + warn!("Banned moderator tried to impersonate: {}", user_session.user_token.as_str()); + return Err(AdvancedCommonWebError::NotAuthorized); + } + + // 2. Determine lookup strategy from the request. + let lookup = resolve_lookup_strategy(&request)?; + + info!( + "Moderator {} requesting impersonation: {:?}", + user_session.user_token.as_str(), + lookup, + ); + + // 3. Look up the target user. + let target_user = perform_user_lookup(&lookup, &server_state).await?; + + // 4. Validate target user state. + if target_user.is_banned { + warn!( + "Moderator {} tried to impersonate banned user {}", + user_session.user_token.as_str(), + target_user.user_token.as_str(), + ); + return Err(AdvancedCommonWebError::BadInputWithSimpleMessage( + "Target user is banned".to_string(), + )); + } + + // 5. Generate the secret password token. + let password_token = crockford_entropy_lower(16); + + // 6. IP address for auditing. + let ip_address = get_request_ip(&http_request); + + // 7. Begin transaction. + let mut transaction = server_state.mysql_pool.begin().await + .map_err(|err| { + warn!("Failed to begin transaction: {:?}", err); + AdvancedCommonWebError::from_error(err) + })?; + + // 8. Insert staff audit log. + let _audit_token = insert_staff_audit_log(InsertStaffAuditLogArgs { + audit_action: StaffAuditAction::ImpersonateUserRequest, + maybe_entity_type: Some(StaffAuditEntityType::User), + maybe_entity_token: Some(target_user.user_token.as_str()), + staff_user_token: &user_session.user_token, + actor_ip_address: &ip_address, + mysql_executor: &mut *transaction, + phantom: PhantomData, + }).await.map_err(|err| { + warn!("Failed to insert staff audit log: {:?}", err); + AdvancedCommonWebError::from_error(err) + })?; + + // 9. Insert impersonation request (expires in 20 minutes). + let expires_at = Utc::now() + Duration::minutes(20); + + let _impersonation_token = insert_user_impersonation_request( + InsertUserImpersonationRequestArgs { + impersonated_user_token: &target_user.user_token, + impersonator_user_token: &user_session.user_token, + user_impersonation_token: &password_token, + ip_address_creation: &ip_address, + expires_at, + mysql_executor: &mut *transaction, + phantom: PhantomData, + }, + ).await.map_err(|err| { + warn!("Failed to insert impersonation request: {:?}", err); + AdvancedCommonWebError::from_error(err) + })?; + + // 10. Commit transaction. + transaction.commit().await.map_err(|err| { + warn!("Failed to commit transaction: {:?}", err); + AdvancedCommonWebError::from_error(err) + })?; + + info!( + "Impersonation request created: moderator={} target_user={} target_username={}", + user_session.user_token.as_str(), + target_user.user_token.as_str(), + target_user.username, + ); + + // 11. Respond with the password token. + Ok(Json(ModerationImpersonateSuccessResponse { + success: true, + password_token, + })) +} + +// --- Helpers --- + +/// Which field to look up the user by, after normalization. +#[derive(Debug)] +enum UserLookup { + Username(String), + Email(String), + Token(String), +} + +/// Resolve which lookup field was provided. Exactly one must be set. +fn resolve_lookup_strategy( + request: &ModerationImpersonateRequest, +) -> Result { + let mut fields_set = 0u8; + + if request.username.is_some() { fields_set += 1; } + if request.user_token.is_some() { fields_set += 1; } + if request.email_address.is_some() { fields_set += 1; } + if request.username_email_or_token.is_some() { fields_set += 1; } + + if fields_set == 0 { + return Err(AdvancedCommonWebError::BadInputWithSimpleMessage( + "Provide one of: username, user_token, email_address, or username_email_or_token".to_string(), + )); + } + + if fields_set > 1 { + return Err(AdvancedCommonWebError::BadInputWithSimpleMessage( + "Only one lookup field should be provided".to_string(), + )); + } + + if let Some(ref username) = request.username { + return Ok(UserLookup::Username(username.trim().to_lowercase())); + } + + if let Some(ref token) = request.user_token { + return Ok(UserLookup::Token(token.trim().to_string())); + } + + if let Some(ref email) = request.email_address { + return Ok(UserLookup::Email(email.trim().to_lowercase())); + } + + if let Some(ref value) = request.username_email_or_token { + return Ok(classify_ambiguous_lookup(value)); + } + + Err(AdvancedCommonWebError::BadInputWithSimpleMessage( + "No lookup field provided".to_string(), + )) +} + +/// Classify an ambiguous lookup value as username, email, or token. +fn classify_ambiguous_lookup(value: &str) -> UserLookup { + let trimmed = value.trim(); + + if trimmed.contains('@') { + return UserLookup::Email(trimmed.to_lowercase()); + } + + if trimmed.starts_with("user_") || trimmed.starts_with("U:") { + return UserLookup::Token(trimmed.to_string()); + } + + UserLookup::Username(trimmed.to_lowercase()) +} + +async fn perform_user_lookup( + lookup: &UserLookup, + server_state: &ServerState, +) -> Result { + let maybe_user = match lookup { + UserLookup::Username(username) => { + lookup_user_for_moderation_by_username(username, &server_state.mysql_pool).await + } + UserLookup::Email(email) => { + lookup_user_for_moderation_by_email(email, &server_state.mysql_pool).await + } + UserLookup::Token(token) => { + lookup_user_for_moderation_by_token(token, &server_state.mysql_pool).await + } + }; + + match maybe_user { + Ok(Some(user)) => Ok(user), + Ok(None) => { + warn!("User not found for impersonation lookup: {:?}", lookup); + Err(AdvancedCommonWebError::NotFound) + } + Err(err) => { + warn!("User lookup error: {:?}", err); + Err(AdvancedCommonWebError::from_anyhow_error(err)) + } + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/login_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/login_handler.rs index 5a8db5c8ab..0b6b3e9b96 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/login_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/login_handler.rs @@ -1,15 +1,29 @@ use std::fmt; use std::fmt::Formatter; +use std::marker::PhantomData; use actix_artcraft::sessions::user_sessions::http_user_session_manager::HttpUserSessionManager; use actix_web::error::ResponseError; use actix_web::http::StatusCode; use actix_web::{web, HttpRequest, HttpResponse}; +use chrono::{Duration, Utc}; +use enums::by_table::staff_audit_logs::staff_audit_action::StaffAuditAction; +use enums::by_table::staff_audit_logs::staff_audit_entity_type::StaffAuditEntityType; use http_server_common::request::get_request_ip::get_request_ip; use http_server_common::response::serialize_as_json_error::serialize_as_json_error; use log::{info, warn}; +use mysql_queries::queries::staff_audit_logs::insert_staff_audit_log::{ + insert_staff_audit_log, InsertStaffAuditLogArgs, +}; +use mysql_queries::queries::user_impersonation_requests::lookup_user_impersonation_request::lookup_user_impersonation_request; +use mysql_queries::queries::user_impersonation_requests::mark_impersonation_token_as_redeemed::{ + mark_impersonation_token_as_redeemed, MarkImpersonationTokenAsRedeemedArgs, +}; use mysql_queries::queries::users::user::get::lookup_user_for_login_by_email::lookup_user_for_login_by_email; use mysql_queries::queries::users::user::get::lookup_user_for_login_by_username::lookup_user_for_login_by_username; +use mysql_queries::queries::users::user_sessions::create_impersonated_user_session::{ + create_impersonated_user_session, CreateImpersonatedUserSessionArgs, +}; use mysql_queries::queries::users::user_sessions::create_user_session::create_user_session; use password::bcrypt_confirm_password::bcrypt_confirm_password; use sqlx::MySqlPool; @@ -18,6 +32,7 @@ use utoipa::ToSchema; use artcraft_api_defs::users::login::{LoginErrorType, LoginRequest, LoginSuccessResponse}; use password::errors::password_confirm_error::PasswordConfirmError; +use tokens::tokens::users::UserToken; use crate::http_server::session::lookup::user_session_feature_flags::UserSessionFeatureFlags; use crate::util::enroll_in_studio::enroll_in_studio; @@ -130,51 +145,56 @@ pub async fn login_handler( } }; - match bcrypt_confirm_password(request.password.clone(), &actual_hash) { + let ip_address = get_request_ip(&http_request); + + // Determine the password error to return if impersonation also fails. + let password_error = match bcrypt_confirm_password(request.password.clone(), &actual_hash) { + Ok(true) => None, // Password is valid — normal login. + Ok(false) => Some(LoginErrorResponse::invalid_credentials()), Err(PasswordConfirmError::HashIsSentinelValue) => { - warn!("Attempting to log in with a passwordless account"); - return Err(LoginErrorResponse::account_without_password()); + Some(LoginErrorResponse::account_without_password()) } Err(e) => { warn!("Login hash comparison error: {:?}", e); - return Err(LoginErrorResponse::server_error()); - } - Ok(is_valid) => { - if !is_valid { - info!("invalid credentials"); - return Err(LoginErrorResponse::invalid_credentials()); - } - // Good to go...! - }, - } - - let ip_address = get_request_ip(&http_request); - - let create_session_result = - create_user_session(&user.token.0, &ip_address, &mysql_pool).await; - - let session_token = match create_session_result { - Ok(token) => token, - Err(e) => { - warn!("login create session error : {:?}", e); - return Err(LoginErrorResponse::server_error()); + Some(LoginErrorResponse::server_error()) } }; - info!("login session created for user: {} / {:?}", &user.token, &user.token); + let session_token = if password_error.is_none() { + // Normal login path — password matched. + let create_session_result = + create_user_session(&user.token.0, &ip_address, &mysql_pool).await; + let token = match create_session_result { + Ok(token) => token, + Err(e) => { + warn!("login create session error: {:?}", e); + return Err(LoginErrorResponse::server_error()); + } + }; - let user_feature_flags = - UserSessionFeatureFlags::new(user.maybe_feature_flags.as_deref()); + info!("login session created for user: {} / {:?}", &user.token, &user.token); - // NB: Enroll new users in studio for a while. - enroll_in_studio(&user.token, &ip_address, &mysql_pool, Some(&user_feature_flags)).await - .map_err(|e| { - warn!("error enrolling in studio: {:?}", e); - }).ok(); + let user_feature_flags = + UserSessionFeatureFlags::new(user.maybe_feature_flags.as_deref()); + // NB: Enroll new users in studio for a while. + enroll_in_studio(&user.token, &ip_address, &mysql_pool, Some(&user_feature_flags)).await + .map_err(|e| { + warn!("error enrolling in studio: {:?}", e); + }).ok(); - let session_token = UserSessionToken::new_from_str(&session_token); + UserSessionToken::new_from_str(&token) + } else { + // Password didn't match — try impersonation token as a fallback. + match try_impersonation_login(&user.token, &request.password, &ip_address, &mysql_pool).await { + Ok(session_token) => session_token, + Err(_) => { + // Impersonation also failed; return the original password error. + return Err(password_error.unwrap()); + } + } + }; let session_cookie = match session_cookie_manager.create_cookie(&session_token, &user.token) { Ok(cookie) => cookie, @@ -199,3 +219,118 @@ pub async fn login_handler( .content_type("application/json") .body(body)) } + +/// Attempt to log in via a moderator impersonation token (supplied in the password field). +/// +/// Returns the impersonated user's session token on success, or an error if the +/// impersonation token is invalid, expired, already redeemed, or doesn't match. +async fn try_impersonation_login( + target_user_token: &UserToken, + password: &str, + ip_address: &str, + mysql_pool: &MySqlPool, +) -> Result { + + let record = lookup_user_impersonation_request(password, mysql_pool) + .await + .map_err(|err| { + warn!("Impersonation lookup error: {:?}", err); + LoginErrorResponse::server_error() + })? + .ok_or_else(|| { + info!("No impersonation request found for provided token"); + LoginErrorResponse::invalid_credentials() + })?; + + // Validate: not already redeemed. + if record.is_redeemed { + warn!("Impersonation token already redeemed: {}", record.token); + return Err(LoginErrorResponse::invalid_credentials()); + } + + // Validate: not expired. + if Utc::now() > record.expires_at { + warn!("Impersonation token expired: {} (expired at {:?})", record.token, record.expires_at); + return Err(LoginErrorResponse::invalid_credentials()); + } + + // Validate: the impersonated user matches the user we're logging into. + if record.impersonated_user_token != *target_user_token { + warn!( + "Impersonation token user mismatch: token is for {} but login is for {}", + record.impersonated_user_token.as_str(), + target_user_token.as_str(), + ); + return Err(LoginErrorResponse::invalid_credentials()); + } + + info!( + "Valid impersonation login: moderator={} impersonating={}", + record.impersonator_user_token.as_str(), + target_user_token.as_str(), + ); + + // Begin transaction: audit log + mark redeemed + create session. + let mut transaction = mysql_pool.begin().await + .map_err(|err| { + warn!("Failed to begin impersonation transaction: {:?}", err); + LoginErrorResponse::server_error() + })?; + + // Insert staff audit log for the redemption. + let _audit_token = insert_staff_audit_log(InsertStaffAuditLogArgs { + audit_action: StaffAuditAction::ImpersonateUserRedeem, + maybe_entity_type: Some(StaffAuditEntityType::User), + maybe_entity_token: Some(target_user_token.as_str()), + staff_user_token: &record.impersonator_user_token, + actor_ip_address: ip_address, + mysql_executor: &mut *transaction, + phantom: PhantomData, + }).await.map_err(|err| { + warn!("Failed to insert impersonation audit log: {:?}", err); + LoginErrorResponse::server_error() + })?; + + // Mark the impersonation token as redeemed. + mark_impersonation_token_as_redeemed(MarkImpersonationTokenAsRedeemedArgs { + user_impersonation_token: password, + ip_address_redemption: ip_address, + mysql_executor: &mut *transaction, + phantom: PhantomData, + }).await.map_err(|err| { + warn!("Failed to mark impersonation token as redeemed: {:?}", err); + LoginErrorResponse::server_error() + })?; + + // Create the impersonated session (60 min expiry). + let expires_at = Utc::now() + Duration::minutes(60); + + let session_token = create_impersonated_user_session( + CreateImpersonatedUserSessionArgs { + user_token: target_user_token, + impersonator_user_token: &record.impersonator_user_token, + ip_address, + expires_at, + mysql_executor: &mut *transaction, + phantom: PhantomData, + }, + ).await.map_err(|err| { + warn!("Failed to create impersonated session: {:?}", err); + LoginErrorResponse::server_error() + })?; + + // Commit the transaction. + transaction.commit().await.map_err(|err| { + warn!("Failed to commit impersonation transaction: {:?}", err); + LoginErrorResponse::server_error() + })?; + + info!( + "Impersonation session created: moderator={} as user={} session={}", + record.impersonator_user_token.as_str(), + target_user_token.as_str(), + session_token.as_str(), + ); + + Ok(session_token) +} 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 ae9202618d..ac96f71691 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 @@ -36,6 +36,7 @@ use crate::http_server::endpoints::moderation::user::moderator_user_lookup_by_st 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::user_feature_flags::edit_user_feature_flags_handler::edit_user_feature_flags_handler; +use crate::http_server::endpoints::moderation::user_sessions::moderator_user_session_impersonation_request_handler::moderator_user_session_impersonation_request_handler; pub fn add_moderator_routes (app: App) -> App where @@ -103,6 +104,12 @@ pub fn add_moderator_routes (app: App) -> App .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)) + .route(web::head().to(|| HttpResponse::Ok())) + ) + ) .service(web::resource("/wallet_ledger_entry/{wallet_ledger_entry_token}") .route(web::get().to(moderator_get_wallet_ledger_entry_handler)) .route(web::head().to(|| HttpResponse::Ok()))