model-access: refreshable-grant — manager holds the refresh token, control plane never reads it #15
+223
-2
@@ -7,8 +7,11 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-control/internal/secrets"
|
||||
)
|
||||
|
||||
// licenceCommand is everything about model access the mesh holds.
|
||||
@@ -18,7 +21,8 @@ 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|manager|refresh|forget")
|
||||
return errors.New(
|
||||
"licence add|list|use|release|key|manager|grant|set-grant|refresh|submit-refresh|forget")
|
||||
}
|
||||
switch args[0] {
|
||||
case "add":
|
||||
@@ -33,13 +37,20 @@ func licenceCommand(ctx context.Context, args []string) error {
|
||||
return licenceKey(ctx, args[1:])
|
||||
case "manager":
|
||||
return licenceManager(ctx, args[1:])
|
||||
case "grant":
|
||||
return licenceGrant(ctx, args[1:])
|
||||
case "set-grant":
|
||||
return licenceSetGrant(ctx, args[1:])
|
||||
case "refresh":
|
||||
return licenceRefresh(ctx, args[1:])
|
||||
case "submit-refresh":
|
||||
return licenceSubmitRefresh(ctx, args[1:])
|
||||
case "forget":
|
||||
return licenceForget(ctx, args[1:])
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"licence %q; it is add, list, use, release, key, manager, refresh or forget", args[0])
|
||||
"licence %q; it is add, list, use, release, key, manager, grant, set-grant, refresh, "+
|
||||
"submit-refresh or forget", args[0])
|
||||
}
|
||||
|
||||
func licenceAdd(ctx context.Context, args []string) error {
|
||||
@@ -256,6 +267,216 @@ func licenceManager(ctx context.Context, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// envelopeJSON is the wire shape of a refresh-token at-rest envelope on this command surface: the
|
||||
// three opaque parts of secrets.AtRest and nothing else.
|
||||
//
|
||||
// **Every field of it is ciphertext or a public key.** `token` is the refresh token under a data
|
||||
// key, `wrapped_key` is that data key sealed to the manager node, `manager_key` is the manager's
|
||||
// public sealing key. None of them is the refresh token in the clear — which is why this surface may
|
||||
// print one out (`grant`) and read one in (`set-grant`, `submit-refresh`) without the control plane
|
||||
// ever holding a refresh token it could read. The manager runtime, on the manager node, is the only
|
||||
// place these open (novox/hq ADR 0050, Phase C).
|
||||
type envelopeJSON struct {
|
||||
Token string `json:"token"`
|
||||
WrappedKey string `json:"wrapped_key"`
|
||||
ManagerKey string `json:"manager_key"`
|
||||
}
|
||||
|
||||
func (e envelopeJSON) atRest() secrets.AtRest {
|
||||
return secrets.AtRest{Token: e.Token, WrappedKey: e.WrappedKey, ManagerKey: e.ManagerKey}
|
||||
}
|
||||
|
||||
func envelopeOf(a secrets.AtRest) envelopeJSON {
|
||||
return envelopeJSON{Token: a.Token, WrappedKey: a.WrappedKey, ManagerKey: a.ManagerKey}
|
||||
}
|
||||
|
||||
// readEnvelope reads an at-rest envelope from a file or standard input as JSON.
|
||||
func readEnvelope(from string) (envelopeJSON, error) {
|
||||
var raw []byte
|
||||
var err error
|
||||
if from != "" {
|
||||
raw, err = os.ReadFile(from)
|
||||
} else {
|
||||
raw, err = readAllStdin()
|
||||
}
|
||||
if err != nil {
|
||||
return envelopeJSON{}, err
|
||||
}
|
||||
var env envelopeJSON
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
return envelopeJSON{}, fmt.Errorf("the refresh-token envelope is not JSON: %w", err)
|
||||
}
|
||||
if env.Token == "" || env.WrappedKey == "" || env.ManagerKey == "" {
|
||||
return envelopeJSON{}, errors.New(
|
||||
"an at-rest envelope is {token, wrapped_key, manager_key}, and one part is missing")
|
||||
}
|
||||
return env, nil
|
||||
}
|
||||
|
||||
func readAllStdin() ([]byte, error) {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
return io.ReadAll(reader)
|
||||
}
|
||||
|
||||
// licenceGrant prints a licence's refresh-token envelope, so the manager runtime can fetch the
|
||||
// opaque thing it will open on the manager node (novox/hq ADR 0050, Phase C).
|
||||
//
|
||||
// **What is printed is ciphertext.** The envelope is the refresh token sealed at rest to the manager
|
||||
// node's key; it opens nowhere but that node. Printing it here is how the manager runtime — which
|
||||
// does not read this database directly — is handed the envelope to open, and it discloses nothing a
|
||||
// copy of the store did not already hold.
|
||||
func licenceGrant(ctx context.Context, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("licence grant <name>")
|
||||
}
|
||||
name := args[0]
|
||||
|
||||
held, err := openLicences(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer held.Close()
|
||||
|
||||
at, ok, err := held.RefreshGrant(ctx, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
// Said, not printed as an empty object: a licence with no grant and a failed read must not
|
||||
// look the same to whatever parses this.
|
||||
return fmt.Errorf(
|
||||
"%q has no refresh token stored; its manager adopts one first with `licence set-grant %s`",
|
||||
name, name)
|
||||
}
|
||||
out, err := json.Marshal(envelopeOf(at))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(out))
|
||||
return nil
|
||||
}
|
||||
|
||||
// licenceSetGrant stores a refresh-token envelope the manager runtime produced — adoption, and the
|
||||
// re-seal after a rotation done outside this process (novox/hq ADR 0050, Phase C).
|
||||
//
|
||||
// **It takes an envelope, never a refresh token.** The manager node reads the operator's refresh
|
||||
// token, seals it at rest to its own key, and hands the sealed envelope here. So the one moment a
|
||||
// refresh token is in the clear is on the manager node, never in the control plane — the same bound
|
||||
// the whole carve-out keeps. This surface refuses anything that is not a complete envelope rather
|
||||
// than storing half of one.
|
||||
func licenceSetGrant(ctx context.Context, args []string) error {
|
||||
set := flag.NewFlagSet("licence set-grant", flag.ContinueOnError)
|
||||
from := set.String("file", "", "read the envelope from a file instead of standard input")
|
||||
positionals, err := parseAround(set, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(positionals) != 1 {
|
||||
return errors.New("licence set-grant <name> [--file <path>]")
|
||||
}
|
||||
name := positionals[0]
|
||||
|
||||
env, err := readEnvelope(*from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
held, err := openLicences(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer held.Close()
|
||||
if err := held.SetRefreshGrant(ctx, name, env.atRest()); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s now holds a refresh token for %s, encrypted at rest and readable by that node "+
|
||||
"alone.\n the control plane stored the envelope without opening it\n",
|
||||
"the manager", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// licenceSubmitRefresh publishes a refresh a MANAGER NODE already performed: the new access token is
|
||||
// sealed to every holder, and a rotated refresh token replaces the stored envelope (novox/hq ADR
|
||||
// 0050, Phase C).
|
||||
//
|
||||
// **This is the boundary the invariant rests on.** The manager runtime, on the manager node, opened
|
||||
// the at-rest envelope with that node's key, called the vendor's OAuth endpoint, and produced this:
|
||||
// the new access token in the clear, and — only if the vendor rotated it — the refresh token already
|
||||
// re-sealed at rest. This reads exactly those two things and no refresh token in the clear ever
|
||||
// reaches it, because it is never given one. The access token is sealed per holder and discarded,
|
||||
// as any accepted key is; the rotated envelope is stored opaque.
|
||||
func licenceSubmitRefresh(ctx context.Context, args []string) error {
|
||||
set := flag.NewFlagSet("licence submit-refresh", flag.ContinueOnError)
|
||||
accessFrom := set.String("access-file", "",
|
||||
"read the new access token from a file instead of standard input")
|
||||
grantFrom := set.String("grant-file", "",
|
||||
"the rotated refresh-token envelope, if the vendor rotated it; omit if it did not")
|
||||
positionals, err := parseAround(set, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(positionals) != 1 {
|
||||
return errors.New(
|
||||
"licence submit-refresh <name> [--access-file <path>] [--grant-file <path>]")
|
||||
}
|
||||
name := positionals[0]
|
||||
|
||||
var accessToken string
|
||||
if *accessFrom != "" {
|
||||
raw, err := os.ReadFile(*accessFrom)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
accessToken = strings.TrimSpace(string(raw))
|
||||
} else {
|
||||
raw, err := readAllStdin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
accessToken = strings.TrimSpace(string(raw))
|
||||
}
|
||||
if accessToken == "" {
|
||||
return errors.New("no access token was given, so there is nothing to seal")
|
||||
}
|
||||
|
||||
// The rotated envelope is optional: absent, the stored refresh token is left exactly as it was.
|
||||
var rotated *secrets.AtRest
|
||||
if *grantFrom != "" {
|
||||
env, err := readEnvelope(*grantFrom)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
at := env.atRest()
|
||||
rotated = &at
|
||||
}
|
||||
|
||||
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.SubmitRefresh(ctx, name, accessToken, rotated, func(node string) (string, error) {
|
||||
return inv.SealingKeyOf(ctx, node)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rotatedNote := "the refresh token was left with its manager unchanged"
|
||||
if rotated != nil {
|
||||
rotatedNote = "the rotated refresh token replaced the stored envelope, still readable by the " +
|
||||
"manager node alone"
|
||||
}
|
||||
fmt.Printf("submitted a refresh for %s: a new access token sealed to %d holder(s), and %s.\n"+
|
||||
" run `push` to deliver it\n", name, sealed, rotatedNote)
|
||||
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.
|
||||
//
|
||||
|
||||
+106
-14
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user