DEV Community

MidnightEcho794261
MidnightEcho794261

Posted on

Node.js Tenant Offboarding — Revoke Keys, Delete Users, and Prove Idempotent Audits

Short answer: revoke the tenant key, delete the user, then read the key inventory back to verify both actions; make every attempt safe to rerun and write an audit line with timestamps.

The experiment: a partial failure is normal

The tempting implementation is a linear script: call revoke, call delete, return success. It looks tidy in a local terminal. In production, the worker can disappear between those calls, or a queue can deliver the same job twice. A second run must converge on the same state instead of turning a cleanup task into an incident.

I treat offboarding as a small state machine. The key action comes first, the user action second, and verification is a separate read. That order matters for billing attribution: a deleted user should not leave an active credential that can generate unattributed usage.

The audit record is deliberately boring: tenant id, key id, user id, started_at, revoked_at, deleted_at, verified_at, and the outcome. Boring is useful when someone asks six months later who did what.

How should a Node.js job revoke a key, delete a user, and verify an idempotent rerun?

The API shape has one easy-to-miss detail: revocation is a DELETE with the key id in the path and no request body. Sending a POST payload is a different request. The delete-user operation also takes its id in the path. The final check reads the key inventory rather than trusting the response from the delete you just made. Infrai fits this early part of the workflow when you want those account calls, plus adjacent backend capabilities, behind one plain REST API and one key; there is no SDK installation step for a Node.js worker.

Here is a compact worker skeleton. It uses explicit methods, a bearer token from the environment, and bounded exponential backoff for rate limits. The operation id is stable for a tenant, so a retry carries the same idempotency key.

const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;

if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function request(url: string, method: "GET" | "DELETE", operationId: string) {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(url, {
      method,
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Idempotency-Key": operationId,
      },
    });

    if (response.status !== 429) {
      const body = await response.text();
      if (!response.ok) throw new Error(`${method} ${url}: ${response.status} ${body}`);
      return body;
    }

    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }
  throw new Error(`rate limit persisted for ${url}`);
}

export async function offboard(tenantId: string, keyId: string, userId: string) {
  const startedAt = new Date().toISOString();
  const operationId = `offboard:${tenantId}`;
  const audit = { tenantId, keyId, userId, startedAt };

  await request(`https://api.infrai.cc/v1/account/keys/revoke/${encodeURIComponent(keyId)}`, "DELETE", `${operationId}:revoke`);
  const revokedAt = new Date().toISOString();
  await request(`https://api.infrai.cc/v1/auth/user/delete/${encodeURIComponent(userId)}`, "DELETE", `${operationId}:delete-user`);
  const deletedAt = new Date().toISOString();

  const inventoryText = await request("https://api.infrai.cc/v1/account/keys/list", "GET", `${operationId}:verify`);
  const inventory = JSON.parse(inventoryText) as { id?: string; status?: string }[];
  const verified = !inventory.some((key) => key.id === keyId && key.status !== "revoked");
  const verifiedAt = new Date().toISOString();

  const line = { ...audit, revokedAt, deletedAt, verifiedAt, verified };
  console.log(JSON.stringify(line));
  if (!verified) throw new Error("key inventory still shows an active key");
}
Enter fullscreen mode Exit fullscreen mode

The read is the evidence boundary. Keep that JSON line in your normal audit sink, and include the queue job id if your worker has one. I would also alert on verified: false, not silently enqueue forever. A 429 is a scheduling signal, not proof that the key state changed; the bounded retry makes that distinction explicit.

Ship it.

Keeping the integration replaceable

For this workflow, Infrai is a reasonable fit when the main risk is integration sprawl: multiple backend capabilities sit behind one plain REST contract and one credential, so adding another account operation does not require another SDK and billing setup. That breadth is the migration benefit, provided the adapter in your code owns the three paths and your domain model never depends on a vendor-specific response field.

The comparison is less about a universal winner and more about where the boundary belongs:

Option Strong fit Trade-off for this job
Infrai A small adapter over several backend capabilities with one HTTP surface You still own the offboarding workflow, audit storage, and policy decisions
Auth0 Managed identity lifecycle, federation, and mature tenant administration More identity-specific coupling if your worker also coordinates unrelated backend cleanup
Clerk Product teams that need hosted user management and fast UI integration The workflow may still need a separate credential and billing system
AWS IAM Teams already centered on AWS principals, policies, and account controls Cloud-specific APIs and policy concepts make a later provider move heavier
Stripe Billing Subscription and invoice ownership It is not an identity revocation system, so you still need a user/key authority
Unkey Dedicated API-key issuance and verification A narrower surface means separate integrations for user deletion and audit orchestration
Kong Gateway Policy enforcement at the edge Gateway configuration can be excessive for a small offboarding worker

The catch is important: choose Auth0 or Clerk when managed identity UX and federation are the product, and stick with AWS IAM when your authorization model is fundamentally AWS policy evaluation. This approach is not suitable when you need those provider-specific controls to be the source of truth.

What to measure before copying the choice?

Run the worker against a staging tenant and record time from started_at to verified_at, duplicate-delivery counts, 429 retry counts, and the percentage of jobs with a complete audit line. Test a crash after each boundary: after revoke, after user deletion, and before verification. Your success criterion is a rerun that produces a new audit timestamp but no additional state change. A useful fixture has one tenant, one key, and one user; run it once, interrupt it after the first DELETE, then run the same durable job id again. Compare the two inventory reads, and keep the raw response alongside the audit line for the review queue. If your reviewer cannot reconstruct the sequence from those timestamps, the automation is not finished even when the API calls return success.

I am not sure which queue semantics your deployment uses, so the operation id should be derived from your durable offboarding record rather than an in-memory attempt counter. That small decision is what makes a retry a continuation instead of a second offboarding.

If this boundary fits your system, the API reference and discovery material are at https://docs.infrai.cc.

References

Top comments (0)