A lexicon-driven AppView for ATProto.
0

Configure Feed

Select the types of activity you want to include in your feed.

fix: implement revocation for space credentials

Signed-off-by: Trezy <tre@trezy.com>

+271 -4
+4
migrations/postgres/20260707000000_add_revoked_at_to_space_credentials.sql
··· 1 + -- Add revocation support to space credentials (M3). 2 + -- A NULL revoked_at means active; a timestamp means the credential (and any 3 + -- other credential sharing the row) has been revoked and must be rejected. 4 + ALTER TABLE happyview_space_credentials ADD COLUMN revoked_at TEXT;
+4
migrations/sqlite/20260707000000_add_revoked_at_to_space_credentials.sql
··· 1 + -- Add revocation support to space credentials (M3). 2 + -- A NULL revoked_at means active; a timestamp means the credential (and any 3 + -- other credential sharing the row) has been revoked and must be rejected. 4 + ALTER TABLE happyview_space_credentials ADD COLUMN revoked_at TEXT;
+13 -1
packages/docs/content/docs/experimental/spaces/credentials.md
··· 197 197 | `sub` | The full `at://` space URI | 198 198 | `iat` | Issued at (Unix timestamp) | 199 199 | `exp` | Expiry (Unix timestamp) | 200 - | `jti` | Random nonce for replay protection | 200 + | `jti` | Random nonce making each issued credential unique | 201 + 202 + The credential is a bearer token: it is reused for the full 2-hour TTL and is not 203 + bound to a specific client or audience, so any service holding it can read the 204 + space's records. Containment relies on the short TTL and on revocation. 205 + 206 + ## Revocation 207 + 208 + A credential is invalidated before its TTL expires when its holder is removed 209 + from the space (`com.atproto.simplespace.removeMember`): HappyView records the 210 + revocation and rejects the credential on subsequent reads. Because a credential 211 + is an opaque bearer token, revocation is scoped to a member — removing (and, if 212 + needed, re-adding) a member revokes all of that member's outstanding credentials. 201 213 202 214 ## Using a credential 203 215
+40
src/spaces/db.rs
··· 307 307 Ok(result.rows_affected() > 0) 308 308 } 309 309 310 + /// Returns true if a space credential with the given token hash has been revoked. 311 + pub async fn is_space_credential_revoked( 312 + pool: &sqlx::AnyPool, 313 + backend: DatabaseBackend, 314 + token_hash: &str, 315 + ) -> Result<bool, AppError> { 316 + let sql = adapt_sql( 317 + "SELECT revoked_at FROM happyview_space_credentials WHERE token_hash = ? AND revoked_at IS NOT NULL LIMIT 1", 318 + backend, 319 + ); 320 + let row: Option<(String,)> = sqlx::query_as(&sql) 321 + .bind(token_hash) 322 + .fetch_optional(pool) 323 + .await 324 + .map_err(|e| AppError::Internal(format!("failed to check credential revocation: {e}")))?; 325 + Ok(row.is_some()) 326 + } 327 + 328 + /// Revoke all active space credentials issued to `did` within `space_id`. 329 + /// Returns the number of credentials revoked. 330 + pub async fn revoke_space_credentials_for_member( 331 + pool: &sqlx::AnyPool, 332 + backend: DatabaseBackend, 333 + space_id: &str, 334 + did: &str, 335 + ) -> Result<u64, AppError> { 336 + let sql = adapt_sql( 337 + "UPDATE happyview_space_credentials SET revoked_at = ? WHERE space_id = ? AND issued_to = ? AND revoked_at IS NULL", 338 + backend, 339 + ); 340 + let result = sqlx::query(&sql) 341 + .bind(now_rfc3339()) 342 + .bind(space_id) 343 + .bind(did) 344 + .execute(pool) 345 + .await 346 + .map_err(|e| AppError::Internal(format!("failed to revoke space credentials: {e}")))?; 347 + Ok(result.rows_affected()) 348 + } 349 + 310 350 pub async fn get_member( 311 351 pool: &sqlx::AnyPool, 312 352 backend: DatabaseBackend,
+19 -3
src/spaces/routes.rs
··· 357 357 /// Like `require_auth`, but also accepts a verified space credential as an 358 358 /// identity source. Use this in space endpoints that support `Bearer 359 359 /// <space_credential>` in addition to DPoP auth. 360 + /// Whether a verified space credential has been revoked (e.g. its holder was 361 + /// removed from the space). Consulted after signature/exp verification so a 362 + /// leaked or stale credential can be invalidated before its TTL expires (M3). 363 + async fn space_credential_revoked(state: &AppState, token: &str) -> Result<bool, AppError> { 364 + let token_hash = hex::encode(Sha256::digest(token.as_bytes())); 365 + db::is_space_credential_revoked(&state.db, state.db_backend, &token_hash).await 366 + } 367 + 360 368 async fn require_auth_or_credential( 361 369 state: &AppState, 362 370 claims: &XrpcClaims, ··· 372 380 &state.config.plc_url, 373 381 ) 374 382 .await?; 383 + if space_credential_revoked(state, token).await? { 384 + return Err(AppError::Auth("space credential has been revoked".into())); 385 + } 375 386 return Ok(verified.sub); 376 387 } 377 388 ··· 450 461 .await 451 462 { 452 463 Ok(claims) if claims.sub == space_uri => { 453 - // External credential grants read access; write is not supported via space credential 454 - if require_write { 464 + // A revoked credential is treated as invalid — fall through to 465 + // the local membership check rather than granting access. 466 + if space_credential_revoked(state, token).await? { 467 + // fall through 468 + } else if require_write { 469 + // External credential grants read access only. 455 470 return Err(AppError::Forbidden( 456 471 "Write access is required for this action".into(), 457 472 )); 473 + } else { 474 + return Ok(SpaceAccess::Read); 458 475 } 459 - return Ok(SpaceAccess::Read); 460 476 } 461 477 Ok(_) => { 462 478 // Credential is valid but for a different space — fall through
+6
src/spaces/simplespace.rs
··· 422 422 return Err(AppError::NotFound("Member not found in this space".into())); 423 423 } 424 424 425 + // Revoke any outstanding space credentials the removed member holds so their 426 + // cross-service access ends immediately rather than lingering for the 427 + // credential's 2h TTL (M3). 428 + db::revoke_space_credentials_for_member(&state.db, state.db_backend, &space.id, &input.did) 429 + .await?; 430 + 425 431 Ok(Json(serde_json::json!({ "success": true }))) 426 432 } 427 433
+136
tests/spaces_credential_revocation.rs
··· 1 + mod common; 2 + 3 + use axum::body::Body; 4 + use axum::http::{Request, StatusCode}; 5 + use happyview::db::now_rfc3339; 6 + use happyview::spaces::db as spaces_db; 7 + use happyview::spaces::types::*; 8 + use serde_json::json; 9 + use serial_test::serial; 10 + use tower::ServiceExt; 11 + use uuid::Uuid; 12 + 13 + use common::app::TestApp; 14 + 15 + const AUTHORITY: &str = "did:plc:cred-authority"; 16 + const MEMBER: &str = "did:plc:cred-holder"; 17 + 18 + fn space_uri() -> String { 19 + format!("at://{AUTHORITY}/space/com.example.cred/main") 20 + } 21 + 22 + async fn enable_spaces(app: &TestApp) { 23 + let (name, value) = app.admin_cookie(); 24 + let req = Request::builder() 25 + .method("PUT") 26 + .uri("/admin/settings/feature.spaces_enabled") 27 + .header(name, value) 28 + .header("content-type", "application/json") 29 + .body(Body::from(json!({ "value": "true" }).to_string())) 30 + .unwrap(); 31 + assert!( 32 + app.router 33 + .clone() 34 + .oneshot(req) 35 + .await 36 + .unwrap() 37 + .status() 38 + .is_success(), 39 + "failed to enable spaces" 40 + ); 41 + } 42 + 43 + /// Removing a member from a space revokes their outstanding space credentials. 44 + /// Before the fix, removeMember left the credential active for its full TTL. 45 + #[tokio::test] 46 + #[serial] 47 + async fn remove_member_revokes_credentials() { 48 + common::require_db!(); 49 + let app = TestApp::new().await; 50 + enable_spaces(&app).await; 51 + 52 + // Create a space and a member. 53 + let space_id = Uuid::new_v4().to_string(); 54 + let now = now_rfc3339(); 55 + let space = Space { 56 + id: space_id.clone(), 57 + did: AUTHORITY.to_string(), 58 + authority_did: AUTHORITY.to_string(), 59 + creator_did: AUTHORITY.to_string(), 60 + type_nsid: "com.example.cred".to_string(), 61 + skey: "main".to_string(), 62 + display_name: None, 63 + description: None, 64 + mint_policy: MintPolicy::MemberList, 65 + app_access: AppAccess::Open, 66 + managing_app_did: None, 67 + config: SpaceConfig::default(), 68 + revision: None, 69 + created_at: now.clone(), 70 + updated_at: now.clone(), 71 + }; 72 + spaces_db::create_space(&app.state.db, app.state.db_backend, &space) 73 + .await 74 + .unwrap(); 75 + spaces_db::add_member( 76 + &app.state.db, 77 + app.state.db_backend, 78 + &SpaceMember { 79 + id: Uuid::new_v4().to_string(), 80 + space_id: space_id.clone(), 81 + did: MEMBER.to_string(), 82 + access: SpaceAccess::Read, 83 + is_delegation: false, 84 + granted_by: Some(AUTHORITY.to_string()), 85 + created_at: now.clone(), 86 + }, 87 + ) 88 + .await 89 + .unwrap(); 90 + 91 + // The member holds an outstanding credential (represented by its hash). 92 + let token_hash = "member-credential-hash"; 93 + let sql = happyview::db::adapt_sql( 94 + "INSERT INTO happyview_space_credentials (id, space_id, issued_to, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)", 95 + app.state.db_backend, 96 + ); 97 + sqlx::query(&sql) 98 + .bind(Uuid::new_v4().to_string()) 99 + .bind(&space_id) 100 + .bind(MEMBER) 101 + .bind(token_hash) 102 + .bind(&now) 103 + .bind(&now) 104 + .execute(&app.state.db) 105 + .await 106 + .unwrap(); 107 + 108 + assert!( 109 + !spaces_db::is_space_credential_revoked(&app.state.db, app.state.db_backend, token_hash) 110 + .await 111 + .unwrap() 112 + ); 113 + 114 + // The authority removes the member. 115 + let (cookie_name, cookie_val) = 116 + common::auth::admin_cookie_header(AUTHORITY, &app.state.cookie_key); 117 + let req = Request::builder() 118 + .method("POST") 119 + .uri("/xrpc/com.atproto.simplespace.removeMember") 120 + .header(cookie_name, cookie_val) 121 + .header("content-type", "application/json") 122 + .body(Body::from( 123 + json!({ "space": space_uri(), "did": MEMBER }).to_string(), 124 + )) 125 + .unwrap(); 126 + let resp = app.router.clone().oneshot(req).await.unwrap(); 127 + assert_eq!(resp.status(), StatusCode::OK); 128 + 129 + // The credential is now revoked. 130 + assert!( 131 + spaces_db::is_space_credential_revoked(&app.state.db, app.state.db_backend, token_hash) 132 + .await 133 + .unwrap(), 134 + "removing a member must revoke their outstanding credentials" 135 + ); 136 + }
+49
tests/spaces_db.rs
··· 559 559 560 560 #[tokio::test] 561 561 #[serial] 562 + async fn space_credential_revocation_round_trip() { 563 + common::require_db!(); 564 + let pool = test_db::test_pool().await; 565 + let backend = test_db::test_backend(); 566 + test_db::truncate_all(&pool).await; 567 + 568 + let space_id = new_id(); 569 + let space = make_space(&space_id, "did:plc:cred-owner", "com.example.cred", "cred"); 570 + spaces_db::create_space(&pool, backend, &space) 571 + .await 572 + .expect("create_space failed"); 573 + 574 + let member = "did:plc:cred-member"; 575 + let token_hash = "abc123hash"; 576 + let sql = happyview::db::adapt_sql( 577 + "INSERT INTO happyview_space_credentials (id, space_id, issued_to, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)", 578 + backend, 579 + ); 580 + sqlx::query(&sql) 581 + .bind(new_id()) 582 + .bind(&space_id) 583 + .bind(member) 584 + .bind(token_hash) 585 + .bind(now_rfc3339()) 586 + .bind(now_rfc3339()) 587 + .execute(&pool) 588 + .await 589 + .expect("insert credential row"); 590 + 591 + assert!( 592 + !spaces_db::is_space_credential_revoked(&pool, backend, token_hash) 593 + .await 594 + .unwrap() 595 + ); 596 + 597 + let revoked = spaces_db::revoke_space_credentials_for_member(&pool, backend, &space_id, member) 598 + .await 599 + .unwrap(); 600 + assert_eq!(revoked, 1); 601 + 602 + assert!( 603 + spaces_db::is_space_credential_revoked(&pool, backend, token_hash) 604 + .await 605 + .unwrap() 606 + ); 607 + } 608 + 609 + #[tokio::test] 610 + #[serial] 562 611 async fn remove_member() { 563 612 common::require_db!(); 564 613 let pool = test_db::test_pool().await;