diff --git a/CLAUDE.md b/CLAUDE.md index 329cd12f..4da85c8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -210,6 +210,7 @@ Optional: - `SQLX_POOL_SIZE`: Database connection pool size (should match Cloud Run concurrency, default: `50`) - `VITE_ALLOWED_PUBKEYS`: Comma-separated pubkeys for whitelist access (web frontend) - `ENABLE_EXAMPLES`: Enable `/examples` directory serving (default: `false`, set to `true` for development) +- `KEYCAST_SERVICE_TOKEN`: Bearer token for service-to-service admin API calls (relay-manager, COOP). Required for `/api/admin/users/:pubkey/status` endpoints. Development (`.env` in `/web`): - `VITE_ALLOWED_PUBKEYS`: Comma-separated pubkeys for dev access diff --git a/Cargo.lock b/Cargo.lock index ee10c91d..29528b49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3482,6 +3482,7 @@ dependencies = [ "sha2 0.10.9", "sha256", "sqlx", + "subtle", "thiserror 2.0.18", "tokio", "tower 0.5.3", diff --git a/api/Cargo.toml b/api/Cargo.toml index 7c958b1d..7c0983b5 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -37,6 +37,7 @@ serde = { workspace = true } serde_json = { workspace = true } serde_yaml = "0.9" sha256 = "1.5" +subtle = "2.6" sqlx = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } diff --git a/api/src/api/http/admin.rs b/api/src/api/http/admin.rs index 09b1d6cf..7de0cac4 100644 --- a/api/src/api/http/admin.rs +++ b/api/src/api/http/admin.rs @@ -2,6 +2,7 @@ // ABOUTME: Used for Vine import and support workflows use axum::extract::{Path, Query, State}; +use axum::http::HeaderMap; use axum::Json; use chrono::{Duration, Utc}; use nostr_sdk::{FromBech32, Keys}; @@ -17,6 +18,7 @@ use keycast_core::repositories::{ RegisteredClientRepository, RepositoryError, UserRepository, }; use keycast_core::types::claim_token::generate_claim_token; +use keycast_core::types::user::UserStatus; /// Admin token expiry in days (30 days for long-lived admin tokens) const ADMIN_TOKEN_EXPIRY_DAYS: i64 = 30; @@ -898,6 +900,9 @@ pub struct UserLookupDetails { pub display_name: Option, pub vine_id: Option, pub has_personal_key: bool, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub suspended_reason: Option, pub active_sessions: i64, pub created_at: String, pub last_active: Option, @@ -964,6 +969,8 @@ pub async fn get_user_lookup( display_name: details.display_name, vine_id: details.vine_id, has_personal_key: details.has_personal_key, + status: details.status.as_str().to_string(), + suspended_reason: details.suspended_reason, active_sessions: sessions.len() as i64, created_at: details.created_at.to_rfc3339(), last_active, @@ -1798,3 +1805,126 @@ pub async fn test_registered_client_pattern( matches: test_redirect_pattern(&req.pattern, &req.uri), })) } + +// --- Service-token-authenticated admin endpoints (for relay-manager, COOP) --- + +fn authorize_service_token(headers: &HeaderMap) -> Result<(), ApiError> { + use subtle::ConstantTimeEq; + + let expected = std::env::var("KEYCAST_SERVICE_TOKEN") + .ok() + .map(|token| token.trim().to_string()) + .filter(|token| !token.is_empty()) + .ok_or_else(|| ApiError::internal("KEYCAST_SERVICE_TOKEN not configured"))?; + + let actual = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| ApiError::auth("Missing Authorization header"))?; + + // Hash both to fixed length to avoid leaking expected token length via timing + let expected_hash = blake3::hash(format!("Bearer {expected}").as_bytes()); + let actual_hash = blake3::hash(actual.as_bytes()); + if expected_hash + .as_bytes() + .ct_eq(actual_hash.as_bytes()) + .into() + { + Ok(()) + } else { + Err(ApiError::auth("Invalid service token")) + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct UserStatusResponse { + pub pubkey: String, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub suspended_reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub suspended_at: Option>, +} + +pub async fn get_user_status_admin( + tenant: crate::api::tenant::TenantExtractor, + State(auth_state): State, + headers: HeaderMap, + Path(pubkey): Path, +) -> ApiResult> { + authorize_service_token(&headers)?; + let tenant_id = tenant.0.id; + let user_repo = UserRepository::new(auth_state.state.db.clone()); + + let (status, suspended_reason, suspended_at) = user_repo + .get_user_status(&pubkey, tenant_id) + .await? + .ok_or_else(|| ApiError::not_found("User not found"))?; + + Ok(Json(UserStatusResponse { + pubkey, + status: status.as_str().to_string(), + suspended_reason, + suspended_at, + })) +} + +#[derive(Debug, Deserialize)] +pub struct SetUserStatusRequest { + pub status: String, + pub reason: Option, +} + +pub async fn set_user_status_admin( + tenant: crate::api::tenant::TenantExtractor, + State(auth_state): State, + headers: HeaderMap, + Path(pubkey): Path, + Json(req): Json, +) -> ApiResult> { + authorize_service_token(&headers)?; + let tenant_id = tenant.0.id; + let user_repo = UserRepository::new(auth_state.state.db.clone()); + + let status = match req.status.as_str() { + "active" => UserStatus::Active, + "suspended" => UserStatus::Suspended, + "banned" => UserStatus::Banned, + _ => { + return Err(ApiError::bad_request( + "Invalid status. Must be: active, suspended, banned", + )) + } + }; + + if !status.is_active() && req.reason.as_ref().is_none_or(|r| r.trim().is_empty()) { + return Err(ApiError::bad_request( + "reason is required when status is suspended or banned", + )); + } + + let reason = if status.is_active() { + None + } else { + req.reason.as_deref().map(str::trim) + }; + let (old_status, updated_status, suspended_reason, suspended_at) = user_repo + .set_user_status(&pubkey, tenant_id, &status, reason) + .await?; + + tracing::info!( + event = "user_status_changed", + pubkey = %pubkey, + old_status = %old_status.as_str(), + new_status = %updated_status.as_str(), + reason = ?req.reason, + "Admin changed user status" + ); + + Ok(Json(UserStatusResponse { + pubkey, + status: updated_status.as_str().to_string(), + suspended_reason, + suspended_at, + })) +} diff --git a/api/src/api/http/auth.rs b/api/src/api/http/auth.rs index de863b5a..6ca783a0 100644 --- a/api/src/api/http/auth.rs +++ b/api/src/api/http/auth.rs @@ -186,6 +186,7 @@ pub(crate) async fn generate_ucan_token( email: &str, redirect_origin: &str, relays: Option<&[String]>, + status: Option<&keycast_core::types::user::UserStatus>, ) -> Result { use crate::ucan_auth::{nostr_pubkey_to_did, NostrKeyMaterial}; use serde_json::json; @@ -204,6 +205,11 @@ pub(crate) async fn generate_ucan_token( if let Some(relays) = relays { facts_obj["relays"] = json!(relays); } + if let Some(s) = status { + if !s.is_active() { + facts_obj["account_status"] = json!(s.as_str()); + } + } let facts = facts_obj; @@ -238,6 +244,7 @@ pub async fn generate_server_signed_ucan( server_keys: &Keys, is_first_party: bool, admin_role: Option<&str>, + status: Option<&keycast_core::types::user::UserStatus>, ) -> Result { use crate::ucan_auth::{nostr_pubkey_to_did, NostrKeyMaterial}; use serde_json::json; @@ -260,6 +267,11 @@ pub async fn generate_server_signed_ucan( if let Some(role) = admin_role { facts["admin_role"] = json!(role); } + if let Some(s) = status { + if !s.is_active() { + facts["account_status"] = json!(s.as_str()); + } + } let ucan = UcanBuilder::default() .issued_by(&server_key_material) // Server issues @@ -358,6 +370,10 @@ pub struct AccountStatusResponse { pub email: String, pub email_verified: bool, pub public_key: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub account_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub suspended_reason: Option, } #[derive(Debug, Deserialize, Serialize)] @@ -758,6 +774,7 @@ async fn nostr_auth_login( &server_keys, false, // NIP-98 admin login is not first-party OAuth Some(admin_role), + None, // Admin login does not carry user account status ) .await?; @@ -1021,7 +1038,7 @@ pub async fn login( let user_repo = UserRepository::new(pool.clone()); let user = user_repo.find_with_password(&req.email, tenant_id).await?; - let (public_key, password_hash, email_verified) = match user { + let (public_key, password_hash, email_verified, user_status) = match user { Some(u) => u, None => { super::auth_observability::record_auth_event_and_log( @@ -1140,8 +1157,20 @@ pub async fn login( let keys = Keys::new(secret_key.into()); // Generate UCAN token for session cookie with redirect_origin - let ucan_token = - generate_ucan_token(&keys, tenant_id, &req.email, &redirect_origin, None).await?; + let status_ref = if user_status.is_active() { + None + } else { + Some(&user_status) + }; + let ucan_token = generate_ucan_token( + &keys, + tenant_id, + &req.email, + &redirect_origin, + None, + status_ref, + ) + .await?; // Track successful login METRICS.inc_login(); @@ -1798,8 +1827,15 @@ pub async fn verify_email( // Mark email as verified (token kept for idempotent re-verification) user_repo.verify_email(&public_key, tenant_id).await?; - // Get user's email for UCAN + // Get user's email and account status for UCAN let email = user_repo.get_email(&public_key, tenant_id).await?; + // Best-effort: DB errors → None (no status fact). Hard enforcement is at signing time. + let user_status = user_repo + .get_user_status(&public_key, tenant_id) + .await + .ok() + .flatten() + .map(|(s, _, _)| s); // Get user's keys to generate UCAN (tenant-scoped) let personal_keys_repo = PersonalKeysRepository::new(pool.clone()); @@ -1823,7 +1859,15 @@ pub async fn verify_email( .unwrap_or_else(|_| "http://localhost:3000".to_string()); // Generate UCAN token for session cookie - let ucan_token = generate_ucan_token(&keys, tenant_id, &email, &redirect_origin, None).await?; + let ucan_token = generate_ucan_token( + &keys, + tenant_id, + &email, + &redirect_origin, + None, + user_status.as_ref(), + ) + .await?; tracing::info!( event = "email_verification", @@ -2305,11 +2349,25 @@ pub async fn get_account_status( .await?; match user { - Some((email, email_verified)) => Ok(Json(AccountStatusResponse { - email: email.unwrap_or_default(), - email_verified: email_verified.unwrap_or(false), - public_key: user_pubkey, - })), + Some((email, email_verified, status, suspended_reason)) => { + let account_status = if status.is_active() { + None + } else { + Some(status.as_str().to_string()) + }; + let reason = if status.is_active() { + None + } else { + suspended_reason + }; + Ok(Json(AccountStatusResponse { + email: email.unwrap_or_default(), + email_verified: email_verified.unwrap_or(false), + public_key: user_pubkey, + account_status, + suspended_reason: reason, + })) + } None => Err(AuthError::UserNotFound), } } @@ -3090,6 +3148,14 @@ pub async fn sign_event( let pool = &auth_state.state.db; let key_manager = auth_state.state.key_manager.as_ref(); + // Check account status before either signing path (fast or slow) + let user_repo = UserRepository::new(pool.clone()); + if let Some((status, _, _)) = user_repo.get_user_status(&user_pubkey, tenant_id).await? { + if !status.is_active() { + return Err(AuthError::Forbidden("Account restricted".to_string())); + } + } + // Parse unsigned event first for validation let unsigned_event: UnsignedEvent = serde_json::from_value(req.event.clone()) .map_err(|e| AuthError::Internal(format!("Invalid event format: {}", e)))?; @@ -3509,8 +3575,21 @@ pub async fn change_key( // Issue new UCAN session cookie signed by the new key let redirect_origin = extract_origin_from_headers(&headers)?; - let ucan_token = - generate_ucan_token(&new_keys, tenant_id, &email, &redirect_origin, None).await?; + let change_key_status = user_repo + .get_user_status(&new_pubkey, tenant_id) + .await + .ok() + .flatten() + .map(|(s, _, _)| s); + let ucan_token = generate_ucan_token( + &new_keys, + tenant_id, + &email, + &redirect_origin, + None, + change_key_status.as_ref(), + ) + .await?; let cookie = format!( "keycast_session={}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400", @@ -4161,6 +4240,7 @@ mod tests { "second@example.com", "http://localhost:3000", None, + None, ) .await .unwrap(); diff --git a/api/src/api/http/claim.rs b/api/src/api/http/claim.rs index 9fac0e6f..236230f8 100644 --- a/api/src/api/http/claim.rs +++ b/api/src/api/http/claim.rs @@ -390,6 +390,14 @@ pub async fn claim_post( let user_pubkey = nostr_sdk::PublicKey::from_hex(&claim_token.user_pubkey) .map_err(|e| ClaimError::Internal(format!("Invalid pubkey: {}", e)))?; + // Fetch account status for UCAN fact (normally active at claim time) + let claim_user_status = user_repo + .get_user_status(&claim_token.user_pubkey, tenant_id) + .await + .ok() + .flatten() + .map(|(s, _, _)| s); + // Load server keys for UCAN signing let server_keys = get_server_keys()?; @@ -402,6 +410,7 @@ pub async fn claim_post( &server_keys, false, // Account claim is not first-party OAuth None, + claim_user_status.as_ref(), ) .await .map_err(|e| ClaimError::Internal(format!("Failed to generate session: {:?}", e)))?; diff --git a/api/src/api/http/headless.rs b/api/src/api/http/headless.rs index 3c20aa22..e09db4a8 100644 --- a/api/src/api/http/headless.rs +++ b/api/src/api/http/headless.rs @@ -345,7 +345,7 @@ pub async fn headless_login( let user_repo = UserRepository::new(pool.clone()); let user = user_repo.find_with_password(&req.email, tenant_id).await?; - let (public_key, password_hash, email_verified) = match user { + let (public_key, password_hash, email_verified, _user_status) = match user { Some(u) => u, None => { super::auth_observability::record_auth_event_and_log( diff --git a/api/src/api/http/nostr_rpc.rs b/api/src/api/http/nostr_rpc.rs index 342aab8c..be801563 100644 --- a/api/src/api/http/nostr_rpc.rs +++ b/api/src/api/http/nostr_rpc.rs @@ -61,6 +61,7 @@ impl NostrRpcResponse { #[derive(Debug)] pub enum RpcError { Auth(AuthError), + AccountSuspended(String), InvalidParams(String), UnsupportedMethod(String), SigningFailed(String), @@ -73,6 +74,7 @@ impl IntoResponse for RpcError { fn into_response(self) -> Response { let (status, message) = match self { RpcError::Auth(e) => return e.into_response(), + RpcError::AccountSuspended(msg) => (StatusCode::FORBIDDEN, msg), RpcError::InvalidParams(msg) => (StatusCode::BAD_REQUEST, msg), RpcError::UnsupportedMethod(method) => ( StatusCode::BAD_REQUEST, @@ -150,6 +152,13 @@ pub async fn nostr_rpc( } }; + // For mutating operations (sign/encrypt/decrypt), check user account status. + // get_public_key is NOT gated -- suspended users need to retrieve their pubkey. + let needs_status_check = !matches!(req.method.as_str(), "get_public_key"); + if needs_status_check { + check_user_status_active(pool, &handler.user_pubkey_hex(), tenant_id).await?; + } + // Dispatch based on method - all permission checks use cached data (no DB hits) let result = match req.method.as_str() { "get_public_key" => JsonValue::String(handler.user_pubkey_hex()), @@ -238,6 +247,30 @@ pub async fn nostr_rpc( Ok(Json(NostrRpcResponse::success(result))) } +/// Check that the user's account is active before allowing mutating operations. +/// This runs a DB query per request (not cached) so status changes take effect immediately. +async fn check_user_status_active( + pool: &sqlx::PgPool, + user_pubkey_hex: &str, + tenant_id: i64, +) -> Result<(), RpcError> { + let status: Option<(String,)> = + sqlx::query_as("SELECT status FROM users WHERE pubkey = $1 AND tenant_id = $2") + .bind(user_pubkey_hex) + .bind(tenant_id) + .fetch_optional(pool) + .await + .map_err(|e| { + RpcError::Internal(format!("Database error checking user status: {}", e)) + })?; + + match status { + Some((s,)) if s == "active" => Ok(()), + Some(_) => Err(RpcError::AccountSuspended("Account restricted".to_string())), + None => Err(RpcError::Auth(AuthError::InvalidToken)), + } +} + /// Load an HttpRpcHandler on-demand from DB and cache it /// Called when http_handler_cache misses for the given bunker_pubkey /// Loads authorization metadata, user keys, AND permissions - all cached in handler diff --git a/api/src/api/http/oauth.rs b/api/src/api/http/oauth.rs index 838e2637..040de5ee 100644 --- a/api/src/api/http/oauth.rs +++ b/api/src/api/http/oauth.rs @@ -531,7 +531,7 @@ pub async fn auth_status( // Return authenticated with pubkey, optionally with email info if user exists in DB // NIP-07 admins may not have a user record, but their UCAN session is still valid - if let Some((email, email_verified)) = user_info { + if let Some((email, email_verified, _status, _reason)) = user_info { Ok(Json(AuthStatusResponse { authenticated: true, pubkey: Some(user_pubkey), @@ -625,7 +625,7 @@ pub async fn authorize_get( tracing::warn!("UCAN cookie has pubkey {} but user doesn't exist in tenant {}, clearing stale cookie", pubkey, tenant_id); (None, true, None) // User was deleted, clear the cookie } - Some((email, _verified)) => (user_pubkey, false, email), + Some((email, _verified, _status, _reason)) => (user_pubkey, false, email), } } } else { @@ -2414,12 +2414,18 @@ async fn handle_refresh_token_grant( )); } - // Get user email for UCAN generation + // Get user email and account status for UCAN generation let user_repo = UserRepository::new(pool.clone()); let email = user_repo .get_email(&oauth_auth.user_pubkey, tenant_id) .await .unwrap_or_default(); + let user_status = user_repo + .get_user_status(&oauth_auth.user_pubkey, tenant_id) + .await + .ok() + .flatten() + .map(|(s, _, _)| s); // Get user's encrypted keys for bunker key derivation (tenant-scoped) let personal_keys_repo = PersonalKeysRepository::new(pool.clone()); @@ -2453,6 +2459,7 @@ async fn handle_refresh_token_grant( &auth_state.state.server_keys, false, // Refresh tokens are not first-party None, + user_status.as_ref(), ) .await .map_err(|e| OAuthError::InvalidRequest(format!("UCAN generation failed: {:?}", e)))?; @@ -2866,6 +2873,17 @@ async fn create_oauth_authorization_and_token( let bunker_keys = keycast_core::bunker_key::derive_bunker_keys(&user_secret_key, &secret_hash); let bunker_public_key = bunker_keys.public_key(); + // Fetch account status for UCAN fact + let code_exchange_user_status = { + let user_repo = UserRepository::new(pool.clone()); + user_repo + .get_user_status(user_pubkey, tenant_id) + .await + .ok() + .flatten() + .map(|(s, _, _)| s) + }; + // Generate server-signed UCAN for REST RPC API access (after bunker key derivation) // is_headless enables first_party fact for account deletion authorization let access_token = super::auth::generate_server_signed_ucan( @@ -2878,6 +2896,7 @@ async fn create_oauth_authorization_and_token( &auth_state.state.server_keys, is_headless, // first_party fact for headless flow tokens None, + code_exchange_user_status.as_ref(), ) .await .map_err(|e| OAuthError::InvalidRequest(format!("UCAN generation failed: {:?}", e)))?; @@ -3070,7 +3089,7 @@ pub async fn oauth_login( // Validate credentials let user_repo = UserRepository::new(pool.clone()); - let (public_key, password_hash, email_verified) = + let (public_key, password_hash, email_verified, user_status) = match user_repo.find_with_password(&req.email, tenant_id).await? { Some(user) => user, None => { @@ -3203,10 +3222,22 @@ pub async fn oauth_login( } }; - // Generate UCAN token with redirect_origin - let ucan_token = generate_ucan_token(&keys, tenant_id, &req.email, &redirect_origin, None) - .await - .map_err(|e| OAuthError::InvalidRequest(format!("UCAN generation failed: {:?}", e)))?; + // Generate UCAN token with redirect_origin and account status + let status_ref = if user_status.is_active() { + None + } else { + Some(&user_status) + }; + let ucan_token = generate_ucan_token( + &keys, + tenant_id, + &req.email, + &redirect_origin, + None, + status_ref, + ) + .await + .map_err(|e| OAuthError::InvalidRequest(format!("UCAN generation failed: {:?}", e)))?; // OAuth popup login: bunker authorization will be created manually by user if needed diff --git a/api/src/api/http/routes.rs b/api/src/api/http/routes.rs index 0578bb4b..5301d6fe 100644 --- a/api/src/api/http/routes.rs +++ b/api/src/api/http/routes.rs @@ -227,6 +227,15 @@ pub fn api_routes( .layer(auth_cors.clone()) .with_state(auth_state.clone()); + // Service-authenticated admin routes (for relay-manager, COOP) + // Uses KEYCAST_SERVICE_TOKEN Bearer auth, not UCAN + let service_admin_routes = Router::new() + .route( + "/admin/users/:pubkey/status", + get(admin::get_user_status_admin).put(admin::set_user_status_admin), + ) + .with_state(auth_state.clone()); + // Claim routes (public, accessed via email link) // Users claim preloaded accounts by setting email/password let claim_routes = Router::new() @@ -292,6 +301,7 @@ pub fn api_routes( .merge(policy_routes.layer(public_cors.clone())) // Public - available to third-party OAuth apps .merge(headless_routes.layer(public_cors.clone())) // Public CORS - embedded flow for web + mobile (PKCE protects token exchange) .merge(admin_routes) // Admin routes for preloaded accounts (has auth_cors) + .merge(service_admin_routes) // Service-token admin routes (no CORS, server-to-server) .merge(claim_routes.layer(public_cors.clone())) // Public - claim preloaded accounts .merge(metrics_route.layer(public_cors.clone())) // Public - Prometheus metrics .merge(docs_route.layer(public_cors)) diff --git a/api/tests/atproto_oauth_http_test.rs b/api/tests/atproto_oauth_http_test.rs index e04127cf..0a8d74ff 100644 --- a/api/tests/atproto_oauth_http_test.rs +++ b/api/tests/atproto_oauth_http_test.rs @@ -362,6 +362,7 @@ async fn par_authorize_and_token_exchange_with_existing_login_session() { &server_keys, true, None, + None, ) .await .unwrap(); @@ -513,6 +514,7 @@ async fn authorize_rejects_when_atproto_link_is_not_ready() { &server_keys, true, None, + None, ) .await .unwrap(); @@ -615,6 +617,7 @@ async fn par_uses_request_host_tenant_for_authorize_flow() { &server_keys, true, None, + None, ) .await .unwrap(); @@ -705,6 +708,7 @@ async fn authorize_rejects_revoked_request_uri_before_redirecting() { &server_keys, true, None, + None, ) .await .unwrap(); @@ -829,6 +833,7 @@ async fn refresh_token_rotation_requires_the_bound_dpop_key() { &server_keys, true, None, + None, ) .await .unwrap(); @@ -1144,6 +1149,7 @@ async fn confidential_client_requires_private_key_jwt_at_par_and_keeps_key_bindi &server_keys, true, None, + None, ) .await .unwrap(); diff --git a/api/tests/nostr_rpc_integration_test.rs b/api/tests/nostr_rpc_integration_test.rs index b60ff1ef..c528dae8 100644 --- a/api/tests/nostr_rpc_integration_test.rs +++ b/api/tests/nostr_rpc_integration_test.rs @@ -10,7 +10,7 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use chrono::{Duration, Utc}; use keycast_api::api::{ http::{ - auth::AuthError, + auth::{sign_event, AuthError, SignEventRequest}, nostr_rpc::{nostr_rpc, NostrRpcRequest, NostrRpcResponse, RpcError}, routes::AuthState, }, @@ -1165,3 +1165,236 @@ async fn test_server_signed_non_preload_redirect_origin_rejected() { other => panic!("expected InvalidToken, got: {:?}", other), } } + +// ============================================================================ +// Account suspension tests +// ============================================================================ + +async fn build_self_signed_ucan( + user_keys: &Keys, + tenant_id: i64, + redirect_origin: &str, + bunker_pubkey: Option<&str>, +) -> String { + let user_did = nostr_pubkey_to_did(&user_keys.public_key()); + let key_material = NostrKeyMaterial::from_keys(user_keys.clone()); + let mut facts = json!({ + "tenant_id": tenant_id, + "email": "test@example.com", + "redirect_origin": redirect_origin, + }); + if let Some(bpk) = bunker_pubkey { + facts["bunker_pubkey"] = json!(bpk); + } + + let ucan = UcanBuilder::default() + .issued_by(&key_material) + .for_audience(&user_did) + .with_lifetime(3600) + .with_fact(facts) + .build() + .expect("Failed to build UCAN") + .sign() + .await + .expect("Failed to sign UCAN"); + + ucan.encode().expect("Failed to encode UCAN") +} + +async fn suspend_user(pool: &PgPool, pubkey: &str, tenant_id: i64) { + use keycast_core::repositories::UserRepository; + use keycast_core::types::user::UserStatus; + let user_repo = UserRepository::new(pool.clone()); + user_repo + .set_user_status( + pubkey, + tenant_id, + &UserStatus::Suspended, + Some("age_review"), + ) + .await + .expect("Failed to suspend user"); +} + +/// Test: suspended user denied from sign_event HTTP endpoint (covers slow path) +#[tokio::test] +#[serial] +async fn test_suspended_user_denied_sign_event() { + let pool = setup_db().await; + let tenant_id = create_test_tenant(&pool).await; + let (user_keys, pubkey) = create_test_user(); + let key_manager = FileKeyManager::new().expect("Failed to create key manager"); + + insert_user(&pool, tenant_id, &pubkey).await; + create_personal_key(&pool, tenant_id, &pubkey, &user_keys, &key_manager).await; + + let redirect_origin = format!("https://suspend-sign-{}.example.com", Uuid::new_v4()); + create_test_oauth_authorization( + &pool, + tenant_id, + &pubkey, + &redirect_origin, + None, + None, + None, + ) + .await; + + // Suspend the user + suspend_user(&pool, &pubkey, tenant_id).await; + + // Build UCAN and call sign_event (no cached handler → slow path) + // sign_event extracts user from UCAN audience, doesn't need bunker_pubkey + let token = build_self_signed_ucan(&user_keys, tenant_id, &redirect_origin, None).await; + let auth_state = create_test_auth_state( + pool.clone(), + Arc::new(Box::new(key_manager) as Box), + ); + + let unsigned = EventBuilder::text_note("should be denied").build(user_keys.public_key()); + let event_json = serde_json::to_value(&unsigned).expect("Failed to serialize unsigned event"); + + let mut headers = HeaderMap::new(); + headers.insert( + "Authorization", + format!("Bearer {}", token).parse().unwrap(), + ); + headers.insert("host", "login.divine.video".parse().unwrap()); + headers.insert("x-forwarded-proto", "https".parse().unwrap()); + + let result = sign_event( + create_test_tenant_extractor(tenant_id), + State(auth_state), + headers, + Json(SignEventRequest { event: event_json }), + ) + .await; + + match result { + Err(AuthError::Forbidden(msg)) => { + assert_eq!(msg, "Account restricted"); + } + other => panic!("expected Forbidden(Account restricted), got: {:?}", other), + } +} + +/// Test: suspended user denied from nostr_rpc sign_event method +#[tokio::test] +#[serial] +async fn test_suspended_user_denied_nostr_rpc_sign() { + let pool = setup_db().await; + let tenant_id = create_test_tenant(&pool).await; + let (user_keys, pubkey) = create_test_user(); + let key_manager = FileKeyManager::new().expect("Failed to create key manager"); + + insert_user(&pool, tenant_id, &pubkey).await; + create_personal_key(&pool, tenant_id, &pubkey, &user_keys, &key_manager).await; + + let redirect_origin = format!("https://suspend-rpc-{}.example.com", Uuid::new_v4()); + let (_auth_id, bunker_pubkey) = create_test_oauth_authorization( + &pool, + tenant_id, + &pubkey, + &redirect_origin, + None, + None, + None, + ) + .await; + + // Suspend the user + suspend_user(&pool, &pubkey, tenant_id).await; + + // Include bunker_pubkey so nostr_rpc routes through Mode 1 (OAuth handler path) + let token = build_self_signed_ucan( + &user_keys, + tenant_id, + &redirect_origin, + Some(&bunker_pubkey), + ) + .await; + let auth_state = create_test_auth_state( + pool.clone(), + Arc::new(Box::new(key_manager) as Box), + ); + + let unsigned = EventBuilder::text_note("should be denied").build(user_keys.public_key()); + let event_json = serde_json::to_value(&unsigned).expect("Failed to serialize unsigned event"); + + let err = invoke_nostr_rpc( + create_test_tenant_extractor(tenant_id), + auth_state, + &format!("Bearer {}", token), + None, + NostrRpcRequest { + method: "sign_event".to_string(), + params: vec![event_json], + }, + ) + .await + .expect_err("Suspended user should be denied signing via RPC"); + + match err { + RpcError::AccountSuspended(msg) => { + assert_eq!(msg, "Account restricted"); + } + other => panic!("expected AccountSuspended, got: {:?}", other), + } +} + +/// Test: suspended user can still call get_public_key via nostr_rpc +#[tokio::test] +#[serial] +async fn test_suspended_user_allowed_get_public_key() { + let pool = setup_db().await; + let tenant_id = create_test_tenant(&pool).await; + let (user_keys, pubkey) = create_test_user(); + let key_manager = FileKeyManager::new().expect("Failed to create key manager"); + + insert_user(&pool, tenant_id, &pubkey).await; + create_personal_key(&pool, tenant_id, &pubkey, &user_keys, &key_manager).await; + + let redirect_origin = format!("https://suspend-gpk-{}.example.com", Uuid::new_v4()); + let (_auth_id, bunker_pubkey) = create_test_oauth_authorization( + &pool, + tenant_id, + &pubkey, + &redirect_origin, + None, + None, + None, + ) + .await; + + // Suspend the user + suspend_user(&pool, &pubkey, tenant_id).await; + + let token = build_self_signed_ucan( + &user_keys, + tenant_id, + &redirect_origin, + Some(&bunker_pubkey), + ) + .await; + let auth_state = create_test_auth_state( + pool.clone(), + Arc::new(Box::new(key_manager) as Box), + ); + + let response = invoke_nostr_rpc( + create_test_tenant_extractor(tenant_id), + auth_state, + &format!("Bearer {}", token), + None, + get_public_key_request(), + ) + .await + .expect("Suspended user should still be able to get_public_key"); + + let result_pubkey = response + .result + .as_ref() + .and_then(|v| v.as_str()) + .expect("result should be a string"); + assert_eq!(result_pubkey, pubkey); +} diff --git a/api/tests/user_status_admin_test.rs b/api/tests/user_status_admin_test.rs new file mode 100644 index 00000000..fffe8713 --- /dev/null +++ b/api/tests/user_status_admin_test.rs @@ -0,0 +1,592 @@ +// ABOUTME: HTTP-layer tests for the service-token admin user status endpoints +// ABOUTME: Tests GET/PUT /admin/users/:pubkey/status with auth validation + +#![cfg(feature = "integration-tests")] + +mod common; + +use axum::{ + body::Body, + extract::State, + http::{Request, StatusCode}, + routing::get, + Json, Router, +}; +use chrono::Utc; +use http_body_util::BodyExt; +use keycast_api::{ + api::http::{ + admin::{ + get_user_status_admin, set_user_status_admin, SetUserStatusRequest, UserStatusResponse, + }, + routes::AuthState, + }, + bcrypt_queue::BcryptQueue, + handlers::http_rpc_handler::new_http_handler_cache, + state::KeycastState, +}; +use keycast_core::{ + encryption::{KeyManager, KeyManagerError}, + repositories::UserRepository, + secret_pool::SecretPool, +}; +use moka::future::Cache; +use nostr_sdk::Keys; +use sqlx::PgPool; +use std::sync::Arc; +use tower::ServiceExt; +use zeroize::Zeroizing; + +const TENANT_ID: i64 = 1; +const SERVICE_TOKEN: &str = "test-service-token-secret"; + +struct TestKeyManager; + +#[async_trait::async_trait] +impl KeyManager for TestKeyManager { + async fn encrypt(&self, plaintext_bytes: &[u8]) -> Result, KeyManagerError> { + Ok(plaintext_bytes.to_vec()) + } + + async fn decrypt( + &self, + ciphertext_bytes: &[u8], + ) -> Result>, KeyManagerError> { + Ok(Zeroizing::new(ciphertext_bytes.to_vec())) + } +} + +fn create_test_auth_state(pool: PgPool) -> AuthState { + let bcrypt_queue = BcryptQueue::new(); + let secret_pool = SecretPool::new(1); + let tenant_cache = Cache::builder().max_capacity(10).build(); + let key_manager: Arc> = Arc::new(Box::new(TestKeyManager)); + + AuthState { + state: Arc::new(KeycastState { + db: pool, + key_manager, + signer_handlers: None, + http_handler_cache: new_http_handler_cache(), + server_keys: Keys::generate(), + tenant_cache, + bcrypt_sender: bcrypt_queue.sender(), + redis: None, + secret_pool: secret_pool.receiver(), + }), + auth_tx: None, + } +} + +fn build_app(auth_state: AuthState) -> Router { + use keycast_api::api::tenant::{Tenant, TenantExtractor}; + + let get_state = auth_state.clone(); + let put_state = auth_state.clone(); + + Router::new().route( + "/admin/users/:pubkey/status", + get( + move |axum::extract::Path(pubkey): axum::extract::Path, + headers: axum::http::HeaderMap| { + let state = get_state.clone(); + let tenant = TenantExtractor(Arc::new(Tenant { + id: TENANT_ID, + domain: "localhost".to_string(), + name: "Test".to_string(), + settings: None, + created_at: Utc::now(), + updated_at: Utc::now(), + })); + async move { + get_user_status_admin( + tenant, + State(state), + headers, + axum::extract::Path(pubkey), + ) + .await + } + }, + ) + .put( + move |axum::extract::Path(pubkey): axum::extract::Path, + headers: axum::http::HeaderMap, + Json(body): Json| { + let state = put_state.clone(); + let tenant = TenantExtractor(Arc::new(Tenant { + id: TENANT_ID, + domain: "localhost".to_string(), + name: "Test".to_string(), + settings: None, + created_at: Utc::now(), + updated_at: Utc::now(), + })); + async move { + set_user_status_admin( + tenant, + State(state), + headers, + axum::extract::Path(pubkey), + Json(body), + ) + .await + } + }, + ), + ) +} + +async fn create_test_user(pool: &PgPool) -> String { + let pubkey = Keys::generate().public_key().to_hex(); + sqlx::query( + "INSERT INTO users (pubkey, tenant_id, created_at, updated_at) VALUES ($1, $2, NOW(), NOW())", + ) + .bind(&pubkey) + .bind(TENANT_ID) + .execute(pool) + .await + .expect("Failed to create test user"); + pubkey +} + +#[tokio::test] +async fn test_get_user_status_returns_active_by_default() { + common::assert_test_database_url(); + unsafe { std::env::set_var("KEYCAST_SERVICE_TOKEN", SERVICE_TOKEN) }; + let pool = common::setup_test_db().await; + let auth_state = create_test_auth_state(pool.clone()); + let app = build_app(auth_state); + + let pubkey = create_test_user(&pool).await; + + let resp = app + .oneshot( + Request::get(format!("/admin/users/{}/status", pubkey)) + .header("authorization", format!("Bearer {}", SERVICE_TOKEN)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + let status: UserStatusResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(status.status, "active"); + assert!(status.suspended_reason.is_none()); + assert!(status.suspended_at.is_none()); +} + +#[tokio::test] +async fn test_set_user_status_suspended() { + common::assert_test_database_url(); + unsafe { std::env::set_var("KEYCAST_SERVICE_TOKEN", SERVICE_TOKEN) }; + let pool = common::setup_test_db().await; + let auth_state = create_test_auth_state(pool.clone()); + let app = build_app(auth_state); + + let pubkey = create_test_user(&pool).await; + + let resp = app + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/admin/users/{}/status", pubkey)) + .header("authorization", format!("Bearer {}", SERVICE_TOKEN)) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_string(&serde_json::json!({ + "status": "suspended", + "reason": "age_review" + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + let status: UserStatusResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(status.status, "suspended"); + assert_eq!(status.suspended_reason.as_deref(), Some("age_review")); + assert!(status.suspended_at.is_some()); +} + +#[tokio::test] +async fn test_set_user_status_unsuspend() { + common::assert_test_database_url(); + unsafe { std::env::set_var("KEYCAST_SERVICE_TOKEN", SERVICE_TOKEN) }; + let pool = common::setup_test_db().await; + let auth_state = create_test_auth_state(pool.clone()); + + let pubkey = create_test_user(&pool).await; + + // Suspend first + let user_repo = UserRepository::new(pool.clone()); + user_repo + .set_user_status( + &pubkey, + TENANT_ID, + &keycast_core::types::user::UserStatus::Suspended, + Some("age_review"), + ) + .await + .unwrap(); + + // Now unsuspend via HTTP + let app = build_app(auth_state); + let resp = app + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/admin/users/{}/status", pubkey)) + .header("authorization", format!("Bearer {}", SERVICE_TOKEN)) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_string(&serde_json::json!({ + "status": "active" + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + let status: UserStatusResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(status.status, "active"); + assert!(status.suspended_reason.is_none()); + assert!(status.suspended_at.is_none()); +} + +#[tokio::test] +async fn test_set_user_status_invalid_status() { + common::assert_test_database_url(); + unsafe { std::env::set_var("KEYCAST_SERVICE_TOKEN", SERVICE_TOKEN) }; + let pool = common::setup_test_db().await; + let auth_state = create_test_auth_state(pool.clone()); + let app = build_app(auth_state); + + let pubkey = create_test_user(&pool).await; + + let resp = app + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/admin/users/{}/status", pubkey)) + .header("authorization", format!("Bearer {}", SERVICE_TOKEN)) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_string(&serde_json::json!({ + "status": "invalid_value", + "reason": "test" + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_set_user_status_missing_reason() { + common::assert_test_database_url(); + unsafe { std::env::set_var("KEYCAST_SERVICE_TOKEN", SERVICE_TOKEN) }; + let pool = common::setup_test_db().await; + let auth_state = create_test_auth_state(pool.clone()); + let app = build_app(auth_state); + + let pubkey = create_test_user(&pool).await; + + let resp = app + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/admin/users/{}/status", pubkey)) + .header("authorization", format!("Bearer {}", SERVICE_TOKEN)) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_string(&serde_json::json!({ + "status": "suspended" + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_set_user_status_missing_auth() { + common::assert_test_database_url(); + unsafe { std::env::set_var("KEYCAST_SERVICE_TOKEN", SERVICE_TOKEN) }; + let pool = common::setup_test_db().await; + let auth_state = create_test_auth_state(pool.clone()); + let app = build_app(auth_state); + + let pubkey = create_test_user(&pool).await; + + let resp = app + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/admin/users/{}/status", pubkey)) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_string(&serde_json::json!({ + "status": "suspended", + "reason": "test" + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_set_user_status_wrong_token() { + common::assert_test_database_url(); + unsafe { std::env::set_var("KEYCAST_SERVICE_TOKEN", SERVICE_TOKEN) }; + let pool = common::setup_test_db().await; + let auth_state = create_test_auth_state(pool.clone()); + let app = build_app(auth_state); + + let pubkey = create_test_user(&pool).await; + + let resp = app + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/admin/users/{}/status", pubkey)) + .header("authorization", "Bearer wrong-token") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_string(&serde_json::json!({ + "status": "suspended", + "reason": "test" + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_set_user_status_user_not_found() { + common::assert_test_database_url(); + unsafe { std::env::set_var("KEYCAST_SERVICE_TOKEN", SERVICE_TOKEN) }; + let pool = common::setup_test_db().await; + let auth_state = create_test_auth_state(pool.clone()); + let app = build_app(auth_state); + + let fake_pubkey = Keys::generate().public_key().to_hex(); + + let resp = app + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/admin/users/{}/status", fake_pubkey)) + .header("authorization", format!("Bearer {}", SERVICE_TOKEN)) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_string(&serde_json::json!({ + "status": "suspended", + "reason": "test" + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_get_user_status_user_not_found() { + common::assert_test_database_url(); + unsafe { std::env::set_var("KEYCAST_SERVICE_TOKEN", SERVICE_TOKEN) }; + let pool = common::setup_test_db().await; + let auth_state = create_test_auth_state(pool.clone()); + let app = build_app(auth_state); + + let fake_pubkey = Keys::generate().public_key().to_hex(); + + let resp = app + .oneshot( + Request::get(format!("/admin/users/{}/status", fake_pubkey)) + .header("authorization", format!("Bearer {}", SERVICE_TOKEN)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_escalate_suspended_to_banned_preserves_suspended_at() { + common::assert_test_database_url(); + unsafe { std::env::set_var("KEYCAST_SERVICE_TOKEN", SERVICE_TOKEN) }; + let pool = common::setup_test_db().await; + let auth_state = create_test_auth_state(pool.clone()); + + let pubkey = create_test_user(&pool).await; + + // Suspend first + let user_repo = UserRepository::new(pool.clone()); + user_repo + .set_user_status( + &pubkey, + TENANT_ID, + &keycast_core::types::user::UserStatus::Suspended, + Some("age_review"), + ) + .await + .unwrap(); + + // Read the original suspended_at + let (_, _, original_suspended_at) = user_repo + .get_user_status(&pubkey, TENANT_ID) + .await + .unwrap() + .unwrap(); + let original_ts = original_suspended_at.expect("suspended_at should be set"); + + // Small delay so timestamps differ if overwritten + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + // Escalate to banned via HTTP + let app = build_app(auth_state); + let resp = app + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/admin/users/{}/status", pubkey)) + .header("authorization", format!("Bearer {}", SERVICE_TOKEN)) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_string(&serde_json::json!({ + "status": "banned", + "reason": "policy_violation" + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + let status: UserStatusResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(status.status, "banned"); + assert_eq!(status.suspended_reason.as_deref(), Some("policy_violation")); + // suspended_at should be preserved from the original suspension, not overwritten + let banned_ts = status + .suspended_at + .expect("suspended_at should still be set"); + assert_eq!(banned_ts, original_ts); +} + +#[tokio::test] +async fn test_set_user_status_whitespace_only_reason_rejected() { + common::assert_test_database_url(); + unsafe { std::env::set_var("KEYCAST_SERVICE_TOKEN", SERVICE_TOKEN) }; + let pool = common::setup_test_db().await; + let auth_state = create_test_auth_state(pool.clone()); + let app = build_app(auth_state); + + let pubkey = create_test_user(&pool).await; + + let resp = app + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/admin/users/{}/status", pubkey)) + .header("authorization", format!("Bearer {}", SERVICE_TOKEN)) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_string(&serde_json::json!({ + "status": "suspended", + "reason": " " + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_reactivate_from_banned_clears_suspended_at() { + common::assert_test_database_url(); + unsafe { std::env::set_var("KEYCAST_SERVICE_TOKEN", SERVICE_TOKEN) }; + let pool = common::setup_test_db().await; + let auth_state = create_test_auth_state(pool.clone()); + + let pubkey = create_test_user(&pool).await; + + // Ban user directly + let user_repo = UserRepository::new(pool.clone()); + user_repo + .set_user_status( + &pubkey, + TENANT_ID, + &keycast_core::types::user::UserStatus::Banned, + Some("policy_violation"), + ) + .await + .unwrap(); + + // Reactivate via HTTP + let app = build_app(auth_state); + let resp = app + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/admin/users/{}/status", pubkey)) + .header("authorization", format!("Bearer {}", SERVICE_TOKEN)) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_string(&serde_json::json!({ + "status": "active" + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + let status: UserStatusResponse = serde_json::from_slice(&body).unwrap(); + assert_eq!(status.status, "active"); + assert!(status.suspended_reason.is_none()); + assert!(status.suspended_at.is_none()); +} diff --git a/core/src/repositories/user.rs b/core/src/repositories/user.rs index c7c7d4fc..b31efed8 100644 --- a/core/src/repositories/user.rs +++ b/core/src/repositories/user.rs @@ -2,11 +2,18 @@ // ABOUTME: Provides methods for finding, creating, and querying user data use crate::repositories::RepositoryError; -use crate::types::user::{User, UserAtprotoState}; +use crate::types::user::{User, UserAtprotoState, UserStatus}; use chrono::{DateTime, Utc}; use nostr_sdk::PublicKey; use sqlx::{FromRow, PgPool}; +pub type StatusTransition = ( + UserStatus, + UserStatus, + Option, + Option>, +); + /// Data returned when looking up a user by verification token. /// Includes fields needed to check async bcrypt completion state. #[derive(Debug, FromRow)] @@ -28,6 +35,8 @@ pub struct AdminUserDetails { pub display_name: Option, pub vine_id: Option, pub has_personal_key: bool, + pub status: UserStatus, + pub suspended_reason: Option, pub created_at: DateTime, pub updated_at: DateTime, } @@ -51,7 +60,7 @@ impl UserRepository { pubkey: &PublicKey, ) -> Result { sqlx::query_as::<_, User>( - "SELECT pubkey, created_at, updated_at FROM users WHERE tenant_id = $1 AND pubkey = $2", + "SELECT pubkey, created_at, updated_at, status, suspended_reason, suspended_at FROM users WHERE tenant_id = $1 AND pubkey = $2", ) .bind(tenant_id) .bind(pubkey.to_hex()) @@ -75,7 +84,7 @@ impl UserRepository { "INSERT INTO users (tenant_id, pubkey, created_at, updated_at) VALUES ($1, $2, NOW(), NOW()) ON CONFLICT (pubkey) DO UPDATE SET updated_at = users.updated_at - RETURNING pubkey, created_at, updated_at", + RETURNING pubkey, created_at, updated_at, status, suspended_reason, suspended_at", ) .bind(tenant_id) .bind(&pubkey_hex) @@ -195,15 +204,15 @@ impl UserRepository { Ok(result.map(|r| r.0)) } - /// Find user with password hash and email verification status for login verification. - /// Returns (pubkey, password_hash, email_verified). + /// Find user with password hash, email verification status, and account status for login verification. + /// Returns (pubkey, password_hash, email_verified, status). pub async fn find_with_password( &self, email: &str, tenant_id: i64, - ) -> Result, RepositoryError> { + ) -> Result, RepositoryError> { sqlx::query_as( - "SELECT pubkey, password_hash, email_verified FROM users WHERE email = $1 AND tenant_id = $2 AND password_hash IS NOT NULL", + "SELECT pubkey, password_hash, email_verified, status FROM users WHERE email = $1 AND tenant_id = $2 AND password_hash IS NOT NULL", ) .bind(email) .bind(tenant_id) @@ -615,15 +624,33 @@ impl UserRepository { .map_err(Into::into) } - /// Get user's email and verified status. - /// Returns None if user doesn't exist, Some with nullable email/verified if user exists. + /// Get user's email, verified status, and account status. + /// Returns None if user doesn't exist. pub async fn get_account_status( &self, pubkey: &str, tenant_id: i64, - ) -> Result, Option)>, RepositoryError> { + ) -> Result, Option, UserStatus, Option)>, RepositoryError> + { + sqlx::query_as( + "SELECT email, email_verified, status, suspended_reason FROM users WHERE pubkey = $1 AND tenant_id = $2", + ) + .bind(pubkey) + .bind(tenant_id) + .fetch_optional(&self.pool) + .await + .map_err(Into::into) + } + + /// Get user's account status fields for admin queries. + /// Returns (status, suspended_reason, suspended_at) or None if user not found. + pub async fn get_user_status( + &self, + pubkey: &str, + tenant_id: i64, + ) -> Result, Option>)>, RepositoryError> { sqlx::query_as( - "SELECT email, email_verified FROM users WHERE pubkey = $1 AND tenant_id = $2", + "SELECT status, suspended_reason, suspended_at FROM users WHERE pubkey = $1 AND tenant_id = $2", ) .bind(pubkey) .bind(tenant_id) @@ -632,6 +659,42 @@ impl UserRepository { .map_err(Into::into) } + /// Set user's account status. Clears suspended_reason/suspended_at when setting to active. + /// Preserves suspended_at when escalating between non-active states (e.g. suspended → banned). + /// Returns (old_status, new_status, suspended_reason, suspended_at) atomically via CTE. + pub async fn set_user_status( + &self, + pubkey: &str, + tenant_id: i64, + status: &UserStatus, + reason: Option<&str>, + ) -> Result { + let now = Utc::now(); + let suspended_reason: Option<&str> = if status.is_active() { None } else { reason }; + // When setting to active, clear suspended_at. When restricting, preserve existing + // suspended_at if already set (escalation), otherwise set it now. + let row: Option = sqlx::query_as( + "WITH old AS (SELECT status, suspended_at FROM users WHERE pubkey = $5 AND tenant_id = $6) \ + UPDATE users SET status = $1, suspended_reason = $2, \ + suspended_at = CASE WHEN $1 = 'active' THEN NULL \ + WHEN (SELECT suspended_at FROM old) IS NOT NULL THEN (SELECT suspended_at FROM old) \ + ELSE $3 END, \ + updated_at = $4 \ + WHERE pubkey = $5 AND tenant_id = $6 \ + RETURNING (SELECT status FROM old), users.status, users.suspended_reason, users.suspended_at", + ) + .bind(status.as_str()) + .bind(suspended_reason) + .bind(now) + .bind(now) + .bind(pubkey) + .bind(tenant_id) + .fetch_optional(&self.pool) + .await?; + + row.ok_or_else(|| RepositoryError::NotFound("user not found".to_string())) + } + /// Check if username is available (excluding a specific pubkey). pub async fn check_username_available( &self, @@ -1254,6 +1317,8 @@ impl UserRepository { u.display_name, u.vine_id, (pk.user_pubkey IS NOT NULL) as \"has_personal_key\", + u.status, + u.suspended_reason, u.created_at, u.updated_at FROM users u diff --git a/core/src/types/user.rs b/core/src/types/user.rs index faa7fcbd..a894e41b 100644 --- a/core/src/types/user.rs +++ b/core/src/types/user.rs @@ -17,6 +17,29 @@ pub enum UserError { NotFound, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, sqlx::Type)] +#[sqlx(type_name = "TEXT", rename_all = "lowercase")] +pub enum UserStatus { + #[default] + Active, + Suspended, + Banned, +} + +impl UserStatus { + pub fn as_str(&self) -> &'static str { + match self { + UserStatus::Active => "active", + UserStatus::Suspended => "suspended", + UserStatus::Banned => "banned", + } + } + + pub fn is_active(&self) -> bool { + matches!(self, UserStatus::Active) + } +} + /// A user is a representation of a Nostr user (based solely on a pubkey value) #[derive(Debug, FromRow, Serialize, Deserialize)] pub struct User { @@ -26,6 +49,9 @@ pub struct User { pub created_at: DateTime, /// The date and time the user was last updated pub updated_at: DateTime, + pub status: UserStatus, + pub suspended_reason: Option, + pub suspended_at: Option>, } #[derive(Debug, Clone, FromRow, Serialize, Deserialize, PartialEq, Eq)] @@ -77,7 +103,7 @@ impl User { pubkey: &PublicKey, ) -> Result { match sqlx::query_as::<_, User>( - "SELECT pubkey, created_at, updated_at FROM users WHERE tenant_id = $1 AND pubkey = $2", + "SELECT pubkey, created_at, updated_at, status, suspended_reason, suspended_at FROM users WHERE tenant_id = $1 AND pubkey = $2", ) .bind(tenant_id) .bind(pubkey.to_hex()) diff --git a/database/migrations/20260518120000_add_user_status.sql b/database/migrations/20260518120000_add_user_status.sql new file mode 100644 index 00000000..6f5420d2 --- /dev/null +++ b/database/migrations/20260518120000_add_user_status.sql @@ -0,0 +1,9 @@ +ALTER TABLE users + ADD COLUMN status TEXT NOT NULL DEFAULT 'active', + ADD COLUMN suspended_reason TEXT, + ADD COLUMN suspended_at TIMESTAMPTZ; + +ALTER TABLE users + ADD CONSTRAINT users_status_check CHECK (status IN ('active', 'suspended', 'banned')); + +CREATE INDEX idx_users_status ON users (status) WHERE status != 'active'; diff --git a/signer/src/signer_daemon.rs b/signer/src/signer_daemon.rs index c049df63..410aff96 100644 --- a/signer/src/signer_daemon.rs +++ b/signer/src/signer_daemon.rs @@ -120,6 +120,28 @@ impl Nip46Handler { } } + /// Check that the user's account is active (not suspended or banned). + /// Only applies to OAuth authorizations (personal keys tied to a user account). + /// Team authorizations don't have user rows — they're managed through team admin. + async fn check_user_active(&self) -> SignerResult<()> { + if !self.is_oauth { + return Ok(()); + } + let user_pubkey = self.user_keys.public_key().to_hex(); + let status: Option<(String,)> = + sqlx::query_as("SELECT status FROM users WHERE pubkey = $1 AND tenant_id = $2") + .bind(&user_pubkey) + .bind(self.tenant_id) + .fetch_optional(&self.pool) + .await?; + + match status { + Some((s,)) if s == "active" => Ok(()), + Some(_) => Err(SignerError::permission_denied("Account restricted")), + None => Err(SignerError::permission_denied("User not found")), + } + } + /// Validate permissions before signing an event. /// /// Loads the policy permissions for this authorization and checks each one. @@ -165,12 +187,14 @@ impl Nip46Handler { } /// Validate permissions before encrypting plaintext for a recipient. + /// Includes a user status check (DB query) — callers should NOT add a separate check. #[doc(hidden)] pub async fn validate_permissions_for_encrypt( &self, plaintext: &str, recipient_pubkey: &PublicKey, ) -> SignerResult<()> { + self.check_user_active().await?; // Load permissions based on authorization type let permissions = if self.is_oauth { let oauth_auth = @@ -210,12 +234,14 @@ impl Nip46Handler { } /// Validate permissions before decrypting ciphertext from a sender. + /// Includes a user status check (DB query) — callers should NOT add a separate check. #[doc(hidden)] pub async fn validate_permissions_for_decrypt( &self, ciphertext: &str, sender_pubkey: &PublicKey, ) -> SignerResult<()> { + self.check_user_active().await?; // Load permissions based on authorization type let permissions = if self.is_oauth { let oauth_auth = @@ -1672,7 +1698,8 @@ impl SigningHandler for Nip46Handler { self.authorization_id ); - // VALIDATE PERMISSIONS BEFORE SIGNING + // Check account status and policy permissions before signing + self.check_user_active().await?; self.validate_permissions_for_sign(&unsigned_event).await?; // Canonicalize the pubkey to match the signer keys, matching SigningSession::sign_event behavior. @@ -1771,7 +1798,8 @@ impl Nip46Handler { content, ); - // VALIDATE PERMISSIONS BEFORE SIGNING + // Check account status and policy permissions before signing + self.check_user_active().await?; self.validate_permissions_for_sign(&unsigned_event).await?; // Sign the event with user keys (CPU-bound, use spawn_blocking)