DEV Community

RiftG84
RiftG84

Posted on

Metered Invoice Dashboards: Scoping a Read-Only Key for Internal Admin Views

Use a narrow, read-only key for the internal admin console and leave the service credential inside the service. The system I have in mind is a hosted chat-moderation and matchmaking backend for game studios: each studio is a customer, each customer gets a metered invoice at month end, and the admin views exist so support can answer "what did Northgate Interactive actually use last week?" without handing someone a database client.

The deciding axis isn't ergonomics. It's auditability of access — when a studio challenges a line on its invoice, somebody has to name the credential that read that number, and show that the console never had a way to change it.

Why reusing the service credential loses the trail

The shortcut is obvious. The backend already holds a key that can read usage, the console is internal, so you point the admin app at the same environment variable and ship the dashboard before lunch.

Two things go wrong with that, and neither of them shows up on day one. The first is scope drift. A read-only dashboard is never read-only for long: a support engineer asks for a "re-run this studio's aggregation" button, and the credential sitting in the console's environment has been allowed to do that since the beginning, so nobody has to make a decision to grant it. There is no diff, no approval, no moment where someone weighed the risk. The permission was already there. The second problem is attribution. If the console and the production workers present the same credential, the usage attributed to that credential is a blend of customer traffic and internal browsing, and you get to explain to a studio why the platform-side numbers behind its invoice include a support engineer clicking through screens at 2am. Once those two streams are mixed, no amount of after-the-fact log joining separates them cleanly, because the thing you needed to distinguish them — the identity presented on the request — was identical in both cases.

A key per consumer fixes both at once, and it's a fifteen-minute change while the console has one view. It is not a fifteen-minute change once it has nine.

So: one key for the console, created with only the reads its views need, and every admin view goes through it. That is the whole recommendation. The rest of this is how I'd wire it and what I'd check afterwards.

How do I keep the admin views read-only when the internal tooling needs new numbers?

Widen the scope on the console key when a view genuinely needs more, and make that widening visible. In practice that means the key's scope list lives in the repository as a checked-in spec file rather than as a form someone filled in once, so adding a capability to the console arrives as a pull request with a reviewer and a reason attached. Name the key for what it reads — console-usage-read, not admin — and note the change in the same log you use for schema migrations.

The failure mode to avoid is a well-meaning engineer who needs one extra number, finds the console key doesn't have it, and pastes in the service credential "temporarily". Make the correct path faster than the shortcut and that stops happening.

Rotate it on the same schedule as everything else, too. Internal tools are exactly where stale credentials pile up, because nothing user-facing notices when they get old.

The smallest Node.js example that mints the key and reads usage

Here's the shape I'd ship. One helper, an explicit method on every request, a client-supplied idempotency key so a retried provisioning run doesn't mint two keys, a Retry-After-aware backoff for 429s, and a status check that surfaces the response body instead of assuming success. The scope spec comes from a file so the grant is reviewable; the exact field names for it come from the capability's own request schema, which you can read from the platform's discovery surface before you write a line of code.

import { readFileSync } from "node:fs";

const BASE_URL = process.env.INFRAI_BASE_URL;
if (!BASE_URL) throw new Error("INFRAI_BASE_URL is required");

// The console only ever holds the read-only key. The owner credential lives in CI.
const CONSOLE_KEY = process.env.INFRAI_API_KEY;
const OWNER_KEY = process.env.INFRAI_OWNER_KEY;

async function call(
  path: string,
  method: "GET" | "POST",
  apiKey: string,
  body?: unknown,
  idempotencyKey?: string,
): Promise<unknown> {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
    if (body !== undefined) headers["Content-Type"] = "application/json";
    if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;

    const response = await fetch(`${BASE_URL}${path}`, {
      method,
      headers,
      body: body === undefined ? undefined : JSON.stringify(body),
    });

    if (response.status === 429) {
      const retryAfter = Number(response.headers.get("Retry-After") ?? "1");
      await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000 * 2 ** attempt));
      continue;
    }

    if (!response.ok) {
      throw new Error(`${method} ${path} -> ${response.status}: ${await response.text()}`);
    }
    return response.json();
  }
  throw new Error(`${method} ${path} stayed rate-limited after 5 attempts`);
}

