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