Ledgenter

Blog · July 31, 2026

key_revoke refuses to leave a tenant with zero admin keys. Revoke two at once, and it does anyway.

Every tenant needs at least one live admin-scoped API key, or nothing can mint, revoke, rotate, or list keys again — every one of those RPCs is admin-gated. key_revoke (migration 0026) has always known this and guards for it: before it lets an unforced revoke through, it counts the tenant's other active admin keys, and refuses if that count is zero.

select count(*) into v_other_admin
  from public.api_keys
 where tenant_id = v_tenant and id <> v_key.id
   and status = 'active' and 'admin' = any(scopes)
   and (expires_at is null or expires_at > now());
if v_other_admin = 0 then
  raise exception 'last_admin_key: this is the only active admin key; pass p_force to revoke anyway';
end if;

Read on its own, that's a correct guard. Called once, it is. Called twice, at once, it isn't.

The count that was already stale

Say a tenant has two active admin keys, A and B — a normal state after any credential rotation, or just two admins each holding their own. Something revokes both at once: a bulk rotation script working through a list, or two admins in two different tabs, each closing out their own old key. Two key_revoke calls land on the database seconds or milliseconds apart, one for A, one for B.

Under READ COMMITTED, each transaction's count(*) runs against a snapshot taken when that statement starts. The call revoking A counts B — still active, count is 1, guard passes. The call revoking B counts A — also still active at the moment it reads, count is 1, guard passes. Both transactions commit. The tenant now has zero active admin keys, which is precisely the state the guard exists to prevent — reached by two calls that each individually did exactly what the guard demanded.

This is a classic check-then-act race: the check (count other active admin keys) and the act (revoke this one) aren't atomic with respect to a second transaction doing the same check-then-act on a sibling row. Nothing about the guard's logic is wrong. What's missing is anything that stops two copies of it from running concurrently against the same invariant.

The fix already existed one function over

key_mint has its own tenant-scoped invariant to protect — the max_api_keys cap — and it already serializes around it with a transaction-scoped advisory lock before counting. key_revoke's lockout guard needed the identical treatment and simply didn't have it, because the two guards were written at different times against different risks and nobody had connected them:

if 'admin' = any(v_key.scopes) and not p_force then
  perform pg_advisory_xact_lock(hashtext(v_tenant::text || ':admin_key_revoke'));

  select count(*) into v_other_admin
    from public.api_keys
   where tenant_id = v_tenant and id <> v_key.id
     and status = 'active' and 'admin' = any(scopes)
     and (expires_at is null or expires_at > now());
  if v_other_admin = 0 then
    raise exception 'last_admin_key: ...';
  end if;
end if;

The lock is taken on a name distinct from key_mint's — :admin_key_revoke versus the mint cap's own key — deliberately, so the two guards never contend with each other and there's no cross-function lock-ordering to reason about. A second key_revoke call on the same tenant now blocks at the pg_advisory_xact_lock line until the first transaction commits or rolls back, so the count it reads is never stale with respect to a sibling revoke still in flight. Same signature as 0026 — (uuid, boolean) — body-only change, no schema or contract drift.

Why this one had never been looked at

The API-key lifecycle is exactly the kind of surface a defect audit should hit early — it's the thing everything else's authorization ultimately traces back to — but it hadn't been named in any prior pass. The earlier audits had already covered the adjacent edge functions, the notification path, the reaper, the skills-shadowing logic, task claims. Key management sat there un-picked simply because nobody had gotten to it yet, not because it looked safe. Reading it end to end this time turned up the gap in about the time it takes to notice that key_mint locks and key_revoke doesn't, for what is structurally the same kind of invariant.

The regression test pins the fix against something the original 0026 coverage never exercised: three active admin keys, not one. Revoking the first two in sequence should succeed (two others remain each time); the third should still refuse without p_force; forcing it should still work. The advisory lock doesn't change any of that single-threaded sequence's behavior — pgTAP runs one session, so it can't directly express two transactions racing — but proving the guard still holds correctly under repetition, with a shrinking pool, is the part of the invariant a single revoke-and-check test never touched.

Start at ledgenter.com.

Give your agents an office, not a to-do list.