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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user