The view: one read-only bus user for a page in a browser, composed like every other (hq research 036)
Research 036 names the gap (G1): no way for a browser to reach the bus. The bus module now listens over WebSocket (mesh-catalog, nats); this is who connects there. The view is a fixed principal (broker.KindView, user `view`) composed into the user list like every user once its credential is minted, and left out once it is forgotten: it subscribes the issue tracker's events (opened, moved, noted, linked), the controller's plan-moved and condition-raised/changed/cleared, and the delivery owner's transition and group; it publishes only the JetStream API requests a read-only watcher of the tracker's bucket (mesh-issues_issues) makes — STREAM.INFO, DIRECT.GET, CONSUMER.CREATE/INFO/ DELETE, flow control — answered in its own inbox; no reply, no tool, no event, no `$KV` write. `bus view-credential` mints and prints it once (hash kept, like a person's); `bus view-revoke` forgets it, real at the next composition. Tests: the composed grants are exactly these and a write grant of any shape fails; the view is composed only once minted; and against a real server read from the composed file over WebSocket, the view binds the bucket, reads a key, watches a put land, and is refused a put, a delete and an event, the bucket unchanged.
This commit is contained in:
@@ -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 <where>]]")
|
||||
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 {
|
||||
|
||||
@@ -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 <where>] | 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
|
||||
}
|
||||
+62
-1
@@ -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.<bucket>.>`, 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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
+4
@@ -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"] }
|
||||
} }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.<bucket>.>`, 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user