this repo has no description

Add admin functionality

lewis 8d46c1d5 23b56c3e

-28
.sqlx/query-04c220298334c369872f0b0ad162b992c2353e28257b53f3f10cbff8abb26f5a.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT deactivated_at, takedown_ref FROM users WHERE did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "deactivated_at", 9 - "type_info": "Timestamptz" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "takedown_ref", 14 - "type_info": "Text" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Text" 20 - ] 21 - }, 22 - "nullable": [ 23 - true, 24 - true 25 - ] 26 - }, 27 - "hash": "04c220298334c369872f0b0ad162b992c2353e28257b53f3f10cbff8abb26f5a" 28 - }
-22
.sqlx/query-1add22e111d5eff8beadbd832b4b8146d95da0a0ce8ce31dc9a2f930a26cc9ce.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT takedown_ref FROM users WHERE did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "takedown_ref", 9 - "type_info": "Text" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - true 19 - ] 20 - }, 21 - "hash": "1add22e111d5eff8beadbd832b4b8146d95da0a0ce8ce31dc9a2f930a26cc9ce" 22 - }
+34
.sqlx/query-225c3844ce6962121e5cc0aa544c79d0f93bb3458487d79b64bd40ae9accd522.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT deactivated_at, takedown_ref, is_admin FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "deactivated_at", 9 + "type_info": "Timestamptz" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "takedown_ref", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "is_admin", 19 + "type_info": "Bool" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "Text" 25 + ] 26 + }, 27 + "nullable": [ 28 + true, 29 + true, 30 + false 31 + ] 32 + }, 33 + "hash": "225c3844ce6962121e5cc0aa544c79d0f93bb3458487d79b64bd40ae9accd522" 34 + }
+9 -3
.sqlx/query-6b67b2b6759f01be11d5997a3ad68d381f59a02235a6940877f62193af8d9761.json .sqlx/query-cc68023c320bc4376925c2cd921cd48045a47ca5841eef8c8889894f2c2452f6.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "SELECT k.key_bytes, k.encryption_version, u.deactivated_at, u.takedown_ref\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1", 3 + "query": "SELECT k.key_bytes, k.encryption_version, u.deactivated_at, u.takedown_ref, u.is_admin\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 22 22 "ordinal": 3, 23 23 "name": "takedown_ref", 24 24 "type_info": "Text" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "is_admin", 29 + "type_info": "Bool" 25 30 } 26 31 ], 27 32 "parameters": { ··· 33 38 false, 34 39 true, 35 40 true, 36 - true 41 + true, 42 + false 37 43 ] 38 44 }, 39 - "hash": "6b67b2b6759f01be11d5997a3ad68d381f59a02235a6940877f62193af8d9761" 45 + "hash": "cc68023c320bc4376925c2cd921cd48045a47ca5841eef8c8889894f2c2452f6" 40 46 }
-28
.sqlx/query-90bcc8fb97f73a0b5f427971aca891936b3f906c2d4cdb4bf203dd6a4c9aa060.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT k.key_bytes, k.encryption_version FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "key_bytes", 9 - "type_info": "Bytea" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "encryption_version", 14 - "type_info": "Int4" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Text" 20 - ] 21 - }, 22 - "nullable": [ 23 - false, 24 - true 25 - ] 26 - }, 27 - "hash": "90bcc8fb97f73a0b5f427971aca891936b3f906c2d4cdb4bf203dd6a4c9aa060" 28 - }
+9 -3
.sqlx/query-bee4276cbb537512cced16f7017d8f7c068d30f319ef965fa9ec9fb1a3490151.json .sqlx/query-49cd5f335121f5eb4f578f6ca3af40e95264ded8021cfc7490b578a96fb8db3c.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "SELECT t.did, t.expires_at, u.deactivated_at, u.takedown_ref,\n k.key_bytes as \"key_bytes?\", k.encryption_version as \"encryption_version?\"\n FROM oauth_token t\n JOIN users u ON t.did = u.did\n LEFT JOIN user_keys k ON u.id = k.user_id\n WHERE t.token_id = $1", 3 + "query": "SELECT t.did, t.expires_at, u.deactivated_at, u.takedown_ref, u.is_admin,\n k.key_bytes as \"key_bytes?\", k.encryption_version as \"encryption_version?\"\n FROM oauth_token t\n JOIN users u ON t.did = u.did\n LEFT JOIN user_keys k ON u.id = k.user_id\n WHERE t.token_id = $1", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 25 25 }, 26 26 { 27 27 "ordinal": 4, 28 + "name": "is_admin", 29 + "type_info": "Bool" 30 + }, 31 + { 32 + "ordinal": 5, 28 33 "name": "key_bytes?", 29 34 "type_info": "Bytea" 30 35 }, 31 36 { 32 - "ordinal": 5, 37 + "ordinal": 6, 33 38 "name": "encryption_version?", 34 39 "type_info": "Int4" 35 40 } ··· 45 50 true, 46 51 true, 47 52 false, 53 + false, 48 54 true 49 55 ] 50 56 }, 51 - "hash": "bee4276cbb537512cced16f7017d8f7c068d30f319ef965fa9ec9fb1a3490151" 57 + "hash": "49cd5f335121f5eb4f578f6ca3af40e95264ded8021cfc7490b578a96fb8db3c" 52 58 }
+46
.sqlx/query-e6077393f797f94d6048f01edd45b27a89ea481427753a860215d6ee85f8dcf8.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT u.deactivated_at, u.takedown_ref, u.is_admin,\n k.key_bytes as \"key_bytes?\", k.encryption_version as \"encryption_version?\"\n FROM users u\n LEFT JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "deactivated_at", 9 + "type_info": "Timestamptz" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "takedown_ref", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "is_admin", 19 + "type_info": "Bool" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "key_bytes?", 24 + "type_info": "Bytea" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "encryption_version?", 29 + "type_info": "Int4" 30 + } 31 + ], 32 + "parameters": { 33 + "Left": [ 34 + "Text" 35 + ] 36 + }, 37 + "nullable": [ 38 + true, 39 + true, 40 + false, 41 + false, 42 + true 43 + ] 44 + }, 45 + "hash": "e6077393f797f94d6048f01edd45b27a89ea481427753a860215d6ee85f8dcf8" 46 + }
+20
.sqlx/query-fd64104d130b93dd5fc9414b8710ad5183b647eaaff90decbce15e10d83c7538.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT COUNT(*) as count FROM users", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "count", 9 + "type_info": "Int8" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [] 14 + }, 15 + "nullable": [ 16 + null 17 + ] 18 + }, 19 + "hash": "fd64104d130b93dd5fc9414b8710ad5183b647eaaff90decbce15e10d83c7538" 20 + }
+1
migrations/20251218_add_is_admin.sql
··· 1 + ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE;
+2 -9
src/api/admin/account/delete.rs
··· 1 + use crate::auth::BearerAuthAdmin; 1 2 use crate::state::AppState; 2 3 use axum::{ 3 4 Json, ··· 16 17 17 18 pub async fn delete_account( 18 19 State(state): State<AppState>, 19 - headers: axum::http::HeaderMap, 20 + _auth: BearerAuthAdmin, 20 21 Json(input): Json<DeleteAccountInput>, 21 22 ) -> Response { 22 - let auth_header = headers.get("Authorization"); 23 - if auth_header.is_none() { 24 - return ( 25 - StatusCode::UNAUTHORIZED, 26 - Json(json!({"error": "AuthenticationRequired"})), 27 - ) 28 - .into_response(); 29 - } 30 23 let did = input.did.trim(); 31 24 if did.is_empty() { 32 25 return (
+2 -9
src/api/admin/account/email.rs
··· 1 + use crate::auth::BearerAuthAdmin; 1 2 use crate::state::AppState; 2 3 use axum::{ 3 4 Json, ··· 26 27 27 28 pub async fn send_email( 28 29 State(state): State<AppState>, 29 - headers: axum::http::HeaderMap, 30 + _auth: BearerAuthAdmin, 30 31 Json(input): Json<SendEmailInput>, 31 32 ) -> Response { 32 - let auth_header = headers.get("Authorization"); 33 - if auth_header.is_none() { 34 - return ( 35 - StatusCode::UNAUTHORIZED, 36 - Json(json!({"error": "AuthenticationRequired"})), 37 - ) 38 - .into_response(); 39 - } 40 33 let recipient_did = input.recipient_did.trim(); 41 34 let content = input.content.trim(); 42 35 if recipient_did.is_empty() {
+3 -18
src/api/admin/account/info.rs
··· 1 + use crate::auth::BearerAuthAdmin; 1 2 use crate::state::AppState; 2 3 use axum::{ 3 4 Json, ··· 35 36 36 37 pub async fn get_account_info( 37 38 State(state): State<AppState>, 38 - headers: axum::http::HeaderMap, 39 + _auth: BearerAuthAdmin, 39 40 Query(params): Query<GetAccountInfoParams>, 40 41 ) -> Response { 41 - let auth_header = headers.get("Authorization"); 42 - if auth_header.is_none() { 43 - return ( 44 - StatusCode::UNAUTHORIZED, 45 - Json(json!({"error": "AuthenticationRequired"})), 46 - ) 47 - .into_response(); 48 - } 49 42 let did = params.did.trim(); 50 43 if did.is_empty() { 51 44 return ( ··· 102 95 103 96 pub async fn get_account_infos( 104 97 State(state): State<AppState>, 105 - headers: axum::http::HeaderMap, 98 + _auth: BearerAuthAdmin, 106 99 Query(params): Query<GetAccountInfosParams>, 107 100 ) -> Response { 108 - let auth_header = headers.get("Authorization"); 109 - if auth_header.is_none() { 110 - return ( 111 - StatusCode::UNAUTHORIZED, 112 - Json(json!({"error": "AuthenticationRequired"})), 113 - ) 114 - .into_response(); 115 - } 116 101 let dids: Vec<&str> = params.dids.split(',').map(|s| s.trim()).collect(); 117 102 if dids.is_empty() { 118 103 return (
+3 -20
src/api/admin/account/profile.rs
··· 1 1 use crate::api::repo::record::create_record_internal; 2 + use crate::auth::BearerAuthAdmin; 2 3 use crate::state::AppState; 3 4 use axum::{ 4 5 Json, ··· 36 37 37 38 pub async fn create_profile( 38 39 State(state): State<AppState>, 39 - headers: axum::http::HeaderMap, 40 + _auth: BearerAuthAdmin, 40 41 Json(input): Json<CreateProfileInput>, 41 42 ) -> Response { 42 - let auth_header = headers.get("Authorization"); 43 - if auth_header.is_none() { 44 - return ( 45 - StatusCode::UNAUTHORIZED, 46 - Json(json!({"error": "AuthenticationRequired"})), 47 - ) 48 - .into_response(); 49 - } 50 - 51 43 let did = input.did.trim(); 52 44 if did.is_empty() { 53 45 return ( ··· 101 93 102 94 pub async fn create_record_admin( 103 95 State(state): State<AppState>, 104 - headers: axum::http::HeaderMap, 96 + _auth: BearerAuthAdmin, 105 97 Json(input): Json<CreateRecordAdminInput>, 106 98 ) -> Response { 107 - let auth_header = headers.get("Authorization"); 108 - if auth_header.is_none() { 109 - return ( 110 - StatusCode::UNAUTHORIZED, 111 - Json(json!({"error": "AuthenticationRequired"})), 112 - ) 113 - .into_response(); 114 - } 115 - 116 99 let did = input.did.trim(); 117 100 if did.is_empty() { 118 101 return (
+4 -27
src/api/admin/account/update.rs
··· 1 + use crate::auth::BearerAuthAdmin; 1 2 use crate::state::AppState; 2 3 use axum::{ 3 4 Json, ··· 17 18 18 19 pub async fn update_account_email( 19 20 State(state): State<AppState>, 20 - headers: axum::http::HeaderMap, 21 + _auth: BearerAuthAdmin, 21 22 Json(input): Json<UpdateAccountEmailInput>, 22 23 ) -> Response { 23 - let auth_header = headers.get("Authorization"); 24 - if auth_header.is_none() { 25 - return ( 26 - StatusCode::UNAUTHORIZED, 27 - Json(json!({"error": "AuthenticationRequired"})), 28 - ) 29 - .into_response(); 30 - } 31 24 let account = input.account.trim(); 32 25 let email = input.email.trim(); 33 26 if account.is_empty() || email.is_empty() { ··· 70 63 71 64 pub async fn update_account_handle( 72 65 State(state): State<AppState>, 73 - headers: axum::http::HeaderMap, 66 + _auth: BearerAuthAdmin, 74 67 Json(input): Json<UpdateAccountHandleInput>, 75 68 ) -> Response { 76 - let auth_header = headers.get("Authorization"); 77 - if auth_header.is_none() { 78 - return ( 79 - StatusCode::UNAUTHORIZED, 80 - Json(json!({"error": "AuthenticationRequired"})), 81 - ) 82 - .into_response(); 83 - } 84 69 let did = input.did.trim(); 85 70 let handle = input.handle.trim(); 86 71 if did.is_empty() || handle.is_empty() { ··· 158 143 159 144 pub async fn update_account_password( 160 145 State(state): State<AppState>, 161 - headers: axum::http::HeaderMap, 146 + _auth: BearerAuthAdmin, 162 147 Json(input): Json<UpdateAccountPasswordInput>, 163 148 ) -> Response { 164 - let auth_header = headers.get("Authorization"); 165 - if auth_header.is_none() { 166 - return ( 167 - StatusCode::UNAUTHORIZED, 168 - Json(json!({"error": "AuthenticationRequired"})), 169 - ) 170 - .into_response(); 171 - } 172 149 let did = input.did.trim(); 173 150 let password = input.password.trim(); 174 151 if did.is_empty() || password.is_empty() {
+5 -36
src/api/admin/invite.rs
··· 1 + use crate::auth::BearerAuthAdmin; 1 2 use crate::state::AppState; 2 3 use axum::{ 3 4 Json, ··· 18 19 19 20 pub async fn disable_invite_codes( 20 21 State(state): State<AppState>, 21 - headers: axum::http::HeaderMap, 22 + _auth: BearerAuthAdmin, 22 23 Json(input): Json<DisableInviteCodesInput>, 23 24 ) -> Response { 24 - let auth_header = headers.get("Authorization"); 25 - if auth_header.is_none() { 26 - return ( 27 - StatusCode::UNAUTHORIZED, 28 - Json(json!({"error": "AuthenticationRequired"})), 29 - ) 30 - .into_response(); 31 - } 32 25 if let Some(codes) = &input.codes { 33 26 for code in codes { 34 27 let _ = sqlx::query!( ··· 91 84 92 85 pub async fn get_invite_codes( 93 86 State(state): State<AppState>, 94 - headers: axum::http::HeaderMap, 87 + _auth: BearerAuthAdmin, 95 88 Query(params): Query<GetInviteCodesParams>, 96 89 ) -> Response { 97 - let auth_header = headers.get("Authorization"); 98 - if auth_header.is_none() { 99 - return ( 100 - StatusCode::UNAUTHORIZED, 101 - Json(json!({"error": "AuthenticationRequired"})), 102 - ) 103 - .into_response(); 104 - } 105 90 let limit = params.limit.unwrap_or(100).clamp(1, 500); 106 91 let sort = params.sort.as_deref().unwrap_or("recent"); 107 92 let order_clause = match sort { ··· 229 214 230 215 pub async fn disable_account_invites( 231 216 State(state): State<AppState>, 232 - headers: axum::http::HeaderMap, 217 + _auth: BearerAuthAdmin, 233 218 Json(input): Json<DisableAccountInvitesInput>, 234 219 ) -> Response { 235 - let auth_header = headers.get("Authorization"); 236 - if auth_header.is_none() { 237 - return ( 238 - StatusCode::UNAUTHORIZED, 239 - Json(json!({"error": "AuthenticationRequired"})), 240 - ) 241 - .into_response(); 242 - } 243 220 let account = input.account.trim(); 244 221 if account.is_empty() { 245 222 return ( ··· 283 260 284 261 pub async fn enable_account_invites( 285 262 State(state): State<AppState>, 286 - headers: axum::http::HeaderMap, 263 + _auth: BearerAuthAdmin, 287 264 Json(input): Json<EnableAccountInvitesInput>, 288 265 ) -> Response { 289 - let auth_header = headers.get("Authorization"); 290 - if auth_header.is_none() { 291 - return ( 292 - StatusCode::UNAUTHORIZED, 293 - Json(json!({"error": "AuthenticationRequired"})), 294 - ) 295 - .into_response(); 296 - } 297 266 let account = input.account.trim(); 298 267 if account.is_empty() { 299 268 return (
+2 -12
src/api/admin/server_stats.rs
··· 1 + use crate::auth::BearerAuthAdmin; 1 2 use crate::state::AppState; 2 3 use axum::{ 3 4 Json, 4 5 extract::State, 5 - http::{HeaderMap, StatusCode}, 6 6 response::{IntoResponse, Response}, 7 7 }; 8 8 use serde::Serialize; 9 - use serde_json::json; 10 9 11 10 #[derive(Serialize)] 12 11 #[serde(rename_all = "camelCase")] ··· 19 18 20 19 pub async fn get_server_stats( 21 20 State(state): State<AppState>, 22 - headers: HeaderMap, 21 + _auth: BearerAuthAdmin, 23 22 ) -> Response { 24 - let auth_header = headers.get("Authorization"); 25 - if auth_header.is_none() { 26 - return ( 27 - StatusCode::UNAUTHORIZED, 28 - Json(json!({"error": "AuthenticationRequired"})), 29 - ) 30 - .into_response(); 31 - } 32 - 33 23 let user_count: i64 = match sqlx::query_scalar!("SELECT COUNT(*) FROM users") 34 24 .fetch_one(&state.db) 35 25 .await
+3 -18
src/api/admin/status.rs
··· 1 + use crate::auth::BearerAuthAdmin; 1 2 use crate::state::AppState; 2 3 use axum::{ 3 4 Json, ··· 32 33 33 34 pub async fn get_subject_status( 34 35 State(state): State<AppState>, 35 - headers: axum::http::HeaderMap, 36 + _auth: BearerAuthAdmin, 36 37 Query(params): Query<GetSubjectStatusParams>, 37 38 ) -> Response { 38 - let auth_header = headers.get("Authorization"); 39 - if auth_header.is_none() { 40 - return ( 41 - StatusCode::UNAUTHORIZED, 42 - Json(json!({"error": "AuthenticationRequired"})), 43 - ) 44 - .into_response(); 45 - } 46 39 if params.did.is_none() && params.uri.is_none() && params.blob.is_none() { 47 40 return ( 48 41 StatusCode::BAD_REQUEST, ··· 208 201 209 202 pub async fn update_subject_status( 210 203 State(state): State<AppState>, 211 - headers: axum::http::HeaderMap, 204 + _auth: BearerAuthAdmin, 212 205 Json(input): Json<UpdateSubjectStatusInput>, 213 206 ) -> Response { 214 - let auth_header = headers.get("Authorization"); 215 - if auth_header.is_none() { 216 - return ( 217 - StatusCode::UNAUTHORIZED, 218 - Json(json!({"error": "AuthenticationRequired"})), 219 - ) 220 - .into_response(); 221 - } 222 207 let subject_type = input.subject.get("$type").and_then(|t| t.as_str()); 223 208 match subject_type { 224 209 Some("com.atproto.admin.defs#repoRef") => {
+9 -2
src/api/identity/account.rs
··· 379 379 }; 380 380 let verification_code = format!("{:06}", rand::random::<u32>() % 1_000_000); 381 381 let code_expires_at = chrono::Utc::now() + chrono::Duration::minutes(30); 382 + let is_first_user = sqlx::query_scalar!("SELECT COUNT(*) as count FROM users") 383 + .fetch_one(&mut *tx) 384 + .await 385 + .map(|c| c.unwrap_or(0) == 0) 386 + .unwrap_or(false); 382 387 let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as( 383 388 r#"INSERT INTO users ( 384 389 handle, email, did, password_hash, 385 390 preferred_notification_channel, 386 - discord_id, telegram_username, signal_number 387 - ) VALUES ($1, $2, $3, $4, $5::notification_channel, $6, $7, $8) RETURNING id"#, 391 + discord_id, telegram_username, signal_number, 392 + is_admin 393 + ) VALUES ($1, $2, $3, $4, $5::notification_channel, $6, $7, $8, $9) RETURNING id"#, 388 394 ) 389 395 .bind(short_handle) 390 396 .bind(&email) ··· 412 418 .map(|s| s.trim()) 413 419 .filter(|s| !s.is_empty()), 414 420 ) 421 + .bind(is_first_user) 415 422 .fetch_one(&mut *tx) 416 423 .await; 417 424 let user_id = match user_insert {
+38
src/auth/extractor.rs
··· 21 21 AuthenticationFailed, 22 22 AccountDeactivated, 23 23 AccountTakedown, 24 + AdminRequired, 24 25 } 25 26 26 27 impl IntoResponse for AuthError { ··· 50 51 StatusCode::UNAUTHORIZED, 51 52 "AccountTakedown", 52 53 "Account has been taken down", 54 + ), 55 + AuthError::AdminRequired => ( 56 + StatusCode::FORBIDDEN, 57 + "AdminRequired", 58 + "This action requires admin privileges", 53 59 ), 54 60 }; 55 61 ··· 176 182 177 183 match validate_bearer_token_cached_allow_deactivated(&state.db, &state.cache, token).await { 178 184 Ok(user) => Ok(BearerAuthAllowDeactivated(user)), 185 + Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown), 186 + Err(_) => Err(AuthError::AuthenticationFailed), 187 + } 188 + } 189 + } 190 + 191 + pub struct BearerAuthAdmin(pub AuthenticatedUser); 192 + 193 + impl FromRequestParts<AppState> for BearerAuthAdmin { 194 + type Rejection = AuthError; 195 + 196 + async fn from_request_parts( 197 + parts: &mut Parts, 198 + state: &AppState, 199 + ) -> Result<Self, Self::Rejection> { 200 + let auth_header = parts 201 + .headers 202 + .get(AUTHORIZATION) 203 + .ok_or(AuthError::MissingToken)? 204 + .to_str() 205 + .map_err(|_| AuthError::InvalidFormat)?; 206 + 207 + let token = extract_bearer_token(auth_header)?; 208 + 209 + match validate_bearer_token_cached(&state.db, &state.cache, token).await { 210 + Ok(user) => { 211 + if !user.is_admin { 212 + return Err(AuthError::AdminRequired); 213 + } 214 + Ok(BearerAuthAdmin(user)) 215 + } 216 + Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated), 179 217 Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown), 180 218 Err(_) => Err(AuthError::AuthenticationFailed), 181 219 }
+34 -37
src/auth/mod.rs
··· 11 11 pub mod verify; 12 12 13 13 pub use extractor::{ 14 - AuthError, BearerAuth, BearerAuthAllowDeactivated, ExtractedToken, 14 + AuthError, BearerAuth, BearerAuthAdmin, BearerAuthAllowDeactivated, ExtractedToken, 15 15 extract_auth_token_from_header, extract_bearer_token_from_header, 16 16 }; 17 17 pub use token::{ ··· 50 50 pub did: String, 51 51 pub key_bytes: Option<Vec<u8>>, 52 52 pub is_oauth: bool, 53 + pub is_admin: bool, 53 54 } 54 55 55 56 pub async fn validate_bearer_token( ··· 103 104 } 104 105 } 105 106 106 - let (decrypted_key, deactivated_at, takedown_ref) = if let Some(key) = cached_key { 107 + let (decrypted_key, deactivated_at, takedown_ref, is_admin) = if let Some(key) = cached_key { 107 108 let user_status = sqlx::query!( 108 - "SELECT deactivated_at, takedown_ref FROM users WHERE did = $1", 109 + "SELECT deactivated_at, takedown_ref, is_admin FROM users WHERE did = $1", 109 110 did 110 111 ) 111 112 .fetch_optional(db) ··· 114 115 .flatten(); 115 116 116 117 match user_status { 117 - Some(status) => (Some(key), status.deactivated_at, status.takedown_ref), 118 - None => (None, None, None), 118 + Some(status) => (Some(key), status.deactivated_at, status.takedown_ref, status.is_admin), 119 + None => (None, None, None, false), 119 120 } 120 121 } else if let Some(user) = sqlx::query!( 121 - "SELECT k.key_bytes, k.encryption_version, u.deactivated_at, u.takedown_ref 122 + "SELECT k.key_bytes, k.encryption_version, u.deactivated_at, u.takedown_ref, u.is_admin 122 123 FROM users u 123 124 JOIN user_keys k ON u.id = k.user_id 124 125 WHERE u.did = $1", ··· 142 143 .await; 143 144 } 144 145 145 - (Some(key), user.deactivated_at, user.takedown_ref) 146 + (Some(key), user.deactivated_at, user.takedown_ref, user.is_admin) 146 147 } else { 147 - (None, None, None) 148 + (None, None, None, false) 148 149 }; 149 150 150 151 if let Some(decrypted_key) = decrypted_key { ··· 200 201 did: did.clone(), 201 202 key_bytes: Some(decrypted_key), 202 203 is_oauth: false, 204 + is_admin, 203 205 }); 204 206 } 205 207 } ··· 208 210 209 211 if let Ok(oauth_info) = crate::oauth::verify::extract_oauth_token_info(token) 210 212 && let Some(oauth_token) = sqlx::query!( 211 - r#"SELECT t.did, t.expires_at, u.deactivated_at, u.takedown_ref, 213 + r#"SELECT t.did, t.expires_at, u.deactivated_at, u.takedown_ref, u.is_admin, 212 214 k.key_bytes as "key_bytes?", k.encryption_version as "encryption_version?" 213 215 FROM oauth_token t 214 216 JOIN users u ON t.did = u.did ··· 242 244 did: oauth_token.did, 243 245 key_bytes, 244 246 is_oauth: true, 247 + is_admin: oauth_token.is_admin, 245 248 }); 246 249 } 247 250 } ··· 280 283 .await 281 284 { 282 285 Ok(result) => { 283 - if !allow_deactivated { 284 - let deactivated = sqlx::query_scalar!( 285 - "SELECT deactivated_at FROM users WHERE did = $1", 286 - result.did 287 - ) 288 - .fetch_optional(db) 289 - .await 290 - .ok() 291 - .flatten() 292 - .flatten(); 293 - if deactivated.is_some() { 294 - return Err(TokenValidationError::AccountDeactivated); 295 - } 296 - } 297 - let takedown = 298 - sqlx::query_scalar!("SELECT takedown_ref FROM users WHERE did = $1", result.did) 299 - .fetch_optional(db) 300 - .await 301 - .ok() 302 - .flatten() 303 - .flatten(); 304 - if takedown.is_some() { 305 - return Err(TokenValidationError::AccountTakedown); 306 - } 307 - let key_bytes = sqlx::query!( 308 - "SELECT k.key_bytes, k.encryption_version FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.did = $1", 286 + let user_info = sqlx::query!( 287 + r#"SELECT u.deactivated_at, u.takedown_ref, u.is_admin, 288 + k.key_bytes as "key_bytes?", k.encryption_version as "encryption_version?" 289 + FROM users u 290 + LEFT JOIN user_keys k ON u.id = k.user_id 291 + WHERE u.did = $1"#, 309 292 result.did 310 293 ) 311 294 .fetch_optional(db) 312 295 .await 313 296 .ok() 314 - .flatten() 315 - .and_then(|row| crate::config::decrypt_key(&row.key_bytes, row.encryption_version).ok()); 297 + .flatten(); 298 + let Some(user_info) = user_info else { 299 + return Err(TokenValidationError::AuthenticationFailed); 300 + }; 301 + if !allow_deactivated && user_info.deactivated_at.is_some() { 302 + return Err(TokenValidationError::AccountDeactivated); 303 + } 304 + if user_info.takedown_ref.is_some() { 305 + return Err(TokenValidationError::AccountTakedown); 306 + } 307 + let key_bytes = if let (Some(kb), Some(ev)) = (&user_info.key_bytes, user_info.encryption_version) { 308 + crate::config::decrypt_key(kb, Some(ev)).ok() 309 + } else { 310 + None 311 + }; 316 312 Ok(AuthenticatedUser { 317 313 did: result.did, 318 314 key_bytes, 319 315 is_oauth: true, 316 + is_admin: user_info.is_admin, 320 317 }) 321 318 } 322 319 Err(_) => Err(TokenValidationError::AuthenticationFailed),
+5 -5
tests/admin_email.rs
··· 18 18 let client = common::client(); 19 19 let base_url = common::base_url().await; 20 20 let pool = get_pool().await; 21 - let (access_jwt, did) = common::create_account_and_login(&client).await; 21 + let (access_jwt, did) = common::create_admin_account_and_login(&client).await; 22 22 let res = client 23 23 .post(format!("{}/xrpc/com.atproto.admin.sendEmail", base_url)) 24 24 .bearer_auth(&access_jwt) ··· 58 58 let client = common::client(); 59 59 let base_url = common::base_url().await; 60 60 let pool = get_pool().await; 61 - let (access_jwt, did) = common::create_account_and_login(&client).await; 61 + let (access_jwt, did) = common::create_admin_account_and_login(&client).await; 62 62 let res = client 63 63 .post(format!("{}/xrpc/com.atproto.admin.sendEmail", base_url)) 64 64 .bearer_auth(&access_jwt) ··· 92 92 async fn test_send_email_recipient_not_found() { 93 93 let client = common::client(); 94 94 let base_url = common::base_url().await; 95 - let (access_jwt, _) = common::create_account_and_login(&client).await; 95 + let (access_jwt, _) = common::create_admin_account_and_login(&client).await; 96 96 let res = client 97 97 .post(format!("{}/xrpc/com.atproto.admin.sendEmail", base_url)) 98 98 .bearer_auth(&access_jwt) ··· 113 113 async fn test_send_email_missing_content() { 114 114 let client = common::client(); 115 115 let base_url = common::base_url().await; 116 - let (access_jwt, did) = common::create_account_and_login(&client).await; 116 + let (access_jwt, did) = common::create_admin_account_and_login(&client).await; 117 117 let res = client 118 118 .post(format!("{}/xrpc/com.atproto.admin.sendEmail", base_url)) 119 119 .bearer_auth(&access_jwt) ··· 134 134 async fn test_send_email_missing_recipient() { 135 135 let client = common::client(); 136 136 let base_url = common::base_url().await; 137 - let (access_jwt, _) = common::create_account_and_login(&client).await; 137 + let (access_jwt, _) = common::create_admin_account_and_login(&client).await; 138 138 let res = client 139 139 .post(format!("{}/xrpc/com.atproto.admin.sendEmail", base_url)) 140 140 .bearer_auth(&access_jwt)
+8 -8
tests/admin_invite.rs
··· 7 7 #[tokio::test] 8 8 async fn test_admin_get_invite_codes_success() { 9 9 let client = client(); 10 - let (access_jwt, _did) = create_account_and_login(&client).await; 10 + let (access_jwt, _did) = create_admin_account_and_login(&client).await; 11 11 let create_payload = json!({ 12 12 "useCount": 3 13 13 }); ··· 38 38 #[tokio::test] 39 39 async fn test_admin_get_invite_codes_with_limit() { 40 40 let client = client(); 41 - let (access_jwt, _did) = create_account_and_login(&client).await; 41 + let (access_jwt, _did) = create_admin_account_and_login(&client).await; 42 42 for _ in 0..5 { 43 43 let create_payload = json!({ 44 44 "useCount": 1 ··· 86 86 #[tokio::test] 87 87 async fn test_disable_account_invites_success() { 88 88 let client = client(); 89 - let (access_jwt, did) = create_account_and_login(&client).await; 89 + let (access_jwt, did) = create_admin_account_and_login(&client).await; 90 90 let payload = json!({ 91 91 "account": did 92 92 }); ··· 122 122 #[tokio::test] 123 123 async fn test_enable_account_invites_success() { 124 124 let client = client(); 125 - let (access_jwt, did) = create_account_and_login(&client).await; 125 + let (access_jwt, did) = create_admin_account_and_login(&client).await; 126 126 let disable_payload = json!({ 127 127 "account": did 128 128 }); ··· 186 186 #[tokio::test] 187 187 async fn test_disable_account_invites_not_found() { 188 188 let client = client(); 189 - let (access_jwt, _did) = create_account_and_login(&client).await; 189 + let (access_jwt, _did) = create_admin_account_and_login(&client).await; 190 190 let payload = json!({ 191 191 "account": "did:plc:nonexistent" 192 192 }); ··· 206 206 #[tokio::test] 207 207 async fn test_disable_invite_codes_by_code() { 208 208 let client = client(); 209 - let (access_jwt, _did) = create_account_and_login(&client).await; 209 + let (access_jwt, _did) = create_admin_account_and_login(&client).await; 210 210 let create_payload = json!({ 211 211 "useCount": 5 212 212 }); ··· 255 255 #[tokio::test] 256 256 async fn test_disable_invite_codes_by_account() { 257 257 let client = client(); 258 - let (access_jwt, did) = create_account_and_login(&client).await; 258 + let (access_jwt, did) = create_admin_account_and_login(&client).await; 259 259 for _ in 0..3 { 260 260 let create_payload = json!({ 261 261 "useCount": 1 ··· 321 321 #[tokio::test] 322 322 async fn test_admin_enable_account_invites_not_found() { 323 323 let client = client(); 324 - let (access_jwt, _did) = create_account_and_login(&client).await; 324 + let (access_jwt, _did) = create_admin_account_and_login(&client).await; 325 325 let payload = json!({ 326 326 "account": "did:plc:nonexistent" 327 327 });
+24 -21
tests/admin_moderation.rs
··· 7 7 #[tokio::test] 8 8 async fn test_get_subject_status_user_success() { 9 9 let client = client(); 10 - let (access_jwt, did) = create_account_and_login(&client).await; 10 + let (access_jwt, did) = create_admin_account_and_login(&client).await; 11 11 let res = client 12 12 .get(format!( 13 13 "{}/xrpc/com.atproto.admin.getSubjectStatus", ··· 28 28 #[tokio::test] 29 29 async fn test_get_subject_status_not_found() { 30 30 let client = client(); 31 - let (access_jwt, _did) = create_account_and_login(&client).await; 31 + let (access_jwt, _did) = create_admin_account_and_login(&client).await; 32 32 let res = client 33 33 .get(format!( 34 34 "{}/xrpc/com.atproto.admin.getSubjectStatus", ··· 47 47 #[tokio::test] 48 48 async fn test_get_subject_status_no_param() { 49 49 let client = client(); 50 - let (access_jwt, _did) = create_account_and_login(&client).await; 50 + let (access_jwt, _did) = create_admin_account_and_login(&client).await; 51 51 let res = client 52 52 .get(format!( 53 53 "{}/xrpc/com.atproto.admin.getSubjectStatus", ··· 80 80 #[tokio::test] 81 81 async fn test_update_subject_status_takedown_user() { 82 82 let client = client(); 83 - let (access_jwt, did) = create_account_and_login(&client).await; 83 + let (admin_jwt, _) = create_admin_account_and_login(&client).await; 84 + let (_, target_did) = create_account_and_login(&client).await; 84 85 let payload = json!({ 85 86 "subject": { 86 87 "$type": "com.atproto.admin.defs#repoRef", 87 - "did": did 88 + "did": target_did 88 89 }, 89 90 "takedown": { 90 91 "apply": true, ··· 96 97 "{}/xrpc/com.atproto.admin.updateSubjectStatus", 97 98 base_url().await 98 99 )) 99 - .bearer_auth(&access_jwt) 100 + .bearer_auth(&admin_jwt) 100 101 .json(&payload) 101 102 .send() 102 103 .await ··· 111 112 "{}/xrpc/com.atproto.admin.getSubjectStatus", 112 113 base_url().await 113 114 )) 114 - .bearer_auth(&access_jwt) 115 - .query(&[("did", did.as_str())]) 115 + .bearer_auth(&admin_jwt) 116 + .query(&[("did", target_did.as_str())]) 116 117 .send() 117 118 .await 118 119 .expect("Failed to send request"); ··· 125 126 #[tokio::test] 126 127 async fn test_update_subject_status_remove_takedown() { 127 128 let client = client(); 128 - let (access_jwt, did) = create_account_and_login(&client).await; 129 + let (admin_jwt, _) = create_admin_account_and_login(&client).await; 130 + let (_, target_did) = create_account_and_login(&client).await; 129 131 let takedown_payload = json!({ 130 132 "subject": { 131 133 "$type": "com.atproto.admin.defs#repoRef", 132 - "did": did 134 + "did": target_did 133 135 }, 134 136 "takedown": { 135 137 "apply": true, ··· 141 143 "{}/xrpc/com.atproto.admin.updateSubjectStatus", 142 144 base_url().await 143 145 )) 144 - .bearer_auth(&access_jwt) 146 + .bearer_auth(&admin_jwt) 145 147 .json(&takedown_payload) 146 148 .send() 147 149 .await; 148 150 let remove_payload = json!({ 149 151 "subject": { 150 152 "$type": "com.atproto.admin.defs#repoRef", 151 - "did": did 153 + "did": target_did 152 154 }, 153 155 "takedown": { 154 156 "apply": false ··· 159 161 "{}/xrpc/com.atproto.admin.updateSubjectStatus", 160 162 base_url().await 161 163 )) 162 - .bearer_auth(&access_jwt) 164 + .bearer_auth(&admin_jwt) 163 165 .json(&remove_payload) 164 166 .send() 165 167 .await ··· 170 172 "{}/xrpc/com.atproto.admin.getSubjectStatus", 171 173 base_url().await 172 174 )) 173 - .bearer_auth(&access_jwt) 174 - .query(&[("did", did.as_str())]) 175 + .bearer_auth(&admin_jwt) 176 + .query(&[("did", target_did.as_str())]) 175 177 .send() 176 178 .await 177 179 .expect("Failed to send request"); ··· 187 189 #[tokio::test] 188 190 async fn test_update_subject_status_deactivate_user() { 189 191 let client = client(); 190 - let (access_jwt, did) = create_account_and_login(&client).await; 192 + let (admin_jwt, _) = create_admin_account_and_login(&client).await; 193 + let (_, target_did) = create_account_and_login(&client).await; 191 194 let payload = json!({ 192 195 "subject": { 193 196 "$type": "com.atproto.admin.defs#repoRef", 194 - "did": did 197 + "did": target_did 195 198 }, 196 199 "deactivated": { 197 200 "apply": true ··· 202 205 "{}/xrpc/com.atproto.admin.updateSubjectStatus", 203 206 base_url().await 204 207 )) 205 - .bearer_auth(&access_jwt) 208 + .bearer_auth(&admin_jwt) 206 209 .json(&payload) 207 210 .send() 208 211 .await ··· 213 216 "{}/xrpc/com.atproto.admin.getSubjectStatus", 214 217 base_url().await 215 218 )) 216 - .bearer_auth(&access_jwt) 217 - .query(&[("did", did.as_str())]) 219 + .bearer_auth(&admin_jwt) 220 + .query(&[("did", target_did.as_str())]) 218 221 .send() 219 222 .await 220 223 .expect("Failed to send request"); ··· 226 229 #[tokio::test] 227 230 async fn test_update_subject_status_invalid_type() { 228 231 let client = client(); 229 - let (access_jwt, _did) = create_account_and_login(&client).await; 232 + let (access_jwt, _did) = create_admin_account_and_login(&client).await; 230 233 let payload = json!({ 231 234 "subject": { 232 235 "$type": "invalid.type",
+3 -3
tests/admin_stats.rs
··· 1 1 mod common; 2 - use common::{base_url, client, create_account_and_login}; 2 + use common::{base_url, client, create_admin_account_and_login}; 3 3 use serde_json::Value; 4 4 5 5 #[tokio::test] 6 6 async fn test_get_server_stats() { 7 7 let client = client(); 8 8 let base = base_url().await; 9 - let (token1, _) = create_account_and_login(&client).await; 9 + let (token1, _) = create_admin_account_and_login(&client).await; 10 10 11 - let (_, _) = create_account_and_login(&client).await; 11 + let (_, _) = create_admin_account_and_login(&client).await; 12 12 13 13 let resp = client 14 14 .get(format!("{}/xrpc/com.bspds.admin.getServerStats", base))
+18 -4
tests/common/mod.rs
··· 511 511 512 512 #[allow(dead_code)] 513 513 pub async fn create_account_and_login(client: &Client) -> (String, String) { 514 + create_account_and_login_internal(client, false).await 515 + } 516 + 517 + #[allow(dead_code)] 518 + pub async fn create_admin_account_and_login(client: &Client) -> (String, String) { 519 + create_account_and_login_internal(client, true).await 520 + } 521 + 522 + async fn create_account_and_login_internal(client: &Client, make_admin: bool) -> (String, String) { 514 523 let mut last_error = String::new(); 515 524 for attempt in 0..3 { 516 525 if attempt > 0 { ··· 539 548 }; 540 549 if res.status() == StatusCode::OK { 541 550 let body: Value = res.json().await.expect("Invalid JSON"); 542 - if let Some(access_jwt) = body["accessJwt"].as_str() { 543 - let did = body["did"].as_str().expect("No did").to_string(); 544 - return (access_jwt.to_string(), did); 545 - } 546 551 let did = body["did"].as_str().expect("No did").to_string(); 547 552 let conn_str = get_db_connection_string().await; 548 553 let pool = sqlx::postgres::PgPoolOptions::new() ··· 550 555 .connect(&conn_str) 551 556 .await 552 557 .expect("Failed to connect to test database"); 558 + if make_admin { 559 + sqlx::query!("UPDATE users SET is_admin = TRUE WHERE did = $1", &did) 560 + .execute(&pool) 561 + .await 562 + .expect("Failed to mark user as admin"); 563 + } 564 + if let Some(access_jwt) = body["accessJwt"].as_str() { 565 + return (access_jwt.to_string(), did); 566 + } 553 567 let verification_code: String = sqlx::query_scalar!( 554 568 "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", 555 569 &did