mesh-grants: the grantsstate package (its states grants and user-list, the sealed list's rules) is the module's own, a package artifact (D-10, ADR 0634 §1; issue 1966)
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
module git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0
|
||||
git.novox.be/novox/mesh-delivery/sdk v0.0.0
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f
|
||||
)
|
||||
|
||||
replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds => ../../mesh-delivery-builder/builds
|
||||
|
||||
replace git.novox.be/novox/mesh-delivery/sdk => ../../../sdk
|
||||
@@ -0,0 +1,102 @@
|
||||
// Package grantsstate holds what the grants part owns and no seat carries (novox/hq ADR 0634 §1): the layout of its states
|
||||
// `grants` (what a build's module may use) and `user-list` (the sealed list of users a bus node follows), and the rules over
|
||||
// them. Other modules import it from the grants part by its published version (ADR 0573 §1).
|
||||
package grantsstate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/kept"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
)
|
||||
|
||||
// **The grants issued** (novox/hq ADR 0528 clause 2, ADR 0429 §6 rule 6, ADR 0547 §4, §5, ADR 0590): when the grants
|
||||
// part has issued a module's bus credentials for a fingerprint, it writes `<module>.<fingerprint hex>` in its state
|
||||
// `grants` (PartGrantsBucket). The assigner reads it: a node's want moves to a fingerprint only once its grant exists.
|
||||
// Only the grants part writes the bucket.
|
||||
const (
|
||||
// PartGrantsState is the grants part's state of issued credentials by fingerprint, as its manifest names it.
|
||||
PartGrantsState = "grants"
|
||||
// PartGrantsBucket is that state's bucket, `<owner module>_<name>`.
|
||||
PartGrantsBucket = "mesh-grants_" + PartGrantsState
|
||||
// PartGrantsRead is that state as a reader names it in `reads`.
|
||||
PartGrantsRead = "mesh-grants." + PartGrantsState
|
||||
// PartGrantsHistory is the revisions the bucket keeps per key.
|
||||
PartGrantsHistory = 5
|
||||
)
|
||||
|
||||
// PartGrantKey is the key, in PartGrantsBucket, of a module's credential issued for a fingerprint: `<module>.<hex>`,
|
||||
// the hex being the fingerprint's key part (FingerprintKeyPart).
|
||||
func PartGrantKey(module, fingerprint string) (string, error) {
|
||||
return builds.BuilderBuildKey(module, fingerprint)
|
||||
}
|
||||
|
||||
// ParsePartGrantKey is the inverse of PartGrantKey; it refuses a want or held key, naming it.
|
||||
func ParsePartGrantKey(key string) (module, fingerprint string, err error) {
|
||||
if module, fingerprint, err = builds.ParseBuilderBuildKey(key); err != nil {
|
||||
return "", "", fmt.Errorf("grantsstate: %q is not a grant key, <module>.<fingerprint hex>: %w", key, err)
|
||||
}
|
||||
return module, fingerprint, nil
|
||||
}
|
||||
|
||||
// Grant is the value of a grant key: the bus accounts (their public keys) the module's processes were issued
|
||||
// for that fingerprint, none for a module whose grants the tool runner's account carries, so a reissue for another
|
||||
// account is a new revision. The entry's own time says when it was issued.
|
||||
type Grant struct {
|
||||
V int `json:"v"`
|
||||
Accounts []string `json:"accounts,omitempty"`
|
||||
}
|
||||
|
||||
func (g Grant) check() error {
|
||||
for _, a := range g.Accounts {
|
||||
if strings.TrimSpace(a) == "" || strings.ContainsAny(a, " \t\n") {
|
||||
return fmt.Errorf("grantsstate: a grant names an empty or spaced account %q", a)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeGrant is g as JSON at Version; it refuses an empty or spaced account.
|
||||
func EncodeGrant(g Grant) ([]byte, error) {
|
||||
if err := g.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.V = keys.Version
|
||||
return json.Marshal(g)
|
||||
}
|
||||
|
||||
// DecodeGrant reads a grant; it refuses an unknown version (ErrUnknownVersion) and an empty or spaced account.
|
||||
func DecodeGrant(data []byte) (Grant, error) {
|
||||
var g Grant
|
||||
if err := kept.Decode(data, &g); err != nil {
|
||||
return Grant{}, err
|
||||
}
|
||||
if err := g.check(); err != nil {
|
||||
return Grant{}, err
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// SameGrant says whether two grants are the same value.
|
||||
func SameGrant(a, b Grant) bool { return reflect.DeepEqual(a, b) }
|
||||
|
||||
// NamesNode says whether the grant names module's accounts on node: its own `<node>.<module>` or a process's
|
||||
// `<node>.<module>.<process>`. A grant that names no account (a module that does not speak on the bus) names every
|
||||
// node. The assigner wants a node only once the grant names its account (issue 1945), and a put waits for it (ADR
|
||||
// 0546 clause 4).
|
||||
func (g Grant) NamesNode(node, module string) bool {
|
||||
if len(g.Accounts) == 0 {
|
||||
return true
|
||||
}
|
||||
own := node + "." + module
|
||||
for _, a := range g.Accounts {
|
||||
if a == own || strings.HasPrefix(a, own+".") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package grantsstate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
)
|
||||
|
||||
// 1394/B1b: a grant key round-trips; a want or held key is refused, naming it; a grant round-trips and one naming an
|
||||
// empty account is refused.
|
||||
func TestTheGrantsIssued(t *testing.T) {
|
||||
fp := "src2:" + strings.Repeat("c", 64)
|
||||
key, err := PartGrantKey("mesh-delivery-assigner", fp)
|
||||
if err != nil || key != "mesh-delivery-assigner."+strings.Repeat("c", 64) {
|
||||
t.Fatalf("PartGrantKey = %q, %v", key, err)
|
||||
}
|
||||
module, back, err := ParsePartGrantKey(key)
|
||||
if err != nil || module != "mesh-delivery-assigner" || back != fp {
|
||||
t.Errorf("ParsePartGrantKey = %q, %q, %v", module, back, err)
|
||||
}
|
||||
held := "mesh-mail." + strings.Repeat("c", 64) + ".held"
|
||||
for _, bad := range []string{"want.ace.mesh-mail", held, "ace.mesh-mail", ""} {
|
||||
if _, _, err := ParsePartGrantKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") {
|
||||
t.Errorf("ParsePartGrantKey(%q) = %v, want a refusal naming it", bad, err)
|
||||
}
|
||||
}
|
||||
for _, g := range []Grant{{}, {Accounts: []string{"UABC", "UDEF"}}} {
|
||||
data, err := EncodeGrant(g)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := DecodeGrant(data)
|
||||
g.V = keys.Version
|
||||
if err != nil || !SameGrant(got, g) {
|
||||
t.Errorf("grant %+v came back %+v, %v", g, got, err)
|
||||
}
|
||||
}
|
||||
if _, err := EncodeGrant(Grant{Accounts: []string{""}}); err == nil {
|
||||
t.Errorf("a grant naming an empty account was accepted")
|
||||
}
|
||||
g := Grant{Accounts: []string{"ace.mesh-build-go", "shanks.mesh-mail.reader"}}
|
||||
for _, c := range []struct {
|
||||
g Grant
|
||||
node, module string
|
||||
names bool
|
||||
}{
|
||||
{g, "ace", "mesh-build-go", true},
|
||||
{g, "shanks", "mesh-mail", true},
|
||||
{g, "g14", "mesh-build-go", false},
|
||||
{g, "ace", "mesh-build", false},
|
||||
{Grant{}, "novox", "nats", true},
|
||||
} {
|
||||
if got := c.g.NamesNode(c.node, c.module); got != c.names {
|
||||
t.Errorf("%v names %s.%s: %v, want %v", c.g.Accounts, c.node, c.module, got, c.names)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The grants part's list sealed to the bus node (novox/hq ADR 0547 clause 7): the key's value round-trips and refuses what
|
||||
// the bus node could not follow, and the object opens with the node's key alone, to the digest the key names.
|
||||
func TestTheGrantsPartsSealedUserList(t *testing.T) {
|
||||
list := []byte("accounts { MESH { users = [ { user: \"controller\" } ] } }\n")
|
||||
digest := UserListDigest(list)
|
||||
node, err := seal.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other, _ := seal.GenerateKeyPair()
|
||||
g := GrantsUserList{Follow: FollowGrants, Digest: digest, Object: UserListObjectName(digest),
|
||||
SealedTo: SealingKeyID(node.PublicKey), Node: "novox", Revision: 1, Users: 1}
|
||||
raw, err := EncodeGrantsUserList(g)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := DecodeGrantsUserList(raw)
|
||||
if err != nil || back.Digest != digest || back.LeftOut == nil || back.Follow != FollowGrants {
|
||||
t.Fatalf("decoded %+v, %v", back, err)
|
||||
}
|
||||
// Issue 1126 B4a (ADR 0590): a key written with the lease's flag still reads; the controller's list is no source.
|
||||
if old, err := DecodeGrantsUserList([]byte(`{"v":1,"follow":"grants","digest":"","object":"","sealed_to":"",` +
|
||||
`"node":"novox","revision":3,"users":40,"left_out":[],"at":"2026-10-30T10:00:00Z","lease-handed":true}`)); err != nil || old.Revision != 3 {
|
||||
t.Errorf("a key with the lease's flag decodes %+v, %v", old, err)
|
||||
}
|
||||
for _, bad := range []GrantsUserList{{Follow: "both", Digest: digest, Object: g.Object}, {Follow: "controller"},
|
||||
{Follow: FollowGrants, Digest: digest, Object: "other"}, {Follow: FollowGrants, Digest: "md5:x", Object: "md5:x"}} {
|
||||
if _, err := EncodeGrantsUserList(bad); err == nil {
|
||||
t.Errorf("%+v was written", bad)
|
||||
}
|
||||
}
|
||||
if _, err := EncodeGrantsUserList(GrantsUserList{Follow: FollowGrants}); err != nil {
|
||||
t.Errorf("a switch with no list yet refused: %v", err)
|
||||
}
|
||||
object, err := SealUserList(list, node.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(object, []byte("controller")) {
|
||||
t.Fatal("the object carries the list in the clear")
|
||||
}
|
||||
opener := func(priv string) func(seal.Box) ([]byte, error) {
|
||||
return func(b seal.Box) ([]byte, error) { s, err := seal.Open(b, priv); return []byte(s), err }
|
||||
}
|
||||
if got, err := OpenUserList(object, digest, opener(node.PrivateKey)); err != nil || !bytes.Equal(got, list) {
|
||||
t.Fatalf("opened %q, %v", got, err)
|
||||
}
|
||||
if _, err := OpenUserList(object, digest, opener(other.PrivateKey)); err == nil {
|
||||
t.Error("another key opened the list")
|
||||
}
|
||||
if _, err := OpenUserList(object, "sha256:00", opener(node.PrivateKey)); err == nil {
|
||||
t.Error("a list of another digest was taken")
|
||||
}
|
||||
if _, err := SealUserList(list, "not a key"); err == nil {
|
||||
t.Error("a list was sealed to no key")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package grantsstate
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/kept"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
)
|
||||
|
||||
// **The grants part's user list, sealed to the bus node** (novox/hq ADR 0547 clauses 7 and 8, issue 1945): the contract
|
||||
// between the grants part (mesh-delivery modules/mesh-grants), which writes it, and the node-engine of the node holding
|
||||
// mesh-broker (mesh-host), which follows it once the operator's take-over says so.
|
||||
//
|
||||
// - The grants part composes the bus's user list (the accounts block, buscompose.ComposeAccounts, with the vault's
|
||||
// hashes), seals it to the bus node's sealing key (seal.Seal, the key the node says in its have of the bus module,
|
||||
// UserListWritten.Key), and puts the box as one object of its object bucket GrantsUserListObjects, named by the
|
||||
// list's digest (UserListObjectName).
|
||||
// - It writes the key GrantsUserListKey of its state `user-list` (bucket GrantsUserListBucket): the GrantsUserList
|
||||
// below, which names the object, the list's digest, its revision, its user count and the users left out, and which
|
||||
// source the bus node follows (Follow: FollowGrants, the one source since the controller ended, ADR 0590).
|
||||
// - The node-engine of the bus node watches that key alone. It opens the object, checks the digest, writes the bus's
|
||||
// user-list file (mode 0600) only when the file's digest differs, and says what it loaded in its have of the bus
|
||||
// module (UserListWritten, Source GrantsUserListSource).
|
||||
|
||||
// The names of the grants part's user list on the bus.
|
||||
const (
|
||||
// GrantsUserListBucket is the grants part's state `user-list`.
|
||||
GrantsUserListBucket = "mesh-grants_user-list"
|
||||
// GrantsUserListKey is the one key of it the bus node reads.
|
||||
GrantsUserListKey = "sealed"
|
||||
// GrantsUserListObjects is the grants part's object bucket, where each sealed list is one object.
|
||||
GrantsUserListObjects = "mesh-grants-user-list"
|
||||
// GrantsUserListObjectStream is the bucket's stream.
|
||||
GrantsUserListObjectStream = "OBJ_" + GrantsUserListObjects
|
||||
// GrantsUserListSource is the source a have names for a list taken from the grants part.
|
||||
GrantsUserListSource = GrantsUserListBucket + "." + GrantsUserListKey
|
||||
)
|
||||
|
||||
// FollowGrants is the list the bus node follows: the grants part's, the one source (ADR 0590).
|
||||
const FollowGrants = "grants"
|
||||
|
||||
// GrantsUserList is the value of the grants part's key `user-list.sealed`.
|
||||
type GrantsUserList struct {
|
||||
V int `json:"v"`
|
||||
// Follow is which list the bus node follows: FollowGrants, the one value it takes.
|
||||
Follow string `json:"follow"`
|
||||
// Digest is the list's digest, `sha256:<hex>` of the file's bytes as the bus loads them.
|
||||
Digest string `json:"digest"`
|
||||
// Object is the object's name in GrantsUserListObjects (UserListObjectName of Digest).
|
||||
Object string `json:"object"`
|
||||
// SealedTo is the fingerprint of the key the object is sealed to (SealingKeyID), and Node the node it holds.
|
||||
SealedTo string `json:"sealed_to"`
|
||||
Node string `json:"node"`
|
||||
// Revision counts the lists the grants part wrote: one more for each new digest.
|
||||
Revision uint64 `json:"revision"`
|
||||
Users int `json:"users"`
|
||||
// LeftOut are the users the list leaves out for want of a hash in the vault.
|
||||
LeftOut []string `json:"left_out"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// EncodeGrantsUserList is g as JSON at Version; it refuses a value the bus node could not follow.
|
||||
func EncodeGrantsUserList(g GrantsUserList) ([]byte, error) {
|
||||
g.V = keys.Version
|
||||
if g.LeftOut == nil {
|
||||
g.LeftOut = []string{}
|
||||
}
|
||||
if err := g.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(g)
|
||||
}
|
||||
|
||||
// DecodeGrantsUserList reads the key's value; it refuses another version and what Encode refuses.
|
||||
func DecodeGrantsUserList(data []byte) (GrantsUserList, error) {
|
||||
var g GrantsUserList
|
||||
if err := kept.Decode(data, &g); err != nil {
|
||||
return GrantsUserList{}, err
|
||||
}
|
||||
if err := g.check(); err != nil {
|
||||
return GrantsUserList{}, err
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (g GrantsUserList) check() error {
|
||||
if g.Follow != FollowGrants {
|
||||
return fmt.Errorf("grantsstate: the user list's follow %q is not %s, the one list since the controller ended (ADR 0590)",
|
||||
g.Follow, FollowGrants)
|
||||
}
|
||||
if g.Digest == "" {
|
||||
return nil // a switch with no list written yet: the bus node keeps what it follows
|
||||
}
|
||||
if !strings.HasPrefix(g.Digest, "sha256:") || g.Object != UserListObjectName(g.Digest) {
|
||||
return fmt.Errorf("grantsstate: the user list names object %q for digest %q; the object is the digest's hex", g.Object, g.Digest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserListDigest is a list's digest as the have and the key say it, `sha256:<hex>` of its bytes.
|
||||
func UserListDigest(content []byte) string {
|
||||
sum := sha256.Sum256(content)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// UserListObjectName is the object a list of that digest is put under: the digest's hex.
|
||||
func UserListObjectName(digest string) string { return strings.TrimPrefix(digest, "sha256:") }
|
||||
|
||||
// SealingKeyID is a short fingerprint of a public key in PEM, `sha256:<16 hex>`, to say which key a box is sealed to
|
||||
// without carrying the key.
|
||||
func SealingKeyID(publicPEM string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(publicPEM)))
|
||||
return "sha256:" + hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
// SealUserList is the object a list is put as: the sealed box (seal.Seal) of its bytes to the bus node's key, as JSON.
|
||||
func SealUserList(list []byte, nodePEM string) ([]byte, error) {
|
||||
if !strings.Contains(nodePEM, "PUBLIC KEY") {
|
||||
return nil, fmt.Errorf("grantsstate: the bus node's key is not a public key in PEM")
|
||||
}
|
||||
box, err := seal.Seal(string(list), nodePEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("grantsstate: the user list could not be sealed: %w", err)
|
||||
}
|
||||
return json.Marshal(box)
|
||||
}
|
||||
|
||||
// OpenUserList opens an object with open (the node's own key) and checks it is the list of digest.
|
||||
func OpenUserList(object []byte, digest string, open func(seal.Box) ([]byte, error)) ([]byte, error) {
|
||||
var box seal.Box
|
||||
if err := json.Unmarshal(object, &box); err != nil {
|
||||
return nil, fmt.Errorf("grantsstate: the object is no sealed box: %w", err)
|
||||
}
|
||||
list, err := open(box)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if got := UserListDigest(list); got != digest {
|
||||
return nil, fmt.Errorf("grantsstate: the opened list's digest is %s, and the key names %s", got, digest)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
@@ -133,6 +133,15 @@
|
||||
"loads": [
|
||||
"mesh-grants"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "grantsstate",
|
||||
"kind": "package",
|
||||
"language": "go",
|
||||
"paths": [
|
||||
"grantsstate"
|
||||
],
|
||||
"series": "0.1"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user