diff --git a/cmd/mesh-controller/operator.go b/cmd/mesh-controller/operator.go index af037eb..0bf944d 100644 --- a/cmd/mesh-controller/operator.go +++ b/cmd/mesh-controller/operator.go @@ -2,12 +2,15 @@ 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" ) @@ -26,9 +29,25 @@ import ( // operator key make [--out ] make a keypair: private half to the file, public half printed // operator key set 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 ] | operator key set [--replace] | operator key show" +const operatorUsage = "operator key make [--out ] | operator key set [--replace] | " + + "operator key show | operator issue --invokes | operator revoke | " + + "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) } @@ -160,3 +179,125 @@ func readPrivateKey(path string) (string, error) { } 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 --invokes ") + } + 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 ") + } + 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 +} diff --git a/internal/inventory/busrecords.go b/internal/inventory/busrecords.go index 955394f..4b29bef 100644 --- a/internal/inventory/busrecords.go +++ b/internal/inventory/busrecords.go @@ -80,8 +80,13 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) { } out.Enrolling = enrolling - // People are not recorded yet: the account model is built (design 25 §7's first item) and - // `operator issue` is not, so there is nobody to derive. Left empty rather than guessed at. + people, err := i.People(ctx) + if err != nil { + return broker.Records{}, err + } + for _, p := range people { + out.People[p.Name] = p.Invokes + } return out, nil } diff --git a/internal/inventory/bususers.go b/internal/inventory/bususers.go index 780a193..b46a643 100644 --- a/internal/inventory/bususers.go +++ b/internal/inventory/bususers.go @@ -163,3 +163,70 @@ func (i *Inventory) SeedBusUser(ctx context.Context, u BusUser, password string) u.Username, u.Kind, u.Node, u.Module, string(hash)) return err } + +// A person who may call the mesh's tools (novox/hq design 25 §7). +// +// **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. + +// Person is somebody who may reach the mesh's tools. +type Person struct { + Name string + // Invokes are the tools they may call, each `.`, or the single entry `*` for an + // administrator. + Invokes []string +} + +// RecordPerson adds somebody, or changes what they may call. +// +// Replacing rather than merging: what a person may call is stated in full, so a change that meant to +// remove a tool does remove it. A list that could only grow is a permission nobody can take back. +func (i *Inventory) RecordPerson(ctx context.Context, p Person) error { + if p.Name == "" { + return errors.New("a person needs a name: it becomes their user on the bus") + } + if len(p.Invokes) == 0 { + return fmt.Errorf( + "%s may call nothing, so there is no reason for them to reach the mesh. Name the tools, "+ + "or `*` for an administrator", p.Name) + } + _, err := i.store.Pool().Exec(ctx, + `insert into person (name, invokes) values ($1, $2) + on conflict (name) do update set invokes = excluded.invokes`, + p.Name, p.Invokes) + return err +} + +// People is everybody who may reach the mesh's tools. +func (i *Inventory) People(ctx context.Context) ([]Person, error) { + rows, err := i.store.Pool().Query(ctx, `select name, invokes from person order by name`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Person + for rows.Next() { + var p Person + if err := rows.Scan(&p.Name, &p.Invokes); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +// ForgetPerson removes somebody and the credential they were given. +// +// **Both, or neither is a revocation.** A person's row gone and their bus user left behind is a +// credential that still works and that nothing derives, which is the worst of both: it keeps working +// and nobody can explain why. +func (i *Inventory) ForgetPerson(ctx context.Context, name string) error { + if name == "" { + return errors.New("forgetting nobody would forget everybody") + } + if _, err := i.store.Pool().Exec(ctx, `delete from person where name = $1`, name); err != nil { + return err + } + return i.ForgetBusUser(ctx, "person."+name) +} diff --git a/internal/inventory/migrations/0034-a-person-may-reach-the-mesh.sql b/internal/inventory/migrations/0034-a-person-may-reach-the-mesh.sql new file mode 100644 index 0000000..83b3082 --- /dev/null +++ b/internal/inventory/migrations/0034-a-person-may-reach-the-mesh.sql @@ -0,0 +1,19 @@ +-- A person who may call the mesh's tools from a workstation. +-- +-- novox/hq design 25 §7. Everything else that reaches the bus is a machine or a module running on +-- one; this is the exception the mesh has always had informally — somebody at a terminal — and never +-- recorded. Until now "the operator" meant whoever held the keys, which is a role and not a record, +-- so nothing could say who may call what. +-- +-- **The authority is a list of tools and nothing else.** A person is 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. That is why there is no scope column and no node +-- column — a person is not on a machine. +create table person ( + name text primary key, + -- The tools this person may invoke, each `.`, or the single entry `*` for an + -- administrator. Stored as given: the permission is derived from it at every composition, so a + -- normalised form here would be a second opinion about authority (novox/hq ADR 0043). + invokes text[] not null default '{}', + created timestamptz not null default now() +); diff --git a/internal/inventory/people_test.go b/internal/inventory/people_test.go new file mode 100644 index 0000000..1085d57 --- /dev/null +++ b/internal/inventory/people_test.go @@ -0,0 +1,120 @@ +package inventory + +import ( + "context" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/broker" +) + +// Somebody who may call the mesh's tools, and what the bus makes of them. + +// A person's authority is a list of tools, and it becomes exactly that on the bus — nothing on +// control, nothing on nodes, nothing they could publish as a module. +func TestAPersonMayCallToolsAndNothingElse(t *testing.T) { + inv := ForTest(t) + ctx := context.Background() + if err := inv.RecordPerson(ctx, Person{Name: "ada", + Invokes: []string{"mesh-catalog.catalog_tools"}}); err != nil { + t.Fatal(err) + } + + records, err := inv.BusRecords(ctx) + if err != nil { + t.Fatal(err) + } + if got := records.People["ada"]; len(got) != 1 || got[0] != "mesh-catalog.catalog_tools" { + t.Fatalf("ada may call %v", got) + } + + users, err := broker.Users(records) + if err != nil { + t.Fatal(err) + } + var found bool + for _, u := range users { + if u.Username() != "person.ada" { + continue + } + found = true + perms, err := broker.PermissionsFor(u) + if err != nil { + t.Fatal(err) + } + if len(perms.Publish) != 1 || perms.Publish[0] != "mesh.mod.mesh-catalog.tool.catalog_tools" { + t.Errorf("ada may publish %v, which should be the one tool and nothing else", perms.Publish) + } + for _, s := range perms.Publish { + if strings.HasPrefix(s, "mesh.control") || strings.HasPrefix(s, "mesh.node") || + strings.Contains(s, ".event.") { + t.Errorf("a person may publish %s — an event would let them claim a module said "+ + "something, and control is not theirs", s) + } + } + if perms.AllowResponses { + t.Error("a person may answer a request, which is impersonating a module on a bus where " + + "anyone may serve a tool") + } + } + if !found { + t.Fatal("no bus user was derived for a recorded person") + } +} + +// Stating what somebody may call replaces what was there. A list that could only grow is a permission +// nobody can take back. +func TestChangingWhatAPersonMayCallRemovesWhatIsNotNamed(t *testing.T) { + inv := ForTest(t) + ctx := context.Background() + if err := inv.RecordPerson(ctx, Person{Name: "ada", Invokes: []string{"a.one", "b.two"}}); err != nil { + t.Fatal(err) + } + if err := inv.RecordPerson(ctx, Person{Name: "ada", Invokes: []string{"a.one"}}); err != nil { + t.Fatal(err) + } + people, err := inv.People(ctx) + if err != nil { + t.Fatal(err) + } + if len(people) != 1 || len(people[0].Invokes) != 1 || people[0].Invokes[0] != "a.one" { + t.Fatalf("ada may call %v; the removed tool is still there", people) + } +} + +// Somebody who may call nothing is refused: there is no reason for them to reach the mesh, and an +// empty list is more likely a mistake than an intention. +func TestSomebodyWhoMayCallNothingIsRefused(t *testing.T) { + inv := ForTest(t) + if err := inv.RecordPerson(context.Background(), Person{Name: "ada"}); err == nil { + t.Fatal("somebody who may call nothing was recorded") + } +} + +// Forgetting somebody takes their credential with them. **Both, or it is not a revocation**: a +// person's row gone and their bus user left behind is a credential that still works and that nothing +// derives. +func TestForgettingAPersonTakesTheirCredential(t *testing.T) { + inv := ForTest(t) + ctx := context.Background() + if err := inv.RecordPerson(ctx, Person{Name: "ada", Invokes: []string{"a.one"}}); err != nil { + t.Fatal(err) + } + if _, err := inv.MintBusPassword(ctx, BusUser{Username: "person.ada", Kind: BusPerson}); err != nil { + t.Fatal(err) + } + + if err := inv.ForgetPerson(ctx, "ada"); err != nil { + t.Fatal(err) + } + if _, known, err := inv.BusUserHash(ctx, "person.ada"); err != nil || known { + t.Fatalf("a forgotten person's credential still works: %v %v", known, err) + } + records, err := inv.BusRecords(ctx) + if err != nil { + t.Fatal(err) + } + if _, still := records.People["ada"]; still { + t.Fatal("a forgotten person is still composed into the bus") + } +}