diff --git a/internal/broker/callers.go b/internal/broker/callers.go new file mode 100644 index 00000000..24392b58 --- /dev/null +++ b/internal/broker/callers.go @@ -0,0 +1,117 @@ +package broker + +import ( + "regexp" + "strings" +) + +// A tool call names its caller (novox/hq issue 365, by ADR 0259 §3's precedent for asks). +// +// mesh.mod..call.[.]. +// mesh.seat..call.[.]. +// +// The last token is the bus user that published the call, and each user is granted these subjects with its own +// name there and no other — the caller is a fact the server enforces, and the tool runtime hands it to the +// module from the subject a call arrived on (mesh-tools node-tools internal/bus caller.go holds the same shape). +// +// **A kind of its own, `call`, not the `tool` subject with a token appended.** Every grant to call a tool is a +// wildcard over the `tool` kind — `.tool..*` for the instance on any machine, `mesh.mod.*.tool.>` for every +// tool — and either would match a `tool` subject with any caller appended, so a caller could name another. +// Under `call` nothing is granted but the caller-named subjects. No stream's filter covers it: a tool call is +// never persisted (design 25 §3). +// +// **Derived from the `tool` grants, in one place** (callerNamed): wherever a principal may call or answer a +// tool, it may call it in its own name, or answer it naming any caller — so no kind of principal is left +// unable to call in its own name, and a new grant to call is one in both shapes by construction. +// +// The `tool` grants stay beside these for one release, so every caller and every runtime moves without a gap: +// their retirement is hq issue 464. +const CallKind = "call" + +var userName = regexp.MustCompile(`^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$`) + +// CallerToken is a bus user's name as the last token of a call it publishes: each dot written `~`, which no part +// of a user's name may hold (safeSubject), so the token names that user and no other. "" for a name that is not +// a user's. +func CallerToken(user string) string { + if !userName.MatchString(user) { + return "" + } + return strings.ReplaceAll(user, ".", "~") +} + +// toolGrant splits a grant on the `tool` kind — `mesh.mod..tool.` or `mesh.seat..tool.`, any +// part possibly a wildcard — at the kind; ok is false for any other subject. +func toolGrant(subject string) (head, rest string, ok bool) { + parts := strings.SplitN(subject, ".", 5) + if len(parts) != 5 || parts[0] != "mesh" || (parts[1] != "mod" && parts[1] != "seat") || parts[3] != "tool" || + parts[2] == "" || parts[4] == "" { + return "", "", false + } + return parts[0] + "." + parts[1] + "." + parts[2], parts[4], true +} + +// CalledSubject is where a call to a tool subject goes naming its caller; "" when the subject is not a tool's +// or the user is not a bus user. +func CalledSubject(subject, user string) string { + head, rest, ok := toolGrant(subject) + token := CallerToken(user) + if !ok || token == "" || strings.ContainsAny(rest, "*>") { + return "" + } + return head + "." + CallKind + "." + rest + "." + token +} + +// CalledPattern is what a holder answering a tool subject also subscribes, to hear the calls that name their +// caller: the same address under the `call` kind, any caller last. "" for a subject that is not a tool's. +func CalledPattern(subject string) string { + head, rest, ok := toolGrant(subject) + if !ok || strings.ContainsAny(rest, "*>") { + return "" + } + return head + "." + CallKind + "." + rest + ".*" +} + +// calledPublish is a grant to call on the `tool` kind, as the same grant in the caller's own name: its last +// token the caller's, and nothing that reaches past it. A `>` is every tail a call carries — the tool alone or +// the tool and the machine — so it becomes both, each ending in the caller. +func calledPublish(grant, token string) []string { + head, rest, ok := toolGrant(grant) + if !ok || token == "" { + return nil + } + base := head + "." + CallKind + "." + switch { + case rest == ">": + return []string{base + "*." + token, base + "*.*." + token} + case strings.HasSuffix(rest, ".>"): + return []string{base + strings.TrimSuffix(rest, ">") + "*." + token} + } + return []string{base + rest + "." + token} +} + +// calledSubscribe is a grant to answer on the `tool` kind, as the same grant for the calls naming any caller. +func calledSubscribe(grant string) []string { + head, rest, ok := toolGrant(grant) + if !ok { + return nil + } + base := head + "." + CallKind + "." + if strings.HasSuffix(rest, ">") { + return []string{base + rest} + } + return []string{base + rest + ".*"} +} + +// callerNamed adds, beside a principal's grants on the `tool` kind, the same grants under `call`: to publish in +// its own name, to subscribe naming anybody. +func callerNamed(user string, pub, sub []string) ([]string, []string) { + token := CallerToken(user) + for _, g := range pub { + pub = append(pub, calledPublish(g, token)...) + } + for _, g := range sub { + sub = append(sub, calledSubscribe(g)...) + } + return unique(pub), unique(sub) +} diff --git a/internal/broker/callers_live_test.go b/internal/broker/callers_live_test.go new file mode 100644 index 00000000..cacb0424 --- /dev/null +++ b/internal/broker/callers_live_test.go @@ -0,0 +1,100 @@ +package broker + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" + "golang.org/x/crypto/bcrypt" +) + +// **On a real server, as composed** (novox/hq issue 365): a machine's runtime calls in its own name, and the +// server refuses it a call naming another — the grant, not the runtime, is what makes the caller a fact. +func TestAServerComposedFromTheGrantsRefusesACallNamingAnother(t *testing.T) { + hash, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + ledger := Seat{Name: "issue-tracker", Scope: "mesh", Serves: []string{"open"}} + accounts, err := ComposeAccounts([]Principal{ + {Kind: KindNodeTools, Node: "novox", Module: RuntimeModule, PasswordHash: string(hash), + Carries: []Declared{{Module: "mesh-issues", Holds: []Seat{ledger}}}}, + {Kind: KindNodeTools, Node: "shanks", Module: RuntimeModule, PasswordHash: string(hash)}, + }) + if err != nil { + t.Fatal(err) + } + conf := filepath.Join(t.TempDir(), "bus.conf") + if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: "+ + `"`+t.TempDir()+`"`+" }\n"+accounts), 0o600); err != nil { + t.Fatal(err) + } + opts, err := server.ProcessConfigFile(conf) + if err != nil { + t.Fatalf("the composed accounts do not parse: %v", err) + } + opts.NoLog, opts.NoSigs = true, true + s, err := server.NewServer(opts) + if err != nil { + t.Fatal(err) + } + go s.Start() + if !s.ReadyForConnections(10 * time.Second) { + t.Fatal("the bus did not come up") + } + defer s.Shutdown() + + holder, err := nats.Connect(s.ClientURL(), nats.UserInfo("novox.node-tools", "pw")) + if err != nil { + t.Fatal(err) + } + defer holder.Close() + heard := make(chan string, 4) + sub, err := holder.Subscribe("mesh.seat.issue-tracker.call.open.*", func(m *nats.Msg) { + heard <- m.Subject + _ = m.Respond([]byte(`{"result":{}}`)) + }) + if err != nil { + t.Fatal(err) + } + _ = holder.Flush() + if !sub.IsValid() { + t.Fatal("the holder may not hear the caller-named calls to its seat") + } + + refusals := make(chan error, 4) + caller, err := nats.Connect(s.ClientURL(), nats.UserInfo("shanks.node-tools", "pw"), + nats.CustomInboxPrefix("_INBOX.shanks.node-tools"), + nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { refusals <- err })) + if err != nil { + t.Fatal(err) + } + defer caller.Close() + if _, err := caller.Request("mesh.seat.issue-tracker.call.open.shanks~node-tools", []byte(`{}`), 3*time.Second); err != nil { + t.Fatalf("a call in the caller's own name was not answered: %v", err) + } + if got := <-heard; got != "mesh.seat.issue-tracker.call.open.shanks~node-tools" { + t.Fatalf("heard %s", got) + } + if err := caller.Publish("mesh.seat.issue-tracker.call.open.novox~node-tools", []byte(`{}`)); err != nil { + t.Fatal(err) + } + _ = caller.Flush() + select { + case err := <-refusals: + if !errors.Is(err, nats.ErrPermissionViolation) { + t.Errorf("the server said %v, want a permissions violation", err) + } + case <-time.After(3 * time.Second): + t.Error("the server did not refuse a call naming another caller") + } + select { + case got := <-heard: + t.Errorf("a call naming another reached the holder: %s", got) + case <-time.After(200 * time.Millisecond): + } +} diff --git a/internal/broker/callers_test.go b/internal/broker/callers_test.go new file mode 100644 index 00000000..f11cac30 --- /dev/null +++ b/internal/broker/callers_test.go @@ -0,0 +1,119 @@ +package broker + +import ( + "strings" + "testing" +) + +// **A tool call names its caller, and the bus lets each user name itself alone** (novox/hq issue 365, by ADR +// 0259 §3's precedent for asks): wherever a principal may call a tool, it may call it on the caller-named +// subject — kind `call`, its own bus user last, dots written `~` — and on no subject naming anybody else. +func TestEachCredentialMayCallOnlyInItsOwnName(t *testing.T) { + ledger := Seat{Name: "node-desk", Scope: "node", Serves: []string{"who"}} + tracker := Seat{Name: "issue-tracker", Scope: "mesh", Serves: []string{"open"}} + for _, c := range []struct { + p Principal + may []string + mayNot []string + subject []string // what it subscribes, to answer calls naming any caller + }{ + {p: Principal{Kind: KindPerson, Module: "jochen", Invokes: []string{"*"}}, + may: []string{"mesh.mod.ledger.call.who.person~jochen", "mesh.mod.ledger.call.who.anchor.person~jochen", + "mesh.seat.issue-tracker.call.open.person~jochen", "mesh.seat.node-desk.call.who.anchor.person~jochen"}, + mayNot: []string{"mesh.mod.ledger.call.who.shanks~node-tools", "mesh.mod.ledger.call.who.anchor.controller", + "mesh.seat.issue-tracker.call.open.person~somebody", "mesh.mod.ledger.call.who.person"}}, + {p: Principal{Kind: KindNodeTools, Node: "shanks", Module: RuntimeModule, + Carries: []Declared{{Module: "ledger", Holds: []Seat{ledger, tracker}}}}, + may: []string{"mesh.mod.ledger.call.who.shanks~node-tools", "mesh.seat.issue-tracker.call.open.shanks~node-tools"}, + mayNot: []string{"mesh.mod.ledger.call.who.novox~node-tools", "mesh.seat.issue-tracker.call.open.controller"}, + subject: []string{"mesh.mod.ledger.call.who.novox~node-tools", "mesh.seat.node-desk.call.who.shanks.person~jochen", "mesh.seat.issue-tracker.call.open.controller"}}, + {p: Principal{Kind: KindModule, Node: "two", Module: "shop", Invokes: []string{"ledger.who", "seat:issue-tracker.open"}}, + may: []string{"mesh.mod.ledger.call.who.two~shop", "mesh.mod.ledger.call.who.anchor.two~shop", + "mesh.seat.issue-tracker.call.open.two~shop"}, + mayNot: []string{"mesh.mod.ledger.call.other.two~shop", "mesh.mod.ledger.call.who.one~shop", + "mesh.seat.issue-tracker.call.open.one~telegram"}}, + {p: Principal{Kind: KindNode, Node: "one", Checks: []string{"ledger.health"}}, + may: []string{"mesh.mod.ledger.call.health.one.node~one"}, + mayNot: []string{"mesh.mod.ledger.call.health.two.node~one", "mesh.mod.ledger.call.health.one.node~two"}}, + {p: Principal{Kind: KindModule, Node: "one", Module: "ledger", Holds: []Seat{ledger, tracker}}, + subject: []string{"mesh.mod.ledger.call.who.person~jochen", "mesh.mod.ledger.call.who.one.controller", + "mesh.seat.node-desk.call.who.one.two~shop", "mesh.seat.issue-tracker.call.open.two~shop"}}, + } { + perms, err := PermissionsFor(c.p) + if err != nil { + t.Fatalf("%s: %v", c.p.Username(), err) + } + for _, s := range c.may { + if !MayPublish(perms, s) { + t.Errorf("%s may not call %s, in its own name", c.p.Username(), s) + } + } + for _, s := range c.mayNot { + if MayPublish(perms, s) { + t.Errorf("%s may call %s, naming somebody else", c.p.Username(), s) + } + } + for _, s := range c.subject { + if !MaySubscribe(perms, s) { + t.Errorf("%s does not hear %s, a call to what it serves", c.p.Username(), s) + } + } + } +} + +// The subjects that name no caller stay granted beside the caller-named ones for one release, so a caller +// moves over without a gap (their retirement: hq issue 464). +func TestTheSubjectsThatNameNoCallerStayGrantedForOneRelease(t *testing.T) { + perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "two", Module: "shop", Invokes: []string{"ledger.who"}}) + if err != nil { + t.Fatal(err) + } + for _, s := range []string{"mesh.mod.ledger.tool.who", "mesh.mod.ledger.tool.who.anchor"} { + if !MayPublish(perms, s) { + t.Errorf("the old subject %s is no longer granted", s) + } + } +} + +// The desk's hidden prompt is the controller's alone on the caller-named subjects too (the review of +// 2026-10-09, M4): a grant of every tool does not reach it there either. +func TestTheDesksPromptIsTheControllersAloneUnderCallToo(t *testing.T) { + for _, p := range []Principal{{Kind: KindPerson, Module: "jochen", Invokes: []string{"*"}}, + {Kind: KindNodeTools, Node: "shanks", Module: RuntimeModule}} { + perms, err := PermissionsFor(p) + if err != nil { + t.Fatal(err) + } + token := CallerToken(p.Username()) + for _, s := range []string{"mesh.seat.node-launcher.call.secret.shanks." + token, + "mesh.mod.shell.call.node-launcher.secret.shanks." + token, "mesh.mod.shell.call.node-launcher.secret." + token} { + if MayPublish(perms, s) { + t.Errorf("%s may publish %s, the desk's hidden prompt", p.Username(), s) + } + } + } +} + +// The controller's grants are the installer's first user list too, so they move with hq issue 464: this release +// it calls and serves on the subjects that name no caller alone, and nobody may call in its name. +func TestTheControllerKeepsTheSubjectsThatNameNoCallerThisRelease(t *testing.T) { + perms, err := PermissionsFor(Principal{Kind: KindController}) + if err != nil { + t.Fatal(err) + } + for _, s := range append(perms.Publish, perms.Subscribe...) { + if strings.Contains(s, "."+CallKind+".") { + t.Errorf("the controller is granted %s, which the installer's first user list does not carry", s) + } + } + for _, p := range []Principal{{Kind: KindPerson, Module: "jochen", Invokes: []string{"*"}}, + {Kind: KindNodeTools, Node: "shanks", Module: RuntimeModule}} { + perms, err := PermissionsFor(p) + if err != nil { + t.Fatal(err) + } + if MayPublish(perms, "mesh.mod.ledger.call.who.controller") || MayPublish(perms, "mesh.seat.mesh-controller.call.status.controller") { + t.Errorf("%s may call in the controller's name", p.Username()) + } + } +} diff --git a/internal/broker/invokes_test.go b/internal/broker/invokes_test.go index 3d1e86ca..c22c12e8 100644 --- a/internal/broker/invokes_test.go +++ b/internal/broker/invokes_test.go @@ -42,8 +42,9 @@ func TestInvokingGrantsNothingButTheCall(t *testing.T) { if strings.Contains(p, ".event.") { t.Errorf("a module that only invokes may publish %q, an event it never declared", p) } - // A role's tools are tools (ADR 0132); a role's work queue and events are not. - if strings.HasPrefix(p, "mesh.seat.") && !strings.Contains(p, ".tool.") { + // A role's tools are tools (ADR 0132), named by their caller or not (novox/hq issue 365); a role's work + // queue and events are not. + if strings.HasPrefix(p, "mesh.seat.") && !strings.Contains(p, ".tool.") && !strings.Contains(p, ".call.") { t.Errorf("a module that only invokes may publish %q, a seat it neither holds nor uses", p) } } diff --git a/internal/broker/nats.go b/internal/broker/nats.go index d46caf7f..659a9cc8 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -212,6 +212,10 @@ func ControllerOnly() []string { for _, base := range []string{"mesh.seat." + v.Seat + ".tool." + v.Verb, "mesh.mod.*.tool." + v.Seat + "." + v.Verb} { out = append(out, base, base+".*") } + // And where a call names its caller (novox/hq issue 365): any machine, any caller. + for _, base := range []string{"mesh.seat." + v.Seat + "." + CallKind + "." + v.Verb, "mesh.mod.*." + CallKind + "." + v.Seat + "." + v.Verb} { + out = append(out, base+".>") + } } return out } @@ -875,6 +879,19 @@ func PermissionsFor(p Principal) (Permissions, error) { pub = append(pub, "$JS.ACK."+consumerStream(p)+"."+consumerDurable(p)+".>") } + // **And every tool grant again, naming its caller** (novox/hq issue 365): a call in this principal's own + // name, and an answer to a call naming anybody. The `tool` grants above stay for one release beside these, + // so callers and runtimes move without a gap; their retirement is hq issue 464. + // + // **Not the controller's, this release.** Its grants are also the installer's first user list + // (TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose), judged against the node-engine the mesh + // runs, so a change to them waits on a node-engine delivery. It calls and serves on the subjects that name + // no caller meanwhile — a caller-named call to its seat reaches nobody and is asked again on those at once — + // and moves with issue 464. + if p.Kind != KindController { + pub, sub = callerNamed(p.Username(), pub, sub) + } + sort.Strings(pub) sort.Strings(sub) // One writer per piece of state (novox/hq to-be 45 §1): a grant that would make a second is diff --git a/internal/broker/nats_test.go b/internal/broker/nats_test.go index 23d3f8d3..bfd52cfb 100644 --- a/internal/broker/nats_test.go +++ b/internal/broker/nats_test.go @@ -240,9 +240,9 @@ func TestAPersonReachesNothingButTools(t *testing.T) { perms, _ := PermissionsFor(Principal{Kind: KindPerson, Module: "jo", Invokes: []string{"*"}, PasswordHash: "x"}) for _, p := range perms.Publish { - // A tool call, or asking what answers (novox/hq ADR 0197) — a question every service - // answers about itself, which claims nothing and controls nothing. - if !strings.Contains(p, ".tool.") && !strings.HasPrefix(p, "$SRV.") { + // A tool call — named by its caller or not (novox/hq issue 365) — or asking what answers (novox/hq + // ADR 0197), a question every service answers about itself, which claims nothing and controls nothing. + if !strings.Contains(p, ".tool.") && !strings.Contains(p, ".call.") && !strings.HasPrefix(p, "$SRV.") { t.Errorf("a person may publish %q, which is not a tool call", p) } } diff --git a/internal/broker/testdata/composed.conf b/internal/broker/testdata/composed.conf index 0919b2b8..ea22d3c5 100644 --- a/internal/broker/testdata/composed.conf +++ b/internal/broker/testdata/composed.conf @@ -43,17 +43,17 @@ accounts { } } { user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: { publish: { allow: ["$JS.ACK.EVENTS.one_telegram.>", "$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.>", "$JS.API.CONSUMER.INFO.EVENTS.one_telegram", "$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram", "$JS.API.CONSUMER.MSG.NEXT.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.one.telegram", "mesh.seat.telegram-sender.event.delivered", "mesh.seat.telegram-sender.event.failed"] } - subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.telegram", "$SRV.INFO.telegram.>", "$SRV.PING", "$SRV.PING.telegram", "$SRV.PING.telegram.>", "$SRV.STATS", "$SRV.STATS.telegram", "$SRV.STATS.telegram.>", "_INBOX.one.telegram.>", "mesh.assignment.one.telegram", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] } + subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.telegram", "$SRV.INFO.telegram.>", "$SRV.PING", "$SRV.PING.telegram", "$SRV.PING.telegram.>", "$SRV.STATS", "$SRV.STATS.telegram", "$SRV.STATS.telegram.>", "_INBOX.one.telegram.>", "mesh.assignment.one.telegram", "mesh.mod.telegram.call.>", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] } allow_responses: { max: 1, ttl: "1m" } } } { user: "two.audit", password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa", permissions: { publish: { allow: ["$JS.ACK.EVENTS.two_audit.>", "$JS.API.CONSUMER.INFO.EVENTS.two_audit", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_audit", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.audit"] } - subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.audit", "$SRV.INFO.audit.>", "$SRV.PING", "$SRV.PING.audit", "$SRV.PING.audit.>", "$SRV.STATS", "$SRV.STATS.audit", "$SRV.STATS.audit.>", "_INBOX.two.audit.>", "mesh.assignment.two.audit", "mesh.mod.audit.tool.>", "mesh.mod.shop.event.order.placed"] } + subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.audit", "$SRV.INFO.audit.>", "$SRV.PING", "$SRV.PING.audit", "$SRV.PING.audit.>", "$SRV.STATS", "$SRV.STATS.audit", "$SRV.STATS.audit.>", "_INBOX.two.audit.>", "mesh.assignment.two.audit", "mesh.mod.audit.call.>", "mesh.mod.audit.tool.>", "mesh.mod.shop.event.order.placed"] } allow_responses: { max: 1, ttl: "1m" } } } { user: "two.shop", password: "$2a$11$ssssssssssssssssssssss", permissions: { publish: { allow: ["$JS.ACK.EVENTS.two_shop.>", "$JS.API.CONSUMER.INFO.EVENTS.two_shop", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.shop", "mesh.mod.shop.event.order.placed", "mesh.seat.telegram-sender.accept.send"] } - subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.shop", "$SRV.INFO.shop.>", "$SRV.PING", "$SRV.PING.shop", "$SRV.PING.shop.>", "$SRV.STATS", "$SRV.STATS.shop", "$SRV.STATS.shop.>", "_INBOX.two.shop.>", "mesh.assignment.two.shop", "mesh.mod.shop.tool.>"] } + subscribe: { allow: ["$SRV.INFO", "$SRV.INFO.shop", "$SRV.INFO.shop.>", "$SRV.PING", "$SRV.PING.shop", "$SRV.PING.shop.>", "$SRV.STATS", "$SRV.STATS.shop", "$SRV.STATS.shop.>", "_INBOX.two.shop.>", "mesh.assignment.two.shop", "mesh.mod.shop.call.>", "mesh.mod.shop.tool.>"] } allow_responses: { max: 1, ttl: "1m" } } } ]