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
113 lines
3.6 KiB
Go
113 lines
3.6 KiB
Go
package adapters
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/novox/mesh-control/internal/secrets"
|
|
)
|
|
|
|
func TestTheTwoShapesAreSelectedByVendor(t *testing.T) {
|
|
static, err := For("anthropic-api-key")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if static.Shape() != StaticKey {
|
|
t.Fatalf("anthropic-api-key is %q, expected static-key", static.Shape())
|
|
}
|
|
|
|
grant, err := For("anthropic")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if grant.Shape() != RefreshableGrant {
|
|
t.Fatalf("anthropic is %q, expected refreshable-grant", grant.Shape())
|
|
}
|
|
}
|
|
|
|
// The type assertion is what gates the carve-out: a static key is not a Refresher, so it can never
|
|
// reach the machinery that holds a token readably. A refreshable grant is one.
|
|
func TestOnlyARefreshableGrantIsARefresher(t *testing.T) {
|
|
static, _ := For("anthropic-api-key")
|
|
if _, ok := static.(Refresher); ok {
|
|
t.Fatal("a static-key adapter is a Refresher, so the carve-out is not gated by shape")
|
|
}
|
|
grant, _ := For("anthropic")
|
|
if _, ok := grant.(Refresher); !ok {
|
|
t.Fatal("a refreshable-grant adapter is not a Refresher, so it cannot be refreshed")
|
|
}
|
|
}
|
|
|
|
// With nothing plugged in, a refresh is refused in a way that names why — not answered with a
|
|
// silent no-op that would look like a refresh that changed nothing.
|
|
func TestARefreshWithNoRefresherPluggedInIsRefused(t *testing.T) {
|
|
grant, _ := For("anthropic")
|
|
r := grant.(Refresher)
|
|
_, err := r.Refresh(context.Background(), RefreshInput{Licence: "personal"})
|
|
if err == nil {
|
|
t.Fatal("a refresh succeeded with no vendor refresher plugged in")
|
|
}
|
|
if !strings.Contains(err.Error(), "Phase C") {
|
|
t.Fatalf("the refusal does not point at the missing plug-in: %v", err)
|
|
}
|
|
}
|
|
|
|
type fakeVendor struct {
|
|
result RefreshResult
|
|
got RefreshInput
|
|
}
|
|
|
|
func (f *fakeVendor) Refresh(_ context.Context, in RefreshInput) (RefreshResult, error) {
|
|
f.got = in
|
|
return f.result, nil
|
|
}
|
|
|
|
// A plugged-in refresher is dispatched to, and is handed the at-rest envelope (never a plaintext
|
|
// refresh token) plus which node is the manager.
|
|
func TestAPluggedInRefresherIsDispatchedTo(t *testing.T) {
|
|
fake := &fakeVendor{result: RefreshResult{AccessToken: "at-new"}}
|
|
RegisterRefresher("anthropic", fake)
|
|
defer RegisterRefresher("anthropic", nil)
|
|
|
|
grant, _ := For("anthropic")
|
|
r := grant.(Refresher)
|
|
in := RefreshInput{
|
|
Licence: "personal", Manager: "workstation",
|
|
AtRest: secrets.AtRest{Token: "tok", WrappedKey: "wk", ManagerKey: "mk"},
|
|
}
|
|
out, err := r.Refresh(context.Background(), in)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out.AccessToken != "at-new" {
|
|
t.Fatalf("the dispatched result did not come back: %q", out.AccessToken)
|
|
}
|
|
if fake.got.Manager != "workstation" || fake.got.AtRest.Token != "tok" {
|
|
t.Fatalf("the refresher was handed the wrong input: %+v", fake.got)
|
|
}
|
|
}
|
|
|
|
// A vendor this build has no adapter for is refused, and the refusal lists what it does know so a
|
|
// typo and an unsupported vendor are told apart.
|
|
func TestAnUnknownVendorIsRefusedWithTheList(t *testing.T) {
|
|
_, err := For("acme-models")
|
|
if err == nil {
|
|
t.Fatal("an unknown vendor returned an adapter")
|
|
}
|
|
if !strings.Contains(err.Error(), "anthropic") {
|
|
t.Fatalf("the refusal does not list the known vendors: %v", err)
|
|
}
|
|
}
|
|
|
|
// Both shapes deliver their stored blob unchanged: a static key has no vendor step, and a
|
|
// refreshable grant's holder row holds an access token with no refresh token to strip.
|
|
func TestBothShapesDeliverTheStoredBlobUnchanged(t *testing.T) {
|
|
for _, vendor := range []string{"anthropic", "anthropic-api-key"} {
|
|
a, _ := For(vendor)
|
|
if got := a.Deliver("sealed-blob"); got != "sealed-blob" {
|
|
t.Fatalf("%s changed the delivered blob to %q", vendor, got)
|
|
}
|
|
}
|
|
}
|