Files
mesh-controller/cmd/mesh-controller/operator.go
T
jschoubben 53e8f5bdd8 A person may be issued, listed and revoked
Design 25 §7's first item, which existed as a permission model and as nothing a person
could actually be given. There is a record now, and three commands.

Their authority is a list of tools and nothing else. Not a module: they hold no seat,
nothing is addressed to them, nothing is delivered to them, and they have no consumer to
acknowledge. What they have is permission to ask — which is why there is no scope and no
node in the record.

Stating what somebody may call replaces what was there rather than adding to it: a list
that could only grow is a permission nobody can take back. Forgetting somebody takes
their credential with them, because a person's row gone with their bus user left behind
is a credential that still works and that nothing derives — the worst of both, since it
keeps working and nobody can explain why.

The credential is printed once and the mesh keeps only a hash, the same contract a token
has. And it starts working at the next composition rather than immediately, because the
bus's users are a file — said out loud in both the issue and the revoke messages, since
"revoked" that still works for another minute is worth knowing about.

Four properties held by test, each a way of being wrong that would not announce itself:
a person may publish exactly the tool subjects they were given and nothing on control,
nodes or events; they cannot answer a request; changing the list removes what is no longer
named; and forgetting them revokes them.
2026-09-27 17:07:19 +02:00

