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
80 lines
4.4 KiB
SQL
80 lines
4.4 KiB
SQL
-- A licence is a named thing, and the name is the operator's.
|
|
--
|
|
-- novox/hq ADR 0024. Not an anonymous credential hanging off a vendor: *the personal account*,
|
|
-- *the organisation's account* are names a person uses, and the mesh has to use them too, because
|
|
-- the whole point is saying WHICH ONE a given consumer uses.
|
|
--
|
|
-- **Many to many.** One vendor has several licences; one licence serves several consumers. So it
|
|
-- is deliberately not a claim — claims are for things only one holder may have, and two machines
|
|
-- sharing an account is the ordinary case rather than a collision.
|
|
|
|
create table licence (
|
|
-- The operator's name for it. The primary key, because that is what a person types and what a
|
|
-- consumer is pinned to.
|
|
name text primary key,
|
|
-- Which company sells it: anthropic, openai, a model the mesh runs itself. Selects the adapter
|
|
-- that runs this licence's lifecycle (novox/hq ADR 0050). Named `vendor`, not `provider`: the
|
|
-- inventory already uses "provider" for which node answers a brokered provision.
|
|
vendor text not null,
|
|
-- 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()
|
|
);
|
|
|
|
-- Who holds it, and the key sealed to them.
|
|
--
|
|
-- **The node is a name, not a foreign key.** It lives in another context and this one may not join
|
|
-- across that boundary (novox/hq ADR 0008); a name is the published identifier and is what
|
|
-- crossing a context boundary is allowed to carry.
|
|
create table licence_holder (
|
|
licence text not null references licence(name) on delete cascade,
|
|
node text not null,
|
|
module text not null,
|
|
|
|
-- Sealed to that node's key. Null until a key has been supplied while this holder existed --
|
|
-- which is a real state and not an error: the mesh discarded the plaintext, so it cannot seal
|
|
-- to a holder that arrived afterwards, and saying so is better than delivering nothing.
|
|
sealed text,
|
|
node_key text,
|
|
|
|
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()
|
|
);
|