## 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.
196 lines
7.0 KiB
PHP
196 lines
7.0 KiB
PHP
<?php
|
|
|
|
namespace objects;
|
|
|
|
use classes\account_deletion_service;
|
|
use classes\db;
|
|
use classes\object_property;
|
|
use Exception;
|
|
use modules\subusers\helpers\subusers_permission_node_key;
|
|
use Random\RandomException;
|
|
use traits\db_object_t;
|
|
|
|
class subuser_grants_o extends db
|
|
{
|
|
use db_object_t;
|
|
|
|
public object_property $billing_customer_number;
|
|
public object_property $subuser;
|
|
public object_property $assigned_vehicle_id;
|
|
public object_property $enabled;
|
|
public object_property $note;
|
|
public object_property $permissions;
|
|
public object_property $created_at;
|
|
public object_property $updated_at;
|
|
public object_property $deleted_at;
|
|
const defaultPermissions = [
|
|
'VEHICLES_LIST',
|
|
'SELFSERVE_LIST',
|
|
'SELFSERVE_ADD',
|
|
'BOOKINGS_LIST',
|
|
'BOOKINGS_ADD',
|
|
'ORDERS_LIST',
|
|
];
|
|
|
|
public static function normalizePermissionsValue(mixed $raw): array
|
|
{
|
|
if ($raw === null || $raw === '' || $raw === false || $raw === 0 || $raw === '0') {
|
|
return [];
|
|
}
|
|
|
|
if ($raw instanceof subusers_permission_node_key) {
|
|
return [$raw->name];
|
|
}
|
|
|
|
if (is_array($raw)) {
|
|
$permissions = [];
|
|
$permissionCandidates = array_is_list($raw)
|
|
? $raw
|
|
: array_keys(array_filter($raw, static fn ($enabled): bool => (bool)$enabled));
|
|
|
|
foreach ($permissionCandidates as $permission) {
|
|
if ($permission instanceof subusers_permission_node_key) {
|
|
$permission = $permission->name;
|
|
}
|
|
|
|
if (!is_string($permission)) {
|
|
continue;
|
|
}
|
|
|
|
$permission = strtoupper(trim($permission));
|
|
if ($permission !== '' && subusers_permission_node_key::tryFrom($permission) !== null) {
|
|
$permissions[] = $permission;
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique($permissions));
|
|
}
|
|
|
|
if (is_string($raw)) {
|
|
$decoded = json_decode($raw, true);
|
|
if (json_last_error() === JSON_ERROR_NONE) {
|
|
return self::normalizePermissionsValue($decoded);
|
|
}
|
|
|
|
$permission = strtoupper(trim($raw));
|
|
if (subusers_permission_node_key::tryFrom($permission) !== null) {
|
|
return [$permission];
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
|
|
public function structure(): void
|
|
{
|
|
$this->setTable('subuser_grants');
|
|
}
|
|
|
|
public function objectChanged(): void
|
|
{
|
|
// No need to invalidate the cache, since the plate_scans object is not cached
|
|
}
|
|
|
|
public function getObjectProperties(): void
|
|
{
|
|
$this->billing_customer_number = new object_property($this->table, $this->id, 'billing_customer_number', 'int');
|
|
$this->subuser = new object_property($this->table, $this->id, 'subuser', 'int');
|
|
$this->assigned_vehicle_id = new object_property($this->table, $this->id, 'assigned_vehicle_id', 'int');
|
|
$this->enabled = new object_property($this->table, $this->id, 'enabled', 'bool');
|
|
$this->note = new object_property($this->table, $this->id, 'note', 'string');
|
|
$this->permissions = new object_property($this->table, $this->id, 'permissions', 'json');
|
|
$this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp');
|
|
$this->updated_at = new object_property($this->table, $this->id, 'updated_at', 'timestamp');
|
|
$this->deleted_at = new object_property($this->table, $this->id, 'deleted_at', 'timestamp');
|
|
}
|
|
|
|
|
|
public function asArray(): array
|
|
{
|
|
return [
|
|
'id' => (int)$this->id,
|
|
'billing_customer_number' => (int)$this->billing_customer_number->value(),
|
|
'subuser' => (int)$this->subuser->value(),
|
|
'assigned_vehicle_id' => $this->assigned_vehicle_id->value() !== null ? (int)$this->assigned_vehicle_id->value() : null,
|
|
'enabled' => (bool)$this->enabled->value(),
|
|
'note' => $this->note->value(),
|
|
'permissions' => self::normalizePermissionsValue($this->permissions->value()),
|
|
'created_at' => $this->created_at->value(),
|
|
'updated_at' => $this->updated_at->value(),
|
|
'deleted_at' => $this->deleted_at->value(),
|
|
];
|
|
}
|
|
|
|
|
|
/**
|
|
* Add a new subuser grant to the database.
|
|
* @param int $billing_customer_number
|
|
* @param int $subuser
|
|
* @param bool $enabled
|
|
* @param string|null $note
|
|
* @param array|null $permissions
|
|
* @return $this
|
|
* @throws Exception If the object creation fails
|
|
*/
|
|
public function add(int $billing_customer_number, int $subuser, bool $enabled, ?string $note, ?array $permissions = self::defaultPermissions): subuser_grants_o
|
|
{
|
|
global $db;
|
|
if (account_deletion_service::principalIsBlocked('subuser', $subuser)) {
|
|
throw new Exception('Subuser account is unavailable');
|
|
}
|
|
$permissions = self::normalizePermissionsValue($permissions);
|
|
$tmp = $this->add_object([
|
|
'billing_customer_number' => (int)$billing_customer_number,
|
|
'subuser' => (int)$subuser,
|
|
'enabled' => (bool)$enabled,
|
|
'note' => !empty($note) ? $db->escape_string($note) : null,
|
|
'permissions' => json_encode($permissions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
]);
|
|
$this->id = (int)$tmp;
|
|
$this->getObjectProperties();
|
|
return $this;
|
|
}
|
|
|
|
public function getGrantsForSubuserAndCustomer(int $subuser_id, ?int $customer_number): array
|
|
{
|
|
$grants = self::getFieldsWhere([
|
|
'billing_customer_number' => $customer_number,
|
|
'subuser' => $subuser_id,
|
|
'enabled' => 1,
|
|
'deleted_at' => null,
|
|
], ['permissions']);
|
|
// Extract permissions from the grants
|
|
$permissions = [];
|
|
foreach ($grants as $grant) {
|
|
$grant_permissions = self::normalizePermissionsValue($grant['permissions'] ?? null);
|
|
if (is_array($grant_permissions)) {
|
|
$permissions = array_merge($permissions, $grant_permissions);
|
|
}
|
|
}
|
|
return array_values(array_unique($permissions));
|
|
}
|
|
|
|
public function getGrantForSubuserAndCustomer(int $subuser_id, int $customer_number, bool $includeDisabled = true): ?subuser_grants_o
|
|
{
|
|
$grants = self::getFieldsWhere([
|
|
'billing_customer_number' => $customer_number,
|
|
'subuser' => $subuser_id,
|
|
'deleted_at' => null,
|
|
], ['id', 'enabled']);
|
|
|
|
if (!$includeDisabled) {
|
|
$grants = array_values(array_filter($grants, static fn (array $grant): bool => (int)($grant['enabled'] ?? 0) === 1));
|
|
}
|
|
|
|
if (count($grants) === 0) {
|
|
return null;
|
|
}
|
|
|
|
usort($grants, static fn (array $left, array $right): int => (int)$right['id'] <=> (int)$left['id']);
|
|
$grant = (new subuser_grants_o())->select((int)$grants[0]['id']);
|
|
$grant->getObjectProperties();
|
|
return $grant;
|
|
}
|
|
}
|