Account Deletion Cascade
Deleting a ThreatWinds user account triggers a cross-service cleanup. This page documents exactly what happens, in what order, across auth-api, billing-api, and compute-api, so consumers know which resources survive and which are swept up.
Per-role behavior (when a user is deleted)
When a user is deleted — either via DELETE /auth/v2/user (self) or DELETE /auth/v2/admin/user/{id} (admin) — billing-api iterates every customer the user was a member of and applies one of three paths, independently per customer:
| User’s role on this customer | Other owners exist? | Action |
|---|---|---|
| Owner | Yes | Only the user’s Role row is removed. The customer, its subscription, other members, and all their compute instances are untouched. The deleted user’s own compute instances are still cascade-deleted (see below). |
| Owner | No (last owner) | Full customer cascade. Stripe customer is deleted (cancels the subscription as a side effect), all Role rows are deleted, the Customer row is deleted, customer Valkey usage counters are wiped, and a customer.deleted event fires which wipes every compute instance under the customer. |
| Admin or User | n/a | Only the Role row is removed. The deleted user’s own compute instances are still cascade-deleted. |
Memberships are evaluated independently: a user who is last-owner of customer A and plain User of customer B gets the full cascade on A and only a role removal on B, from a single deletion event.
Scenario walkthroughs
Scenario 1 — Non-owner member with compute instances
A user holds User or Admin role on one customer and owns 3 compute instances.
On DELETE /auth/v2/user:
- auth-api hard-deletes the user (sessions, emails, API keys).
user.deletedis emitted.- billing-api removes the user’s Role row. Customer intact.
- compute-api deletes the user’s 3 instances and reconciles the customer’s quota counter to the remaining fleet count.
Final state: user gone, 3 instances gone, customer and its other members unchanged.
Scenario 2 — Owner with co-owners
A user is Owner of a customer with another Owner on the same account.
- auth-api hard-deletes the user and emits
user.deleted. - billing-api sees “owner but not last owner” — removes the Role row only. The other owner retains full control.
- compute-api still cascade-deletes the deleted user’s own instances (if any).
Final state: customer persists under the surviving owner; only the deleted user’s personal resources are removed.
Scenario 3 — Last owner deletion
A customer has 1 Owner, 2 Admins, and a mix of compute instances. The Owner’s account is deleted.
- auth-api hard-deletes the user and emits
user.deleted. - billing-api’s
HandleUserDeletedsees “last owner” and runs the full cascade:- Stripe
customer.Del— cancels the subscription as a side effect. - All Role rows for the customer are deleted.
- The Customer row is deleted;
DeleteCustomerUsageKeyswipes everyusage:*:customer:{id}:*key in Valkey. - A
customer.deletedevent is published.
- Stripe
- compute-api’s
HandleUserDeleteddeletes the deleted user’s own instances. compute-api’sHandleCustomerDeleteddeletes every remaining instance under the customer (the Admins’ instances).
Final state: customer gone, every instance gone, Admins lose access to the customer but their own auth accounts survive.
Scenario 4 — Admin force-deletes a user who is last owner
Identical cascade to Scenario 3. The entry point is DELETE /auth/v2/admin/user/{id} instead of the user’s own DELETE /user; triggeredBy in events is the admin’s userID.
Scenario 5 — Owner calls DELETE /billing/v1/customer
The owner deletes the customer directly (without deleting their own user account):
- billing-api runs the same cascade as Scenario 3 (Stripe delete, Role rows, Customer row, Valkey keys,
customer.deletedemitted —reason=owner_initiated). - The owner’s auth account is NOT touched; they continue to exist as a user, now unaffiliated.
- compute-api’s
HandleCustomerDeletedwipes every instance under the customer.
Scenario 6 — billing_admin force-deletes a customer
DELETE /api/billing/v1/admin/customer/{id} is gated by the billing_admin role. Identical cascade to Scenario 5; reason=admin_forced, triggeredBy=adminUserID. No user account is touched.
Scenario 7 — compute_admin force-deletes an instance
DELETE /api/compute/v1/admin/instances/{id} is gated by the compute_admin role. Synchronous: the GCP VM is deleted, the Datastore row is removed, and the quota counter is released against the instance’s customer. No events fire — scoped to a single resource. 204 on success, 404 if the instance does not exist.
Events
Two ThreatWinds Lifecycle Events (LCE) drive the cascade.
user.deleted
Published by auth-api on hard-delete of a user (either DELETE /auth/v2/user or DELETE /auth/v2/admin/user/{id}).
{
"userID": "uuid",
"fullName": "string",
"alias": "string"
}
Partition key: user:<userID>.
customer.deleted
Published by billing-api from the deleteCustomer cascade helper — triggered by any of the last-owner user-delete path, owner-initiated DELETE /customer, or admin-forced DELETE /admin/customer/{id}.
{
"customerId": "uuid",
"gcid": "cus_XXXXXXXX",
"reason": "owner_deleted | owner_initiated | admin_forced",
"triggeredBy": "uuid"
}
gcidis the Stripe customer ID.reasonis observability-only; consumers MUST NOT branch on it.triggeredByis the UUID of the user whose action caused the customer deletion (the deleted user forowner_deleted, the calling owner forowner_initiated, the admin foradmin_forced).
Partition key: customer:<customerId>.
Recovery from event loss
LCE runs on Valkey pub/sub (fire-and-forget). If a compute-api consumer is down during a cascade, the event is lost and instances can be orphaned. Recovery paths:
| Situation | Recovery tool |
|---|---|
| Orphan instances under an existing customer | DELETE /api/compute/v1/admin/instances/{id} (compute_admin) |
| Orphan customer record (e.g. pre-cascade state) | DELETE /api/billing/v1/admin/customer/{id} (billing_admin) |
Both admin endpoints run the standard cleanup path and are idempotent — calling them against an already-deleted resource returns a 404, not an error state.
Idempotency
Every handler on the cascade path is designed to be safe on redelivery:
- billing-api
HandleUserDeleted— an empty membership list is a no-op. - billing-api
deleteCustomer— a missing customer returns the stored 404 without mutation; Stripe 404 is treated as success. - compute-api
HandleUserDeleted— a user with no remaining instances is a no-op. - compute-api
HandleCustomerDeleted— a customer with no remaining instances is a no-op. - Stripe’s own
customer.deletedwebhook (for dashboard-side deletions) is an idempotent no-op when the local customer is already gone.