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
+67 -2
View File
@@ -18,7 +18,7 @@ import (
// them too, because the whole point is saying which one a given consumer uses.
func licenceCommand(ctx context.Context, args []string) error {
if len(args) == 0 {
return errors.New("licence add|list|use|release|key|forget")
return errors.New("licence add|list|use|release|key|manager|refresh|forget")
}
switch args[0] {
case "add":
@@ -31,10 +31,15 @@ func licenceCommand(ctx context.Context, args []string) error {
return licenceUse(ctx, args[1:], false)
case "key":
return licenceKey(ctx, args[1:])
case "manager":
return licenceManager(ctx, args[1:])
case "refresh":
return licenceRefresh(ctx, args[1:])
case "forget":
return licenceForget(ctx, args[1:])
}
return fmt.Errorf("licence %q; it is add, list, use, release, key or forget", args[0])
return fmt.Errorf(
"licence %q; it is add, list, use, release, key, manager, refresh or forget", args[0])
}
func licenceAdd(ctx context.Context, args []string) error {
@@ -224,6 +229,66 @@ func licenceKey(ctx context.Context, args []string) error {
return nil
}
// licenceManager names the one node that holds a refreshable-grant licence's refresh token readably
// and refreshes it centrally (novox/hq ADR 0050).
//
// **Only a refreshable-grant licence has one.** A static-key licence has no refresh token to hold, so
// naming a manager for it is refused where the mistake is made rather than kept as a field that means
// nothing — the absent manager is part of what keeps a static key from ever holding a value readably
// at rest.
func licenceManager(ctx context.Context, args []string) error {
if len(args) != 2 {
return errors.New("licence manager <name> <node>")
}
name, node := args[0], args[1]
held, err := openLicences(ctx)
if err != nil {
return err
}
defer held.Close()
if err := held.SetManager(ctx, name, node); err != nil {
return err
}
fmt.Printf("%s holds and refreshes %s.\n"+
" Its refresh token is kept encrypted at rest, readable by %s alone — no other node, and "+
"not this database on its own.\n", node, name, node)
return nil
}
// licenceRefresh mints a new access token for a refreshable-grant licence and seals it to every
// holder (novox/hq ADR 0050). The refresh token stays with the manager and is never delivered.
//
// The vendor's actual refresh is a plug-in this build does not ship (Phase C), so here this reports
// that plainly rather than pretending to have refreshed.
func licenceRefresh(ctx context.Context, args []string) error {
if len(args) != 1 {
return errors.New("licence refresh <name>")
}
name := args[0]
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
held, err := open.Licences(ctx)
if err != nil {
return err
}
inv := open.inventory
sealed, err := held.Refresh(ctx, name, func(node string) (string, error) {
return inv.SealingKeyOf(ctx, node)
})
if err != nil {
return err
}
fmt.Printf("refreshed %s: a new access token sealed to %d holder(s), and the refresh token left "+
"with its manager.\n run `push` to deliver it\n", name, sealed)
return nil
}
func licenceForget(ctx context.Context, args []string) error {
if len(args) != 1 {
return errors.New("licence forget <name>")
+2
View File
@@ -151,6 +151,8 @@ func usage() {
builds [<module>] what has been built lately, and what came of it
builder issue <name> a broker account for a build machine, scoped to build work
licence add|list|use|key model access, under the name a person calls it
licence manager <name> <node> the node that holds a refreshable licence's refresh token
licence refresh <name> mint a new access token and seal it to every holder
rotate <provision> [--consumer <n>] a new credential for every holder, both ends at once
pin <node> <provision> <from> which node this one gets a provision from
unpin <node> <provision> put that question back