mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
BUZZ-301: Add user deletion request intake (#8496)
## Summary
Add the first testable piece of user deletion: submit a synthetic
request, list requests, and inspect one by UUID. Operators can verify
that intake reaches PostgreSQL before we add inventory, approval, and
execution.
Add one operator-global `entity_deletion_requests` table with an
additive migration and matching desired-state schema. Identical
submissions return the same row. Conflicting submissions fail without
changing it. Only `demo:` targets are accepted, and requests stay
`submitted`.
Keep the commands in `buzz-deletion`, reached through a thin `buzz-admin
user-deletions` adapter. Reuse the existing database connection setup.
This change adds no worker, stage history, community gates, or deletion
operations.
### Rollout plan
This is an expand-only schema change plus an operator CLI. Existing
relay code does not use the new request table. No backfill or real
deletion is part of this release.
1. **Order the PRs.** Land #8496 first, using 0068; main now owns 0067
for channel join roles. Rework #8489 to build on this table with the
next available migration number. Renumbering its existing CREATE TABLE
migration alone is insufficient; both PRs currently create the same
request table.
2. **Apply on staging.** For deployments that manage schema separately,
apply the reviewed additive migration before using the new CLI. Where
auto-migration is enabled, the new relay binary applies it at startup.
Keep existing tables and records unchanged.
3. **Verify on staging.** Use the new `buzz-admin` binary with
`DATABASE_URL` pointing to staging. Submit a unique synthetic label,
list requests, and inspect the returned UUID:
```bash
buzz-admin user-deletions submit --target demo:staging-intake-1
--requested-by <operator>
buzz-admin user-deletions list --limit 5
buzz-admin user-deletions inspect <returned-uuid>
```
Verify one `entity_deletion_requests` row with status `submitted`.
Repeat the identical submission and verify the same UUID and timestamps.
Use a new label if this label was used by an earlier staging test.
4. **Apply beyond staging.** After verification, use the deployment's
reviewed schema/release workflow to add the table in the remaining
environments. Requests run only through the operator CLI; this release
starts no deletion worker. No feature flag is needed for this synthetic
intake: the database accepts only `demo:` targets, and real deletion is
absent.
5. **Rollback.** Keep the additive table and saved requests; stop using
the new commands and roll back the binary if needed. After 0068 is
applied, an older binary with auto-migration enabled can fail at startup
with sqlx `VersionMissing`. A binary rollback therefore requires
migration execution to be disabled on the older relay
(`BUZZ_AUTO_MIGRATE=false`, including the corresponding deployment
configuration).
### Related work
This is a smaller first slice of the full-workflow draft
[#8489](https://github.com/block/buzz/pull/8489), which is preserved for
later extraction. The two PRs overlap and should not both be merged as
written.
### Testing
The author submitted `demo:rarora-manual-1` to a separate local
PostgreSQL 17 database. Repeating the same submission returned its
original UUID and timestamps. List, inspect, and a direct SQL query all
returned the same saved request.
<details>
<summary>Actual command inputs and outputs</summary>
Commands ran from the new worktree with `DATABASE_URL` set to the local
`buzz_user_deletion_submit` database. Outputs below are stdout;
connection diagnostics on stderr are omitted.
**Submit again — returns the existing request without creating a
duplicate**
```bash
./target/debug/buzz-admin user-deletions submit \
--target demo:rarora-manual-1 --requested-by rarora
```
```json
{
"id": "2e5bcdec-ab68-44d2-9743-1dd9fa7d2f89",
"entity_type": "user",
"target": "demo:rarora-manual-1",
"status": "submitted",
"requested_by": "rarora",
"reason": null,
"created_at": "2026-10-20T19:57:44.516698Z",
"updated_at": "2026-10-20T19:57:44.516698Z"
}
```
**List saved requests**
```bash
./target/debug/buzz-admin user-deletions list --limit 5
```
```json
[
{
"id": "2e5bcdec-ab68-44d2-9743-1dd9fa7d2f89",
"entity_type": "user",
"target": "demo:rarora-manual-1",
"status": "submitted",
"requested_by": "rarora",
"reason": null,
"created_at": "2026-10-20T19:57:44.516698Z",
"updated_at": "2026-10-20T19:57:44.516698Z"
}
]
```
**Inspect one request**
```bash
./target/debug/buzz-admin user-deletions inspect 2e5bcdec-ab68-44d2-9743-1dd9fa7d2f89
```
```json
{
"id": "2e5bcdec-ab68-44d2-9743-1dd9fa7d2f89",
"entity_type": "user",
"target": "demo:rarora-manual-1",
"status": "submitted",
"requested_by": "rarora",
"reason": null,
"created_at": "2026-10-20T19:57:44.516698Z",
"updated_at": "2026-10-20T19:57:44.516698Z"
}
```
**Read the table directly**
```bash
docker exec buzz-user-deletion-dev-20261020 \
psql -X -U buzz -d buzz_user_deletion_submit -c \
"SELECT id, entity_type, target, status, requested_by
FROM entity_deletion_requests ORDER BY created_at DESC;"
```
```text
id | entity_type | target | status | requested_by
--------------------------------------+-------------+----------------------+-----------+--------------
2e5bcdec-ab68-44d2-9743-1dd9fa7d2f89 | user | demo:rarora-manual-1 | submitted | rarora
(1 row)
```
</details>
A separate CLI smoke test also verified that a conflicting operator and
a non-demo target fail without changing the saved request. The existing
community-deletion `list --limit 1` command passed against the smoke
database.
See [the intake
walkthrough](729233c726/docs/operator-user-deletion-intake.md)
for setup, commands, expected fields, and the SQL query.
Generated with Codex
---------
Signed-off-by: Ravneet Arora <rarora@squareup.com>
This commit is contained in:
4
Justfile
4
Justfile
@@ -454,9 +454,9 @@ test-unit:
|
||||
-E 'test(=bucket_index::tests::bucket_snapshot_json_round_trip_preserves_community_keys)'
|
||||
# buzz-admin: storage-snapshot worker, the community archive command
|
||||
# parser, validation, and propagation-evidence tests, and the partition
|
||||
# audit outcome contract, and the NIP-FI binding command's policy loading and backfill report.
|
||||
# audit outcome contract, NIP-FI policy/backfill, and user-deletion intake.
|
||||
cargo nextest run -p buzz-admin \
|
||||
-E 'test(storage_snapshot) + test(/^communities::tests::/) + test(/^tests::communities_/) + test(=tests::partition_audit_outcomes_keep_their_json_names_and_exit_codes) + test(/^nip_fi::tests::/)'
|
||||
-E 'test(storage_snapshot) + test(/^communities::tests::/) + test(/^tests::communities_/) + test(=tests::partition_audit_outcomes_keep_their_json_names_and_exit_codes) + test(/^nip_fi::tests::/) + test(/^user_deletions::tests::/)'
|
||||
# Multi-tenant conformance gate (buzz-conformance): the independent
|
||||
# replay checker + golden fixtures. No infra — pure in-process trace
|
||||
# replay — so it belongs in the unit job. Run all targets (lib + the
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
mod communities;
|
||||
mod deletions;
|
||||
mod nip_fi;
|
||||
mod user_deletions;
|
||||
|
||||
use std::future::Future;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
@@ -126,6 +127,11 @@ enum Command {
|
||||
#[command(subcommand)]
|
||||
command: deletions::DeletionsCommand,
|
||||
},
|
||||
/// Submit and inspect synthetic user-deletion requests.
|
||||
UserDeletions {
|
||||
#[command(subcommand)]
|
||||
command: user_deletions::UserDeletionsCommand,
|
||||
},
|
||||
/// Emit missing kind:39000/39001/39002 channel discovery events, or
|
||||
/// republish only a targeted channel's kind:39002 roster.
|
||||
///
|
||||
@@ -203,6 +209,7 @@ async fn run(cli: Cli) -> Result<i32> {
|
||||
} => cmd_list_product_feedback(limit).await,
|
||||
Command::Communities { command } => communities::run(command).await,
|
||||
Command::Deletions { command } => deletions::run(command).await,
|
||||
Command::UserDeletions { command } => user_deletions::run(command).await,
|
||||
Command::NipFi { command } => nip_fi::run(command).await,
|
||||
Command::ReconcileChannels { channel, relay_key } => {
|
||||
reconcile_channels(channel, relay_key).await?;
|
||||
|
||||
40
crates/buzz-admin/src/user_deletions.rs
Normal file
40
crates/buzz-admin/src/user_deletions.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
//! Thin `buzz-admin user-deletions` adapter.
|
||||
|
||||
pub use buzz_deletion::user::Command as UserDeletionsCommand;
|
||||
|
||||
/// Delegate request intake to the shared deletion crate.
|
||||
pub async fn run(command: UserDeletionsCommand) -> anyhow::Result<i32> {
|
||||
buzz_deletion::user::run(command).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use clap::Parser;
|
||||
|
||||
#[test]
|
||||
fn user_intake_commands_are_registered_without_execution() {
|
||||
for args in [
|
||||
vec![
|
||||
"buzz-admin",
|
||||
"user-deletions",
|
||||
"submit",
|
||||
"--target",
|
||||
"demo:alice",
|
||||
"--requested-by",
|
||||
"operator-a",
|
||||
],
|
||||
vec!["buzz-admin", "user-deletions", "list"],
|
||||
vec![
|
||||
"buzz-admin",
|
||||
"user-deletions",
|
||||
"inspect",
|
||||
"00000000-0000-0000-0000-000000000001",
|
||||
],
|
||||
] {
|
||||
assert!(crate::Cli::try_parse_from(args).is_ok());
|
||||
}
|
||||
for command in ["inventory", "approve", "run", "drain", "worker"] {
|
||||
assert!(crate::Cli::try_parse_from(["buzz-admin", "user-deletions", command]).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,10 +66,10 @@ pub(crate) use runtime::{
|
||||
};
|
||||
pub use store::{
|
||||
admin_moderation, allowlist, api_token, archived_identities, artifact, channel,
|
||||
channel_members, community, deletion, dm, event, feed, git_repo, moderation, nip_fi_binding,
|
||||
operator_listener, partition, personal_read, product_feedback, push, reaction, read_state,
|
||||
relay_admin_actions, relay_invite, relay_members, relay_operators, reminder, replaceable,
|
||||
storage_accounting, thread, thread_window, usage, user, workflow,
|
||||
channel_members, community, deletion, dm, entity_deletion, event, feed, git_repo, moderation,
|
||||
nip_fi_binding, operator_listener, partition, personal_read, product_feedback, push, reaction,
|
||||
read_state, relay_admin_actions, relay_invite, relay_members, relay_operators, reminder,
|
||||
replaceable, storage_accounting, thread, thread_window, usage, user, workflow,
|
||||
};
|
||||
|
||||
pub use allowlist::AllowlistEntry;
|
||||
|
||||
@@ -543,6 +543,7 @@ mod postgres_tests {
|
||||
"product_feedback",
|
||||
"replica_heartbeat",
|
||||
"community_deletion_requests",
|
||||
"entity_deletion_requests",
|
||||
"community_deletion_approvals",
|
||||
"community_deletion_checkpoints",
|
||||
"community_deletion_manifest_keys",
|
||||
@@ -769,7 +770,12 @@ mod postgres_tests {
|
||||
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
|
||||
migrations.sort_by_key(|migration| migration.version);
|
||||
|
||||
assert_eq!(migrations.len(), 67);
|
||||
assert_eq!(migrations.len(), 68);
|
||||
assert_eq!(migrations[67].version, 68);
|
||||
assert!(migrations[67]
|
||||
.sql
|
||||
.as_str()
|
||||
.contains("CREATE TABLE entity_deletion_requests"));
|
||||
assert_eq!(migrations[64].version, 65);
|
||||
let retire_ttl = strip_sql_comments(migrations[64].sql.as_str());
|
||||
for statement in [
|
||||
@@ -2704,13 +2710,13 @@ mod postgres_tests {
|
||||
///
|
||||
/// This bootstraps one probe database from `schema.sql` **through the real
|
||||
/// `bin/pgschema apply` binary** — the exact path CI (`ci.yml`) and both
|
||||
/// test-relay launchers take — and migrates another through 1–38, then
|
||||
/// asserts the three admin tables have identical column definitions (name,
|
||||
/// type, nullability, default) and identical index shapes, including each
|
||||
/// test-relay launchers take — and migrates another through the latest
|
||||
/// migration, then asserts the admin and entity-intake tables have identical
|
||||
/// column definitions (name, type, nullability, default) and index shapes, including each
|
||||
/// key's catalog sort/null options (`pg_index.indoption`). Columns are keyed
|
||||
/// by name, not ordinal, because migrations append via `ALTER TABLE` while
|
||||
/// `schema.sql` declares them inline — positions legitimately differ, shapes
|
||||
/// must not.
|
||||
/// must not. Entity-intake constraints are also compared in the live catalog.
|
||||
///
|
||||
/// Driving the real binary is load-bearing: `pgschema` 1.7.4 discards
|
||||
/// per-key `NULLS FIRST`/`NULLS LAST` when it re-emits an index, so a naive
|
||||
@@ -2768,6 +2774,21 @@ mod postgres_tests {
|
||||
.expect("read index shapes")
|
||||
}
|
||||
|
||||
async fn constraints(pool: &PgPool, table: &str) -> Vec<(String, String, bool)> {
|
||||
sqlx::query_as(
|
||||
"SELECT con.conname, pg_get_constraintdef(con.oid), con.convalidated \
|
||||
FROM pg_constraint con \
|
||||
JOIN pg_class t ON t.oid = con.conrelid \
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace \
|
||||
WHERE n.nspname = 'public' AND t.relname = $1 \
|
||||
ORDER BY con.conname",
|
||||
)
|
||||
.bind(table)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.expect("read constraint definitions")
|
||||
}
|
||||
|
||||
let base_url = std::env::var("BUZZ_TEST_DATABASE_URL")
|
||||
.or_else(|_| std::env::var("DATABASE_URL"))
|
||||
.unwrap_or_else(|_| TEST_DB_URL.to_owned());
|
||||
@@ -2801,18 +2822,25 @@ mod postgres_tests {
|
||||
let desired = PgPool::connect(&format!("{base_prefix}/{desired_db}"))
|
||||
.await
|
||||
.expect("connect desired-state probe database");
|
||||
sqlx::raw_sql(include_str!(
|
||||
"../../../../scripts/reconcile-schema-after-pgschema.sql"
|
||||
))
|
||||
.execute(&desired)
|
||||
.await
|
||||
.expect("reconcile desired-state probe database");
|
||||
let migrated = PgPool::connect(&format!("{base_prefix}/{migrated_db}"))
|
||||
.await
|
||||
.expect("connect migrated probe database");
|
||||
MIGRATOR
|
||||
.run_to(55, &migrated)
|
||||
.run(&migrated)
|
||||
.await
|
||||
.expect("apply migrations 1-55");
|
||||
.expect("apply migrations for schema parity");
|
||||
|
||||
for table in [
|
||||
"relay_admin_actions",
|
||||
"relay_admin_outbox",
|
||||
"relay_operator_audit",
|
||||
"entity_deletion_requests",
|
||||
] {
|
||||
assert_eq!(
|
||||
columns(&desired, table).await,
|
||||
@@ -2830,6 +2858,12 @@ mod postgres_tests {
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
constraints(&desired, "entity_deletion_requests").await,
|
||||
constraints(&migrated, "entity_deletion_requests").await,
|
||||
"constraint parity mismatch for entity_deletion_requests"
|
||||
);
|
||||
|
||||
desired.close().await;
|
||||
migrated.close().await;
|
||||
for probe_db in [desired_db, migrated_db] {
|
||||
|
||||
130
crates/buzz-db/src/store/entity_deletion.rs
Normal file
130
crates/buzz-db/src/store/entity_deletion.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
//! Operator-global intake for synthetic user-deletion requests.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{Db, DbError, Result};
|
||||
|
||||
/// A submitted demonstration request; no cleanup is authorized by this record.
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct EntityRequest {
|
||||
/// Stable request identifier.
|
||||
pub id: Uuid,
|
||||
/// Currently always `user`.
|
||||
pub entity_type: String,
|
||||
/// Synthetic `demo:` label, not a resolved account identity.
|
||||
pub target: String,
|
||||
/// Currently always `submitted`.
|
||||
pub status: String,
|
||||
/// Operator identity supplied at submission.
|
||||
pub requested_by: String,
|
||||
/// Optional explanation supplied at submission.
|
||||
pub reason: Option<String>,
|
||||
/// Time of the original submission.
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// Time of the last change; identical resubmissions do not change it.
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Writer-database access to synthetic deletion requests.
|
||||
#[derive(Clone)]
|
||||
pub struct EntityDeletionStore {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Use the writer pool for operator-global request intake and inspection.
|
||||
pub fn entity_deletion_store(&self) -> EntityDeletionStore {
|
||||
EntityDeletionStore {
|
||||
pool: self.pool().clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EntityDeletionStore {
|
||||
/// Save a request or return an identical previous submission.
|
||||
///
|
||||
/// A conflicting operator or reason returns an error without changing the
|
||||
/// existing request. Concurrent identical submissions converge on one row.
|
||||
pub async fn submit(
|
||||
&self,
|
||||
target: &str,
|
||||
requested_by: &str,
|
||||
reason: Option<&str>,
|
||||
) -> Result<EntityRequest> {
|
||||
validate_submission(target, requested_by, reason)?;
|
||||
let mut tx = self.pool.begin().await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO entity_deletion_requests (target, requested_by, reason)
|
||||
VALUES ($1, $2, $3) ON CONFLICT (entity_type, target) DO NOTHING",
|
||||
)
|
||||
.bind(target)
|
||||
.bind(requested_by)
|
||||
.bind(reason)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let request: EntityRequest = sqlx::query_as(
|
||||
"SELECT * FROM entity_deletion_requests WHERE entity_type = 'user' AND target = $1",
|
||||
)
|
||||
.bind(target)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if request.requested_by != requested_by || request.reason.as_deref() != reason {
|
||||
return Err(DbError::DeletionSafety(format!(
|
||||
"target already has request {}; inspect it before resubmission",
|
||||
request.id
|
||||
)));
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
/// Load a request from the authoritative writer database.
|
||||
pub async fn get(&self, id: Uuid) -> Result<EntityRequest> {
|
||||
sqlx::query_as("SELECT * FROM entity_deletion_requests WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
.ok_or_else(|| DbError::NotFound(format!("entity deletion request {id}")))
|
||||
}
|
||||
|
||||
/// List up to 1,000 requests, newest first.
|
||||
pub async fn list(&self, limit: u16) -> Result<Vec<EntityRequest>> {
|
||||
Ok(sqlx::query_as(
|
||||
"SELECT * FROM entity_deletion_requests ORDER BY created_at DESC, id LIMIT $1",
|
||||
)
|
||||
.bind(i64::from(limit.clamp(1, 1000)))
|
||||
.fetch_all(&self.pool)
|
||||
.await?)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_submission(target: &str, requested_by: &str, reason: Option<&str>) -> Result<()> {
|
||||
let slug = target.strip_prefix("demo:").unwrap_or_default();
|
||||
if slug.is_empty()
|
||||
|| slug.len() > 64
|
||||
|| !slug.as_bytes()[0].is_ascii_alphanumeric()
|
||||
|| !slug
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
|
||||
{
|
||||
return Err(DbError::DeletionSafety(
|
||||
"target must be demo:<1..64 lowercase letters, digits, underscores or hyphens>".into(),
|
||||
));
|
||||
}
|
||||
if requested_by.is_empty() || requested_by.len() > 256 || requested_by.trim() != requested_by {
|
||||
return Err(DbError::DeletionSafety(
|
||||
"operator identity must be 1..256 bytes without surrounding whitespace".into(),
|
||||
));
|
||||
}
|
||||
if reason.is_some_and(|r| r.len() > 4096) {
|
||||
return Err(DbError::DeletionSafety("reason exceeds 4096 bytes".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "entity_deletion_postgres_tests.rs"]
|
||||
mod postgres_tests;
|
||||
185
crates/buzz-db/src/store/entity_deletion_postgres_tests.rs
Normal file
185
crates/buzz-db/src/store/entity_deletion_postgres_tests.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
use super::*;
|
||||
|
||||
async fn store() -> EntityDeletionStore {
|
||||
EntityDeletionStore {
|
||||
pool: PgPool::connect(&crate::test_support::database_url())
|
||||
.await
|
||||
.expect("connect isolated database"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_plain_schema(pool: &PgPool) {
|
||||
let fences: i64 = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM pg_trigger
|
||||
WHERE tgrelid = 'entity_deletion_requests'::regclass",
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("fences");
|
||||
assert_eq!(fences, 0);
|
||||
let foreign_keys: i64 = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM pg_constraint
|
||||
WHERE conrelid = 'entity_deletion_requests'::regclass AND contype = 'f'",
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("foreign keys");
|
||||
assert_eq!(foreign_keys, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn concurrent_submissions_converge_and_conflicts_preserve_request() {
|
||||
let store = store().await;
|
||||
let (first, second) = tokio::join!(
|
||||
store.submit("demo:alice", "operator-a", Some("manual test")),
|
||||
store.submit("demo:alice", "operator-a", Some("manual test")),
|
||||
);
|
||||
let first = first.expect("first submission");
|
||||
let second = second.expect("concurrent submission");
|
||||
assert_eq!(first.id, second.id);
|
||||
assert_eq!(first.status, "submitted");
|
||||
assert_eq!(first.entity_type, "user");
|
||||
assert_eq!(first.created_at, second.created_at);
|
||||
assert_eq!(first.updated_at, second.updated_at);
|
||||
assert!(store
|
||||
.submit("demo:alice", "operator-b", Some("manual test"))
|
||||
.await
|
||||
.is_err());
|
||||
assert!(store
|
||||
.submit("demo:alice", "operator-a", None)
|
||||
.await
|
||||
.is_err());
|
||||
let saved = store.get(first.id).await.expect("saved request");
|
||||
assert_eq!(
|
||||
serde_json::to_value(saved).unwrap(),
|
||||
serde_json::to_value(first).unwrap()
|
||||
);
|
||||
assert_eq!(store.list(100).await.expect("list").len(), 1);
|
||||
store
|
||||
.submit("demo:bob", "operator-a", None)
|
||||
.await
|
||||
.expect("second target");
|
||||
let recent = store.list(1).await.expect("bounded newest first");
|
||||
assert_eq!(recent.len(), 1);
|
||||
assert_eq!(recent[0].target, "demo:bob");
|
||||
assert!(matches!(
|
||||
store.get(Uuid::new_v4()).await,
|
||||
Err(DbError::NotFound(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn invalid_submissions_do_not_create_rows() {
|
||||
let store = store().await;
|
||||
for target in [
|
||||
"alice",
|
||||
"demo:",
|
||||
"demo:Alice",
|
||||
"demo:-alice",
|
||||
"demo:alice@example.com",
|
||||
] {
|
||||
assert!(
|
||||
store.submit(target, "operator-a", None).await.is_err(),
|
||||
"{target}"
|
||||
);
|
||||
}
|
||||
assert!(store
|
||||
.submit(&format!("demo:{}", "a".repeat(65)), "operator-a", None)
|
||||
.await
|
||||
.is_err());
|
||||
for actor in ["", " operator-a", "operator-a "] {
|
||||
assert!(store.submit("demo:alice", actor, None).await.is_err());
|
||||
}
|
||||
assert!(store
|
||||
.submit("demo:alice", &"a".repeat(257), None)
|
||||
.await
|
||||
.is_err());
|
||||
assert!(store
|
||||
.submit("demo:alice", "operator-a", Some(&"a".repeat(4097)))
|
||||
.await
|
||||
.is_err());
|
||||
assert!(store.list(100).await.expect("empty list").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn desired_schema_has_plain_intake_without_database_guards() {
|
||||
let store = store().await;
|
||||
assert_plain_schema(&store.pool).await;
|
||||
crate::Db::from_pool(store.pool.clone())
|
||||
.validate_deletion_catalog()
|
||||
.await
|
||||
.expect("community catalog");
|
||||
assert!(sqlx::query(
|
||||
"INSERT INTO entity_deletion_requests (target, requested_by)
|
||||
VALUES ('real-user', 'operator-a')",
|
||||
)
|
||||
.execute(&store.pool)
|
||||
.await
|
||||
.is_err());
|
||||
assert!(sqlx::query(
|
||||
"INSERT INTO entity_deletion_requests (target, requested_by, status)
|
||||
VALUES ('demo:alice', 'operator-a', 'running')",
|
||||
)
|
||||
.execute(&store.pool)
|
||||
.await
|
||||
.is_err());
|
||||
store
|
||||
.submit("demo:alice", "operator-a", None)
|
||||
.await
|
||||
.expect("valid submission");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn migration_schema_entity_intake_preserves_existing_data() {
|
||||
let store = store().await;
|
||||
crate::migration::run_migrations_through(&store.pool, 67)
|
||||
.await
|
||||
.expect("previous schema");
|
||||
let community = Uuid::new_v4();
|
||||
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, 'intake-upgrade.invalid')")
|
||||
.bind(community)
|
||||
.execute(&store.pool)
|
||||
.await
|
||||
.expect("existing community");
|
||||
crate::migration::run_migrations(&store.pool)
|
||||
.await
|
||||
.expect("upgrade");
|
||||
assert_plain_schema(&store.pool).await;
|
||||
let registered: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS (SELECT 1 FROM _operator_global_tables
|
||||
WHERE table_name = 'entity_deletion_requests')",
|
||||
)
|
||||
.fetch_one(&store.pool)
|
||||
.await
|
||||
.expect("registry");
|
||||
assert!(registered);
|
||||
let request = store
|
||||
.submit("demo:alice", "operator-a", None)
|
||||
.await
|
||||
.expect("submit");
|
||||
crate::migration::run_migrations(&store.pool)
|
||||
.await
|
||||
.expect("rerun");
|
||||
assert_eq!(
|
||||
store
|
||||
.get(request.id)
|
||||
.await
|
||||
.expect("preserved request")
|
||||
.target,
|
||||
"demo:alice"
|
||||
);
|
||||
let host: String = sqlx::query_scalar("SELECT host FROM communities WHERE id = $1")
|
||||
.bind(community)
|
||||
.fetch_one(&store.pool)
|
||||
.await
|
||||
.expect("preserved community");
|
||||
assert_eq!(host, "intake-upgrade.invalid");
|
||||
crate::Db::from_pool(store.pool.clone())
|
||||
.validate_deletion_catalog()
|
||||
.await
|
||||
.expect("community catalog");
|
||||
}
|
||||
@@ -18,6 +18,8 @@ pub mod community;
|
||||
pub mod deletion;
|
||||
/// Direct message channel persistence.
|
||||
pub mod dm;
|
||||
/// Operator-global synthetic user-deletion request intake.
|
||||
pub mod entity_deletion;
|
||||
/// Event storage and retrieval.
|
||||
pub mod event;
|
||||
/// App-owned push enqueue and channel TTL refresh for inserted events.
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
#![warn(missing_docs)]
|
||||
//! Shared durable whole-community deletion engine and store adapters.
|
||||
|
||||
/// Synthetic user-deletion request intake.
|
||||
pub mod user;
|
||||
|
||||
use std::future::Future;
|
||||
#[cfg(test)]
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -546,6 +549,10 @@ fn resolve_submit_host(host: Option<&str>, relay_url: Option<&str>) -> Result<St
|
||||
}
|
||||
|
||||
async fn connect_store() -> Result<DeletionStore> {
|
||||
Ok(store(&connect_database().await?))
|
||||
}
|
||||
|
||||
async fn connect_database() -> Result<Db> {
|
||||
let database_url = required_env("DATABASE_URL")?;
|
||||
// The scheduled drain starts in a fresh pod whose first connection can
|
||||
// outlast the relay's three-second budget.
|
||||
@@ -559,7 +566,7 @@ async fn connect_store() -> Result<DeletionStore> {
|
||||
"deletion",
|
||||
)
|
||||
.await?;
|
||||
Ok(store(&db))
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
fn resolve_s3_region(buzz_region: Option<String>, aws_region: Option<String>) -> String {
|
||||
|
||||
53
crates/buzz-deletion/src/user.rs
Normal file
53
crates/buzz-deletion/src/user.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
//! PostgreSQL-only intake commands for synthetic user-deletion requests.
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Subcommand;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Submit and inspect synthetic requests; these commands never execute deletion.
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum Command {
|
||||
/// Save a request. An identical resubmission returns the existing row.
|
||||
Submit {
|
||||
/// Synthetic user label, for example demo:alice.
|
||||
#[arg(long)]
|
||||
target: String,
|
||||
/// Submitting operator identity.
|
||||
#[arg(long)]
|
||||
requested_by: String,
|
||||
/// Optional explanation.
|
||||
#[arg(long)]
|
||||
reason: Option<String>,
|
||||
},
|
||||
/// List synthetic requests, newest first.
|
||||
List {
|
||||
/// Maximum number of rows.
|
||||
#[arg(long, default_value_t = 100, value_parser = clap::value_parser!(u16).range(1..=1000))]
|
||||
limit: u16,
|
||||
},
|
||||
/// Show a saved request by UUID.
|
||||
Inspect {
|
||||
/// Request UUID returned by submit.
|
||||
id: Uuid,
|
||||
},
|
||||
}
|
||||
|
||||
/// Run intake commands using the existing deletion database connection.
|
||||
pub async fn run(command: Command) -> Result<i32> {
|
||||
let db = crate::connect_database().await?;
|
||||
let store = db.entity_deletion_store();
|
||||
match command {
|
||||
Command::Submit {
|
||||
target,
|
||||
requested_by,
|
||||
reason,
|
||||
} => crate::print_json(
|
||||
&store
|
||||
.submit(&target, &requested_by, reason.as_deref())
|
||||
.await?,
|
||||
)?,
|
||||
Command::List { limit } => crate::print_json(&store.list(limit).await?)?,
|
||||
Command::Inspect { id } => crate::print_json(&store.get(id).await?)?,
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
72
docs/operator-user-deletion-intake.md
Normal file
72
docs/operator-user-deletion-intake.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Submit a synthetic user-deletion request
|
||||
|
||||
This first slice saves and reads requests. It does not discover users, build
|
||||
inventory, approve requests, start workers, or delete data. Only synthetic
|
||||
`demo:` labels are accepted.
|
||||
|
||||
## Prepare a local database
|
||||
|
||||
From the repository root, activate Hermit and start a separate PostgreSQL
|
||||
container. These are disposable local credentials.
|
||||
|
||||
```bash
|
||||
. ./bin/activate-hermit
|
||||
docker run --detach --name buzz-user-deletion-intake \
|
||||
--publish 127.0.0.1:55433:5432 \
|
||||
--env POSTGRES_USER=buzz \
|
||||
--env POSTGRES_PASSWORD=buzz-local-demo \
|
||||
--env POSTGRES_DB=buzz postgres:17-alpine
|
||||
export DATABASE_URL=postgres://buzz:buzz-local-demo@127.0.0.1:55433/buzz
|
||||
cargo build -p buzz-admin
|
||||
# Wait for "accepting connections" before migrating.
|
||||
docker exec buzz-user-deletion-intake pg_isready -U buzz -d buzz
|
||||
./target/debug/buzz-admin migrate
|
||||
```
|
||||
|
||||
Use an unused port and container name if these are occupied. The commands below
|
||||
need only PostgreSQL; a relay, Redis, and object storage are unnecessary.
|
||||
|
||||
## Submit and see the row
|
||||
|
||||
```bash
|
||||
./target/debug/buzz-admin user-deletions submit \
|
||||
--target demo:alice --requested-by operator-a
|
||||
```
|
||||
|
||||
The command prints the saved row as JSON. It contains:
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| `id` | New UUID; copy this for inspection |
|
||||
| `entity_type` | `user` |
|
||||
| `target` | `demo:alice` |
|
||||
| `status` | `submitted` |
|
||||
| `requested_by` | `operator-a` |
|
||||
| `reason` | `null`, unless supplied with `--reason` |
|
||||
| `created_at` | Submission time |
|
||||
| `updated_at` | Same time; no execution has started |
|
||||
|
||||
List recent requests, then inspect the UUID returned above:
|
||||
|
||||
```bash
|
||||
./target/debug/buzz-admin user-deletions list --limit 10
|
||||
REQUEST_ID=<returned-uuid>
|
||||
./target/debug/buzz-admin user-deletions inspect "$REQUEST_ID"
|
||||
```
|
||||
|
||||
To see the PostgreSQL row directly:
|
||||
|
||||
```bash
|
||||
docker exec buzz-user-deletion-intake psql -X -U buzz -d buzz -c \
|
||||
"SELECT id, entity_type, target, status, requested_by, created_at
|
||||
FROM entity_deletion_requests ORDER BY created_at DESC;"
|
||||
```
|
||||
|
||||
Exactly one row is saved in `entity_deletion_requests`. Repeating the same
|
||||
target, operator, and reason returns that row without changing its timestamps.
|
||||
Reusing the target with a different operator or reason fails and preserves the
|
||||
original row. Use a new `demo:` label for a new request.
|
||||
|
||||
This slice stops at `submitted`. Inventory, approval, stage history, worker
|
||||
ownership, and execution will be added in later changes. Their fields and tables
|
||||
can be added through normal additive migrations.
|
||||
20
migrations/0068_entity_deletion_requests.sql
Normal file
20
migrations/0068_entity_deletion_requests.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
-- Synthetic request intake only; no deletion worker or user cleanup.
|
||||
|
||||
-- Operator-global intake for synthetic user-deletion requests.
|
||||
CREATE TABLE entity_deletion_requests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
entity_type TEXT NOT NULL DEFAULT 'user' CHECK (entity_type = 'user'),
|
||||
target TEXT NOT NULL CHECK (target ~ '^demo:[a-z0-9][a-z0-9_-]{0,63}$'),
|
||||
status TEXT NOT NULL DEFAULT 'submitted' CHECK (status = 'submitted'),
|
||||
requested_by TEXT NOT NULL CHECK (length(trim(requested_by)) BETWEEN 1 AND 256),
|
||||
reason TEXT CHECK (octet_length(reason) <= 4096),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (entity_type, target)
|
||||
);
|
||||
|
||||
INSERT INTO _operator_global_tables (table_name, reason)
|
||||
VALUES ('entity_deletion_requests', 'Operator-global synthetic user-deletion intake')
|
||||
ON CONFLICT (table_name) DO NOTHING;
|
||||
@@ -122,6 +122,7 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
\i tables/public/push_gateway_delivery_request_replays.sql
|
||||
\i tables/public/replica_heartbeat.sql
|
||||
\i tables/public/community_deletion_requests.sql
|
||||
\i tables/public/entity_deletion_requests.sql
|
||||
\i tables/public/community_deletion_approvals.sql
|
||||
\i tables/public/community_deletion_checkpoints.sql
|
||||
\i tables/public/community_deletion_manifest_keys.sql
|
||||
@@ -194,6 +195,7 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
\i seeds/push_gateway_delivery_request_replays.sql
|
||||
\i seeds/replica_heartbeat.sql
|
||||
\i seeds/community_deletion_requests.sql
|
||||
\i seeds/entity_deletion_requests.sql
|
||||
\i seeds/community_deletion_approvals.sql
|
||||
\i seeds/community_deletion_checkpoints.sql
|
||||
\i seeds/community_deletion_manifest_keys.sql
|
||||
|
||||
3
schema/seeds/entity_deletion_requests.sql
Normal file
3
schema/seeds/entity_deletion_requests.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
INSERT INTO _operator_global_tables (table_name, reason)
|
||||
VALUES ('entity_deletion_requests', 'Operator-global synthetic user-deletion intake')
|
||||
ON CONFLICT (table_name) DO NOTHING;
|
||||
12
schema/tables/public/entity_deletion_requests.sql
Normal file
12
schema/tables/public/entity_deletion_requests.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- Operator-global intake for synthetic user-deletion requests.
|
||||
CREATE TABLE entity_deletion_requests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
entity_type TEXT NOT NULL DEFAULT 'user' CHECK (entity_type = 'user'),
|
||||
target TEXT NOT NULL CHECK (target ~ '^demo:[a-z0-9][a-z0-9_-]{0,63}$'),
|
||||
status TEXT NOT NULL DEFAULT 'submitted' CHECK (status = 'submitted'),
|
||||
requested_by TEXT NOT NULL CHECK (length(trim(requested_by)) BETWEEN 1 AND 256),
|
||||
reason TEXT CHECK (octet_length(reason) <= 4096),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (entity_type, target)
|
||||
);
|
||||
@@ -147,6 +147,9 @@ run_unit_tests() {
|
||||
run_test_step "buzz-admin NIP-FI binding command tests" \
|
||||
cargo test -p buzz-admin nip_fi::tests -- --nocapture
|
||||
|
||||
run_test_step "buzz-admin user-deletion intake command tests" \
|
||||
cargo test -p buzz-admin user_deletions::tests -- --nocapture
|
||||
|
||||
# Multi-tenant conformance gate: independent replay checker + golden
|
||||
# fixtures (buzz-conformance). Pure in-process trace replay, no infra.
|
||||
run_test_step "buzz-conformance tests" \
|
||||
|
||||
Reference in New Issue
Block a user