mesh/delivery merged: source.main moved to novox/mesh-delivery@13380e3d0e52
mesh/check pass 9132b6a296b9 on 13380e3d0e52 | unit 168 run, 473 kept | integration 178 run, 539 kept | composition passed in 36ms on novox
ADR 0547 clause 9: the vault writes the hash of each bus account it holds into bus-accounts.<user>. The grants part watches that state (no timer), carries the hash with each shadow user in memory only, and its user-list tool says per user whether a hash is held and how its line would differ from the loaded one (same, adds, left out for want of a credential, loses its login); never the hash, since bcrypt salts make two hashes incomparable. Take-over names the users with no hash. Issue: 1945
328 lines
11 KiB
Go
328 lines
11 KiB
Go
package grants
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
|
)
|
|
|
|
// 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"`
|
|
// 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})
|
|
}
|
|
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"`
|
|
}
|
|
|
|
// 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"`
|
|
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
|
|
}
|
|
|
|
// 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 len(d.PublishAdded)+len(d.PublishDropped)+len(d.SubscribeAdded)+len(d.SubscribeDropped) == 0 && d.Responses == "" {
|
|
c.Same++
|
|
continue
|
|
}
|
|
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])
|
|
}
|
|
}
|