304 lines
10 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"strings"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/secrets"
)
// operatorCommand is the mesh's one holder of secrets that is not a machine.
//
// **Every secret a module holds for itself is sealed to the node that uses it, and a node whose key
// is gone takes its secrets with it** — the store's superuser and the broker's administrator among
// them. novox/hq ADR 0085 (amended) gives them a second recipient: a person, with a sealing key
// whose private half is made where the operator is and never enters the mesh. Making the key and
// telling the mesh about it are two commands, on purpose: the first needs no mesh at all and runs
// wherever the operator keeps things; the second gives the mesh the public half and nothing else.
// The controller's own container is a scratch image with no writable path, which is the right
// shape for a program that must hold no key — so the private half could not be written there
// even by mistake.
//
// operator key make [--out <file>] make a keypair: private half to the file, public half printed
// operator key set <public> tell the mesh which key to seal to
// operator key show the public key, its fingerprint, and what it can recover
const operatorUsage = "operator key make [--out <file>] | operator key set <public> [--replace] | " +
"operator key show | operator issue <name> --invokes <tool,tool|*> | operator revoke <name> | " +
"operator list"
func operatorCommand(ctx context.Context, args []string) error {
if len(args) == 0 {
return errors.New(operatorUsage)
}
// The people who may reach the mesh's tools (design 25 §7). Beside the operator's key because
// both answer "who, other than a machine, may do something here" — and a person reading this
// command's usage is asking exactly that.
switch args[0] {
case "issue":
return personIssue(ctx, args[1:])
case "revoke":
return personRevoke(ctx, args[1:])
case "list":
return personList(ctx)
}
if len(args) < 2 || args[0] != "key" {
return errors.New(operatorUsage)
}
switch args[1] {
case "make":
return operatorKeyMake(args[2:])
case "set":
return operatorKeySet(ctx, args[2:])
case "show":
return operatorKeyShow(ctx)
default:
return errors.New(operatorUsage)
}
}
// operatorKeyMake needs no mesh: it is the operator's, run wherever the key will live.
func operatorKeyMake(args []string) error {
set := flag.NewFlagSet("operator key make", flag.ContinueOnError)
out := set.String("out", "operator.key",
"where to write the private key (0600); keep it off the mesh, and keep it")
if err := set.Parse(args); err != nil {
return err
}
public, private, err := secrets.Keypair()
if err != nil {
return err
}
// Create-exclusive: a key somebody may still need is never overwritten, and there is no window
// between checking and writing in which one could appear.
if err := writeNew(*out, []byte(private+"\n")); err != nil {
return err
}
fmt.Printf("operator key %s\n", secrets.Fingerprint(public))
fmt.Printf(" private half written to %s (0600) — keep it off the mesh, and keep it\n", *out)
fmt.Printf(" public half, to give the mesh with `operator key set`:\n")
fmt.Printf("public %s\n", public)
return nil
}
func operatorKeySet(ctx context.Context, args []string) error {
rest, flags := split(args)
set := flag.NewFlagSet("operator key set", flag.ContinueOnError)
replace := set.Bool("replace", false,
"replace an existing operator key — secrets sealed to the old one stay sealed to it")
if err := set.Parse(flags); err != nil {
return err
}
if len(rest) != 1 {
return errors.New(operatorUsage)
}
public := strings.TrimSpace(rest[0])
if _, err := secrets.Seal(public, []byte("probe")); err != nil {
return fmt.Errorf("that is not a public sealing key: %w", err)
}
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
inv := open.inventory
if current, err := inv.OperatorKey(ctx); err != nil {
return err
} else if current != "" && current != public && !*replace {
return fmt.Errorf(
"the mesh already has an operator key (%s). Pass --replace to change it — "+
"secrets sealed to the current key stay sealed to it until each is issued again",
secrets.Fingerprint(current))
}
orphaned, err := inv.SetOperatorKey(ctx, public)
if err != nil {
return err
}
fmt.Printf("operator key %s\n", secrets.Fingerprint(public))
fmt.Printf(" the mesh holds the public half only and cannot open what it seals to it;\n")
fmt.Printf(" from now on every secret a module holds for itself is sealed to it as well.\n")
fmt.Printf(" Secrets made before this cannot be — each is recoverable once issued again\n")
if orphaned > 0 {
fmt.Printf(" %d secret(s) are sealed to the previous key and stay so until issued again\n", orphaned)
}
return nil
}
func operatorKeyShow(ctx context.Context) error {
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
inv := open.inventory
key, err := inv.OperatorKey(ctx)
if err != nil {
return err
}
if key == "" {
fmt.Println("the mesh has no operator key; `operator key make` then `operator key set` gives it one")
return nil
}
kept, earlier, unrecoverable, err := inv.KeptForOperator(ctx)
if err != nil {
return err
}
fmt.Printf("operator key %s\n %s\n", secrets.Fingerprint(key), key)
fmt.Printf(" %d secret(s) recoverable with it\n", len(kept))
if len(earlier) > 0 {
fmt.Printf(" %d secret(s) sealed to an earlier operator key — recoverable with that key only, until issued again:\n", len(earlier))
for _, k := range earlier {
fmt.Printf(" %s %s %s (%s)\n", k.Node, k.Module, k.Name, secrets.Fingerprint(k.Key))
}
}
if len(unrecoverable) > 0 {
fmt.Printf(" %d secret(s) not recoverable — made before the mesh had an operator key:\n", len(unrecoverable))
for _, k := range unrecoverable {
fmt.Printf(" %s %s %s\n", k.Node, k.Module, k.Name)
}
}
return nil
}
// readPrivateKey is the operator's key from the file `operator key make` wrote.
func readPrivateKey(path string) (string, error) {
if path == "" {
return "", errors.New("--key <file> names the operator's private key, written by `operator key make`")
}
raw, err := os.ReadFile(path)
if err != nil {
return "", err
}
return strings.TrimSpace(string(raw)), nil
}
// personIssue gives somebody a credential for the mesh's tools, and prints it once.
//
// **Printed, not stored.** The mesh keeps a hash and nothing else, so this is the only moment the
// credential exists anywhere but on the workstation that will use it — the same contract a token has,
// and for the same reason: a credential recoverable from the mesh's store has the store's blast
// radius.
func personIssue(ctx context.Context, args []string) error {
set := flag.NewFlagSet("operator issue", flag.ContinueOnError)
invokes := set.String("invokes", "", "the tools this person may call, comma-separated, or * for every one")
if err := set.Parse(args); err != nil {
return err
}
if set.NArg() != 1 {
return errors.New("operator issue <name> --invokes <tool,tool|*>")
}
name := set.Arg(0)
if *invokes == "" {
return errors.New(
"say what this person may call: --invokes mesh-catalog.catalog_tools,gitea.repo_create, " +
"or --invokes '*' for an administrator")
}
var tools []string
for _, t := range strings.Split(*invokes, ",") {
if t = strings.TrimSpace(t); t != "" {
tools = append(tools, t)
}
}
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
inv := open.inventory
if err := inv.RecordPerson(ctx, inventory.Person{Name: name, Invokes: tools}); err != nil {
return err
}
// Refused here rather than at the next composition, where it would stop the whole file being
// written for everybody. A name that cannot be part of a subject is one the server would read as
// a wider permission than anybody granted.
if _, err := broker.PermissionsFor(broker.Principal{
Kind: broker.KindPerson, Module: name, Invokes: tools, PasswordHash: "x",
}); err != nil {
return err
}
user := broker.Principal{Kind: broker.KindPerson, Module: name}.Username()
password, err := inv.MintBusPassword(ctx, inventory.BusUser{Username: user, Kind: inventory.BusPerson})
if err != nil {
return err
}
where, err := broker.FromEnvironment()
if err != nil && !errors.Is(err, broker.ErrNotConfigured) {
return err
}
held, err := json.Marshal(struct {
URL string `json:"url"`
Fingerprint string `json:"fingerprint,omitempty"`
User string `json:"user"`
Password string `json:"password"`
Person string `json:"person"`
Invokes []string `json:"invokes"`
}{
URL: "nats://" + where.Address, Fingerprint: where.Fingerprint,
User: user, Password: password, Person: name, Invokes: tools,
})
if err != nil {
return err
}
fmt.Printf("issued %s, who may call %s\n", name, strings.Join(tools, ", "))
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()
fmt.Println(string(held))
return nil
}
func personRevoke(ctx context.Context, args []string) error {
if len(args) != 1 {
return errors.New("operator revoke <name>")
}
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
if err := open.inventory.ForgetPerson(ctx, args[0]); err != nil {
return err
}
// **Revoked at the next composition, not now.** The bus's users are a file, so a credential stops
// working when the file no longer names it. Said plainly, because "revoked" that still works for
// another minute is worth knowing about.
fmt.Printf("%s is forgotten, and their credential stops working at the next composition — "+
"push the machine holding mesh-broker to make it so\n", args[0])
return nil
}
func personList(ctx context.Context) error {
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
people, err := open.inventory.People(ctx)
if err != nil {
return err
}
if len(people) == 0 {
fmt.Println("nobody but machines reaches this mesh")
return nil
}
for _, p := range people {
fmt.Printf("%-20s %s\n", p.Name, strings.Join(p.Invokes, ", "))
}
return nil
}