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.
304 lines
10 KiB
Go
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
|
|
}
|