Skip to content

Commit a8a56fe

Browse files
authored
fix(relay): delete devices atomically (#1665)
1 parent e984343 commit a8a56fe

2 files changed

Lines changed: 166 additions & 12 deletions

File tree

src/crates/services/relay-service/src/db.rs

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -456,13 +456,39 @@ impl DeviceRow {
456456
Ok(rows)
457457
}
458458

459-
pub async fn delete(pool: &DbPool, device_id: &str) -> Result<()> {
460-
sqlx::query("DELETE FROM devices WHERE device_id = ?")
459+
/// Delete a device owned by `user_id` and revoke all of its auth tokens.
460+
///
461+
/// Tokens must be removed before the device because `auth_tokens.device_id`
462+
/// references `devices.device_id`. Keep both operations in one transaction
463+
/// so a partial deletion cannot leave the account in an inconsistent state.
464+
pub async fn delete_for_user(pool: &DbPool, user_id: &str, device_id: &str) -> Result<bool> {
465+
let mut tx = pool
466+
.begin()
467+
.await
468+
.map_err(|e| anyhow!("begin device deletion: {e}"))?;
469+
470+
sqlx::query(
471+
"DELETE FROM auth_tokens WHERE device_id IN (\
472+
SELECT device_id FROM devices WHERE device_id = ? AND user_id = ?\
473+
)",
474+
)
475+
.bind(device_id)
476+
.bind(user_id)
477+
.execute(&mut *tx)
478+
.await
479+
.map_err(|e| anyhow!("revoke device tokens: {e}"))?;
480+
481+
let result = sqlx::query("DELETE FROM devices WHERE device_id = ? AND user_id = ?")
461482
.bind(device_id)
462-
.execute(pool)
483+
.bind(user_id)
484+
.execute(&mut *tx)
463485
.await
464486
.map_err(|e| anyhow!("delete device: {e}"))?;
465-
Ok(())
487+
488+
tx.commit()
489+
.await
490+
.map_err(|e| anyhow!("commit device deletion: {e}"))?;
491+
Ok(result.rows_affected() > 0)
466492
}
467493
}
468494

src/crates/services/relay-service/src/routes/devices.rs

Lines changed: 136 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -207,15 +207,23 @@ async fn delete_device(
207207
return Err(StatusCode::BAD_REQUEST);
208208
}
209209

210-
// Remove from DB.
211-
let _ = crate::db::DeviceRow::delete(db, &target_device_id)
210+
// Revoke the target's auth tokens before removing its device row. The DB
211+
// helper also scopes the deletion to this account and performs both writes
212+
// atomically so a guessed device id cannot affect another account.
213+
let deleted = crate::db::DeviceRow::delete_for_user(db, &user_id, &target_device_id)
212214
.await
213-
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
214-
215-
// Revoke any auth tokens belonging to the removed device.
216-
let _ = crate::db::AuthToken::revoke_by_device(db, &target_device_id)
217-
.await
218-
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
215+
.map_err(|error| {
216+
tracing::error!(
217+
user_id = %user_id,
218+
target_device_id = %target_device_id,
219+
%error,
220+
"Failed to delete account device"
221+
);
222+
StatusCode::INTERNAL_SERVER_ERROR
223+
})?;
224+
if !deleted {
225+
return Err(StatusCode::NOT_FOUND);
226+
}
219227

