licences: submit-refresh, the module-produced refresh entry point

Phase C of model-access (ADR 0050). Refresh above calls an in-process
VendorRefresher, which would open the at-rest envelope inside the control
plane's own process. Anthropic must not: its refresh runs on the manager
node. So add SubmitRefresh, the companion that publishes a refresh a
manager node already performed -- it is given only the new access token in
the clear (sealed per holder, as any accepted key) and an opaque re-sealed
refresh envelope (stored unopened). The refresh token in the clear never
crosses this boundary. The reseal-and-publish half is extracted and shared
with Refresh, so the sealing logic is one implementation.

CLI: licence grant (print the opaque envelope), set-grant (store a
module-produced envelope -- adoption), submit-refresh (access token +
optional rotated envelope). Tests defend that the manager alone opens the
refresh token and the control plane never holds it in the clear.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
2026-09-07 01:00:27 +02:00
parent 59faa150ac
commit 8e0c22fc2e
3 changed files with 488 additions and 16 deletions
+106 -14
View File
@@ -482,8 +482,106 @@ func (l *Licences) Refresh(ctx context.Context, licence string, keys SealingKeys
"the refresh produced no access token for %q, so nothing was resealed", licence)
}
// Reseal the new access token to the holders that exist now — the same set Accept seals to — and
// keep no readable copy.
// The reseal-and-publish half, shared with SubmitRefresh: the new access token is sealed to every
// holder that exists now, and a rotated refresh token replaces the stored envelope — never seen
// in the clear either way.
sealed, err := resealAndPublish(ctx, tx, licence, result.AccessToken, result.NewAtRest, keys)
if err != nil {
return 0, err
}
if err := tx.Commit(ctx); err != nil {
return 0, err
}
return sealed, nil
}
// SubmitRefresh takes a refresh a MANAGER NODE already performed and publishes it: it seals the new
// access token to every holder and replaces the stored refresh envelope if the vendor rotated it.
//
// **This is the entry point that keeps the control plane blind to the refresh token** (novox/hq ADR
// 0050, Phase C). `Refresh` above calls an in-process VendorRefresher — which would open the at-rest
// envelope inside the control plane's own process, exactly what the carve-out forbids. So Anthropic
// registers no in-process refresher; instead its manager runtime, on the manager node, opens the
// envelope with that node's own key, calls the vendor's OAuth endpoint, and submits the *result*
// here: the new access token in the clear (which the mesh seals per holder and discards, as it does
// any accepted key) and — only if the vendor rotated it — the refresh token already re-sealed at
// rest (which the mesh stores without ever opening). The refresh token in the clear never crosses
// this boundary, because this function is never given it.
//
// It reuses the same lease, the same reseal-and-publish, and the same all-or-nothing transaction as
// `Refresh`; the only difference is where the access token came from — a module on the manager node
// rather than a plug-in in this process.
func (l *Licences) SubmitRefresh(
ctx context.Context, licence, accessToken string, newAtRest *secrets.AtRest, keys SealingKeys,
) (int, error) {
if strings.TrimSpace(accessToken) == "" {
return 0, fmt.Errorf(
"a refresh submitted for %q carried no access token, so there is nothing to seal", licence)
}
tx, err := l.store.Pool().Begin(ctx)
if err != nil {
return 0, err
}
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
// The same lease Refresh takes: a submitted refresh and an in-process one serialise rather than
// racing to publish.
if _, err := tx.Exec(ctx,
`select pg_advisory_xact_lock(hashtext($1)::bigint)`, licence); err != nil {
return 0, fmt.Errorf("cannot take the refresh lease on %q: %w", licence, err)
}
// The submitter must be a refreshable-grant licence with a manager named — the same gate Refresh
// applies, so a static key can never reach this machinery and a licence with no manager is not
// silently accepted from whoever called.
var vendor string
var manager *string
err = tx.QueryRow(ctx,
`select vendor, manager from licence where name = $1`, licence).Scan(&vendor, &manager)
if errors.Is(err, pgx.ErrNoRows) {
return 0, fmt.Errorf("this mesh has no licence called %q", licence)
}
if err != nil {
return 0, err
}
adapter, err := adapters.For(vendor)
if err != nil {
return 0, err
}
if adapter.Shape() != adapters.RefreshableGrant {
return 0, fmt.Errorf(
"%q is a %s licence; only a refreshable-grant licence has a refresh to submit", licence,
adapter.Shape())
}
if manager == nil || *manager == "" {
return 0, fmt.Errorf(
"%q has no manager named, so a refresh cannot be submitted for it. Name one:\n"+
" licence manager %s <node>", licence, licence)
}
sealed, err := resealAndPublish(ctx, tx, licence, accessToken, newAtRest, keys)
if err != nil {
return 0, err
}
if err := tx.Commit(ctx); err != nil {
return 0, err
}
return sealed, nil
}
// resealAndPublish seals a new access token to every current holder and, if one is given, replaces
// the stored refresh envelope with a rotated one. It is the half `Refresh` and `SubmitRefresh` share:
// the value's source differs, what is done with it does not.
//
// The refresh token is never touched here — a rotated one arrives already re-sealed at rest, and is
// stored as the opaque envelope it is. `KeyFor` can therefore only ever deliver the access token.
func resealAndPublish(
ctx context.Context, tx pgx.Tx, licence, accessToken string, newAtRest *secrets.AtRest,
keys SealingKeys,
) (int, error) {
holders, err := holdersTx(ctx, tx, licence)
if err != nil {
return 0, err
@@ -498,7 +596,7 @@ func (l *Licences) Refresh(ctx context.Context, licence string, keys SealingKeys
return 0, fmt.Errorf(
"%s has no sealing key, so the new access token cannot be sealed to it", h.Node)
}
blob, err := secrets.Seal(key, []byte(result.AccessToken))
blob, err := secrets.Seal(key, []byte(accessToken))
if err != nil {
return 0, err
}
@@ -511,26 +609,20 @@ func (l *Licences) Refresh(ctx context.Context, licence string, keys SealingKeys
sealed++
}
// The refresh token stays put unless the vendor rotated it, in which case the refresher returned
// it already re-encrypted at rest — replaced here without ever being seen in the clear.
if result.NewAtRest != nil {
if result.NewAtRest.Token == "" || result.NewAtRest.WrappedKey == "" ||
result.NewAtRest.ManagerKey == "" {
// The refresh token stays put unless the vendor rotated it, in which case it arrived already
// re-encrypted at rest — replaced here without ever being seen in the clear.
if newAtRest != nil {
if newAtRest.Token == "" || newAtRest.WrappedKey == "" || newAtRest.ManagerKey == "" {
return 0, fmt.Errorf(
"the refresh returned an incomplete re-sealed refresh token for %q", licence)
}
if _, err := tx.Exec(ctx,
`update refresh_grant set token = $2, wrapped_key = $3, manager_key = $4, updated_at = now()
where licence = $1`,
licence, result.NewAtRest.Token, result.NewAtRest.WrappedKey,
result.NewAtRest.ManagerKey); err != nil {
licence, newAtRest.Token, newAtRest.WrappedKey, newAtRest.ManagerKey); err != nil {
return 0, err
}
}
if err := tx.Commit(ctx); err != nil {
return 0, err
}
return sealed, nil
}
+159
View File
@@ -0,0 +1,159 @@
package licences
import (
"strings"
"testing"
"github.com/novox/mesh-control/internal/secrets"
)
// SubmitRefresh is the Phase-C boundary: a refresh a manager NODE performed is published here, and
// the control plane is given only the access token in the clear and an opaque re-sealed refresh
// envelope — never the refresh token. These tests defend that the boundary keeps its shape.
// A submitted refresh seals the access token to every holder, exactly as an in-process refresh does,
// and delivers no refresh token to anybody.
func TestSubmitRefreshSealsTheAccessTokenAndNeverTheRefreshToken(t *testing.T) {
held, ctx := fresh(t)
// No in-process refresher registered: the anthropic production path uses SubmitRefresh, not
// Refresh, precisely so nothing opens the envelope inside this process.
_, _, holders, keys := aRefreshableLicence(t, held, ctx, &fakeRefresher{})
sealed, err := held.SubmitRefresh(ctx, "personal", "at-from-the-manager-node", nil, keys)
if err != nil {
t.Fatal(err)
}
if sealed != 2 {
t.Fatalf("%d holder(s) were resealed, expected 2", sealed)
}
for node, open := range holders {
blob, err := held.KeyFor(ctx, "personal", node, "assistant")
if err != nil {
t.Fatal(err)
}
got, err := open(blob)
if err != nil {
t.Fatalf("%s cannot open what it was delivered", node)
}
if string(got) != "at-from-the-manager-node" {
t.Fatalf("%s was delivered %q, not the access token", node, got)
}
if string(got) == "rt-the-refresh-token" || strings.Contains(blob, "rt-the-refresh-token") {
t.Fatalf("%s was delivered the refresh token", node)
}
}
}
// The refresh token stored at rest is untouched by a submit that carried no rotation, and the manager
// node — and only it — still opens it. The submit path never saw the refresh token in the clear.
func TestSubmitRefreshWithoutRotationLeavesTheGrantOpenableByTheManagerAlone(t *testing.T) {
held, ctx := fresh(t)
managerPub, managerPriv, _, keys := aRefreshableLicence(t, held, ctx, &fakeRefresher{})
before := grantRow(t, held, ctx)
if _, err := held.SubmitRefresh(ctx, "personal", "at-access", nil, keys); err != nil {
t.Fatal(err)
}
if grantRow(t, held, ctx) != before {
t.Fatal("a submit with no rotation changed the stored refresh token")
}
at, ok, err := held.RefreshGrant(ctx, "personal")
if err != nil || !ok {
t.Fatalf("the grant is not stored: ok=%v err=%v", ok, err)
}
got, err := secrets.OpenAtRest(at, managerPub, managerPriv)
if err != nil {
t.Fatal(err)
}
if got != "rt-the-refresh-token" {
t.Fatalf("the manager read back %q", got)
}
// A node that is not the manager cannot: the whole of "the manager node only".
otherPub, otherPriv := managerPair(t)
if _, err := secrets.OpenAtRest(at, otherPub, otherPriv); err == nil {
t.Fatal("a node that is not the manager opened the refresh token")
}
}
// A submit that carries a rotated envelope replaces the stored one — and the control plane stored it
// without opening it: only the manager node reads the rotated token back.
func TestSubmitRefreshWithRotationReplacesTheEnvelopeUnopened(t *testing.T) {
held, ctx := fresh(t)
managerPub, managerPriv, _, keys := aRefreshableLicence(t, held, ctx, &fakeRefresher{})
before := grantRow(t, held, ctx)
// The manager node re-sealed the rotated refresh token at rest; the control plane is handed only
// this envelope.
rotated, err := secrets.SealAtRest("rt-a-rotated-refresh-token", managerPub)
if err != nil {
t.Fatal(err)
}
if _, err := held.SubmitRefresh(ctx, "personal", "at-access", &rotated, keys); err != nil {
t.Fatal(err)
}
if grantRow(t, held, ctx) == before {
t.Fatal("the rotated refresh token did not replace the stored envelope")
}
at, ok, err := held.RefreshGrant(ctx, "personal")
if err != nil || !ok {
t.Fatalf("the rotated grant is not stored: ok=%v err=%v", ok, err)
}
got, err := secrets.OpenAtRest(at, managerPub, managerPriv)
if err != nil {
t.Fatal(err)
}
if got != "rt-a-rotated-refresh-token" {
t.Fatalf("the stored grant opened to %q, not the rotated token", got)
}
}
// A submit with an empty access token is refused before anything is sealed: publishing nothing while
// reporting success is the failure this whole design refuses.
func TestSubmitRefreshRefusesAnEmptyAccessToken(t *testing.T) {
held, ctx := fresh(t)
_, _, _, keys := aRefreshableLicence(t, held, ctx, &fakeRefresher{})
if _, err := held.SubmitRefresh(ctx, "personal", " ", nil, keys); err == nil {
t.Fatal("a refresh with no access token was published")
}
}
// A static-key licence cannot have a refresh submitted for it: the carve-out never fires, so the
// machinery that holds a token readably is unreachable.
func TestSubmitRefreshRefusesAStaticKeyLicence(t *testing.T) {
held, ctx := fresh(t)
if err := held.Add(ctx, "plain", "anthropic-api-key", nil); err != nil {
t.Fatal(err)
}
if err := held.Use(ctx, "plain", "workstation", "assistant"); err != nil {
t.Fatal(err)
}
_, err := held.SubmitRefresh(ctx, "plain", "at-access", nil,
func(string) (string, error) { return ASealingKey(t), nil })
if err == nil {
t.Fatal("a refresh was submitted for a static-key licence")
}
if !strings.Contains(err.Error(), "refreshable-grant") {
t.Fatalf("the refusal does not name the shape: %v", err)
}
}
// A refreshable licence with no manager named refuses a submit and says how to name one: a refresh
// cannot be published for a licence no node is responsible for.
func TestSubmitRefreshRefusesWithoutAManager(t *testing.T) {
held, ctx := fresh(t)
if err := held.Add(ctx, "personal", "anthropic", nil); err != nil {
t.Fatal(err)
}
_, err := held.SubmitRefresh(ctx, "personal", "at-access", nil,
func(string) (string, error) { return "", nil })
if err == nil {
t.Fatal("a refresh was submitted for a licence with no manager")
}
if !strings.Contains(err.Error(), "manager") {
t.Fatalf("the refusal does not point at the missing manager: %v", err)
}
}