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:
@@ -21,6 +21,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/novox/mesh-control/internal/licences/adapters"
|
||||
"github.com/novox/mesh-control/internal/secrets"
|
||||
"github.com/novox/mesh-control/internal/store"
|
||||
)
|
||||
|
||||
@@ -304,6 +305,257 @@ func (l *Licences) Accept(ctx context.Context, licence, value string, keys Seali
|
||||
return sealed, nil
|
||||
}
|
||||
|
||||
// ManagerOf is the node that holds a licence's refresh token readably, empty if none is named.
|
||||
//
|
||||
// Empty for every static-key licence, which has nothing to refresh, and for a refreshable-grant one
|
||||
// before its manager is set (novox/hq ADR 0050).
|
||||
func (l *Licences) ManagerOf(ctx context.Context, licence string) (string, error) {
|
||||
var manager *string
|
||||
err := l.store.Pool().QueryRow(ctx,
|
||||
`select manager from licence where name = $1`, licence).Scan(&manager)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", fmt.Errorf("this mesh has no licence called %q", licence)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if manager == nil {
|
||||
return "", nil
|
||||
}
|
||||
return *manager, nil
|
||||
}
|
||||
|
||||
// SetManager names the one node that holds a licence's refresh token and refreshes it centrally.
|
||||
//
|
||||
// **Only a refreshable-grant licence has one.** A static-key licence has no refresh token, so naming
|
||||
// a manager for it is refused rather than kept — the absent manager is part of what keeps a static
|
||||
// key from ever growing a value something holds readably at rest (novox/hq ADR 0050). The bound
|
||||
// "the manager node only" starts here, at the one place a manager is written.
|
||||
func (l *Licences) SetManager(ctx context.Context, licence, node string) error {
|
||||
if strings.TrimSpace(node) == "" {
|
||||
return errors.New("a manager needs a node")
|
||||
}
|
||||
vendor, err := l.vendorOf(ctx, licence)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
adapter, err := adapters.For(vendor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if adapter.Shape() != adapters.RefreshableGrant {
|
||||
return fmt.Errorf(
|
||||
"%q is a %s licence; only a refreshable-grant licence has a manager, because only it "+
|
||||
"has a refresh token to hold", licence, adapter.Shape())
|
||||
}
|
||||
tag, err := l.store.Pool().Exec(ctx,
|
||||
`update licence set manager = $2 where name = $1`, licence, node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("this mesh has no licence called %q", licence)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetRefreshGrant stores, or replaces, a licence's refresh token as its at-rest envelope.
|
||||
//
|
||||
// **The envelope is opaque here.** It was produced by the manager node — the only place the refresh
|
||||
// token is ever in the clear (novox/hq ADR 0050, Phase C) — and this context keeps it and forwards
|
||||
// it to a refresh without opening it. The control plane holds no key that could, which is the whole
|
||||
// point of where the carve-out draws the line.
|
||||
func (l *Licences) SetRefreshGrant(ctx context.Context, licence string, at secrets.AtRest) error {
|
||||
if at.Token == "" || at.WrappedKey == "" || at.ManagerKey == "" {
|
||||
return errors.New("an incomplete refresh-token envelope is not one to keep")
|
||||
}
|
||||
_, err := l.store.Pool().Exec(ctx,
|
||||
`insert into refresh_grant (licence, token, wrapped_key, manager_key)
|
||||
values ($1, $2, $3, $4)
|
||||
on conflict (licence) do update set
|
||||
token = excluded.token, wrapped_key = excluded.wrapped_key,
|
||||
manager_key = excluded.manager_key, updated_at = now()`,
|
||||
licence, at.Token, at.WrappedKey, at.ManagerKey)
|
||||
if err != nil && strings.Contains(err.Error(), "refresh_grant_licence_fkey") {
|
||||
return fmt.Errorf("this mesh has no licence called %q", licence)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// RefreshGrant is a licence's refresh token as its at-rest envelope, and whether one is stored.
|
||||
func (l *Licences) RefreshGrant(ctx context.Context, licence string) (secrets.AtRest, bool, error) {
|
||||
var at secrets.AtRest
|
||||
err := l.store.Pool().QueryRow(ctx,
|
||||
`select token, wrapped_key, manager_key from refresh_grant where licence = $1`, licence).
|
||||
Scan(&at.Token, &at.WrappedKey, &at.ManagerKey)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return secrets.AtRest{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return secrets.AtRest{}, false, err
|
||||
}
|
||||
return at, true, nil
|
||||
}
|
||||
|
||||
// Refresh mints a new access token for a refreshable-grant licence, seals it to every holder, and
|
||||
// leaves the refresh token where it is — re-encrypted at rest if the vendor rotated it too.
|
||||
//
|
||||
// **The lease.** One refresh of a licence at a time, held as a transaction-scoped advisory lock on
|
||||
// the licence: two refreshes serialise rather than both minting a token and racing to publish. This
|
||||
// is doc 13's single-actor rotation lease, expressed against the database that is the source of
|
||||
// truth (novox/hq ADR 0003) rather than reinvented.
|
||||
//
|
||||
// **It reuses rotation's reseal-and-publish, not its value source.** doc 13's rotate discards a
|
||||
// mesh-minted secret and regenerates it; here the new access token comes from the vendor refresh
|
||||
// instead, and is then sealed per holder (secrets.Seal, exactly as Accept does) and delivered on the
|
||||
// next push — the same publish path any credential change takes. The refresh token is never sealed
|
||||
// to a holder, so `KeyFor` cannot deliver it.
|
||||
//
|
||||
// **All or nothing.** The reseal, the grant replacement and the lease are one transaction: a refresh
|
||||
// that cannot finish leaves every holder on the token it had and the stored grant untouched — a
|
||||
// licence that has not refreshed, which is far better than one half refreshed (doc 13).
|
||||
//
|
||||
// The vendor refresh itself is the injected VendorRefresher (novox/hq ADR 0050, Phase C); with none
|
||||
// plugged in, the adapter refuses here and nothing is changed.
|
||||
func (l *Licences) Refresh(ctx context.Context, licence string, keys SealingKeys) (int, error) {
|
||||
tx, err := l.store.Pool().Begin(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
|
||||
|
||||
// The lease. Released when the transaction ends, either way.
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
refresher, ok := adapter.(adapters.Refresher)
|
||||
if !ok {
|
||||
// The type assertion is what gates the carve-out to refreshable-grant vendors: a static key
|
||||
// is not a Refresher, so it can never reach the machinery that holds a token readably.
|
||||
return 0, fmt.Errorf(
|
||||
"%q is a %s licence and cannot be refreshed; only a refreshable-grant licence has a "+
|
||||
"refresh token", licence, adapter.Shape())
|
||||
}
|
||||
if manager == nil || *manager == "" {
|
||||
return 0, fmt.Errorf(
|
||||
"%q has no manager named, so there is no node to refresh it. Name one:\n"+
|
||||
" licence manager %s <node>", licence, licence)
|
||||
}
|
||||
|
||||
var at secrets.AtRest
|
||||
err = tx.QueryRow(ctx,
|
||||
`select token, wrapped_key, manager_key from refresh_grant where licence = $1`, licence).
|
||||
Scan(&at.Token, &at.WrappedKey, &at.ManagerKey)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, fmt.Errorf(
|
||||
"%q has no refresh token stored yet; its manager %s adopts one first "+
|
||||
"(novox/hq ADR 0050, Phase C)", licence, *manager)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
result, err := refresher.Refresh(ctx,
|
||||
adapters.RefreshInput{Licence: licence, Manager: *manager, AtRest: at})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if strings.TrimSpace(result.AccessToken) == "" {
|
||||
return 0, fmt.Errorf(
|
||||
"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.
|
||||
holders, err := holdersTx(ctx, tx, licence)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
sealed := 0
|
||||
for _, h := range holders {
|
||||
key, err := keys(h.Node)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if key == "" {
|
||||
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))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`update licence_holder set sealed = $4, node_key = $5
|
||||
where licence = $1 and node = $2 and module = $3`,
|
||||
h.Licence, h.Node, h.Module, blob, key); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
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 == "" {
|
||||
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 {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return sealed, nil
|
||||
}
|
||||
|
||||
// holdersTx reads a licence's holders inside a transaction, so the reseal set is consistent under
|
||||
// the refresh lease.
|
||||
func holdersTx(ctx context.Context, tx pgx.Tx, licence string) ([]Holder, error) {
|
||||
rows, err := tx.Query(ctx,
|
||||
`select licence, node, module, coalesce(sealed, '') from licence_holder
|
||||
where licence = $1 order by node, module`, licence)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Holder
|
||||
for rows.Next() {
|
||||
var h Holder
|
||||
if err := rows.Scan(&h.Licence, &h.Node, &h.Module, &h.Sealed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, h)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Names is every licence's name, sorted — what a refusal lists when a consumer has not chosen.
|
||||
func Names(all []Licence) []string {
|
||||
out := make([]string, 0, len(all))
|
||||
|
||||
Reference in New Issue
Block a user