220228
// Disconnect active WS session if any.
221229
state
@@ -225,3 +233,123 @@ async fn delete_device(
225233
tracing::info!("Device {target_device_id} removed from account {user_id}");
226234
Ok(StatusCode::NO_CONTENT)
227235
}
236+
237+
#[cfg(test)]
238+
mod tests {
239+
use super::*;
240+
use crate::db::{connect, AuthToken, DbPool, DeviceRow, UserRow};
241+
use crate::relay::RoomManager;
242+
use crate::MemoryAssetStore;
243+
use axum::body::Body;
244+
use axum::http::Request;
245+
use std::sync::Arc;
246+
use tower::ServiceExt;
247+
248+
struct TestContext {
249+
app: axum::Router,
250+
db: Arc<DbPool>,
251+
owner_token: String,
252+
target_token: String,
253+
other_token: String,
254+
}
255+
256+
async fn setup_app() -> TestContext {
257+
let db = Arc::new(connect(":memory:").await.unwrap());
258+
UserRow::create(&db, "owner", "alice", "s", "ks", "{}", "hash", "wmk")
259+
.await
260+
.unwrap();
261+
UserRow::create(&db, "other", "bob", "s", "ks", "{}", "hash", "wmk")
262+
.await
263+
.unwrap();
264+
DeviceRow::upsert(&db, "owner-device", "owner", "Owner", None)
265+
.await
266+
.unwrap();
267+
DeviceRow::upsert(&db, "target-device", "owner", "Target", None)
268+
.await
269+
.unwrap();
270+
DeviceRow::upsert(&db, "other-device", "other", "Other", None)
271+
.await
272+
.unwrap();
273+
274+
let owner_token = AuthToken::create(&db, "owner", "owner-device")
275+
.await
276+
.unwrap()
277+
.token;
278+
let target_token = AuthToken::create(&db, "owner", "target-device")
279+
.await
280+
.unwrap()
281+
.token;
282+
let other_token = AuthToken::create(&db, "other", "other-device")
283+
.await
284+
.unwrap()
285+
.token;
286+
let app = crate::build_relay_router(
287+
RoomManager::new(),
288+
Arc::new(MemoryAssetStore::new()),
289+
std::time::Instant::now(),
290+
Some(db.clone()),
291+
"test",
292+
);
293+
294+
TestContext {
295+
app,
296+
db,
297+
owner_token,
298+
target_token,
299+
other_token,
300+
}
301+
}
302+
303+
async fn delete(app: &axum::Router, token: &str, device_id: &str) -> StatusCode {
304+
app.clone()
305+
.oneshot(
306+
Request::builder()
307+
.method("DELETE")
308+
.uri(format!("/api/devices/{device_id}"))
309+
.header(header::AUTHORIZATION, format!("Bearer {token}"))
310+
.body(Body::empty())
311+
.unwrap(),
312+
)
313+
.await
314+
.unwrap()
315+
.status()
316+
}
317+
318+
#[tokio::test]
319+
async fn deleting_owned_device_revokes_token_before_device_row() {
320+
let ctx = setup_app().await;
321+
322+
let status = delete(&ctx.app, &ctx.owner_token, "target-device").await;
323+
324+
assert_eq!(status, StatusCode::NO_CONTENT);
325+
assert!(AuthToken::find(&ctx.db, &ctx.target_token)
326+
.await
327+
.unwrap()
328+
.is_none());
329+
let devices = DeviceRow::list_by_user(&ctx.db, "owner").await.unwrap();
330+
assert_eq!(devices.len(), 1);
331+
assert_eq!(devices[0].device_id, "owner-device");
332+
}
333+
334+
#[tokio::test]
335+
async fn deleting_own_or_another_accounts_device_is_rejected() {
336+
let ctx = setup_app().await;
337+
338+
assert_eq!(
339+
delete(&ctx.app, &ctx.owner_token, "owner-device").await,
340+
StatusCode::BAD_REQUEST
341+
);
342+
assert_eq!(
343+
delete(&ctx.app, &ctx.owner_token, "other-device").await,
344+
StatusCode::NOT_FOUND
345+
);
346+
assert!(AuthToken::find(&ctx.db, &ctx.owner_token)
347+
.await
348+
.unwrap()
349+
.is_some());
350+
assert!(AuthToken::find(&ctx.db, &ctx.other_token)
351+
.await
352+
.unwrap()
353+
.is_some());
354+
}
355+
}

0 commit comments

Comments
 (0)