The refreshable-grant refresh token no longer rides a custom at-rest envelope that a
module opens with a node private key. A module is never given a node's private sealing
key, so that path could not exist -- the gap Phase C hit.
Instead the refresh token is a credential sealed to the MANAGER holder with the same
anonymous box (secrets.Seal / crypto_box_seal) every credential uses, stored as one
sealed blob, and delivered by the existing host-unseal-and-mount: the host opens it with
the node's real key and mounts the cleartext at the manager module's bound path, exactly
as a consumer's db password is delivered.
- refresh_grant now stores { sealed, manager_key }, dropping the AtRest token/wrapped_key
columns; internal/secrets/atrest.go is retired (nothing else used it).
- the licence records its manager as (node, module); KeyFor delivers the refresh token to
the manager holder and the access token to consumers, disambiguated by module so the two
can co-locate. Accept and the reseal skip the manager holder.
- the manager holder is delivered the node's PUBLIC sealing key in its bound facts, so the
module can re-seal a rotated refresh token with no private key of its own; the
declaration tolerates its empty pre-adoption secret rather than refusing.
- SubmitRefresh / set-grant take a sealed blob, never a refresh token in the clear.
The invariant holds unchanged: the control plane never reads the refresh token, and no node
but the manager holds it. A committed cross-language test proves the TypeScript module seal
opens under Go box.OpenAnonymous (the host's Unseal) -- both are NaCl crypto_box_seal.
Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
89 lines
5.3 KiB
SQL
89 lines
5.3 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,
|
|
-- The module ON the manager node that runs the refresh -- the manager holder. Named alongside
|
|
-- the manager node because a node may run the manager module AND a consuming module of the same
|
|
-- licence (the lab co-locates both), and which holder is delivered the refresh token rather than
|
|
-- an access token turns on the module, not the node alone. Null exactly when `manager` is.
|
|
manager_module 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, sealed to the manager node's key -- the same anonymous box every credential
|
|
-- the mesh delivers uses.
|
|
--
|
|
-- **novox/hq ADR 0050's carve-out, delivered the way the mesh delivers everything else.** 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 -- reads it. But the earlier attempt at a bespoke at-rest envelope, and the module holding
|
|
-- the node's private key to open it, hit a wall the mesh's own design forbids: a module is never
|
|
-- given a node's private sealing key. So the refresh token rides the *ordinary* path instead -- it is
|
|
-- an anonymous sealed box (secrets.Seal, `crypto_box_seal`) to the manager node's public sealing key,
|
|
-- exactly like a consumer's db password, and the HOST unseals it and mounts the cleartext at the
|
|
-- manager module's bound path. This database on its own holds a sealed box with no private half to
|
|
-- open it (novox/hq ADR 0004), the same guarantee as every other sealed value here.
|
|
--
|
|
-- **Separate from the access tokens.** licence_holder.sealed is the ACCESS token, sealed per holder
|
|
-- and delivered to consumers. This is the REFRESH token, one per licence, delivered to the manager
|
|
-- holder alone. Keeping them apart is what makes "a consumer is never delivered the refresh token"
|
|
-- structural: a consumer's delivery reads licence_holder, and the refresh token is not there.
|
|
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( anonymous-box( manager sealing key, refresh_token ) ) -- the refresh token sealed to
|
|
-- the manager node, openable only by that node's private half, which the mesh never holds.
|
|
sealed text not null,
|
|
-- The manager's public sealing key the refresh token was sealed 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()
|
|
);
|