From 79536ff35f87d71e9b3bc79ae3477d9a749f945a Mon Sep 17 00:00:00 2001 From: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Date: Sun, 2 Aug 2026 14:47:25 -0700 Subject: [PATCH] fix: bind deletion approval to frozen inventory digest Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> --- .github/workflows/ci.yml | 2 +- crates/buzz-db/src/deletion.rs | 34 ++++++++++++++++++++++++++ migrations/0027_community_deletion.sql | 10 +++++--- schema/schema.sql | 10 +++++--- 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a8b252ee..86d8c8489 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -689,7 +689,7 @@ jobs: run: | cargo nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ - -E '(package(buzz-deletion) and test(/tests::/)) or (package(buzz-relay) and test(/finalize_push_(holds_serving_lease_through_post_cas_publication|db_failure_after_cas_is_not_success_and_releases_lease)/))' \ + -E '(package(buzz-deletion) and test(/tests::/)) or (package(buzz-db) and test(/deletion::postgres_tests/)) or (package(buzz-relay) and test(/finalize_push_(holds_serving_lease_through_post_cas_publication|db_failure_after_cas_is_not_success_and_releases_lease)/))' \ --run-ignored ignored-only env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz diff --git a/crates/buzz-db/src/deletion.rs b/crates/buzz-db/src/deletion.rs index 72a52ac2b..713c5d544 100644 --- a/crates/buzz-db/src/deletion.rs +++ b/crates/buzz-db/src/deletion.rs @@ -2440,6 +2440,18 @@ mod postgres_tests { .expect("live schema after row churn"); assert_eq!(current_schema, inventory.schema); + let mismatched_insert = sqlx::query( + "INSERT INTO community_deletion_approvals \ + (request_id, inventory_digest, approved_by) VALUES ($1, $2, 'tampered')", + ) + .bind(request.id) + .bind(vec![0_u8; 32]) + .execute(&db.pool) + .await; + assert!( + mismatched_insert.is_err(), + "a mismatched approval must be unrepresentable" + ); let approved = store .approve(request.id, "approver-a", Some("reviewed")) .await @@ -2449,6 +2461,28 @@ mod postgres_tests { approved.inventory_digest, Some(hex::encode(inventory.digest().unwrap())) ); + let mismatched_approval = sqlx::query( + "UPDATE community_deletion_approvals SET inventory_digest = $2 WHERE request_id = $1", + ) + .bind(request.id) + .bind(vec![0_u8; 32]) + .execute(&db.pool) + .await; + assert!( + mismatched_approval.is_err(), + "approval digest must remain database-bound to the frozen request digest" + ); + let mismatched_request = sqlx::query( + "UPDATE community_deletion_requests SET inventory_digest = $2 WHERE id = $1", + ) + .bind(request.id) + .bind(vec![1_u8; 32]) + .execute(&db.pool) + .await; + assert!( + mismatched_request.is_err(), + "the frozen request digest must remain bound to its approval" + ); assert!(store .claim_specific(request.id, "executor-a", DEFAULT_LEASE_DURATION) .await diff --git a/migrations/0027_community_deletion.sql b/migrations/0027_community_deletion.sql index 1b074ecf4..7d4589478 100644 --- a/migrations/0027_community_deletion.sql +++ b/migrations/0027_community_deletion.sql @@ -49,7 +49,8 @@ CREATE TABLE community_deletion_requests ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), completed_at TIMESTAMPTZ, CHECK ((blocked_at IS NULL) = (blocked_reason IS NULL)), - CHECK ((inventory_frozen_at IS NULL) = (inventory_digest IS NULL)) + CHECK ((inventory_frozen_at IS NULL) = (inventory_digest IS NULL)), + UNIQUE (id, inventory_digest) ); CREATE INDEX community_deletion_requests_runnable ON community_deletion_requests (next_attempt_at, created_at) @@ -61,11 +62,14 @@ CREATE INDEX community_deletion_requests_lease WHERE lease_owner IS NOT NULL; CREATE TABLE community_deletion_approvals ( - request_id UUID PRIMARY KEY REFERENCES community_deletion_requests(id) ON DELETE RESTRICT, + request_id UUID PRIMARY KEY, inventory_digest BYTEA NOT NULL CHECK (length(inventory_digest) = 32), approved_by TEXT NOT NULL, note TEXT, - approved_at TIMESTAMPTZ NOT NULL DEFAULT now() + approved_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (request_id, inventory_digest) + REFERENCES community_deletion_requests(id, inventory_digest) + ON DELETE RESTRICT ); CREATE TABLE community_deletion_checkpoints ( diff --git a/schema/schema.sql b/schema/schema.sql index ee7c9ee95..c535f5ef7 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -1112,7 +1112,8 @@ CREATE TABLE community_deletion_requests ( updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), completed_at TIMESTAMPTZ, CHECK ((blocked_at IS NULL) = (blocked_reason IS NULL)), - CHECK ((inventory_frozen_at IS NULL) = (inventory_digest IS NULL)) + CHECK ((inventory_frozen_at IS NULL) = (inventory_digest IS NULL)), + UNIQUE (id, inventory_digest) ); CREATE INDEX community_deletion_requests_runnable ON community_deletion_requests (next_attempt_at, created_at) @@ -1123,11 +1124,14 @@ CREATE INDEX community_deletion_requests_lease ON community_deletion_requests (lease_until) WHERE lease_owner IS NOT NULL; CREATE TABLE community_deletion_approvals ( - request_id UUID PRIMARY KEY REFERENCES community_deletion_requests(id) ON DELETE RESTRICT, + request_id UUID PRIMARY KEY, inventory_digest BYTEA NOT NULL CHECK (length(inventory_digest) = 32), approved_by TEXT NOT NULL, note TEXT, - approved_at TIMESTAMPTZ NOT NULL DEFAULT now() + approved_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (request_id, inventory_digest) + REFERENCES community_deletion_requests(id, inventory_digest) + ON DELETE RESTRICT ); CREATE TABLE community_deletion_checkpoints ( request_id UUID NOT NULL REFERENCES community_deletion_requests(id) ON DELETE RESTRICT,