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.
This commit is contained in:
@@ -2,12 +2,15 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
"github.com/novox/mesh-controller/internal/secrets"
|
"github.com/novox/mesh-controller/internal/secrets"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,9 +29,25 @@ import (
|
|||||||
// operator key make [--out <file>] make a keypair: private half to the file, public half printed
|
// 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 set <public> tell the mesh which key to seal to
|
||||||
// operator key show the public key, its fingerprint, and what it can recover
|
// 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"
|
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 {
|
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" {
|
if len(args) < 2 || args[0] != "key" {
|
||||||
return errors.New(operatorUsage)
|
return errors.New(operatorUsage)
|
||||||
}
|
}
|
||||||
@@ -160,3 +179,125 @@ func readPrivateKey(path string) (string, error) {
|
|||||||
}
|
}
|
||||||
return strings.TrimSpace(string(raw)), nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,8 +80,13 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) {
|
|||||||
}
|
}
|
||||||
out.Enrolling = enrolling
|
out.Enrolling = enrolling
|
||||||
|
|
||||||
// People are not recorded yet: the account model is built (design 25 §7's first item) and
|
people, err := i.People(ctx)
|
||||||
// `operator issue` is not, so there is nobody to derive. Left empty rather than guessed at.
|
if err != nil {
|
||||||
|
return broker.Records{}, err
|
||||||
|
}
|
||||||
|
for _, p := range people {
|
||||||
|
out.People[p.Name] = p.Invokes
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
u.Username, u.Kind, u.Node, u.Module, string(hash))
|
||||||
return err
|
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 `<module>.<tool>`, 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 `<module>.<tool>`, 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()
|
||||||
|
);
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user