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:
Jeppe B
2026-07-22 19:22:17 +02:00
committed by GitHub
parent 34cf804d75
commit 0060fb45ca
25 changed files with 2429 additions and 16 deletions
+150
View File
@@ -2579,6 +2579,156 @@ paths:
'401':
$ref: '#/components/responses/Unauthorized'
/account/deletion:
get:
tags:
- Security
summary: Describe account deletion requirements
description: Returns the authenticated customer or chauffeur deletion state, required confirmation phrase, and categories retained for legal obligations.
operationId: getAccountDeletion
responses:
'200':
description: Account deletion requirements retrieved successfully
content:
application/json:
schema:
type: object
required:
- principal_type
- status
- confirmation_phrase
- password_required
- two_factor_required
- access_effect
- retained_data_categories
- privacy_policy_version
properties:
principal_type:
type: string
enum: [customer, subuser]
status:
type: string
enum: [available, requested, processing, failed, manual_review, completed]
confirmation_phrase:
type: string
enum: [SLET MIN KONTO]
password_required:
type: boolean
description: False for authenticated passkey-only accounts that have no password.
two_factor_required:
type: boolean
access_effect:
type: string
retained_data_categories:
type: array
items:
type: string
enum: [invoices_payments_accounting, orders_wash_history, security_audit_logs, legal_obligations, customer_reference, driver_reference]
privacy_policy_version:
type: string
request_id:
type: string
format: uuid
nullable: true
requested_at:
type: string
format: date-time
nullable: true
'401':
$ref: '#/components/responses/Unauthorized'
'500':
$ref: '#/components/responses/InternalServerError'
post:
tags:
- Security
summary: Request deletion of the authenticated account
description: Reauthenticates the principal, records an auditable deletion request, and revokes access immediately. A background worker subsequently anonymizes personal account fields while preserving legally required history.
operationId: requestAccountDeletion
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- confirmation
- acknowledge_legal_retention
properties:
password:
type: string
format: password
description: Required when password_required is true; omit for passkey-only accounts.
passkey_challenge_token:
type: string
description: Required for passwordless accounts; issued only by the deletion-specific challenge endpoint.
passkey_credential:
type: object
description: Fresh WebAuthn assertion bound to passkey_challenge_token and the authenticated principal.
two_factor_code:
type: string
description: Required when two-factor authentication is enabled.
confirmation:
type: string
enum: [SLET MIN KONTO]
acknowledge_legal_retention:
type: boolean
enum: [true]
responses:
'202':
description: Deletion request accepted and account access revoked
content:
application/json:
schema:
type: object
required:
- request_id
- status
- requested_at
- access_revoked
- retained_data_categories
properties:
request_id:
type: string
format: uuid
status:
type: string
enum: [requested, processing, failed, manual_review, completed]
requested_at:
type: string
format: date-time
access_revoked:
type: boolean
enum: [true]
retained_data_categories:
type: array
items:
type: string
enum: [invoices_payments_accounting, orders_wash_history, security_audit_logs, legal_obligations, customer_reference, driver_reference]
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'429':
description: Too many deletion confirmation attempts
'500':
$ref: '#/components/responses/InternalServerError'
/account/deletion/passkey/challenge:
post:
tags: [Security]
summary: Create a deletion-specific WebAuthn challenge
description: Creates a short-lived, single-use challenge bound to the authenticated passwordless principal. A normal sign-in assertion cannot authorize deletion.
operationId: createAccountDeletionPasskeyChallenge
responses:
'200':
description: Deletion-specific challenge created
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'500':
$ref: '#/components/responses/InternalServerError'
/auth/2fa/setup:
post:
tags: