Files
api/services/nginx/app/classes/account_deletion_service.php
T
Jeppe Bandopenhands a71194d46e refactor(api): centralise truthy-string -> bool coercion in a shared trait (#368)
## What

Centralises the truthy-string → bool coercion that six different classes
were reimplementing (and which two implementations disagreed about).

The shared helper lives in
`services/nginx/app/traits/boolean_normalization_t.php`:

```php
namespace traits;
trait boolean_normalization_t {
    public static function normalizeBoolean(mixed $value): bool {
        if (is_bool($value)) return $value;
        return in_array(strtolower(trim((string)$value)), ['1','true','yes','on'], true);
    }
}
```

`traits/module_config_variable_t::inputToBool` now delegates to it. The
seven call sites that previously inlined the same expression (or wrapped
it in a private `toBool`/`boolValue`/`isEnabled`) are reduced to a
single `self::normalizeBoolean(...)` call:

| Class | Old helper | New |
| --- | --- | --- |
| `classes/cron_worker.php` | inline in `boolOption` |
`self::normalizeBoolean(...)` (after empty-value short-circuit) |
| `classes/replica_failover_manager.php` | `boolValue` |
`self::normalizeBoolean(...)` |
| `classes/release_manager.php` | `toBool` |
`self::normalizeBoolean(...)` |
| `classes/releasemanager.php` | `isEnabled` |
`self::normalizeBoolean(...)` |
| `classes/superuser_system_status_service.php` | inline in
`parseModuleConfigValue` | `self::normalizeBoolean(...)` |
| `classes/module_usage_service.php` | `toBool` |
`self::normalizeBoolean(...)` |
| `classes/account_deletion_service.php` | inline in `apiEnabled` |
`self::normalizeBoolean(...)` |
| `traits/module_config_variable_t.php` | `inputToBool` (narrow set) |
`self::normalizeBoolean(...)` (full set) |

## Why

The pre-PR repo had two silent bugs:

1. **`inputToBool` accepted only `'true'`/`'1'`** while the six inline
copies accepted the wider `['1','true','yes','on']` set. Config values
such as `"yes"` or `" ON "` would round-trip to `false` through
`inputToBool` but `true` through any of the inline copies. This PR picks
the wider set as the single source of truth; the change is a strict
superset, so no caller flips from truthy to falsy.
2. **Six copies of the same expression** to drift in any of the seven
places (whitespace handling, case sensitivity, empty-string semantics).
One trait replaces them.

## Tests

* `tests/Unit/Traits/BooleanNormalizationTest.php` — Pest, runs the
helper directly through anonymous-class composition (no DB/HTTP).
* `tests/Smoke/boolean_normalization_smoke.php` — standalone PHP smoke
runner for environments without composer installed. Verified locally:
7/7 consumer wiring checks pass, 19/19 normalization cases pass (`true`,
`false`, `1`, `0`, `'true'`, `'TRUE'`, `'1'`, `'yes'`, `'YES'`, `'on'`,
`' ON '`, `'false'`, `'no'`, `'off'`, `''`, `null`, `'0'`, `[]`,
stdClass).
* All eight touched files pass `php -l` syntax check.

## Risk

* Behavioural change is a strict superset for the shared expression
path, so no caller can flip from truthy → falsy. The only consumer that
saw a behaviour change for *negative* inputs is `inputToBool` itself,
which previously rejected `'yes'`/`'on'`. Worth a CI pass on the
unit/integration suites before merge.

## Co-author

Co-authored-by: openhands <openhands@all-hands.dev>

---

_This PR was generated by an AI agent (OpenHands) on behalf of
copenhagentruckwash._

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-12 20:20:03 +02:00

1046 lines
46 KiB
PHP

<?php
namespace classes;
use objects\logs_o;
use objects\passkeys_o;
use objects\subusers_o;
use objects\users_o;
use Throwable;
use traits\boolean_normalization_t;
require_once __DIR__ . '/../traits/boolean_normalization_t.php';
class account_deletion_service
{
use boolean_normalization_t;
public const CONFIRMATION_PHRASE = 'SLET MIN KONTO';
public const POLICY_VERSION = '2026-07-20';
public const MAX_RETRIES = 5;
public const PROCESSING_LEASE_SECONDS = 900;
public const LEGACY_SESSION_CUTOFF = '2026-07-20 14:45:20';
private const MAX_CREDENTIAL_ATTEMPTS = 4;
/** @var array<string, bool> */
private static array $tableExistsCache = [];
/** @var array<string, array<int, string>> */
private static array $columnsCache = [];
public function __construct()
{
$schema = account_deletion_schema_bootstrap::check();
if (!$schema['ready']) {
throw new \RuntimeException('Account deletion schema is not ready; run the explicit schema CLI.');
}
self::$tableExistsCache['account_deletion_requests'] = true;
unset(self::$columnsCache['users'], self::$columnsCache['subusers']);
}
public static function apiEnabled(): bool
{
return self::featureEnabled('api_enabled');
}
public static function workerEnabled(): bool
{
return self::featureEnabled('worker_enabled');
}
private static function featureEnabled(string $variable): bool
{
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) return false;
try {
$module = $db->escape_string('account_deletion');
$variable = $db->escape_string($variable);
$result = $db->query("SELECT value FROM module_config WHERE module = '$module' AND variable = '$variable' LIMIT 1");
if ($result === false || $result->num_rows === 0) return false;
$row = $result->fetch_assoc();
return self::normalizeBoolean((string)($row['value'] ?? ''));
} catch (Throwable) {
return false;
}
}
public function passkeyChallenge(array $principal): array
{
if ($this->principalHasPassword($principal)) {
throw new account_deletion_http_exception('Passkey reauthentication is only required for passwordless accounts', 400);
}
global $db;
$id = (int)$principal['id'];
$isSubuser = $principal['type'] === 'subuser' ? 1 : 0;
$result = $db->query("SELECT credential_id, transports FROM passkeys WHERE user_id = $id AND is_subuser = $isSubuser AND deleted_at IS NULL");
if ($result === false) throw new \RuntimeException('Unable to load deletion passkeys.');
$rows = $db->fetch_all($result);
if ($rows === []) throw new account_deletion_http_exception('No active passkey is available', 403);
$token = bin2hex(random_bytes(32));
$this->execute("INSERT INTO tokens (user_id, token, type) VALUES ($id, " . self::sql($token) . ", 'ACCOUNT_DELETION_PASSKEY_CHALLENGE')", 'Unable to persist deletion passkey challenge.');
$originHost = parse_url((string)($_SERVER['HTTP_ORIGIN'] ?? ''), PHP_URL_HOST);
$httpHost = isset($_SERVER['HTTP_HOST']) ? explode(':', (string)$_SERVER['HTTP_HOST'])[0] : null;
$challenge = rtrim(strtr(base64_encode((string)hex2bin($token)), '+/', '-_'), '=');
return ['challenge_token' => $token, 'publicKey' => [
'challenge' => $challenge,
'rpId' => $originHost ?: $httpHost ?: ($_SERVER['SERVER_NAME'] ?? 'truckwash.io'),
'timeout' => 60000, 'userVerification' => 'required',
'allowCredentials' => array_map(static function (array $row): array {
$transports = json_decode((string)($row['transports'] ?? '[]'), true);
return ['type' => 'public-key', 'id' => (string)$row['credential_id'], 'transports' => is_array($transports) ? $transports : []];
}, $rows),
]];
}
/**
* Authentication hot-path guard. Missing pre-deployment schema is treated as no block;
* once the deletion schema exists, either an active request or deleted_at blocks access.
*/
public static function principalIsBlocked(string $principalType, int $principalId): bool
{
if (!in_array($principalType, ['customer', 'subuser'], true) || $principalId <= 0) {
return true;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return false;
}
if (self::tableExists('account_deletion_requests')) {
$key = self::sql($principalType . ':' . $principalId);
$result = $db->query(
"SELECT id FROM account_deletion_requests
WHERE active_principal_key = $key
AND status IN ('requested', 'processing', 'failed', 'manual_review')
LIMIT 1"
);
if ($result === false) {
throw new \RuntimeException('Unable to inspect active account deletion requests.');
}
if ($result->num_rows > 0) {
return true;
}
}
$table = $principalType === 'customer' ? 'users' : 'subusers';
if (!self::tableExists($table) || !in_array('deleted_at', self::columns($table), true)) {
return false;
}
$result = $db->query("SELECT deleted_at FROM `$table` WHERE id = $principalId LIMIT 1");
if ($result === false) {
throw new \RuntimeException('Unable to inspect principal deletion state.');
}
if ($result->num_rows === 0) {
return false;
}
$row = $result->fetch_assoc();
return ($row['deleted_at'] ?? null) !== null;
}
/** @return array{type:string,id:int,customer_number:?int,object:users_o|subusers_o,token:string,email:?string,name:string} */
public function currentPrincipal(): array
{
$authentication = new authentication();
$user = $authentication->get_user();
if ($user !== false) {
$bearerToken = $this->bearerToken();
$this->rejectImpersonationToken($bearerToken);
$customerNumber = (int)$user->customer_number->value();
if ($customerNumber <= 0) {
throw new account_deletion_http_exception(
'Account deletion is only available to customer accounts',
403
);
}
return [
'type' => 'customer',
'id' => (int)$user->id,
'customer_number' => $customerNumber,
'object' => $user,
'token' => $bearerToken,
'email' => $this->nullableString($user->email->value()),
'name' => $this->nullableString($user->display_name->value()) ?? 'Kunde',
];
}
$subuser = $authentication->get_subuser();
if ($subuser !== false) {
return [
'type' => 'subuser',
'id' => (int)$subuser->id,
'customer_number' => null,
'object' => $subuser,
'token' => $this->bearerToken(),
'email' => $this->nullableString($subuser->email->value()),
'name' => $this->nullableString($subuser->name->value()) ?? 'Chauffør',
];
}
throw new account_deletion_http_exception('Unauthorized', 401);
}
public function state(array $principal): array
{
$row = $this->activeRequest((string)$principal['type'], (int)$principal['id']);
return $this->statePayload($principal, $row);
}
public function request(array $principal, array $input, ?string $ip, ?string $userAgent): array
{
$this->validateConfirmation($input);
$throttleKey = $this->recordCredentialAttempt($principal, $ip);
$this->verifyCredentials($principal, $input);
$this->clearCredentialAttempts($throttleKey);
$existing = $this->activeRequest((string)$principal['type'], (int)$principal['id']);
if ($existing !== null) {
return $this->acceptedPayload($existing);
}
global $db;
$connection = $db->conn();
$connection->begin_transaction();
try {
$existing = $this->activeRequest((string)$principal['type'], (int)$principal['id'], true);
if ($existing !== null) {
$connection->commit();
return $this->acceptedPayload($existing);
}
$requestId = self::uuidV4();
$principalType = (string)$principal['type'];
$principalId = (int)$principal['id'];
$customerNumber = $principal['customer_number'] !== null ? (int)$principal['customer_number'] : null;
$retained = json_encode($this->retainedDataCategories($principalType), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($retained)) {
throw new \RuntimeException('Could not encode retained-data categories.');
}
$now = date('Y-m-d H:i:s');
$activeKey = $principalType . ':' . $principalId;
$sql = "INSERT INTO account_deletion_requests
(request_id, principal_type, principal_id, customer_number_snapshot,
active_principal_key, status, policy_version, retained_data_json,
request_ip, request_user_agent, requested_at, next_attempt_at)
VALUES ("
. self::sql($requestId) . ', '
. self::sql($principalType) . ', '
. $principalId . ', '
. ($customerNumber !== null ? (string)$customerNumber : 'NULL') . ', '
. self::sql($activeKey) . ", 'requested', "
. self::sql(self::POLICY_VERSION) . ', '
. self::sql($retained) . ', '
. ($ip !== null ? self::sql(substr($ip, 0, 45)) : 'NULL') . ', '
. ($userAgent !== null ? self::sql(substr($userAgent, 0, 512)) : 'NULL') . ', '
. self::sql($now) . ', '
. self::sql($now) . ')';
$this->execute($sql, 'Unable to create account deletion request.');
$this->blockPrincipalImmediately($principalType, $principalId, $customerNumber, $now);
$this->enqueueOutbox($requestId, 'requested', ['principal_type' => $principalType, 'principal_id' => $principalId]);
$connection->commit();
} catch (Throwable $throwable) {
$connection->rollback();
$existing = $this->activeRequest((string)$principal['type'], (int)$principal['id']);
if ($existing !== null) {
return $this->acceptedPayload($existing);
}
throw $throwable;
}
$this->revokeCaches($principal);
$this->sendRequestedNotification($principal, $requestId);
$created = $this->requestByPublicId($requestId);
if ($created === null) {
throw new \RuntimeException('Account deletion request was not persisted.');
}
return $this->acceptedPayload($created);
}
/** @return array{processed:int,completed:int,failed:int} */
public function processPending(int $limit = 25): array
{
$limit = max(1, min(100, $limit));
global $db;
$now = self::sql(date('Y-m-d H:i:s'));
$staleBefore = self::sql(date('Y-m-d H:i:s', time() - self::PROCESSING_LEASE_SECONDS));
$result = $db->query(
"SELECT * FROM account_deletion_requests
WHERE (
(status IN ('requested', 'failed')
AND retry_count < " . self::MAX_RETRIES . "
AND (next_attempt_at IS NULL OR next_attempt_at <= $now))
OR
(status = 'processing' AND (processing_at IS NULL OR processing_at <= $staleBefore))
)
ORDER BY requested_at ASC, id ASC
LIMIT $limit"
);
if ($result === false) {
throw new \RuntimeException('Unable to inspect queued account deletion requests.');
}
$rows = $db->fetch_all($result);
$summary = ['processed' => 0, 'completed' => 0, 'failed' => 0];
foreach ($rows as $row) {
$id = (int)($row['id'] ?? 0);
if ($id <= 0 || !$this->claim($id)) {
continue;
}
$summary['processed']++;
try {
$this->complete($row);
$summary['completed']++;
} catch (Throwable $throwable) {
$this->recordFailure($id, (int)($row['retry_count'] ?? 0), $throwable);
$summary['failed']++;
}
}
$this->dispatchOutbox(50);
return $summary;
}
private function dispatchOutbox(int $limit): void
{
global $db;
$limit = max(1, min(100, $limit));
$result = $db->query("SELECT * FROM account_deletion_outbox WHERE (status = 'pending' AND available_at <= NOW()) OR (status = 'processing' AND processing_at <= DATE_SUB(NOW(), INTERVAL 15 MINUTE)) ORDER BY id ASC LIMIT $limit");
if ($result === false) throw new \RuntimeException('Unable to inspect account deletion outbox.');
foreach ($db->fetch_all($result) as $event) {
$id = (int)($event['id'] ?? 0);
if ($id <= 0) continue;
$this->execute("UPDATE account_deletion_outbox SET status = 'processing', processing_at = NOW() WHERE id = $id AND (status = 'pending' OR (status = 'processing' AND processing_at <= DATE_SUB(NOW(), INTERVAL 15 MINUTE)))", 'Unable to claim deletion outbox event.');
if ((int)$db->conn()->affected_rows !== 1) continue;
$connection = $db->conn();
$connection->begin_transaction();
try {
$payload = json_decode((string)$event['payload_json'], true);
$actor = is_array($payload) ? (int)($payload['principal_id'] ?? 0) : 0;
$action = 'ACCOUNT_DELETION_' . strtoupper((string)$event['event_type']);
$this->execute(
"INSERT INTO logs (module, department, type, user_id, action, message, created_at, updated_at) VALUES "
. "('account_deletion', 'global', 1, $actor, " . self::sql($action) . ', '
. self::sql('request:' . (string)$event['request_id']) . ', NOW(), NOW())',
'Unable to persist account deletion audit event.'
);
$this->execute("UPDATE account_deletion_outbox SET status = 'delivered', processing_at = NULL, delivered_at = NOW(), last_error = NULL WHERE id = $id AND status = 'processing'", 'Unable to acknowledge deletion outbox event.');
$connection->commit();
} catch (Throwable $throwable) {
$connection->rollback();
$error = substr((new \ReflectionClass($throwable))->getShortName(), 0, 191);
$this->execute("UPDATE account_deletion_outbox SET status = 'pending', processing_at = NULL, attempts = attempts + 1, available_at = DATE_ADD(NOW(), INTERVAL 5 MINUTE), last_error = " . self::sql($error) . " WHERE id = $id", 'Unable to reschedule deletion outbox event.');
}
}
}
private function validateConfirmation(array $input): void
{
if (($input['confirmation'] ?? null) !== self::CONFIRMATION_PHRASE) {
throw new account_deletion_http_exception('Confirmation phrase does not match', 400);
}
if (($input['acknowledge_legal_retention'] ?? null) !== true) {
throw new account_deletion_http_exception('Legal retention acknowledgement is required', 400);
}
}
private function verifyCredentials(array $principal, array $input): void
{
$object = $principal['object'];
$password = $input['password'] ?? null;
$passwordMatches = false;
if ($object instanceof users_o) {
$hash = $object->getPassword();
if (is_string($hash) && $hash !== '') {
if (!is_string($password) || $password === '') {
throw new account_deletion_http_exception('Password is required', 400);
}
try {
$passwordMatches = $object->passwordMatches($password);
} catch (Throwable) {
$passwordMatches = false;
}
} else {
$passwordMatches = $this->verifyDeletionPasskey($principal, $input);
}
} elseif ($object instanceof subusers_o) {
$hash = $object->password->value();
if (is_string($hash) && $hash !== '') {
if (!is_string($password) || $password === '') {
throw new account_deletion_http_exception('Password is required', 400);
}
$passwordMatches = password_verify($password, $hash);
} else {
$passwordMatches = $this->verifyDeletionPasskey($principal, $input);
}
}
if (!$passwordMatches) {
throw new account_deletion_http_exception('Invalid credentials', 401);
}
if ($object->isTwoFactorEnabled()) {
$code = $input['two_factor_code'] ?? null;
if (!is_string($code) || trim($code) === '') {
throw new account_deletion_http_exception('Two-factor code is required', 400);
}
if (!(new authentication())->verify_2fa_code($object, $code)) {
throw new account_deletion_http_exception('Invalid two-factor code', 401);
}
}
}
private function verifyDeletionPasskey(array $principal, array $input): bool
{
global $db;
$challenge = $input['passkey_challenge_token'] ?? null;
$credential = $input['passkey_credential'] ?? null;
if (!is_string($challenge) || strlen($challenge) !== 64 || $credential === null) {
throw new account_deletion_http_exception('A fresh deletion passkey assertion is required', 400);
}
$credentialJson = is_string($credential) ? $credential : json_encode($credential, JSON_UNESCAPED_SLASHES);
$credentialArray = is_array($credential) ? $credential : json_decode((string)$credentialJson, true);
$credentialId = is_array($credentialArray) ? ($credentialArray['id'] ?? $credentialArray['rawId'] ?? null) : null;
$id = (int)$principal['id'];
$challengeSql = self::sql($challenge);
$result = $db->query("SELECT id FROM tokens WHERE token = $challengeSql AND user_id = $id AND type = 'ACCOUNT_DELETION_PASSKEY_CHALLENGE' AND created_at >= DATE_SUB(NOW(), INTERVAL 5 MINUTE) LIMIT 1");
if ($result === false) throw new \RuntimeException('Unable to inspect deletion passkey challenge.');
$this->execute("DELETE FROM tokens WHERE token = $challengeSql AND type = 'ACCOUNT_DELETION_PASSKEY_CHALLENGE'", 'Unable to consume deletion passkey challenge.');
if ($result->num_rows === 0 || (int)$db->conn()->affected_rows !== 1 || !is_string($credentialId) || !is_string($credentialJson)) return false;
$passkey = (new passkeys_o())->findByCredentialId($credentialId, $id);
if ($passkey === null || (bool)$passkey->is_subuser->value() !== ($principal['type'] === 'subuser')) return false;
$host = parse_url((string)($_SERVER['HTTP_ORIGIN'] ?? ''), PHP_URL_HOST) ?: ($_SERVER['SERVER_NAME'] ?? 'localhost');
return (new webauthn())->verifyAssertion($credentialJson, $challenge, $passkey, $host);
}
private function recordCredentialAttempt(array $principal, ?string $ip): string
{
$key = hash(
'sha256',
(string)$principal['type'] . ':' . (int)$principal['id'] . ':' . trim((string)$ip)
);
$keySql = self::sql($key);
$maximumAttempts = self::MAX_CREDENTIAL_ATTEMPTS;
$this->execute(
"INSERT INTO account_deletion_credential_attempts (throttle_key, attempt_count, window_started_at)
VALUES ($keySql, 1, NOW()) ON DUPLICATE KEY UPDATE
blocked_until = IF(
window_started_at < DATE_SUB(NOW(), INTERVAL 15 MINUTE),
NULL,
IF(attempt_count + 1 >= $maximumAttempts, DATE_ADD(NOW(), INTERVAL 15 MINUTE), blocked_until)
),
attempt_count = IF(window_started_at < DATE_SUB(NOW(), INTERVAL 15 MINUTE), 1, attempt_count + 1),
window_started_at = IF(window_started_at < DATE_SUB(NOW(), INTERVAL 15 MINUTE), NOW(), window_started_at)",
'Unable to record deletion credential attempt.'
);
global $db;
$result = $db->query("SELECT attempt_count, blocked_until FROM account_deletion_credential_attempts WHERE throttle_key = $keySql LIMIT 1");
if ($result === false || $result->num_rows !== 1) throw new \RuntimeException('Unable to enforce deletion credential throttle.');
$row = $result->fetch_assoc();
if ((int)$row['attempt_count'] >= self::MAX_CREDENTIAL_ATTEMPTS || (($row['blocked_until'] ?? null) !== null && strtotime((string)$row['blocked_until']) > time())) {
throw new account_deletion_http_exception('Too many deletion confirmation attempts', 429);
}
return $key;
}
private function clearCredentialAttempts(string $key): void
{
$this->execute('DELETE FROM account_deletion_credential_attempts WHERE throttle_key = ' . self::sql($key), 'Unable to clear deletion credential attempts.');
}
private function blockPrincipalImmediately(string $type, int $id, ?int $customerNumber, string $now): void
{
global $db;
if ($type === 'customer') {
if (self::tableExists('limited_backoffice_employees')) {
$employeeLock = $db->query(
"SELECT user_id FROM limited_backoffice_employees
WHERE user_id = $id LIMIT 1 FOR UPDATE"
);
if ($employeeLock === false) {
throw new \RuntimeException('Unable to lock deleted employee.');
}
}
$this->updateExistingColumns('users', $id, ['deleted_at' => $now]);
if (self::tableExists('limited_backoffice_login_grants')) {
$this->execute(
"UPDATE limited_backoffice_login_grants
SET revoked_at = COALESCE(revoked_at, " . self::sql($now) . ")
WHERE (target_user_id = $id OR actor_user_id = $id)
AND consumed_at IS NULL",
'Unable to revoke deleted principal login grants.'
);
}
$this->deleteTokensForPrincipal('customer', $id);
} else {
$this->updateExistingColumns('subusers', $id, ['suspended_at' => $now, 'deleted_at' => $now]);
$this->execute(
"UPDATE subuser_grants
SET enabled = 0, deleted_at = COALESCE(deleted_at, " . self::sql($now) . "), note = NULL
WHERE subuser = $id", 'Unable to revoke deleted subuser grants.'
);
$this->deleteTokensForPrincipal('subuser', $id);
}
if (self::tableExists('passkeys')) {
$this->execute(
"UPDATE passkeys SET deleted_at = COALESCE(deleted_at, " . self::sql($now) . ")
WHERE user_id = $id AND is_subuser = " . ($type === 'subuser' ? '1' : '0'),
'Unable to revoke deleted principal passkeys.'
);
}
}
private function deleteTokensForPrincipal(string $type, int $id): void
{
global $db;
$types = $type === 'customer'
? "'AUTH_TOKEN', '2FA_VERIFICATION_USER', 'PASSKEY_CHALLENGE'"
: "'AUTH_TOKEN_SUBUSER', '2FA_VERIFICATION_SUBUSER', 'PASSKEY_CHALLENGE'";
$typeWhere = "type IN ($types)";
if ($type === 'customer') {
$typeWhere = "($typeWhere OR LEFT(type, 25) = 'AUTH_TOKEN_IMPERSONATION:')";
}
$result = $db->query("SELECT token FROM tokens WHERE user_id = $id AND $typeWhere");
if ($result === false) {
throw new \RuntimeException('Unable to inspect principal credentials.');
}
$tokens = $db->fetch_all($result);
$this->execute("DELETE FROM tokens WHERE user_id = $id AND $typeWhere", 'Unable to revoke deleted principal tokens.');
if (!defined('redis')) {
return;
}
foreach ($tokens as $tokenRow) {
$token = (string)($tokenRow['token'] ?? '');
if ($token === '') {
continue;
}
try {
constant('redis')->clear_token($token)->clear_auth_session($token);
constant('redis')->delete('passkey_challenge_principal:' . $token);
} catch (Throwable) {
}
}
}
private function revokeCaches(array $principal): void
{
if (!defined('redis')) {
return;
}
$redis = constant('redis');
$id = (int)$principal['id'];
$token = (string)($principal['token'] ?? '');
try {
if ($token !== '') {
$redis->clear_token($token)->clear_auth_session($token);
(new subusers_o())->invalidateSessionToken($token);
}
if ($principal['type'] === 'customer') {
$customerNumber = (int)($principal['customer_number'] ?? 0);
if ($customerNumber > 0) {
$redis->clear_user_id_from_customer_number($customerNumber);
}
$redis->clear_customer_number_from_user_id($id);
} else {
foreach ($redis->get_keys('*subuser_sessions_session_token:*') as $key) {
if ((int)$redis->get((string)$key) === $id) {
$redis->delete((string)$key);
}
}
foreach ($redis->get_keys('*subuser_setup_token*') as $key) {
$key = (string)$key;
if (str_ends_with($key, ':' . $id) || (int)$redis->get($key) === $id) {
$redis->delete($key);
}
}
}
foreach ($redis->get_keys('obj_prop:' . ($principal['type'] === 'customer' ? 'users' : 'subusers') . ':' . $id . ':*') as $key) {
$redis->delete((string)$key);
}
} catch (Throwable) {
// Database token deletion and auth guards remain authoritative.
}
}
private function claim(int $id): bool
{
global $db;
$now = self::sql(date('Y-m-d H:i:s'));
$staleBefore = self::sql(date('Y-m-d H:i:s', time() - self::PROCESSING_LEASE_SECONDS));
$this->execute(
"UPDATE account_deletion_requests
SET status = 'processing', processing_at = $now, failure_code = NULL
WHERE id = $id AND (
status IN ('requested', 'failed')
OR (status = 'processing' AND (processing_at IS NULL OR processing_at <= $staleBefore))
)", 'Unable to claim account deletion request.'
);
return (int)$db->conn()->affected_rows === 1;
}
private function complete(array $row): void
{
global $db;
$id = (int)$row['id'];
$principalType = (string)$row['principal_type'];
$principalId = (int)$row['principal_id'];
$email = $this->principalEmail($principalType, $principalId);
$name = $this->principalName($principalType, $principalId);
$connection = $db->conn();
$connection->begin_transaction();
try {
$now = date('Y-m-d H:i:s');
if ($principalType === 'customer') {
$this->updateExistingColumns('users', $principalId, [
'display_name' => 'Slettet konto',
'email' => null,
'phone_country_code' => null,
'phone' => null,
'password' => null,
'xlvask_customer_id' => null,
'sms_notifications_enabled' => 0,
'email_notifications_enabled' => 0,
'wash_certificate_email' => null,
'two_factor_secret' => null,
'two_factor_enabled' => 0,
'deleted_at' => $now,
]);
$this->deleteIfColumnsExist('user_key_value_pairs', ['user_id' => $principalId]);
$this->deleteIfColumnsExist('customer_attributes', ['user_id' => $principalId]);
} else {
$this->updateExistingColumns('subusers', $principalId, [
'username' => null,
'password' => null,
'name' => 'Slettet chauffør',
'email' => null,
'email_verified_at' => null,
'phone_country_code' => null,
'phone' => null,
'phone_verified_at' => null,
'two_factor_secret' => null,
'two_factor_enabled' => 0,
'suspended_at' => $now,
'deleted_at' => $now,
]);
}
$this->softDeleteAttachments($principalType, $principalId, $now);
$this->deleteSessionActivity($principalType, $principalId);
$this->execute(
"UPDATE account_deletion_requests
SET status = 'completed', active_principal_key = NULL,
completed_at = " . self::sql($now) . ", next_attempt_at = NULL, failure_code = NULL
WHERE id = $id AND status = 'processing'", 'Unable to complete account deletion request.'
);
if ((int)$db->conn()->affected_rows !== 1) throw new \RuntimeException('Account deletion completion lease was lost.');
$this->enqueueOutbox((string)$row['request_id'], 'completed', ['principal_type' => $principalType, 'principal_id' => $principalId]);
$connection->commit();
} catch (Throwable $throwable) {
$connection->rollback();
throw $throwable;
}
$principal = [
'type' => $principalType,
'id' => $principalId,
'customer_number' => $row['customer_number_snapshot'] !== null ? (int)$row['customer_number_snapshot'] : null,
'token' => '',
];
$this->revokeCaches($principal);
$this->sendCompletedNotification($email, $name, (string)$row['request_id']);
}
private function recordFailure(int $id, int $previousRetries, Throwable $throwable): void
{
global $db;
$retryCount = $previousRetries + 1;
$delay = min(86400, 300 * (2 ** max(0, $retryCount - 1)));
$nextAttempt = $retryCount >= self::MAX_RETRIES ? null : date('Y-m-d H:i:s', time() + $delay);
$failureCode = substr((new \ReflectionClass($throwable))->getShortName(), 0, 191);
$nextAttemptSql = $nextAttempt !== null ? self::sql($nextAttempt) : 'NULL';
$terminal = $retryCount >= self::MAX_RETRIES;
$status = $terminal ? 'manual_review' : 'failed';
$connection = $db->conn();
$connection->begin_transaction();
try {
$this->execute(
"UPDATE account_deletion_requests SET status = " . self::sql($status) . ", retry_count = $retryCount,
failure_code = " . self::sql($failureCode) . ", next_attempt_at = $nextAttemptSql,
manual_review_required_at = " . ($terminal ? 'NOW()' : 'NULL') . " WHERE id = $id",
'Unable to persist account deletion failure.'
);
if ($terminal) {
$result = $db->query("SELECT request_id FROM account_deletion_requests WHERE id = $id LIMIT 1");
if ($result === false || $result->num_rows !== 1) throw new \RuntimeException('Unable to identify terminal deletion failure.');
$failureRow = $result->fetch_assoc();
$this->enqueueOutbox((string)$failureRow['request_id'], 'manual_review_required', ['failure_code' => $failureCode]);
}
$connection->commit();
} catch (Throwable $failure) {
$connection->rollback();
throw $failure;
}
$this->auditRaw('ACCOUNT_DELETION_FAILED', 0, 'request_row:' . $id . ';failure:' . $failureCode);
if ($terminal) {
$message = 'request_row:' . $id . ';failure:' . $failureCode . ';action:manual_support_review';
$this->auditRaw('ACCOUNT_DELETION_REQUIRES_SUPPORT', 0, $message);
error_log('[account-deletion] Terminal failure requires support review: ' . $message);
}
}
private function activeRequest(string $type, int $id, bool $forUpdate = false): ?array
{
global $db;
$key = self::sql($type . ':' . $id);
$result = $db->query(
"SELECT * FROM account_deletion_requests
WHERE active_principal_key = $key
LIMIT 1" . ($forUpdate ? ' FOR UPDATE' : '')
);
if ($result === false) {
throw new \RuntimeException('Unable to inspect active account deletion requests.');
}
if ($result->num_rows === 0) {
return null;
}
return $result->fetch_assoc() ?: null;
}
private function requestByPublicId(string $requestId): ?array
{
global $db;
$result = $db->query(
'SELECT * FROM account_deletion_requests WHERE request_id = ' . self::sql($requestId) . ' LIMIT 1'
);
if ($result === false) {
throw new \RuntimeException('Unable to inspect account deletion request state.');
}
if ($result->num_rows === 0) {
return null;
}
return $result->fetch_assoc() ?: null;
}
private function statePayload(array $principal, ?array $row): array
{
$status = $row !== null ? (string)$row['status'] : 'available';
return [
'principal_type' => $principal['type'],
'status' => $status,
'confirmation_phrase' => self::CONFIRMATION_PHRASE,
'password_required' => $this->principalHasPassword($principal),
'two_factor_required' => (bool)$principal['object']->isTwoFactorEnabled(),
'access_effect' => $principal['type'] === 'customer'
? 'Din loginidentitet lukkes straks. Andre loginidentiteter og virksomhedens historiske data bevares.'
: 'Din chaufførkonto lukkes straks, og alle dine virksomhedsadgange deaktiveres.',
'retained_data_categories' => $this->retainedDataCategories((string)$principal['type']),
'privacy_policy_version' => self::POLICY_VERSION,
'request_id' => $row['request_id'] ?? null,
'requested_at' => $row['requested_at'] ?? null,
];
}
private function principalHasPassword(array $principal): bool
{
$object = $principal['object'];
if ($object instanceof users_o) {
return $object->hasPassword();
}
$password = $object->password->value();
return is_string($password) && trim($password) !== '';
}
private function acceptedPayload(array $row): array
{
$retained = json_decode((string)($row['retained_data_json'] ?? '[]'), true);
return [
'request_id' => (string)$row['request_id'],
'status' => (string)$row['status'],
'requested_at' => (string)$row['requested_at'],
'access_revoked' => true,
'retained_data_categories' => is_array($retained) ? array_values($retained) : [],
];
}
/** @return array<int, string> */
private function retainedDataCategories(string $principalType): array
{
$categories = [
'invoices_payments_accounting',
'orders_wash_history',
'security_audit_logs',
'legal_obligations',
];
if ($principalType === 'customer') {
$categories[] = 'customer_reference';
} else {
$categories[] = 'driver_reference';
}
return $categories;
}
private function updateExistingColumns(string $table, int $id, array $updates): void
{
global $db;
$columns = self::columns($table);
$set = [];
foreach ($updates as $column => $value) {
if (!in_array($column, $columns, true)) {
continue;
}
$set[] = '`' . $column . '` = ' . self::valueSql($value);
}
if ($set !== []) {
$this->execute("UPDATE `$table` SET " . implode(', ', $set) . " WHERE id = $id", "Unable to update deletion principal in $table.");
}
}
private function deleteIfColumnsExist(string $table, array $conditions): void
{
if (!self::tableExists($table)) {
return;
}
$columns = self::columns($table);
foreach (array_keys($conditions) as $column) {
if (!in_array($column, $columns, true)) {
return;
}
}
global $db;
$where = [];
foreach ($conditions as $column => $value) {
$where[] = '`' . $column . '` = ' . self::valueSql($value);
}
$this->execute("DELETE FROM `$table` WHERE " . implode(' AND ', $where), "Unable to delete identity-owned data from $table.");
}
private function softDeleteAttachments(string $principalType, int $principalId, string $now): void
{
if (!self::tableExists('object_attachments')) {
return;
}
$columns = self::columns('object_attachments');
if (!array_diff(['object_type', 'object_id', 'content', 'deleted_at'], $columns)) {
global $db;
$objectType = $principalType === 'customer' ? 'users' : 'subusers';
$this->execute(
"UPDATE object_attachments SET deleted_at = COALESCE(deleted_at, " . self::sql($now) . '), content = NULL'
. ' WHERE object_type = ' . self::sql($objectType) . " AND object_id = $principalId", 'Unable to delete identity attachments.'
);
}
}
private function deleteSessionActivity(string $principalType, int $principalId): void
{
if (!self::tableExists('system_session_activity')) {
return;
}
$columns = self::columns('system_session_activity');
if (!array_diff(['session_kind', 'principal_id'], $columns)) {
global $db;
$sessionKind = $principalType === 'customer' ? 'user' : 'subuser';
$this->execute(
'DELETE FROM system_session_activity WHERE session_kind = ' . self::sql($sessionKind)
. " AND principal_id = $principalId", 'Unable to delete identity session activity.'
);
}
}
private function principalEmail(string $type, int $id): ?string
{
return $this->principalField($type, $id, 'email');
}
private function principalName(string $type, int $id): string
{
$field = $type === 'customer' ? 'display_name' : 'name';
return $this->principalField($type, $id, $field) ?? ($type === 'customer' ? 'Kunde' : 'Chauffør');
}
private function principalField(string $type, int $id, string $field): ?string
{
$table = $type === 'customer' ? 'users' : 'subusers';
if (!in_array($field, self::columns($table), true)) {
return null;
}
global $db;
$result = $db->query("SELECT `$field` FROM `$table` WHERE id = $id LIMIT 1");
if ($result === false || $result->num_rows === 0) {
return null;
}
$row = $result->fetch_assoc();
return $this->nullableString($row[$field] ?? null);
}
private function sendRequestedNotification(array $principal, string $requestId): void
{
$email = $principal['email'] ?? null;
if (!is_string($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return;
}
try {
(new email())->sendEmail(
$email,
(string)$principal['name'],
'Vi har modtaget din anmodning om kontosletning',
'<p>Din adgang er nu lukket, og anmodningen behandles.</p><p>Reference: '
. htmlspecialchars($requestId, ENT_QUOTES, 'UTF-8') . '</p>'
);
} catch (Throwable $throwable) {
$this->auditRaw('ACCOUNT_DELETION_NOTIFICATION_FAILED', (int)$principal['id'], 'requested');
}
}
private function sendCompletedNotification(?string $email, string $name, string $requestId): void
{
if ($email === null || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return;
}
try {
(new email())->sendEmail(
$email,
$name,
'Din kontosletning er gennemført',
'<p>Dine personlige kontooplysninger er anonymiseret. Lovpligtige historiske data opbevares som beskrevet i privatlivspolitikken.</p><p>Reference: '
. htmlspecialchars($requestId, ENT_QUOTES, 'UTF-8') . '</p>'
);
} catch (Throwable) {
$this->auditRaw('ACCOUNT_DELETION_NOTIFICATION_FAILED', 0, 'completed');
}
}
private function auditRaw(string $action, int $actorId, string $message): void
{
try {
(new logs_o())->add('account_deletion', 'global', 1, $actorId, $action, $message);
} catch (Throwable) {
}
}
private function enqueueOutbox(string $requestId, string $eventType, array $payload): void
{
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);
if (!is_string($json)) throw new \RuntimeException('Unable to encode deletion outbox event.');
$this->execute(
'INSERT INTO account_deletion_outbox (request_id, event_type, payload_json, available_at) VALUES ('
. self::sql($requestId) . ', ' . self::sql($eventType) . ', ' . self::sql($json) . ', NOW()) '
. 'ON DUPLICATE KEY UPDATE payload_json = VALUES(payload_json), available_at = LEAST(available_at, VALUES(available_at))',
'Unable to persist account deletion outbox event.'
);
}
private function bearerToken(): string
{
$headers = function_exists('getallheaders') ? getallheaders() : [];
$value = (string)($headers['Authorization'] ?? $_SERVER['HTTP_AUTHORIZATION'] ?? '');
return trim(str_replace('Bearer ', '', $value));
}
private function rejectImpersonationToken(string $token): void
{
if ($token === '') {
return;
}
global $db;
$result = $db->query(
'SELECT type, created_at FROM tokens WHERE token = ' . self::sql($token) . ' LIMIT 1'
);
if ($result === false) {
throw new \RuntimeException('Unable to inspect account deletion session provenance.');
}
$row = $result->fetch_assoc();
if (str_starts_with((string)($row['type'] ?? ''), 'AUTH_TOKEN_IMPERSONATION:')) {
throw new account_deletion_http_exception(
'Account deletion is unavailable during support impersonation',
403
);
}
if (($row['type'] ?? '') === 'AUTH_TOKEN' && strtotime((string)($row['created_at'] ?? '')) < strtotime(self::LEGACY_SESSION_CUTOFF)) {
throw new account_deletion_http_exception('A fresh login is required before account deletion', 403);
}
}
private function nullableString(mixed $value): ?string
{
if (!is_scalar($value)) {
return null;
}
$value = trim((string)$value);
return $value !== '' ? $value : null;
}
private static function tableExists(string $table): bool
{
if (array_key_exists($table, self::$tableExistsCache)) {
return self::$tableExistsCache[$table];
}
global $db;
$tableSql = $db->escape_string($table);
$result = $db->query("SHOW TABLES LIKE '$tableSql'");
if ($result === false) {
throw new \RuntimeException('Unable to inspect database table availability.');
}
return self::$tableExistsCache[$table] = $result->num_rows > 0;
}
/** @return array<int, string> */
private static function columns(string $table): array
{
if (isset(self::$columnsCache[$table])) {
return self::$columnsCache[$table];
}
if (!self::tableExists($table)) {
return self::$columnsCache[$table] = [];
}
global $db;
$result = $db->query('SHOW COLUMNS FROM `' . preg_replace('/[^a-zA-Z0-9_]/', '', $table) . '`');
if ($result === false) {
throw new \RuntimeException('Unable to inspect database column availability.');
}
$rows = $db->fetch_all($result);
return self::$columnsCache[$table] = array_values(array_map('strval', array_column($rows, 'Field')));
}
private static function valueSql(mixed $value): string
{
if ($value === null) {
return 'NULL';
}
if (is_bool($value)) {
return $value ? '1' : '0';
}
if (is_int($value) || is_float($value)) {
return (string)$value;
}
return self::sql((string)$value);
}
private function execute(string $sql, string $message): void
{
global $db;
if ($db->query($sql) === false) throw new \RuntimeException($message);
}
private static function sql(string $value): string
{
global $db;
return "'" . $db->escape_string($value) . "'";
}
private static function uuidV4(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
$hex = bin2hex($bytes);
return substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-' . substr($hex, 12, 4)
. '-' . substr($hex, 16, 4) . '-' . substr($hex, 20);
}
}