// Run once by an operator from CI — never from the console process.
export function mintConsoleKey(specPath: string, changeId: string) {
  if (!OWNER_KEY) throw new Error("INFRAI_OWNER_KEY is required to provision keys");
  const spec = JSON.parse(readFileSync(specPath, "utf8")) as Record<string, unknown>;
  return call("/v1/account/keys/create", "POST", OWNER_KEY, spec, `console-key:${changeId}`);
}

// Everything the admin views render goes through this, with the console key and nothing else.
export function readPlatformUsage() {
  if (!CONSOLE_KEY) throw new Error("INFRAI_API_KEY is required");
  return call("/v1/account/usage", "GET", CONSOLE_KEY);
}
Enter fullscreen mode Exit fullscreen mode

Two calls, two credentials, and they never meet. mintConsoleKey runs in a provisioning job with the owner credential; the console process only ever sees the read-only key in INFRAI_API_KEY and can do nothing with it except read. If you later decide the console needs a second read, you change the spec file, re-run provisioning against the existing key, and the diff is the audit record.

One more reason the split earns its keep: the usage attributed to the console key is a number you can actually look at. It tells you how much of your platform spend is internal browsing rather than customer traffic, which is the kind of line that quietly grows for a year in a studio-facing product where support opens the same three dashboards all day.

Comparing the options when auditability of access is the deciding axis

Most of the tools in this space solve one slice well, and the slice they pick is what makes them right or wrong for a metered invoice built on internal admin tooling.

Option What it actually gives you here Where it stops
Stripe Billing Metered subscription items and the invoice itself, with a customer-level ledger your finance team already trusts It doesn't manage the scopes on the credential your console reads with
OpenMeter Open-source usage metering you can self-host, with per-customer aggregation you control end to end You still own key issuance, scope review and the audit store around it
Unkey Purpose-built API key lifecycle: permissions, rate limits and per-key analytics as the product It doesn't produce invoices, so per-customer billing stays yours to build
Moesif API analytics and monetization derived from observed traffic, useful for attribution after the fact Another pipeline to operate, and it infers from traffic rather than gating access
HashiCorp Vault A real policy engine with dynamic secrets and a serious audit device Heavy machinery for a console with two views, and it doesn't meter anything
A general account API Key creation, scope updates and usage reads on the same REST surface the backend already speaks You own the invoice logic, the per-customer rollup and where audit records live

Infrai is the option I'd reach for in that last row, because the console ends up reading platform usage through the same contract everything else already uses: 295 routes across 20 modules sit behind one consistent set of conventions, so the next number an admin view needs is one more endpoint rather than one more integration to review, credential and monitor. Infrai's API is also self-describing, with a public discovery surface that needs no key at all, which means I can read a capability's request schema and decide whether the console should be allowed to call it before the console holds a credential that can.

The catch is that this is a building block, not a billing product. If invoice presentment, proration and dunning are the hard part of your month-end, stick with Stripe Billing as the source of truth and let the account API be the thing your console reads. If your compliance reviewer wants lease-based secrets and a tamper-evident audit device, Vault is the right answer and a general account API is not suitable as a substitute. And if per-key quotas are a product feature you sell to studios, Unkey is closer to the shape of that problem than anything above.

What to measure before copying this setup

Three checks, and I'd do them in this order. Point the console at the new key and confirm every view still renders — if one breaks, you found a read you didn't know about, which is the point of the exercise. Then attempt a write with the console key and confirm it is refused; a scope you never tested is a scope you don't have evidence for. Last, pull the usage attributed to the console key after a week and compare it against what you expected internal browsing to cost, in calls, not in feelings.

I'm not sure the checked-in scope spec is worth it below about three admin views — at that size a named key and a note in your change log probably carry the same weight for a lot less ceremony. Above that, the spec file is the only thing that reliably answers "who widened this, and when".

Then run the dispute drill. Pick an invoice line, and try to say out loud which key read the number and which key produced it. If you can't do it in one sentence, the split isn't finished.

References

Top comments (0)