diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 1cc54a18..e9260a73 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -2448,6 +2448,31 @@ func TestGrants(t *testing.T) { ledgerX := strings.Replace(xManifest, `"state"`, `"requires":[{"name":"records","storage":"ledger","template":"ledger",`+ `"types":[{"name":"decision","schema":{},"operations":[{"name":"propose","effect":"set-fields"}]}]}],"state"`, 1) catalogSeat := func(s string) bool { return strings.HasPrefix(s, "mesh.seat.mesh-catalog.") } + // Review round 1: the list in force gives a wide publish and a deny, the pass composes the user narrower with the + // composer's empty deny: the user kept keeps the deny, and dropping it counts as a loss. + for _, d := range []struct { + name string + composed, kept grants.User + loss string + }{ + {"a deny of the list in force is kept", + grants.User{User: "a.mod-v", Publish: []string{"mesh.a"}, Subscribe: []string{}, PublishDeny: []string{}}, + grants.User{User: "a.mod-v", Publish: []string{"mesh.>", "mesh.a"}, Subscribe: []string{}, PublishDeny: []string{"mesh.seat.mesh-vault.desk.>"}}, + "deny dropped mesh.seat.mesh-vault.desk.>"}, + {"a deny the pass composes is kept beside it", + grants.User{User: "a.mod-v", Publish: []string{"mesh.a"}, Subscribe: []string{}, PublishDeny: []string{"mesh.b"}}, + grants.User{User: "a.mod-v", Publish: []string{"mesh.>", "mesh.a"}, Subscribe: []string{}, PublishDeny: []string{"mesh.b", "mesh.seat.mesh-vault.desk.>"}}, + "deny mesh.b"}, + } { + inForce := grants.User{User: "a.mod-v", Publish: []string{"mesh.>"}, Subscribe: []string{}, PublishDeny: []string{"mesh.seat.mesh-vault.desk.>"}} + got := grants.KeepLoaded([]grants.User{d.composed}, nil, map[string]grants.User{"a.mod-v": inForce})["a.mod-v"] + if !slices.Equal(got.Publish, d.kept.Publish) || !slices.Equal(got.PublishDeny, d.kept.PublishDeny) { + t.Errorf("%s: kept %+v, want %+v", d.name, got, d.kept) + } + if !slices.Contains(grants.Losses(d.composed, inForce), d.loss) { + t.Errorf("%s: losses %v name no %q", d.name, grants.Losses(d.composed, inForce), d.loss) + } + } for _, row := range []struct { name string bend func(*fakeBus, *grants.Part) @@ -2497,6 +2522,22 @@ func TestGrants(t *testing.T) { if g, _, _ := p.Sealed(); g.Digest == "" { t.Fatal("no list in force sealed") } + // The indexer's membership as published serves a tool more than any pass composes now (a tool runner's + // seat, say): a pass composing part of the mesh keeps it as published, a whole one publishes what it composes. + const extra = `{"subject":"mesh.tools.a.mesh-delivery-indexer.extra"}` + { + var m map[string]any + if err := json.Unmarshal(b.own[grants.StateMemberships]["a.mesh-delivery-indexer"], &m); err != nil { + t.Fatal(err) + } + var e any + _ = json.Unmarshal([]byte(extra), &e) + m["serves"] = append(m["serves"].([]any), e) + b.own[grants.StateMemberships]["a.mesh-delivery-indexer"], _ = json.Marshal(m) + } + servesExtra := func() bool { + return strings.Contains(string(b.own[grants.StateMemberships]["a.mesh-delivery-indexer"]), "mesh-delivery-indexer.extra") + } // The bus loaded it with what a whole seat list gives besides, and without the indexer's serving yet. var loaded []grants.User for _, u := range whole { @@ -2521,6 +2562,12 @@ func TestGrants(t *testing.T) { if g, _, _ := p.Sealed(); true { t.Logf("sealed %d users (%s), kept with the loaded list's grants %v, said %q", g.Users, short(g.Digest), res.KeptLoaded, res.Sealed.Why) } + partial := row.name != "the ledger of a module whose manifest is refused" + if servesExtra() != partial || partial != slices.ContainsFunc(res.NotPublished, func(n string) bool { + return strings.HasPrefix(n, "a.mesh-delivery-indexer: kept as published: ") && strings.Contains(n, "mesh-delivery-indexer.extra") + }) { + t.Errorf("the published membership serving more: kept %v in a pass composing part of the mesh %v; not published %v", servesExtra(), partial, res.NotPublished) + } if row.held != "" { if res.Sealed == nil || !strings.Contains(res.Sealed.Why, row.held) { t.Fatalf("not held for %q: %+v", row.held, res.Sealed) diff --git a/modules/mesh-grants/internal/grants/keeploaded.go b/modules/mesh-grants/internal/grants/keeploaded.go index 22369709..80ef40a5 100644 --- a/modules/mesh-grants/internal/grants/keeploaded.go +++ b/modules/mesh-grants/internal/grants/keeploaded.go @@ -1,6 +1,7 @@ package grants import ( + "encoding/json" "fmt" "slices" "sort" @@ -11,7 +12,7 @@ import ( // the node-engines' haves (Inputs.ByHaves), what the composer derives is a part of the mesh: that list sealed took the node // seats from every tool runner, the vault's reads from the node-engines and the seat queues from the build modules. Such a // pass composes each user of the loaded list and of the list in force as the union of what it composes and what those lists -// give it (publish, subscribe, the allowance of responses; a deny only where every list that says one denies it), so it +// give it (publish, subscribe, the allowance of responses, and every deny any of them says), so it // only adds, the indexer's serving of the seat that reads the list included; and the list is held where it would still take // a permission from a loaded user (holdsBack, LoadedLosses). @@ -52,18 +53,16 @@ func KeepLoaded(composed, loaded []User, inForce map[string]User) map[string]Use if !short { continue } + // Every deny any list says is kept: a deny the composer drops while it composes part of the mesh would widen the + // user (the composer gives every composed user a deny, empty or not, so an intersection would drop the list in + // force's). u := User{User: name, Publish: c.Publish, Subscribe: c.Subscribe, AllowResponses: c.AllowResponses, PublishDeny: c.PublishDeny} - denyKnown := composedHere && c.PublishDeny != nil for _, o := range from { u.Publish = set(append(slices.Clone(u.Publish), o.Publish...)) u.Subscribe = set(append(slices.Clone(u.Subscribe), o.Subscribe...)) u.AllowResponses = u.AllowResponses || o.AllowResponses - switch { - case o.PublishDeny == nil: - case !denyKnown: - u.PublishDeny, denyKnown = set(o.PublishDeny), true - default: - u.PublishDeny = slices.DeleteFunc(slices.Clone(u.PublishDeny), func(d string) bool { return !slices.Contains(o.PublishDeny, d) }) + if o.PublishDeny != nil || u.PublishDeny != nil { + u.PublishDeny = set(append(slices.Clone(u.PublishDeny), o.PublishDeny...)) } } if u.Publish == nil { @@ -77,8 +76,9 @@ func KeepLoaded(composed, loaded []User, inForce map[string]User) map[string]Use return out } -// Losses are the permissions had gives that now does not: each publish and subscribe, the allowance of responses, and -// each deny now adds where had's denies are known. Pure. +// Losses are how now differs from had where had's word is lost: each publish and subscribe had gives that now does not, +// the allowance of responses, and, where had's denies are known, each deny now adds and each deny of had now drops (a deny +// dropped widens the user past what had held it to). Pure. func Losses(now, had User) []string { var out []string for _, s := range minus(had.Publish, now.Publish) { @@ -94,6 +94,9 @@ func Losses(now, had User) []string { for _, s := range minus(now.PublishDeny, had.PublishDeny) { out = append(out, "deny "+s) } + for _, s := range minus(had.PublishDeny, now.PublishDeny) { + out = append(out, "deny dropped "+s) + } } return out } @@ -120,3 +123,51 @@ func LoadedLosses(listed []string, composed, loaded []User) []string { sort.Strings(out) return slices.Compact(out) } + +// MembershipNarrows names what the membership last published (was) gives that the one composed now does not, field by field +// (` `), the private network, the contributions received and the bindings aside, which the pass carries over +// or the bus module writes; none when now covers was. Both are memberships as JSON. Pure. +func MembershipNarrows(was, now []byte) []string { + var a, b map[string]any + if json.Unmarshal(was, &a) != nil || json.Unmarshal(now, &b) != nil { + return nil + } + var out []string + for _, field := range sortedKeys(a) { + switch field { + case "mesh", "receives", "bindings": + continue + } + out = append(out, uncovered(field, a[field], b[field])...) + } + return out +} + +// uncovered names each part of a that b does not hold: a key of an object by key, an element of a list by its value. +func uncovered(path string, a, b any) []string { + switch av := a.(type) { + case map[string]any: + bv, _ := b.(map[string]any) + var out []string + for _, k := range sortedKeys(av) { + out = append(out, uncovered(path+"."+k, av[k], bv[k])...) + } + return out + case []any: + bv, _ := b.([]any) + var out []string + for _, e := range av { + ej, _ := json.Marshal(e) + if !slices.ContainsFunc(bv, func(x any) bool { xj, _ := json.Marshal(x); return string(xj) == string(ej) }) { + out = append(out, path+" "+string(ej)) + } + } + return out + } + aj, _ := json.Marshal(a) + bj, _ := json.Marshal(b) + if string(aj) != string(bj) { + return []string{path + " " + string(aj)} + } + return nil +} diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index 5f3c9381..02dca360 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -434,6 +434,17 @@ func (p *Part) Compose() Result { continue } m, now := publish(was, found, ms[key], holdings, seated) + // A pass that composes part of the mesh never narrows a published membership: it keeps the one published + // (keeploaded.go, issue 1966), so a tool runner or a seat holder goes on serving what it serves. + if why := in.keepsLoaded(); now && found && why != "" { + if body, err := json.Marshal(m.Membership); err == nil { + if bare, err := membership.BareGrantsMembership(was); err == nil { + if narrower := MembershipNarrows(bare, body); len(narrower) > 0 { + m.Published, now = "kept as published: "+why+", and this pass composes it without "+strings.Join(narrower, ", "), false + } + } + } + } // Published is written to the state, where every runtime reads it (process.FollowMembership); only one // published, so a membership held back never reaches a reader. if now {