mesh-vault: retire an account the grants part no longer wants only within ADR 0230's bounds, keeping its value, and re-enable it when wanted again; account_retire for a person past the bound (issue 774, ADR 0561 §2)
This commit is contained in:
@@ -61,6 +61,8 @@ type AccountStore interface {
|
||||
Put(a AccountHash) error
|
||||
// Users is every user with a key.
|
||||
Users() ([]string, error)
|
||||
// Delete takes a user's key out: an account retired (retire.go).
|
||||
Delete(user string) error
|
||||
}
|
||||
|
||||
// busAccounts is AccountStore over the vault's own state through its runtime.
|
||||
@@ -80,6 +82,8 @@ func (busAccounts) Get(user string) (*AccountHash, error) {
|
||||
|
||||
func (busAccounts) Users() ([]string, error) { return stdio.State(BusAccountsState).Keys() }
|
||||
|
||||
func (busAccounts) Delete(user string) error { return stdio.State(BusAccountsState).Delete(user) }
|
||||
|
||||
func (busAccounts) Put(a AccountHash) error {
|
||||
_, err := stdio.State(BusAccountsState).Put(a.User, a)
|
||||
return err
|
||||
@@ -181,6 +185,10 @@ func (v *Vault) HashAccount(secret string, force bool) (Hashed, error) {
|
||||
if err != nil {
|
||||
return Hashed{}, err
|
||||
}
|
||||
if rec.Retired != nil {
|
||||
v.hashes.paid(secret)
|
||||
return Hashed{}, fmt.Errorf("%s is retired (%s): its hash is written again only when it is wanted again", secret, rec.Retired.Why)
|
||||
}
|
||||
user, password, err := accountOf(secret, value)
|
||||
if err != nil {
|
||||
return Hashed{}, err
|
||||
@@ -259,7 +267,7 @@ func (v *Vault) HashHeld() (written []Hashed, failed map[string]string) {
|
||||
return nil, failed
|
||||
}
|
||||
for _, r := range records {
|
||||
if !Covered(r.Name) {
|
||||
if !Covered(r.Name) || r.Retired != nil {
|
||||
continue
|
||||
}
|
||||
h, err := v.HashAccount(r.Name, false)
|
||||
|
||||
@@ -48,6 +48,13 @@ func (f *fakeAccounts) Put(a AccountHash) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeAccounts) Delete(user string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
delete(f.keys, user)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeAccounts) Users() ([]string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
@@ -109,6 +109,8 @@ func watchWants(env *Env) bool {
|
||||
say("the grants part's wanted accounts are not watched, so nothing is minted; begun again at the next pass: %v", err)
|
||||
return false
|
||||
}
|
||||
// The watch returns once every current key was handed: what has no key now is no longer wanted.
|
||||
env.Vault.WantsRead()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -209,6 +211,7 @@ func main() {
|
||||
env.pass()
|
||||
if env.Vault != nil {
|
||||
env.Vault.HashOwed()
|
||||
env.Vault.RetirePass()
|
||||
}
|
||||
env.pairPass()
|
||||
if env.Custody != nil {
|
||||
|
||||
@@ -56,6 +56,8 @@ type minting struct {
|
||||
heldBack map[string]string
|
||||
// minted are the accounts this vault minted since it started, by user (vault_status).
|
||||
minted map[string]Minted
|
||||
// retiring is what the passes keep of the accounts no longer wanted (retire.go).
|
||||
retiring retiring
|
||||
// mintMu keeps one mint at a time: a want heard, the switch turning on, and issue-account.
|
||||
mintMu sync.Mutex
|
||||
}
|
||||
|
||||
@@ -1,6 +1,67 @@
|
||||
package main
|
||||
|
||||
// wantGone is a wanted account's key deleted: forgotten here (its retirement is ADR 0230's, retire's).
|
||||
// A wanted account no longer wanted is retired within ADR 0230's bounds (novox/hq ADR 0561 §2, issue 774).
|
||||
//
|
||||
// The grants part deletes a wanted account's key when no input names its user any more. The vault then retires the
|
||||
// account: it takes its hash out of `bus-accounts`, so the grants part's next list drops the user, and marks the value
|
||||
// retired in its store, keeping it (a value is deleted only by a person, ADR 0230). As ADR 0230 §15 and §18 have it, the
|
||||
// same set of accounts gone must stand for five passes and ten minutes first, and a set of more than three, or more than
|
||||
// half of the accounts held, waits for a person, who retires them with `account_retire`. Wanted again, an account is
|
||||
// re-enabled at once: the mark taken off and its hash written again. Nothing is retired while the controller holds the
|
||||
// lease (it forgets the accounts it mints), or before the wanted accounts are read whole.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// retiring is what the passes keep of the accounts no longer wanted, under minting.mu.
|
||||
type retiring struct {
|
||||
wantsRead bool
|
||||
key string
|
||||
passes int
|
||||
first time.Time
|
||||
waiting []string
|
||||
why string
|
||||
retired []string
|
||||
}
|
||||
|
||||
// RetireState is what vault_status says of retirement.
|
||||
type RetireState struct {
|
||||
Gone []string `json:"gone"`
|
||||
Passes int `json:"passes"`
|
||||
Since time.Time `json:"since,omitzero"`
|
||||
Waiting []string `json:"waiting_for_a_person"`
|
||||
Why string `json:"why"`
|
||||
Retired []string `json:"retired"`
|
||||
}
|
||||
|
||||
// RetireState answers how retirement stands.
|
||||
func (v *Vault) RetireState() RetireState {
|
||||
m := &v.minting
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
r := m.retiring
|
||||
out := RetireState{Passes: r.passes, Since: r.first, Why: r.why, Waiting: append([]string{}, r.waiting...),
|
||||
Retired: append([]string{}, r.retired...), Gone: []string{}}
|
||||
if r.key != "" {
|
||||
out.Gone = strings.Split(r.key, " ")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// WantsRead says the wanted accounts were read whole once (the watch handed every current key): only then is an account
|
||||
// with no key known to be no longer wanted.
|
||||
func (v *Vault) WantsRead() {
|
||||
v.minting.mu.Lock()
|
||||
defer v.minting.mu.Unlock()
|
||||
v.minting.retiring.wantsRead = true
|
||||
}
|
||||
|
||||
// wantGone is a wanted account's key deleted: forgotten as wanted, its retirement left to the passes.
|
||||
func (v *Vault) wantGone(user string) {
|
||||
v.minting.mu.Lock()
|
||||
defer v.minting.mu.Unlock()
|
||||
@@ -8,5 +69,184 @@ func (v *Vault) wantGone(user string) {
|
||||
delete(v.minting.heldBack, user)
|
||||
}
|
||||
|
||||
// wantBack is a wanted account's key written again.
|
||||
func (v *Vault) wantBack(string) {}
|
||||
// wantBack is a wanted account's key written: a retired account of it is re-enabled at once.
|
||||
func (v *Vault) wantBack(user string) {
|
||||
w, ok := v.wanted(user)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
secret, err := AccountSecret(w)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
rec, held, err := v.Store.Read(secret)
|
||||
if err != nil || !held || rec.Retired == nil {
|
||||
return
|
||||
}
|
||||
rec.Retired = nil
|
||||
if err := v.Store.Write(rec); err != nil {
|
||||
v.log("%s is wanted again and NOT RE-ENABLED: its mark could not be taken off: %v", user, err)
|
||||
return
|
||||
}
|
||||
if _, err := v.HashAccount(secret, true); err != nil {
|
||||
v.hashes.owe(secret, err.Error())
|
||||
v.log("%s is wanted again and its hash is not written yet, written at the next pass: %v", user, err)
|
||||
}
|
||||
v.minting.mu.Lock()
|
||||
r := &v.minting.retiring
|
||||
for i, u := range r.retired {
|
||||
if u == user {
|
||||
r.retired = append(r.retired[:i], r.retired[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
v.minting.mu.Unlock()
|
||||
v.log("%s is wanted again: re-enabled, its hash written again", user)
|
||||
}
|
||||
|
||||
// goneAccounts are the users whose hash the vault wrote from a value it holds and that no wanted key names, sorted, and
|
||||
// how many such hashes it holds in all.
|
||||
func (v *Vault) goneAccounts() ([]string, int, error) {
|
||||
users, err := v.Accounts.Users()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var gone []string
|
||||
held := 0
|
||||
for _, u := range users {
|
||||
a, err := v.Accounts.Get(u)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if a == nil || a.Adopted || a.Of == "" {
|
||||
continue
|
||||
}
|
||||
held++
|
||||
if _, wanted := v.wanted(u); !wanted {
|
||||
gone = append(gone, u)
|
||||
}
|
||||
}
|
||||
sort.Strings(gone)
|
||||
return gone, held, nil
|
||||
}
|
||||
|
||||
// RetirePass is one pass of retirement, at the vault's minute pass.
|
||||
func (v *Vault) RetirePass() {
|
||||
if v.Accounts == nil {
|
||||
return
|
||||
}
|
||||
v.leaseAfresh()
|
||||
on, leaseWhy := v.mintingOn()
|
||||
m := &v.minting
|
||||
m.mu.Lock()
|
||||
read := m.retiring.wantsRead
|
||||
m.mu.Unlock()
|
||||
reset := func(why string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.retiring.key, m.retiring.passes, m.retiring.first, m.retiring.waiting, m.retiring.why = "", 0, time.Time{}, nil, why
|
||||
}
|
||||
switch {
|
||||
case !read:
|
||||
reset("the wanted accounts are not read whole yet, so none is known to be gone")
|
||||
return
|
||||
case !on:
|
||||
reset("nothing is retired: " + leaseWhy)
|
||||
return
|
||||
}
|
||||
gone, held, err := v.goneAccounts()
|
||||
if err != nil {
|
||||
reset("the accounts held could not be read: " + err.Error())
|
||||
return
|
||||
}
|
||||
if len(gone) == 0 {
|
||||
reset("")
|
||||
return
|
||||
}
|
||||
now := v.Now().UTC()
|
||||
key := strings.Join(gone, " ")
|
||||
m.mu.Lock()
|
||||
r := &m.retiring
|
||||
if r.key != key {
|
||||
r.key, r.passes, r.first, r.waiting = key, 0, now, nil
|
||||
}
|
||||
r.passes++
|
||||
passes, since := r.passes, r.first
|
||||
m.mu.Unlock()
|
||||
setWhy := func(why string, waiting []string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.retiring.why, m.retiring.waiting = why, waiting
|
||||
}
|
||||
if passes < StablePasses || now.Sub(since) < StableFor {
|
||||
setWhy(fmt.Sprintf("%d no longer wanted (%s): retired once the same set stands for %d passes and %s (ADR 0230)",
|
||||
len(gone), key, StablePasses, StableFor), nil)
|
||||
return
|
||||
}
|
||||
if len(gone) > RetireAtOnce || float64(len(gone)) > RetireFraction*float64(held) {
|
||||
why := fmt.Sprintf("%d accounts are no longer wanted, more than %d or more than half of the %d held at once: they "+
|
||||
"wait for a person (account_retire), ADR 0230 §18", len(gone), RetireAtOnce, held)
|
||||
setWhy(why, gone)
|
||||
v.log("%s: %s", why, key)
|
||||
return
|
||||
}
|
||||
for _, u := range gone {
|
||||
if err := v.retireOne(u, "no longer wanted by the grants part", "the vault's pass"); err != nil {
|
||||
v.log("%s is not retired: %v", u, err)
|
||||
}
|
||||
}
|
||||
reset("")
|
||||
}
|
||||
|
||||
// retireOne marks the value of user's account retired and takes its hash out of bus-accounts.
|
||||
func (v *Vault) retireOne(user, why, by string) error {
|
||||
a, err := v.Accounts.Get(user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a == nil || a.Adopted || a.Of == "" {
|
||||
return fmt.Errorf("the vault holds no account of %s it made or was handed: an adopted hash is never retired here", user)
|
||||
}
|
||||
rec, held, err := v.Store.Read(a.Of)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if held {
|
||||
rec.Retired = &RetiredMark{At: v.Now().UTC().Format(time.RFC3339), Why: why + ", by " + by}
|
||||
if err := v.Store.Write(rec); err != nil {
|
||||
return fmt.Errorf("its value could not be marked retired: %w", err)
|
||||
}
|
||||
}
|
||||
if err := v.Accounts.Delete(user); err != nil {
|
||||
return fmt.Errorf("its hash could not be taken out of %s: %w", BusAccountsState, err)
|
||||
}
|
||||
v.minting.mu.Lock()
|
||||
v.minting.retiring.retired = append(v.minting.retiring.retired, user)
|
||||
v.minting.mu.Unlock()
|
||||
v.log("retired the bus account %s (%s): its hash is out of %s and its value kept, marked; %s", user, a.Of,
|
||||
BusAccountsState, why)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RetireAccounts is the tool account_retire: a person retires accounts no longer wanted, past ADR 0230's bound, with why.
|
||||
func (v *Vault) RetireAccounts(caller string, users []string, why string) (any, error) {
|
||||
if strings.TrimSpace(why) == "" {
|
||||
return nil, errors.New("account_retire says why (`why`)")
|
||||
}
|
||||
if len(users) == 0 {
|
||||
return nil, errors.New("account_retire names the accounts (`users`), as vault_status lists them waiting")
|
||||
}
|
||||
for _, u := range users {
|
||||
if _, wanted := v.wanted(u); wanted {
|
||||
return nil, fmt.Errorf("%s is still wanted by the grants part: only an account no longer wanted is retired; nothing was retired", u)
|
||||
}
|
||||
}
|
||||
var done []string
|
||||
for _, u := range users {
|
||||
if err := v.retireOne(u, strings.TrimSpace(why), someoneOr("", caller)); err != nil {
|
||||
return map[string]any{"retired": done}, fmt.Errorf("%s: %w", u, err)
|
||||
}
|
||||
done = append(done, u)
|
||||
}
|
||||
return map[string]any{"retired": done}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 774/AC9 (novox/hq ADR 0561 §2, ADR 0230 §15, §18): a wanted account whose key is deleted is retired (its hash taken
|
||||
// out of bus-accounts, its value kept, marked) only once the same set has stood for five passes and ten minutes, and never
|
||||
// more than three, or more than half of those held, at once: past that it waits for a person (account_retire). Wanted
|
||||
// again, it is re-enabled at once; and a retired account is not hashed again at a start.
|
||||
func TestAWantGoneRetiresItsAccountOnlyWithinTheBounds(t *testing.T) {
|
||||
v, accounts, _ := mintingVault(t, true)
|
||||
clock := time.Date(2026, 10, 28, 9, 0, 0, 0, time.UTC)
|
||||
v.Now = func() time.Time { return clock }
|
||||
modules := []string{"m1", "m2", "m3", "m4", "m5", "m6", "m7", "m8"}
|
||||
for _, m := range modules {
|
||||
w := aWant(m)
|
||||
v.WantHeard(w.User, wantRaw(t, w), false)
|
||||
}
|
||||
v.WantsRead()
|
||||
if len(accounts.keys) != 8 {
|
||||
t.Fatalf("minted %d", len(accounts.keys))
|
||||
}
|
||||
pass := func(n int, every time.Duration) {
|
||||
for i := 0; i < n; i++ {
|
||||
clock = clock.Add(every)
|
||||
v.RetirePass()
|
||||
}
|
||||
}
|
||||
v.WantHeard("ace.m1", nil, true)
|
||||
pass(5, time.Second)
|
||||
if _, ok := accounts.keys["ace.m1"]; !ok {
|
||||
t.Fatal("retired after five passes in five seconds: ADR 0230 waits ten minutes too")
|
||||
}
|
||||
pass(1, 10*time.Minute)
|
||||
if _, ok := accounts.keys["ace.m1"]; ok {
|
||||
t.Fatalf("not retired after five passes and ten minutes: %+v", v.RetireState())
|
||||
}
|
||||
if rec, held, _ := v.Store.Read("own/ace/m1/broker"); !held || rec.Retired == nil {
|
||||
t.Fatalf("the retired account's value is %+v (held %v): kept, and marked", rec, held)
|
||||
}
|
||||
if written, _ := v.HashHeld(); len(written) != 0 {
|
||||
t.Errorf("a start hashed a retired account again: %+v", written)
|
||||
}
|
||||
// Wanted again: re-enabled at once.
|
||||
w := aWant("m1")
|
||||
v.WantHeard(w.User, wantRaw(t, w), false)
|
||||
if rec, _, _ := v.Store.Read("own/ace/m1/broker"); rec.Retired != nil || accounts.keys["ace.m1"].Hash == "" {
|
||||
t.Fatalf("wanted again, ace.m1 is %+v with hash %v", rec.Retired, accounts.keys["ace.m1"])
|
||||
}
|
||||
// Four gone at once is over the bound: it waits for a person, who retires them by hand.
|
||||
for _, m := range modules[4:] {
|
||||
v.WantHeard("ace."+m, nil, true)
|
||||
}
|
||||
pass(6, 3*time.Minute)
|
||||
st := v.RetireState()
|
||||
if len(accounts.keys) != 8 || len(st.Waiting) != 4 || !strings.Contains(st.Why, "more than 3") {
|
||||
t.Fatalf("over the bound: %d keys, %+v", len(accounts.keys), st)
|
||||
}
|
||||
if _, err := v.RetireAccounts("person.jochen", []string{"ace.m5"}, ""); err == nil {
|
||||
t.Error("retired by hand with no why")
|
||||
}
|
||||
if _, err := v.RetireAccounts("person.jochen", []string{"ace.m2"}, "test"); err == nil {
|
||||
t.Error("retired by hand an account still wanted")
|
||||
}
|
||||
if _, err := v.RetireAccounts("person.jochen", []string{"ace.m5", "ace.m6"}, "the modules left"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := accounts.keys["ace.m5"]; ok || len(accounts.keys) != 6 {
|
||||
t.Errorf("after a person's retirement %d keys remain", len(accounts.keys))
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,8 @@ type Record struct {
|
||||
// MovedFrom is, for a value the move took from the controller's store (design 57's V3), the node-engine that
|
||||
// handed it in and the controller's generation for it, kept as it was.
|
||||
MovedFrom *MovedFrom `json:"moved-from,omitempty"`
|
||||
// Retired marks a bus account no longer wanted (retire.go, ADR 0561 §2): its value kept, its hash taken out.
|
||||
Retired *RetiredMark `json:"retired,omitempty"`
|
||||
}
|
||||
|
||||
// OperatorCopy is a value sealed to the operator key named in Key.
|
||||
|
||||
@@ -424,6 +424,28 @@ func definitions(env *Env) []toolDef {
|
||||
}
|
||||
return env.Vault.HashUser(arg(a, "user"), caller)
|
||||
}},
|
||||
{Name: "account_retire",
|
||||
Description: "Retire bus accounts the grants part no longer wants, by a person's act with why (novox/hq ADR 0561 §2, " +
|
||||
"ADR 0230 §18): the vault takes each one's hash out of its state `bus-accounts`, so the grants part's next user " +
|
||||
"list drops the user, and keeps its value marked retired. For the set vault_status lists as waiting for a " +
|
||||
"person (more than three, or more than half, gone at once); refused for an account still wanted, an adopted " +
|
||||
"hash, and with no why. Wanted again, an account is re-enabled at once.",
|
||||
Input: map[string]any{"users": map[string]any{"type": "array", "items": str("a bus user")},
|
||||
"why": str("why they are retired")},
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
if env.Vault == nil {
|
||||
return nil, errors.New("the vault is not ready")
|
||||
}
|
||||
var users []string
|
||||
if list, ok := a["users"].([]any); ok {
|
||||
for _, u := range list {
|
||||
if s, ok := u.(string); ok {
|
||||
users = append(users, strings.TrimSpace(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
return env.Vault.RetireAccounts(caller, users, arg(a, "why"))
|
||||
}},
|
||||
{Name: "secret_vault_export",
|
||||
Description: "Every secret the vault itself holds, as its copy sealed to the operator key, in the shape of " +
|
||||
"`secret export`: saved to a file, `nox secret recover vault mesh-vault <name> --key <operator key file> " +
|
||||
@@ -506,7 +528,7 @@ func definitions(env *Env) []toolDef {
|
||||
key, _ := env.Vault.Store.OperatorKey()
|
||||
return map[string]any{"ready": true, "operator_key": key, "copies_on_an_earlier_key": left,
|
||||
"operator_key_refused": env.keyRefused(), "handed_rolled": env.Vault.Store.Rolled(),
|
||||
"minting": env.Vault.MintState(), "ok": left == 0 && env.keyRefused() == "" && key != ""}, nil
|
||||
"minting": env.Vault.MintState(), "retiring": env.Vault.RetireState(), "ok": left == 0 && env.keyRefused() == "" && key != ""}, nil
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,7 @@ func TestNoBucketHoldsASecret(t *testing.T) {
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Get(", "", 1))
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Put(", "", 1))
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Keys(", "", 1))
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Delete(", "", 1))
|
||||
}
|
||||
// The haves, in main.go alone: one read and one watch, never a write; and one watch of the wanted accounts, the
|
||||
// grants part's, which hold no secret (ADR 0561 §2).
|
||||
|
||||
@@ -159,6 +159,7 @@
|
||||
"secret_own",
|
||||
"account_hash",
|
||||
"account_adopt",
|
||||
"account_retire",
|
||||
"secret_asks",
|
||||
"secret_vault_export",
|
||||
"provisioner_retirement",
|
||||
|
||||
Reference in New Issue
Block a user