Short answer: revoke every credential and deny new work before deleting tenant data. Keep a non-secret tombstone and an append-only audit trail until the marketplace access review is signed, then let a retryable purge remove data in dependency order. Delete-first makes the account disappear from the control plane while tokens, queued jobs, or externally held credentials may still be usable; revoke-first puts refused traffic ahead of a clean database, which is the defensible trade when the alternative is unbounded post-termination spend.
The decision rule is strict: if a capability can cause a charge, mutation, or privileged read, its denial must be durably recorded before destructive deletion begins. A spend ceiling is not merely a billing preference here. It is an invariant that must survive retries, crashes, and late delivery.
Should tenant offboarding revoke access before it can delete orphan rows?
A tenant row is usually an ownership anchor, not the capability itself. API keys may have been copied into a workload, sessions may be cached, and jobs may already be waiting in a queue. Deleting the anchor first removes the easiest place to enumerate those capabilities. It can also erase the human-readable identity that an approver needs to decide whether the exit was complete.
The awkward outcome is an account that looks absent while work attributed to it still arrives. Refusing that work is correct. Losing the evidence needed to explain the refusal is not. In a marketplace, this distinction reaches directly into reconciliation: an accepted operation can create a seller obligation, a buyer-visible state change, or a fee, so the offboarding boundary must be expressed as an authorization state rather than inferred from whether a parent row happens to exist.
Order is the control.
Use three invariants:
- After the revocation commit, no new tenant-authorized operation may be admitted.
- Every accepted operation has an immutable tenant subject and idempotency key, even after the mutable tenant profile is gone.
- A reviewer can connect the request, revocation result, deletion result, exceptions, and actor without recovering a secret.
The third invariant matters because OWASP recommends logging who requested a secret, for which system, whether it was approved, when it was used, and when it expired, while explicitly warning that secrets must never be logged. An access review that contains only tenant deleted cannot establish those facts. Store key identifiers or one-way fingerprints, not credential material.
Decision record: separate authority from retention
The chosen design is a small state machine with active, revoking, revoked, and purging states. The authorization path treats every state except active as denied. Revocation rotates or disables credentials in each authority domain, records individual outcomes, and only then commits the revoked transition. Purging runs later and may be retried independently.
This is not an exactly-once delivery claim. Networks and workers do not provide one. The exactly-once mindset belongs in the effect: a stable operation ID, a uniqueness constraint on each revocation action, and compare-and-set state transitions make duplicate delivery converge on one recorded result.
| Order | Security boundary | Failure after first step | Review quality | Best fit |
|---|---|---|---|---|
| Revoke, then purge | Explicit denial state | Data remains retained but inaccessible while cleanup retries | Preserves identity and per-capability evidence | Credentials can spend, mutate, or disclose data |
| Delete, then revoke | Missing tenant record | Capabilities may outlive the record used to find them | Correlation depends on detached records being complete | Deletion atomically destroys every capability |
| One local transaction | Database commit | External authorities can still be uncertain | Complete only for database-owned capabilities | A single transactional authority domain |
The first option deliberately accepts temporary retention and refused traffic. That cost is bounded by a purge deadline and observable backlog. The competing risk, accepting one more operation after exit, does not have a useful ceiling because downstream settlement can already have begun. For a marketplace with a hard spend ceiling, denial wins.
The trade-off is visible during a concrete review. Suppose the inventory has three capability classes: interactive sessions, workload credentials, and queued delegated jobs. The reviewer should see one terminal result for every discovered capability, while requests arriving after the boundary appear as denials rather than new marketplace effects. A missing result keeps the review open; a duplicate worker delivery reuses the operation ID; a retained settlement record points to the pseudonymous subject rather than restoring the deleted profile. This example does not prescribe a universal inventory size, but it shows what the signer compares and why a bare count of deleted rows is weak evidence.
A reviewer should sign evidence, not a green badge. The review packet needs the tenant subject, offboarding request ID, requested and effective times, actor, enumerated capability IDs, outcome for each authority, outstanding purge classes, retention deadline, and audit-chain reference. It must omit secrets and avoid copying personal profile data that the purge is meant to remove.
The critical path in Go
The handler below models the local consistency boundary. Revoke must be idempotent at each authority: a repeated call for the same operation either returns the previously recorded outcome or confirms that the capability is already disabled. BeginRevocation must reject new admissions before any remote call starts; otherwise a slow revocation fan-out leaves an acceptance window.
package offboarding
import (
"context"
"errors"
"time"
)
type Capability struct {
ID, Authority string
}
type Store interface {
BeginRevocation(context.Context, string, string, string, time.Time) ([]Capability, error)
RecordRevoked(context.Context, string, string, string, time.Time) error
FinishRevocation(context.Context, string, string, time.Time) error
EnqueuePurge(context.Context, string, string) error
}
type Revoker interface {
Revoke(context.Context, string, string, string) error
}
type Service struct {
store Store
revoker Revoker
now func() time.Time
}
func (s Service) Offboard(ctx context.Context, tenantID, operationID, actor string) error {
if tenantID == "" || operationID == "" || actor == "" {
return errors.New("tenant, operation, and actor are required")
}
capabilities, err := s.store.BeginRevocation(ctx, tenantID, operationID, actor, s.now())
if err != nil {
return err
}
for _, capability := range capabilities {
if err := s.revoker.Revoke(ctx, capability.Authority, capability.ID, operationID); err != nil {
return err
}
if err := s.store.RecordRevoked(ctx, tenantID, operationID, capability.ID, s.now()); err != nil {
return err
}
}
if err := s.store.FinishRevocation(ctx, tenantID, operationID, s.now()); err != nil {
return err
}
return s.store.EnqueuePurge(ctx, tenantID, operationID)
}
One detail is intentionally severe: a failed authority stops progress to revoked. The worker retries with the same operation ID, while the admission path continues to refuse traffic because the tenant is already revoking. The review remains unsigned until every required capability has a terminal result.
No guesswork.
The purge worker should receive only identifiers, not a snapshot of the tenant object. It deletes leaf records according to declared retention classes, verifies that no purgeable children remain, and finally replaces the mutable account with a minimal tombstone. Ledger or settlement records that must remain should reference a stable, pseudonymous subject rather than depend on the mutable tenant row through a cascading foreign key. This prevents referential cleanup from becoming accidental financial history deletion.
Failure boundaries, tests, and operations
The boundary between admission denial and credential revocation deserves the most testing. Pause the worker after BeginRevocation, then prove that a request bearing a formerly valid credential is refused. Repeat the same offboarding operation twice and prove that each capability has one effective revocation result. Crash after a remote revocation but before its local receipt is recorded; the retry must reconcile the already-disabled capability without inventing a second action.
Also test an operation admitted immediately before the state transition. Its idempotency record must decide its fate: a retry may return the original result, but it must not create a second marketplace effect. This is where an apparently tidy tenant_id foreign key with cascade deletion becomes dangerous. If deleting the account removes the idempotency record, a late retry can look new.
Operationally, watch state age rather than raw job counts. Alert on tenants stuck in revoking, capabilities without terminal outcomes, purge deadlines exceeded, denied requests after the effective time, and any accepted request after that time. The last condition is a correctness breach; the others are workflow failures that can be repaired without reopening access.
The limitation is deliberate: revoke-first can retain tenant-linked data longer than delete-first, and an unavailable external authority can hold the workflow in revoking. It is a poor fit when deletion is legally required at a deadline that cannot accommodate that wait. In that case, isolate the minimum audit subject from purgeable profile data, define the retention basis before deployment, and escalate unresolved authority receipts instead of silently treating deletion as proof of revocation.
Deployment needs the same ordering as runtime. First teach every admission point to honor the offboarding state. Next backfill stable tenant subjects and capability inventory. Then enable revocation and purge workers. Turning on destructive cleanup before all admission paths understand revoking merely moves the race into production.
The access review can now be compact: compare the capability inventory captured at the boundary with terminal revocation receipts, confirm that no post-boundary operation was accepted, and list retained record classes with their deletion or retention basis. The signer is approving explicit evidence and declared exceptions, not trusting absence in an admin screen.
Rejected option and its legitimate use
Delete-first was rejected for this marketplace because deletion and revocation do not share one authority boundary, while accepted traffic can create financial obligations. It also weakens the review by removing the primary correlation record before the reviewer sees the final capability inventory.
There is a legitimate delete-first case. If the tenant row and every usable capability live in the same transactional database, all authorization checks fail closed on a missing row, no asynchronous job carries delegated authority, and a single transaction deletes capabilities before the parent through enforced constraints, deletion can itself be the revocation mechanism. Prove those premises with concurrency tests and schema inspection. Do not assume them from a diagram.
For the broader case, keep authority shutdown and data erasure as separate, monotonic operations. The former protects the spend ceiling immediately; the latter satisfies the retention plan without destroying the evidence required for a credible sign-off.
Top comments (0)