## Summary - Add self-service deletion for the authenticated customer or subuser identity only. - Preserve shared customer grants, reset keys, bookings, order bookings, vehicles, invoices, and legally required history. - Require password/TOTP or a fresh deletion-specific, five-minute, single-use WebAuthn assertion. - Reject support impersonation and expired legacy plain-session tokens. - Use durable database throttling, transactional request processing, a durable outbox, and terminal `manual_review` state. - Keep API and worker default-off behind separate `account_deletion.api_enabled` and `account_deletion.worker_enabled` module-config flags. ## Safe rollout 1. Keep both flags disabled. 2. Run `php scripts/account-deletion-schema.php check`. 3. If needed, run `php scripts/account-deletion-schema.php apply --yes`, then rerun `check` until `ready:true`. 4. Deploy the frontend companion PR while the API remains disabled. 5. Enable `api_enabled` for a controlled canary; verify password and passwordless request flows plus immediate authentication revocation. 6. Inspect queued request/outbox state, then enable `worker_enabled`. 7. Verify anonymization, preserved tenant/history data, outbox delivery, retries, and manual-review behavior before broad rollout. ## Verification - Account deletion unit tests: 2 passed, 43 assertions. - PHP lint, both OpenAPI YAML parses, runtime-DDL scan, destructive-scope scan, and `git diff --check` passed. - Full API/unit/integration evidence is required from exact-head CI; local Docker is unavailable and shared-vendor tests were explicitly discarded. ## Security notes - Schema mutation is CLI-only; web and cron paths perform read-only readiness checks. - Runtime behavior fails closed when schema/config/throttle/delivery prerequisites are unavailable.
128 lines
6.3 KiB
PHP
128 lines
6.3 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
/**
|
|
* Explicit account-deletion schema management.
|
|
*
|
|
* apply() must only be invoked by the dedicated CLI. Web requests and cron jobs
|
|
* are deliberately limited to the read-only check().
|
|
*/
|
|
class account_deletion_schema_bootstrap
|
|
{
|
|
/** @return array{ready:bool,missing:array<int,string>} */
|
|
public static function check(): array
|
|
{
|
|
global $db;
|
|
$missing = [];
|
|
foreach (['account_deletion_requests', 'account_deletion_credential_attempts', 'account_deletion_outbox'] as $table) {
|
|
$tableSql = $db->escape_string($table);
|
|
$result = $db->query("SHOW TABLES LIKE '$tableSql'");
|
|
if ($result === false || $result->num_rows === 0) {
|
|
$missing[] = 'table:' . $table;
|
|
}
|
|
}
|
|
foreach (['users' => 'deleted_at', 'subusers' => 'deleted_at'] as $table => $column) {
|
|
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$column'");
|
|
if ($result === false || $result->num_rows === 0) {
|
|
$missing[] = 'column:' . $table . '.' . $column;
|
|
}
|
|
}
|
|
if (!in_array('table:account_deletion_requests', $missing, true)) {
|
|
$result = $db->query("SHOW COLUMNS FROM account_deletion_requests LIKE 'manual_review_required_at'");
|
|
if ($result === false || $result->num_rows === 0) {
|
|
$missing[] = 'column:account_deletion_requests.manual_review_required_at';
|
|
}
|
|
}
|
|
return ['ready' => $missing === [], 'missing' => $missing];
|
|
}
|
|
|
|
public static function apply(): void
|
|
{
|
|
if (PHP_SAPI !== 'cli') {
|
|
throw new \RuntimeException('Account deletion schema changes are CLI-only.');
|
|
}
|
|
global $db;
|
|
self::execute("CREATE TABLE IF NOT EXISTS account_deletion_requests (
|
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
request_id CHAR(36) NOT NULL,
|
|
principal_type VARCHAR(16) NOT NULL,
|
|
principal_id BIGINT UNSIGNED NOT NULL,
|
|
customer_number_snapshot INT NULL,
|
|
active_principal_key VARCHAR(191) NULL,
|
|
status VARCHAR(32) NOT NULL DEFAULT 'requested',
|
|
policy_version VARCHAR(32) NOT NULL,
|
|
retained_data_json LONGTEXT NOT NULL,
|
|
request_ip VARCHAR(45) NULL,
|
|
request_user_agent VARCHAR(512) NULL,
|
|
retry_count INT UNSIGNED NOT NULL DEFAULT 0,
|
|
failure_code VARCHAR(191) NULL,
|
|
requested_at DATETIME NOT NULL,
|
|
processing_at DATETIME NULL,
|
|
completed_at DATETIME NULL,
|
|
next_attempt_at DATETIME NULL,
|
|
manual_review_required_at DATETIME NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
UNIQUE KEY uniq_account_deletion_request_id (request_id),
|
|
UNIQUE KEY uniq_account_deletion_active_principal (active_principal_key),
|
|
INDEX idx_account_deletion_worker (status, next_attempt_at, requested_at),
|
|
INDEX idx_account_deletion_principal (principal_type, principal_id, requested_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
|
|
self::ensureColumn('account_deletion_requests', 'manual_review_required_at', 'DATETIME NULL AFTER `next_attempt_at`');
|
|
self::execute("CREATE TABLE IF NOT EXISTS account_deletion_credential_attempts (
|
|
throttle_key CHAR(64) NOT NULL,
|
|
attempt_count INT UNSIGNED NOT NULL DEFAULT 1,
|
|
window_started_at DATETIME NOT NULL,
|
|
blocked_until DATETIME NULL,
|
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (throttle_key), INDEX idx_account_deletion_throttle_expiry (updated_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
|
|
self::execute("CREATE TABLE IF NOT EXISTS account_deletion_outbox (
|
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
request_id CHAR(36) NOT NULL,
|
|
event_type VARCHAR(64) NOT NULL,
|
|
payload_json LONGTEXT NOT NULL,
|
|
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
|
attempts INT UNSIGNED NOT NULL DEFAULT 0,
|
|
available_at DATETIME NOT NULL,
|
|
processing_at DATETIME NULL,
|
|
delivered_at DATETIME NULL,
|
|
last_error VARCHAR(191) NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id), UNIQUE KEY uniq_account_deletion_outbox_event (request_id, event_type),
|
|
INDEX idx_account_deletion_outbox_delivery (status, available_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
|
|
self::ensureColumn('account_deletion_outbox', 'processing_at', 'DATETIME NULL AFTER `available_at`');
|
|
self::ensureColumn('users', 'deleted_at', 'DATETIME NULL AFTER `updated_at`');
|
|
self::ensureColumn('subusers', 'deleted_at', 'DATETIME NULL AFTER `suspended_at`');
|
|
self::ensureIndex('users', 'idx_users_deleted_at', '`deleted_at`');
|
|
self::ensureIndex('subusers', 'idx_subusers_deleted_at', '`deleted_at`');
|
|
}
|
|
|
|
private static function execute(string $sql): void
|
|
{
|
|
global $db;
|
|
if ($db->query($sql) === false) {
|
|
throw new \RuntimeException('Account deletion schema operation failed.');
|
|
}
|
|
}
|
|
|
|
private static function ensureColumn(string $table, string $column, string $definition): void
|
|
{
|
|
global $db;
|
|
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$column'");
|
|
if ($result === false) throw new \RuntimeException('Unable to inspect account deletion schema.');
|
|
if ($result->num_rows === 0) self::execute("ALTER TABLE `$table` ADD COLUMN `$column` $definition");
|
|
}
|
|
|
|
private static function ensureIndex(string $table, string $index, string $columns): void
|
|
{
|
|
global $db;
|
|
$result = $db->query("SHOW INDEX FROM `$table` WHERE Key_name = '$index'");
|
|
if ($result === false) throw new \RuntimeException('Unable to inspect account deletion indexes.');
|
|
if ($result->num_rows === 0) self::execute("ALTER TABLE `$table` ADD INDEX `$index` ($columns)");
|
|
}
|
|
}
|