M-3: licences and bindings kept in the bus's own storage

The state licences holds one key per licence by its key form, never a secret, with its
latest reading beside it; the state bindings, the same local name the consumers read,
holds each consumer's licence, kind, generation, since, why and by, written by the store
itself. Each consumer counts its own generations, past what its node reported.

Builds: design 39 bus-and-switch §2, §3, B11, B22

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
This commit is contained in:
2026-11-11 23:16:25 +01:00
parent 5ca3876bea
commit 15094d6e72
8 changed files with 520 additions and 239 deletions
@@ -0,0 +1,180 @@
package main
// The manager's kept data on the bus (design 39 bus-and-switch §2, §3, B11, B22): the state `licences`, one key per
// licence by its key form (§10), and the state `bindings`, one key per consumer, its value the form the consumers
// have always read. Written by the manager alone.
import (
"context"
"encoding/json"
"errors"
"sort"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// keptState is a state this module keeps, as mesh-sdk's KeptState answers it.
type keptState interface {
Get(key string) (*stdio.StateEntry, error)
Put(key string, value any) (uint64, error)
PutTTL(key string, value any, ttl time.Duration) (uint64, error)
Create(key string, value any) (uint64, error)
CreateTTL(key string, value any, ttl time.Duration) (uint64, error)
Delete(key string) error
Keys() ([]string, error)
History(key string) ([]stdio.StateRevision, error)
}
// BindingState is the value of a consumer's key in `bindings` (§3, B22: the form 0 its readers decode): no secret,
// only what it should hold, which generation, since when, why and by whom.
type BindingState struct {
Licence string `json:"licence"`
Kind string `json:"kind"`
Generation int64 `json:"generation"`
Since string `json:"since,omitempty"`
Why string `json:"why,omitempty"`
By string `json:"by,omitempty"`
}
// BusStore keeps the manager's data in the bus's own storage.
type BusStore struct {
LicencesState keptState
BindingsState keptState
Now func() time.Time
rest
}
func (s *BusStore) get(st keptState, key string, into any) (bool, error) {
e, err := st.Get(key)
if err != nil || e == nil || len(e.Value) == 0 || string(e.Value) == "null" {
return false, err
}
return true, json.Unmarshal(e.Value, into)
}
func (s *BusStore) Licences(context.Context) ([]Licence, error) {
keys, err := s.LicencesState.Keys()
if err != nil {
return nil, err
}
out := []Licence{}
for _, k := range keys {
var l Licence
if ok, err := s.get(s.LicencesState, k, &l); err != nil {
return nil, err
} else if ok {
out = append(out, l)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
func (s *BusStore) Licence(_ context.Context, name string) (*Licence, error) {
var l Licence
ok, err := s.get(s.LicencesState, KeyFor(name), &l)
if err != nil || !ok || l.Name != name {
return nil, err
}
return &l, nil
}
func (s *BusStore) LicenceForAccount(ctx context.Context, account string) (*Licence, error) {
all, err := s.Licences(ctx)
if err != nil {
return nil, err
}
for _, l := range all {
if l.AccountUUID == account {
return &l, nil
}
}
return nil, nil
}
func (s *BusStore) SaveLicence(_ context.Context, l Licence) error {
if l.Name == "" {
return errors.New("a licence has a name")
}
_, err := s.LicencesState.Put(KeyFor(l.Name), l)
return err
}
func (s *BusStore) Bindings(context.Context) ([]Binding, error) {
keys, err := s.BindingsState.Keys()
if err != nil {
return nil, err
}
out := []Binding{}
for _, k := range keys {
if b, err := s.binding(k); err != nil {
return nil, err
} else if b != nil {
out = append(out, *b)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer })
return out, nil
}
func (s *BusStore) binding(consumer string) (*Binding, error) {
var v BindingState
ok, err := s.get(s.BindingsState, consumer, &v)
if err != nil || !ok {
return nil, err
}
return &Binding{Consumer: consumer, Licence: v.Licence, Kind: v.Kind, Generation: v.Generation, Since: v.Since,
Why: v.Why, By: v.By}, nil
}
func (s *BusStore) Binding(_ context.Context, consumer string) (*Binding, error) {
return s.binding(consumer)
}
// Bind writes a consumer's key at its next generation: one more than the higher of its own and the floor (B11).
func (s *BusStore) Bind(_ context.Context, consumer, licence string, m Move) (Binding, error) {
before, err := s.binding(consumer)
if err != nil {
return Binding{}, err
}
generation := m.floor(consumer)
if before != nil {
generation = max(generation, before.Generation)
}
b := Binding{Consumer: consumer, Licence: licence, Kind: m.Kind, Generation: generation + 1,
Since: s.Now().UTC().Format(time.RFC3339), Why: m.Why, By: m.By}
_, err = s.BindingsState.Put(consumer, BindingState{Licence: b.Licence, Kind: b.Kind, Generation: b.Generation,
Since: b.Since, Why: b.Why, By: b.By})
return b, err
}
func (s *BusStore) Unbind(_ context.Context, consumer string) (bool, error) {
before, err := s.binding(consumer)
if err != nil || before == nil {
return false, err
}
return true, s.BindingsState.Delete(consumer)
}
// Advance gives every consumer bound to licence its next generation, keeping its licence and since.
func (s *BusStore) Advance(ctx context.Context, licence string, m Move) ([]Binding, error) {
all, err := s.Bindings(ctx)
if err != nil {
return nil, err
}
var out []Binding
for _, b := range all {
if b.Licence != licence {
continue
}
b.Generation = max(b.Generation, m.floor(b.Consumer)) + 1
b.Kind, b.Why, b.By = m.Kind, m.Why, m.By
if _, err := s.BindingsState.Put(b.Consumer, BindingState{Licence: b.Licence, Kind: b.Kind,
Generation: b.Generation, Since: b.Since, Why: b.Why, By: b.By}); err != nil {
return out, err
}
out = append(out, b)
}
return out, nil
}
@@ -0,0 +1,178 @@
package main
import (
"context"
"encoding/json"
"errors"
"sort"
"strings"
"sync"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// memState is a state of the bus as the runtime answers it, in memory: values, their past values, keys' lifetimes.
type memState struct {
mu sync.Mutex
now func() time.Time
values map[string]json.RawMessage
until map[string]time.Time
history map[string][]stdio.StateRevision
rev uint64
writes int
}
func newMemState(now func() time.Time) *memState {
return &memState{now: now, values: map[string]json.RawMessage{}, until: map[string]time.Time{},
history: map[string][]stdio.StateRevision{}}
}
func (s *memState) live(key string) bool {
if u, ok := s.until[key]; ok && !s.now().Before(u) {
delete(s.values, key)
delete(s.until, key)
}
_, ok := s.values[key]
return ok
}
func (s *memState) Get(key string) (*stdio.StateEntry, error) {
s.mu.Lock()
defer s.mu.Unlock()
if !s.live(key) {
return nil, nil
}
return &stdio.StateEntry{Key: key, Value: s.values[key]}, nil
}
func (s *memState) write(key string, value any, ttl time.Duration, create bool) (uint64, error) {
s.mu.Lock()
defer s.mu.Unlock()
if create && s.live(key) {
return 0, errors.New("the key has a value")
}
raw, err := json.Marshal(value)
if err != nil {
return 0, err
}
s.rev++
s.writes++
s.values[key] = raw
delete(s.until, key)
if ttl > 0 {
s.until[key] = s.now().Add(ttl)
}
s.history[key] = append(s.history[key], stdio.StateRevision{Key: key, Value: raw, Revision: s.rev, Op: "put", At: s.now()})
return s.rev, nil
}
func (s *memState) Put(key string, value any) (uint64, error) { return s.write(key, value, 0, false) }
func (s *memState) PutTTL(key string, value any, ttl time.Duration) (uint64, error) {
return s.write(key, value, ttl, false)
}
func (s *memState) Create(key string, value any) (uint64, error) { return s.write(key, value, 0, true) }
func (s *memState) CreateTTL(key string, value any, ttl time.Duration) (uint64, error) {
return s.write(key, value, ttl, true)
}
func (s *memState) Delete(key string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.rev++
s.writes++
delete(s.values, key)
delete(s.until, key)
s.history[key] = append(s.history[key], stdio.StateRevision{Key: key, Revision: s.rev, Op: "delete", At: s.now()})
return nil
}
func (s *memState) Keys() ([]string, error) {
s.mu.Lock()
defer s.mu.Unlock()
out := []string{}
for k := range s.values {
if s.live(k) {
out = append(out, k)
}
}
sort.Strings(out)
return out, nil
}
func (s *memState) History(key string) ([]stdio.StateRevision, error) {
s.mu.Lock()
defer s.mu.Unlock()
return append([]stdio.StateRevision(nil), s.history[key]...), nil
}
// design 39 bus-and-switch §2: a rotation and a reading each write the licence's key, by its key form, and no field
// holds a token.
func TestARotationAndAReadingWriteTheLicenceKey(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
key := KeyFor(licence1)
before := len(mm.licences.history[key])
if _, err := mm.m.Rotate(ctx, licence1, true); err != nil {
t.Fatal(err)
}
if err := mm.m.ReadUsage(ctx); err != nil {
t.Fatal(err)
}
if got := len(mm.licences.history[key]); got < before+2 {
t.Fatalf("the key %s was written %d times by a rotation and a reading, want 2", key, got-before)
}
for _, r := range mm.licences.history[key] {
for _, token := range []string{"rt-", "at-"} {
if strings.Contains(string(r.Value), token) {
t.Fatalf("a value of %s holds %s: %s", key, token, r.Value)
}
}
}
var l Licence
_ = json.Unmarshal(mm.licences.values[key], &l)
if l.Name != licence1 || l.AccountUUID != "acct-1" || !strings.Contains(string(mm.licences.values[key]), `"account":"acct-1"`) {
t.Fatalf("the key holds %+v", l)
}
}
// design 39 bus-and-switch §3, B11: bind, switch, release and a rotation each write or delete the consumer's key with
// its why and by; each consumer counts its own generations, past what its node reported.
func TestEachMoveWritesTheBindingWithWhyAndBy(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
_ = mm.store.SaveLicence(ctx, Licence{Name: "api", Kind: "api-key", Generation: 1})
mm.m.Reported = func(node string) int64 {
if node == "server" {
return 40
}
return 0
}
asked := WithCause(ctx, Cause{Why: "the switch verb", By: "jane"})
if _, err := mm.m.Bind(asked, "server", "api"); err != nil {
t.Fatal(err)
}
if b := mm.bound("server"); b.Licence != "api" || b.Generation != 41 || b.Why != "the switch verb" || b.By != "jane" ||
b.Since == "" || b.Kind != "api-key" {
t.Fatalf("server's binding %+v", b)
}
laptop := mm.bound("laptop").Generation
if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil {
t.Fatal(err)
}
if b := mm.bound("laptop"); b.Generation != laptop+1 || b.Licence != "api" {
t.Fatalf("laptop's switch %+v, from generation %d", b, laptop)
}
if _, err := mm.m.Release(WithCause(ctx, Cause{Why: "the release verb", By: "jane"}), "server"); err != nil {
t.Fatal(err)
}
if _, ok := mm.bindings.values["server"]; ok {
t.Fatal("release left the key")
}
if h, _ := mm.bindings.History("server"); len(h) != 2 || h[1].Op != "delete" {
t.Fatalf("server's history %v", h)
}
}
@@ -63,7 +63,7 @@ func assertRefused(t *testing.T, mm *miniMesh) {
said = said || strings.HasPrefix(e, "licence.refused ")
}
audited := false
for _, a := range mm.store.Audits {
for _, a := range mm.mem.Audits {
audited = audited || a["what"] == "refused"
}
if !said || !audited {
@@ -71,9 +71,9 @@ func manager() (*Manager, error) {
if n := os.Getenv("MESH_NODE"); n != "" {
node = n
}
bindings := stdio.State("bindings")
built = &Manager{
Store: store,
Store: &BusStore{LicencesState: stdio.State("licences"), BindingsState: stdio.State("bindings"), Now: time.Now,
rest: store},
Vendor: LiveVendor(),
Crypt: crypt,
Grants: VaultGrants{Keys: keys},
@@ -86,16 +86,16 @@ func manager() (*Manager, error) {
}
return a, json.Unmarshal(raw, &a)
},
PutBinding: func(_ context.Context, consumer string, b BindingState) error {
_, err := bindings.Put(consumer, b)
return err
Reported: func(node string) int64 {
heardMu.Lock()
defer heardMu.Unlock()
return heard[node].Generation
},
DeleteBinding: func(_ context.Context, consumer string) error { return bindings.Delete(consumer) },
Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) },
Now: time.Now,
Log: say,
Holder: fmt.Sprintf("%s:%d", node, os.Getpid()),
Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")),
Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) },
Now: time.Now,
Log: say,
Holder: fmt.Sprintf("%s:%d", node, os.Getpid()),
Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")),
}
return built, nil
}
@@ -127,9 +127,14 @@ func keypair(dir string) (KeyPair, error) {
// ---- the long-running half ---------------------------------------------------------------------------
// heard is what every node last reported holding (ADR 0206), by node.
var (
heardMu sync.Mutex
heard = map[string]Holdings{}
)
func daemon() {
var mu sync.Mutex
reports := map[string]Holdings{}
mu, reports := &heardMu, heard
var passing sync.Mutex
pass := func() {
passing.Lock() // one pass at a time: each account is leased, and a pass is cheap
@@ -53,17 +53,6 @@ type Holdings struct {
Generation int64 `json:"generation"`
}
// BindingState is what `bindings` holds for one consumer: no secret, only what it should hold and which
// generation.
type BindingState struct {
Licence string `json:"licence"`
Kind string `json:"kind"`
Generation int64 `json:"generation"`
// Why and By are what moved it and who asked (novox/hq issue 625), so the node can say so when it applies it.
Why string `json:"why,omitempty"`
By string `json:"by,omitempty"`
}
// Cause is why a consumer's binding changed and who or what asked for it (novox/hq issue 625): Why is the verb
// called or the rule that fired, By the caller the runtime proved (stdio.CallerOf) or this module itself.
type Cause struct {
@@ -168,12 +157,11 @@ type Manager struct {
// AskGrant asks a node's agent module for the grant a login left there, sealed to publicKey. An error
// is the node not answering; a nil Sealed is no login waiting.
AskGrant func(ctx context.Context, node, publicKey string) (GrantAnswer, error)
// PutBinding and DeleteBinding are this module's `bindings` state.
PutBinding func(ctx context.Context, consumer string, b BindingState) error
DeleteBinding func(ctx context.Context, consumer string) error
Emit func(event string, body map[string]any) error
Now func() time.Time
Log func(format string, args ...any)
// Reported is the binding generation a node last reported applying (B11), 0 when none is known.
Reported func(node string) int64
Emit func(event string, body map[string]any) error
Now func() time.Time
Log func(format string, args ...any)
// Holder names this process in a lease, so a second run is told apart.
Holder string
Settings Settings
@@ -242,17 +230,9 @@ func (m *Manager) CandidatesIn(ctx context.Context, reports []Holdings) (map[str
// newest first; the first that refreshes is adopted and the rest are settled as skipped without being
// exchanged. Answers what was adopted.
func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, error) {
// **Never a generation a node has already passed** (novox/hq issue 243). A store rebuilt after a loss
// counts from one again, while every node still holds the number it last applied. The nodes no longer
// wait for a higher number, but a binding numbered exactly as the one a node applied would still be
// skipped. So the count is moved past every number reported, before anything here binds.
var highest int64
for _, r := range reports {
highest = max(highest, r.Generation)
}
if err := m.Store.GenerationsAbove(ctx, highest); err != nil {
return nil, err
}
// **Never a generation a node has already passed** (novox/hq issue 243, B11): each consumer's count is its own,
// and moves past the number its node reported applying.
floor := m.floorIn(reports)
byAccount, err := m.CandidatesIn(ctx, reports)
if err != nil {
return nil, err
@@ -265,7 +245,7 @@ func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, e
// A node reporting an account the manager already holds, and bound to nothing, is bound to it
// (ADR 0206 §7) — whenever its report arrives, not only when the licence is adopted: a node whose own
// login is older than the one adopted is never a candidate, and would otherwise never be bound.
if err := m.bindReporters(ctx, reports); err != nil {
if err := m.bindReporters(ctx, reports, floor); err != nil {
return nil, err
}
var adopted []string
@@ -277,7 +257,7 @@ func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, e
continue
}
for i, c := range list {
name, err := m.adoptOne(ctx, c, reports)
name, err := m.adoptOne(ctx, c, reports, floor)
if err != nil {
m.Log("adopting %s's login failed: %v", c.Node, err)
continue
@@ -296,7 +276,7 @@ func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, e
}
// bindReporters binds each reporting node that is bound to nothing to the licence its account already has.
func (m *Manager) bindReporters(ctx context.Context, reports []Holdings) error {
func (m *Manager) bindReporters(ctx context.Context, reports []Holdings, floor func(string) int64) error {
for _, rep := range reports {
if rep.Identity == nil || rep.Identity.AccountUUID == "" {
continue
@@ -314,21 +294,17 @@ func (m *Manager) bindReporters(ctx context.Context, reports []Holdings) error {
if l == nil {
continue
}
b, err := m.Store.Bind(ctx, rep.Node, l.Name)
c := Cause{Why: rep.Node + " reported the account of " + l.Name + " and was bound to nothing", By: Itself}
b, err := m.Store.Bind(ctx, rep.Node, l.Name, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: floor})
if err != nil {
return err
}
c := Cause{Why: rep.Node + " reported the account of " + l.Name + " and was bound to nothing", By: Itself}
m.moved(ctx, rep.Node, "", l.Name, b.Generation, c)
if err := m.PutBinding(ctx, rep.Node, BindingState{Licence: l.Name, Kind: l.Kind, Generation: b.Generation,
Why: c.Why, By: c.By}); err != nil {
return err
}
}
return nil
}
func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings) (string, error) {
func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings, floor func(string) int64) (string, error) {
answer, err := m.AskGrant(ctx, c.Node, m.Keys.PublicKey)
if err != nil {
// Not answering is not an answer: asked again on the next pass.
@@ -429,7 +405,7 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
if b, err := m.Store.Binding(ctx, c.Node); err != nil {
return "", err
} else if b == nil || b.Licence != l.Name {
moved, err := m.Store.Bind(ctx, c.Node, l.Name)
moved, err := m.Store.Bind(ctx, c.Node, l.Name, Move{Kind: l.Kind, Why: cause.Why, By: cause.By, Floor: floor})
if err != nil {
return "", err
}
@@ -446,12 +422,12 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
if b, err := m.Store.Binding(ctx, rep.Node); err != nil || b != nil {
continue
}
if b, err := m.Store.Bind(ctx, rep.Node, l.Name); err == nil {
m.moved(ctx, rep.Node, "", l.Name, b.Generation,
Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself})
bc := Cause{Why: rep.Node + " reports the account of " + l.Name + ", logged in on " + c.Node + ", and was bound to nothing", By: Itself}
if b, err := m.Store.Bind(ctx, rep.Node, l.Name, Move{Kind: l.Kind, Why: bc.Why, By: bc.By, Floor: floor}); err == nil {
m.moved(ctx, rep.Node, "", l.Name, b.Generation, bc)
}
}
if err := m.publishAdvance(ctx, l); err != nil {
if err := m.publishAdvance(ctx, l, floor); err != nil {
return "", err
}
_ = m.Emit("licence.adopted", map[string]any{"licence": l.Name, "from": c.Node, "replaced": held != nil})
@@ -501,21 +477,30 @@ func (m *Manager) grantOf(ctx context.Context, l Licence) (FullGrant, error) {
return g, json.Unmarshal([]byte(plain), &g)
}
// publishAdvance gives every consumer of a licence a new generation, and tells each in the state with the
// cause ctx carries, so the node says why it fetched again (novox/hq issue 625).
func (m *Manager) publishAdvance(ctx context.Context, l Licence) error {
bindings, err := m.Store.Advance(ctx, l.Name)
if err != nil {
return err
}
// publishAdvance gives every consumer of a licence a new generation, with the cause ctx carries, so the node says why
// it fetched again (novox/hq issue 625).
func (m *Manager) publishAdvance(ctx context.Context, l Licence, floor func(string) int64) error {
c := CauseOf(ctx)
for _, b := range bindings {
if err := m.PutBinding(ctx, b.Consumer, BindingState{Licence: b.Licence, Kind: l.Kind, Generation: b.Generation,
Why: c.Why, By: c.By}); err != nil {
return err
_, err := m.Store.Advance(ctx, l.Name, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: floor})
return err
}
// floorIn is the floor of each consumer's generation (B11): the higher of what its node reported in reports and what
// Reported says it last applied. A worker's node is the part of its name before the first dot (ADR 0183 §19).
func (m *Manager) floorIn(reports []Holdings) func(string) int64 {
return func(consumer string) int64 {
node, _, _ := strings.Cut(consumer, ".")
var f int64
for _, r := range reports {
if r.Node == node {
f = max(f, r.Generation)
}
}
if m.Reported != nil {
f = max(f, m.Reported(node))
}
return f
}
return nil
}
// ---- keeping grants alive ----------------------------------------------------------------------------
@@ -599,7 +584,7 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri
m.record(ctx, "rotated", fmt.Sprintf("rotated %s, its token now expiring %s: %s; asked by %s", name,
time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339), c.Why, c.By),
map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt, "why": c.Why, "by": c.By})
if err := m.publishAdvance(ctx, *l); err != nil {
if err := m.publishAdvance(ctx, *l, m.floorIn(nil)); err != nil {
return nil, err
}
return map[string]any{"licence": name, "refreshed": true, "expiresAt": time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339)}, nil
@@ -660,6 +645,11 @@ func (m *Manager) ReadUsage(ctx context.Context) error {
if err := m.Store.RecordUsage(ctx, l.Name, m.Now().UnixMilli(), reading, raw); err != nil {
return err
}
// The latest reading beside its licence, so licences answers from one key (§2).
l.Usage = &reading
if err := m.Store.SaveLicence(ctx, l); err != nil {
return err
}
_ = m.Emit("usage.read", map[string]any{"licence": l.Name, "sessionPct": reading.SessionPct,
"weeklyPct": reading.WeeklyPct, "sonnetPct": reading.SonnetPct})
}
@@ -733,7 +723,8 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[strin
if err != nil {
return nil, err
}
b, err := m.Store.Bind(ctx, consumer, licence)
c := CauseOf(ctx)
b, err := m.Store.Bind(ctx, consumer, licence, Move{Kind: l.Kind, Why: c.Why, By: c.By, Floor: m.floorIn(nil)})
if err != nil {
return nil, err
}
@@ -741,12 +732,7 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[strin
if before != nil {
from = before.Licence
}
c := CauseOf(ctx)
m.moved(ctx, consumer, from, licence, b.Generation, c)
if err := m.PutBinding(ctx, consumer, BindingState{Licence: licence, Kind: l.Kind, Generation: b.Generation,
Why: c.Why, By: c.By}); err != nil {
return nil, err
}
return map[string]any{"consumer": consumer, "licence": licence, "generation": b.Generation, "from": from}, nil
}
@@ -759,9 +745,6 @@ func (m *Manager) Release(ctx context.Context, consumer string) (map[string]any,
if ok, err := m.Store.Unbind(ctx, consumer); err != nil || !ok {
return map[string]any{"consumer": consumer, "released": false, "reason": "it was bound to nothing"}, err
}
if err := m.DeleteBinding(ctx, consumer); err != nil {
return nil, err
}
c := CauseOf(ctx)
m.record(ctx, "released", fmt.Sprintf("released %s from %s: %s; asked by %s", consumer, was.Licence, c.Why, c.By),
map[string]any{"consumer": consumer, "licence": was.Licence, "generation": was.Generation, "why": c.Why, "by": c.By})
@@ -835,7 +818,7 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from, "why": c.Why, "by": c.By})
m.Log("adopted the API key %s from %s; asked by %s", name, from, c.By)
ctx = WithCause(ctx, Cause{Why: "the API key " + name + " adopted from " + from, By: c.By})
if err := m.publishAdvance(ctx, l); err != nil {
if err := m.publishAdvance(ctx, l, m.floorIn(nil)); err != nil {
return nil, err
}
_ = m.Emit("licence.adopted", map[string]any{"licence": name, "from": from, "replaced": held != nil})
@@ -82,12 +82,14 @@ func (g *memGrants) Get(_ context.Context, key, part string) (string, error) {
}
type miniMesh struct {
grants *memGrants
m *Manager
store *MemoryStore
grants *memGrants
m *Manager
store *BusStore
mem *MemoryStore
licences *memState
bindings *memState
vendor *stubVendor
logins map[string]FullGrant // node → what its credentials file holds
state map[string]BindingState
events []string
now time.Time
keys KeyPair
@@ -96,10 +98,12 @@ type miniMesh struct {
func newMesh(t *testing.T) *miniMesh {
t.Helper()
mm := &miniMesh{logins: map[string]FullGrant{}, state: map[string]BindingState{}, now: t0,
mm := &miniMesh{logins: map[string]FullGrant{}, now: t0,
grants: &memGrants{held: map[string]heldGrant{}}}
now := func() time.Time { return mm.now }
mm.store = NewMemoryStore(now)
mm.mem = NewMemoryStore(now)
mm.licences, mm.bindings = newMemState(now), newMemState(now)
mm.store = &BusStore{LicencesState: mm.licences, BindingsState: mm.bindings, Now: now, rest: mm.mem}
mm.vendor = &stubVendor{live: map[string]bool{}, now: now}
crypt, _ := NewCrypt("a key the vault made")
mm.keys, _ = GenerateKeyPair()
@@ -117,8 +121,6 @@ func newMesh(t *testing.T) *miniMesh {
box, err := Seal(string(raw), publicKey)
return GrantAnswer{Sealed: &box, Fingerprint: Fingerprint(g.RefreshToken)}, err
},
PutBinding: func(_ context.Context, c string, b BindingState) error { mm.state[c] = b; return nil },
DeleteBinding: func(_ context.Context, c string) error { delete(mm.state, c); return nil },
Emit: func(event string, body map[string]any) error {
raw, _ := json.Marshal(body)
mm.events = append(mm.events, event+" "+string(raw))
@@ -129,6 +131,15 @@ func newMesh(t *testing.T) *miniMesh {
return mm
}
// bound is a consumer's value in the state bindings, as its readers decode it.
func (mm *miniMesh) bound(consumer string) BindingState {
var b BindingState
if e, _ := mm.bindings.Get(consumer); e != nil {
_ = json.Unmarshal(e.Value, &b)
}
return b
}
func (mm *miniMesh) report(node, rt string, at time.Time, account string) Holdings {
h := Holdings{Node: node, Identity: &Identity{AccountUUID: account, EmailAddress: account + "@example.org"},
Kind: "subscription", ChangedAt: at.Format(time.RFC3339Nano)}
@@ -165,8 +176,8 @@ func TestAManagerWithNoLicenceAdoptsTheNewestLoginThatRefreshesAndNeverExchanges
}
// A first binding follows the login: both nodes reported this account and were bound to nothing.
bs, _ := mm.store.Bindings(ctx)
if len(bs) != 2 || mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 {
t.Fatalf("bindings %v, state %v", bs, mm.state)
if len(bs) != 2 || mm.bound("laptop").Licence != licence1 || mm.bound("server").Licence != licence1 {
t.Fatalf("bindings %v, state %v", bs, mm.bindings.values)
}
// The adoption is said, and so is each binding it made (novox/hq issue 625).
events := strings.Join(mm.events, "|")
@@ -264,9 +275,9 @@ func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
g0 := mm.state["laptop"].Generation
g0 := mm.bound("laptop").Generation
_, _ = mm.m.Rotate(ctx, licence1, true)
g1 := mm.state["laptop"].Generation
g1 := mm.bound("laptop").Generation
if g1 <= g0 {
t.Fatalf("a rotation went from %d to %d", g0, g1)
}
@@ -278,13 +289,13 @@ func TestARotationAndASwitchEachGiveANewerGeneration(t *testing.T) {
if _, err := mm.m.Bind(ctx, "laptop", "api"); err != nil {
t.Fatal(err)
}
if mm.state["laptop"].Licence != "api" || mm.state["laptop"].Generation <= g1 {
t.Fatalf("a switch to a licence rotated less often went backwards: %v", mm.state["laptop"])
if mm.bound("laptop").Licence != "api" || mm.bound("laptop").Generation <= g1 {
t.Fatalf("a switch to a licence rotated less often went backwards: %v", mm.bound("laptop"))
}
if _, err := mm.m.Release(ctx, "laptop"); err != nil {
t.Fatal(err)
}
if _, ok := mm.state["laptop"]; ok {
if _, ok := mm.bindings.values["laptop"]; ok {
t.Fatal("a released consumer still has a binding in the state")
}
}
@@ -333,7 +344,7 @@ func TestNothingTheManagerPublishesKeepsOrListsCarriesAToken(t *testing.T) {
_ = mm.m.ReadUsage(ctx)
ls, _ := mm.m.Licences(ctx)
bs, _ := mm.store.Bindings(ctx)
everything, _ := json.Marshal([]any{mm.events, mm.state, ls, bs})
everything, _ := json.Marshal([]any{mm.events, mm.bindings.values, mm.licences.values, ls, bs})
for _, token := range []string{"rt-secret", "rt-1", "rt-2", "at-1", "at-2", "local-"} {
if strings.Contains(string(everything), token) {
t.Fatalf("%s was published: %s", token, everything)
@@ -393,15 +404,15 @@ func TestANodeReportingAnAdoptedAccountLaterIsBoundToIt(t *testing.T) {
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("novox", "rt-new", true, t0)})
late := mm.login("laptop", "rt-older", true, t0.Add(-24*time.Hour))
_, _ = mm.m.Consider(ctx, []Holdings{late})
if mm.state["laptop"].Licence != licence1 {
t.Fatalf("a node reporting the adopted account later was not bound: %v", mm.state)
if mm.bound("laptop").Licence != licence1 {
t.Fatalf("a node reporting the adopted account later was not bound: %v", mm.bindings.values)
}
if strings.Contains(strings.Join(mm.vendor.exchanged, ","), "rt-older") {
t.Fatal("the older login was exchanged")
}
g := mm.state["laptop"].Generation
g := mm.bound("laptop").Generation
_, _ = mm.m.Consider(ctx, []Holdings{late})
if mm.state["laptop"].Generation != g {
if mm.bound("laptop").Generation != g {
t.Fatal("a node already bound was bound again")
}
}
@@ -411,8 +422,8 @@ func TestALoginToAnotherAccountMovesItsNodeAndNoOther(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0), mm.report("server", "", t0, "acct-1")})
if mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 {
t.Fatalf("%v", mm.state)
if mm.bound("laptop").Licence != licence1 || mm.bound("server").Licence != licence1 {
t.Fatalf("%v", mm.bindings.values)
}
mm.now = t0.Add(time.Hour)
mm.logins["laptop"] = FullGrant{AccessToken: "local", RefreshToken: "rt-b", ExpiresAt: 1}
@@ -422,11 +433,11 @@ func TestALoginToAnotherAccountMovesItsNodeAndNoOther(t *testing.T) {
if len(adopted) != 1 || adopted[0] != "acct-2@example.org" {
t.Fatalf("adopted %v", adopted)
}
if mm.state["laptop"].Licence != "acct-2@example.org" {
t.Fatalf("the node a login was made on stayed bound to %v", mm.state["laptop"])
if mm.bound("laptop").Licence != "acct-2@example.org" {
t.Fatalf("the node a login was made on stayed bound to %v", mm.bound("laptop"))
}
if mm.state["server"].Licence != licence1 {
t.Fatalf("another node moved: %v", mm.state["server"])
if mm.bound("server").Licence != licence1 {
t.Fatalf("another node moved: %v", mm.bound("server"))
}
ls, _ := mm.store.Licences(ctx)
if len(ls) != 2 {
@@ -439,11 +450,11 @@ func TestALoginThatDoesNotRefreshMovesNothing(t *testing.T) {
mm := newMesh(t)
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
g := mm.state["laptop"]
g := mm.bound("laptop")
mm.logins["laptop"] = FullGrant{AccessToken: "local", RefreshToken: "rt-dead", ExpiresAt: 1}
_, _ = mm.m.Consider(ctx, []Holdings{mm.report("laptop", "rt-dead", t0.Add(time.Hour), "acct-2")})
if mm.state["laptop"] != g {
t.Fatalf("a dead login moved its node: %v", mm.state["laptop"])
if mm.bound("laptop") != g {
t.Fatalf("a dead login moved its node: %v", mm.bound("laptop"))
}
}
@@ -483,7 +494,7 @@ func TestARebuiltStoreNeverGivesAGenerationANodeHasPassed(t *testing.T) {
if _, err := mm.m.Consider(ctx, []Holdings{laptop}); err != nil {
t.Fatal(err)
}
if g := mm.state["laptop"].Generation; g <= 16 {
if g := mm.bound("laptop").Generation; g <= 16 {
t.Fatalf("the laptop applied generation 16 and was given %d", g)
}
}
@@ -512,7 +523,7 @@ func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
mm.m.Log = func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) }
ctx := context.Background()
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
if s := mm.state["laptop"]; !strings.Contains(s.Why, "a login to "+licence1+" on laptop") || s.By != Itself {
if s := mm.bound("laptop"); !strings.Contains(s.Why, "a login to "+licence1+" on laptop") || s.By != Itself {
t.Fatalf("a login's binding said %+v", s)
}
file := filepath.Join(t.TempDir(), "key")
@@ -525,7 +536,7 @@ func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
if _, err := mm.m.Bind(asked, "laptop", "api"); err != nil {
t.Fatal(err)
}
if s := mm.state["laptop"]; s.Why != "the switch verb" || s.By != "g14.node-tools" {
if s := mm.bound("laptop"); s.Why != "the switch verb" || s.By != "g14.node-tools" {
t.Fatalf("the node is told %+v", s)
}
said := strings.Join(lines, "\n")
@@ -544,7 +555,7 @@ func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
t.Fatalf("the event %q lacks %s; events %v", switched, want, mm.events)
}
}
last := mm.store.Audits[len(mm.store.Audits)-1]
last := mm.mem.Audits[len(mm.mem.Audits)-1]
if raw, _ := json.Marshal(last); !strings.Contains(string(raw), `"by":"g14.node-tools"`) || !strings.Contains(string(raw), "switched") {
t.Fatalf("the audit kept %s", raw)
}
@@ -7,34 +7,57 @@ package main
import (
"context"
"sort"
"sync"
"time"
)
// Licence is one licence: an account, or an API key.
// Licence is one licence: an account, or an API key. Its form is the value of the state `licences` (design 39
// bus-and-switch §2): never a secret, which the vault holds (§15).
type Licence struct {
Name string `json:"name"`
Kind string `json:"kind"` // subscription | api-key
AccountUUID string `json:"accountUuid,omitempty"`
Email string `json:"email,omitempty"`
OrganizationUUID string `json:"organizationUuid,omitempty"`
RefreshFingerprint string `json:"-"`
AccountUUID string `json:"account,omitempty"`
Email string `json:"address,omitempty"`
OrganizationUUID string `json:"organisation,omitempty"`
RefreshFingerprint string `json:"refreshFingerprint,omitempty"`
AccessExpiresAt int64 `json:"accessExpiresAt,omitempty"`
RefreshExpiresAt int64 `json:"refreshExpiresAt,omitempty"`
Failures int `json:"failures"`
NotifiedAt int64 `json:"-"`
NotifiedAt int64 `json:"notifiedAt,omitempty"`
AdoptedAt int64 `json:"adoptedAt"`
AdoptedFrom string `json:"adoptedFrom,omitempty"`
RotatedAt int64 `json:"rotatedAt,omitempty"`
// Generation is the generation of the licence's secrets in the vault (design 39 bus-and-switch §2, §15).
// Generation is the generation of the licence's secrets in the vault (§2, §15).
Generation int64 `json:"generation,omitempty"`
// Usage is the latest reading of the licence's usage (§2, §24).
Usage *UsageReading `json:"usage,omitempty"`
}
// Binding is one consumer's binding and the generation it was last given (ADR 0206).
// Binding is one consumer's binding (ADR 0206, §3): the licence, kind and generation it should hold, since when, and
// why and by whom it was moved last.
type Binding struct {
Consumer string `json:"consumer"`
Licence string `json:"licence"`
Kind string `json:"kind,omitempty"`
Generation int64 `json:"generation"`
Since string `json:"since,omitempty"`
Why string `json:"why,omitempty"`
By string `json:"by,omitempty"`
}
// Move is what moves a binding: the licence's kind, why and by whom, and the floor a consumer's generation must pass
// (B11: the generation its node last reported); a nil Floor is none.
type Move struct {
Kind, Why, By string
Floor func(consumer string) int64
}
// floor is the floor m gives consumer.
func (m Move) floor(consumer string) int64 {
if m.Floor == nil {
return 0
}
return m.Floor(consumer)
}
// Outcome is what became of a login the manager was offered, by its refresh token's fingerprint.
@@ -61,18 +84,21 @@ type Store interface {
Licence(ctx context.Context, name string) (*Licence, error)
LicenceForAccount(ctx context.Context, account string) (*Licence, error)
SaveLicence(ctx context.Context, l Licence) error
Bindings(ctx context.Context) ([]Binding, error)
Binding(ctx context.Context, consumer string) (*Binding, error)
// Bind binds (or switches) a consumer at its next generation.
Bind(ctx context.Context, consumer, licence string, m Move) (Binding, error)
Unbind(ctx context.Context, consumer string) (bool, error)
// Advance gives every consumer of a licence its next generation: what a rotation is to them.
Advance(ctx context.Context, licence string, m Move) ([]Binding, error)
rest
}
// rest is what the store keeps beside its licences and bindings.
type rest interface {
// Lease takes a lease for d, or answers false while another holder's is live.
Lease(ctx context.Context, key, holder string, d time.Duration) (bool, error)
Unlease(ctx context.Context, key, holder string) error
Bindings(ctx context.Context) ([]Binding, error)
Binding(ctx context.Context, consumer string) (*Binding, error)
// Bind binds (or switches) a consumer at the next generation.
Bind(ctx context.Context, consumer, licence string) (Binding, error)
Unbind(ctx context.Context, consumer string) (bool, error)
// Advance gives every consumer of a licence a new generation: what a rotation is to them.
Advance(ctx context.Context, licence string) ([]Binding, error)
// GenerationsAbove makes every generation given from now on greater than this one.
GenerationsAbove(ctx context.Context, generation int64) error
Outcome(ctx context.Context, fingerprint string) (Outcome, error)
RecordOutcome(ctx context.Context, fingerprint, node, account string, o Outcome, why string) error
RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error
@@ -81,69 +107,28 @@ type Store interface {
Close()
}
// MemoryStore is the tests' store.
// MemoryStore is the tests' store of what the bus store does not keep yet.
type MemoryStore struct {
mu sync.Mutex
now func() time.Time
rows map[string]Licence
binds map[string]Binding
leases map[string]struct {
holder string
until time.Time
}
outcomes map[string]Outcome
usage []UsageRow
Audits []map[string]any
generation int64
outcomes map[string]Outcome
usage []UsageRow
Audits []map[string]any
}
// NewMemoryStore is an empty store whose leases age by now.
func NewMemoryStore(now func() time.Time) *MemoryStore {
return &MemoryStore{now: now, rows: map[string]Licence{}, binds: map[string]Binding{},
return &MemoryStore{now: now,
leases: map[string]struct {
holder string
until time.Time
}{}, outcomes: map[string]Outcome{}}
}
func (m *MemoryStore) Licences(context.Context) ([]Licence, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]Licence, 0, len(m.rows))
for _, l := range m.rows {
out = append(out, l)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
func (m *MemoryStore) Licence(_ context.Context, name string) (*Licence, error) {
m.mu.Lock()
defer m.mu.Unlock()
if l, ok := m.rows[name]; ok {
return &l, nil
}
return nil, nil
}
func (m *MemoryStore) LicenceForAccount(_ context.Context, account string) (*Licence, error) {
m.mu.Lock()
defer m.mu.Unlock()
for _, l := range m.rows {
if l.AccountUUID == account {
return &l, nil
}
}
return nil, nil
}
func (m *MemoryStore) SaveLicence(_ context.Context, l Licence) error {
m.mu.Lock()
defer m.mu.Unlock()
m.rows[l.Name] = l
return nil
}
func (m *MemoryStore) Lease(_ context.Context, key, holder string, d time.Duration) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -166,67 +151,6 @@ func (m *MemoryStore) Unlease(_ context.Context, key, holder string) error {
return nil
}
func (m *MemoryStore) Bindings(context.Context) ([]Binding, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]Binding, 0, len(m.binds))
for _, b := range m.binds {
out = append(out, b)
}
sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer })
return out, nil
}
func (m *MemoryStore) Binding(_ context.Context, consumer string) (*Binding, error) {
m.mu.Lock()
defer m.mu.Unlock()
if b, ok := m.binds[consumer]; ok {
return &b, nil
}
return nil, nil
}
func (m *MemoryStore) Bind(_ context.Context, consumer, licence string) (Binding, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.generation++
b := Binding{Consumer: consumer, Licence: licence, Generation: m.generation}
m.binds[consumer] = b
return b, nil
}
func (m *MemoryStore) Unbind(_ context.Context, consumer string) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
_, ok := m.binds[consumer]
delete(m.binds, consumer)
return ok, nil
}
func (m *MemoryStore) GenerationsAbove(_ context.Context, generation int64) error {
m.mu.Lock()
defer m.mu.Unlock()
m.generation = max(m.generation, generation)
return nil
}
func (m *MemoryStore) Advance(_ context.Context, licence string) ([]Binding, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []Binding
for c, b := range m.binds {
if b.Licence != licence {
continue
}
m.generation++
b.Generation = m.generation
m.binds[c] = b
out = append(out, b)
}
sort.Slice(out, func(i, j int) bool { return out[i].Consumer < out[j].Consumer })
return out, nil
}
func (m *MemoryStore) Outcome(_ context.Context, fp string) (Outcome, error) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -1,9 +1,9 @@
package main
import (
"context"
"crypto/rand"
"encoding/base64"
"context"
"os"
"strings"
"testing"