Files
mesh-delivery/modules/mesh-grants/internal/grants/grants.go
T
jschoubben 94f555277c Never name the private network the mesh provides as a module missing its grant (issue 774)
mesh-wireguard is provided by the mesh itself (mesh-sdk delivery.ProvidedByMesh): no repository builds it, so the
indexer holds no fingerprint of it and the assigner never wants it by one; the controller composes it with the
node's own. The grants part named it "no fingerprint of the new form: the indexer holds no value of it" on every
pass, a warning nothing could clear (sweep 2026-10-30 item 6).

Quick fix: drop mesh-wireguard by name from the missing list.
Thorough fix, chosen as cheap and covering every module the mesh may provide later: MissingGrants passes over any
module ProvidedByMesh names. The controller says the same of it in mesh-control (its grants and switch lines).
2026-10-30 18:10:55 +01:00

507 lines
20 KiB
Go

package grants
import (
"encoding/json"
"fmt"
"slices"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// **The grants the part writes** (novox/hq ADR 0547 §4, §5, ADR 0561 §2, §6; issue 774). A module's grant is the record
// that its bus credential exists for the fingerprint the catalogue's trunk names for it: `<module>.<fingerprint hex>` in
// the part's state grants, naming the accounts (the module's own and each process's) the vault holds a hash for. It is the
// controller's grant as the controller composes it (cmd/mesh-controller grants.go grantsFrom), so the two compare by key.
// Until the controller's lease is handed away nobody reads the part's keys: the assigner reads the controller's
// (delivery.GrantSource), and the tool grants says every difference, so the switch is made on a measured equality.
//
// A module of which an account has no hash yet is granted the accounts that have one; a module none of whose accounts has
// one has no grant, and one it was written for is deleted (an older grant naming a node whose account is gone would let the
// assigner want it, and a grant naming no account names every node). Nothing else is ever deleted, and nothing is written
// or deleted in a pass that could not read the vault's hashes, the indexer's modules or a manifest of the module.
// GrantsWritten is what one pass did to the state grants.
type GrantsWritten struct {
Wanted int `json:"wanted"`
Written []string `json:"written,omitempty"`
Deleted []string `json:"deleted,omitempty"`
// Kept says why a pass wrote and deleted nothing, or left a module out.
Kept []string `json:"kept,omitempty"`
// Missing is each module the placement or an assignment puts on a machine that has no grant after the pass,
// `<module>: <why>`: the assigner's sixth rule wants none of it (MissingGrants). Never passed over in silence: once
// the part's grants are the ones read, it is the condition mesh-grants.grant-missing.
Missing []string `json:"missing,omitempty"`
// Waits is each grant composed and not written, `<key>: <why>`: after take-over a grant is new or changed only once the
// list the bus loaded carries it (issue 774, GrantWaits). Read with grants_grants (last_pass).
Waits []string `json:"waits,omitempty"`
}
// GrantWaits says why a grant composed now, new or changed, is not written yet, or "" when it may be (issue 774). The
// assigner moves a module's want to the fingerprint once its grant is there, so the grant is the word that the list the bus
// has loaded already carries the module's accounts as composed now (composed, from ListRecords: what runs and what the trunk
// names): every publish and subscribe allowed, the allowance of responses, and no deny the composed account lacks. A grant
// that adds no permission is written at once, whatever list is held or pending; one that adds any waits until a list that
// carries it is loaded. loaded is the loaded list by user, nil when its contents are unknown (unknown says why): then only
// a grant naming no account is written. In shadow nobody reads the part's grants and nothing waits. Pure.
func GrantWaits(tookOver bool, accounts []string, composed, loaded map[string]User, unknown string) string {
if !tookOver || len(accounts) == 0 {
return ""
}
if loaded == nil {
return "waits: the contents of the list the bus loaded are unknown (" + unknown + "), so no grant adding an account is written"
}
var adds []string
for _, user := range accounts {
c, ok := composed[user]
if !ok {
continue
}
l, ok := loaded[user]
if !ok {
adds = append(adds, user+" (not in the loaded list)")
continue
}
n := len(minus(c.Publish, l.Publish)) + len(minus(c.Subscribe, l.Subscribe))
if c.AllowResponses && !l.AllowResponses {
n++
}
if l.PublishDeny != nil {
n += len(minus(l.PublishDeny, c.PublishDeny))
}
if n > 0 {
adds = append(adds, fmt.Sprintf("%s (%d permissions)", user, n))
}
}
if len(adds) == 0 {
return ""
}
return "waits for the bus to load a list carrying what it adds: " + strings.Join(adds, ", ")
}
// **The permissions of each list the part sealed, one key per user** (issue 774, sweep 2026-10-30 item 5): the last two
// lists, by digest, each user's permissions as sealed, never a hash: what tells which grants the list the bus loaded
// carries, without the bus module's tools. One value holding every user of two lists outgrew the bucket's largest value
// (JetStream 10054, "message size exceeds maximum", 16:37 and 16:55 on 2026-10-30), so a list's permissions were never
// kept and a grant judged against them waited. Each user's permissions are a key of their own,
// `sealed-grants.<digest hex>.<user>`, which grows with that user alone; the key `sealed-lists` names the lists kept and
// their users, and is written only once every user's key of its newest list is: it never names a list whose keys are not
// all there.
// KeySealedGrants is the key that held every user of the last two lists in one value, before this release: read when the
// index names no list, and taken away once the index is written.
const KeySealedGrants = "sealed-grants"
// KeySealedLists is the key of the state user-list naming the sealed lists whose permissions are kept, newest first.
const KeySealedLists = "sealed-lists"
// sealedKept is how many sealed lists keep their permissions: the one in force and the one before.
const sealedKept = 2
// SealedGrants is the value KeySealedGrants held.
type SealedGrants struct {
Lists []SealedList `json:"lists"`
}
// SealedList is one sealed list's users and their permissions.
type SealedList struct {
Digest string `json:"digest"`
Users []User `json:"users"`
}
// SealedLists is the value of KeySealedLists: the lists kept, newest first, each by its digest and its users' names.
type SealedLists struct {
Lists []SealedNames `json:"lists"`
}
// SealedNames is one kept list: its digest and its users, sorted.
type SealedNames struct {
Digest string `json:"digest"`
Users []string `json:"users"`
}
// SealedUserKey is the key of one user's permissions in the sealed list of digest.
func SealedUserKey(digest, user string) string {
return KeySealedGrants + "." + strings.TrimPrefix(digest, "sha256:") + "." + user
}
// keepSealedGrants records the permissions of the list sealed now, one key per user, beside the list before; the lists
// older than those two lose their keys.
func (p *Part) keepSealedGrants(res *Result, digest string, list []byte, composed []User) {
in := listUsers(list)
var users []User
for _, u := range composed {
if slices.Contains(in, u.User) {
u.Hash = ""
users = append(users, u)
}
}
failed := false
names := make([]string, 0, len(users))
for _, u := range users {
names = append(names, u.User)
data, _ := json.Marshal(u)
if _, err := p.Store.Put(StateUserList, SealedUserKey(digest, u.User), data); err != nil {
failed = true
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateUserList, SealedUserKey(digest, u.User), err))
}
}
if failed {
// The index keeps naming the lists whose keys are all there; this list's permissions are not kept.
return
}
sort.Strings(names)
was := p.sealedLists()
next := SealedLists{Lists: []SealedNames{{Digest: digest, Users: names}}}
var gone []SealedNames
for _, l := range was.Lists {
switch {
case l.Digest == digest:
case len(next.Lists) < sealedKept:
next.Lists = append(next.Lists, l)
default:
gone = append(gone, l)
}
}
data, _ := json.Marshal(next)
if _, err := p.Store.Put(StateUserList, KeySealedLists, data); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateUserList, KeySealedLists, err))
return
}
for _, l := range gone {
for _, user := range l.Users {
if err := p.Store.Delete(StateUserList, SealedUserKey(l.Digest, user)); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be taken away: %v", StateUserList, SealedUserKey(l.Digest, user), err))
}
}
}
if _, _, found, err := p.Store.Get(StateUserList, KeySealedGrants); err == nil && found {
if err := p.Store.Delete(StateUserList, KeySealedGrants); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be taken away: %v", StateUserList, KeySealedGrants, err))
}
}
}
// sealedLists is the index of kept lists; none when it cannot be read.
func (p *Part) sealedLists() SealedLists {
var out SealedLists
if raw, _, found, err := p.Store.Get(StateUserList, KeySealedLists); err == nil && found {
_ = json.Unmarshal(raw, &out)
}
return out
}
// sealedGrantsOf is the permissions of each user of the sealed list of digest, as the part kept them, and whether it kept
// every one: from the index and each user's key, else from the one value written before this release.
func (p *Part) sealedGrantsOf(digest string) (map[string]User, bool) {
for _, l := range p.sealedLists().Lists {
if l.Digest != digest {
continue
}
out := map[string]User{}
for _, user := range l.Users {
raw, _, found, err := p.Store.Get(StateUserList, SealedUserKey(digest, user))
if err != nil || !found {
return nil, false
}
var u User
if json.Unmarshal(raw, &u) != nil {
return nil, false
}
out[u.User] = u
}
return out, true
}
raw, _, found, err := p.Store.Get(StateUserList, KeySealedGrants)
if err == nil && found {
var sg SealedGrants
if json.Unmarshal(raw, &sg) == nil {
for _, sl := range sg.Lists {
if sl.Digest == digest {
out := map[string]User{}
for _, u := range sl.Users {
out[u.User] = u
}
return out, true
}
}
}
}
return nil, false
}
// loadedContents is the list the bus loaded, by user: as the bus module's nats_users read it, else as the part sealed the
// list of the digest the bus node says it loaded from the part's key; nil and why when neither says.
func (p *Part) loadedContents(in Inputs, read []User, readErr error) (map[string]User, string) {
byUser := func(us []User) map[string]User {
out := map[string]User{}
for _, u := range us {
out[u.User] = u
}
return out
}
if readErr == nil {
return byUser(read), ""
}
l := in.Loaded
switch {
case l == nil:
return nil, "the bus node says no loaded list, and nats_users: " + readErr.Error()
case l.Source != delivery.GrantsUserListSource || l.Follows != delivery.FollowGrants || l.Error != "":
return nil, "the bus loaded no list of the grants part's (source " + l.Source + ", follows " + l.Follows + "), and nats_users: " + readErr.Error()
}
if kept, ok := p.sealedGrantsOf(l.Digest); ok {
return kept, ""
}
return nil, "the part keeps no permissions of the loaded list " + short(l.Digest) + " (sealed before it kept them), and nats_users: " + readErr.Error()
}
// GrantMissingCondition is the key of the warning raised while a placed module has no grant (issue 1126).
const GrantMissingCondition = "mesh-grants.grant-missing"
// MissingGrants is each module assigned or placed on a machine that want leaves without a grant, `<module>: <why>`,
// sorted, but a module the mesh provides itself, which is never wanted by fingerprint: its manifest refused (skip), no fingerprint of the new form at the indexer's trunk, or no account with a hash
// in the vault (drop). **The fingerprint is the indexer's, never a commit's** (issue 1126: link2pay, snake, f1-circuits
// and txt-game, whose source commit has not moved since a build of the old form, have a done key of the new form at it,
// and that is the fingerprint granted). Pure.
func MissingGrants(in Inputs, want map[string]delivery.ControllerGrant, drop []string, skip map[string]bool) []string {
placed := map[string]bool{}
for _, ms := range in.Assigned {
for _, m := range ms {
placed[m] = true
}
}
var out []string
for _, module := range sortedKeys(placed) {
// A module the mesh provides itself (the private network) is built by no repository, so the indexer holds no
// fingerprint of it and the assigner wants none: the controller composes it with the node's own. It needs no grant,
// and naming it missing raised a warning nothing could clear (sweep 2026-10-30 item 6).
if delivery.ProvidedByMesh(module) {
continue
}
fp, has := in.Fingerprints[module]
key, err := delivery.PartGrantKey(module, fp)
if has && err == nil {
if _, granted := want[key]; granted {
continue
}
}
why := ""
switch {
case skip[module]:
why = "its manifest at the indexer's trunk is refused"
case !has:
why = "no fingerprint of the new form: " + orSaid(in.NoFingerprint[module], "the indexer holds no value of it")
case err != nil:
why = fmt.Sprintf("no grant key for %s: %v", fp, err)
case slices.Contains(drop, key):
why = "none of its accounts at " + fp + " has a hash in the vault"
default:
why = "no grant composed at " + fp
}
out = append(out, module+": "+why)
}
return out
}
func orSaid(s, none string) string {
if s == "" {
return none
}
return s
}
// sayMissing raises mesh-grants.grant-missing while a placed module has no grant, once the controller's lease is handed
// away (the part's grants are then the ones the assigner reads), and clears it when every one has one; in shadow the
// pass and the tool say them. Said once per set, again when it changes, and always at the first pass after a start.
func (p *Part) sayMissing(missing []string) {
if p.Say == nil {
return
}
why := ""
if on, _ := leaseHandedAway(p.Read); on && len(missing) > 0 {
why = strings.Join(missing, "; ")
}
p.holdMu.Lock()
defer p.holdMu.Unlock()
if p.missingKnown && why == p.missingSaid {
return
}
c := asks.Condition{Key: GrantMissingCondition, Subject: asks.Scope{Scope: "module", ID: "mesh-grants"}}
if why == "" {
c.State = asks.StateCleared
} else {
c.Severity, c.State = asks.SeverityWarning, asks.StateRaised
c.Summary = fmt.Sprintf("%d placed module(s) have no grant, so no want can name them", len(missing))
c.Headline, c.Needs = "A placed module has no bus grant", "a look at why each has none"
c.Explanation = asks.NeedsYou + "a look. The grants part wrote no grant for these modules, so the assigner's " +
"credential rule gives them no want: " + why
c.Resolved = "every placed module has a grant at the fingerprint the indexer names"
}
if p.Say(c) == nil {
p.missingSaid, p.missingKnown = why, true
}
}
// GrantsOf is the grant of every module at the fingerprint the indexer's trunk names for it, by key, and the keys that
// have none (drop): see the comment above. A module in skip (its manifest was refused) is neither. Pure.
func GrantsOf(in Inputs, recs buscompose.Records, skip map[string]bool) (map[string]delivery.ControllerGrant, []string, error) {
principals, err := buscompose.Users(recs)
if err != nil {
return nil, nil, err
}
accounts, missing := map[string][]string{}, map[string]bool{}
for _, p := range principals {
if p.Module == "" || (p.Kind != buscompose.KindModule && p.Kind != buscompose.KindProcess) {
continue
}
if in.Hashes[p.Username()].Hash == "" {
missing[p.Module] = true
continue
}
accounts[p.Module] = append(accounts[p.Module], p.Username())
}
out := map[string]delivery.ControllerGrant{}
var drop []string
for module, fp := range in.Fingerprints {
if skip[module] || !strings.HasPrefix(fp, delivery.SourcePrefix2) {
continue
}
key, err := delivery.PartGrantKey(module, fp)
if err != nil {
continue
}
names := accounts[module]
if missing[module] && len(names) == 0 {
drop = append(drop, key)
continue
}
sort.Strings(names)
out[key] = delivery.ControllerGrant{Accounts: names}
}
sort.Strings(drop)
return out, drop, nil
}
// grantsReadFailed names a read of this pass without which no grant is written: the hashes and the indexer's modules and
// manifests. A manifest refused for one module only leaves that module out (writeGrants).
func grantsReadFailed(in Inputs) string {
for _, g := range in.Gaps {
if !g.Failed {
continue
}
for _, input := range []string{ReadAccounts, ReadModules, ReadManifests} {
if strings.HasPrefix(g.Input, input) {
return g.Input + ": " + g.Why
}
}
}
return ""
}
// writeGrants writes each grant whose key is absent or differs and says grant-written for each, and deletes the key of a
// module that has none now.
func (p *Part) writeGrants(res *Result, in Inputs, recs buscompose.Records, composed []User, loaded map[string]User, unknown string) {
if why := grantsReadFailed(in); why != "" {
res.Grants = &GrantsWritten{Kept: []string{"no grant is written or deleted in a pass that could not read " + why}}
return
}
skip := map[string]bool{}
for _, g := range in.Refused {
skip[strings.TrimPrefix(g.Input, "manifest of ")] = true
}
want, drop, err := GrantsOf(in, recs, skip)
if err != nil {
res.Errors = append(res.Errors, "the grants could not be composed: "+err.Error())
return
}
done := &GrantsWritten{Wanted: len(want), Missing: MissingGrants(in, want, drop, skip)}
res.Grants = done
p.sayMissing(done.Missing)
tookOver := p.SwitchState() == TookOver
byUser := map[string]User{}
for _, u := range composed {
byUser[u.User] = u
}
for _, key := range sortedKeys(want) {
g := want[key]
data, err := delivery.EncodeControllerGrant(g)
if err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("the grant %s cannot be encoded: %v", key, err))
continue
}
if raw, _, found, err := p.Store.Get(StateGrants, key); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be read: %v", StateGrants, key, err))
continue
} else if found {
if was, err := delivery.DecodeControllerGrant(raw); err == nil && delivery.SameControllerGrant(was, withVersion(g)) {
continue
}
}
if waits := GrantWaits(tookOver, g.Accounts, byUser, loaded, unknown); waits != "" {
done.Waits = append(done.Waits, key+": "+waits)
continue
}
if _, err := p.Store.Put(StateGrants, key, data); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be written: %v", StateGrants, key, err))
continue
}
done.Written = append(done.Written, key)
module, fp, _ := delivery.ParsePartGrantKey(key)
p.say(res, EventGrantWritten, map[string]any{"module": module, "fingerprint": fp, "accounts": g.Accounts})
}
for _, key := range drop {
if _, _, found, err := p.Store.Get(StateGrants, key); err != nil || !found {
continue
}
if err := p.Store.Delete(StateGrants, key); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("its own %s %s cannot be deleted: %v", StateGrants, key, err))
continue
}
done.Deleted = append(done.Deleted, key)
}
}
func withVersion(g delivery.ControllerGrant) delivery.ControllerGrant {
g.V = delivery.Version
return g
}
// GrantDifference is one key on which the part's grants and the controller's differ.
type GrantDifference struct {
Key string `json:"key"`
// Kind is `only the controller's`, `only the part's` or `accounts differ`.
Kind string `json:"kind"`
Controller []string `json:"controller_accounts,omitempty"`
Part []string `json:"part_accounts,omitempty"`
}
// CompareGrants lists every key the two sets differ on, in key order: a key held by one alone, or held by both with other
// accounts. Pure.
func CompareGrants(controller, part map[string]delivery.ControllerGrant) []GrantDifference {
keys := map[string]bool{}
for k := range controller {
keys[k] = true
}
for k := range part {
keys[k] = true
}
out := []GrantDifference{}
for _, k := range sortedKeys(keys) {
c, inC := controller[k]
g, inP := part[k]
switch {
case inC && !inP:
out = append(out, GrantDifference{Key: k, Kind: "only the controller's", Controller: c.Accounts})
case inP && !inC:
out = append(out, GrantDifference{Key: k, Kind: "only the part's", Part: g.Accounts})
case !delivery.SameControllerGrant(withVersion(c), withVersion(g)):
out = append(out, GrantDifference{Key: k, Kind: "accounts differ", Controller: c.Accounts, Part: g.Accounts})
}
}
return out
}