Merge pull request 'Ask a node's engine which of its directories the walk would refuse, read-only (issue 358)' (#258) from fix/358-judge-directories-read-only into main
This commit was merged in pull request #258.
This commit is contained in:
+10
-1
@@ -336,6 +336,13 @@ func AskHandOverSubject(node string) string { return "mesh.node." + node + ".ask
|
||||
// a hand-over is (link.SetuidSearchContext), for the same reason.
|
||||
func AskSetuidSearchSubject(node string) string { return "mesh.node." + node + ".ask.setuid-search" }
|
||||
|
||||
// AskJudgeDirectoriesSubject is where the controller asks one machine's node-engine to judge, read-only, which of
|
||||
// its declared directories the walk that follows no link another account could make would refuse (novox/hq issue
|
||||
// 358): a request answered once, signed as a hand-over is (link.JudgeDirectoriesContext), for the same reason.
|
||||
func AskJudgeDirectoriesSubject(node string) string {
|
||||
return "mesh.node." + node + ".ask.judge-directories"
|
||||
}
|
||||
|
||||
// inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25
|
||||
// §4): with one account, inbox privacy is the permission list or it is nothing, so each user's
|
||||
// inbox is derived from its own identity and its permissions name that prefix and no other.
|
||||
@@ -614,7 +621,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// issue 356), which it answers on the request's reply: the one request a node is asked.
|
||||
AskHandOverSubject(p.Node),
|
||||
// And asking it for a fresh search for setuid programs (novox/hq issue 361), answered the same way.
|
||||
AskSetuidSearchSubject(p.Node)}
|
||||
AskSetuidSearchSubject(p.Node),
|
||||
// And asking it to judge its declared directories, read-only (novox/hq issue 358), answered the same way.
|
||||
AskJudgeDirectoriesSubject(p.Node)}
|
||||
// The node-engine witnesses the core builds it places (novox/hq to-be 45 §8, ADR 0236; the
|
||||
// contract is lease/witness.go): it asks its own machine's node tools PING, and, where the
|
||||
// machine runs the controller, reads the lease's one key — read, never written.
|
||||
|
||||
@@ -114,7 +114,9 @@ func TestOnlyWhatCanBeAskedMayAnswer(t *testing.T) {
|
||||
node, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one", PasswordHash: "x"})
|
||||
if !node.AllowResponses || !slices.Contains(node.Subscribe, AskHandOverSubject("one")) ||
|
||||
!slices.Contains(node.Subscribe, AskSetuidSearchSubject("one")) ||
|
||||
slices.Contains(node.Subscribe, AskSetuidSearchSubject("two")) {
|
||||
slices.Contains(node.Subscribe, AskSetuidSearchSubject("two")) ||
|
||||
!slices.Contains(node.Subscribe, AskJudgeDirectoriesSubject("one")) ||
|
||||
slices.Contains(node.Subscribe, AskJudgeDirectoriesSubject("two")) {
|
||||
t.Fatalf("a node cannot answer the hand-over it is asked: %v %v", node.AllowResponses, node.Subscribe)
|
||||
}
|
||||
person, _ := PermissionsFor(Principal{Kind: KindPerson, Node: "one", Module: "jo", PasswordHash: "x"})
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ accounts {
|
||||
} }
|
||||
{ user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: {
|
||||
publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.INFO.NODES.one", "$SRV.PING.node-tools.one", "mesh.control.one.>"] }
|
||||
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare"] }
|
||||
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.judge-directories", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
{ user: "one.nats", password: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb", permissions: {
|
||||
|
||||
@@ -96,6 +96,11 @@ var WritersTable = []WriterRow{
|
||||
{State: "a fresh setuid search asked of a machine", Writer: "controller, at its terminal",
|
||||
KeptIn: "the machine, which throws its last search away", Others: "the engine verifies the mesh's signature and starts it, or refuses",
|
||||
Subjects: []string{"mesh.node.*.ask.setuid-search"}, Writes: isController},
|
||||
// A read-only judgement of a machine's declared directories asked of its engine (novox/hq issue 358): signed as a
|
||||
// hand-over is, under a signing context of its own. It changes nothing on the machine.
|
||||
{State: "a judgement of a machine's directories asked of it", Writer: "controller",
|
||||
KeptIn: "nowhere: the engine answers it and keeps nothing", Others: "the engine verifies the mesh's signature and answers, or refuses",
|
||||
Subjects: []string{"mesh.node.*.ask.judge-directories"}, Writes: isController},
|
||||
{State: "a machine's applied state and its report", Writer: "the node-engine's apply queue",
|
||||
KeptIn: "the machine; the report on the bus", Others: "the reconcile and a delivery enqueue, never apply",
|
||||
// And its health statement between reports (novox/hq ADR 0240): the same writer stating the same
|
||||
|
||||
@@ -17,6 +17,7 @@ var designRows = []string{
|
||||
"a machine's declaration",
|
||||
"a hand-over asked of a machine",
|
||||
"a fresh setuid search asked of a machine",
|
||||
"a judgement of a machine's directories asked of it",
|
||||
"a machine's applied state and its report",
|
||||
"the controller lease",
|
||||
"plans and their tiers",
|
||||
@@ -186,3 +187,21 @@ func TestASetuidSearchAskHasOnePublisher(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **A judgement of a machine's directories asked of it has one publisher, the controller** (novox/hq issue 358), as a
|
||||
// hand-over has.
|
||||
func TestADirectoryJudgementAskHasOnePublisher(t *testing.T) {
|
||||
for _, p := range []Principal{
|
||||
{Kind: KindNode, Node: "laptop"},
|
||||
{Kind: KindNodeTools, Node: "laptop", Module: RuntimeModule},
|
||||
{Kind: KindModule, Node: "laptop", Module: "notes"},
|
||||
} {
|
||||
err := CheckWriters(p, []string{AskJudgeDirectoriesSubject("laptop")})
|
||||
if err == nil || !strings.Contains(err.Error(), "a judgement of a machine's directories asked of it") {
|
||||
t.Errorf("%s may ask a judgement of directories: %v", p.Username(), err)
|
||||
}
|
||||
}
|
||||
if err := CheckWriters(Principal{Kind: KindController}, []string{AskJudgeDirectoriesSubject("laptop")}); err != nil {
|
||||
t.Fatalf("the controller may not ask a judgement: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,6 +490,13 @@ var ControllerVerbs = []Verb{
|
||||
"why": "with consumer or older-than: why — required, and recorded in the hand-act log",
|
||||
"cause": "with consumer or older-than: the cause in a word (cleanup-waiting when absent)",
|
||||
}, nil, "confirm")},
|
||||
{Name: "judge-directories", Description: "Read-only: which of the directories one machine is declared its " +
|
||||
"node-engine's walk that follows no link another account could make would refuse (novox/hq issue 358), one line " +
|
||||
"each: placed, would be refused and why (the link and who could replace it), refused already by the placement " +
|
||||
"guard, or below a home. The engine judges the declaration it keeps and makes, chmods and chowns nothing.",
|
||||
Input: schema(map[string]string{
|
||||
"node": "the machine's name",
|
||||
}, []string{"node"})},
|
||||
// What a consumer gave up on (novox/hq issue 330): kept in DEAD_LETTERS until a person acts on it.
|
||||
{Name: "root-free", Description: "Whether each machine named is root-free now (novox/hq ADR 0259 §8): no agent " +
|
||||
"there can become root without a person. Judged when asked, never from a condition: free only when the machine " +
|
||||
|
||||
@@ -148,6 +148,28 @@ func AskSetuidSearch(ctx context.Context, conn *nats.Conn, signer Signer, node,
|
||||
"there", record: "a start"})
|
||||
}
|
||||
|
||||
// JudgeDirectoriesContext is prefixed to a directory judgement ask's bytes before signing (novox/hq issue 358): never
|
||||
// another ask's signature, nor a declaration's. The engine holds the same words.
|
||||
const JudgeDirectoriesContext = "novox-mesh judge-directories v1\n"
|
||||
|
||||
// AskJudgeDirectories asks one machine's node-engine to judge, read-only, which of the directories it is declared
|
||||
// the walk that follows no link another account could make would refuse (novox/hq issue 358): the ask a hand-over
|
||||
// is, naming no path, signed under JudgeDirectoriesContext. The engine changes nothing and answers one line per
|
||||
// directory.
|
||||
func AskJudgeDirectories(ctx context.Context, conn *nats.Conn, signer Signer, node, by string,
|
||||
timeout time.Duration) (HandOverAnswer, error) {
|
||||
if conn == nil {
|
||||
return HandOverAnswer{}, errors.New("this controller is not on the bus")
|
||||
}
|
||||
body, err := signAsk(ctx, signer, JudgeDirectoriesContext, HandOverAsk{Node: node, By: by})
|
||||
if err != nil {
|
||||
return HandOverAnswer{}, err
|
||||
}
|
||||
return askSigned(ctx, conn, broker.AskJudgeDirectoriesSubject(node), body, node, timeout, askWords{
|
||||
what: "a judgement of its directories", nothing: "nothing was judged, and nothing is changed by one",
|
||||
issue: "issue 358", unknown: "ask again: a judgement changes nothing", record: "a judgement"})
|
||||
}
|
||||
|
||||
// SignHandOver fills the ask's expiry and nonce and signs it with the mesh's key, over HandOverContext and the
|
||||
// ask's bytes exactly as they travel.
|
||||
func SignHandOver(ctx context.Context, signer Signer, ask HandOverAsk) ([]byte, error) {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package link
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/testbus"
|
||||
)
|
||||
|
||||
// A judgement of a machine's directories is asked of the machine on its own subject, signed under its own context
|
||||
// (never a hand-over's), naming no path, and the engine's answer comes back (novox/hq issue 358). The engine holds the
|
||||
// same subject and context (mesh-host internal/link, TestTheDirectoryJudgementAskKeepsItsSubjectAndContext).
|
||||
func TestADirectoryJudgementIsAskedOfTheMachineSignedUnderItsOwnContext(t *testing.T) {
|
||||
if broker.AskJudgeDirectoriesSubject("laptop") != "mesh.node.laptop.ask.judge-directories" ||
|
||||
JudgeDirectoriesContext != "novox-mesh judge-directories v1\n" {
|
||||
t.Fatalf("the subject %q, the context %q", broker.AskJudgeDirectoriesSubject("laptop"), JudgeDirectoriesContext)
|
||||
}
|
||||
conn, err := nats.Connect(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(conn.Close)
|
||||
signer, public := testSigner(t)
|
||||
var heard HandOverAsk
|
||||
engine, err := conn.Subscribe(broker.AskJudgeDirectoriesSubject("laptop"), func(msg *nats.Msg) {
|
||||
var signed SignedHandOver
|
||||
_ = json.Unmarshal(msg.Data, &signed)
|
||||
if ed25519.Verify(public, append([]byte(HandOverContext), signed.Ask...), signed.Signature) ||
|
||||
ed25519.Verify(public, append([]byte(SetuidSearchContext), signed.Ask...), signed.Signature) ||
|
||||
!ed25519.Verify(public, append([]byte(JudgeDirectoriesContext), signed.Ask...), signed.Signature) {
|
||||
_ = msg.Respond([]byte(`{"refused":"not signed as a judgement"}`))
|
||||
return
|
||||
}
|
||||
_ = json.Unmarshal(signed.Ask, &heard)
|
||||
body, _ := json.Marshal(HandOverAnswer{Said: "3 directories declared, asked by " + heard.By})
|
||||
_ = msg.Respond(body)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = engine.Unsubscribe() })
|
||||
a, err := AskJudgeDirectories(context.Background(), conn, signer, "laptop", "jo", 5*time.Second)
|
||||
if err != nil || a.Said != "3 directories declared, asked by jo" {
|
||||
t.Fatalf("answered %+v, %v", a, err)
|
||||
}
|
||||
if heard.Node != "laptop" || heard.Path != "" || heard.Nonce == "" || heard.Expires.IsZero() {
|
||||
t.Fatalf("the engine heard %+v", heard)
|
||||
}
|
||||
if _, err := AskJudgeDirectories(context.Background(), conn, signer, "anchor", "jo", 5*time.Second); err == nil ||
|
||||
!strings.Contains(err.Error(), "nothing was judged") {
|
||||
t.Fatalf("a machine with no engine listening: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user