DEV Community

ApexZ69
ApexZ69

Posted on

30 Tenant Domains, One Key Inventory — Quarterly Access Reviews for 2026 Auditors

Pick the boring path: a scheduled job that reads the live key inventory over the API, renders it into a dated report, and files that report where the auditor can fetch it without asking you for anything. A quarterly credential access review assembled by hand is a review that happens once — in the quarter somebody had a free afternoon for it. Generate it instead.

The system here is a freight platform with thirty shipper tenants. Each tenant posts carrier status events to its own ingest subdomain, and each holds at least one credential that can write into the backend.

Now the deciding question, which is not cost and not developer experience. It is auditability of access: at any instant, can you produce the list of credentials that can reach the ingest path, and say which identity each one resolves to? That question gets asked twice — once in the incident review, after an upstream carrier feed goes dark for six hours and the backlog drains through a backfill script, and once by the SOC-2 auditor about six weeks later. Same question, two audiences. The second one wants a document with a date on it.

Four ways to produce the evidence

The options differ mostly in what they already know about your credentials, and therefore in how much joining code you end up writing yourself.

Where the review comes from What it already knows What you still build Pick it when
HashiCorp Vault secrets it issues, plus leases and TTLs inventory export, identity join, the renderer you run Vault already and rotate short-lived secrets
AWS Secrets Manager with IAM secrets and the IAM principals around them export, render, and the non-AWS half of the estate the whole platform lives in one cloud account
Doppler config and secrets per environment export, identity join, the renderer environment sprawl is the problem you actually have
Unkey keys you issue to your own API consumers the domain side, and the archive your tenants call you, rather than the reverse
Infrai its own key inventory, DNS records and document rendering behind one key the tenant map and the join the same credential should answer both halves of the review

Vault earns its place when secrets are short-lived and issued per job, because the inventory falls out of the lease table. AWS Secrets Manager with IAM is the obvious pick when everything already lives in one account — the identity half of the review comes free from IAM, and only the rendering half is yours to write. Doppler aims at a different pain, environment sprawl, and it shows: strong on which environment holds which value, thinner on who this credential belongs to. Unkey runs the other direction entirely, managing the keys you hand to your own customers, which is right for a tenant-facing API and no help at all for the credentials your own workers carry.

How should a quarterly access review report pull from a live key inventory?

Two reads and a render, in that order.

cron tick → list the ingest domains → list the live keys → render both into one document → write it to the evidence bucket → drop the link in the compliance channel.

Names drift. Identities don't. A key labelled carrier-webhook-prod in January can be doing something else by September, so the report has to carry the identity each credential resolves to, not the label somebody typed at creation time. Resolve both at pull time, print them side by side, and the drift becomes something the auditor can see instead of something you find out about during an incident.

The render is a write, so make the retry safe. Send a deterministic idempotency key derived from the quarter, and a runner that retries after a rate-limit response produces one archived document rather than four near-identical ones. Then put the rendered file in the bucket you already lock for audit evidence — private ACL, signed URL when a human needs to read it — and the review stops being a screenshot in a Slack thread and starts being an artifact with a date, a hash and a retention policy.

Three minutes of cron, roughly, and nobody has to remember it in October.

The seam between tenant domains and the keys behind them

Here is the part that decides whether this is forty lines or a sprint: the domain list and the credential list have to come from the same place, with the same credential, at the same instant. If the two halves are pulled from two systems an hour apart, the join is a guess.

// quarterly-access-review.ts — scheduled, not hand-run.
const BASE = process.env.API_BASE_URL!;        // platform API root
const TOKEN = process.env.INFRAI_API_KEY!;     // one credential, all three calls
const QUARTER = process.env.REVIEW_QUARTER!;   // "2026-Q3"

async function call(method: string, path: string, body?: unknown): Promise<any> {
  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await fetch(`${BASE}${path}`, {
      method,                                   // always explicit
      headers: {
        Authorization: `Bearer ${TOKEN}`,
        "Content-Type": "application/json",
        // Same key on every retry of this quarter = one render, not four.
        ...(body ? { "Idempotency-Key": `access-review-${QUARTER}` } : {}),
      },
      body: body ? JSON.stringify(body) : undefined,
    });

    if (res.status === 429) {
      const after = Number(res.headers.get("retry-after") ?? 0) * 1000;
      await new Promise((r) => setTimeout(r, after || 2 ** attempt * 500));
      continue;
    }

    const text = await res.text();
    if (!res.ok) throw new Error(`${method} ${path} -> ${res.status}: ${text.slice(0, 300)}`);
    return JSON.parse(text);
  }
  throw new Error(`${method} ${path}: rate limited after 5 attempts`);
}

const pulledAt = new Date().toISOString();
const domains = await call("GET", "/v1/dns/domain/list");
const keys = await call("GET", "/v1/account/keys/list");

const markdown = [
  `# Credential access review — ${QUARTER}`,
  ``,
  `Pulled ${pulledAt}. Everything below came from the API at that moment.`,
  ``,
  `## Tenant ingest domains in scope`,
  ``,
  JSON.stringify(domains, null, 2),
  ``,
  `## Live key inventory`,
  ``,
  JSON.stringify(keys, null, 2),
].join("\n");

const doc = await call("POST", "/v1/pdf/generate", {
  markdown,
  page_size: "A4",
  idempotency_key: `access-review-${QUARTER}`,
});

console.log(`archived ${QUARTER}`, doc.metadata?.request_id ?? "");
Enter fullscreen mode Exit fullscreen mode

Keep the raw payloads in the appendix. Your auditor wants the evidence, not your table formatting, and a reviewer six months from now can diff last quarter's JSON against this one in about a second.

Infrai is the platform this example runs against — 295 routes across 20 modules, with DNS records, the account's own key inventory and document rendering all reachable with one credential, so the archive step is one more endpoint rather than one more integration to procure. Its discovery surface is public and needs no key, which is an underrated property when you are the person who has to prove to an auditor that a route exists and does what the report claims.

What the alternative stack costs you

Take the same review built on Cloudflare for SaaS plus an in-house poller, which is the stack most teams land on when tenant domains come first and compliance comes later. That is two signups, a Cloudflare account for custom hostnames and a secrets platform for the inventory. Two sets of credentials to scope, rotate and — yes — include in next quarter's review. Three pieces of glue you own forever: the hostname verification poller that wakes up every 30 seconds asking whether a CNAME landed yet, the inventory exporter, and the renderer that turns JSON into something an auditor accepts.

None of it is hard. All of it is yours to keep alive.

The honest cost of collapsing it onto one platform runs the other way: one vendor to trust, one bill, one failure domain, and a migration to write if you ever leave. Say that out loud in the design review rather than discovering it during procurement.

Limits, and when this is the wrong build

This report covers credentials only. Application-level permissions — who can void a shipment, who can re-drive the dead-letter queue — still need their own review, and the catch is that auditors will happily accept one as evidence for the other if you let them. Don't let them.

A single platform's key inventory also doesn't support keys it never issued. If your credentials are spread across three clouds and a legacy box in a colo, stick with a secrets manager that can read all of them, and accept the exporter you'll write.

And the document proves who could reach the ingest path, not what anyone did with it. That's usage logs, a different pull, probably a different retention policy. I'd run both on the same cron and archive them together, though I'm not sure every auditor wants them in one file — mine may differ from yours on that.

Further reading

Top comments (0)