L-10: E-16 answers not_found for another user's device (D-156)

A member naming the other user's device now gets the same 404 not_found
body as an unknown id, so E-16 no longer reveals whose a device id is.
The admin keeps revoking any device except the current one (AC-3.2, §8.4).
contracts/openapi.yaml drops the 403 on E-16 and states the rule;
web/src/api/schema.d.ts regenerated. The forbidden test becomes a
not_found test, plus one asserting the body equals the unknown-id body
and that nothing was revoked.

Also bumps ctutils 0.4.3 -> 0.4.4 (lockfile only) for RUSTSEC-2026-0385,
published 2026-10-23, which failed cargo audit on main.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-24 11:03:19 +02:00
parent 593c7275a6
commit 07c36d24a2
5 changed files with 48 additions and 18 deletions
@@ -11,7 +11,7 @@ use crate::api::{ApiJson, IdPath, JsonBody, rows};
use crate::auth::AuthDevice;
use crate::db;
use crate::error::{AppError, ErrorCode};
use crate::openapi::responses::{BadRequest, Conflict, Forbidden, NotFound};
use crate::openapi::responses::{BadRequest, Conflict, NotFound};
use crate::wire::{DeviceList, PushTokenRequest, Role, UuidSchema};
/// Longest `fcm_token` stored, in characters after trimming (D-114; a Firebase Installation ID has 22).
@@ -41,13 +41,12 @@ pub async fn list_devices(
path = "/devices/{device_id}",
operation_id = "revokeDevice",
summary = "E-16 Revoke a device",
description = "Revoking an already revoked device is 204. Admin any device except the current one; member own devices except the current one (SPEC §8.4).",
description = "Revoking an already revoked device is 204. Admin any device except the current one; member own devices except the current one, and a device of the other user is 404 `not_found`, the same answer as an unknown id (SPEC §8.4, D-156).",
params(
("device_id" = UuidSchema, Path, description = "Not a UUID is 404 `not_found`."),
),
responses(
(status = 204, description = "Revoked."),
(status = 403, response = Forbidden),
(status = 404, response = NotFound),
(status = 409, response = Conflict),
)
@@ -58,12 +57,12 @@ pub async fn revoke_device(
IdPath(device_id): IdPath,
) -> Result<StatusCode, AppError> {
let mut tx = db::begin_immediate(&state.db).await?;
// D-156 (security review L-10): for a member, a device of the other user is the same `not_found` as an unknown
// id, so the answer does not tell whether the id belongs to someone.
let target = rows::device(&mut tx, device_id, device.device_id)
.await?
.filter(|target| device.role == Role::Admin || target.user_id == device.user_id)
.ok_or_else(AppError::not_found)?;
if device.role != Role::Admin && target.user_id != device.user_id {
return Err(AppError::forbidden());
}
if target.is_current {
return Err(AppError::new(
ErrorCode::CannotRevokeCurrentDevice,