From 7a8a19b11bf90bfcd271d0207118fbedbf83ddad Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 27 Sep 2026 00:17:52 +0200 Subject: [PATCH] A person's account (step 4.4, the account half) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design 25 §7. A person is not a module and holds no seat: nothing is addressed to them, nothing is delivered to them, and they have no durable consumer. What they have is permission to ask, as a list of tools or `*` for an administrator. Four properties the tests hold it to, each of which is a way of being wrong that would not announce itself: a person reaches nothing but tools, so one cannot claim a module said something; no ack subject, because authority over a consumer that does not exist is authority nobody would audit; no allow_responses, because a person who can answer a request is impersonating a module on a bus where anyone may serve a tool; and two people do not share an inbox. --- internal/broker/nats.go | 36 +++++++++++++++++ internal/broker/nats_test.go | 77 ++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 4a09ba3..80089fd 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -29,6 +29,10 @@ const ( KindNode Kind = "node" KindController Kind = "controller" KindEnrolment Kind = "enrolment" + // KindPerson is somebody reaching the mesh's tools from a workstation (design 25 §7). Its + // authority is a list of tools and nothing else — not control, not declarations, not builds, + // and no ability to answer anything, because a person asks. + KindPerson Kind = "person" ) // Seat is a role on the bus as a principal relates to it: the subjects it accepts, and those it @@ -56,6 +60,14 @@ type Principal struct { Holds []Seat Uses []Seat + // Invokes are the tools a person may call, as `.`; a single `*` is every tool, + // for an administrator. Only meaningful for KindPerson. + // + // **A list, not a role.** A person is not a module and holds no seat: nothing is addressed + // to them, nothing is delivered to them, and they have no durable consumer to acknowledge. + // What they have is permission to ask. + Invokes []string + // PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal // and never appears here: this file is written to a node's disk and read by a server, and a // secret that can be read from a configuration file is a secret with a wider blast radius @@ -73,6 +85,8 @@ var safeSubject = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) // applies, kept. func (p Principal) Username() string { switch p.Kind { + case KindPerson: + return "person." + p.Module case KindModule: return p.Node + "." + p.Module case KindNode: @@ -129,6 +143,22 @@ func PermissionsFor(p Principal) (Permissions, error) { pub = []string{"mesh.control.>", "mesh.node.>", "mesh.build.>", "$JS.API.>"} sub = []string{"mesh.control.>", "mesh.build.>", "$JS.API.>"} + case KindPerson: + // Tools, and nothing else. Every subject a person may publish is a tool call; a person + // who could publish an event would be able to claim a module said something. + for _, t := range p.Invokes { + if t == "*" { + pub = append(pub, "mesh.mod.*.tool.>") + continue + } + module, tool, ok := strings.Cut(t, ".") + if !ok { + return Permissions{}, fmt.Errorf( + "%q does not name a tool: a person invokes ., or * for every one", t) + } + pub = append(pub, "mesh.mod."+module+".tool."+tool) + } + case KindEnrolment: // A leaked token is useless for anything but enrolling: it cannot read a declaration, hear // an event, or subscribe any inbox but the one its own token derives (design 25 §6). @@ -190,6 +220,12 @@ func PermissionsFor(p Principal) (Permissions, error) { } } + if p.Kind == KindPerson { + // An inbox to hear answers in, and nothing else. No ack subject: a person has no durable + // consumer, because nothing is delivered to a person — they ask and are answered. + sub = append(sub, p.inbox()) + } + if p.Kind == KindModule || p.Kind == KindNode || p.Kind == KindController { // Its own reply space, and nothing wider. sub = append(sub, p.inbox()) diff --git a/internal/broker/nats_test.go b/internal/broker/nats_test.go index 64193d8..f1aa154 100644 --- a/internal/broker/nats_test.go +++ b/internal/broker/nats_test.go @@ -164,3 +164,80 @@ func TestAUserWithoutAPasswordIsRefused(t *testing.T) { t.Fatal("composed a user with no password hash") } } + +// A person reaches the mesh's tools from a workstation (design 25 §7). Their authority is a list +// of tools and nothing else. +func TestAPersonMayAskOnlyTheToolsTheyWereGiven(t *testing.T) { + perms, err := PermissionsFor(Principal{Kind: KindPerson, Module: "jo", + Invokes: []string{"shop.price", "telegram.status"}, PasswordHash: "x"}) + if err != nil { + t.Fatal(err) + } + has(t, perms.Publish, "mesh.mod.shop.tool.price") + has(t, perms.Publish, "mesh.mod.telegram.tool.status") + hasNot(t, perms.Publish, "mesh.mod.shop.tool.refund") + hasNot(t, perms.Publish, "mesh.mod.*.tool.>") +} + +// An administrator gets every tool, which is a different grant and looks like one. +func TestAnAdministratorMayAskAnyTool(t *testing.T) { + perms, _ := PermissionsFor(Principal{Kind: KindPerson, Module: "jo", + Invokes: []string{"*"}, PasswordHash: "x"}) + has(t, perms.Publish, "mesh.mod.*.tool.>") +} + +// **Nothing but tools.** A person who could publish an event would be able to claim a module +// said something; one who could publish control traffic would be a second controller. +func TestAPersonReachesNothingButTools(t *testing.T) { + perms, _ := PermissionsFor(Principal{Kind: KindPerson, Module: "jo", + Invokes: []string{"*"}, PasswordHash: "x"}) + for _, p := range perms.Publish { + if !strings.Contains(p, ".tool.") { + t.Errorf("a person may publish %q, which is not a tool call", p) + } + } + for _, s := range perms.Subscribe { + if !strings.HasPrefix(s, "_INBOX.person.") { + t.Errorf("a person may subscribe %q; only their own inbox should be reachable", s) + } + } +} + +// A person has no durable consumer, because nothing is delivered to a person — so no ack +// subject, and an ack permission would be authority over something that does not exist. +func TestAPersonHasNoAckSubject(t *testing.T) { + perms, _ := PermissionsFor(Principal{Kind: KindPerson, Module: "jo", + Invokes: []string{"*"}, PasswordHash: "x"}) + for _, p := range perms.Publish { + if strings.HasPrefix(p, "$JS.ACK") { + t.Errorf("a person was granted %q, and has no consumer to acknowledge", p) + } + } +} + +// A person asks and is answered; they never answer. allow_responses would let a person reply to +// a request — which, on a bus where anyone may serve a tool, is somebody impersonating a module. +func TestAPersonMayNotAnswer(t *testing.T) { + perms, _ := PermissionsFor(Principal{Kind: KindPerson, Module: "jo", + Invokes: []string{"*"}, PasswordHash: "x"}) + if perms.AllowResponses { + t.Fatal("a person may answer a request, which is impersonating a module") + } +} + +// Two people do not share an inbox, or one would read the other's answers. +func TestTwoPeopleDoNotShareAnInbox(t *testing.T) { + a, _ := PermissionsFor(Principal{Kind: KindPerson, Module: "jo", Invokes: []string{"*"}, PasswordHash: "x"}) + b, _ := PermissionsFor(Principal{Kind: KindPerson, Module: "sam", Invokes: []string{"*"}, PasswordHash: "x"}) + if a.Subscribe[0] == b.Subscribe[0] { + t.Fatalf("both read %s", a.Subscribe[0]) + } +} + +// A malformed grant is refused rather than widened into something that happens to parse. +func TestAToolGrantThatNamesNoToolIsRefused(t *testing.T) { + if _, err := PermissionsFor(Principal{Kind: KindPerson, Module: "jo", + Invokes: []string{"shop"}, PasswordHash: "x"}); err == nil { + t.Fatal("a grant naming a module but no tool was accepted") + } +}