diff --git a/cmd/mesh-controller/bus_step.go b/cmd/mesh-controller/bus_step.go index e7785536..5d076a76 100644 --- a/cmd/mesh-controller/bus_step.go +++ b/cmd/mesh-controller/bus_step.go @@ -151,6 +151,13 @@ func busCommand(ctx context.Context, args []string) error { if len(args) > 0 && !strings.HasPrefix(args[0], "-") { sub, args = args[0], args[1:] } + // The view's credential, a terminal line like a person's (bus_view.go). + switch sub { + case "view-credential": + return busViewCredential(ctx, args) + case "view-revoke": + return busViewRevoke(ctx, args) + } set := flag.NewFlagSet("bus", flag.ContinueOnError) snapshot := set.String("snapshot-taken", "", "where the streams' snapshot a person took is, while the mesh takes none itself") reversible := set.Bool("reversible", false, "the new version can be undone by putting the old one back") @@ -160,14 +167,14 @@ func busCommand(ctx context.Context, args []string) error { if rest, err := parseAround(set, args); err != nil { return err } else if len(rest) > 0 { - return errors.New("bus [upgrade --why … --reversible|--irreversible [--snapshot-taken ]]") + return errors.New(busUsage) } switch sub { case "": return busStatus(ctx) case "upgrade": default: - return fmt.Errorf("bus says what a bus upgrade would do, or `bus upgrade` — not %q", sub) + return fmt.Errorf("bus says what a bus upgrade would do, or `bus upgrade`, `bus view-credential`, `bus view-revoke` — not %q", sub) } // Everything refused before anything is done. if err := why.require("bus upgrade"); err != nil { diff --git a/cmd/mesh-controller/bus_view.go b/cmd/mesh-controller/bus_view.go new file mode 100644 index 00000000..ad44797b --- /dev/null +++ b/cmd/mesh-controller/bus_view.go @@ -0,0 +1,111 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "strconv" + "strings" + + "github.com/novox/mesh-controller/internal/broker" + "github.com/novox/mesh-controller/internal/inventory" +) + +// The view's credential: the one read-only user a page in a browser connects to the bus as, over the +// bus module's WebSocket listener (novox/hq research 036, gap G1; broker.KindView). +// +// **A terminal line, like a person's credential** (operator.go): printed once, never stored — the mesh +// keeps a hash — and revoked by forgetting the row, which the next composition of the user list makes +// real. There is one view; issuing it again rotates its password. +const busUsage = "bus [upgrade --why … --reversible|--irreversible [--snapshot-taken ] | view-credential | view-revoke]" + +// busWebSocketPort is the port the bus module's WebSocket listener is published on, mirrored from the +// nats module's manifest (its `bus-websocket` opening), because the credential names where to connect +// and the controller does not read the module's configuration. Reached across the overlay only: the +// opening is from the mesh, and the mesh's filter admits nothing else. +const busWebSocketPort = 4223 + +func busViewCredential(ctx context.Context, args []string) error { + if len(args) != 0 { + return errors.New("bus view-credential takes nothing: there is one view, and this prints its credential once") + } + open, err := openStores(ctx) + if err != nil { + return err + } + defer open.Close() + inv := open.inventory + + // Refused here rather than at the next composition, where it would stop the whole file. + if _, err := broker.PermissionsFor(broker.Principal{Kind: broker.KindView, PasswordHash: "x"}); err != nil { + return err + } + password, err := inv.MintBusPassword(ctx, inventory.BusUser{Username: broker.ViewUser, Kind: inventory.BusView}) + if err != nil { + return err + } + + where, err := broker.FromEnvironment() + if err != nil && !errors.Is(err, broker.ErrNotConfigured) { + return err + } + host := where.Address + if h, _, err := net.SplitHostPort(where.Address); err == nil { + host = h + } + websocket := "" + if host != "" { + websocket = "ws://" + net.JoinHostPort(host, strconv.Itoa(busWebSocketPort)) + } + held, err := json.Marshal(struct { + WebSocket string `json:"websocket,omitempty"` + URL string `json:"url,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + User string `json:"user"` + Password string `json:"password"` + InboxPrefix string `json:"inbox_prefix"` + Hears []string `json:"hears"` + Reads string `json:"reads"` + }{ + WebSocket: websocket, URL: "nats://" + where.Address, Fingerprint: where.Fingerprint, + User: broker.ViewUser, Password: password, + // The client must make its inboxes under the view's own prefix: its subscribe grant is + // `_INBOX.view.>` and no wider (design 25 §4), and a client's default inbox is not under it. + InboxPrefix: "_INBOX." + broker.ViewUser, + Hears: broker.ViewHears, Reads: broker.ViewBucket, + }) + if err != nil { + return err + } + + fmt.Printf("issued the view, which hears %s and reads the bucket %s, and nothing else\n", + strings.Join(broker.ViewHears, ", "), broker.ViewBucket) + fmt.Println(" this is the only time the credential is printed; the mesh keeps a hash") + fmt.Println(" it works once the bus has been told, which is the next push to the machine holding mesh-broker;") + fmt.Println(" the WebSocket listener it connects through is the bus module's, live there after the bus step a person starts (`bus upgrade`)") + fmt.Println() + fmt.Println(string(held)) + return nil +} + +func busViewRevoke(ctx context.Context, args []string) error { + if len(args) != 0 { + return errors.New("bus view-revoke takes nothing: there is one view") + } + open, err := openStores(ctx) + if err != nil { + return err + } + defer open.Close() + + if err := open.inventory.ForgetBusUser(ctx, broker.ViewUser); err != nil { + return err + } + // **Revoked at the next composition, not now** — as a person is (operator revoke): the bus's users + // are a file, and the credential stops working when the file no longer names it. + fmt.Println("the view is forgotten, and its credential stops working at the next composition — " + + "push the machine holding mesh-broker to make it so") + return nil +} diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 06c41c48..97556da7 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -42,8 +42,59 @@ const ( // Its authority is the union of what the modules it carries would each have had for their // tools — and nothing of what they consume, because tools are what it runs, not reactions. KindNodeTools Kind = "node-tools" + // KindView is the one read-only principal a view onto the bus connects as (novox/hq research 036, + // gap G1): a page in a browser, over the bus module's WebSocket listener, watching the issue tracker. + // Fixed, and derived from no declaration: what it hears is ViewHears, what it reads is ViewBucket, + // and it publishes nothing but the JetStream API requests a read-only watcher of that one bucket + // makes (ViewReads), each answered in its own inbox. Composed like every other user, into the same + // file, once its credential is minted (`bus view-credential`); forgotten like every other user + // (`bus view-revoke`), at the next composition. + KindView Kind = "view" ) +// ViewUser is the view's one username: there is one view, and it is nobody's machine or module. +const ViewUser = "view" + +// ViewBucket is the state the view reads: the issue tracker's issues, as the bus names the bucket +// (mesh-issues's state `issues`, novox/hq ADR 0201). +var ViewBucket = BucketName("mesh-issues", "issues") + +// ViewHears are the events the view subscribes, each named: the issue tracker's own, the controller's +// walks and conditions, and the delivery owner's — what a page about issues shows beside them. Subscribe +// only, and no stream or consumer of its own: a page hears what happens while it is open, and reads the +// bucket for everything before. +var ViewHears = []string{ + moduleEventSubject("mesh-issues", "opened"), + moduleEventSubject("mesh-issues", "moved"), + moduleEventSubject("mesh-issues", "noted"), + moduleEventSubject("mesh-issues", "linked"), + seatEventSubject(ControllerSeat, "plan-moved"), + seatEventSubject(ControllerSeat, "condition-raised"), + seatEventSubject(ControllerSeat, "condition-changed"), + seatEventSubject(ControllerSeat, "condition-cleared"), + moduleEventSubject("mesh-delivery", "transition"), + moduleEventSubject("mesh-delivery", "group"), +} + +// ViewReads are the JetStream API requests a read-only watcher of ViewBucket makes, on that bucket's +// stream and no other: the same requests a module reading another's state is granted (stateGrants, +// measured against the server) — binding (STREAM.INFO), a key read directly (DIRECT.GET), an ordered +// consumer for a watch or a listing (CONSUMER.CREATE), deleting it, and answering its flow control +// ($JS.FC) — and CONSUMER.INFO, which the browser client asks of the consumer it just made before it +// hands a watch over (nats.js kv: `oc.info(true)`). Nothing here is a write: no `$KV..>`, which +// is what a put or a delete publishes to, and no STREAM.* that defines, purges or deletes. +func ViewReads() []string { + stream := "KV_" + ViewBucket + return []string{ + "$JS.API.STREAM.INFO." + stream, + "$JS.API.DIRECT.GET." + stream + ".>", + "$JS.API.CONSUMER.CREATE." + stream + ".>", + "$JS.API.CONSUMER.INFO." + stream + ".>", + "$JS.API.CONSUMER.DELETE." + stream + ".>", + "$JS.FC." + stream + ".>", + } +} + // RuntimeModule is the module that IS the node's tool runtime (novox/hq ADR 0175). Where it is // assigned, the mesh composes one runtime principal for the machine in place of that module's own, // and the per-module containers that served tools until then stop being the way tools reach a node. @@ -261,6 +312,8 @@ func (p Principal) Username() string { switch p.Kind { case KindPerson: return "person." + p.Module + case KindView: + return ViewUser case KindModule, KindNodeTools: // The runtime is named exactly as the module it stands for would have been: the mesh // issues its credential through the same path a module's takes (`module issue`), and @@ -533,6 +586,14 @@ func PermissionsFor(p Principal) (Permissions, error) { // itself, its replies to the asker's own inbox. pub = append(pub, discovering()...) + case KindView: + // Hears what it is for and reads one bucket, and nothing else (ViewHears, ViewReads): no tool, + // no event of its own, no stream, no bucket written. Its requests are answered in its own + // inbox, granted below with the person's; a reply to anything is never permitted, because + // nothing is ever asked of it. + sub = append(sub, ViewHears...) + pub = append(pub, ViewReads()...) + 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). @@ -834,7 +895,7 @@ func PermissionsFor(p Principal) (Permissions, error) { pub = unique(pub) } - if p.Kind == KindPerson { + if p.Kind == KindPerson || p.Kind == KindView { // 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()) diff --git a/internal/broker/nats_golden_test.go b/internal/broker/nats_golden_test.go index 01571741..354e365f 100644 --- a/internal/broker/nats_golden_test.go +++ b/internal/broker/nats_golden_test.go @@ -31,6 +31,8 @@ func TestTheComposedConfigMatchesTheGolden(t *testing.T) { // The bus's own module: the snapshot API and its inbox, nothing else (novox/hq ADR 0235). {Kind: KindModule, Node: "one", Module: "nats", SnapshotsTheBus: true, Serves: []string{"nats_streams"}, PasswordHash: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb"}, + // The view: hears the issue tracker and reads its bucket, writes nothing (research 036). + {Kind: KindView, PasswordHash: "$2a$11$vvvvvvvvvvvvvvvvvvvvvv"}, }) if err != nil { t.Fatal(err) diff --git a/internal/broker/testdata/composed.conf b/internal/broker/testdata/composed.conf index 8f6c6e55..ac99af7e 100644 --- a/internal/broker/testdata/composed.conf +++ b/internal/broker/testdata/composed.conf @@ -56,6 +56,10 @@ accounts { 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.>"] } allow_responses: { max: 1, ttl: "1m" } } } + { user: "view", password: "$2a$11$vvvvvvvvvvvvvvvvvvvvvv", permissions: { + publish: { allow: ["$JS.API.CONSUMER.CREATE.KV_mesh-issues_issues.>", "$JS.API.CONSUMER.DELETE.KV_mesh-issues_issues.>", "$JS.API.CONSUMER.INFO.KV_mesh-issues_issues.>", "$JS.API.DIRECT.GET.KV_mesh-issues_issues.>", "$JS.API.STREAM.INFO.KV_mesh-issues_issues", "$JS.FC.KV_mesh-issues_issues.>"] } + subscribe: { allow: ["_INBOX.view.>", "mesh.mod.mesh-delivery.event.group", "mesh.mod.mesh-delivery.event.transition", "mesh.mod.mesh-issues.event.linked", "mesh.mod.mesh-issues.event.moved", "mesh.mod.mesh-issues.event.noted", "mesh.mod.mesh-issues.event.opened", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.plan-moved"] } + } } ] } } diff --git a/internal/broker/users.go b/internal/broker/users.go index 86ff3b4e..62cb5902 100644 --- a/internal/broker/users.go +++ b/internal/broker/users.go @@ -67,6 +67,9 @@ type Records struct { Enrolling []string // People is each person's name against the tools they may invoke, `*` for an administrator. People map[string][]string + // View says the mesh minted the view's credential (`bus view-credential`), so the one read-only + // view principal is composed (KindView); forgotten, it is left out, like a person. + View bool // Interchangeable is each module whose definition says its instances are the same anywhere // (ADR 0160), which decides whether the module's plain subject is issued to every instance. Interchangeable map[string]bool @@ -142,6 +145,9 @@ func Users(r Records) ([]Principal, error) { for _, person := range sortedNames(r.People) { out = append(out, Principal{Kind: KindPerson, Module: person, Invokes: r.People[person]}) } + if r.View { + out = append(out, Principal{Kind: KindView}) + } // Refused here rather than discovered by the server. Two users with one name is a file the // server reads as one of them, and which one depends on the order — so a module assigned to a diff --git a/internal/broker/view_live_test.go b/internal/broker/view_live_test.go new file mode 100644 index 00000000..c120edce --- /dev/null +++ b/internal/broker/view_live_test.go @@ -0,0 +1,170 @@ +package broker + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" + "golang.org/x/crypto/bcrypt" +) + +// The view against a real server, over WebSocket (novox/hq research 036): the composed user list is +// what the server reads, the listener is the shape the bus module declares (no TLS, compression on, +// reached across the overlay only), and the view with its credential binds the issue tracker's bucket, +// reads a key, watches a change land — and is refused every write: a put, a delete, an event. +// +// go test ./internal/broker/ -run TestTheView +func TestTheViewWatchesTheIssuesOverWebSocketAndWritesNothing(t *testing.T) { + hash := func(password string) string { + h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + return string(h) + } + const node = "anchor" + tracker := Principal{Kind: KindModule, Node: node, Module: "mesh-issues", State: []string{"issues"}, + Emits: []string{"opened"}, PasswordHash: hash("tracker")} + accounts, err := ComposeAccounts([]Principal{ + {Kind: KindController, PasswordHash: hash("controller")}, + tracker, + {Kind: KindView, PasswordHash: hash("view")}, + }) + if err != nil { + t.Fatal(err) + } + conf := filepath.Join(t.TempDir(), "accounts.conf") + if err := os.WriteFile(conf, []byte(accounts), 0o600); err != nil { + t.Fatal(err) + } + // The server reads the composed file as the bus does — through its own parser — and listens as the + // bus module's configuration says: a WebSocket listener without TLS and with compression, beside + // the client port. Ports chosen by the system, so this runs beside a live bus. + opts, err := server.ProcessConfigFile(conf) + if err != nil { + t.Fatalf("the server refused the composed user list: %v", err) + } + opts.Host, opts.Port = "127.0.0.1", server.RANDOM_PORT + opts.JetStream, opts.StoreDir = true, t.TempDir() + opts.NoLog, opts.NoSigs = true, true + opts.Websocket = server.WebsocketOpts{Host: "127.0.0.1", Port: server.RANDOM_PORT, NoTLS: true, Compression: true} + s, err := server.NewServer(opts) + if err != nil { + t.Fatal(err) + } + go s.Start() + if !s.ReadyForConnections(30 * time.Second) { + s.Shutdown() + t.Fatal("the server did not come up") + } + t.Cleanup(func() { s.Shutdown(); s.WaitForShutdown() }) + + dial := func(url, user, password string, refused chan<- string) *nats.Conn { + t.Helper() + nc, err := nats.Connect(url, nats.UserInfo(user, password), nats.CustomInboxPrefix("_INBOX."+user), + nats.Compression(true), nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { + if refused != nil && errors.Is(err, nats.ErrPermissionViolation) { + refused <- err.Error() + } + })) + if err != nil { + t.Fatalf("%s could not connect to %s: %v", user, url, err) + } + t.Cleanup(nc.Close) + return nc + } + + // The controller defines the bucket, as it does for every module's state; the tracker writes it. + controller := dial(s.ClientURL(), "controller", "controller", nil) + cjs, _ := controller.JetStream() + if _, err := cjs.CreateKeyValue(&nats.KeyValueConfig{Bucket: ViewBucket, History: 8}); err != nil { + t.Fatalf("the controller could not define %s: %v", ViewBucket, err) + } + trackerConn := dial(s.ClientURL(), tracker.Username(), "tracker", nil) + tjs, _ := trackerConn.JetStream() + tkv, err := tjs.KeyValue(ViewBucket) + if err != nil { + t.Fatal(err) + } + if _, err := tkv.Put("365", []byte(`{"number":365,"status":"open"}`)); err != nil { + t.Fatalf("the tracker could not write its own bucket: %v", err) + } + + // The view, over WebSocket with its credential. + refused := make(chan string, 8) + view := dial(s.WebsocketURL(), ViewUser, "view", refused) + if !strings.HasPrefix(view.ConnectedUrl(), "ws://") { + t.Fatalf("the view is connected to %s, not over WebSocket", view.ConnectedUrl()) + } + vjs, _ := view.JetStream(nats.MaxWait(3 * time.Second)) + vkv, err := vjs.KeyValue(ViewBucket) + if err != nil { + t.Fatalf("the view could not bind %s: %v", ViewBucket, err) + } + if got, err := vkv.Get("365"); err != nil { + t.Fatalf("the view could not read a key: %v", err) + } else if !strings.Contains(string(got.Value()), `"number":365`) { + t.Fatalf("the view read %q", got.Value()) + } + watch, err := vkv.WatchAll() + if err != nil { + t.Fatalf("the view could not watch the bucket: %v", err) + } + t.Cleanup(func() { _ = watch.Stop() }) + seen := func(key string) { + t.Helper() + deadline := time.After(10 * time.Second) + for { + select { + case e := <-watch.Updates(): + if e != nil && e.Key() == key { + return + } + case <-deadline: + t.Fatalf("the view's watch never saw %s", key) + } + } + } + seen("365") + if _, err := tkv.Put("366", []byte(`{"number":366,"status":"open"}`)); err != nil { + t.Fatal(err) + } + seen("366") + + // And every write is refused: the server says so, and the bucket is unchanged. + if _, err := vkv.Put("367", []byte(`{"number":367}`)); err == nil { + t.Error("the view put a key") + } + if err := vkv.Delete("365"); err == nil { + t.Error("the view deleted a key") + } + if err := view.Publish("mesh.mod.mesh-issues.event.opened", []byte(`{"number":367}`)); err != nil { + t.Fatal(err) + } + _ = view.Flush() + violations := map[string]bool{} + deadline := time.After(10 * time.Second) + for len(violations) < 3 { + select { + case v := <-refused: + for _, subject := range []string{"$KV." + ViewBucket + ".367", "$KV." + ViewBucket + ".365", "mesh.mod.mesh-issues.event.opened"} { + if strings.Contains(v, subject) { + violations[subject] = true + } + } + case <-deadline: + t.Fatalf("the server refused %d of the view's 3 writes as permission violations", len(violations)) + } + } + if _, err := tkv.Get("367"); !errors.Is(err, nats.ErrKeyNotFound) { + t.Errorf("after the view's put, 367 is %v", err) + } + if _, err := tkv.Get("365"); err != nil { + t.Errorf("after the view's delete, 365 is gone: %v", err) + } +} diff --git a/internal/broker/view_test.go b/internal/broker/view_test.go new file mode 100644 index 00000000..b58e04ed --- /dev/null +++ b/internal/broker/view_test.go @@ -0,0 +1,142 @@ +package broker + +import ( + "reflect" + "sort" + "strings" + "testing" +) + +// The view (novox/hq research 036): one read-only user, composed like every other, whose whole +// authority is a list here — so a grant that is not on the list fails a test, not a review. + +// Exactly what it hears, exactly what it asks, and nothing it could write or answer. A mutation that +// adds a publish grant — `$KV..>`, an event, a tool — fails here. +func TestTheViewHearsAndReadsAndCanPublishNothingElse(t *testing.T) { + perms, err := PermissionsFor(Principal{Kind: KindView}) + if err != nil { + t.Fatal(err) + } + wantSub := append(append([]string(nil), ViewHears...), "_INBOX.view.>") + sort.Strings(wantSub) + if !reflect.DeepEqual(perms.Subscribe, wantSub) { + t.Errorf("the view subscribes\n %v\nand should subscribe exactly\n %v", perms.Subscribe, wantSub) + } + wantPub := ViewReads() + sort.Strings(wantPub) + if !reflect.DeepEqual(perms.Publish, wantPub) { + t.Errorf("the view publishes\n %v\nand should publish exactly\n %v", perms.Publish, wantPub) + } + if len(perms.PublishDeny) != 0 { + t.Errorf("the view needs no deny, because nothing it may publish reaches the controller's own: %v", perms.PublishDeny) + } + if perms.AllowResponses { + t.Error("the view may answer, and nothing is ever asked of it") + } + + // Every publish grant is a JetStream API request about the one bucket's stream, or its flow control. + // **The mutation this holds against**: a write grant of any shape. + stream := "KV_" + ViewBucket + for _, p := range perms.Publish { + readOnly := strings.HasPrefix(p, "$JS.API.STREAM.INFO."+stream) || + strings.HasPrefix(p, "$JS.API.DIRECT.GET."+stream+".") || + strings.HasPrefix(p, "$JS.API.CONSUMER.CREATE."+stream+".") || + strings.HasPrefix(p, "$JS.API.CONSUMER.INFO."+stream+".") || + strings.HasPrefix(p, "$JS.API.CONSUMER.DELETE."+stream+".") || + strings.HasPrefix(p, "$JS.FC."+stream+".") + if !readOnly { + t.Errorf("the view is granted a publish on %q, which is not a read of %s", p, ViewBucket) + } + } + for _, refused := range []string{ + "$KV." + ViewBucket + ".365", // a put or a delete + "$KV.mesh-controller_conditions.x", // another bucket + "$JS.API.STREAM.CREATE." + stream, // defining the stream + "$JS.API.STREAM.PURGE." + stream, // emptying it + "$JS.API.STREAM.DELETE." + stream, // deleting it + "$JS.API.STREAM.MSG.DELETE." + stream, // deleting a message + "$JS.API.CONSUMER.CREATE.KV_mesh-controller_conditions.x", // reading another bucket + "$JS.API.STREAM.INFO.EVENTS", // the events stream + "$JS.API.INFO", // the account + "mesh.mod.mesh-issues.event.opened", // claiming the tracker said something + "mesh.mod.mesh-issues.tool.open", // opening an issue + "mesh.seat.issue-tracker.tool.open", // through the seat + "mesh.seat.issue-tracker.tool.open.novox", // on one machine + "mesh.seat.mesh-controller.tool.status", // the controller's verbs + "mesh.seat.mesh-controller.event.plan-moved", + "$SRV.PING", + "_INBOX.controller.x", + } { + if MayPublish(perms, refused) { + t.Errorf("the view may publish %q", refused) + } + } + for _, refused := range []string{ + "mesh.mod.mesh-issues.tool.open", // a tool asked of the tracker + "mesh.mod.telegram.event.received", // another module's events + "mesh.seat.mesh-controller.event.applied", + "mesh.control.novox.report", + "_INBOX.controller.x", + "_INBOX.person.jochen.x", + "_DELIVER.controller.EVENTS", + } { + if MaySubscribe(perms, refused) { + t.Errorf("the view may subscribe %q", refused) + } + } + for _, heard := range []string{ + "mesh.mod.mesh-issues.event.opened", + "mesh.mod.mesh-issues.event.moved", + "mesh.mod.mesh-issues.event.noted", + "mesh.mod.mesh-issues.event.linked", + "mesh.seat.mesh-controller.event.plan-moved", + "mesh.seat.mesh-controller.event.condition-raised", + "mesh.seat.mesh-controller.event.condition-changed", + "mesh.seat.mesh-controller.event.condition-cleared", + "mesh.mod.mesh-delivery.event.transition", + "mesh.mod.mesh-delivery.event.group", + "_INBOX.view.abc", + } { + if !MaySubscribe(perms, heard) { + t.Errorf("the view cannot subscribe %q", heard) + } + } +} + +// Composed once the mesh minted its credential, and not before: its row is the whole record of it. +func TestTheViewIsComposedOnlyOnceItsCredentialIsMinted(t *testing.T) { + without, err := Users(Records{Nodes: []string{"anchor"}}) + if err != nil { + t.Fatal(err) + } + for _, p := range without { + if p.Kind == KindView { + t.Fatal("the view is composed before its credential was minted") + } + } + with, err := Users(Records{Nodes: []string{"anchor"}, View: true}) + if err != nil { + t.Fatal(err) + } + views := 0 + for _, p := range with { + if p.Kind == KindView { + views++ + if p.Username() != ViewUser { + t.Errorf("the view is called %q, and its row is %q", p.Username(), ViewUser) + } + } + } + if views != 1 { + t.Fatalf("%d view users composed; there is one view", views) + } + // And without its hash it is named as missing, like any user — never written as a user anybody is. + _, missing := WithPasswords(with, map[string]string{}) + found := false + for _, m := range missing { + found = found || m == ViewUser + } + if !found { + t.Error("a view with no password was not named as missing one") + } +} diff --git a/internal/inventory/busrecords.go b/internal/inventory/busrecords.go index 68ca4f54..0a59b58a 100644 --- a/internal/inventory/busrecords.go +++ b/internal/inventory/busrecords.go @@ -108,6 +108,15 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) { for _, p := range people { out.People[p.Name] = p.Invokes } + // The view is composed once its credential is minted and until it is forgotten: its row is the + // record of it, nothing else being declared about it (broker.KindView). + kept, err := i.BusUsers(ctx) + if err != nil { + return broker.Records{}, err + } + if u, minted := kept[broker.ViewUser]; minted && u.Kind == BusView { + out.View = true + } return out, nil } diff --git a/internal/inventory/bususers.go b/internal/inventory/bususers.go index b0f661f5..5d6194cf 100644 --- a/internal/inventory/bususers.go +++ b/internal/inventory/bususers.go @@ -45,6 +45,9 @@ const ( // BusNodeTools is a machine's tool runtime (novox/hq ADR 0175): named like the module it // stands for, recorded as what it is. BusNodeTools = "node-tools" + // BusView is the one read-only view onto the bus (broker.KindView): its row is the whole record of + // it, minted by `bus view-credential` and forgotten by `bus view-revoke`. + BusView = "view" ) // MintBusPassword makes a bus password and records its hash under a username, replacing whatever was