fix(authz): require admin to edit/cover crawler-imported mangas (0.87.3)

`require_can_edit` matched `Some(owner) != caller → Forbidden` but let
`None` through. Every crawler row (`repo::crawler::upsert_manga`) has
NULL `uploaded_by`, so any signed-in user could PATCH /api/v1/mangas/<id>
and rewrite the catalog — `update`, `put_cover`, and `delete_cover`
were all in scope. With self-registration on by default, the path was:
register → mass-edit catalog + delete cover blobs from storage.

The original carve-out at the comment said "Once an admin role lands the
NULL case can flip to admin-only." The admin role landed in migration
0018; flipping it now. New rule:

  * `Some(owner)` and owner == caller → ok (unchanged)
  * `Some(_)` and caller is admin → ok (admin moderation)
  * `Some(_)` otherwise → 403 (unchanged)
  * `None` and caller is admin → ok (NEW — operator can curate)
  * `None` otherwise → 403 (was: ok — the bug)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-22 21:09:34 +02:00
parent dd25f073cd
commit ee9f5c1a4d
5 changed files with 71 additions and 22 deletions

View File

@@ -235,7 +235,7 @@ async fn update(
if !repo::manga::exists(&state.db, id).await? {
return Err(AppError::NotFound);
}
require_can_edit(&state, id, user.id).await?;
require_can_edit(&state, id, user.id, user.is_admin).await?;
if let Some(ref status) = patch.status {
let trimmed = status.trim();
@@ -306,7 +306,7 @@ async fn put_cover(
if !repo::manga::exists(&state.db, id).await? {
return Err(AppError::NotFound);
}
require_can_edit(&state, id, user.id).await?;
require_can_edit(&state, id, user.id, user.is_admin).await?;
let mut cover: Option<UploadedImage> = None;
while let Some(field) = next_field(&mut multipart).await? {
@@ -354,7 +354,7 @@ async fn delete_cover(
if !repo::manga::exists(&state.db, id).await? {
return Err(AppError::NotFound);
}
require_can_edit(&state, id, user.id).await?;
require_can_edit(&state, id, user.id, user.is_admin).await?;
if let Some(key) = repo::manga::get(&state.db, id).await?.cover_image_path {
match state.storage.delete(&key).await {
Ok(()) | Err(StorageError::NotFound) => {}
@@ -467,11 +467,16 @@ fn validate_new_manga(input: &NewManga) -> AppResult<()> {
/// exist (the caller runs [`repo::manga::exists`] first so a missing id
/// surfaces as `NotFound`, not `Forbidden`).
///
/// Rule: a non-NULL `uploaded_by` must match the current user. Legacy
/// rows with `uploaded_by IS NULL` (pre-migration-0011) are still
/// editable by any signed-in user — there's nobody to gate on yet, and
/// the historical-data note in 0011 acknowledges the gap. Once an
/// admin role lands the NULL case can flip to admin-only.
/// Rule: a non-NULL `uploaded_by` must match the current user, OR the
/// caller is an admin. Rows with `uploaded_by IS NULL` (crawler-imported
/// + legacy pre-0011) are admin-only.
///
/// Why: every crawler row has NULL `uploaded_by` (see
/// `repo::crawler::upsert_manga`). The earlier "any signed-in user can
/// edit a NULL row" rule meant `register → PATCH /api/v1/mangas/<id>`
/// rewrote the catalog for free, and `delete_cover` removed blobs from
/// `storage`. Now that the admin role exists (migration 0018), gate
/// crawler/legacy rows on it.
///
/// Returns `Forbidden` (not `NotFound`) on owner mismatch — mangas
/// are listable via `GET /mangas`, so existence isn't a secret and
@@ -479,11 +484,18 @@ fn validate_new_manga(input: &NewManga) -> AppResult<()> {
/// `repo::collection::require_owner`, which collapses both states to
/// `NotFound` because collections are private to a user and existence
/// itself is information worth hiding from non-owners.
async fn require_can_edit(state: &AppState, manga_id: Uuid, user_id: Uuid) -> AppResult<()> {
async fn require_can_edit(
state: &AppState,
manga_id: Uuid,
user_id: Uuid,
is_admin: bool,
) -> AppResult<()> {
match repo::manga::uploaded_by(&state.db, manga_id).await? {
Some(owner) if owner != user_id => Err(AppError::Forbidden),
// Some(owner) == user_id (good) or None (legacy row, no owner).
_ => Ok(()),
Some(owner) if owner == user_id => Ok(()),
Some(_) if is_admin => Ok(()),
Some(_) => Err(AppError::Forbidden),
None if is_admin => Ok(()),
None => Err(AppError::Forbidden),
}
}