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
273 lines
13 KiB
Go
273 lines
13 KiB
Go
// Package adapters is the per-vendor lifecycle a licence needs, selected by the licence's vendor.
|
|
//
|
|
// novox/hq ADR 0050: model access is one consumer-facing, vendor-blind provision, and *a vendor is
|
|
// an adapter* keyed by `licence.vendor`. A consumer names `model-access` and never a vendor; which
|
|
// 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).
|
|
//
|
|
// **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 (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/novox/mesh-control/internal/secrets"
|
|
)
|
|
|
|
// Shape is how a vendor's credential behaves, and the switch the ADR 0050 carve-out turns on.
|
|
type Shape string
|
|
|
|
const (
|
|
// StaticKey is an operator-supplied value with nothing to rotate. The full "the mesh cannot
|
|
// read what it stores" guarantee holds: accept seals it and discards the plaintext.
|
|
StaticKey Shape = "static-key"
|
|
// RefreshableGrant is an OAuth-style grant a manager node refreshes centrally (ADR 0050,
|
|
// Phase B). It carries the one bounded relaxation of that guarantee. No adapter of this shape
|
|
// ships in Phase A.
|
|
RefreshableGrant Shape = "refreshable-grant"
|
|
)
|
|
|
|
// Adapter is a vendor's lifecycle, selected by a licence's vendor.
|
|
//
|
|
// Only these verbs are required, and they are all a static-key vendor needs: what shape it is, how
|
|
// a supplied value is sealed, and what a consumer is delivered. The optional capabilities below are
|
|
// found by type assertion, so a static-key adapter simply does not implement them.
|
|
type Adapter interface {
|
|
// Vendor is the name a licence's `vendor` field carries to select this adapter.
|
|
Vendor() string
|
|
// Shape is static-key or refreshable-grant.
|
|
Shape() Shape
|
|
// Accept takes an operator-supplied credential and seals it to one holder — the `accept` verb
|
|
// ADR 0024 defines. For a static key this is the generic seal, unchanged.
|
|
Accept(value, consumerKey, providerKey string) (secrets.Sealed, error)
|
|
// Deliver is the credential value a consumer receives. For a sealed static key it is the sealed
|
|
// blob unchanged: the mesh never unseals it, only the holding node's private key can. A
|
|
// refreshable-grant adapter (Phase B) strips the refresh token here.
|
|
Deliver(sealed string) string
|
|
}
|
|
|
|
// RefreshInput is what producing a new access token needs, and all a refresh is given.
|
|
//
|
|
// It carries the refresh token **only as its sealed blob** — the caller (the control plane) never
|
|
// holds the refresh token in the clear, because it cannot open the box. 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 box opens.
|
|
Manager string
|
|
// Sealed is the refresh token as an anonymous sealed box to the manager's key. Opaque to the
|
|
// control plane; openable only by the manager node's private half.
|
|
Sealed string
|
|
// ManagerKey is the manager's public sealing key the token was sealed to.
|
|
ManagerKey string
|
|
}
|
|
|
|
// 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-sealed to the manager, ready to replace the stored blob.
|
|
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
|
|
// NewSealed is the refresh token re-sealed to the manager node, present only when the vendor
|
|
// rotated the refresh token too. Empty leaves the stored blob untouched. Already sealed, so the
|
|
// control plane stores it without ever seeing the refresh token in the clear.
|
|
NewSealed string
|
|
// NewManagerKey is the key NewSealed was sealed to, carried with it.
|
|
NewManagerKey string
|
|
}
|
|
|
|
// 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, 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
|
|
// account identity worth checking implements it. A static-key adapter does not.
|
|
type Identifier interface {
|
|
Identity(credential string) (string, error)
|
|
}
|
|
|
|
// UsageReader is a vendor's usage reading (ADR 0050 / ADR 0054, Phase B), mapped to the common
|
|
// normalised grain. A static-key adapter does not implement it.
|
|
type UsageReader interface {
|
|
Usage(ctx context.Context, licence string) ([]UsageRow, error)
|
|
}
|
|
|
|
// UsageRow is the vendor-neutral usage grain ADR 0054 fixes: the metric is vendor-defined and no
|
|
// common unit is forced. Defined here as the Phase-B seam; nothing in Phase A produces one.
|
|
type UsageRow struct {
|
|
Licence string
|
|
Consumer string
|
|
Period string
|
|
Metric string
|
|
Value float64
|
|
// Raw is the vendor's own response, kept so a reading can be re-derived if the normalisation is
|
|
// later found wrong.
|
|
Raw []byte
|
|
}
|
|
|
|
// registry maps a vendor name to the shape its adapter has.
|
|
//
|
|
// 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 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": RefreshableGrant,
|
|
"anthropic-api-key": StaticKey,
|
|
}
|
|
|
|
// For returns the adapter for a vendor, refusing an unknown one clearly.
|
|
//
|
|
// The refusal names what this mesh does know, because a vendor typo and a vendor this build has no
|
|
// adapter for are the same symptom to whoever hits it, and the list is the difference between fixing
|
|
// the name and filing a bug.
|
|
func For(vendor string) (Adapter, error) {
|
|
shape, known := registry[vendor]
|
|
if !known {
|
|
return nil, fmt.Errorf(
|
|
"this mesh has no adapter for the vendor %q; it knows: %s",
|
|
vendor, strings.Join(vendors(), ", "))
|
|
}
|
|
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 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 has shape %q, which this build has no adapter for", vendor, shape)
|
|
}
|
|
}
|
|
|
|
// vendors is every vendor this mesh has an adapter for, sorted — what a refusal lists.
|
|
func vendors() []string {
|
|
out := make([]string, 0, len(registry))
|
|
for v := range registry {
|
|
out = append(out, v)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// staticKey is the adapter for a vendor whose credential is a single operator-supplied value.
|
|
//
|
|
// It holds no vendor-specific logic, and that is the point: accept is the generic seal, deliver is
|
|
// the value, and refresh / identity / usage are not implemented at all. It is one type shared by
|
|
// every static-key vendor rather than one per vendor, because the sealing is vendor-independent.
|
|
type staticKey struct{ vendor string }
|
|
|
|
func (s staticKey) Vendor() string { return s.vendor }
|
|
func (s staticKey) Shape() Shape { return StaticKey }
|
|
|
|
// Accept is the generic anonymous-box seal (ADR 0024): a static key needs nothing vendor-specific.
|
|
func (s staticKey) Accept(value, consumerKey, providerKey string) (secrets.Sealed, error) {
|
|
return secrets.Accept(value, consumerKey, providerKey)
|
|
}
|
|
|
|
// Deliver hands the consumer the credential value. For a sealed static key that is the sealed blob
|
|
// 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. It lives in the licences context's own refresh_grant store, keyed by licence, sealed
|
|
// to the manager node (secrets.Seal) and delivered to the manager holder alone. What Accept seals and
|
|
// Deliver hands out to a CONSUMER is the ACCESS token, per holder, exactly as a static key's value is
|
|
// — so "a consumer is never delivered the refresh token" is structural here: a consumer's row never
|
|
// holds it, because the refresh token is a different holder's credential entirely.
|
|
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)
|
|
}
|