M-5: the usage and audit logs kept in the bus's own storage
Every reading is appended under its licence's key with the vendor's answer whole (ADR 0054), and every act under its kind, never rewritten; an act whose audit entry the log does not take fails and says so. The memory store goes: the tests run the bus store over a bus kept in memory. Builds: design 39 bus-and-switch §6, §7, B20 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
@@ -43,8 +44,9 @@ type BusStore struct {
|
||||
BindingsState keptState
|
||||
LeasesState keptState
|
||||
OfferedState keptState
|
||||
UsageLog keptLog
|
||||
AuditLog keptLog
|
||||
Now func() time.Time
|
||||
rest
|
||||
}
|
||||
|
||||
func (s *BusStore) get(st keptState, key string, into any) (bool, error) {
|
||||
@@ -249,3 +251,118 @@ func (s *BusStore) putOffered(fingerprint string, v offeredValue, ttl time.Durat
|
||||
_, err := s.OfferedState.PutTTL(OfferedKey(fingerprint), v, ttl)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- the logs (§6, §7) ------------------------------------------------------------------------------------------
|
||||
|
||||
// keptLog is a log this module keeps, as mesh-sdk's KeptLog answers it.
|
||||
type keptLog interface {
|
||||
Append(key string, value any) (uint64, error)
|
||||
Read(from uint64, keys string, limit int) (*stdio.LogPage, error)
|
||||
Last(key string) (*stdio.LogEntry, error)
|
||||
Info() (*stdio.LogInfo, error)
|
||||
}
|
||||
|
||||
// UsageEntry is one entry of the log `usage` (§6): when, the flattened reading and the vendor's answer whole (ADR 0054).
|
||||
type UsageEntry struct {
|
||||
At string `json:"at"`
|
||||
Reading UsageReading `json:"reading"`
|
||||
Raw map[string]any `json:"raw,omitempty"`
|
||||
}
|
||||
|
||||
// AuditKinds are the subjects of the log `audit` (§7); an act of another kind is kept under `drift`.
|
||||
var AuditKinds = []string{"adopted", "refused", "failed", "rotated", "bound", "switched", "released", "drift", "carried",
|
||||
"settings"}
|
||||
|
||||
func (s *BusStore) RecordUsage(_ context.Context, licence string, at int64, r UsageReading, raw map[string]any) error {
|
||||
_, err := s.UsageLog.Append(KeyFor(licence), UsageEntry{At: time.UnixMilli(at).UTC().Format(time.RFC3339Nano),
|
||||
Reading: r, Raw: raw})
|
||||
return err
|
||||
}
|
||||
|
||||
// Usage is the latest readings, newest first, of one licence or every one.
|
||||
func (s *BusStore) Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error) {
|
||||
names := map[string]string{}
|
||||
all, err := s.Licences(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, l := range all {
|
||||
names[KeyFor(l.Name)] = l.Name
|
||||
}
|
||||
keys := ""
|
||||
if licence != "" {
|
||||
keys = KeyFor(licence)
|
||||
}
|
||||
entries, err := s.newest(s.UsageLog, keys, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []UsageRow{}
|
||||
for _, e := range entries {
|
||||
var u UsageEntry
|
||||
if json.Unmarshal(e.Value, &u) != nil {
|
||||
continue
|
||||
}
|
||||
at, _ := time.Parse(time.RFC3339Nano, u.At)
|
||||
name := names[e.Key]
|
||||
if name == "" {
|
||||
name = e.Key
|
||||
}
|
||||
out = append(out, UsageRow{Licence: name, At: at.UnixMilli(), Reading: u.Reading})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// newest is up to limit entries of keys, newest first: read back from the log's last sequence, a window that doubles
|
||||
// until it holds limit entries or reaches the first.
|
||||
func (s *BusStore) newest(l keptLog, keys string, limit int) ([]stdio.LogEntry, error) {
|
||||
info, err := l.Info()
|
||||
if err != nil || info.Last == 0 {
|
||||
return nil, err
|
||||
}
|
||||
for span := uint64(max(limit, 1)); ; span *= 2 {
|
||||
from := uint64(1)
|
||||
if info.Last > span {
|
||||
from = info.Last - span + 1
|
||||
}
|
||||
var got []stdio.LogEntry
|
||||
for next := from; next != 0; {
|
||||
page, err := l.Read(next, keys, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
got = append(got, page.Entries...)
|
||||
next = page.Next
|
||||
}
|
||||
if len(got) >= limit || from <= max(info.First, 1) {
|
||||
if len(got) > limit {
|
||||
got = got[len(got)-limit:]
|
||||
}
|
||||
for i, j := 0, len(got)-1; i < j; i, j = i+1, j-1 {
|
||||
got[i], got[j] = got[j], got[i]
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Audit appends one entry under its kind, with when; a log that does not take it fails the act (B20).
|
||||
func (s *BusStore) Audit(_ context.Context, what string, detail map[string]any) error {
|
||||
kind := "drift"
|
||||
for _, k := range AuditKinds {
|
||||
if k == what {
|
||||
kind = k
|
||||
}
|
||||
}
|
||||
entry := map[string]any{"at": s.Now().UTC().Format(time.RFC3339Nano)}
|
||||
if kind != what {
|
||||
entry["what"] = what
|
||||
}
|
||||
for k, v := range detail {
|
||||
entry[k] = v
|
||||
}
|
||||
if _, err := s.AuditLog.Append(kind, entry); err != nil {
|
||||
return fmt.Errorf("the audit did not take the %s entry, so the act is not done: %w", what, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -213,3 +214,128 @@ func TestALeaseAndAnOfferAreKeptByTheirKeysAndLifetimes(t *testing.T) {
|
||||
t.Fatalf("the outcome lives until %v", u)
|
||||
}
|
||||
}
|
||||
|
||||
// memLog is a log of the bus as the runtime answers it, in memory; full, it refuses an entry.
|
||||
type memLog struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
entries []stdio.LogEntry
|
||||
full bool
|
||||
}
|
||||
|
||||
func newMemLog(now func() time.Time) *memLog { return &memLog{now: now} }
|
||||
|
||||
func (l *memLog) Append(key string, value any) (uint64, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.full {
|
||||
return 0, errors.New("the log is full: maximum bytes exceeded")
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
seq := uint64(len(l.entries) + 1)
|
||||
l.entries = append(l.entries, stdio.LogEntry{Sequence: seq, Key: key, Value: raw, At: l.now()})
|
||||
return seq, nil
|
||||
}
|
||||
|
||||
func (l *memLog) Read(from uint64, keys string, limit int) (*stdio.LogPage, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
p := &stdio.LogPage{Entries: []stdio.LogEntry{}, Last: uint64(len(l.entries))}
|
||||
for _, e := range l.entries {
|
||||
if e.Sequence >= from && (keys == "" || e.Key == keys) {
|
||||
if limit > 0 && len(p.Entries) == limit {
|
||||
p.Next = e.Sequence
|
||||
break
|
||||
}
|
||||
p.Entries = append(p.Entries, e)
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (l *memLog) Last(key string) (*stdio.LogEntry, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
for i := len(l.entries) - 1; i >= 0; i-- {
|
||||
if l.entries[i].Key == key {
|
||||
e := l.entries[i]
|
||||
return &e, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (l *memLog) Info() (*stdio.LogInfo, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
i := &stdio.LogInfo{Entries: uint64(len(l.entries)), Last: uint64(len(l.entries))}
|
||||
if len(l.entries) > 0 {
|
||||
i.First = 1
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
// under is every entry of a log's subject, decoded.
|
||||
func (l *memLog) under(key string) []map[string]any {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
var out []map[string]any
|
||||
for _, e := range l.entries {
|
||||
if key == "" || e.Key == key {
|
||||
var v map[string]any
|
||||
_ = json.Unmarshal(e.Value, &v)
|
||||
v["what"] = e.Key
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §6: a reading appends one entry under its licence's key, the reading beside the raw answer.
|
||||
func TestAReadingAppendsOneUsageEntry(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||
if err := mm.m.ReadUsage(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := mm.usage.under(KeyFor(licence1))
|
||||
if len(got) != 1 || len(mm.usage.entries) != 1 {
|
||||
t.Fatalf("a reading appended %v", mm.usage.entries)
|
||||
}
|
||||
if got[0]["raw"] == nil || got[0]["reading"] == nil || got[0]["at"] == nil {
|
||||
t.Fatalf("the entry %v", got[0])
|
||||
}
|
||||
rows, _ := mm.store.Usage(ctx, licence1, 20)
|
||||
if len(rows) != 1 || rows[0].Licence != licence1 || rows[0].Reading.SessionPct == nil {
|
||||
t.Fatalf("usage answers %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// design 39 bus-and-switch §7, B20: each act appends one entry under its kind; a full log fails the act loudly.
|
||||
func TestEachActAppendsOneAuditEntry(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)})
|
||||
_, _ = mm.m.Rotate(ctx, licence1, true)
|
||||
_ = mm.store.SaveLicence(ctx, Licence{Name: "api", Kind: "api-key", Generation: 1})
|
||||
_, _ = mm.m.Bind(ctx, "laptop", "api")
|
||||
_, _ = mm.m.Release(ctx, "laptop")
|
||||
for kind, n := range map[string]int{"adopted": 1, "bound": 1, "rotated": 1, "switched": 1, "released": 1} {
|
||||
if got := len(mm.audit.under(kind)); got != n {
|
||||
t.Errorf("%s: %d entries, want %d", kind, got, n)
|
||||
}
|
||||
}
|
||||
for _, e := range mm.audit.under("") {
|
||||
if e["at"] == nil || e["by"] == nil && e["what"] != "adopted" {
|
||||
t.Errorf("the entry %v has no at or by", e)
|
||||
}
|
||||
}
|
||||
mm.audit.full = true
|
||||
if _, err := mm.m.Bind(ctx, "laptop", "api"); err == nil || !strings.Contains(err.Error(), "the audit did not take") {
|
||||
t.Fatalf("a bind with a full audit log: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func assertRefused(t *testing.T, mm *miniMesh) {
|
||||
said = said || strings.HasPrefix(e, "licence.refused ")
|
||||
}
|
||||
audited := false
|
||||
for _, a := range mm.mem.Audits {
|
||||
for _, a := range mm.audit.under("") {
|
||||
audited = audited || a["what"] == "refused"
|
||||
}
|
||||
if !said || !audited {
|
||||
|
||||
@@ -63,17 +63,14 @@ func manager() (*Manager, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store, err := PgStoreFromEnv(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node, _ := os.Hostname()
|
||||
if n := os.Getenv("MESH_NODE"); n != "" {
|
||||
node = n
|
||||
}
|
||||
built = &Manager{
|
||||
Store: &BusStore{LicencesState: stdio.State("licences"), BindingsState: stdio.State("bindings"),
|
||||
LeasesState: stdio.State("leases"), OfferedState: stdio.State("offered"), Now: time.Now, rest: store},
|
||||
LeasesState: stdio.State("leases"), OfferedState: stdio.State("offered"), UsageLog: stdio.Log("usage"),
|
||||
AuditLog: stdio.Log("audit"), Now: time.Now},
|
||||
Vendor: LiveVendor(),
|
||||
Crypt: crypt,
|
||||
Grants: VaultGrants{Keys: keys},
|
||||
|
||||
@@ -81,19 +81,23 @@ func CauseOf(ctx context.Context) Cause {
|
||||
|
||||
// record says one change of what a consumer holds, the same fields in three places (novox/hq issue 625): a line
|
||||
// in the journal, a row in the audit table, and the event `licence.<what>` on the bus. Never a token.
|
||||
func (m *Manager) record(ctx context.Context, what, line string, detail map[string]any) {
|
||||
_ = m.Store.Audit(ctx, what, detail)
|
||||
func (m *Manager) record(ctx context.Context, what, line string, detail map[string]any) error {
|
||||
if err := m.Store.Audit(ctx, what, detail); err != nil {
|
||||
m.Log("%s, but %v", line, err)
|
||||
return err
|
||||
}
|
||||
m.Log("%s", line)
|
||||
_ = m.Emit("licence."+what, detail)
|
||||
return nil
|
||||
}
|
||||
|
||||
// moved records a consumer bound or switched: from which licence to which, at which generation, why and by whom.
|
||||
func (m *Manager) moved(ctx context.Context, consumer, from, licence string, generation int64, c Cause) {
|
||||
func (m *Manager) moved(ctx context.Context, consumer, from, licence string, generation int64, c Cause) error {
|
||||
what, line := "bound", fmt.Sprintf("bound %s to %s", consumer, licence)
|
||||
if from != "" {
|
||||
what, line = "switched", fmt.Sprintf("switched %s from %s to %s", consumer, from, licence)
|
||||
}
|
||||
m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, generation, c.Why, c.By),
|
||||
return m.record(ctx, what, fmt.Sprintf("%s (generation %d): %s; asked by %s", line, generation, c.Why, c.By),
|
||||
map[string]any{"consumer": consumer, "licence": licence, "from": from, "generation": generation,
|
||||
"why": c.Why, "by": c.By})
|
||||
}
|
||||
@@ -299,7 +303,9 @@ func (m *Manager) bindReporters(ctx context.Context, reports []Holdings, floor f
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.moved(ctx, rep.Node, "", l.Name, b.Generation, c)
|
||||
if err := m.moved(ctx, rep.Node, "", l.Name, b.Generation, c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -339,7 +345,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings,
|
||||
if taken != "" {
|
||||
why := "its key " + KeyFor(NameFor(c.Identity)) + " is the key of the licence " + taken
|
||||
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused, why)
|
||||
_ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "licence": NameFor(c.Identity), "reason": why})
|
||||
if err := m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "licence": NameFor(c.Identity), "reason": why}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity), "reason": why})
|
||||
return "", nil
|
||||
}
|
||||
@@ -350,7 +358,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings,
|
||||
r := m.Vendor.Refresh(ctx, offered)
|
||||
if !r.OK {
|
||||
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Dead, fmt.Sprintf("%d %s", r.Status, r.Reason))
|
||||
_ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "account": c.Identity.AccountUUID, "status": r.Status, "reason": r.Reason})
|
||||
if err := m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "account": c.Identity.AccountUUID, "status": r.Status, "reason": r.Reason}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity),
|
||||
"reason": fmt.Sprintf("the login's refresh token did not refresh (%d)", r.Status)})
|
||||
m.Log("%s's login for %s did not refresh: %d %s", c.Node, NameFor(c.Identity), r.Status, r.Reason)
|
||||
@@ -360,7 +370,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings,
|
||||
if r.Account != "" && r.Account != c.Identity.AccountUUID {
|
||||
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Refused,
|
||||
"the node says "+c.Identity.AccountUUID+", the vendor says "+r.Account)
|
||||
_ = m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "reported": c.Identity.AccountUUID, "vendor": r.Account})
|
||||
if err := m.Store.Audit(ctx, "refused", map[string]any{"node": c.Node, "reported": c.Identity.AccountUUID, "vendor": r.Account}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = m.Emit("licence.refused", map[string]any{"node": c.Node, "account": NameFor(c.Identity),
|
||||
"reason": "the account the node reported is not the one the vendor answered for"})
|
||||
return "", nil
|
||||
@@ -394,8 +406,10 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings,
|
||||
why = "a newer login"
|
||||
}
|
||||
_ = m.Store.RecordOutcome(ctx, c.Fingerprint, c.Node, c.Identity.AccountUUID, Adopted, why)
|
||||
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": l.Name, "node": c.Node, "account": c.Identity.AccountUUID,
|
||||
"vendorNamedAccount": r.Account != ""})
|
||||
if err := m.Store.Audit(ctx, "adopted", map[string]any{"licence": l.Name, "node": c.Node, "account": c.Identity.AccountUUID,
|
||||
"vendorNamedAccount": r.Account != ""}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// A login moves its node (ADR 0209): the node this login came from is bound to its licence —
|
||||
// switched, if it was bound to another. Then every node reporting this account and bound to nothing.
|
||||
@@ -413,7 +427,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings,
|
||||
if b != nil {
|
||||
from = b.Licence
|
||||
}
|
||||
m.moved(ctx, c.Node, from, l.Name, moved.Generation, cause)
|
||||
if err := m.moved(ctx, c.Node, from, l.Name, moved.Generation, cause); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
for _, rep := range reports {
|
||||
if rep.Identity == nil || rep.Identity.AccountUUID != c.Identity.AccountUUID {
|
||||
@@ -424,7 +440,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings,
|
||||
}
|
||||
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.moved(ctx, rep.Node, "", l.Name, b.Generation, bc); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := m.publishAdvance(ctx, l, floor); err != nil {
|
||||
@@ -553,7 +571,9 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri
|
||||
if !r.OK {
|
||||
l.Failures++
|
||||
m.Log("%s did not refresh (%d in a row): %d %s", name, l.Failures, r.Status, r.Reason)
|
||||
_ = m.Store.Audit(ctx, "failed", map[string]any{"licence": name, "status": r.Status, "reason": r.Reason, "failures": l.Failures})
|
||||
if err := m.Store.Audit(ctx, "failed", map[string]any{"licence": name, "status": r.Status, "reason": r.Reason, "failures": l.Failures}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.notify(l, fmt.Sprintf("refresh failed %d time(s) in a row: %d", l.Failures, r.Status), l.Failures >= m.Settings.FailuresToNotify)
|
||||
if err := m.Store.SaveLicence(ctx, *l); err != nil {
|
||||
return nil, err
|
||||
@@ -581,9 +601,11 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri
|
||||
}
|
||||
}
|
||||
ctx = WithCause(ctx, Cause{Why: "a rotation of " + name + " (" + c.Why + ")", By: c.By})
|
||||
m.record(ctx, "rotated", fmt.Sprintf("rotated %s, its token now expiring %s: %s; asked by %s", name,
|
||||
if err := 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})
|
||||
map[string]any{"licence": name, "expiresAt": l.AccessExpiresAt, "why": c.Why, "by": c.By}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.publishAdvance(ctx, *l, m.floorIn(nil)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -732,7 +754,9 @@ func (m *Manager) Bind(ctx context.Context, consumer, licence string) (map[strin
|
||||
if before != nil {
|
||||
from = before.Licence
|
||||
}
|
||||
m.moved(ctx, consumer, from, licence, b.Generation, c)
|
||||
if err := m.moved(ctx, consumer, from, licence, b.Generation, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"consumer": consumer, "licence": licence, "generation": b.Generation, "from": from}, nil
|
||||
}
|
||||
|
||||
@@ -746,8 +770,10 @@ func (m *Manager) Release(ctx context.Context, consumer string) (map[string]any,
|
||||
return map[string]any{"consumer": consumer, "released": false, "reason": "it was bound to nothing"}, 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})
|
||||
if err := 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}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"consumer": consumer, "released": true, "was": was.Licence}, nil
|
||||
}
|
||||
|
||||
@@ -800,7 +826,9 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str
|
||||
}
|
||||
if taken != "" {
|
||||
why := "its key " + KeyFor(name) + " is the key of the licence " + taken
|
||||
_ = m.Store.Audit(ctx, "refused", map[string]any{"licence": name, "kind": "api-key", "from": from, "reason": why})
|
||||
if err := m.Store.Audit(ctx, "refused", map[string]any{"licence": name, "kind": "api-key", "from": from, "reason": why}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = m.Emit("licence.refused", map[string]any{"licence": name, "from": from, "reason": why})
|
||||
return nil, fmt.Errorf("%s is refused: %s", name, why)
|
||||
}
|
||||
@@ -815,7 +843,9 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str
|
||||
return nil, err
|
||||
}
|
||||
c := CauseOf(ctx)
|
||||
_ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from, "why": c.Why, "by": c.By})
|
||||
if err := m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from, "why": c.Why, "by": c.By}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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, m.floorIn(nil)); err != nil {
|
||||
|
||||
@@ -85,17 +85,18 @@ type miniMesh struct {
|
||||
grants *memGrants
|
||||
m *Manager
|
||||
store *BusStore
|
||||
mem *MemoryStore
|
||||
usage *memLog
|
||||
audit *memLog
|
||||
licences *memState
|
||||
bindings *memState
|
||||
leases *memState
|
||||
offered *memState
|
||||
vendor *stubVendor
|
||||
logins map[string]FullGrant // node → what its credentials file holds
|
||||
events []string
|
||||
now time.Time
|
||||
keys KeyPair
|
||||
askDown bool
|
||||
vendor *stubVendor
|
||||
logins map[string]FullGrant // node → what its credentials file holds
|
||||
events []string
|
||||
now time.Time
|
||||
keys KeyPair
|
||||
askDown bool
|
||||
}
|
||||
|
||||
func newMesh(t *testing.T) *miniMesh {
|
||||
@@ -103,11 +104,11 @@ func newMesh(t *testing.T) *miniMesh {
|
||||
mm := &miniMesh{logins: map[string]FullGrant{}, now: t0,
|
||||
grants: &memGrants{held: map[string]heldGrant{}}}
|
||||
now := func() time.Time { return mm.now }
|
||||
mm.mem = NewMemoryStore(now)
|
||||
mm.usage, mm.audit = newMemLog(now), newMemLog(now)
|
||||
mm.licences, mm.bindings = newMemState(now), newMemState(now)
|
||||
mm.leases, mm.offered = newMemState(now), newMemState(now)
|
||||
mm.store = &BusStore{LicencesState: mm.licences, BindingsState: mm.bindings, LeasesState: mm.leases,
|
||||
OfferedState: mm.offered, Now: now, rest: mm.mem}
|
||||
OfferedState: mm.offered, UsageLog: mm.usage, AuditLog: mm.audit, Now: now}
|
||||
mm.vendor = &stubVendor{live: map[string]bool{}, now: now}
|
||||
crypt, _ := NewCrypt("a key the vault made")
|
||||
mm.keys, _ = GenerateKeyPair()
|
||||
@@ -559,7 +560,8 @@ func TestEveryMoveSaysWhatWhyAndWho(t *testing.T) {
|
||||
t.Fatalf("the event %q lacks %s; events %v", switched, want, mm.events)
|
||||
}
|
||||
}
|
||||
last := mm.mem.Audits[len(mm.mem.Audits)-1]
|
||||
all := mm.audit.under("")
|
||||
last := all[len(all)-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)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
package main
|
||||
|
||||
// The manager's store (novox/hq ADR 0183, design 39 §1): licences with their grants encrypted, bindings
|
||||
// with a generation, what became of each login it was offered, usage readings, and the audit. One
|
||||
// interface, two implementations — postgres for the mesh (pgstore.go), memory for the tests — so every
|
||||
// rule is tested without a database, and the database is asked only to keep rows.
|
||||
// The manager's store (novox/hq ADR 0183, design 39 bus-and-switch §2 to §7): licences, bindings with a generation,
|
||||
// leases, what became of each login it was offered, usage readings and the audit, all in the bus's own storage
|
||||
// (busstore.go). Every rule is tested on the same store over a bus kept in memory.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -96,63 +94,8 @@ type Store interface {
|
||||
Unlease(ctx context.Context, key, holder string) error
|
||||
Outcome(ctx context.Context, fingerprint string) (Outcome, error)
|
||||
RecordOutcome(ctx context.Context, fingerprint, node, account string, o Outcome, why string) error
|
||||
rest
|
||||
}
|
||||
|
||||
// rest is what the store keeps beside its licences and bindings.
|
||||
type rest interface {
|
||||
RecordUsage(ctx context.Context, licence string, at int64, r UsageReading, raw map[string]any) error
|
||||
Usage(ctx context.Context, licence string, limit int) ([]UsageRow, error)
|
||||
// Audit appends one entry for an act; an error is the act failing (B20).
|
||||
Audit(ctx context.Context, what string, detail map[string]any) error
|
||||
Close()
|
||||
}
|
||||
|
||||
// MemoryStore is the tests' store of what the bus store does not keep yet.
|
||||
type MemoryStore struct {
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
leases map[string]struct {
|
||||
holder string
|
||||
until time.Time
|
||||
}
|
||||
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}
|
||||
}
|
||||
|
||||
func (m *MemoryStore) RecordUsage(_ context.Context, licence string, at int64, r UsageReading, _ map[string]any) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.usage = append(m.usage, UsageRow{Licence: licence, At: at, Reading: r})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Usage(_ context.Context, licence string, limit int) ([]UsageRow, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var out []UsageRow
|
||||
for i := len(m.usage) - 1; i >= 0 && len(out) < limit; i-- {
|
||||
if licence == "" || m.usage[i].Licence == licence {
|
||||
out = append(out, m.usage[i])
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Audit(_ context.Context, what string, detail map[string]any) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
d := map[string]any{"what": what}
|
||||
for k, v := range detail {
|
||||
d[k] = v
|
||||
}
|
||||
m.Audits = append(m.Audits, d)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) Close() {}
|
||||
|
||||
Reference in New Issue
Block a user