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:

  1. auth-api hard-deletes the user (sessions, emails, API keys).
  2. user.deleted is emitted.
  3. billing-api removes the user’s Role row. Customer intact.
  4. 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.

  1. auth-api hard-deletes the user and emits user.deleted.
  2. billing-api sees “owner but not last owner” — removes the Role row only. The other owner retains full control.
  3. 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.

  1. auth-api hard-deletes the user and emits user.deleted.
  2. billing-api’s HandleUserDeleted sees “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; DeleteCustomerUsageKeys wipes every usage:*:customer:{id}:* key in Valkey.
    • A customer.deleted event is published.
  3. compute-api’s HandleUserDeleted deletes the deleted user’s own instances. compute-api’s HandleCustomerDeleted deletes 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):

  1. billing-api runs the same cascade as Scenario 3 (Stripe delete, Role rows, Customer row, Valkey keys, customer.deleted emitted — reason=owner_initiated).
  2. The owner’s auth account is NOT touched; they continue to exist as a user, now unaffiliated.
  3. compute-api’s HandleCustomerDeleted wipes 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"
}
  • gcid is the Stripe customer ID.
  • reason is observability-only; consumers MUST NOT branch on it.
  • triggeredBy is the UUID of the user whose action caused the customer deletion (the deleted user for owner_deleted, the calling owner for owner_initiated, the admin for admin_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.deleted webhook (for dashboard-side deletions) is an idempotent no-op when the local customer is already gone.