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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user