model access B: refreshable-grant machinery — manager, at-rest refresh token, refresh flow

The ADR 0050 carve-out, built generic and vendor-neutral. A refreshable-grant
licence records one manager node; that node holds the refresh token encrypted at
rest, access tokens are still sealed per holder, and the refresh token is never in
a holder's delivery. Bounded on the three stated axes: refreshable-grant vendors
only, the refresh token only, the manager node only. Anthropic's actual OAuth
refresh stays a Phase-C plug-in behind a clean seam.

- New at-rest crypto (secrets.SealAtRest/OpenAtRest): envelope encryption distinct
  from the per-holder anonymous-box seal. The refresh token is under a symmetric
  data key (secretbox); the data key is wrapped to the manager node's public
  sealing key. The database alone holds ciphertext and a wrapped key with no
  private half to open either — only the manager node reads it back.

- Refreshable-grant adapter dispatch: anthropic is now refreshable-grant,
  anthropic-api-key the static-key second case. The adapter implements the
  Refresher seam by delegating to an injected VendorRefresher (the Phase-C plug,
  none shipped). static-key is untouched. The type assertion to Refresher is what
  gates the carve-out to refreshable-grant vendors.

- Refresh lease/rotate/publish flow (Licences.Refresh): a transaction-scoped
  advisory lock is the single-refresher lease; the new access token comes from the
  vendor refresh, is sealed per holder (secrets.Seal, as Accept does) and delivered
  on the next push — doc 13's reseal-and-publish half, all-or-nothing. The refresh
  token stays put, re-encrypted at rest only if the vendor rotated it.

- Manager and refresh_grant schema: consolidated into migrations/0001 and carried
  by a new incremental 0003 (the dual-write rule).

- 17 new tests, including the four security checks: KeyFor never carries the
  refresh token, a static key has no manager and cannot be refreshed, the at-rest
  token needs the manager's key, and a refresh delivers a new sealed access token.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
2026-09-07 00:23:35 +02:00
parent 163200c4dd
commit 2e33c5e80e
10 changed files with 1220 additions and 24 deletions
@@ -19,6 +19,12 @@ create table licence (
-- What a consumer needs to know that is not secret -- a base URL, a model name. The key is
-- never here.
serves jsonb not null default '{}'::jsonb,
-- The one node that holds this licence's refresh token readably, and refreshes it (novox/hq
-- ADR 0050's carve-out). Null for a static-key licence, which has nothing to refresh and no
-- manager -- and the null is the check that a static key never grows a readable-at-rest value.
-- A name, not a foreign key: nodes live in another context this one may not join across
-- (novox/hq ADR 0008).
manager text,
added_at timestamptz not null default now()
);
@@ -41,3 +47,33 @@ create table licence_holder (
added_at timestamptz not null default now(),
primary key (licence, node, module)
);
-- The refresh token, encrypted at rest under the manager node's key.
--
-- **novox/hq ADR 0050's carve-out, and its one home.** A `refreshable-grant` licence cannot be both
-- sealed so the mesh cannot read it and rotated centrally, because rotating means a node reads the
-- refresh token back. So exactly one node -- the licence's manager -- holds it readably, and it is
-- kept here as an envelope only that node can open: a symmetric data key encrypts the token
-- (secretbox), and the data key is sealed to the manager's public key. This database on its own
-- holds ciphertext and a wrapped key with no private half to open either (novox/hq ADR 0004).
--
-- **Separate from the access tokens.** licence_holder.sealed is the ACCESS token, sealed per holder
-- and delivered. This is the REFRESH token, one per licence, never delivered to anybody. Keeping
-- them in different tables is what makes "the refresh token is stripped on delivery" structural:
-- delivery reads licence_holder, and the refresh token is not in it.
create table refresh_grant (
-- One refresh token per licence. On delete cascade: forgetting a licence forgets its refresh
-- token with it, the same way it forgets its holders.
licence text primary key references licence(name) on delete cascade,
-- base64( nonce || secretbox(data_key, refresh_token) ) -- the token under the symmetric key.
token text not null,
-- base64( anonymous-box(manager_key, data_key) ) -- the data key closed to the manager node.
wrapped_key text not null,
-- The manager's public sealing key the data key was wrapped to. Kept so a manager that
-- regenerated its key can be told it can no longer open this, rather than discovering it as a
-- refresh that will not decrypt (the same reason licence_holder.node_key is kept).
manager_key text not null,
updated_at timestamptz not null default now()
);
@@ -0,0 +1,19 @@
-- A manager, and the refresh token it holds encrypted at rest.
--
-- novox/hq ADR 0050, Phase B. The consolidated schema (0001) now creates the `manager` column and
-- the `refresh_grant` table; this migration carries an existing database the same distance, so a
-- database that predates the carve-out gains exactly what a fresh one is created with.
--
-- **Guarded, so it is a no-op on a database created after 0001 was updated.** A fresh database
-- already has both, and re-adding them would fail; `if not exists` on both makes the two paths --
-- fresh and pre-existing -- end at the same schema.
alter table licence add column if not exists manager text;
create table if not exists refresh_grant (
licence text primary key references licence(name) on delete cascade,
token text not null,
wrapped_key text not null,
manager_key text not null,
updated_at timestamptz not null default now()
);