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