Files
api/services/nginx/app/classes/account_deletion_schema_bootstrap.php
T
Jeppe B 0060fb45ca Add in-app account deletion (#319)
## 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.
2026-07-22 19:22:17 +02:00

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)");
}
}