Our Personal Data Server from scratch! tranquil.farm
oauth atproto pds rust postgresql objectstorage fun

fix: match ref pds permission-levels for some endpoints

+216 -110
+3 -3
crates/tranquil-pds/src/api/actor/preferences.rs
··· 1 1 use crate::api::error::ApiError; 2 - use crate::auth::{Active, Auth}; 2 + use crate::auth::{Auth, NotTakendown, Permissive}; 3 3 use crate::state::AppState; 4 4 use axum::{ 5 5 Json, ··· 32 32 pub struct GetPreferencesOutput { 33 33 pub preferences: Vec<Value>, 34 34 } 35 - pub async fn get_preferences(State(state): State<AppState>, auth: Auth<Active>) -> Response { 35 + pub async fn get_preferences(State(state): State<AppState>, auth: Auth<Permissive>) -> Response { 36 36 let has_full_access = auth.permissions().has_full_access(); 37 37 let user_id: uuid::Uuid = match state.user_repo.get_id_by_did(&auth.did).await { 38 38 Ok(Some(id)) => id, ··· 89 89 } 90 90 pub async fn put_preferences( 91 91 State(state): State<AppState>, 92 - auth: Auth<Active>, 92 + auth: Auth<NotTakendown>, 93 93 Json(input): Json<PutPreferencesInput>, 94 94 ) -> Response { 95 95 let has_full_access = auth.permissions().has_full_access();
-1
crates/tranquil-pds/src/api/error.rs
··· 546 546 crate::auth::extractor::AuthError::ServiceAuthNotAllowed => Self::AuthenticationFailed( 547 547 Some("Service authentication not allowed for this endpoint".to_string()), 548 548 ), 549 - crate::auth::extractor::AuthError::SigningKeyRequired => Self::InvalidSigningKey, 550 549 crate::auth::extractor::AuthError::InsufficientScope(msg) => { 551 550 Self::InsufficientScope(Some(msg)) 552 551 }
+2 -2
crates/tranquil-pds/src/api/identity/plc/request.rs
··· 1 1 use crate::api::EmptyResponse; 2 2 use crate::api::error::ApiError; 3 - use crate::auth::{Auth, NotTakendown}; 3 + use crate::auth::{Auth, Permissive}; 4 4 use crate::state::AppState; 5 5 use axum::{ 6 6 extract::State, ··· 15 15 16 16 pub async fn request_plc_operation_signature( 17 17 State(state): State<AppState>, 18 - auth: Auth<NotTakendown>, 18 + auth: Auth<Permissive>, 19 19 ) -> Result<Response, ApiError> { 20 20 if let Err(e) = crate::auth::scope_check::check_identity_scope( 21 21 auth.is_oauth(),
+2 -2
crates/tranquil-pds/src/api/identity/plc/sign.rs
··· 1 1 use crate::api::ApiError; 2 - use crate::auth::{Auth, NotTakendown}; 2 + use crate::auth::{Auth, Permissive}; 3 3 use crate::circuit_breaker::with_circuit_breaker; 4 4 use crate::plc::{PlcClient, PlcError, PlcService, create_update_op, sign_operation}; 5 5 use crate::state::AppState; ··· 40 40 41 41 pub async fn sign_plc_operation( 42 42 State(state): State<AppState>, 43 - auth: Auth<NotTakendown>, 43 + auth: Auth<Permissive>, 44 44 Json(input): Json<SignPlcOperationInput>, 45 45 ) -> Result<Response, ApiError> { 46 46 if let Err(e) = crate::auth::scope_check::check_identity_scope(
+2 -2
crates/tranquil-pds/src/api/identity/plc/submit.rs
··· 1 1 use crate::api::{ApiError, EmptyResponse}; 2 - use crate::auth::{Auth, NotTakendown}; 2 + use crate::auth::{Auth, Permissive}; 3 3 use crate::circuit_breaker::with_circuit_breaker; 4 4 use crate::plc::{PlcClient, signing_key_to_did_key, validate_plc_operation}; 5 5 use crate::state::AppState; ··· 20 20 21 21 pub async fn submit_plc_operation( 22 22 State(state): State<AppState>, 23 - auth: Auth<NotTakendown>, 23 + auth: Auth<Permissive>, 24 24 Json(input): Json<SubmitPlcOperationInput>, 25 25 ) -> Result<Response, ApiError> { 26 26 if let Err(e) = crate::auth::scope_check::check_identity_scope(
+4 -4
crates/tranquil-pds/src/api/server/account_status.rs
··· 1 1 use crate::api::EmptyResponse; 2 2 use crate::api::error::ApiError; 3 - use crate::auth::{Active, Auth, NotTakendown}; 3 + use crate::auth::{Auth, NotTakendown, Permissive}; 4 4 use crate::cache::Cache; 5 5 use crate::plc::PlcClient; 6 6 use crate::state::AppState; ··· 41 41 42 42 pub async fn check_account_status( 43 43 State(state): State<AppState>, 44 - auth: Auth<NotTakendown>, 44 + auth: Auth<Permissive>, 45 45 ) -> Result<Response, ApiError> { 46 46 let did = &auth.did; 47 47 let user_id = state ··· 306 306 307 307 pub async fn activate_account( 308 308 State(state): State<AppState>, 309 - auth: Auth<NotTakendown>, 309 + auth: Auth<Permissive>, 310 310 ) -> Result<Response, ApiError> { 311 311 info!("[MIGRATION] activateAccount called"); 312 312 info!( ··· 470 470 471 471 pub async fn deactivate_account( 472 472 State(state): State<AppState>, 473 - auth: Auth<Active>, 473 + auth: Auth<Permissive>, 474 474 Json(input): Json<DeactivateAccountInput>, 475 475 ) -> Result<Response, ApiError> { 476 476 if let Err(e) = crate::auth::scope_check::check_account_scope(
+4 -4
crates/tranquil-pds/src/api/server/app_password.rs
··· 1 1 use crate::api::EmptyResponse; 2 2 use crate::api::error::ApiError; 3 - use crate::auth::{Active, Auth, generate_app_password}; 3 + use crate::auth::{Auth, NotTakendown, Permissive, generate_app_password}; 4 4 use crate::delegation::{DelegationActionType, intersect_scopes}; 5 5 use crate::state::{AppState, RateLimitKind}; 6 6 use axum::{ ··· 33 33 34 34 pub async fn list_app_passwords( 35 35 State(state): State<AppState>, 36 - auth: Auth<Active>, 36 + auth: Auth<Permissive>, 37 37 ) -> Result<Response, ApiError> { 38 38 let user = state 39 39 .user_repo ··· 90 90 pub async fn create_app_password( 91 91 State(state): State<AppState>, 92 92 headers: HeaderMap, 93 - auth: Auth<Active>, 93 + auth: Auth<NotTakendown>, 94 94 Json(input): Json<CreateAppPasswordInput>, 95 95 ) -> Result<Response, ApiError> { 96 96 let client_ip = crate::rate_limit::extract_client_ip(&headers, None); ··· 227 227 228 228 pub async fn revoke_app_password( 229 229 State(state): State<AppState>, 230 - auth: Auth<Active>, 230 + auth: Auth<Permissive>, 231 231 Json(input): Json<RevokeAppPasswordInput>, 232 232 ) -> Result<Response, ApiError> { 233 233 let user = state
+5 -5
crates/tranquil-pds/src/api/server/email.rs
··· 1 1 use crate::api::error::ApiError; 2 2 use crate::api::{EmptyResponse, TokenRequiredResponse, VerifiedResponse}; 3 - use crate::auth::{Active, Auth}; 3 + use crate::auth::{Auth, NotTakendown}; 4 4 use crate::state::{AppState, RateLimitKind}; 5 5 use axum::{ 6 6 Json, ··· 45 45 pub async fn request_email_update( 46 46 State(state): State<AppState>, 47 47 headers: axum::http::HeaderMap, 48 - auth: Auth<Active>, 48 + auth: Auth<NotTakendown>, 49 49 input: Option<Json<RequestEmailUpdateInput>>, 50 50 ) -> Result<Response, ApiError> { 51 51 let client_ip = crate::rate_limit::extract_client_ip(&headers, None); ··· 140 140 pub async fn confirm_email( 141 141 State(state): State<AppState>, 142 142 headers: axum::http::HeaderMap, 143 - auth: Auth<Active>, 143 + auth: Auth<NotTakendown>, 144 144 Json(input): Json<ConfirmEmailInput>, 145 145 ) -> Result<Response, ApiError> { 146 146 let client_ip = crate::rate_limit::extract_client_ip(&headers, None); ··· 233 233 234 234 pub async fn update_email( 235 235 State(state): State<AppState>, 236 - auth: Auth<Active>, 236 + auth: Auth<NotTakendown>, 237 237 Json(input): Json<UpdateEmailInput>, 238 238 ) -> Result<Response, ApiError> { 239 239 if let Err(e) = crate::auth::scope_check::check_account_scope( ··· 500 500 pub async fn check_email_update_status( 501 501 State(state): State<AppState>, 502 502 headers: axum::http::HeaderMap, 503 - auth: Auth<Active>, 503 + auth: Auth<NotTakendown>, 504 504 ) -> Result<Response, ApiError> { 505 505 let client_ip = crate::rate_limit::extract_client_ip(&headers, None); 506 506 if !state
+2 -2
crates/tranquil-pds/src/api/server/invite.rs
··· 1 1 use crate::api::ApiError; 2 - use crate::auth::{Active, Admin, Auth}; 2 + use crate::auth::{Admin, Auth, NotTakendown}; 3 3 use crate::state::AppState; 4 4 use crate::types::Did; 5 5 use axum::{ ··· 193 193 194 194 pub async fn get_account_invite_codes( 195 195 State(state): State<AppState>, 196 - auth: Auth<Active>, 196 + auth: Auth<NotTakendown>, 197 197 axum::extract::Query(params): axum::extract::Query<GetAccountInviteCodesParams>, 198 198 ) -> Result<Response, ApiError> { 199 199 let include_used = params.include_used.unwrap_or(true);
+2 -2
crates/tranquil-pds/src/api/server/session.rs
··· 1 1 use crate::api::error::ApiError; 2 2 use crate::api::{EmptyResponse, SuccessResponse}; 3 - use crate::auth::{Active, Auth, NotTakendown}; 3 + use crate::auth::{Active, Auth, Permissive}; 4 4 use crate::state::{AppState, RateLimitKind}; 5 5 use crate::types::{AccountState, Did, Handle, PlainPassword}; 6 6 use axum::{ ··· 279 279 280 280 pub async fn get_session( 281 281 State(state): State<AppState>, 282 - auth: Auth<NotTakendown>, 282 + auth: Auth<Permissive>, 283 283 ) -> Result<Response, ApiError> { 284 284 let permissions = auth.permissions(); 285 285 let can_read_email = permissions.allows_email_read();
+22 -81
crates/tranquil-pds/src/auth/extractor.rs
··· 27 27 AccountTakedown, 28 28 AdminRequired, 29 29 ServiceAuthNotAllowed, 30 - SigningKeyRequired, 31 30 InsufficientScope(String), 32 31 OAuthExpiredToken(String), 33 32 UseDpopNonce(String), ··· 430 429 } 431 430 } 432 431 432 + impl OptionalFromRequestParts<AppState> for ServiceAuth { 433 + type Rejection = AuthError; 434 + 435 + async fn from_request_parts( 436 + parts: &mut Parts, 437 + state: &AppState, 438 + ) -> Result<Option<Self>, Self::Rejection> { 439 + match extract_auth_internal(parts, state).await { 440 + Ok(ExtractedAuth::Service(claims)) => { 441 + let did: Did = claims 442 + .iss 443 + .parse() 444 + .map_err(|_| AuthError::AuthenticationFailed)?; 445 + Ok(Some(ServiceAuth { did, claims })) 446 + } 447 + Ok(ExtractedAuth::User(_)) => Err(AuthError::AuthenticationFailed), 448 + Err(AuthError::MissingToken) => Ok(None), 449 + Err(e) => Err(e), 450 + } 451 + } 452 + } 453 + 433 454 pub enum AuthAny<P: AuthPolicy = Active> { 434 455 User(Auth<P>), 435 456 Service(ServiceAuth), ··· 514 535 Err(AuthError::MissingToken) => Ok(None), 515 536 Err(e) => Err(e), 516 537 } 517 - } 518 - } 519 - 520 - pub struct SigningAuth<P: AuthPolicy = Active> { 521 - pub did: Did, 522 - pub key_bytes: Vec<u8>, 523 - pub is_admin: bool, 524 - pub status: AccountStatus, 525 - pub scope: Option<String>, 526 - pub controller_did: Option<Did>, 527 - is_oauth: bool, 528 - _policy: PhantomData<P>, 529 - } 530 - 531 - impl<P: AuthPolicy> SigningAuth<P> { 532 - pub fn needs_scope_check(&self) -> bool { 533 - self.is_oauth 534 - } 535 - 536 - pub fn permissions(&self) -> ScopePermissions { 537 - if let Some(ref scope) = self.scope 538 - && scope != super::SCOPE_ACCESS 539 - { 540 - return ScopePermissions::from_scope_string(Some(scope)); 541 - } 542 - if !self.is_oauth { 543 - return ScopePermissions::from_scope_string(Some("atproto")); 544 - } 545 - ScopePermissions::from_scope_string(self.scope.as_deref()) 546 - } 547 - 548 - #[allow(clippy::result_large_err)] 549 - pub fn check_repo_scope(&self, action: RepoAction, collection: &str) -> Result<(), Response> { 550 - if !self.needs_scope_check() { 551 - return Ok(()); 552 - } 553 - self.permissions() 554 - .assert_repo(action, collection) 555 - .map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response()) 556 - } 557 - } 558 - 559 - impl<P: AuthPolicy> FromRequestParts<AppState> for SigningAuth<P> { 560 - type Rejection = AuthError; 561 - 562 - async fn from_request_parts( 563 - parts: &mut Parts, 564 - state: &AppState, 565 - ) -> Result<Self, Self::Rejection> { 566 - let user = extract_user_auth_internal(parts, state).await?; 567 - P::validate(&user)?; 568 - 569 - let key_bytes = match user.key_bytes { 570 - Some(kb) => kb, 571 - None => { 572 - let user_with_key = state 573 - .user_repo 574 - .get_with_key_by_did(&user.did) 575 - .await 576 - .ok() 577 - .flatten() 578 - .ok_or(AuthError::SigningKeyRequired)?; 579 - crate::config::decrypt_key( 580 - &user_with_key.key_bytes, 581 - user_with_key.encryption_version, 582 - ) 583 - .map_err(|_| AuthError::SigningKeyRequired)? 584 - } 585 - }; 586 - 587 - Ok(SigningAuth { 588 - did: user.did, 589 - key_bytes, 590 - is_admin: user.is_admin, 591 - status: user.status, 592 - scope: user.scope, 593 - controller_did: user.controller_did, 594 - is_oauth: user.auth_source.is_oauth(), 595 - _policy: PhantomData, 596 - }) 597 538 } 598 539 } 599 540
+1 -2
crates/tranquil-pds/src/auth/mod.rs
··· 18 18 19 19 pub use extractor::{ 20 20 Active, Admin, AnyUser, Auth, AuthAny, AuthError, AuthPolicy, ExtractedToken, NotTakendown, 21 - Permissive, ServiceAuth, SigningAuth, extract_auth_token_from_header, 22 - extract_bearer_token_from_header, 21 + Permissive, ServiceAuth, extract_auth_token_from_header, extract_bearer_token_from_header, 23 22 }; 24 23 pub use service::{ServiceTokenClaims, ServiceTokenVerifier, is_service_token}; 25 24
+102
crates/tranquil-pds/tests/actor.rs
··· 436 436 assert_eq!(declared_age["isOverAge16"], false); 437 437 assert_eq!(declared_age["isOverAge18"], false); 438 438 } 439 + 440 + #[tokio::test] 441 + async fn test_deactivated_account_can_get_preferences() { 442 + let client = client(); 443 + let base = base_url().await; 444 + let (token, _did) = create_account_and_login(&client).await; 445 + 446 + let prefs = json!({ 447 + "preferences": [ 448 + { 449 + "$type": "app.bsky.actor.defs#adultContentPref", 450 + "enabled": true 451 + } 452 + ] 453 + }); 454 + let put_resp = client 455 + .post(format!("{}/xrpc/app.bsky.actor.putPreferences", base)) 456 + .header("Authorization", format!("Bearer {}", token)) 457 + .json(&prefs) 458 + .send() 459 + .await 460 + .unwrap(); 461 + assert_eq!(put_resp.status(), 200); 462 + 463 + let deactivate = client 464 + .post(format!( 465 + "{}/xrpc/com.atproto.server.deactivateAccount", 466 + base 467 + )) 468 + .header("Authorization", format!("Bearer {}", token)) 469 + .json(&json!({})) 470 + .send() 471 + .await 472 + .unwrap(); 473 + assert_eq!(deactivate.status(), 200); 474 + 475 + let get_resp = client 476 + .get(format!("{}/xrpc/app.bsky.actor.getPreferences", base)) 477 + .header("Authorization", format!("Bearer {}", token)) 478 + .send() 479 + .await 480 + .unwrap(); 481 + assert_eq!( 482 + get_resp.status(), 483 + 200, 484 + "Deactivated account should still be able to get preferences" 485 + ); 486 + let body: Value = get_resp.json().await.unwrap(); 487 + let prefs_arr = body["preferences"].as_array().unwrap(); 488 + assert_eq!(prefs_arr.len(), 1); 489 + } 490 + 491 + #[tokio::test] 492 + async fn test_deactivated_account_can_put_preferences() { 493 + let client = client(); 494 + let base = base_url().await; 495 + let (token, _did) = create_account_and_login(&client).await; 496 + 497 + let deactivate = client 498 + .post(format!( 499 + "{}/xrpc/com.atproto.server.deactivateAccount", 500 + base 501 + )) 502 + .header("Authorization", format!("Bearer {}", token)) 503 + .json(&json!({})) 504 + .send() 505 + .await 506 + .unwrap(); 507 + assert_eq!(deactivate.status(), 200); 508 + 509 + let prefs = json!({ 510 + "preferences": [ 511 + { 512 + "$type": "app.bsky.actor.defs#adultContentPref", 513 + "enabled": true 514 + } 515 + ] 516 + }); 517 + let put_resp = client 518 + .post(format!("{}/xrpc/app.bsky.actor.putPreferences", base)) 519 + .header("Authorization", format!("Bearer {}", token)) 520 + .json(&prefs) 521 + .send() 522 + .await 523 + .unwrap(); 524 + assert_eq!( 525 + put_resp.status(), 526 + 200, 527 + "Deactivated account should still be able to put preferences" 528 + ); 529 + 530 + let get_resp = client 531 + .get(format!("{}/xrpc/app.bsky.actor.getPreferences", base)) 532 + .header("Authorization", format!("Bearer {}", token)) 533 + .send() 534 + .await 535 + .unwrap(); 536 + assert_eq!(get_resp.status(), 200); 537 + let body: Value = get_resp.json().await.unwrap(); 538 + let prefs_arr = body["preferences"].as_array().unwrap(); 539 + assert_eq!(prefs_arr.len(), 1); 540 + }
+65
crates/tranquil-pds/tests/auth_extractor.rs
··· 581 581 let proof = format!("{}.{}", signing_input, sig_b64); 582 582 (jwk, proof) 583 583 } 584 + 585 + #[tokio::test] 586 + async fn test_optional_service_auth_extractor_behavior() { 587 + let url = base_url().await; 588 + let http_client = client(); 589 + let (access_jwt, did) = create_account_and_login(&http_client).await; 590 + 591 + let service_auth_res = http_client 592 + .get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url)) 593 + .bearer_auth(&access_jwt) 594 + .query(&[("aud", "did:web:test.example")]) 595 + .send() 596 + .await 597 + .unwrap(); 598 + assert_eq!(service_auth_res.status(), StatusCode::OK); 599 + let service_body: Value = service_auth_res.json().await.unwrap(); 600 + let service_token = service_body["token"].as_str().unwrap(); 601 + 602 + let no_auth_res = http_client 603 + .get(format!( 604 + "{}/xrpc/com.atproto.sync.getBlob?did={}&cid=bafyreifakecidfornowfakecidfornow1234567", 605 + url, did 606 + )) 607 + .send() 608 + .await 609 + .unwrap(); 610 + assert!( 611 + no_auth_res.status() == StatusCode::NOT_FOUND 612 + || no_auth_res.status() == StatusCode::BAD_REQUEST, 613 + "getBlob with no auth should reach handler (AuthAny optional path) - got {}", 614 + no_auth_res.status() 615 + ); 616 + 617 + let service_auth_blob_res = http_client 618 + .get(format!( 619 + "{}/xrpc/com.atproto.sync.getBlob?did={}&cid=bafyreifakecidfornowfakecidfornow1234567", 620 + url, did 621 + )) 622 + .bearer_auth(service_token) 623 + .send() 624 + .await 625 + .unwrap(); 626 + assert!( 627 + service_auth_blob_res.status() == StatusCode::NOT_FOUND 628 + || service_auth_blob_res.status() == StatusCode::BAD_REQUEST, 629 + "getBlob with service auth should reach handler (AuthAny service path) - got {}", 630 + service_auth_blob_res.status() 631 + ); 632 + 633 + let user_auth_blob_res = http_client 634 + .get(format!( 635 + "{}/xrpc/com.atproto.sync.getBlob?did={}&cid=bafyreifakecidfornowfakecidfornow1234567", 636 + url, did 637 + )) 638 + .bearer_auth(&access_jwt) 639 + .send() 640 + .await 641 + .unwrap(); 642 + assert!( 643 + user_auth_blob_res.status() == StatusCode::NOT_FOUND 644 + || user_auth_blob_res.status() == StatusCode::BAD_REQUEST, 645 + "getBlob with user auth should reach handler (AuthAny user path) - got {}", 646 + user_auth_blob_res.status() 647 + ); 648 + }