diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go index 4e1d0d2..dc82f65 100644 --- a/go/buscompose/buscompose_test.go +++ b/go/buscompose/buscompose_test.go @@ -15,6 +15,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/announce" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/provisions" "github.com/nats-io/nats.go/jetstream" ) @@ -77,7 +78,9 @@ func engineBeyond(user, g string) bool { strings.HasPrefix(g, "$SRV.") || strings.Contains(g, ".node-engine-"+node) || strings.Contains(g, "KV_"+EngineActsBucket) || strings.HasPrefix(g, "$KV."+EngineActsBucket+"."+node+".") || // And its node's declared packages (novox/hq ADR 0565 clause 6). - strings.Contains(g, "KV_"+EnginePackagesBucket) || g == "$KV."+EnginePackagesBucket+"."+node + strings.Contains(g, "KV_"+EnginePackagesBucket) || g == "$KV."+EnginePackagesBucket+"."+node || + // And the provisions of its node's modules, read (novox/hq ADR 0570 clause 6). + strings.Contains(g, "KV_"+provisions.ProvisionsBucket) } // createsAnAccountsConsumer is the grants part's create and read of a durable consumer an account needs, by its stream @@ -652,6 +655,12 @@ func healthChecks(t *testing.T) { if err != nil { continue } + // ADR 0570 clause 6: a node's engine reads its own node's provision keys, and no other node's, and writes none. + if u.Kind == KindNode && (!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.a.keycloak.db") || + MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.b.keycloak.db") || + MayPublish(perms, "$KV.mesh-provisioner_provisions.a.keycloak.db")) { + t.Errorf("node %s reads the provisions %v", u.Node, perms.Publish) + } if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_packages.a") || MayPublish(perms, "$KV.mesh-host_packages.b")) { t.Errorf("node %s writes the packages keys %v", u.Node, perms.Publish) } diff --git a/go/buscompose/engine.go b/go/buscompose/engine.go index 2e6a902..4681729 100644 --- a/go/buscompose/engine.go +++ b/go/buscompose/engine.go @@ -4,6 +4,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/provisions" ) // The node seat node-engine (novox/hq ADR 0563 §1): every node's engine holds it on its own bus account, and no module @@ -52,6 +53,11 @@ func engineGrants(p Principal) (pub, sub []string) { if own, err := NodeKeysGrants(EngineActsBucket, p.Node); err == nil { pub = append(pub, own...) } + // And the provisions its node's modules consume, its own node's keys alone, read (novox/hq ADR 0570 clause 6: the + // consuming engine checks the vault's name and generation of what it is handed). + if safeSubject.MatchString(p.Node) { + pub = append(pub, readGrants(provisions.ProvisionsBucket, p.Node+".>")...) + } // And its node's declared packages, its own key alone (ADR 0565 clause 6). if safeSubject.MatchString(p.Node) { pub = append(pub, EnginePackagesGrants(p.Node)...) diff --git a/go/delivery/control_records.go b/go/delivery/control_records.go index 9d9f869..a0112d8 100644 --- a/go/delivery/control_records.go +++ b/go/delivery/control_records.go @@ -91,5 +91,42 @@ type DeclaredPackages struct { Packages []PackageDeclared `json:"packages"` } +// mesh-config's state of the approved values held for a pull request (novox/hq ADR 0565 clause 5): read by the checker +// and the assigner's composition check, which lay a pull request's own held values over its modules' values. +const ( + MeshConfigKeptState = "kept" + MeshConfigKeptRead = "mesh-config." + MeshConfigKeptState +) + +// KeptValue is one approved held value, mesh-config's layout unchanged (modules/mesh-config pulls.go): kept apart from +// the values mesh-config resolves, laid only over the composition of its own pull request at its head. +type KeptValue struct { + Pull string `json:"pull"` + Head string `json:"head"` + Digest string `json:"declaration"` + Node string `json:"node"` + Module string `json:"module"` + Key string `json:"key"` + // Value is the value held, as the operator approved it. + Value json.RawMessage `json:"value"` + By string `json:"by"` + Via string `json:"via"` + Why string `json:"why"` + // Approved is who approved it, as the proposal kept it. + Approved string `json:"approved"` + Proposal string `json:"proposal"` + At time.Time `json:"at"` +} + +// HeldValueKey is a held value's key in `kept`: the pull request's key (ConfigCandidateKey's form), the node, the module and +// the config key, joined by dots, as mesh-config writes it. +func HeldValueKey(pull, node, module, key string) (string, error) { + p, err := ConfigCandidateKey(pull) + if err != nil { + return "", err + } + return p + "." + node + "." + module + "." + key, nil +} + // The assigner's state of a person's upgrade policy, one key per module (clause 7; catalogue.PersonPolicy). const AssignerPoliciesState = "policies" diff --git a/go/delivery/control_records_test.go b/go/delivery/control_records_test.go index 6e4d42b..436867f 100644 --- a/go/delivery/control_records_test.go +++ b/go/delivery/control_records_test.go @@ -19,6 +19,9 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) { `{"package":"nano","modules":["web"],"absent":true}]}`, &DeclaredPackages{}}, {"a config candidate", `{"v":1,"pull":"novox/mesh-catalog#42","head":"abc","declares":{"mail":{"quota":{"kind":"config",` + `"default":10,"why":"a quota","type":"integer","minimum":1}}},"at":"2026-10-29T12:00:00Z"}`, &ConfigCandidate{}}, + {"a held value", `{"pull":"novox/mesh-catalog#42","head":"abc","declaration":"sha256:d","node":"novox","module":"mail",` + + `"key":"quota","value":20,"by":"person.jochen","via":"telegram","why":"more room","approved":"the operator",` + + `"proposal":"p-1","at":"2026-10-29T12:00:00Z"}`, &KeptValue{}}, {"the predefined list", `{"v":1,"commit":"abc","at":"2026-10-29T12:00:00Z","list":{"domain":{"type":"string"}}}`, &PredefinedKept{}}, } { if err := json.Unmarshal([]byte(c.raw), c.into); err != nil { @@ -32,6 +35,9 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) { t.Errorf("%s reads back as %s", c.name, again) } } + if k, err := HeldValueKey("novox/mesh-catalog#42", "novox", "mail", "quota"); k != "novox.mesh-catalog.42.novox.mail.quota" || err != nil { + t.Errorf("a held value's key is %q, %v", k, err) + } for pull, want := range map[string]string{"novox/mesh-catalog#42": "novox.mesh-catalog.42", "x#1": "", "a/b": "", "a/b.c#1": ""} { if got, err := ConfigCandidateKey(pull); got != want || (want == "") != (err != nil) { t.Errorf("%s keyed %q, %v", pull, got, err) diff --git a/go/provisions/provisions.go b/go/provisions/provisions.go index 4981dd4..18e1f17 100644 --- a/go/provisions/provisions.go +++ b/go/provisions/provisions.go @@ -92,6 +92,35 @@ func PlanKey(node, module string) (string, error) { return node + "." + module, nil } +// The forms of a provision's credential, as a plan's entry names them (catalogue.PlanProvision.CredentialForm). +const ( + FormPair = "pair" + FormShared = "shared" + FormIssued = "issued" + FormGiven = "given" +) + +// VaultName is the vault's name of a provision's credential in form, as its verb `own` names it and the provider asks +// it: `pair////[/]` for a login of the consumer's own (ADR 0158 +// §11), `provision//` for the provider's one shared value, and `provision////` for a holder's own issued or given value. Empty for a form with no +// credential. +func VaultName(form string, p Provision) string { + switch form { + case FormPair: + n := "pair/" + p.Provider.Node + "/" + p.Provision + "/" + p.Consumer.Node + "/" + p.Consumer.Module + if p.Local != "" { + n += "/" + p.Local + } + return n + case FormShared: + return "provision/" + p.Provider.Node + "/" + p.Provision + case FormIssued, FormGiven: + return "provision/" + p.Provider.Node + "/" + p.Provision + "/" + p.Consumer.Node + "/" + p.Consumer.Module + } + return "" +} + // End is one end of a provision: a module on a node. type End struct { Module string `json:"module"` @@ -99,7 +128,9 @@ type End struct { } // Reference is a value the vault keeps, by its id and the generation of the value: never the value, sealed or not -// (ADR 0349 §7). The holder writes the generation the vault says in its own entry (clause 6). +// (ADR 0349 §7). The holder writes the generation the vault says in its own entry (clause 6). ID is the vault's name of +// the credential exactly as its verb `own` names it (VaultName): the consuming node's engine checks both the name and +// the generation of what it is handed. type Reference struct { ID string `json:"id"` Generation int `json:"generation"` diff --git a/go/provisions/provisions_test.go b/go/provisions/provisions_test.go index 294ef2f..c720f42 100644 --- a/go/provisions/provisions_test.go +++ b/go/provisions/provisions_test.go @@ -72,6 +72,16 @@ func TestAProvisionIsAReferenceUnderItsConsumersKey(t *testing.T) { if k, err := PlanKey("novox", "restic"); k != "novox.restic" || err != nil { t.Errorf("a plan's key is %q, %v", k, err) } + // A reference's id is the vault's name as its verb own names it, in each form (the host agent, 2026-10-29). + for form, want := range map[string]string{FormPair: "pair/novox/postgres-database/novox/keycloak", FormShared: "provision/novox/postgres-database", + FormIssued: "provision/novox/postgres-database/novox/keycloak", "none": ""} { + if got := VaultName(form, db); got != want { + t.Errorf("the %s name is %q", form, got) + } + } + if got := VaultName(FormPair, Provision{Provision: "p", Local: "r", Provider: End{Node: "a"}, Consumer: End{Node: "b", Module: "m"}}); got != "pair/a/p/b/m/r" { + t.Errorf("a local name's pair is %q", got) + } if ProvisionsBucket != "mesh-provisioner_provisions" || PlansRead != "mesh-provisioner.plans" { t.Errorf("the states are %s and %s", ProvisionsBucket, PlansRead) }