diff --git a/Justfile b/Justfile index 59e98d9c11..172b7f4ae0 100644 --- a/Justfile +++ b/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 diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 4f91369d6d..7853a7843e 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -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 { } => 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?; diff --git a/crates/buzz-admin/src/user_deletions.rs b/crates/buzz-admin/src/user_deletions.rs new file mode 100644 index 0000000000..c8db6027fb --- /dev/null +++ b/crates/buzz-admin/src/user_deletions.rs @@ -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 { + 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()); + } + } +} diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index f495983a28..7aa8607cdf 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -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; diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index af87733252..1de188c5eb 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -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] { diff --git a/crates/buzz-db/src/store/entity_deletion.rs b/crates/buzz-db/src/store/entity_deletion.rs new file mode 100644 index 0000000000..c2d5a07b69 --- /dev/null +++ b/crates/buzz-db/src/store/entity_deletion.rs @@ -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, + /// Time of the original submission. + pub created_at: DateTime, + /// Time of the last change; identical resubmissions do not change it. + pub updated_at: DateTime, +} + +/// 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 { + 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 { + 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> { + 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; diff --git a/crates/buzz-db/src/store/entity_deletion_postgres_tests.rs b/crates/buzz-db/src/store/entity_deletion_postgres_tests.rs new file mode 100644 index 0000000000..32a15cec24 --- /dev/null +++ b/crates/buzz-db/src/store/entity_deletion_postgres_tests.rs @@ -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"); +} diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index 6850b625cf..198bb6f407 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -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. diff --git a/crates/buzz-deletion/src/lib.rs b/crates/buzz-deletion/src/lib.rs index c3fb598523..483ce7e90c 100644 --- a/crates/buzz-deletion/src/lib.rs +++ b/crates/buzz-deletion/src/lib.rs @@ -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 Result { + Ok(store(&connect_database().await?)) +} + +async fn connect_database() -> Result { 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 { "deletion", ) .await?; - Ok(store(&db)) + Ok(db) } fn resolve_s3_region(buzz_region: Option, aws_region: Option) -> String { diff --git a/crates/buzz-deletion/src/user.rs b/crates/buzz-deletion/src/user.rs new file mode 100644 index 0000000000..798c14f71f --- /dev/null +++ b/crates/buzz-deletion/src/user.rs @@ -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, + }, + /// 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 { + 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) +} diff --git a/docs/operator-user-deletion-intake.md b/docs/operator-user-deletion-intake.md new file mode 100644 index 0000000000..6900661e08 --- /dev/null +++ b/docs/operator-user-deletion-intake.md @@ -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= +./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. diff --git a/migrations/0068_entity_deletion_requests.sql b/migrations/0068_entity_deletion_requests.sql new file mode 100644 index 0000000000..a084f74790 --- /dev/null +++ b/migrations/0068_entity_deletion_requests.sql @@ -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; diff --git a/schema/schema.sql b/schema/schema.sql index d252cbdb7e..6e85a9a455 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -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 diff --git a/schema/seeds/entity_deletion_requests.sql b/schema/seeds/entity_deletion_requests.sql new file mode 100644 index 0000000000..f0b30c30d0 --- /dev/null +++ b/schema/seeds/entity_deletion_requests.sql @@ -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; diff --git a/schema/tables/public/entity_deletion_requests.sql b/schema/tables/public/entity_deletion_requests.sql new file mode 100644 index 0000000000..a7788023c5 --- /dev/null +++ b/schema/tables/public/entity_deletion_requests.sql @@ -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) +); diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 3850d288c5..8bd7842bff 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -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" \