diff --git a/openapi.yaml b/openapi.yaml index d0b519ac..c3ecc302 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2579,6 +2579,156 @@ paths: '401': $ref: '#/components/responses/Unauthorized' + /account/deletion: + get: + tags: + - Security + summary: Describe account deletion requirements + description: Returns the authenticated customer or chauffeur deletion state, required confirmation phrase, and categories retained for legal obligations. + operationId: getAccountDeletion + responses: + '200': + description: Account deletion requirements retrieved successfully + content: + application/json: + schema: + type: object + required: + - principal_type + - status + - confirmation_phrase + - password_required + - two_factor_required + - access_effect + - retained_data_categories + - privacy_policy_version + properties: + principal_type: + type: string + enum: [customer, subuser] + status: + type: string + enum: [available, requested, processing, failed, manual_review, completed] + confirmation_phrase: + type: string + enum: [SLET MIN KONTO] + password_required: + type: boolean + description: False for authenticated passkey-only accounts that have no password. + two_factor_required: + type: boolean + access_effect: + type: string + retained_data_categories: + type: array + items: + type: string + enum: [invoices_payments_accounting, orders_wash_history, security_audit_logs, legal_obligations, customer_reference, driver_reference] + privacy_policy_version: + type: string + request_id: + type: string + format: uuid + nullable: true + requested_at: + type: string + format: date-time + nullable: true + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' + post: + tags: + - Security + summary: Request deletion of the authenticated account + description: Reauthenticates the principal, records an auditable deletion request, and revokes access immediately. A background worker subsequently anonymizes personal account fields while preserving legally required history. + operationId: requestAccountDeletion + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - confirmation + - acknowledge_legal_retention + properties: + password: + type: string + format: password + description: Required when password_required is true; omit for passkey-only accounts. + passkey_challenge_token: + type: string + description: Required for passwordless accounts; issued only by the deletion-specific challenge endpoint. + passkey_credential: + type: object + description: Fresh WebAuthn assertion bound to passkey_challenge_token and the authenticated principal. + two_factor_code: + type: string + description: Required when two-factor authentication is enabled. + confirmation: + type: string + enum: [SLET MIN KONTO] + acknowledge_legal_retention: + type: boolean + enum: [true] + responses: + '202': + description: Deletion request accepted and account access revoked + content: + application/json: + schema: + type: object + required: + - request_id + - status + - requested_at + - access_revoked + - retained_data_categories + properties: + request_id: + type: string + format: uuid + status: + type: string + enum: [requested, processing, failed, manual_review, completed] + requested_at: + type: string + format: date-time + access_revoked: + type: boolean + enum: [true] + retained_data_categories: + type: array + items: + type: string + enum: [invoices_payments_accounting, orders_wash_history, security_audit_logs, legal_obligations, customer_reference, driver_reference] + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '429': + description: Too many deletion confirmation attempts + '500': + $ref: '#/components/responses/InternalServerError' + + /account/deletion/passkey/challenge: + post: + tags: [Security] + summary: Create a deletion-specific WebAuthn challenge + description: Creates a short-lived, single-use challenge bound to the authenticated passwordless principal. A normal sign-in assertion cannot authorize deletion. + operationId: createAccountDeletionPasskeyChallenge + responses: + '200': + description: Deletion-specific challenge created + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' + /auth/2fa/setup: post: tags: diff --git a/scripts/account-deletion-schema.php b/scripts/account-deletion-schema.php new file mode 100755 index 00000000..dcd4fbb7 --- /dev/null +++ b/scripts/account-deletion-schema.php @@ -0,0 +1,35 @@ +#!/usr/bin/env php +connect(); +$command = $argv[1] ?? 'check'; + +if ($command === 'apply') { + if (($argv[2] ?? '') !== '--yes') { + fwrite(STDERR, "Refusing schema mutation without: apply --yes\n"); + exit(2); + } + \classes\account_deletion_schema_bootstrap::apply(); +} + +if (!in_array($command, ['check', 'apply'], true)) { + fwrite(STDERR, "Usage: scripts/account-deletion-schema.php check|apply --yes\n"); + exit(2); +} + +$status = \classes\account_deletion_schema_bootstrap::check(); +fwrite(STDOUT, json_encode($status, JSON_UNESCAPED_SLASHES) . PHP_EOL); +exit($status['ready'] ? 0 : 1); diff --git a/services/nginx/app/classes/account_deletion_http_exception.php b/services/nginx/app/classes/account_deletion_http_exception.php new file mode 100644 index 00000000..58eac3f2 --- /dev/null +++ b/services/nginx/app/classes/account_deletion_http_exception.php @@ -0,0 +1,13 @@ +} */ + 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)"); + } +} diff --git a/services/nginx/app/classes/account_deletion_service.php b/services/nginx/app/classes/account_deletion_service.php new file mode 100644 index 00000000..e313df3b --- /dev/null +++ b/services/nginx/app/classes/account_deletion_service.php @@ -0,0 +1,1022 @@ + */ + private static array $tableExistsCache = []; + /** @var array> */ + 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 in_array(strtolower(trim((string)($row['value'] ?? ''))), ['1', 'true', 'yes', 'on'], true); + } 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') { + $this->updateExistingColumns('users', $id, ['deleted_at' => $now]); + $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 */ + 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', + '

Din adgang er nu lukket, og anmodningen behandles.

Reference: ' + . htmlspecialchars($requestId, ENT_QUOTES, 'UTF-8') . '

' + ); + } 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', + '

Dine personlige kontooplysninger er anonymiseret. Lovpligtige historiske data opbevares som beskrevet i privatlivspolitikken.

Reference: ' + . htmlspecialchars($requestId, ENT_QUOTES, 'UTF-8') . '

