Files
mesh-delivery/modules/mesh-grants/internal/grants/userlist.go
T
jschoubben 3192d89edf
mesh/delivery merged: source.main moved to novox/mesh-delivery@a49fd9cc65f7
mesh/check checking 3192d89edfcb on a49fd9cc65f7
mesh-grants: refuse a take-over over an uncompared deny; ask words only for added rights
Review round 1 of issue 1945's fix found two ways the guard could let a loss
through or hide one from the operator.

A publish deny was never compared: grants.User carried no deny, so a widened
allow that brings a new deny (a lost publish right) passed as an added right.
The user now carries its deny; a deny the shadow adds is a loss, and while
nats_users answers no deny for the loaded list, a user whose grants differ
and whose shadow grant has a deny refuses, named as a deny the loaded list
cannot be compared on. None of today's 41 differing users carries a deny
(only the four node-tools users do, and they are the same in both lists).

The ask said "takes none away" whenever rights were added, whatever else
differed. Its words now name the added rights only when nothing else refuses
(Refusals: unread list, Removing, a missing hash), and otherwise say what the
yes would be refused for, naming the users; Decide uses the same Refusals.

issue 1945
2026-10-27 22:33:53 +01:00

723 lines
26 KiB
Go

package grants
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"regexp"
"slices"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/outward"
)
// User is one bus user and its grants, as the bus module's nats_users answers them: never a password hash.
type User struct {
User string `json:"user"`
Publish []string `json:"publish"`
Subscribe []string `json:"subscribe"`
AllowResponses bool `json:"allow_responses"`
// PublishDeny is what the user may not publish though an allow reaches it (buscompose's ControllerOnly). The bus
// module's nats_users answers none today, so a loaded user's is nil, read as unknown; empty and not nil once it says.
PublishDeny []string `json:"publish_deny,omitempty"`
// Hash is the bcrypt hash the vault holds for the user (WithHashes): in memory alone, never in a digest, a compare
// or the part's state, and empty for a loaded user.
Hash string `json:"-"`
}
// WithHashes gives each user of the shadow list the hash the vault holds for it, when it holds one.
func WithHashes(users []User, hashes map[string]HeldHash) []User {
for i := range users {
users[i].Hash = hashes[users[i].User].Hash
}
return users
}
// Credential is one user's hash as the list would carry it: whether the vault holds one, and how the user stands in the
// shadow list and the loaded list. Never the hash: a bcrypt hash of the same password differs by its salt, so two hashes
// say nothing compared.
type Credential struct {
User string `json:"user"`
InShadow bool `json:"in_shadow"`
Loaded bool `json:"loaded"`
HashHeld bool `json:"hash_held"`
Adopted bool `json:"adopted,omitempty"`
// Generation is the vault's generation of the account the hash is of; zero for an adopted one.
Generation int `json:"generation,omitempty"`
// Would is how the list with the vault's hashes would differ from the loaded one for this user: same (a hash held,
// loaded), adds (a hash held, not loaded), left-out (in the shadow list, no hash: left out for want of a
// credential), loses-login (loaded, no hash held: its login would be lost) or not-composed (a hash held, loaded,
// and not in the shadow list).
Would string `json:"would"`
}
// The ways a user's line would differ.
const (
WouldSame = "same"
WouldAdd = "adds"
WouldLeaveOut = "left-out"
WouldLoseLogin = "loses-login"
WouldNotComposed = "not-composed"
)
// Credentials is every user's Credential, and the users of each way that differs.
type Credentials struct {
Users []Credential `json:"users"`
HashesHeld int `json:"hashes_held"`
LeftOut []string `json:"left_out"`
LosesLogin []string `json:"loses_login"`
Adds []string `json:"adds"`
}
// CredentialsOf says, of every user of the shadow and the loaded list, whether the vault holds its hash and how its line
// would differ. Pure.
func CredentialsOf(shadow, loaded []User, hashes map[string]HeldHash) Credentials {
c := Credentials{Users: []Credential{}, LeftOut: []string{}, LosesLogin: []string{}, Adds: []string{}}
rows := map[string]*Credential{}
row := func(user string) *Credential {
if rows[user] == nil {
h, held := hashes[user]
rows[user] = &Credential{User: user, HashHeld: held, Adopted: h.Adopted, Generation: h.Generation}
}
return rows[user]
}
for _, u := range shadow {
row(u.User).InShadow = true
}
for _, u := range loaded {
row(u.User).Loaded = true
}
for _, user := range sortedKeys(rows) {
r := rows[user]
switch {
case r.HashHeld && r.InShadow && r.Loaded:
r.Would = WouldSame
case r.HashHeld && r.InShadow:
r.Would = WouldAdd
c.Adds = append(c.Adds, user)
case r.InShadow:
r.Would = WouldLeaveOut
c.LeftOut = append(c.LeftOut, user)
case !r.HashHeld:
r.Would = WouldLoseLogin
c.LosesLogin = append(c.LosesLogin, user)
default:
r.Would = WouldNotComposed
}
if r.HashHeld {
c.HashesHeld++
}
c.Users = append(c.Users, *r)
}
return c
}
// ComposeUsers is the shadow list: every user the composer derives from the records, with its grants. Pure.
func ComposeUsers(r buscompose.Records) ([]User, error) {
principals, err := buscompose.Users(r)
if err != nil {
return nil, err
}
out := make([]User, 0, len(principals))
for _, p := range principals {
perms, err := buscompose.PermissionsFor(p)
if err != nil {
return nil, fmt.Errorf("%s: %w", p.Username(), err)
}
out = append(out, User{User: p.Username(), Publish: set(perms.Publish), Subscribe: set(perms.Subscribe),
AllowResponses: perms.AllowResponses, PublishDeny: set(perms.PublishDeny)})
}
sort.Slice(out, func(i, j int) bool { return out[i].User < out[j].User })
return out, nil
}
func set(in []string) []string {
out := slices.Clone(in)
sort.Strings(out)
out = slices.Compact(out)
if out == nil {
out = []string{}
}
return out
}
// Digest is a list's digest, `sha256:<hex>`, over its users and grants in order.
func Digest(users []User) string {
raw, _ := json.Marshal(users)
sum := sha256.Sum256(raw)
return "sha256:" + hex.EncodeToString(sum[:])
}
// LoadedUsers is the list the bus loaded, as its module's nats_users reads it from the file: every user by name, then
// each one's grants in full.
func LoadedUsers(c Caller) ([]User, error) {
raw, err := c.Call(CallUsers, map[string]any{})
if err != nil {
return nil, fmt.Errorf("%s: %w", CallUsers, err)
}
var names struct {
Users []struct {
User string `json:"user"`
} `json:"users"`
}
if err := json.Unmarshal(unwrap(raw), &names); err != nil {
return nil, fmt.Errorf("%s answered no list of users: %w", CallUsers, err)
}
full := map[string]User{}
ask := func(part string) error {
raw, err := c.Call(CallUsers, map[string]any{"user": part})
if err != nil {
return fmt.Errorf("%s %s: %w", CallUsers, part, err)
}
var got struct {
Users []User `json:"users"`
}
if err := json.Unmarshal(unwrap(raw), &got); err != nil {
return fmt.Errorf("%s %s answered no grants: %w", CallUsers, part, err)
}
for _, u := range got.Users {
u.Publish, u.Subscribe = set(u.Publish), set(u.Subscribe)
full[u.User] = u
}
return nil
}
// Every name with a dot in one call; each name without one (the controller) alone.
if err := ask("."); err != nil {
return nil, err
}
out := make([]User, 0, len(names.Users))
for _, n := range names.Users {
if _, ok := full[n.User]; !ok {
if err := ask(n.User); err != nil {
return nil, err
}
}
u, ok := full[n.User]
if !ok {
return nil, fmt.Errorf("%s names %s and gives no grants for it", CallUsers, n.User)
}
out = append(out, u)
}
sort.Slice(out, func(i, j int) bool { return out[i].User < out[j].User })
return out, nil
}
// unwrap takes a tool's answer out of an MCP content wrapper, when a runtime hands one.
func unwrap(raw json.RawMessage) []byte {
var wrapped struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
}
if json.Unmarshal(raw, &wrapped) == nil && len(wrapped.Content) == 1 && wrapped.Content[0].Text != "" {
return []byte(wrapped.Content[0].Text)
}
return raw
}
// UserDiff is how one user's grants differ: what the shadow list grants that the loaded list does not, and back.
type UserDiff struct {
User string `json:"user"`
PublishAdded []string `json:"publish_added,omitempty"`
PublishDropped []string `json:"publish_dropped,omitempty"`
SubscribeAdded []string `json:"subscribe_added,omitempty"`
SubscribeDropped []string `json:"subscribe_dropped,omitempty"`
Responses string `json:"responses,omitempty"`
// DenyAdded is what the shadow list denies the user to publish and the loaded list does not: a right lost. DenyUnread
// is the shadow's deny of a user whose grants differ, where the loaded list's deny cannot be read (nats_users answers
// none): it cannot be compared, so it is no added right either (ADR 0562 §2).
DenyAdded []string `json:"deny_added,omitempty"`
DenyUnread []string `json:"deny_unread,omitempty"`
}
// same says the user's grants agree in both lists.
func (d UserDiff) same() bool {
return len(d.PublishAdded)+len(d.PublishDropped)+len(d.SubscribeAdded)+len(d.SubscribeDropped)+len(d.DenyAdded) == 0 && d.Responses == ""
}
// Comparison is the shadow list against the loaded list.
type Comparison struct {
Shadow int `json:"shadow"`
Loaded int `json:"loaded"`
// Added are users in the shadow list the loaded list lacks (one left out for want of a credential among them);
// Dropped are loaded users the shadow list lacks; Gone are those of Dropped whose machine or module placement no
// longer names, which both lists agree are gone.
Added []string `json:"added"`
Dropped []string `json:"dropped"`
Gone []string `json:"gone,omitempty"`
Differ []UserDiff `json:"differ"`
// Excused are the subjects the comparison does not count, each with why (Comparison.Excusing).
Excused []Excuse `json:"excused,omitempty"`
Same int `json:"same"`
// Unread says the loaded list could not be read, and why: nothing is compared then.
Unread string `json:"unread,omitempty"`
}
// Equal says the two lists agree in every user, but those both agree are gone.
func (c Comparison) Equal() bool {
return c.Unread == "" && len(c.Added) == 0 && len(c.Differ) == 0 && len(c.Dropped) == len(c.Gone)
}
// Differing names every user that keeps the lists apart: added, dropped and not gone, or granted otherwise.
func (c Comparison) Differing() []string {
var out []string
out = append(out, c.Added...)
for _, d := range c.Dropped {
if !slices.Contains(c.Gone, d) {
out = append(out, d)
}
}
for _, d := range c.Differ {
out = append(out, d.User)
}
sort.Strings(out)
return out
}
// responsesLost says the shadow list takes away a user's allowance of responses the loaded list gives.
func (d UserDiff) responsesLost() bool { return strings.HasPrefix(d.Responses, "shadow false") }
// Removing names every user that keeps the lists apart other than by a right the shadow list adds, each with how: a user
// the shadow list adds, a loaded user it drops that placement still names, a publish or subscribe right a loaded user
// would lose, or an allowance of responses lost (ADR 0562 §2). Empty when the lists agree or differ only by added rights.
func (c Comparison) Removing() []string {
var out []string
for _, u := range c.Added {
out = append(out, u+" (a user the loaded list does not hold)")
}
for _, u := range c.Dropped {
if !slices.Contains(c.Gone, u) {
out = append(out, u+" (dropped, and placement still names it)")
}
}
for _, d := range c.Differ {
var lost []string
if n := len(d.PublishDropped); n > 0 {
lost = append(lost, fmt.Sprintf("%d publish", n))
}
if n := len(d.SubscribeDropped); n > 0 {
lost = append(lost, fmt.Sprintf("%d subscribe", n))
}
if d.responsesLost() {
lost = append(lost, "its allowance of responses")
}
if n := len(d.DenyAdded); n > 0 {
lost = append(lost, fmt.Sprintf("%d publish to a deny added", n))
}
switch {
case len(lost) > 0:
out = append(out, d.User+" (would lose "+strings.Join(lost, " and ")+")")
case len(d.DenyUnread) > 0:
out = append(out, d.User+" (a deny the loaded list cannot be compared on)")
}
}
sort.Strings(out)
return out
}
// AddedRights are the rights the shadow list adds, by user: every user's added publish and subscribe rights and an
// allowance of responses gained, whatever else the comparison holds. They are the whole difference only where Removing is
// empty, and only then is a take-over allowed for them (ADR 0562 §1, §2).
func (c Comparison) AddedRights() []UserDiff {
var out []UserDiff
for _, d := range c.Differ {
a := UserDiff{User: d.User, PublishAdded: d.PublishAdded, SubscribeAdded: d.SubscribeAdded}
if !d.responsesLost() {
a.Responses = d.Responses
}
if len(a.PublishAdded)+len(a.SubscribeAdded) > 0 || a.Responses != "" {
out = append(out, a)
}
}
return out
}
// AddedDigest is the digest of every right the shadow list adds: what an ask names and its yes answers (ADR 0562 §3).
func (c Comparison) AddedDigest() string {
var lines []string
for _, d := range c.AddedRights() {
for _, s := range d.PublishAdded {
lines = append(lines, d.User+"\tpublish\t"+s)
}
for _, s := range d.SubscribeAdded {
lines = append(lines, d.User+"\tsubscribe\t"+s)
}
if d.Responses != "" {
lines = append(lines, d.User+"\tresponses")
}
}
sort.Strings(lines)
sum := sha256.Sum256([]byte(strings.Join(lines, "\n")))
return "sha256:" + hex.EncodeToString(sum[:])
}
// The kinds an added right is told by in the operator's ask.
const (
kindAdvisory = "advisories"
kindRead = "reads"
kindCall = "calls"
kindEvent = "events"
kindOther = "other"
)
var rightKinds = []string{kindRead, kindAdvisory, kindCall, kindEvent, kindOther}
func kindOfRight(subject string) string {
switch {
case strings.HasPrefix(subject, "$JS.EVENT.ADVISORY."):
return kindAdvisory
case strings.HasPrefix(subject, "$JS.") || strings.HasPrefix(subject, "$KV.") || strings.HasPrefix(subject, "$O."):
return kindRead
case strings.HasPrefix(subject, "mesh.seat."):
return kindCall
case strings.HasPrefix(subject, "mesh.mod.") && strings.Contains(subject, ".event."):
return kindEvent
}
return kindOther
}
// kindWords says what each kind is, in the operator's words.
var kindWords = map[string]string{kindRead: "reads of buckets and streams", kindAdvisory: "stream-created advisories",
kindCall: "seat calls", kindEvent: "events", kindOther: "other subjects"}
// tally is one user's added rights counted by kind, as "36 reads, 1 call"; responses allowed are said too.
func tally(d UserDiff) string {
n := map[string]int{}
for _, s := range append(append([]string(nil), d.PublishAdded...), d.SubscribeAdded...) {
n[kindOfRight(s)]++
}
var parts []string
for _, k := range rightKinds {
if n[k] > 0 {
word := k
if n[k] == 1 {
word = map[string]string{kindAdvisory: "advisory", kindRead: "read", kindCall: "call", kindEvent: "event", kindOther: "other"}[k]
}
parts = append(parts, fmt.Sprintf("%d %s", n[k], word))
}
}
if d.Responses != "" {
parts = append(parts, "responses allowed")
}
return strings.Join(parts, ", ")
}
// AddedWordsBudget is how many characters the ask's list of added rights may take, so the ask fits one message on a
// channel such as Telegram (4096 characters) with its headline, explanation and options.
const AddedWordsBudget = 2200
// AddedWords are an ask's words for the rights the shadow list adds (ADR 0562 §3): a summary, a point per kind with its
// total, and the list by user, users with the same count grouped on one line; within the content rule (outward.Check,
// a name that would not pass written with spaces) and AddedWordsBudget, past which the last lines are counted, not named.
func (c Comparison) AddedWords() (summary string, points []string, list string) {
adds := c.AddedRights()
if len(adds) == 0 || c.Unread != "" || len(c.Removing()) > 0 {
return "", nil, ""
}
total, byKind, usersOf := 0, map[string]int{}, map[string]int{}
group := map[string][]string{}
for _, d := range adds {
seen := map[string]bool{}
for _, s := range append(append([]string(nil), d.PublishAdded...), d.SubscribeAdded...) {
k := kindOfRight(s)
total++
byKind[k]++
if !seen[k] {
seen[k] = true
usersOf[k]++
}
}
t := tally(d)
group[t] = append(group[t], plainName(d.User))
}
summary = fmt.Sprintf("The grants part's list adds %s to %s and takes none away; no user, hash or login changes.", count(total, "right"), count(len(adds), "user"))
var kinds []string
for _, k := range rightKinds {
if byKind[k] > 0 {
kinds = append(kinds, fmt.Sprintf("%d %s (%s)", byKind[k], kindWords[k], count(usersOf[k], "user")))
}
}
// One point of the kinds, wrapped at the explanation's point length.
line := "Added: "
for i, k := range kinds {
if i > 0 && len([]rune(line))+len([]rune(k))+2 > 160 {
points = append(points, strings.TrimSuffix(line, ", "))
line = "Added: "
}
line += k + ", "
}
points = append(points, strings.TrimSuffix(line, ", ")+".")
if n := len(c.Excused); n > 0 {
points = append(points, fmt.Sprintf("Not counted: %d rights excused as before (a retired verb, a seat held on several machines).", n))
}
tallies := sortedKeys(group)
sort.SliceStable(tallies, func(i, j int) bool { return len(group[tallies[i]]) > len(group[tallies[j]]) })
var lines []string
used := 0
for i, t := range tallies {
users := group[t]
sort.Strings(users)
l := strings.Join(users, ", ") + ": " + t
if len(users) > 1 {
l += " each"
}
if used+len([]rune(l))+1 > AddedWordsBudget-80 {
rest := 0
for _, more := range tallies[i:] {
rest += len(group[more])
}
lines = append(lines, fmt.Sprintf("and %d more users: grants_user_list names every right added.", rest))
break
}
lines = append(lines, l)
used += len([]rune(l)) + 1
}
return summary, points, strings.Join(lines, "\n")
}
// count is n things, with the plural where n is not one.
func count(n int, thing string) string {
if n == 1 {
return "1 " + thing
}
return fmt.Sprintf("%d %ss", n, thing)
}
// Refusals are why a take-over is refused for the lists themselves, each naming its users: the loaded list unread, any user
// the lists keep apart other than by an added right (Removing), and any user with no hash in the vault but those both
// lists agree are gone. Empty when the lists differ at most by added rights and every login is kept.
func Refusals(c Comparison, cr Credentials) []string {
var reasons []string
if c.Unread != "" {
reasons = append(reasons, "the loaded list could not be read: "+c.Unread)
}
if d := c.Removing(); len(d) > 0 {
reasons = append(reasons, fmt.Sprintf("%d users differ between the shadow list and the loaded list other than by an added right: %s", len(d), strings.Join(d, ", ")))
}
var unhashed []string
for _, u := range append(append([]string(nil), cr.LeftOut...), cr.LosesLogin...) {
if !slices.Contains(c.Gone, u) {
unhashed = append(unhashed, u)
}
}
if len(unhashed) > 0 {
sort.Strings(unhashed)
reasons = append(reasons, fmt.Sprintf("the vault holds no hash for %d users (ADR 0547 clause 9: handed or adopted before take-over): %s",
len(unhashed), strings.Join(unhashed, ", ")))
}
return reasons
}
// TakeOverWords are the take-over ask's words for the lists as compared now: the rights the grants part's list adds
// (AddedWords) when nothing else keeps them apart, or else what a yes would be refused for, the users named in its list
// (ADR 0562 §2, §3). Empty when the lists agree. Held to the content rule: the loaded list's read error is not said.
func TakeOverWords(c Comparison, cr Credentials) (summary string, points []string, list string) {
refusals := Refusals(c, cr)
if len(refusals) == 0 {
return c.AddedWords()
}
summary = "Today a yes would be refused: the lists differ other than by an added right, the loaded list is unread, or a user has no hash."
var lines []string
if c.Unread != "" {
lines = append(lines, "The loaded list could not be read.")
refusals = refusals[1:]
}
used := 0
for _, r := range refusals {
if used+len([]rune(r)) > AddedWordsBudget-80 {
r = string([]rune(r)[:max(0, AddedWordsBudget-80-used)]) + "… grants_take_over_state says the rest."
}
lines = append(lines, r)
used += len([]rune(r)) + 1
if used >= AddedWordsBudget-80 {
break
}
}
for i := range lines {
if _, ok := outward.Check(lines[i]); !ok {
lines[i] = strings.ReplaceAll(lines[i], ".", " ")
}
}
return summary, []string{"Your yes now is refused and said, naming them; ask again once they agree."}, strings.Join(lines, "\n")
}
// plainName is a user's name as the operator channel's content rule lets it pass: as it is, or with spaces for its dots
// where it reads as a host name.
func plainName(user string) string {
if _, ok := outward.Check(user); ok {
return user
}
return strings.ReplaceAll(user, ".", " ")
}
// Compare compares the shadow list with the loaded one. placed says whether placement still names a loaded user's
// machine and module; nil names every one. Pure.
func Compare(shadow, loaded []User, placed func(user string) bool) Comparison {
c := Comparison{Shadow: len(shadow), Loaded: len(loaded), Added: []string{}, Dropped: []string{}, Differ: []UserDiff{}}
have := map[string]User{}
for _, u := range loaded {
have[u.User] = u
}
want := map[string]bool{}
for _, s := range shadow {
want[s.User] = true
l, ok := have[s.User]
if !ok {
c.Added = append(c.Added, s.User)
continue
}
d := UserDiff{User: s.User, PublishAdded: minus(s.Publish, l.Publish), PublishDropped: minus(l.Publish, s.Publish),
SubscribeAdded: minus(s.Subscribe, l.Subscribe), SubscribeDropped: minus(l.Subscribe, s.Subscribe)}
if s.AllowResponses != l.AllowResponses {
d.Responses = fmt.Sprintf("shadow %v, loaded %v", s.AllowResponses, l.AllowResponses)
}
if l.PublishDeny != nil {
d.DenyAdded = minus(s.PublishDeny, l.PublishDeny)
}
if d.same() {
// The same allows compose the same deny: one composer derives it from them.
c.Same++
continue
}
if l.PublishDeny == nil {
d.DenyUnread = s.PublishDeny
}
c.Differ = append(c.Differ, d)
}
for _, l := range loaded {
if !want[l.User] {
c.Dropped = append(c.Dropped, l.User)
if placed != nil && !placed(l.User) {
c.Gone = append(c.Gone, l.User)
}
}
}
return c
}
func minus(a, b []string) []string {
var out []string
for _, x := range a {
if !slices.Contains(b, x) {
out = append(out, x)
}
}
return out
}
// Placed says whether placement still names a user's machine and module: a machine's users (`node.<n>`,
// `provisioner.<n>`) while the machine is seen, a module's or process's (`<n>.<module>[.<process>]`) while the module is
// assigned there. The controller, enrolment users and persons are never judged gone from here.
func Placed(in Inputs) func(user string) bool {
return func(user string) bool {
parts := strings.Split(user, ".")
switch {
case len(parts) < 2:
return true
case parts[0] == "node" || parts[0] == "provisioner":
return slices.Contains(in.Nodes, parts[1])
case parts[0] == "person" || parts[0] == "enrol":
return true
}
return slices.Contains(in.Nodes, parts[0]) && slices.Contains(in.Assigned[parts[0]], parts[1])
}
}
// Excuse is one subject the comparison does not count as a difference between the lists, and why.
type Excuse struct {
User string `json:"user"`
Subject string `json:"subject"`
Why string `json:"why"`
}
// Excusing removes from the differences the subjects f excuses (added: the shadow grants it and the loaded list does not),
// naming each in Excused; a user left with no difference counts as the same. Pure.
func (c Comparison) Excusing(f func(user, subject string, added, subscribe bool) string) Comparison {
var differ []UserDiff
for _, d := range c.Differ {
keep := func(list []string, added, subscribe bool) []string {
var out []string
for _, s := range list {
if why := f(d.User, s, added, subscribe); why != "" {
c.Excused = append(c.Excused, Excuse{User: d.User, Subject: s, Why: why})
continue
}
out = append(out, s)
}
return out
}
d.PublishAdded, d.PublishDropped = keep(d.PublishAdded, true, false), keep(d.PublishDropped, false, false)
d.SubscribeAdded, d.SubscribeDropped = keep(d.SubscribeAdded, true, true), keep(d.SubscribeDropped, false, true)
if d.same() {
c.Same++
continue
}
differ = append(differ, d)
}
if differ == nil {
differ = []UserDiff{}
}
c.Differ = differ
return c
}
// seatSubject is a seat verb's subject, for any caller (`.*`) or one machine's (`.<node>`, a seat of every node).
var seatSubject = regexp.MustCompile(`^mesh\.seat\.([^.]+)\.(?:call|tool)\.([^.]+)(?:\.[^.]+)?$`)
var seatService = regexp.MustCompile(`^\$SRV\.(?:INFO|PING|STATS)\.([^.]+)(?:\.>)?$`)
// ExcusedBy is the excuse of a comparison until placement lives in the catalogue (the operator, 2026-10-26): a verb the
// seat catalogue retired on purpose, which the shadow no longer grants, is no difference; and what the shadow grants more
// for a mesh seat that is claimed on several machines (the standing gap seat holdings: it composes the seat as held on each)
// is named and not counted, for a user on a machine that claims the seat and only as a subscription (what it serves), since
// a subscription to a seat's verbs on a machine whose holder does not answer them harms nothing and is gone when holdings
// follow the catalogue. Nothing else is excused: an added publish, a grant of a user on another machine, or the loss of any
// other subject, keeps the lists apart.
func ExcusedBy(multiHeld map[string][]string) func(user, subject string, added, subscribe bool) string {
// holds says the user is on a machine where the seat is claimed: the machine's tool runner, or a module of it.
holds := func(user, seat string) bool {
node, _, _ := strings.Cut(user, ".")
return slices.Contains(multiHeld[seat], node)
}
return func(user, subject string, added, subscribe bool) string {
if m := seatSubject.FindStringSubmatch(subject); m != nil {
if !added && subscribe && retiredVerb(m[1], m[2]) {
return "a verb the seat catalogue retired"
}
if added && subscribe && holds(user, m[1]) {
return "seat " + m[1] + " is claimed on several machines: composed as held on each until holdings follow the catalogue"
}
}
if m := seatService.FindStringSubmatch(subject); m != nil && added && subscribe && holds(user, m[1]) {
return "seat " + m[1] + " is claimed on several machines: composed as held on each until holdings follow the catalogue"
}
return ""
}
}
// MultiHeld are the mesh seats (held once for the mesh) that more than one machine's module claims, each with the
// machines that claim it.
func MultiHeld(r buscompose.Records) map[string][]string {
nodes := map[string][]string{}
for node, declared := range r.Assigned {
for _, d := range declared {
for _, s := range d.Holds {
if (s.Scope == "" || s.Scope == catalogue.ScopeMesh) && !slices.Contains(nodes[s.Name], node) {
nodes[s.Name] = append(nodes[s.Name], node)
}
}
}
}
for seat, ns := range nodes {
if len(ns) < 2 {
delete(nodes, seat)
continue
}
sort.Strings(ns)
}
return nodes
}