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

@@ -611,18 +611,18 @@ async fn patch_allowed_for_uploader(pool: PgPool) {
assert_eq!(resp.status(), StatusCode::OK);
}
/// Legacy rows with `uploaded_by IS NULL` (created before migration
/// 0011) remain editable by any signed-in user. Without this carve-out
/// the historical-data note in 0011 would be broken.
/// Rows with `uploaded_by IS NULL` — crawler-imported plus any pre-0011
/// legacy rows — are admin-only. A regular user editing them used to be
/// allowed; that meant `register → PATCH /mangas/<id>` rewrote the
/// catalog for free.
#[sqlx::test(migrations = "./migrations")]
async fn patch_allowed_on_legacy_null_uploader(pool: PgPool) {
async fn patch_null_uploader_rejected_for_non_admin(pool: PgPool) {
let h = common::harness(pool.clone());
let (_, cookie) = common::register_user(&h.app).await;
let created = create_manga(&h.app, &cookie, json!({ "title": "Legacy" })).await;
let created = create_manga(&h.app, &cookie, json!({ "title": "Catalog" })).await;
let id = id_of(&created);
// Simulate a row uploaded before the column existed: clear
// uploaded_by directly via SQL.
// Simulate a crawler-imported / pre-0011 row: clear uploaded_by.
sqlx::query("UPDATE mangas SET uploaded_by = NULL WHERE id = $1")
.bind(id)
.execute(&pool)
@@ -639,5 +639,42 @@ async fn patch_allowed_on_legacy_null_uploader(pool: PgPool) {
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
/// Admins CAN edit NULL-`uploaded_by` rows, since they're the operators
/// curating the crawled catalog. Mirror of the test above with the
/// caller promoted.
#[sqlx::test(migrations = "./migrations")]
async fn patch_null_uploader_allowed_for_admin(pool: PgPool) {
let h = common::harness(pool.clone());
let (_, cookie) = common::register_user(&h.app).await;
let created = create_manga(&h.app, &cookie, json!({ "title": "Catalog" })).await;
let id = id_of(&created);
sqlx::query("UPDATE mangas SET uploaded_by = NULL WHERE id = $1")
.bind(id)
.execute(&pool)
.await
.unwrap();
// Mint an admin and let them edit the row.
let (admin_name, admin_cookie) = common::register_user(&h.app).await;
let admin = mangalord::repo::user::find_by_username(&pool, &admin_name)
.await
.unwrap()
.unwrap();
mangalord::repo::user::set_is_admin_unchecked(&pool, admin.id, true)
.await
.unwrap();
let resp = h
.app
.oneshot(common::patch_json_with_cookie(
&format!("/api/v1/mangas/{id}"),
json!({ "status": "completed" }),
&admin_cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}