The controller could not answer an enrolment, and a probe on an open server said it could

Found while reasoning about issue 127's replay question, in code committed earlier
today. The controller's permissions granted no inbox at all, so the answer to
every enrolment on the mesh would have been refused — "Permissions Violation for
Publish to _INBOX.enrol.anchor…" — while the controller logged that it had
enrolled the node.

**`allow_responses` does not cover it, and that is the trap.** It permits one reply
to the reply subject of a message the user received, and a message a JetStream
consumer delivers has had that field claimed for the consumer's own ack address
(design 25 §2). The address the controller actually answers is the one the request
carried in its *payload*, which the server does not recognise as a reply subject at
all. The two mechanisms look interchangeable and are not.

**My earlier verification could not have caught this.** The live enrolment tests run
against a server with no accounts and no permissions, so they exercise the subjects
and the round trip and nothing about authority. Composing the real configuration and
running a server on it is what found it.

Granted the enrolment inbox space and nothing wider: nothing but an enrolling node
ever subscribes under that prefix, each scoped to its own token's, so the controller
publishing there is the mesh answering enrolments and reaches nothing else. Confirmed
against the permissioned server both ways — the answer arrives, and a node's own
inbox is still refused.

Pinned as a rule that needs no server: whatever an enrolling node subscribes, the
controller must be able to publish to, and a node's, a module's and a person's inbox
must stay out of reach. That check is a subject-pattern match rather than a string
compare, so a grant that widened by a wildcard would not slip past it.

It also bears on 127's open question about who replays a build announcement: an
answer to a *module's* inbox would need `_INBOX.>`, which is exactly the blanket
grant design 25 §4 refuses. So the catch-up cannot become an inbox reply.
This commit is contained in:
2026-09-27 14:13:52 +02:00
parent eb72ec36ba
commit d65c37caad
3 changed files with 90 additions and 2 deletions
+68
View File
@@ -256,3 +256,71 @@ func TestAToolGrantThatNamesNoToolIsRefused(t *testing.T) {
t.Fatal("a grant naming a module but no tool was accepted")
}
}
// The controller can answer an enrolment, and reach no other inbox.
//
// **`allow_responses` does not cover this and that is the trap.** It permits one reply to the reply
// subject of a message the user received — and a message a JetStream consumer delivers has had that
// field claimed for the consumer's own ack address, so the address the controller actually answers is
// the one the request carried in its payload, which the server does not recognise as a reply subject
// at all.
//
// Found against a real server, after a live test on an *unpermissioned* one had passed: every
// enrolment on the mesh would have timed out while the controller logged success.
func TestTheControllerCanAnswerAnEnrolmentAndReachNoOtherInbox(t *testing.T) {
ctl, err := PermissionsFor(Principal{Kind: KindController, PasswordHash: "x"})
if err != nil {
t.Fatal(err)
}
enrolling, err := PermissionsFor(Principal{Kind: KindEnrolment, Node: "anchor", PasswordHash: "x"})
if err != nil {
t.Fatal(err)
}
// Whatever the enrolling node waits on, the controller must be able to publish to.
if len(enrolling.Subscribe) != 1 {
t.Fatalf("an enrolling node subscribes %v, and this test knows only how to check one",
enrolling.Subscribe)
}
waitsOn := enrolling.Subscribe[0]
if !covers(ctl.Publish, waitsOn) {
t.Fatalf("the controller may publish %v, none of which reaches %s — so every enrolment on "+
"the mesh times out while the controller logs success", ctl.Publish, waitsOn)
}
// And nothing wider. A node's own inbox and a module's are not the controller's to write into:
// that is the blanket grant design 25 §4 refuses.
for _, other := range []string{"_INBOX.node.anchor.x", "_INBOX.one.shop.x", "_INBOX.person.ada.x"} {
if covers(ctl.Publish, other) {
t.Errorf("the controller can publish to %s, which is an inbox privacy the permission "+
"list is the only thing protecting", other)
}
}
}
// covers says whether any granted subject pattern admits one concrete subject, with NATS's own
// wildcard meanings: `*` is one token, `>` is the rest.
func covers(granted []string, subject string) bool {
want := strings.Split(subject, ".")
for _, pattern := range granted {
if admits(strings.Split(pattern, "."), want) {
return true
}
}
return false
}
func admits(pattern, subject []string) bool {
for i, token := range pattern {
if token == ">" {
return i < len(subject)
}
if i >= len(subject) {
return false
}
if token != "*" && token != subject[i] {
return false
}
}
return len(pattern) == len(subject)
}