' + ); + } 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 */ + 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); + } +} diff --git a/services/nginx/app/classes/authentication.php b/services/nginx/app/classes/authentication.php index 34c6b7db..f604f41b 100644 --- a/services/nginx/app/classes/authentication.php +++ b/services/nginx/app/classes/authentication.php @@ -2,6 +2,8 @@ namespace classes; +require_once WD . '/classes/account_deletion_service.php'; + use classes\totp; use Exception; use interfaces\authentication_i; @@ -69,6 +71,10 @@ class authentication implements authentication_i public function create_2fa_token(int $id, string $type): string { + $principalType = $type === '2FA_VERIFICATION_SUBUSER' ? 'subuser' : 'customer'; + if (account_deletion_service::principalIsBlocked($principalType, $id)) { + throw new Exception('Account unavailable'); + } // Create a temporary 2FA token $token = bin2hex(random_bytes(32)); (new tokens_o())->create($id, $token, $type); @@ -100,6 +106,9 @@ class authentication implements authentication_i throw new \Exception('User not found for customer number: ' . $customer_number); } $user_id = $user->id; + if (account_deletion_service::principalIsBlocked('customer', (int)$user_id)) { + throw new Exception('Account unavailable'); + } // Save the token in the database (new tokens_o())->create($user_id, $token, 'AUTH_TOKEN'); return $token; @@ -107,6 +116,9 @@ class authentication implements authentication_i public function create_token_by_user_id(int $user_id): string { + if (account_deletion_service::principalIsBlocked('customer', $user_id)) { + throw new Exception('Account unavailable'); + } // Create a token $token = bin2hex(random_bytes(32)); // Save the token in the database @@ -116,6 +128,9 @@ class authentication implements authentication_i public function create_employee_token(int $employee_id): string { + if (account_deletion_service::principalIsBlocked('customer', $employee_id)) { + throw new Exception('Account unavailable'); + } // Create a token $token = bin2hex(random_bytes(32)); // Save the token in the database @@ -123,13 +138,26 @@ class authentication implements authentication_i return $token; } + public function create_impersonation_token(int $target_user_id, int $actor_user_id): string + { + if ($actor_user_id <= 0 || account_deletion_service::principalIsBlocked('customer', $target_user_id)) { + throw new Exception('Account unavailable'); + } + $token = bin2hex(random_bytes(32)); + (new tokens_o())->create($target_user_id, $token, 'AUTH_TOKEN_IMPERSONATION:' . $actor_user_id); + return $token; + } + public function validate_token(string $token): bool { // First: try validating as a classic user auth token try { $dbToken = (new tokens_o())->getToken($token); - if ($dbToken && $dbToken->id && $dbToken->type->value() === 'AUTH_TOKEN') { - return true; + if ($dbToken && $dbToken->id && $this->isClassicAuthTokenType((string)$dbToken->type->value())) { + return !account_deletion_service::principalIsBlocked( + 'customer', + (int)$dbToken->user_id->value() + ); } } catch (Exception) { // Ignore and continue to subuser session validation @@ -137,7 +165,7 @@ class authentication implements authentication_i // Fallback: try validating as a subuser session token $subuser = (new subusers_o())->getSubuserBySessionToken($token); if ($subuser !== null) { - return true; + return !account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id); } return false; } @@ -168,7 +196,10 @@ class authentication implements authentication_i if (!$token->id) { return false; } - if ($token->type->value() !== 'AUTH_TOKEN') { + if (!$this->isClassicAuthTokenType((string)$token->type->value())) { + return false; + } + if (account_deletion_service::principalIsBlocked('customer', (int)$token->user_id->value())) { return false; } // Get the user from the database @@ -177,6 +208,11 @@ class authentication implements authentication_i return $user; } + private function isClassicAuthTokenType(string $type): bool + { + return $type === 'AUTH_TOKEN' || str_starts_with($type, 'AUTH_TOKEN_IMPERSONATION:'); + } + public function get_plate_scanner(): plate_scanners_o|false { // Get the token from the headers @@ -227,6 +263,9 @@ class authentication implements authentication_i if ($subuser === null) { return false; } + if (account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) { + return false; + } $customerNumberContext = null; if (isset($headers['X-Customer-Number'])) { $customerNumberContext = (int)$headers['X-Customer-Number']; diff --git a/services/nginx/app/cron/Cron.php b/services/nginx/app/cron/Cron.php index 65dd3f33..22a18591 100644 --- a/services/nginx/app/cron/Cron.php +++ b/services/nginx/app/cron/Cron.php @@ -2,6 +2,7 @@ // prevent direct access use classes\backup_store; +use classes\account_deletion_service; use classes\economic; use classes\economic_transfer_queue; use classes\invoice_period_flag_service; @@ -63,6 +64,12 @@ $response_cron = []; // Define the cron tasks $cron_tasks = [ + 'ProcessAccountDeletionRequestsCron' => [ + 'interval' => 300, + 'last_run' => 0, + 'next_run' => 0, + 'function' => 'ProcessAccountDeletionRequestsCron', + ], // 'CheckUnfulfilledBookings' => [ // 'interval' => 86400, // 24 hours // 'last_run' => 0, @@ -192,6 +199,17 @@ $cron_tasks = [ ], ]; +function ProcessAccountDeletionRequestsCron(): array +{ + if (!account_deletion_service::workerEnabled()) { + return ['processed' => 0, 'completed' => 0, 'failed' => 0, 'skipped' => true]; + } + $result = (new account_deletion_service())->processPending(25); + echo '[' . date('Y-m-d H:i:s') . '][CRON] Account deletion requests: ' + . (int)$result['completed'] . ' completed, ' . (int)$result['failed'] . " failed.\n"; + return $result; +} + function ReplicaFailoverMonitorCron(): void { global $db; diff --git a/services/nginx/app/modules/account/cron/tasks.php b/services/nginx/app/modules/account/cron/tasks.php new file mode 100644 index 00000000..4993d036 --- /dev/null +++ b/services/nginx/app/modules/account/cron/tasks.php @@ -0,0 +1,16 @@ + 'account.process_deletion_requests', + 'legacy_name' => 'ProcessAccountDeletionRequestsCron', + 'name' => 'Process account deletion requests', + 'description' => 'Anonymizes requested customer and chauffeur accounts while retaining legally required records.', + 'module' => 'account', + 'handler' => 'ProcessAccountDeletionRequestsCron', + 'schedule' => ['type' => 'interval', 'seconds' => 300], + 'timeout_seconds' => 300, + 'estimated_duration_ms' => 2000, + 'priority' => 25, + ], +]; diff --git a/services/nginx/app/objects/customer_password_reset_keys_o.php b/services/nginx/app/objects/customer_password_reset_keys_o.php index c937b0a2..88e2f559 100644 --- a/services/nginx/app/objects/customer_password_reset_keys_o.php +++ b/services/nginx/app/objects/customer_password_reset_keys_o.php @@ -2,6 +2,7 @@ namespace objects; +use classes\account_deletion_service; use classes\db; use classes\object_property; use Exception; @@ -56,6 +57,13 @@ class customer_password_reset_keys_o extends db return bin2hex(random_bytes($length / 2)); } + protected function selectedCustomerCanResetPassword(): bool + { + $customer = (new users_o())->getUserByCustomerNumber((int)$this->customer_id->value()); + return $customer->exists() + && !account_deletion_service::principalIsBlocked('customer', (int)$customer->id); + } + /** * Find a valid reset key by token * @param string $token The token to search for @@ -79,6 +87,10 @@ class customer_password_reset_keys_o extends db $row = $result->fetch_assoc(); $this->id = (int)$row['id']; $this->getObjectProperties(); + if (!$this->selectedCustomerCanResetPassword()) { + $this->delete(); + return null; + } return $this; } @@ -121,6 +133,10 @@ class customer_password_reset_keys_o extends db $customer = new users_o(); $customer->getUserByCustomerNumber((int)$this->customer_id->value()); $customer->requireSelected(); + if (account_deletion_service::principalIsBlocked('customer', (int)$customer->id)) { + $this->delete(); + throw new Exception('Invalid or expired token'); + } $customer->setPassword($new_password); $this->delete(); } diff --git a/services/nginx/app/objects/passkeys_o.php b/services/nginx/app/objects/passkeys_o.php index 87f0324e..249e9c77 100644 --- a/services/nginx/app/objects/passkeys_o.php +++ b/services/nginx/app/objects/passkeys_o.php @@ -37,7 +37,7 @@ class passkeys_o extends db { global $db; $credentialId = $db->escape_string($credentialId); - $where = "credential_id = '" . $credentialId . "'"; + $where = "credential_id = '" . $credentialId . "' AND deleted_at IS NULL"; if ($userId !== null) { $where .= ' AND user_id = ' . (int)$userId; } @@ -106,4 +106,4 @@ class passkeys_o extends db { //TODO: Add cache invalidation } -} \ No newline at end of file +} diff --git a/services/nginx/app/objects/subuser_grants_o.php b/services/nginx/app/objects/subuser_grants_o.php index cff71706..748162cc 100644 --- a/services/nginx/app/objects/subuser_grants_o.php +++ b/services/nginx/app/objects/subuser_grants_o.php @@ -2,6 +2,7 @@ namespace objects; +use classes\account_deletion_service; use classes\db; use classes\object_property; use Exception; @@ -135,6 +136,9 @@ class subuser_grants_o extends db public function add(int $billing_customer_number, int $subuser, bool $enabled, ?string $note, ?array $permissions = self::defaultPermissions): subuser_grants_o { global $db; + if (account_deletion_service::principalIsBlocked('subuser', $subuser)) { + throw new Exception('Subuser account is unavailable'); + } $permissions = self::normalizePermissionsValue($permissions); $tmp = $this->add_object([ 'billing_customer_number' => (int)$billing_customer_number, diff --git a/services/nginx/app/objects/subusers_o.php b/services/nginx/app/objects/subusers_o.php index 61e4ad95..96b97779 100644 --- a/services/nginx/app/objects/subusers_o.php +++ b/services/nginx/app/objects/subusers_o.php @@ -3,6 +3,7 @@ namespace objects; use classes\authentication; +use classes\account_deletion_service; use classes\db; use classes\object_property; use Exception; @@ -187,6 +188,9 @@ class subusers_o extends db public function setPassword(string $password): self { self::requireSelected(); + if (account_deletion_service::principalIsBlocked('subuser', (int)$this->id)) { + throw new Exception('Account unavailable'); + } self::assertValidPassword($password); $this->password->set((string)password_hash($password, PASSWORD_DEFAULT)); return $this; @@ -215,6 +219,9 @@ class subusers_o extends db public function generateSetupToken(): string { self::requireSelected(); + if (account_deletion_service::principalIsBlocked('subuser', (int)$this->id)) { + throw new Exception('Account unavailable'); + } try { $token = bin2hex(random_bytes(16)); } catch (Exception $e) { @@ -265,6 +272,10 @@ class subusers_o extends db } $subuser = (new subusers_o())->select((int)$subuser_id); $subuser->getObjectProperties(); + if (account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) { + $this->invalidateSetupToken($token); + return null; + } return $subuser; } @@ -351,6 +362,9 @@ class subusers_o extends db public function generateSession(): string { self::requireSelected(); + if (account_deletion_service::principalIsBlocked('subuser', (int)$this->id)) { + throw new Exception('Account unavailable'); + } $session_token = bin2hex(random_bytes(32)); $this->cache('session_token:' . $session_token, $this->id, 'subuser_sessions'); $this->setCachedExpiration('session_token:' . $session_token, 7 * 24 * 60 * 60, 'subuser_sessions'); // Set the session to expire after 7 days @@ -375,6 +389,10 @@ class subusers_o extends db if ($subuser_id !== null) { $subuser = (new subusers_o())->select((int)$subuser_id); $subuser->getObjectProperties(); + if (account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) { + $this->invalidateSessionToken($token); + return null; + } return $subuser; } return null; diff --git a/services/nginx/app/objects/users_o.php b/services/nginx/app/objects/users_o.php index bbd29456..106faf12 100644 --- a/services/nginx/app/objects/users_o.php +++ b/services/nginx/app/objects/users_o.php @@ -2,6 +2,7 @@ namespace objects; +use classes\account_deletion_service; use classes\db; use classes\customer_rule_product_restriction_schema_bootstrap; use classes\customer_name_cache_payload_builder; @@ -2049,6 +2050,9 @@ class users_o extends db public function generatePasswordResetLink(): string { self::requireSelected(); + if (account_deletion_service::principalIsBlocked('customer', (int)$this->id)) { + throw new Exception('Account unavailable'); + } // Generate token $token = customer_password_reset_keys_o::generateToken(); diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 2b3c0d70..89018725 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -2935,6 +2935,156 @@ paths: '401': $ref: '#/components/responses/Unauthorized' + /account/deletion: + get: + tags: + - Security + summary: Describe account deletion requirements + description: Returns the authenticated customer or chauffeur deletion state, required confirmation phrase, and categories retained for legal obligations. + operationId: getAccountDeletion + responses: + '200': + description: Account deletion requirements retrieved successfully + content: + application/json: + schema: + type: object + required: + - principal_type + - status + - confirmation_phrase + - password_required + - two_factor_required + - access_effect + - retained_data_categories + - privacy_policy_version + properties: + principal_type: + type: string + enum: [customer, subuser] + status: + type: string + enum: [available, requested, processing, failed, manual_review, completed] + confirmation_phrase: + type: string + enum: [SLET MIN KONTO] + password_required: + type: boolean + description: False for authenticated passkey-only accounts that have no password. + two_factor_required: + type: boolean + access_effect: + type: string + retained_data_categories: + type: array + items: + type: string + enum: [invoices_payments_accounting, orders_wash_history, security_audit_logs, legal_obligations, customer_reference, driver_reference] + privacy_policy_version: + type: string + request_id: + type: string + format: uuid + nullable: true + requested_at: + type: string + format: date-time + nullable: true + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' + post: + tags: + - Security + summary: Request deletion of the authenticated account + description: Reauthenticates the principal, records an auditable deletion request, and revokes access immediately. A background worker subsequently anonymizes personal account fields while preserving legally required history. + operationId: requestAccountDeletion + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - confirmation + - acknowledge_legal_retention + properties: + password: + type: string + format: password + description: Required when password_required is true; omit for passkey-only accounts. + passkey_challenge_token: + type: string + description: Required for passwordless accounts; issued only by the deletion-specific challenge endpoint. + passkey_credential: + type: object + description: Fresh WebAuthn assertion bound to passkey_challenge_token and the authenticated principal. + two_factor_code: + type: string + description: Required when two-factor authentication is enabled. + confirmation: + type: string + enum: [SLET MIN KONTO] + acknowledge_legal_retention: + type: boolean + enum: [true] + responses: + '202': + description: Deletion request accepted and account access revoked + content: + application/json: + schema: + type: object + required: + - request_id + - status + - requested_at + - access_revoked + - retained_data_categories + properties: + request_id: + type: string + format: uuid + status: + type: string + enum: [requested, processing, failed, manual_review, completed] + requested_at: + type: string + format: date-time + access_revoked: + type: boolean + enum: [true] + retained_data_categories: + type: array + items: + type: string + enum: [invoices_payments_accounting, orders_wash_history, security_audit_logs, legal_obligations, customer_reference, driver_reference] + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalServerError' + + /account/deletion/passkey/challenge: + post: + tags: [Security] + summary: Create a deletion-specific WebAuthn challenge + description: Creates a short-lived, single-use challenge bound to the authenticated passwordless principal. A normal sign-in assertion cannot authorize deletion. + operationId: createAccountDeletionPasskeyChallenge + responses: + '200': + description: Deletion-specific challenge created + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' + /auth/2fa/setup: post: tags: diff --git a/services/nginx/app/routes/accountDeletionRoute.php b/services/nginx/app/routes/accountDeletionRoute.php new file mode 100644 index 00000000..3dd32011 --- /dev/null +++ b/services/nginx/app/routes/accountDeletionRoute.php @@ -0,0 +1,74 @@ +get('/account/deletion', function () { + global $response; + try { + if (!account_deletion_service::apiEnabled()) { + $response->error('Account deletion is unavailable', 404); + return; + } + $service = new account_deletion_service(); + $principal = $service->currentPrincipal(); + $response->success($service->state($principal)); + } catch (account_deletion_http_exception $exception) { + $response->error($exception->getMessage(), $exception->status); + } catch (Throwable $throwable) { + $response->error('Unable to load account deletion status', 500); + } + }); + + $this->post('/account/deletion', function () { + global $response; + try { + if (!account_deletion_service::apiEnabled()) { + $response->error('Account deletion is unavailable', 404); + return; + } + $service = new account_deletion_service(); + $principal = $service->currentPrincipal(); + $payload = $service->request( + $principal, + $this->getParametersAsArray(), + isset($_SERVER['REMOTE_ADDR']) ? (string)$_SERVER['REMOTE_ADDR'] : null, + isset($_SERVER['HTTP_USER_AGENT']) ? (string)$_SERVER['HTTP_USER_AGENT'] : null, + ); + $response->success($payload, 202); + } catch (account_deletion_http_exception $exception) { + $response->error($exception->getMessage(), $exception->status); + } catch (Throwable $throwable) { + error_log('[account-deletion] Request failed: ' . $throwable->getMessage()); + $response->error('Unable to request account deletion', 500); + } + }); + + $this->post('/account/deletion/passkey/challenge', function () { + global $response; + try { + if (!account_deletion_service::apiEnabled()) { + $response->error('Account deletion is unavailable', 404); + return; + } + $service = new account_deletion_service(); + $response->success($service->passkeyChallenge($service->currentPrincipal())); + } catch (account_deletion_http_exception $exception) { + $response->error($exception->getMessage(), $exception->status); + } catch (Throwable $throwable) { + error_log('[account-deletion] Passkey challenge failed: ' . $throwable->getMessage()); + $response->error('Unable to create deletion passkey challenge', 500); + } + }); + } +} diff --git a/services/nginx/app/routes/authRoute.php b/services/nginx/app/routes/authRoute.php index 7e95f535..baa4be8c 100644 --- a/services/nginx/app/routes/authRoute.php +++ b/services/nginx/app/routes/authRoute.php @@ -3,6 +3,7 @@ namespace routes; use classes\authentication; +use classes\account_deletion_service; use classes\economic; use classes\email; use classes\release_manager; @@ -22,6 +23,7 @@ use objects\passkeys_o; use traits\route_t; require_once WD . '/classes/security_policy_service.php'; +require_once WD . '/classes/account_deletion_service.php'; class authRoute { @@ -67,7 +69,7 @@ class authRoute $passkeys = new passkeys_o(); $passkeys->setAdditionalWhereClause( - '`user_id` = ' . (int)$userId . ' AND `is_subuser` = ' . ($isSubuser ? '1' : '0') + '`user_id` = ' . (int)$userId . ' AND `is_subuser` = ' . ($isSubuser ? '1' : '0') . ' AND `deleted_at` IS NULL' ); $list = $passkeys->listObjectsWithPaginationIfSet(function ($o) { $transports = null; @@ -125,6 +127,10 @@ class authRoute $this->observeLoginFailure('customer', $data['customer_number'], ['reason' => 'missing_user_or_password']); $response->error('Invalid credentials', 401); } + if (account_deletion_service::principalIsBlocked('customer', (int)$user->id)) { + $this->observeLoginFailure('customer', $data['customer_number'], ['reason' => 'account_unavailable']); + $response->error('Invalid credentials', 401); + } $isCredentialsValid = false; try { @@ -312,14 +318,21 @@ class authRoute if ($token_type === '2FA_VERIFICATION_USER') { $user = (new users_o())->getUserById($user_id); - if ($user->exists() && $auth->verify_2fa_code($user, $code)) { + if ( + $user->exists() + && !account_deletion_service::principalIsBlocked('customer', $user_id) + && $auth->verify_2fa_code($user, $code) + ) { $token_o->delete($token_str); $new_token = $auth->create_employee_token($user_id); // Works for both users and employees $response->success(['token' => $new_token]); } } elseif ($token_type === '2FA_VERIFICATION_SUBUSER') { $subuser = (new subusers_o())->select($user_id); - if ($auth->verify_2fa_code($subuser, $code)) { + if ( + !account_deletion_service::principalIsBlocked('subuser', $user_id) + && $auth->verify_2fa_code($subuser, $code) + ) { $token_o->delete($token_str); $new_token = $subuser->generateSession(); $response->success(['session' => $new_token]); @@ -626,6 +639,9 @@ class authRoute // For security reasons, don't reveal if the user exists $response->success(['message' => 'If the customer exists, a password reset email has been sent.']); } + if (account_deletion_service::principalIsBlocked('customer', (int)$user->id)) { + $response->success(['message' => 'If the customer exists, a password reset email has been sent.']); + } $email_address = $user->email->value(); if (empty($email_address)) { @@ -746,8 +762,10 @@ class authRoute } if ($subuser !== null) { - $user_id = (int)$subuser->id; - $allowCredentials = $this->passkeyAllowCredentials($user_id, true); + if (!account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) { + $user_id = (int)$subuser->id; + $allowCredentials = $this->passkeyAllowCredentials($user_id, true); + } } } elseif ($customer_number !== null) { $principal_type = 'user'; @@ -759,8 +777,10 @@ class authRoute $user = (new users_o())->getUserByCustomerNumber($customer_number); if ($user->exists()) { - $user_id = (int)$user->id; - $allowCredentials = $this->passkeyAllowCredentials($user_id, false); + if (!account_deletion_service::principalIsBlocked('customer', (int)$user->id)) { + $user_id = (int)$user->id; + $allowCredentials = $this->passkeyAllowCredentials($user_id, false); + } } } @@ -870,6 +890,11 @@ class authRoute // Success → issue session token accordingly and delete the challenge token $issued_to_user_id = (int)$passkey->user_id->value(); $is_subuser = (bool)$passkey->is_subuser->value(); + if (account_deletion_service::principalIsBlocked($is_subuser ? 'subuser' : 'customer', $issued_to_user_id)) { + $token_o->delete($challenge_token); + $this->clearPasskeyChallengePrincipal($challenge_token); + $response->error('Invalid credential', 401); + } if ( ($challengePrincipalType === 'subuser' && !$is_subuser) || ($challengePrincipalType === 'user' && $is_subuser) diff --git a/services/nginx/app/routes/intimidateRoute.php b/services/nginx/app/routes/intimidateRoute.php index 4ec39c84..e858952e 100644 --- a/services/nginx/app/routes/intimidateRoute.php +++ b/services/nginx/app/routes/intimidateRoute.php @@ -31,7 +31,10 @@ class intimidateRoute // Log the incident (new logs_o())->add('auth', 'global', 1, $user->id, 'AUTH_SUCCESS_INTIMIDATE', 'Created intimidate token for customer: ' . $data['user_id']); // If the credentials are valid, create a token (We're using the create_employee_token, since it's using user_id, and not customer_numbers.) - $token = (new authentication())->create_employee_token($data['user_id']); + $token = (new authentication())->create_impersonation_token( + (int)$data['user_id'], + (int)$user->id + ); // Return the token $response->success(['token' => $token]); }, diff --git a/services/nginx/app/routes/subusersRoute.php b/services/nginx/app/routes/subusersRoute.php index 82350c75..f150e006 100644 --- a/services/nginx/app/routes/subusersRoute.php +++ b/services/nginx/app/routes/subusersRoute.php @@ -3,6 +3,7 @@ namespace routes; use classes\authentication; +use classes\account_deletion_service; use classes\economic; use classes\email; use classes\gatewayapi; @@ -386,6 +387,10 @@ class subusersRoute { global $response; + if (account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) { + $response->error('Chauffeur account is unavailable', 409); + } + $customerNumber = $this->resolveDirectLoginCustomerNumber($subuser); try { $sessionToken = $subuser->generateSession(); @@ -770,8 +775,17 @@ class subusersRoute $response->error('Invalid credentials', 401); } + private function rejectBlockedSubuser(int $subuserId): void + { + global $response; + if (account_deletion_service::principalIsBlocked('subuser', $subuserId)) { + $response->error('Subuser account is unavailable', 409); + } + } + private function issueSetupInvite(subusers_o $subuser): array { + $this->rejectBlockedSubuser((int)$subuser->id); if (!$subuser->requiresSetup()) { return [ 'setup_token' => null, @@ -1455,6 +1469,7 @@ class subusersRoute global $response; $grant = $this->getGrantForScopedUserOrFail($grantId, $customerNumber); + $this->rejectBlockedSubuser((int)$grant->subuser->value()); $updates = []; $templateAccess = $this->parseAccessTemplatePayload(); if ($templateAccess !== null) { @@ -1735,6 +1750,7 @@ class subusersRoute $subuser_id = (int)self::getParameter('subuser_id'); self::requireType($customer_number, self::type_int()); self::requireType($subuser_id, self::type_int()); + $this->rejectBlockedSubuser($subuser_id); if (!self::hasPermission($permission_other, (int)$customer_number)) { $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_ADD, (int)$customer_number); @@ -1793,6 +1809,7 @@ class subusersRoute if (!$grant->exists()) { $response->error('Grant not found', 404); } + $this->rejectBlockedSubuser((int)$grant->subuser->value()); $targetCustomer = (int)$grant->billing_customer_number->value(); if (!self::hasPermission($permission_other, $targetCustomer)) { @@ -2103,6 +2120,9 @@ class subusersRoute if ($subuser === null) { $this->subuserAuthFailure(); } + if (account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) { + $this->subuserAuthFailure(); + } self::requireParameters(['password']); $password = (string)self::getParameter('password'); try { diff --git a/services/nginx/app/tests/Api/AccountDeletionApiTest.php b/services/nginx/app/tests/Api/AccountDeletionApiTest.php new file mode 100644 index 00000000..f76ebc7b --- /dev/null +++ b/services/nginx/app/tests/Api/AccountDeletionApiTest.php @@ -0,0 +1,519 @@ +setModuleConfig('account_deletion', 'api_enabled', 'true', 'bool'); + api_fixtures()->setModuleConfig('account_deletion', 'worker_enabled', 'true', 'bool'); +} + +/** @return array{processed:int,completed:int,failed:int} */ +function runAccountDeletionWorkerForApiTest(): array +{ + $target = strtolower(trim((string)(getenv('CONFIG_DB_TARGET') ?: 'debug'))); + $debug = $target === 'debug'; + $value = static function (string $key) use ($debug): string { + $debugValue = trim((string)getenv('CONFIG_DB_DEBUG_' . $key)); + if ($debug && $debugValue !== '') { + return $debugValue; + } + return trim((string)getenv('CONFIG_DB_' . $key)); + }; + $workerDb = new \classes\db([ + 'host' => $value('HOST'), + 'user' => $value('USER'), + 'password' => $value('PASSWORD'), + 'database' => $value('DATABASE'), + 'port' => (int)($value('PORT') ?: '3306'), + ]); + $workerDb->connect(); + $previousDb = $GLOBALS['db'] ?? null; + $previousTimezone = date_default_timezone_get(); + date_default_timezone_set(trim((string)(getenv('CONFIG_TIMEZONE') ?: 'Europe/Copenhagen'))); + $GLOBALS['db'] = $workerDb; + + try { + return (new \classes\account_deletion_service())->processPending(25); + } finally { + $workerDb->close(); + date_default_timezone_set($previousTimezone); + if ($previousDb !== null) { + $GLOBALS['db'] = $previousDb; + } else { + unset($GLOBALS['db']); + } + } +} + +it('describes the authenticated customer deletion contract', function (): void { + enableAccountDeletionForApiTest(); + api_test_covers('GET /account/deletion', 'happy'); + $session = api_fixtures()->createUserSession([], [ + 'display_name' => 'Deletion Contract Customer', + 'password_plaintext' => 'Secret123!', + ]); + + $response = api_client()->get('/account/deletion', $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($response->data()) + ->toHaveKey('principal_type', 'customer') + ->toHaveKey('status', 'available') + ->toHaveKey('confirmation_phrase', 'SLET MIN KONTO') + ->toHaveKey('password_required', true) + ->toHaveKey('two_factor_required', false) + ->toHaveKey('privacy_policy_version', '2026-07-20') + ->and($response->data()['retained_data_categories'] ?? null) + ->toBeArray() + ->toContain('invoices_payments_accounting') + ->toContain('customer_reference'); +}); + +it('requires authentication before describing account deletion', function (): void { + enableAccountDeletionForApiTest(); + api_test_covers('GET /account/deletion', 'auth'); + + api_client()->get('/account/deletion', [ + 'Authorization' => 'Bearer invalid-account-deletion-token', + ]) + ->assertStatus(401) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Unauthorized'); +}); + +it('rejects employee and superuser-style classic accounts without a customer number', function (): void { + enableAccountDeletionForApiTest(); + $session = api_fixtures()->createUserSession([], [ + 'customer_number' => 0, + 'display_name' => 'Administrative Account', + 'password_plaintext' => 'Secret123!', + ]); + + api_client()->get('/account/deletion', $session['headers']) + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Account deletion is only available to customer accounts'); + + api_client()->post('/account/deletion', [ + 'password' => 'Secret123!', + 'confirmation' => 'SLET MIN KONTO', + 'acknowledge_legal_retention' => true, + ], $session['headers']) + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Account deletion is only available to customer accounts'); + + expect(api_test_runtime()->queryOne( + 'SELECT deleted_at FROM users WHERE id = ' . (int)$session['user']['id'] . ' LIMIT 1' + )['deleted_at'] ?? null)->toBeNull(); +}); + +it('rejects deletion while a support actor is impersonating a customer', function (): void { + enableAccountDeletionForApiTest(); + $session = api_fixtures()->createUserSession([], [ + 'display_name' => 'Impersonated Customer', + 'password_plaintext' => 'Secret123!', + ]); + api_test_runtime()->db()->query( + "UPDATE tokens SET type = 'AUTH_TOKEN_IMPERSONATION:4242' WHERE token = '" . $session['token'] . "'" + ); + + api_client()->get('/account/deletion', $session['headers']) + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Account deletion is unavailable during support impersonation'); + + api_client()->post('/account/deletion', [ + 'password' => 'Secret123!', + 'confirmation' => 'SLET MIN KONTO', + 'acknowledge_legal_retention' => true, + ], $session['headers']) + ->assertStatus(403) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Account deletion is unavailable during support impersonation'); + + expect(api_test_runtime()->queryOne( + 'SELECT deleted_at FROM users WHERE id = ' . (int)$session['user']['id'] . ' LIMIT 1' + )['deleted_at'] ?? null)->toBeNull(); +}); + +it('requires a fresh deletion-specific assertion for a passkey-only customer', function (): void { + enableAccountDeletionForApiTest(); + api_fixtures()->setModuleConfig('email', 'mailersend_enabled', 'false', 'bool'); + $session = api_fixtures()->createUserSession([], [ + 'display_name' => 'Passkey Only Customer', + 'password_plaintext' => 'Secret123!', + ]); + api_test_runtime()->db()->query( + 'UPDATE users SET password = NULL WHERE id = ' . (int)$session['user']['id'] + ); + api_fixtures()->createPasskey([ + 'user_id' => (int)$session['user']['id'], + 'is_subuser' => false, + ]); + + $description = api_client()->get('/account/deletion', $session['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + expect($description->data()) + ->toBeArray() + ->toHaveKey('password_required', false); + + api_client()->post('/account/deletion', [ + 'confirmation' => 'SLET MIN KONTO', + 'acknowledge_legal_retention' => true, + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('A fresh deletion passkey assertion is required'); + + api_client()->post('/account/deletion/passkey/challenge', [], $session['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); +}); + +it('requires password, exact confirmation, and legal-retention acknowledgement', function (): void { + enableAccountDeletionForApiTest(); + api_test_covers('POST /account/deletion', 'failure'); + $session = api_fixtures()->createUserSession([], [ + 'password_plaintext' => 'Secret123!', + ]); + + api_client()->post('/account/deletion', [ + 'password' => 'Secret123!', + 'confirmation' => 'slet min konto', + 'acknowledge_legal_retention' => true, + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Confirmation phrase does not match'); + + api_client()->post('/account/deletion', [ + 'password' => 'Secret123!', + 'confirmation' => 'SLET MIN KONTO', + 'acknowledge_legal_retention' => false, + ], $session['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Legal retention acknowledgement is required'); + + api_client()->post('/account/deletion', [ + 'password' => 'incorrect', + 'confirmation' => 'SLET MIN KONTO', + 'acknowledge_legal_retention' => true, + ], $session['headers']) + ->assertStatus(401) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Invalid credentials'); + + for ($attempt = 0; $attempt < 2; $attempt++) { + api_client()->post('/account/deletion', [ + 'password' => 'incorrect', + 'confirmation' => 'SLET MIN KONTO', + 'acknowledge_legal_retention' => true, + ], $session['headers']) + ->assertStatus(401) + ->assertEnvelope() + ->assertSuccess(false); + } + + api_client()->post('/account/deletion', [ + 'password' => 'incorrect', + 'confirmation' => 'SLET MIN KONTO', + 'acknowledge_legal_retention' => true, + ], $session['headers']) + ->assertStatus(429) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Too many deletion confirmation attempts'); +}); + +it('deletes only the customer login identity and preserves shared tenant data', function (): void { + enableAccountDeletionForApiTest(); + api_test_covers('POST /account/deletion', 'happy'); + api_fixtures()->setModuleConfig('email', 'mailersend_enabled', 'false', 'bool'); + $session = api_fixtures()->createUserSession([], [ + 'display_name' => 'Deletion Customer', + 'password_plaintext' => 'Secret123!', + ]); + $driver = api_fixtures()->createSubuser(['name' => 'Independent Driver']); + $grantId = api_fixtures()->grantSubuser( + (int)$driver['id'], + (int)$session['user']['customer_number'], + ['SELFSERVE_LIST'], + ); + $passkey = api_fixtures()->createPasskey([ + 'user_id' => (int)$session['user']['id'], + 'is_subuser' => false, + ]); + $challengeToken = bin2hex(random_bytes(32)); + $resetToken = bin2hex(random_bytes(16)); + api_test_runtime()->db()->query( + "INSERT INTO tokens (user_id, type, token) VALUES (" + . (int)$session['user']['id'] . ", 'PASSKEY_CHALLENGE', '$challengeToken')" + ); + api_test_runtime()->db()->query( + "INSERT INTO customer_password_reset_keys (customer_id, token, note) VALUES (" + . (int)$session['user']['customer_number'] . ", '$resetToken', 'Deletion revocation test')" + ); + api_test_runtime()->db()->query( + "INSERT INTO bookings (customer_number, contact_email, washCertificateEmail, date, status, notes, data) + VALUES (" . (int)$session['user']['customer_number'] . ", 'booking@example.test', + 'certificate@example.test', DATE_ADD(NOW(), INTERVAL 1 DAY), 'pending', 'Private note', '{\"phone\":\"123\"}')" + ); + $bookingId = (int)api_test_runtime()->db()->insert_id; + api_test_runtime()->db()->query( + "INSERT INTO order_bookings (customer_number, department, reg_1, datetime, note, reference, po, items) + VALUES (" . (int)$session['user']['customer_number'] . ", 1, 'PRIVATE-PLATE', + DATE_ADD(NOW(), INTERVAL 1 DAY), 'Private note', 'Private reference', 'Private PO', '[]')" + ); + $orderBookingId = (int)api_test_runtime()->db()->insert_id; + api_test_runtime()->db()->query( + "INSERT INTO customer_vehicles (customer_id, type, reg, wash_subscription, notes, reference) + VALUES (" . (int)$session['user']['customer_number'] . ", 1, 'PRIVATE-REG', 1, + 'Private vehicle note', 'Private vehicle reference')" + ); + $vehicleId = (int)api_test_runtime()->db()->insert_id; + + $response = api_client()->post('/account/deletion', [ + 'password' => 'Secret123!', + 'confirmation' => 'SLET MIN KONTO', + 'acknowledge_legal_retention' => true, + ], $session['headers']); + + $response + ->assertStatus(202) + ->assertEnvelope() + ->assertSuccess(); + expect($response->data()) + ->toHaveKey('status', 'requested') + ->toHaveKey('access_revoked', true) + ->and($response->data()['retained_data_categories'] ?? []) + ->toContain('orders_wash_history') + ->toContain('customer_reference') + ->and($response->data()['request_id'] ?? null) + ->toBeString() + ->toHaveLength(36); + + api_client()->get('/auth/session', $session['headers']) + ->assertStatus(401) + ->assertEnvelope() + ->assertSuccess(false); + + $request = api_test_runtime()->queryOne( + "SELECT * FROM account_deletion_requests WHERE principal_type = 'customer'" + . ' AND principal_id = ' . (int)$session['user']['id'] . ' LIMIT 1' + ); + $user = api_test_runtime()->queryOne( + 'SELECT id, deleted_at FROM users WHERE id = ' . (int)$session['user']['id'] . ' LIMIT 1' + ); + $token = api_test_runtime()->queryOne( + 'SELECT id FROM tokens WHERE id > 0 AND token = ' . "'" . $session['token'] . "' LIMIT 1" + ); + $grant = api_test_runtime()->queryOne('SELECT * FROM subuser_grants WHERE id = ' . $grantId . ' LIMIT 1'); + $storedPasskey = api_test_runtime()->queryOne('SELECT * FROM passkeys WHERE id = ' . (int)$passkey['id'] . ' LIMIT 1'); + $storedDriver = api_test_runtime()->queryOne('SELECT id, name FROM subusers WHERE id = ' . (int)$driver['id'] . ' LIMIT 1'); + $challenge = api_test_runtime()->queryOne( + "SELECT id FROM tokens WHERE token = '$challengeToken' LIMIT 1" + ); + $resetKey = api_test_runtime()->queryOne( + "SELECT deleted_at FROM customer_password_reset_keys WHERE token = '$resetToken' LIMIT 1" + ); + + expect($request) + ->not->toBeNull() + ->toHaveKey('status', 'requested') + ->and($user['deleted_at'] ?? null)->not->toBeNull() + ->and($token)->toBeNull() + ->and((int)($grant['enabled'] ?? 0))->toBe(1) + ->and($grant['deleted_at'] ?? null)->toBeNull() + ->and($storedPasskey['deleted_at'] ?? null)->not->toBeNull() + ->and($challenge)->toBeNull() + ->and($resetKey['deleted_at'] ?? null)->toBeNull() + ->and($storedDriver['name'] ?? null)->toBe('Independent Driver'); + + expect(runAccountDeletionWorkerForApiTest()) + ->toMatchArray(['processed' => 1, 'completed' => 1, 'failed' => 0]); + + $completedRequest = api_test_runtime()->queryOne( + 'SELECT status, active_principal_key, completed_at FROM account_deletion_requests WHERE id = ' + . (int)$request['id'] . ' LIMIT 1' + ); + $anonymizedUser = api_test_runtime()->queryOne( + 'SELECT customer_number, display_name, email, phone, password FROM users WHERE id = ' + . (int)$session['user']['id'] . ' LIMIT 1' + ); + $anonymizedBooking = api_test_runtime()->queryOne( + 'SELECT contact_email, washCertificateEmail, status, notes, data FROM bookings WHERE id = ' + . $bookingId . ' LIMIT 1' + ); + $cancelledOrderBooking = api_test_runtime()->queryOne( + 'SELECT reg_1, note, reference, po, items, deleted_at FROM order_bookings WHERE id = ' + . $orderBookingId . ' LIMIT 1' + ); + $anonymizedVehicle = api_test_runtime()->queryOne( + 'SELECT reg, wash_subscription, notes, reference, deleted_at FROM customer_vehicles WHERE id = ' + . $vehicleId . ' LIMIT 1' + ); + expect($completedRequest) + ->toHaveKey('status', 'completed') + ->and($completedRequest['active_principal_key'] ?? null)->toBeNull() + ->and($completedRequest['completed_at'] ?? null)->not->toBeNull() + ->and((int)($anonymizedUser['customer_number'] ?? 0))->toBe((int)$session['user']['customer_number']) + ->and($anonymizedUser['display_name'] ?? null)->toBe('Slettet konto') + ->and($anonymizedUser['email'] ?? null)->toBeNull() + ->and($anonymizedUser['phone'] ?? null)->toBeNull() + ->and($anonymizedUser['password'] ?? null)->toBeNull() + ->and($anonymizedBooking['contact_email'] ?? null)->toBe('booking@example.test') + ->and($anonymizedBooking['status'] ?? null)->toBe('pending') + ->and($anonymizedBooking['notes'] ?? null)->toBe('Private note') + ->and($cancelledOrderBooking['reg_1'] ?? null)->toBe('PRIVATE-PLATE') + ->and($cancelledOrderBooking['note'] ?? null)->toBe('Private note') + ->and($cancelledOrderBooking['deleted_at'] ?? null)->toBeNull() + ->and($anonymizedVehicle['reg'] ?? null)->toBe('PRIVATE-REG') + ->and((int)($anonymizedVehicle['wash_subscription'] ?? 0))->toBe(1) + ->and($anonymizedVehicle['notes'] ?? null)->toBe('Private vehicle note') + ->and($anonymizedVehicle['deleted_at'] ?? null)->toBeNull(); +}); + +it('reclaims a stale processing lease after a worker crash', function (): void { + enableAccountDeletionForApiTest(); + api_fixtures()->setModuleConfig('email', 'mailersend_enabled', 'false', 'bool'); + $user = api_fixtures()->createUser([ + 'display_name' => 'Stale Processing Customer', + 'password_plaintext' => 'Secret123!', + ]); + $requestId = 'stale-' . bin2hex(random_bytes(15)); + $principalKey = 'customer:' . (int)$user['id']; + api_test_runtime()->db()->query( + 'UPDATE users SET deleted_at = NOW() WHERE id = ' . (int)$user['id'] + ); + api_test_runtime()->db()->query( + "INSERT INTO account_deletion_requests + (request_id, principal_type, principal_id, customer_number_snapshot, active_principal_key, + status, policy_version, retained_data_json, requested_at, processing_at, next_attempt_at) + VALUES ('$requestId', 'customer', " . (int)$user['id'] . ', ' + . (int)$user['customer_number'] . ", '$principalKey', 'processing', '2026-07-20', '[]', + DATE_SUB(NOW(), INTERVAL 1 HOUR), DATE_SUB(NOW(), INTERVAL 1 HOUR), NULL)" + ); + + expect(runAccountDeletionWorkerForApiTest()) + ->toMatchArray(['processed' => 1, 'completed' => 1, 'failed' => 0]); + + $request = api_test_runtime()->queryOne( + "SELECT status, active_principal_key, completed_at FROM account_deletion_requests + WHERE request_id = '$requestId' LIMIT 1" + ); + $anonymized = api_test_runtime()->queryOne( + 'SELECT display_name, email, password FROM users WHERE id = ' . (int)$user['id'] . ' LIMIT 1' + ); + expect($request) + ->toHaveKey('status', 'completed') + ->and($request['active_principal_key'] ?? null)->toBeNull() + ->and($request['completed_at'] ?? null)->not->toBeNull() + ->and($anonymized['display_name'] ?? null)->toBe('Slettet konto') + ->and($anonymized['email'] ?? null)->toBeNull() + ->and($anonymized['password'] ?? null)->toBeNull(); +}); + +it('revokes a chauffeur across all customer grants without deleting either customer', function (): void { + enableAccountDeletionForApiTest(); + $firstCustomer = api_fixtures()->createUser(['display_name' => 'First Driver Customer']); + $secondCustomer = api_fixtures()->createUser(['display_name' => 'Second Driver Customer']); + $session = api_fixtures()->createSubuserSession( + (int)$firstCustomer['customer_number'], + ['SELFSERVE_LIST'], + ['name' => 'Deletion Driver', 'password_plaintext' => 'Secret123!'], + ); + $secondGrant = api_fixtures()->grantSubuser( + (int)$session['subuser']['id'], + (int)$secondCustomer['customer_number'], + ['ORDERS_LIST'], + ); + + $response = api_client()->post('/account/deletion', [ + 'password' => 'Secret123!', + 'confirmation' => 'SLET MIN KONTO', + 'acknowledge_legal_retention' => true, + ], $session['headers']); + + $response + ->assertStatus(202) + ->assertEnvelope() + ->assertSuccess(); + expect($response->data()['retained_data_categories'] ?? []) + ->toContain('driver_reference'); + + api_client()->get('/subusers/me', $session['headers']) + ->assertStatus(401) + ->assertEnvelope() + ->assertSuccess(false); + + $activeGrants = api_test_runtime()->queryOne( + 'SELECT COUNT(*) AS aggregate FROM subuser_grants WHERE subuser = ' + . (int)$session['subuser']['id'] . ' AND enabled = 1' + ); + $driver = api_test_runtime()->queryOne( + 'SELECT id, deleted_at FROM subusers WHERE id = ' . (int)$session['subuser']['id'] . ' LIMIT 1' + ); + $first = api_test_runtime()->queryOne('SELECT id FROM users WHERE id = ' . (int)$firstCustomer['id'] . ' LIMIT 1'); + $second = api_test_runtime()->queryOne('SELECT id FROM users WHERE id = ' . (int)$secondCustomer['id'] . ' LIMIT 1'); + $otherGrant = api_test_runtime()->queryOne('SELECT enabled, deleted_at FROM subuser_grants WHERE id = ' . $secondGrant . ' LIMIT 1'); + + expect((int)($activeGrants['aggregate'] ?? -1))->toBe(0) + ->and($driver['deleted_at'] ?? null)->not->toBeNull() + ->and($first)->not->toBeNull() + ->and($second)->not->toBeNull() + ->and((int)($otherGrant['enabled'] ?? 1))->toBe(0) + ->and($otherGrant['deleted_at'] ?? null)->not->toBeNull(); + + $staleSetupToken = bin2hex(random_bytes(16)); + $setupKey = '`subusers`_subuser_setup_token_setup_token:' . $staleSetupToken; + $reverseSetupKey = '`subusers`_subuser_setup_token_setup_token_for_subuser:' . (int)$session['subuser']['id']; + api_test_runtime()->redis()?->set($setupKey, (string)$session['subuser']['id']); + api_test_runtime()->redis()?->set($reverseSetupKey, $staleSetupToken); + + api_client()->get('/subusers/setup?token=' . $staleSetupToken) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Invalid or expired token'); + + api_test_runtime()->redis()?->set($setupKey, (string)$session['subuser']['id']); + api_test_runtime()->redis()?->set($reverseSetupKey, $staleSetupToken); + api_client()->post('/subusers/setup', [ + 'token' => $staleSetupToken, + 'password' => 'Replacement123!', + 'name' => 'Repopulated Driver', + 'email' => 'repopulated@example.test', + ]) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Invalid or expired token'); + + $stillBlockedDriver = api_test_runtime()->queryOne( + 'SELECT name, email, deleted_at FROM subusers WHERE id = ' . (int)$session['subuser']['id'] . ' LIMIT 1' + ); + expect($stillBlockedDriver['name'] ?? null)->toBe('Deletion Driver') + ->and($stillBlockedDriver['email'] ?? null)->not->toBe('repopulated@example.test') + ->and($stillBlockedDriver['deleted_at'] ?? null)->not->toBeNull(); +}); diff --git a/services/nginx/app/tests/Api/api_coverage_manifest.php b/services/nginx/app/tests/Api/api_coverage_manifest.php index 6de06af1..6c984221 100644 --- a/services/nginx/app/tests/Api/api_coverage_manifest.php +++ b/services/nginx/app/tests/Api/api_coverage_manifest.php @@ -6,6 +6,8 @@ return [ 'openapi_operations' => [ 'POST /auth/login', 'GET /auth/session', + 'GET /account/deletion', + 'POST /account/deletion', 'GET /auth/logout', 'GET /departments', 'POST /departments', diff --git a/services/nginx/app/tests/Support/Api/ApiFixtures.php b/services/nginx/app/tests/Support/Api/ApiFixtures.php index 65c4cf2d..a585fbb0 100644 --- a/services/nginx/app/tests/Support/Api/ApiFixtures.php +++ b/services/nginx/app/tests/Support/Api/ApiFixtures.php @@ -1054,6 +1054,10 @@ final class ApiFixtures ]); $this->cleanup->add(function () use ($subuserId): void { + $this->deleteWhereIfPossible('account_deletion_requests', [ + 'principal_type' => 'subuser', + 'principal_id' => $subuserId, + ]); $this->deleteWhere('subuser_grants', ['subuser' => $subuserId]); $this->deleteWhere('tokens', ['user_id' => $subuserId, 'type' => 'AUTH_TOKEN_SUBUSER']); $this->deleteById('subusers', $subuserId); @@ -1839,6 +1843,10 @@ final class ApiFixtures private function purgeCustomerTraceData(int $userId, int $customerNumber): void { + $this->deleteWhereIfPossible('account_deletion_requests', [ + 'principal_type' => 'customer', + 'principal_id' => $userId, + ]); $invoiceCollectionIds = $this->fetchIntColumnWhere('collected_order_invoices', 'id', [ 'customer_number' => $customerNumber, ]); @@ -1874,6 +1882,7 @@ final class ApiFixtures $this->deleteWhereIfPossible('customer_attributes', ['user_id' => $userId]); $this->deleteWhereIfPossible('tokens', ['user_id' => $userId]); + $this->deleteWhereIfPossible('customer_password_reset_keys', ['customer_id' => $customerNumber]); $this->deleteWhereIfPossible('user_key_value_pairs', ['user_id' => $userId]); $this->deleteWhereIfPossible('price_overrides', ['user_id' => $userId]); $this->deleteWhereIfPossible('department_customer_price_overrides', ['user_id' => $userId]); @@ -1892,6 +1901,7 @@ final class ApiFixtures 'object_id' => $userId, ]); $this->deleteWhereIfPossible('bookings', ['customer_number' => $customerNumber]); + $this->deleteWhereIfPossible('order_bookings', ['customer_number' => $customerNumber]); $this->deleteWhereIfPossible('orders', ['customer_id' => $customerNumber]); $this->deleteWhereIfPossible('customer_vehicles', ['customer_id' => $customerNumber]); $this->deleteWhereIfPossible('collected_order_invoices', ['customer_number' => $customerNumber]); diff --git a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php index 231eef82..70b6a1c2 100644 --- a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php +++ b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php @@ -831,9 +831,58 @@ CREATE TABLE IF NOT EXISTS `subusers` ( `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `suspended_at` DATETIME NULL, + `deleted_at` DATETIME NULL, PRIMARY KEY (`id`), KEY `idx_subusers_username` (`username`), - KEY `idx_subusers_phone` (`phone`) + KEY `idx_subusers_phone` (`phone`), + KEY `idx_subusers_deleted_at` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'account_deletion_requests' => <<<'SQL' +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`), + KEY `idx_account_deletion_worker` (`status`, `next_attempt_at`, `requested_at`), + KEY `idx_account_deletion_principal` (`principal_type`, `principal_id`, `requested_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'account_deletion_credential_attempts' => <<<'SQL' +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`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'account_deletion_outbox' => <<<'SQL' +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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci SQL, 'subuser_grants' => <<<'SQL' @@ -867,6 +916,20 @@ CREATE TABLE IF NOT EXISTS `tokens` ( KEY `idx_tokens_user_id` (`user_id`), KEY `idx_tokens_type` (`type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +SQL, + 'customer_password_reset_keys' => <<<'SQL' +CREATE TABLE IF NOT EXISTS `customer_password_reset_keys` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `customer_id` INT NOT NULL, + `note` VARCHAR(255) NULL, + `token` VARCHAR(64) NOT NULL, + `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_customer_password_reset_token` (`token`), + KEY `idx_customer_password_reset_customer` (`customer_id`, `deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci SQL, 'passkeys' => <<<'SQL' CREATE TABLE IF NOT EXISTS `passkeys` ( diff --git a/services/nginx/app/tests/Unit/Account/AccountDeletionContractTest.php b/services/nginx/app/tests/Unit/Account/AccountDeletionContractTest.php new file mode 100644 index 00000000..a69496b2 --- /dev/null +++ b/services/nginx/app/tests/Unit/Account/AccountDeletionContractTest.php @@ -0,0 +1,79 @@ +toContain("\$this->get('/account/deletion'") + ->toContain("\$this->post('/account/deletion'") + ->toContain('account_deletion_service::apiEnabled()') + ->toContain('$response->success($payload, 202)') + ->and($service) + ->toContain("public const CONFIRMATION_PHRASE = 'SLET MIN KONTO'") + ->toContain('acknowledge_legal_retention') + ->toContain("active_principal_key = NULL") + ->toContain("status = 'completed'") + ->toContain('processPending') + ->toContain('PROCESSING_LEASE_SECONDS') + ->toContain("status = 'processing' AND (processing_at IS NULL OR processing_at <=") + ->toContain("'ACCOUNT_DELETION_PASSKEY_CHALLENGE'") + ->toContain('verifyDeletionPasskey') + ->toContain('private const MAX_CREDENTIAL_ATTEMPTS = 4') + ->toContain('recordCredentialAttempt') + ->toContain('rejectImpersonationToken') + ->toContain('account_deletion_credential_attempts') + ->toContain('enqueueOutbox') + ->toContain("'manual_review'") + ->toContain('Unable to inspect principal deletion state.') + ->toContain('ACCOUNT_DELETION_REQUIRES_SUPPORT') + ->toContain('manual_support_review') + ->and($authentication) + ->toContain("principalIsBlocked('customer'") + ->toContain("principalIsBlocked('subuser'") + ->toContain('create_impersonation_token') + ->and($intimidateRoute) + ->toContain('create_impersonation_token') + ->and($subusers) + ->toContain("principalIsBlocked('subuser'") + ->and($passkeys) + ->toContain('deleted_at IS NULL') + ->and($passwordReset) + ->toContain("principalIsBlocked('customer'") + ->and($tasks) + ->toContain("'id' => 'account.process_deletion_requests'") + ->and($cron) + ->toContain('function ProcessAccountDeletionRequestsCron(): array') + ->toContain('account_deletion_service::workerEnabled()'); +}); + +it('uses a one-active-request constraint and keeps legal records out of anonymization deletes', function (): void { + $root = dirname(__DIR__, 3); + $schema = (string)file_get_contents($root . '/classes/account_deletion_schema_bootstrap.php'); + $service = (string)file_get_contents($root . '/classes/account_deletion_service.php'); + + expect($schema) + ->toContain('UNIQUE KEY uniq_account_deletion_active_principal') + ->toContain("self::ensureColumn('users', 'deleted_at'") + ->toContain("self::ensureColumn('subusers', 'deleted_at'") + ->toContain('public static function check(): array') + ->toContain("if (PHP_SAPI !== 'cli')") + ->and($service)->not->toContain('ensureTables()') + ->and($service) + ->toContain('invoices_payments_accounting') + ->toContain('orders_wash_history') + ->toContain('customer_reference') + ->toContain('driver_reference') + ->not->toContain("DELETE FROM orders") + ->not->toContain("DELETE FROM collected_order_invoices"); +}); diff --git a/services/nginx/app/tests/Unit/Auth/PasswordResetTokenExpiryTest.php b/services/nginx/app/tests/Unit/Auth/PasswordResetTokenExpiryTest.php index 8f4434fd..bab07622 100644 --- a/services/nginx/app/tests/Unit/Auth/PasswordResetTokenExpiryTest.php +++ b/services/nginx/app/tests/Unit/Auth/PasswordResetTokenExpiryTest.php @@ -51,6 +51,11 @@ if (!class_exists('PasswordResetTokenExpiryProbe')) { { } + protected function selectedCustomerCanResetPassword(): bool + { + return true; + } + public function forceSelectedId(int $id): void { $this->id = $id; diff --git a/services/nginx/app/tests/Unit/Cron/CronTaskRegistryTest.php b/services/nginx/app/tests/Unit/Cron/CronTaskRegistryTest.php index 50fedecf..98b926eb 100644 --- a/services/nginx/app/tests/Unit/Cron/CronTaskRegistryTest.php +++ b/services/nginx/app/tests/Unit/Cron/CronTaskRegistryTest.php @@ -7,7 +7,7 @@ it('discovers module-owned cron task definitions', function (): void { $registry = new cron_task_registry(app_path('modules')); $definitions = $registry->definitions(); - expect($definitions)->toHaveCount(22); + expect($definitions)->toHaveCount(23); expect(array_keys($definitions))->toContain( 'system.sync_logs', 'backups.process_jobs', @@ -16,6 +16,7 @@ it('discovers module-owned cron task definitions', function (): void { 'dynamicimages.pre_render', 'weatherapi.preload_department_responses', 'goals.progress_alerts', + 'account.process_deletion_requests', 'selfserve.activate_opening_cleaner_relays' );