Add in-app account deletion (#319)
## Summary - Add self-service deletion for the authenticated customer or subuser identity only. - Preserve shared customer grants, reset keys, bookings, order bookings, vehicles, invoices, and legally required history. - Require password/TOTP or a fresh deletion-specific, five-minute, single-use WebAuthn assertion. - Reject support impersonation and expired legacy plain-session tokens. - Use durable database throttling, transactional request processing, a durable outbox, and terminal `manual_review` state. - Keep API and worker default-off behind separate `account_deletion.api_enabled` and `account_deletion.worker_enabled` module-config flags. ## Safe rollout 1. Keep both flags disabled. 2. Run `php scripts/account-deletion-schema.php check`. 3. If needed, run `php scripts/account-deletion-schema.php apply --yes`, then rerun `check` until `ready:true`. 4. Deploy the frontend companion PR while the API remains disabled. 5. Enable `api_enabled` for a controlled canary; verify password and passwordless request flows plus immediate authentication revocation. 6. Inspect queued request/outbox state, then enable `worker_enabled`. 7. Verify anonymization, preserved tenant/history data, outbox delivery, retries, and manual-review behavior before broad rollout. ## Verification - Account deletion unit tests: 2 passed, 43 assertions. - PHP lint, both OpenAPI YAML parses, runtime-DDL scan, destructive-scope scan, and `git diff --check` passed. - Full API/unit/integration evidence is required from exact-head CI; local Docker is unavailable and shared-vendor tests were explicitly discarded. ## Security notes - Schema mutation is CLI-only; web and cron paths perform read-only readiness checks. - Runtime behavior fails closed when schema/config/throttle/delivery prerequisites are unavailable.
This commit is contained in:
+150
@@ -2579,6 +2579,156 @@ paths:
|
|||||||
'401':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$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:
|
/auth/2fa/setup:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
Executable
+35
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
|
fwrite(STDERR, "This command is CLI-only.\n");
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const WD = __DIR__ . '/../services/nginx/app';
|
||||||
|
require_once WD . '/vendor/autoload.php';
|
||||||
|
require_once WD . '/config.php';
|
||||||
|
require_once WD . '/classes/db.php';
|
||||||
|
require_once WD . '/classes/account_deletion_schema_bootstrap.php';
|
||||||
|
|
||||||
|
$response = null;
|
||||||
|
$db = new \classes\db($CONFIG_DB);
|
||||||
|
$db->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);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class account_deletion_http_exception extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(string $message, public readonly int $status)
|
||||||
|
{
|
||||||
|
parent::__construct($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace classes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit account-deletion schema management.
|
||||||
|
*
|
||||||
|
* apply() must only be invoked by the dedicated CLI. Web requests and cron jobs
|
||||||
|
* are deliberately limited to the read-only check().
|
||||||
|
*/
|
||||||
|
class account_deletion_schema_bootstrap
|
||||||
|
{
|
||||||
|
/** @return array{ready:bool,missing:array<int,string>} */
|
||||||
|
public static function check(): array
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
$missing = [];
|
||||||
|
foreach (['account_deletion_requests', 'account_deletion_credential_attempts', 'account_deletion_outbox'] as $table) {
|
||||||
|
$tableSql = $db->escape_string($table);
|
||||||
|
$result = $db->query("SHOW TABLES LIKE '$tableSql'");
|
||||||
|
if ($result === false || $result->num_rows === 0) {
|
||||||
|
$missing[] = 'table:' . $table;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (['users' => 'deleted_at', 'subusers' => 'deleted_at'] as $table => $column) {
|
||||||
|
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$column'");
|
||||||
|
if ($result === false || $result->num_rows === 0) {
|
||||||
|
$missing[] = 'column:' . $table . '.' . $column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!in_array('table:account_deletion_requests', $missing, true)) {
|
||||||
|
$result = $db->query("SHOW COLUMNS FROM account_deletion_requests LIKE 'manual_review_required_at'");
|
||||||
|
if ($result === false || $result->num_rows === 0) {
|
||||||
|
$missing[] = 'column:account_deletion_requests.manual_review_required_at';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ['ready' => $missing === [], 'missing' => $missing];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function apply(): void
|
||||||
|
{
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
|
throw new \RuntimeException('Account deletion schema changes are CLI-only.');
|
||||||
|
}
|
||||||
|
global $db;
|
||||||
|
self::execute("CREATE TABLE IF NOT EXISTS account_deletion_requests (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
request_id CHAR(36) NOT NULL,
|
||||||
|
principal_type VARCHAR(16) NOT NULL,
|
||||||
|
principal_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
customer_number_snapshot INT NULL,
|
||||||
|
active_principal_key VARCHAR(191) NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'requested',
|
||||||
|
policy_version VARCHAR(32) NOT NULL,
|
||||||
|
retained_data_json LONGTEXT NOT NULL,
|
||||||
|
request_ip VARCHAR(45) NULL,
|
||||||
|
request_user_agent VARCHAR(512) NULL,
|
||||||
|
retry_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
failure_code VARCHAR(191) NULL,
|
||||||
|
requested_at DATETIME NOT NULL,
|
||||||
|
processing_at DATETIME NULL,
|
||||||
|
completed_at DATETIME NULL,
|
||||||
|
next_attempt_at DATETIME NULL,
|
||||||
|
manual_review_required_at DATETIME NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uniq_account_deletion_request_id (request_id),
|
||||||
|
UNIQUE KEY uniq_account_deletion_active_principal (active_principal_key),
|
||||||
|
INDEX idx_account_deletion_worker (status, next_attempt_at, requested_at),
|
||||||
|
INDEX idx_account_deletion_principal (principal_type, principal_id, requested_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
|
||||||
|
self::ensureColumn('account_deletion_requests', 'manual_review_required_at', 'DATETIME NULL AFTER `next_attempt_at`');
|
||||||
|
self::execute("CREATE TABLE IF NOT EXISTS account_deletion_credential_attempts (
|
||||||
|
throttle_key CHAR(64) NOT NULL,
|
||||||
|
attempt_count INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
window_started_at DATETIME NOT NULL,
|
||||||
|
blocked_until DATETIME NULL,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (throttle_key), INDEX idx_account_deletion_throttle_expiry (updated_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
|
||||||
|
self::execute("CREATE TABLE IF NOT EXISTS account_deletion_outbox (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
request_id CHAR(36) NOT NULL,
|
||||||
|
event_type VARCHAR(64) NOT NULL,
|
||||||
|
payload_json LONGTEXT NOT NULL,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||||
|
attempts INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
available_at DATETIME NOT NULL,
|
||||||
|
processing_at DATETIME NULL,
|
||||||
|
delivered_at DATETIME NULL,
|
||||||
|
last_error VARCHAR(191) NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id), UNIQUE KEY uniq_account_deletion_outbox_event (request_id, event_type),
|
||||||
|
INDEX idx_account_deletion_outbox_delivery (status, available_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
|
||||||
|
self::ensureColumn('account_deletion_outbox', 'processing_at', 'DATETIME NULL AFTER `available_at`');
|
||||||
|
self::ensureColumn('users', 'deleted_at', 'DATETIME NULL AFTER `updated_at`');
|
||||||
|
self::ensureColumn('subusers', 'deleted_at', 'DATETIME NULL AFTER `suspended_at`');
|
||||||
|
self::ensureIndex('users', 'idx_users_deleted_at', '`deleted_at`');
|
||||||
|
self::ensureIndex('subusers', 'idx_subusers_deleted_at', '`deleted_at`');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function execute(string $sql): void
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
if ($db->query($sql) === false) {
|
||||||
|
throw new \RuntimeException('Account deletion schema operation failed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function ensureColumn(string $table, string $column, string $definition): void
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
$result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$column'");
|
||||||
|
if ($result === false) throw new \RuntimeException('Unable to inspect account deletion schema.');
|
||||||
|
if ($result->num_rows === 0) self::execute("ALTER TABLE `$table` ADD COLUMN `$column` $definition");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function ensureIndex(string $table, string $index, string $columns): void
|
||||||
|
{
|
||||||
|
global $db;
|
||||||
|
$result = $db->query("SHOW INDEX FROM `$table` WHERE Key_name = '$index'");
|
||||||
|
if ($result === false) throw new \RuntimeException('Unable to inspect account deletion indexes.');
|
||||||
|
if ($result->num_rows === 0) self::execute("ALTER TABLE `$table` ADD INDEX `$index` ($columns)");
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace classes;
|
namespace classes;
|
||||||
|
|
||||||
|
require_once WD . '/classes/account_deletion_service.php';
|
||||||
|
|
||||||
use classes\totp;
|
use classes\totp;
|
||||||
use Exception;
|
use Exception;
|
||||||
use interfaces\authentication_i;
|
use interfaces\authentication_i;
|
||||||
@@ -69,6 +71,10 @@ class authentication implements authentication_i
|
|||||||
|
|
||||||
public function create_2fa_token(int $id, string $type): string
|
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
|
// Create a temporary 2FA token
|
||||||
$token = bin2hex(random_bytes(32));
|
$token = bin2hex(random_bytes(32));
|
||||||
(new tokens_o())->create($id, $token, $type);
|
(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);
|
throw new \Exception('User not found for customer number: ' . $customer_number);
|
||||||
}
|
}
|
||||||
$user_id = $user->id;
|
$user_id = $user->id;
|
||||||
|
if (account_deletion_service::principalIsBlocked('customer', (int)$user_id)) {
|
||||||
|
throw new Exception('Account unavailable');
|
||||||
|
}
|
||||||
// Save the token in the database
|
// Save the token in the database
|
||||||
(new tokens_o())->create($user_id, $token, 'AUTH_TOKEN');
|
(new tokens_o())->create($user_id, $token, 'AUTH_TOKEN');
|
||||||
return $token;
|
return $token;
|
||||||
@@ -107,6 +116,9 @@ class authentication implements authentication_i
|
|||||||
|
|
||||||
public function create_token_by_user_id(int $user_id): string
|
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
|
// Create a token
|
||||||
$token = bin2hex(random_bytes(32));
|
$token = bin2hex(random_bytes(32));
|
||||||
// Save the token in the database
|
// Save the token in the database
|
||||||
@@ -116,6 +128,9 @@ class authentication implements authentication_i
|
|||||||
|
|
||||||
public function create_employee_token(int $employee_id): string
|
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
|
// Create a token
|
||||||
$token = bin2hex(random_bytes(32));
|
$token = bin2hex(random_bytes(32));
|
||||||
// Save the token in the database
|
// Save the token in the database
|
||||||
@@ -123,13 +138,26 @@ class authentication implements authentication_i
|
|||||||
return $token;
|
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
|
public function validate_token(string $token): bool
|
||||||
{
|
{
|
||||||
// First: try validating as a classic user auth token
|
// First: try validating as a classic user auth token
|
||||||
try {
|
try {
|
||||||
$dbToken = (new tokens_o())->getToken($token);
|
$dbToken = (new tokens_o())->getToken($token);
|
||||||
if ($dbToken && $dbToken->id && $dbToken->type->value() === 'AUTH_TOKEN') {
|
if ($dbToken && $dbToken->id && $this->isClassicAuthTokenType((string)$dbToken->type->value())) {
|
||||||
return true;
|
return !account_deletion_service::principalIsBlocked(
|
||||||
|
'customer',
|
||||||
|
(int)$dbToken->user_id->value()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (Exception) {
|
} catch (Exception) {
|
||||||
// Ignore and continue to subuser session validation
|
// Ignore and continue to subuser session validation
|
||||||
@@ -137,7 +165,7 @@ class authentication implements authentication_i
|
|||||||
// Fallback: try validating as a subuser session token
|
// Fallback: try validating as a subuser session token
|
||||||
$subuser = (new subusers_o())->getSubuserBySessionToken($token);
|
$subuser = (new subusers_o())->getSubuserBySessionToken($token);
|
||||||
if ($subuser !== null) {
|
if ($subuser !== null) {
|
||||||
return true;
|
return !account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -168,7 +196,10 @@ class authentication implements authentication_i
|
|||||||
if (!$token->id) {
|
if (!$token->id) {
|
||||||
return false;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
// Get the user from the database
|
// Get the user from the database
|
||||||
@@ -177,6 +208,11 @@ class authentication implements authentication_i
|
|||||||
return $user;
|
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
|
public function get_plate_scanner(): plate_scanners_o|false
|
||||||
{
|
{
|
||||||
// Get the token from the headers
|
// Get the token from the headers
|
||||||
@@ -227,6 +263,9 @@ class authentication implements authentication_i
|
|||||||
if ($subuser === null) {
|
if ($subuser === null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
$customerNumberContext = null;
|
$customerNumberContext = null;
|
||||||
if (isset($headers['X-Customer-Number'])) {
|
if (isset($headers['X-Customer-Number'])) {
|
||||||
$customerNumberContext = (int)$headers['X-Customer-Number'];
|
$customerNumberContext = (int)$headers['X-Customer-Number'];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// prevent direct access
|
// prevent direct access
|
||||||
|
|
||||||
use classes\backup_store;
|
use classes\backup_store;
|
||||||
|
use classes\account_deletion_service;
|
||||||
use classes\economic;
|
use classes\economic;
|
||||||
use classes\economic_transfer_queue;
|
use classes\economic_transfer_queue;
|
||||||
use classes\invoice_period_flag_service;
|
use classes\invoice_period_flag_service;
|
||||||
@@ -63,6 +64,12 @@ $response_cron = [];
|
|||||||
|
|
||||||
// Define the cron tasks
|
// Define the cron tasks
|
||||||
$cron_tasks = [
|
$cron_tasks = [
|
||||||
|
'ProcessAccountDeletionRequestsCron' => [
|
||||||
|
'interval' => 300,
|
||||||
|
'last_run' => 0,
|
||||||
|
'next_run' => 0,
|
||||||
|
'function' => 'ProcessAccountDeletionRequestsCron',
|
||||||
|
],
|
||||||
// 'CheckUnfulfilledBookings' => [
|
// 'CheckUnfulfilledBookings' => [
|
||||||
// 'interval' => 86400, // 24 hours
|
// 'interval' => 86400, // 24 hours
|
||||||
// 'last_run' => 0,
|
// '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
|
function ReplicaFailoverMonitorCron(): void
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'id' => '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,
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace objects;
|
namespace objects;
|
||||||
|
|
||||||
|
use classes\account_deletion_service;
|
||||||
use classes\db;
|
use classes\db;
|
||||||
use classes\object_property;
|
use classes\object_property;
|
||||||
use Exception;
|
use Exception;
|
||||||
@@ -56,6 +57,13 @@ class customer_password_reset_keys_o extends db
|
|||||||
return bin2hex(random_bytes($length / 2));
|
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
|
* Find a valid reset key by token
|
||||||
* @param string $token The token to search for
|
* @param string $token The token to search for
|
||||||
@@ -79,6 +87,10 @@ class customer_password_reset_keys_o extends db
|
|||||||
$row = $result->fetch_assoc();
|
$row = $result->fetch_assoc();
|
||||||
$this->id = (int)$row['id'];
|
$this->id = (int)$row['id'];
|
||||||
$this->getObjectProperties();
|
$this->getObjectProperties();
|
||||||
|
if (!$this->selectedCustomerCanResetPassword()) {
|
||||||
|
$this->delete();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +133,10 @@ class customer_password_reset_keys_o extends db
|
|||||||
$customer = new users_o();
|
$customer = new users_o();
|
||||||
$customer->getUserByCustomerNumber((int)$this->customer_id->value());
|
$customer->getUserByCustomerNumber((int)$this->customer_id->value());
|
||||||
$customer->requireSelected();
|
$customer->requireSelected();
|
||||||
|
if (account_deletion_service::principalIsBlocked('customer', (int)$customer->id)) {
|
||||||
|
$this->delete();
|
||||||
|
throw new Exception('Invalid or expired token');
|
||||||
|
}
|
||||||
$customer->setPassword($new_password);
|
$customer->setPassword($new_password);
|
||||||
$this->delete();
|
$this->delete();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class passkeys_o extends db
|
|||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
$credentialId = $db->escape_string($credentialId);
|
$credentialId = $db->escape_string($credentialId);
|
||||||
$where = "credential_id = '" . $credentialId . "'";
|
$where = "credential_id = '" . $credentialId . "' AND deleted_at IS NULL";
|
||||||
if ($userId !== null) {
|
if ($userId !== null) {
|
||||||
$where .= ' AND user_id = ' . (int)$userId;
|
$where .= ' AND user_id = ' . (int)$userId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace objects;
|
namespace objects;
|
||||||
|
|
||||||
|
use classes\account_deletion_service;
|
||||||
use classes\db;
|
use classes\db;
|
||||||
use classes\object_property;
|
use classes\object_property;
|
||||||
use Exception;
|
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
|
public function add(int $billing_customer_number, int $subuser, bool $enabled, ?string $note, ?array $permissions = self::defaultPermissions): subuser_grants_o
|
||||||
{
|
{
|
||||||
global $db;
|
global $db;
|
||||||
|
if (account_deletion_service::principalIsBlocked('subuser', $subuser)) {
|
||||||
|
throw new Exception('Subuser account is unavailable');
|
||||||
|
}
|
||||||
$permissions = self::normalizePermissionsValue($permissions);
|
$permissions = self::normalizePermissionsValue($permissions);
|
||||||
$tmp = $this->add_object([
|
$tmp = $this->add_object([
|
||||||
'billing_customer_number' => (int)$billing_customer_number,
|
'billing_customer_number' => (int)$billing_customer_number,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace objects;
|
namespace objects;
|
||||||
|
|
||||||
use classes\authentication;
|
use classes\authentication;
|
||||||
|
use classes\account_deletion_service;
|
||||||
use classes\db;
|
use classes\db;
|
||||||
use classes\object_property;
|
use classes\object_property;
|
||||||
use Exception;
|
use Exception;
|
||||||
@@ -187,6 +188,9 @@ class subusers_o extends db
|
|||||||
public function setPassword(string $password): self
|
public function setPassword(string $password): self
|
||||||
{
|
{
|
||||||
self::requireSelected();
|
self::requireSelected();
|
||||||
|
if (account_deletion_service::principalIsBlocked('subuser', (int)$this->id)) {
|
||||||
|
throw new Exception('Account unavailable');
|
||||||
|
}
|
||||||
self::assertValidPassword($password);
|
self::assertValidPassword($password);
|
||||||
$this->password->set((string)password_hash($password, PASSWORD_DEFAULT));
|
$this->password->set((string)password_hash($password, PASSWORD_DEFAULT));
|
||||||
return $this;
|
return $this;
|
||||||
@@ -215,6 +219,9 @@ class subusers_o extends db
|
|||||||
public function generateSetupToken(): string
|
public function generateSetupToken(): string
|
||||||
{
|
{
|
||||||
self::requireSelected();
|
self::requireSelected();
|
||||||
|
if (account_deletion_service::principalIsBlocked('subuser', (int)$this->id)) {
|
||||||
|
throw new Exception('Account unavailable');
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
$token = bin2hex(random_bytes(16));
|
$token = bin2hex(random_bytes(16));
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
@@ -265,6 +272,10 @@ class subusers_o extends db
|
|||||||
}
|
}
|
||||||
$subuser = (new subusers_o())->select((int)$subuser_id);
|
$subuser = (new subusers_o())->select((int)$subuser_id);
|
||||||
$subuser->getObjectProperties();
|
$subuser->getObjectProperties();
|
||||||
|
if (account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) {
|
||||||
|
$this->invalidateSetupToken($token);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return $subuser;
|
return $subuser;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,6 +362,9 @@ class subusers_o extends db
|
|||||||
public function generateSession(): string
|
public function generateSession(): string
|
||||||
{
|
{
|
||||||
self::requireSelected();
|
self::requireSelected();
|
||||||
|
if (account_deletion_service::principalIsBlocked('subuser', (int)$this->id)) {
|
||||||
|
throw new Exception('Account unavailable');
|
||||||
|
}
|
||||||
$session_token = bin2hex(random_bytes(32));
|
$session_token = bin2hex(random_bytes(32));
|
||||||
$this->cache('session_token:' . $session_token, $this->id, 'subuser_sessions');
|
$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
|
$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) {
|
if ($subuser_id !== null) {
|
||||||
$subuser = (new subusers_o())->select((int)$subuser_id);
|
$subuser = (new subusers_o())->select((int)$subuser_id);
|
||||||
$subuser->getObjectProperties();
|
$subuser->getObjectProperties();
|
||||||
|
if (account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) {
|
||||||
|
$this->invalidateSessionToken($token);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return $subuser;
|
return $subuser;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace objects;
|
namespace objects;
|
||||||
|
|
||||||
|
use classes\account_deletion_service;
|
||||||
use classes\db;
|
use classes\db;
|
||||||
use classes\customer_rule_product_restriction_schema_bootstrap;
|
use classes\customer_rule_product_restriction_schema_bootstrap;
|
||||||
use classes\customer_name_cache_payload_builder;
|
use classes\customer_name_cache_payload_builder;
|
||||||
@@ -2049,6 +2050,9 @@ class users_o extends db
|
|||||||
public function generatePasswordResetLink(): string
|
public function generatePasswordResetLink(): string
|
||||||
{
|
{
|
||||||
self::requireSelected();
|
self::requireSelected();
|
||||||
|
if (account_deletion_service::principalIsBlocked('customer', (int)$this->id)) {
|
||||||
|
throw new Exception('Account unavailable');
|
||||||
|
}
|
||||||
// Generate token
|
// Generate token
|
||||||
$token = customer_password_reset_keys_o::generateToken();
|
$token = customer_password_reset_keys_o::generateToken();
|
||||||
|
|
||||||
|
|||||||
@@ -2935,6 +2935,156 @@ paths:
|
|||||||
'401':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$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:
|
/auth/2fa/setup:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace routes;
|
||||||
|
|
||||||
|
use classes\account_deletion_http_exception;
|
||||||
|
use classes\account_deletion_service;
|
||||||
|
use Throwable;
|
||||||
|
use traits\route_t;
|
||||||
|
|
||||||
|
class accountDeletionRoute
|
||||||
|
{
|
||||||
|
use route_t;
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace routes;
|
namespace routes;
|
||||||
|
|
||||||
use classes\authentication;
|
use classes\authentication;
|
||||||
|
use classes\account_deletion_service;
|
||||||
use classes\economic;
|
use classes\economic;
|
||||||
use classes\email;
|
use classes\email;
|
||||||
use classes\release_manager;
|
use classes\release_manager;
|
||||||
@@ -22,6 +23,7 @@ use objects\passkeys_o;
|
|||||||
use traits\route_t;
|
use traits\route_t;
|
||||||
|
|
||||||
require_once WD . '/classes/security_policy_service.php';
|
require_once WD . '/classes/security_policy_service.php';
|
||||||
|
require_once WD . '/classes/account_deletion_service.php';
|
||||||
|
|
||||||
class authRoute
|
class authRoute
|
||||||
{
|
{
|
||||||
@@ -67,7 +69,7 @@ class authRoute
|
|||||||
|
|
||||||
$passkeys = new passkeys_o();
|
$passkeys = new passkeys_o();
|
||||||
$passkeys->setAdditionalWhereClause(
|
$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) {
|
$list = $passkeys->listObjectsWithPaginationIfSet(function ($o) {
|
||||||
$transports = null;
|
$transports = null;
|
||||||
@@ -125,6 +127,10 @@ class authRoute
|
|||||||
$this->observeLoginFailure('customer', $data['customer_number'], ['reason' => 'missing_user_or_password']);
|
$this->observeLoginFailure('customer', $data['customer_number'], ['reason' => 'missing_user_or_password']);
|
||||||
$response->error('Invalid credentials', 401);
|
$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;
|
$isCredentialsValid = false;
|
||||||
try {
|
try {
|
||||||
@@ -312,14 +318,21 @@ class authRoute
|
|||||||
|
|
||||||
if ($token_type === '2FA_VERIFICATION_USER') {
|
if ($token_type === '2FA_VERIFICATION_USER') {
|
||||||
$user = (new users_o())->getUserById($user_id);
|
$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);
|
$token_o->delete($token_str);
|
||||||
$new_token = $auth->create_employee_token($user_id); // Works for both users and employees
|
$new_token = $auth->create_employee_token($user_id); // Works for both users and employees
|
||||||
$response->success(['token' => $new_token]);
|
$response->success(['token' => $new_token]);
|
||||||
}
|
}
|
||||||
} elseif ($token_type === '2FA_VERIFICATION_SUBUSER') {
|
} elseif ($token_type === '2FA_VERIFICATION_SUBUSER') {
|
||||||
$subuser = (new subusers_o())->select($user_id);
|
$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);
|
$token_o->delete($token_str);
|
||||||
$new_token = $subuser->generateSession();
|
$new_token = $subuser->generateSession();
|
||||||
$response->success(['session' => $new_token]);
|
$response->success(['session' => $new_token]);
|
||||||
@@ -626,6 +639,9 @@ class authRoute
|
|||||||
// For security reasons, don't reveal if the user exists
|
// For security reasons, don't reveal if the user exists
|
||||||
$response->success(['message' => 'If the customer exists, a password reset email has been sent.']);
|
$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();
|
$email_address = $user->email->value();
|
||||||
if (empty($email_address)) {
|
if (empty($email_address)) {
|
||||||
@@ -746,8 +762,10 @@ class authRoute
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($subuser !== null) {
|
if ($subuser !== null) {
|
||||||
$user_id = (int)$subuser->id;
|
if (!account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) {
|
||||||
$allowCredentials = $this->passkeyAllowCredentials($user_id, true);
|
$user_id = (int)$subuser->id;
|
||||||
|
$allowCredentials = $this->passkeyAllowCredentials($user_id, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} elseif ($customer_number !== null) {
|
} elseif ($customer_number !== null) {
|
||||||
$principal_type = 'user';
|
$principal_type = 'user';
|
||||||
@@ -759,8 +777,10 @@ class authRoute
|
|||||||
$user = (new users_o())->getUserByCustomerNumber($customer_number);
|
$user = (new users_o())->getUserByCustomerNumber($customer_number);
|
||||||
|
|
||||||
if ($user->exists()) {
|
if ($user->exists()) {
|
||||||
$user_id = (int)$user->id;
|
if (!account_deletion_service::principalIsBlocked('customer', (int)$user->id)) {
|
||||||
$allowCredentials = $this->passkeyAllowCredentials($user_id, false);
|
$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
|
// Success → issue session token accordingly and delete the challenge token
|
||||||
$issued_to_user_id = (int)$passkey->user_id->value();
|
$issued_to_user_id = (int)$passkey->user_id->value();
|
||||||
$is_subuser = (bool)$passkey->is_subuser->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 (
|
if (
|
||||||
($challengePrincipalType === 'subuser' && !$is_subuser)
|
($challengePrincipalType === 'subuser' && !$is_subuser)
|
||||||
|| ($challengePrincipalType === 'user' && $is_subuser)
|
|| ($challengePrincipalType === 'user' && $is_subuser)
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ class intimidateRoute
|
|||||||
// Log the incident
|
// Log the incident
|
||||||
(new logs_o())->add('auth', 'global', 1, $user->id, 'AUTH_SUCCESS_INTIMIDATE', 'Created intimidate token for customer: ' . $data['user_id']);
|
(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.)
|
// 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
|
// Return the token
|
||||||
$response->success(['token' => $token]);
|
$response->success(['token' => $token]);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace routes;
|
namespace routes;
|
||||||
|
|
||||||
use classes\authentication;
|
use classes\authentication;
|
||||||
|
use classes\account_deletion_service;
|
||||||
use classes\economic;
|
use classes\economic;
|
||||||
use classes\email;
|
use classes\email;
|
||||||
use classes\gatewayapi;
|
use classes\gatewayapi;
|
||||||
@@ -386,6 +387,10 @@ class subusersRoute
|
|||||||
{
|
{
|
||||||
global $response;
|
global $response;
|
||||||
|
|
||||||
|
if (account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) {
|
||||||
|
$response->error('Chauffeur account is unavailable', 409);
|
||||||
|
}
|
||||||
|
|
||||||
$customerNumber = $this->resolveDirectLoginCustomerNumber($subuser);
|
$customerNumber = $this->resolveDirectLoginCustomerNumber($subuser);
|
||||||
try {
|
try {
|
||||||
$sessionToken = $subuser->generateSession();
|
$sessionToken = $subuser->generateSession();
|
||||||
@@ -770,8 +775,17 @@ class subusersRoute
|
|||||||
$response->error('Invalid credentials', 401);
|
$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
|
private function issueSetupInvite(subusers_o $subuser): array
|
||||||
{
|
{
|
||||||
|
$this->rejectBlockedSubuser((int)$subuser->id);
|
||||||
if (!$subuser->requiresSetup()) {
|
if (!$subuser->requiresSetup()) {
|
||||||
return [
|
return [
|
||||||
'setup_token' => null,
|
'setup_token' => null,
|
||||||
@@ -1455,6 +1469,7 @@ class subusersRoute
|
|||||||
global $response;
|
global $response;
|
||||||
|
|
||||||
$grant = $this->getGrantForScopedUserOrFail($grantId, $customerNumber);
|
$grant = $this->getGrantForScopedUserOrFail($grantId, $customerNumber);
|
||||||
|
$this->rejectBlockedSubuser((int)$grant->subuser->value());
|
||||||
$updates = [];
|
$updates = [];
|
||||||
$templateAccess = $this->parseAccessTemplatePayload();
|
$templateAccess = $this->parseAccessTemplatePayload();
|
||||||
if ($templateAccess !== null) {
|
if ($templateAccess !== null) {
|
||||||
@@ -1735,6 +1750,7 @@ class subusersRoute
|
|||||||
$subuser_id = (int)self::getParameter('subuser_id');
|
$subuser_id = (int)self::getParameter('subuser_id');
|
||||||
self::requireType($customer_number, self::type_int());
|
self::requireType($customer_number, self::type_int());
|
||||||
self::requireType($subuser_id, self::type_int());
|
self::requireType($subuser_id, self::type_int());
|
||||||
|
$this->rejectBlockedSubuser($subuser_id);
|
||||||
|
|
||||||
if (!self::hasPermission($permission_other, (int)$customer_number)) {
|
if (!self::hasPermission($permission_other, (int)$customer_number)) {
|
||||||
$this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_ADD, (int)$customer_number);
|
$this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_ADD, (int)$customer_number);
|
||||||
@@ -1793,6 +1809,7 @@ class subusersRoute
|
|||||||
if (!$grant->exists()) {
|
if (!$grant->exists()) {
|
||||||
$response->error('Grant not found', 404);
|
$response->error('Grant not found', 404);
|
||||||
}
|
}
|
||||||
|
$this->rejectBlockedSubuser((int)$grant->subuser->value());
|
||||||
|
|
||||||
$targetCustomer = (int)$grant->billing_customer_number->value();
|
$targetCustomer = (int)$grant->billing_customer_number->value();
|
||||||
if (!self::hasPermission($permission_other, $targetCustomer)) {
|
if (!self::hasPermission($permission_other, $targetCustomer)) {
|
||||||
@@ -2103,6 +2120,9 @@ class subusersRoute
|
|||||||
if ($subuser === null) {
|
if ($subuser === null) {
|
||||||
$this->subuserAuthFailure();
|
$this->subuserAuthFailure();
|
||||||
}
|
}
|
||||||
|
if (account_deletion_service::principalIsBlocked('subuser', (int)$subuser->id)) {
|
||||||
|
$this->subuserAuthFailure();
|
||||||
|
}
|
||||||
self::requireParameters(['password']);
|
self::requireParameters(['password']);
|
||||||
$password = (string)self::getParameter('password');
|
$password = (string)self::getParameter('password');
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,519 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
usesApiSuite();
|
||||||
|
|
||||||
|
function enableAccountDeletionForApiTest(): void
|
||||||
|
{
|
||||||
|
api_fixtures()->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();
|
||||||
|
});
|
||||||
@@ -6,6 +6,8 @@ return [
|
|||||||
'openapi_operations' => [
|
'openapi_operations' => [
|
||||||
'POST /auth/login',
|
'POST /auth/login',
|
||||||
'GET /auth/session',
|
'GET /auth/session',
|
||||||
|
'GET /account/deletion',
|
||||||
|
'POST /account/deletion',
|
||||||
'GET /auth/logout',
|
'GET /auth/logout',
|
||||||
'GET /departments',
|
'GET /departments',
|
||||||
'POST /departments',
|
'POST /departments',
|
||||||
|
|||||||
@@ -1054,6 +1054,10 @@ final class ApiFixtures
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$this->cleanup->add(function () use ($subuserId): void {
|
$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('subuser_grants', ['subuser' => $subuserId]);
|
||||||
$this->deleteWhere('tokens', ['user_id' => $subuserId, 'type' => 'AUTH_TOKEN_SUBUSER']);
|
$this->deleteWhere('tokens', ['user_id' => $subuserId, 'type' => 'AUTH_TOKEN_SUBUSER']);
|
||||||
$this->deleteById('subusers', $subuserId);
|
$this->deleteById('subusers', $subuserId);
|
||||||
@@ -1839,6 +1843,10 @@ final class ApiFixtures
|
|||||||
|
|
||||||
private function purgeCustomerTraceData(int $userId, int $customerNumber): void
|
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', [
|
$invoiceCollectionIds = $this->fetchIntColumnWhere('collected_order_invoices', 'id', [
|
||||||
'customer_number' => $customerNumber,
|
'customer_number' => $customerNumber,
|
||||||
]);
|
]);
|
||||||
@@ -1874,6 +1882,7 @@ final class ApiFixtures
|
|||||||
|
|
||||||
$this->deleteWhereIfPossible('customer_attributes', ['user_id' => $userId]);
|
$this->deleteWhereIfPossible('customer_attributes', ['user_id' => $userId]);
|
||||||
$this->deleteWhereIfPossible('tokens', ['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('user_key_value_pairs', ['user_id' => $userId]);
|
||||||
$this->deleteWhereIfPossible('price_overrides', ['user_id' => $userId]);
|
$this->deleteWhereIfPossible('price_overrides', ['user_id' => $userId]);
|
||||||
$this->deleteWhereIfPossible('department_customer_price_overrides', ['user_id' => $userId]);
|
$this->deleteWhereIfPossible('department_customer_price_overrides', ['user_id' => $userId]);
|
||||||
@@ -1892,6 +1901,7 @@ final class ApiFixtures
|
|||||||
'object_id' => $userId,
|
'object_id' => $userId,
|
||||||
]);
|
]);
|
||||||
$this->deleteWhereIfPossible('bookings', ['customer_number' => $customerNumber]);
|
$this->deleteWhereIfPossible('bookings', ['customer_number' => $customerNumber]);
|
||||||
|
$this->deleteWhereIfPossible('order_bookings', ['customer_number' => $customerNumber]);
|
||||||
$this->deleteWhereIfPossible('orders', ['customer_id' => $customerNumber]);
|
$this->deleteWhereIfPossible('orders', ['customer_id' => $customerNumber]);
|
||||||
$this->deleteWhereIfPossible('customer_vehicles', ['customer_id' => $customerNumber]);
|
$this->deleteWhereIfPossible('customer_vehicles', ['customer_id' => $customerNumber]);
|
||||||
$this->deleteWhereIfPossible('collected_order_invoices', ['customer_number' => $customerNumber]);
|
$this->deleteWhereIfPossible('collected_order_invoices', ['customer_number' => $customerNumber]);
|
||||||
|
|||||||
@@ -831,9 +831,58 @@ CREATE TABLE IF NOT EXISTS `subusers` (
|
|||||||
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
`suspended_at` DATETIME NULL,
|
`suspended_at` DATETIME NULL,
|
||||||
|
`deleted_at` DATETIME NULL,
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
KEY `idx_subusers_username` (`username`),
|
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
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
SQL,
|
SQL,
|
||||||
'subuser_grants' => <<<'SQL'
|
'subuser_grants' => <<<'SQL'
|
||||||
@@ -867,6 +916,20 @@ CREATE TABLE IF NOT EXISTS `tokens` (
|
|||||||
KEY `idx_tokens_user_id` (`user_id`),
|
KEY `idx_tokens_user_id` (`user_id`),
|
||||||
KEY `idx_tokens_type` (`type`)
|
KEY `idx_tokens_type` (`type`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) 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,
|
SQL,
|
||||||
'passkeys' => <<<'SQL'
|
'passkeys' => <<<'SQL'
|
||||||
CREATE TABLE IF NOT EXISTS `passkeys` (
|
CREATE TABLE IF NOT EXISTS `passkeys` (
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
it('wires self-service deletion, authentication guards, and the scheduled worker', function (): void {
|
||||||
|
$root = dirname(__DIR__, 3);
|
||||||
|
$route = (string)file_get_contents($root . '/routes/accountDeletionRoute.php');
|
||||||
|
$intimidateRoute = (string)file_get_contents($root . '/routes/intimidateRoute.php');
|
||||||
|
$service = (string)file_get_contents($root . '/classes/account_deletion_service.php');
|
||||||
|
$authentication = (string)file_get_contents($root . '/classes/authentication.php');
|
||||||
|
$subusers = (string)file_get_contents($root . '/objects/subusers_o.php');
|
||||||
|
$passkeys = (string)file_get_contents($root . '/objects/passkeys_o.php');
|
||||||
|
$passwordReset = (string)file_get_contents($root . '/objects/customer_password_reset_keys_o.php');
|
||||||
|
$tasks = (string)file_get_contents($root . '/modules/account/cron/tasks.php');
|
||||||
|
$cron = (string)file_get_contents($root . '/cron/Cron.php');
|
||||||
|
|
||||||
|
expect($route)
|
||||||
|
->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");
|
||||||
|
});
|
||||||
@@ -51,6 +51,11 @@ if (!class_exists('PasswordResetTokenExpiryProbe')) {
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function selectedCustomerCanResetPassword(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public function forceSelectedId(int $id): void
|
public function forceSelectedId(int $id): void
|
||||||
{
|
{
|
||||||
$this->id = $id;
|
$this->id = $id;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ it('discovers module-owned cron task definitions', function (): void {
|
|||||||
$registry = new cron_task_registry(app_path('modules'));
|
$registry = new cron_task_registry(app_path('modules'));
|
||||||
$definitions = $registry->definitions();
|
$definitions = $registry->definitions();
|
||||||
|
|
||||||
expect($definitions)->toHaveCount(22);
|
expect($definitions)->toHaveCount(23);
|
||||||
expect(array_keys($definitions))->toContain(
|
expect(array_keys($definitions))->toContain(
|
||||||
'system.sync_logs',
|
'system.sync_logs',
|
||||||
'backups.process_jobs',
|
'backups.process_jobs',
|
||||||
@@ -16,6 +16,7 @@ it('discovers module-owned cron task definitions', function (): void {
|
|||||||
'dynamicimages.pre_render',
|
'dynamicimages.pre_render',
|
||||||
'weatherapi.preload_department_responses',
|
'weatherapi.preload_department_responses',
|
||||||
'goals.progress_alerts',
|
'goals.progress_alerts',
|
||||||
|
'account.process_deletion_requests',
|
||||||
'selfserve.activate_opening_cleaner_relays'
|
'selfserve.activate_opening_cleaner_relays'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user