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
+135 -22
View File
@@ -5,13 +5,15 @@
// vendor answers, and the lifecycle that vendor's credential needs, lives here — exactly as
// `public-dns` is one neutral interface answered by registrar-scoped providers (ADR 0044).
//
// **Phase A ships only the `static-key` shape.** A static-key vendor implements almost nothing: the
// credential is an operator-supplied value, sealed to each holder by the generic anonymous box
// (ADR 0024) and delivered unchanged. The refreshable-grant machinery — central rotation, an
// identity guard, a usage reading, refresh-token-stripped delivery — is Phase B, and its verbs are
// named here as optional capabilities (Refresher, Identifier, UsageReader) so the seam exists
// before the code does. The abstraction earns its keep by making the common vendor small, not the
// rare one clever (ADR 0050).
// **Two shapes.** A `static-key` vendor implements almost nothing: the credential is an
// operator-supplied value, sealed to each holder by the generic anonymous box (ADR 0024) and
// delivered unchanged. A `refreshable-grant` vendor carries the bounded carve-out — a manager 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. This package holds the generic half of both. The
// vendor-specific half of a refresh — the actual OAuth call against a vendor's endpoint — is a
// VendorRefresher plugged in from outside (novox/hq ADR 0050, Phase C); this build ships none, and
// a refresh on a vendor with nothing plugged in is refused in as many words rather than pretended.
// The abstraction earns its keep by making the common vendor small, not the rare one clever.
package adapters
import (
@@ -19,6 +21,7 @@ import (
"fmt"
"sort"
"strings"
"sync"
"github.com/novox/mesh-control/internal/secrets"
)
@@ -55,11 +58,72 @@ type Adapter interface {
Deliver(sealed string) string
}
// Refresher is implemented only by a refreshable-grant adapter (ADR 0050, Phase B): the
// lease / rotate / publish machinery a manager node runs. A static-key adapter does not implement
// it, and a caller finds its absence by a type assertion.
// RefreshInput is what producing a new access token needs, and all a refresh is given.
//
// It carries the refresh token **only as its at-rest envelope** — the caller (the control plane)
// never holds the refresh token in the clear, because it cannot open the envelope. Opening it, and
// the vendor call that follows, happen where the manager node's private key is (ADR 0050, Phase C).
type RefreshInput struct {
Licence string
// Manager is the node that holds the refresh token readably — the one place the envelope opens.
Manager string
// AtRest is the refresh token encrypted at rest under the manager's key. Opaque to the control
// plane; meaningful only to the manager node that produced it.
AtRest secrets.AtRest
}
// RefreshResult is what a refresh produced: a new access token to seal per holder, and — only if the
// vendor rotated it — the refresh token re-encrypted at rest, ready to replace the stored envelope.
type RefreshResult struct {
// AccessToken is the new access token, in the clear. The mesh seals it per holder and discards
// it, exactly as it does an accepted key. It is never the refresh token.
AccessToken string
// NewAtRest is the refresh token re-sealed at rest, present only when the vendor rotated the
// refresh token too. Nil leaves the stored envelope untouched. Already encrypted, so the control
// plane stores it without ever seeing the refresh token in the clear.
NewAtRest *secrets.AtRest
}
// Refresher is implemented only by a refreshable-grant adapter (ADR 0050): the vendor-neutral half
// of the lease / rotate / publish machinery. A static-key adapter does not implement it, and a
// caller finds its absence by a type assertion — which is exactly what gates the carve-out to
// refreshable-grant vendors.
type Refresher interface {
Refresh(ctx context.Context, licence string) error
Refresh(ctx context.Context, in RefreshInput) (RefreshResult, error)
}
// VendorRefresher is the vendor-specific half, plugged in from outside (ADR 0050, Phase C): given a
// refresh, it opens the at-rest envelope with the manager node's key, calls the vendor's endpoint,
// and returns the new access token (and a re-sealed refresh token if the vendor rotated it). It is
// the one component that reads a refresh token in the clear, and in production it runs where the
// manager node's private key is. This build registers none; Anthropic's OAuth refresh is Phase C.
type VendorRefresher interface {
Refresh(ctx context.Context, in RefreshInput) (RefreshResult, error)
}
// refreshers is what has been plugged in, keyed by vendor. Empty in this build.
var refreshers struct {
sync.RWMutex
byVendor map[string]VendorRefresher
}
// RegisterRefresher plugs a vendor's real refresh in (ADR 0050, Phase C), or a fake in a test. The
// generic refreshable-grant adapter for that vendor then delegates to it. Registering nothing leaves
// the seam empty, and a refresh on that vendor is refused with a message that names Phase C rather
// than failing obscurely.
func RegisterRefresher(vendor string, r VendorRefresher) {
refreshers.Lock()
defer refreshers.Unlock()
if refreshers.byVendor == nil {
refreshers.byVendor = map[string]VendorRefresher{}
}
refreshers.byVendor[vendor] = r
}
func refresherFor(vendor string) VendorRefresher {
refreshers.RLock()
defer refreshers.RUnlock()
return refreshers.byVendor[vendor]
}
// Identifier is the mis-binding guard (ADR 0050, Phase B): a vendor whose credential carries an
@@ -89,15 +153,16 @@ type UsageRow struct {
// registry maps a vendor name to the shape its adapter has.
//
// The single place a vendor is taught to the mesh. For Phase A the only entry is anthropic as a
// static-key vendor, which is what the lab bed exercises.
// The single place a vendor is taught to the mesh. anthropic's real credential is a subscription
// OAuth grant, so it is the first `refreshable-grant` vendor; `anthropic-api-key` is the same
// company's plain API keys, the early second `static-key` case ADR 0050 names — it exercises the
// whole path with the carve-out switched off. Static-key vendors are added here as one line each.
//
// TODO(Phase B, ADR 0050): anthropic's real credential is a subscription OAuth grant, so its entry
// becomes RefreshableGrant and a refreshable-grant adapter is registered for it; the static-key
// path for the same vendor's plain API keys moves to a separate `anthropic-api-key` vendor, the
// early second static-key case ADR 0050 names. Static-key vendors are added here as one line each.
// TODO(Phase C, ADR 0050): register anthropic's real VendorRefresher — the OAuth refresh against the
// vendor's endpoint — with RegisterRefresher. Until then a refresh on it is refused, naming Phase C.
var registry = map[string]Shape{
"anthropic": StaticKey,
"anthropic": RefreshableGrant,
"anthropic-api-key": StaticKey,
}
// For returns the adapter for a vendor, refusing an unknown one clearly.
@@ -115,12 +180,15 @@ func For(vendor string) (Adapter, error) {
switch shape {
case StaticKey:
return staticKey{vendor: vendor}, nil
case RefreshableGrant:
// The generic refreshable-grant adapter, carrying whatever VendorRefresher has been plugged
// in for this vendor — nil in this build, which a refresh reports rather than hides.
return refreshableGrant{vendor: vendor, refresher: refresherFor(vendor)}, nil
default:
// A vendor registered with a shape this build does not implement yet — the refreshable-grant
// seam. Said plainly rather than answered with a static-key adapter that would silently
// mishandle it.
// A shape this build has no adapter for at all. Said plainly rather than answered with an
// adapter that would silently mishandle it.
return nil, fmt.Errorf(
"the vendor %q needs a %s adapter, which this build does not ship yet", vendor, shape)
"the vendor %q has shape %q, which this build has no adapter for", vendor, shape)
}
}
@@ -153,3 +221,48 @@ func (s staticKey) Accept(value, consumerKey, providerKey string) (secrets.Seale
// unchanged — there is no vendor step between the store and the node, and the mesh never sees the
// plaintext.
func (s staticKey) Deliver(sealed string) string { return sealed }
// refreshableGrant is the adapter for a vendor whose credential is an OAuth-style grant a manager
// node refreshes centrally (novox/hq ADR 0050). It is vendor-neutral: the generic half — the shape
// that gates the carve-out, the per-holder seal of an access token, delivery that carries only an
// access token, and the dispatch of a refresh to a plugged-in VendorRefresher — lives here; the
// vendor's actual OAuth call is the injected refresher.
//
// **What makes this the carve-out and not a second static key.** The refresh token never touches
// this adapter and never touches a holder. It lives in the licences context's own at-rest store,
// keyed by licence, encrypted to the manager node (secrets.AtRest). What Accept seals and Deliver
// hands out is the ACCESS token, per holder, exactly as a static key's value is — so "the refresh
// token is stripped on delivery" is structural here: there is nothing in a holder's row to strip,
// because the refresh token was never put there.
type refreshableGrant struct {
vendor string
refresher VendorRefresher
}
func (r refreshableGrant) Vendor() string { return r.vendor }
func (r refreshableGrant) Shape() Shape { return RefreshableGrant }
// Accept seals an access token to one holder — the generic anonymous-box seal, the same as a static
// key. The refresh token is not accepted here: it is adopted onto the manager node and kept in the
// at-rest store (ADR 0050, Phase C), never sealed per holder.
func (r refreshableGrant) Accept(value, consumerKey, providerKey string) (secrets.Sealed, error) {
return secrets.Accept(value, consumerKey, providerKey)
}
// Deliver hands the consumer its sealed ACCESS token, unchanged. There is no refresh token to strip
// because a holder's row never held one — the stripping is in the shape of the store, not a step
// here.
func (r refreshableGrant) Deliver(sealed string) string { return sealed }
// Refresh is the vendor-neutral half of the lease/rotate/publish machinery: it dispatches to the
// vendor's plugged-in refresher and returns what that produced. The lease, the per-holder reseal and
// the publish are the licences context's (ADR 0050, Phase B); the OAuth call is the vendor's
// (Phase C). With nothing plugged in, it refuses in a way that names why.
func (r refreshableGrant) Refresh(ctx context.Context, in RefreshInput) (RefreshResult, error) {
if r.refresher == nil {
return RefreshResult{}, fmt.Errorf(
"the vendor %q is refreshable-grant but has no refresher plugged in, so its grant "+
"cannot be refreshed in this build (novox/hq ADR 0050, Phase C)", r.vendor)
}
return r.refresher.Refresh(ctx, in)
}