diff --git a/cmd/mesh-controller/desk_secret.go b/cmd/mesh-controller/desk_secret.go index 66fb9be9..a0da69d4 100644 --- a/cmd/mesh-controller/desk_secret.go +++ b/cmd/mesh-controller/desk_secret.go @@ -15,16 +15,23 @@ import ( "github.com/novox/mesh-controller/internal/secrets" ) -// A module's own secret given at the operator's desk (novox/hq ADR 0259 §10). +// A module's own secret given at the operator's desk (novox/hq ADR 0259 §10, ADR 0277). +// +// mesh-controller secret ask [--at ] // // **The value never passes through whoever asked for it.** An agent, or the operator at the mesh MCP -// server, calls `give` with the machine, the module, the secret's name and the desk — never a value. The -// controller makes a sealing keypair for this one call, asks the desk's `node-launcher.secret` to prompt the -// operator without showing what is typed, and is answered with what was typed **sealed to that key**: no -// plaintext on the bus, in a runtime's log or in any call's record. It opens it here, seals it to the +// server, calls `secret-ask` (or `give`) with the machine, the module, the secret's name and the desk — never +// a value. The controller makes a sealing keypair for this one call, asks the desk's `node-launcher.secret` to +// prompt the operator without showing what is typed, and is answered with what was typed **sealed to that +// key**: no plaintext on the bus, in a runtime's log or in any call's record. It opens it here, seals it to the // module's machine exactly as `secret accept` does, and forgets it. What it answers says only that the // value was taken, or why not. // +// **Bounded, and the prompt says who asked** (ADR 0277): one open prompt per secret and few an hour, read from +// the store before the prompt opens (inventory.OpenSecretAsk), so an agent cannot keep a prompt in front of the +// operator until they type. The prompt names the module, the secret, the machine and who asked — the caller as +// the bus named it, never a word the caller chose — written by the desk's launcher from those names alone. +// // **What remains** (ADR 0234's accepted residual risk): on an X11 desk any program of the operator's // account can read the keys as they are typed. And a program that calls the desk's prompt itself, with a // key of its own, is answered with what the operator typed into a prompt they did not ask for — as it could @@ -54,6 +61,37 @@ type deskGive struct { // announce raises the condition that says a module's own secret was given (secretGivenObservation), on // every channel; nil announces nothing (a test that does not look). announce func(node, module, name, how string) error + // askedBy is who asked, as the bus named the caller: said in the prompt and recorded. + askedBy string + // open records the ask and holds the bounds (one open per secret, few an hour), answering the record's id; + // nil keeps no record (a test that does not look). end closes it with how it ended. + open func(node, module, name, desk string) (int64, error) + end func(id int64, outcome string) error +} + +// askedByName is the caller as the prompt names it: the first clause of what the bus said, in the characters +// a name has, at most 80 of them. The desk's launcher refuses anything else, so no words of the caller's own +// reach the prompt. +func askedByName(caller string) string { + first, _, _ := strings.Cut(caller, ",") + var b strings.Builder + for _, r := range strings.TrimSpace(first) { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '_', r == '/', r == '@', + r == '-', r == ' ': + b.WriteRune(r) + default: + b.WriteRune('-') + } + if b.Len() >= 80 { + break + } + } + name := strings.TrimSpace(b.String()) + if name == "" || strings.HasPrefix(name, "-") { + return "an unnamed caller" + } + return name } // errNothingGiven is a prompt dismissed, or not answered in time: nothing changes. @@ -95,20 +133,37 @@ func (d deskGive) give(node, module, name, desk string) (string, error) { "bus, where an agent may answer first (novox/hq ADR 0259 §10)", module, name, node, module, name) } } + // The bounds, read and kept before anybody is asked to type (novox/hq ADR 0277): one open prompt per secret, + // few an hour. How the ask ends is recorded whatever happens below. + outcome := "failed" + if d.open != nil { + id, err := d.open(node, module, name, desk) + if err != nil { + return "", fmt.Errorf("nobody was asked to type anything: %w", err) + } + defer func() { + if d.end != nil { + _ = d.end(id, outcome) + } + }() + } public, private, err := secrets.Keypair() if err != nil { return "", fmt.Errorf("no key could be made to take the value: %w", err) } // By name, never by words: the holder writes the prompt from these, and says the controller asks, which - // the bus alone makes true (broker.ControllerOnly). + // the bus alone makes true (broker.ControllerOnly). Who asked is the bus's word on the caller, cut to a + // name's characters — never an argument of the call. raw, err := d.ask(desk, map[string]any{ "module": module, "secret": name, "node": node, + "asked_by": askedByName(d.askedBy), "seal_to": public, "timeout_seconds": deskPromptWithin, }) if err != nil { + outcome = "refused" return "", fmt.Errorf("the desk on %s could not be asked: %w", desk, err) } var answer struct { @@ -121,8 +176,10 @@ func (d deskGive) give(node, module, name, desk string) (string, error) { } switch { case answer.TimedOut: + outcome = "timed-out" return "", fmt.Errorf("%w: the prompt on %s was not answered within %d seconds", errNothingGiven, desk, deskPromptWithin) case answer.Cancelled: + outcome = "dismissed" return "", fmt.Errorf("%w: the prompt on %s was dismissed", errNothingGiven, desk) case answer.Sealed == "": return "", fmt.Errorf("the desk on %s answered no sealed value", desk) @@ -137,6 +194,7 @@ func (d deskGive) give(node, module, name, desk string) (string, error) { opened[i] = 0 } if strings.TrimSpace(value) == "" { + outcome = "empty" return "", fmt.Errorf("%w: the prompt on %s was answered empty", errNothingGiven, desk) } untilStart, err := d.accept(value) @@ -144,8 +202,10 @@ func (d deskGive) give(node, module, name, desk string) (string, error) { if err != nil { return "", err } + outcome = "given" act := link.HandAct{Verb: "secret accept", Args: []string{node, module, name, "--at-desk", desk}, - Why: fmt.Sprintf("the operator gave %s for %s on %s at the desk on %s", name, module, node, desk), + Why: fmt.Sprintf("the operator gave %s for %s on %s at the desk on %s, asked by %s", name, module, node, desk, + askedByName(d.askedBy)), Cause: "given-at-the-desk"} recorded := "" if err := d.record(act); err != nil { @@ -165,15 +225,23 @@ func (d deskGive) give(node, module, name, desk string) (string, error) { return words + recorded, nil } -// giveAtDesk is `secret accept --at-desk `: the desk path, on this -// controller's stores and bus. -func giveAtDesk(ctx context.Context, node, module, name, desk string) error { +// askAtDesk is `secret ask [--at ]`, and the terminal's `secret accept … --at-desk +// `: the desk path, on this controller's stores and bus. The desk is the module's machine unless named. +func askAtDesk(ctx context.Context, node, module, name, desk string) error { + if desk == "" { + desk = node + } open, err := openStores(ctx) if err != nil { return err } defer open.Close() d := deskGive{ + askedBy: link.Caller(), + open: func(node, module, name, desk string) (int64, error) { + return open.inventory.OpenSecretAsk(ctx, node, module, name, askedByName(link.Caller()), desk) + }, + end: func(id int64, outcome string) error { return open.inventory.EndSecretAsk(ctx, id, outcome) }, declares: func(module, name string) error { return open.inventory.DeclaresOwnSecret(ctx, module, name) }, known: func(machine string) error { _, err := open.inventory.NodeByName(ctx, machine) diff --git a/cmd/mesh-controller/desk_secret_test.go b/cmd/mesh-controller/desk_secret_test.go index 50015daf..170df795 100644 --- a/cmd/mesh-controller/desk_secret_test.go +++ b/cmd/mesh-controller/desk_secret_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "strings" "testing" "time" @@ -124,12 +125,21 @@ func TestADismissedEmptyLateOrForeignAnswerTakesNothing(t *testing.T) { func TestTheGiveVerbRunsTheDeskPathAndTheControllerMayAskTheDesk(t *testing.T) { argv, err := argvFor("give", map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token", "at": "laptop"}) - if err != nil || strings.Join(argv, " ") != "secret accept anchor telegram telegram-token --at-desk laptop" { + if err != nil || strings.Join(argv, " ") != "secret ask anchor telegram telegram-token --at laptop" { t.Fatalf("%v %v", argv, err) } if _, err := argvFor("give", map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token"}); err == nil { t.Error("give without a desk was taken") } + // secret-ask is the same line, with the desk the module's machine unless named (novox/hq ADR 0277). + argv, err = argvFor("secret-ask", map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token"}) + if err != nil || strings.Join(argv, " ") != "secret ask anchor telegram telegram-token" { + t.Fatalf("%v %v", argv, err) + } + argv, err = argvFor("secret-ask", map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token", "at": "laptop"}) + if err != nil || strings.Join(argv, " ") != "secret ask anchor telegram telegram-token --at laptop" { + t.Fatalf("%v %v", argv, err) + } perms, err := broker.PermissionsFor(broker.Principal{Kind: broker.KindController}) if err != nil { t.Fatal(err) @@ -266,13 +276,22 @@ func TestASecretValueIsNeverAcceptedThroughAVerb(t *testing.T) { } } -// The `give` verb's own line passes the terminal-only rule of ADR 0266, and no other `secret accept` does: a -// value, a file, a provider or an extra word is still the terminal's alone. +// The `give` and `secret-ask` verbs' own line passes the terminal-only rule of ADR 0266, and no `secret accept` +// does: a value, a file, a provider or an extra word is still the terminal's alone (novox/hq ADR 0277). func TestOnlyTheGiveLinePassesTheTerminalRuleForSecrets(t *testing.T) { - if err := terminalOnly([]string{"secret", "accept", "anchor", "telegram", "telegram-token", "--at-desk", "laptop"}); err != nil { - t.Errorf("give's line refused: %v", err) + for _, argv := range [][]string{ + {"secret", "ask", "anchor", "telegram", "telegram-token", "--at", "laptop"}, + {"secret", "ask", "anchor", "telegram", "telegram-token"}, + } { + if err := terminalOnly(argv); err != nil { + t.Errorf("%v refused: %v", argv, err) + } } for _, argv := range [][]string{ + {"secret", "accept", "anchor", "telegram", "telegram-token", "--at-desk", "laptop"}, + {"secret", "ask", "anchor", "telegram", "telegram-token", "--from", "/tmp/x"}, + {"secret", "ask", "anchor", "telegram", "telegram-token", "--at", "laptop", "--local"}, + {"secret", "ask", "anchor", "telegram", "--at", "laptop"}, {"secret", "accept", "anchor", "telegram", "telegram-token"}, {"secret", "accept", "anchor", "telegram", "telegram-token", "--from", "/tmp/x"}, {"secret", "accept", "anchor", "telegram", "telegram-token", "--at-desk", "laptop", "--local"}, @@ -398,3 +417,96 @@ func TestAGiveNamingAMachineTheMeshDoesNotKnowAsksNobody(t *testing.T) { } } } + +// novox/hq ADR 0277: the prompt names who asked — the controller's word on the bus's caller, cut to a name's +// characters — and never a word the caller chose: there is no argument for it. +func TestThePromptNamesWhoAskedFromTheBussWordAlone(t *testing.T) { + d, _, acts, asked := aDesk(t, sealedTo(t, typed)) + d.askedBy = "g14/claude-code, through the mesh-controller seat" + if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); err != nil { + t.Fatal(err) + } + if got := (*asked)[0]["asked_by"]; got != "g14/claude-code" { + t.Errorf("asked_by %q", got) + } + if len(*acts) != 1 || !strings.Contains((*acts)[0].Why, "asked by g14/claude-code") { + t.Errorf("the record: %+v", *acts) + } + for in, want := range map[string]string{ + "jochen at a shell on novox": "jochen at a shell on novox", + "laptop/agent": "laptop/agent", + "Your bank asks\nType your PIN, now": "Your bank asks-Type your PIN", + "": "an unnamed caller", + "x": "an unnamed caller", + strings.Repeat("a", 100): strings.Repeat("a", 80), + "--prompt, something else": "an unnamed caller", + } { + if got := askedByName(in); got != want { + t.Errorf("askedByName(%q) = %q, want %q", in, got, want) + } + } + // The verb's schema has no argument that reaches the prompt's words. + for _, verb := range []string{"give", "secret-ask"} { + for _, free := range []string{"asked_by", "prompt", "message", "value", "from"} { + if _, err := argvFor(verb, map[string]any{"node": "anchor", "module": "telegram", "secret": "telegram-token", + "at": "laptop", free: "x"}); err == nil { + t.Errorf("%s takes %s", verb, free) + } + } + } +} + +// An ask for a secret is bounded before anybody is asked to type (ADR 0277): the record refuses it, nothing is +// asked; and how every ask ends is recorded. +func TestASecretAskIsBoundedAndItsEndRecorded(t *testing.T) { + d, accepted, _, asked := aDesk(t, sealedTo(t, typed)) + var ended []string + d.open = func(node, module, name, desk string) (int64, error) { return 7, nil } + d.end = func(id int64, outcome string) error { + ended = append(ended, fmt.Sprintf("%d %s", id, outcome)) + return nil + } + if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); err != nil { + t.Fatal(err) + } + d.open = func(node, module, name, desk string) (int64, error) { + return 0, errors.New("an ask for telegram-token of telegram on anchor is still open") + } + _, err := d.give("anchor", "telegram", "telegram-token", "laptop") + if err == nil || !strings.Contains(err.Error(), "nobody was asked to type anything") || !strings.Contains(err.Error(), "still open") { + t.Errorf("a second ask: %v", err) + } + if len(*asked) != 1 || len(*accepted) != 1 { + t.Errorf("asked %d, accepted %d", len(*asked), len(*accepted)) + } + d.open = func(node, module, name, desk string) (int64, error) { return 8, nil } + d.ask = func(string, map[string]any) (json.RawMessage, error) { + return json.RawMessage(`{"cancelled":true}`), nil + } + if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); !errors.Is(err, errNothingGiven) { + t.Errorf("a dismissed prompt: %v", err) + } + if strings.Join(ended, "; ") != "7 given; 8 dismissed" { + t.Errorf("ended: %v", ended) + } +} + +// A secret ask cannot be turned into a secret read: the line carries no value, the answer carries none, and every +// other `secret` line is the terminal's. +func TestASecretAskIsNeverASecretRead(t *testing.T) { + t.Setenv(verbVar, "mesh-controller.secret-ask") + for _, args := range [][]string{ + {"ask", "anchor", "telegram", "telegram-token", "the-value"}, + {"ask", "anchor", "telegram"}, + {"ask", "anchor", "telegram", "telegram-token", "--from", "/dev/null"}, + } { + if err := secretCommand(context.Background(), args); err == nil { + t.Errorf("secret %v was taken", args) + } + } + for _, args := range [][]string{{"recover", "anchor", "telegram", "telegram-token"}, {"export"}} { + if err := terminalOnly(append([]string{"secret"}, args...)); err == nil { + t.Errorf("secret %v passed the terminal rule", args) + } + } +} diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index ddbdb56d..d3979dfc 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -768,10 +768,22 @@ func (a *verbArguments) commandLine() ([]string, error) { } return append(argv, "--json"), nil case "give": + // The same line as secret-ask with the desk named (novox/hq ADR 0277): one path, one set of bounds. if err := need("node", "module", "secret", "at"); err != nil { return nil, err } - return []string{"secret", "accept", str("node"), str("module"), str("secret"), "--at-desk", str("at")}, nil + return []string{"secret", "ask", str("node"), str("module"), str("secret"), "--at", str("at")}, nil + case "secret-ask": + // A module's own secret asked for, typed by the operator at the desk (novox/hq ADR 0277): never a value + // in the arguments. The desk is the module's machine unless at names another. + if err := need("node", "module", "secret"); err != nil { + return nil, err + } + argv := []string{"secret", "ask", str("node"), str("module"), str("secret")} + if at := str("at"); at != "" { + argv = append(argv, "--at", at) + } + return argv, nil case "rotate": if p := str("provision"); p != "" { argv := []string{"rotate", p} @@ -1538,10 +1550,11 @@ var terminalOnlyCommands = map[string]string{ "licence": "the licences' secrets", } -// givenAtTheDesk is exactly the line the `give` verb composes, and nothing beside it: `secret accept -// --at-desk `, with no other word — no value, no file, no provider. +// givenAtTheDesk is exactly the line the `give` and `secret-ask` verbs compose, and nothing beside it: `secret ask +// `, with `--at ` or no other word — no value, no file, no provider (novox/hq +// ADR 0277). The terminal's own `secret accept … --at-desk` is the terminal's. func givenAtTheDesk(argv []string) bool { - if len(argv) != 7 || argv[0] != "secret" || argv[1] != "accept" || argv[5] != "--at-desk" { + if (len(argv) != 5 && len(argv) != 7) || argv[0] != "secret" || argv[1] != "ask" { return false } for _, w := range argv[2:5] { @@ -1549,7 +1562,10 @@ func givenAtTheDesk(argv []string) bool { return false } } - return argv[6] != "" && !strings.HasPrefix(argv[6], "-") + if len(argv) == 5 { + return true + } + return argv[5] == "--at" && argv[6] != "" && !strings.HasPrefix(argv[6], "-") } // terminalOnly refuses, through any verb, a command that is the operator's at the controller's terminal diff --git a/cmd/mesh-controller/seatverbs_schema_test.go b/cmd/mesh-controller/seatverbs_schema_test.go index 2a6a6410..bf3d8e2d 100644 --- a/cmd/mesh-controller/seatverbs_schema_test.go +++ b/cmd/mesh-controller/seatverbs_schema_test.go @@ -276,12 +276,6 @@ var accountedFlags = map[string]map[string]string{ "all": "withheld: every measurement of a fortnight is more than a call should carry; `command` reaches it", }, // The desk path of `secret accept` (novox/hq ADR 0259 §10): a value is never an argument of a call. - "secret accept": { - "at-desk": "=at", - "from": "withheld: a file of the control node's is read at a shell, never named by a call", - "provider": "withheld: a pair credential's value is given at a shell; give takes a module's own secret", - "local": "withheld: it goes with --provider", - }, "hand-acts": {"json": "set by the verb: the answer is data"}, "conditions": {"json": "set by the verb: the answer is data"}, "retire": {"json": "set by the verb: the answer is data"}, diff --git a/cmd/mesh-controller/secret.go b/cmd/mesh-controller/secret.go index bcb3dd46..37c3cc3c 100644 --- a/cmd/mesh-controller/secret.go +++ b/cmd/mesh-controller/secret.go @@ -39,6 +39,8 @@ func secretCommand(ctx context.Context, args []string) error { } switch args[0] { case "accept": + case "ask": + return secretAsk(ctx, args[1:]) case "rotate": return secretRotate(ctx, args[1:]) case "recover": @@ -78,7 +80,7 @@ func secretCommand(ctx context.Context, args []string) error { if *from != "" || *provider != "" { return errors.New("--at-desk gives a module's own secret, and takes neither --from nor --provider") } - return giveAtDesk(ctx, node, module, name, *desk) + return askAtDesk(ctx, node, module, name, *desk) } value, err := valueFor(node, module, name, *from) @@ -148,7 +150,24 @@ func secretCommand(ctx context.Context, args []string) error { return nil } +// secretAsk is `secret ask [--at ]` (novox/hq ADR 0277): the operator is asked +// for a module's own secret in a prompt at the desk, which only they answer. The one `secret` line a verb may +// run beside rotate (givenAtTheDesk): it carries no value and answers none. +func secretAsk(ctx context.Context, args []string) error { + rest, flags := split(args) + set := flag.NewFlagSet("secret ask", flag.ContinueOnError) + at := set.String("at", "", "the machine the operator sits at, where the prompt opens; the module's machine when absent") + if err := set.Parse(flags); err != nil { + return err + } + if len(rest) != 3 { + return errors.New("secret ask [--at ]") + } + return askAtDesk(ctx, rest[0], rest[1], rest[2], *at) +} + const secretUsage = "secret rotate [--why [--cause ]]\n" + + "secret ask [--at ]\n" + "secret accept [--from | --at-desk ] [--provider [--local ]]\n" + "secret recover --key [--out ] [--from-export ] [--provider ]\n" + "secret export [--out ]" diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index f7757c65..9541db55 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -231,17 +231,32 @@ var ControllerVerbs = []Verb{ "cause": "with why: the cause in a word, the word a second rotation for the same reason uses (optional)", }, nil)}, {Name: "give", Description: "Take a module's own secret from the operator at their desk (novox/hq ADR 0259 " + - "§10): a prompt that does not show what is typed opens on the machine named by at, its answer comes " + - "back sealed to this call alone, and is sealed to the module's machine as `secret accept` seals it. " + - "The value is never an argument and never in the answer: the answer says it was taken, or why not. " + - "Recorded in the hand-act log as a value given at the desk. The prompt waits 25 seconds; dismissed " + - "or unanswered, nothing changes. Then push the machine.", + "§10): the same as secret-ask with the desk named. A prompt that does not show what is typed opens on " + + "the machine named by at, its answer comes back sealed to this call alone, and is sealed to the " + + "module's machine as `secret accept` seals it. The value is never an argument and never in the answer: " + + "the answer says it was taken, or why not. Recorded in the hand-act log as a value given at the desk. " + + "The prompt waits 25 seconds; dismissed or unanswered, nothing changes. Then push the machine.", Input: schema(map[string]string{ "node": "the machine the module runs on, which the secret is sealed to", "module": "the module's name", "secret": "the own secret's name in the module's definition", "at": "the machine the operator sits at, where the prompt opens", }, []string{"node", "module", "secret", "at"})}, + {Name: "secret-ask", Description: "Ask the operator for a module's own secret (novox/hq ADR 0277): a prompt " + + "that shows nothing of what is typed opens at the desk — the module's machine, or the one at names — " + + "naming the module, the secret and who asked; the operator types the value there, never on a channel " + + "and never in a verb's argument; the answer comes back sealed to this call alone and is sealed to the " + + "module's machine as `secret accept` seals it. The answer says the value was taken, or why not. " + + "Refused for a secret the mesh issues itself (the bus account, one the mesh may make) and for a module " + + "that runs as an account of its own, whose value is typed at the controller's terminal. Bounded: one " + + "open prompt per secret, three asks an hour. Recorded in the hand-act log, with who asked, and " + + "announced on every channel. Then push the machine.", + Input: schema(map[string]string{ + "node": "the machine the module runs on, which the secret is sealed to", + "module": "the module's name", + "secret": "the own secret's name in the module's definition", + "at": "the machine the operator sits at, where the prompt opens; the module's machine when absent", + }, []string{"node", "module", "secret"})}, {Name: "issue", Description: "Give a module on a machine its account on the bus: minted, and sealed to the " + "machine as the module's own secret named broker, read at the next push of that machine. For a module " + "whose definition declares that secret; refused with the reason otherwise. Issued again, it replaces the account.", diff --git a/internal/inventory/migrations/0091-a-secret-ask-is-bounded.sql b/internal/inventory/migrations/0091-a-secret-ask-is-bounded.sql new file mode 100644 index 00000000..302ba0ad --- /dev/null +++ b/internal/inventory/migrations/0091-a-secret-ask-is-bounded.sql @@ -0,0 +1,18 @@ +-- A module's own secret asked for at a desk (novox/hq ADR 0277). +-- +-- Every ask for a module's own secret at a desk: who asked, for which secret of which module on which +-- machine, at which desk, and how it ended. Read before a prompt opens, so that one open ask per secret and +-- few per hour hold: an agent that keeps a prompt in front of the operator until they type is refused. +create table secret_ask ( + id bigserial primary key, + node uuid not null references node(id) on delete cascade, + module text not null, + name text not null, + asked_by text not null, + desk text not null, + opened_at timestamptz not null default now(), + ended_at timestamptz, + -- given · dismissed · timed-out · empty · refused · failed + outcome text +); +create index secret_ask_by_secret on secret_ask (node, module, name, opened_at desc); diff --git a/internal/inventory/secret_ask.go b/internal/inventory/secret_ask.go new file mode 100644 index 00000000..62386d3f --- /dev/null +++ b/internal/inventory/secret_ask.go @@ -0,0 +1,110 @@ +package inventory + +import ( + "context" + "fmt" + "time" +) + +// An ask for a module's own secret at a desk (novox/hq ADR 0277, migration 0091): every one is recorded +// before the prompt opens, and the bounds are read from the record. A verb may ask the operator to type a +// secret; it may not keep a prompt in front of them. **One open ask per secret, and few per hour.** + +// The bounds of asking for one secret. +const ( + // SecretAskOpenFor is how long an ask that has not ended counts as open: longer than any prompt waits, + // so a process that died with its prompt does not hold the secret for ever. + SecretAskOpenFor = 2 * time.Minute + // SecretAsksPerHour is how many asks for one secret an hour takes. + SecretAsksPerHour = 3 +) + +// OpenSecretAsk records that an ask for a module's own secret on a machine opens at a desk, or refuses it in +// words when one is still open for that secret or the hour's asks are spent. It answers the record's id, which +// EndSecretAsk closes. +func (i *Inventory) OpenSecretAsk(ctx context.Context, node, module, name, askedBy, desk string) (int64, error) { + record, err := i.NodeByName(ctx, node) + if err != nil { + return 0, err + } + tx, err := i.store.Pool().Begin(ctx) + if err != nil { + return 0, err + } + defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }() + // Serialised per secret, so two asks at once do not both pass the count. + if _, err := tx.Exec(ctx, `select pg_advisory_xact_lock(hashtext($1))`, node+"/"+module+"/"+name); err != nil { + return 0, err + } + var open int + var openBy string + if err := tx.QueryRow(ctx, + `select count(*), coalesce(min(asked_by), '') from secret_ask + where node = $1 and module = $2 and name = $3 and ended_at is null and opened_at > now() - $4::interval`, + record.ID, module, name, SecretAskOpenFor.String()).Scan(&open, &openBy); err != nil { + return 0, err + } + if open > 0 { + return 0, fmt.Errorf("an ask for %s of %s on %s is still open (asked by %s): one prompt at a time for a secret, "+ + "and this one ends within %s", name, module, node, openBy, SecretAskOpenFor) + } + var lastHour int + if err := tx.QueryRow(ctx, + `select count(*) from secret_ask + where node = $1 and module = $2 and name = $3 and opened_at > now() - interval '1 hour'`, + record.ID, module, name).Scan(&lastHour); err != nil { + return 0, err + } + if lastHour >= SecretAsksPerHour { + return 0, fmt.Errorf("%s of %s on %s was asked for %d times in the last hour, and an hour takes %d asks for one "+ + "secret: the operator is not kept at a prompt", name, module, node, lastHour, SecretAsksPerHour) + } + var id int64 + if err := tx.QueryRow(ctx, + `insert into secret_ask (node, module, name, asked_by, desk) values ($1, $2, $3, $4, $5) returning id`, + record.ID, module, name, askedBy, desk).Scan(&id); err != nil { + return 0, err + } + return id, tx.Commit(ctx) +} + +// EndSecretAsk closes an ask with how it ended: given, dismissed, timed-out, empty, refused or failed. +func (i *Inventory) EndSecretAsk(ctx context.Context, id int64, outcome string) error { + _, err := i.store.Pool().Exec(ctx, + `update secret_ask set ended_at = now(), outcome = $2 where id = $1 and ended_at is null`, id, outcome) + return err +} + +// SecretAsk is one recorded ask for a module's own secret. +type SecretAsk struct { + ID int64 + Node string + Module string + Name string + AskedBy string + Desk string + OpenedAt time.Time + EndedAt *time.Time + Outcome string +} + +// SecretAsks is every ask for secrets since a moment, newest first. +func (i *Inventory) SecretAsks(ctx context.Context, since time.Time) ([]SecretAsk, error) { + rows, err := i.store.Pool().Query(ctx, + `select a.id, n.name, a.module, a.name, a.asked_by, a.desk, a.opened_at, a.ended_at, coalesce(a.outcome, '') + from secret_ask a join node n on n.id = a.node + where a.opened_at >= $1 order by a.opened_at desc`, since) + if err != nil { + return nil, err + } + defer rows.Close() + var out []SecretAsk + for rows.Next() { + var a SecretAsk + if err := rows.Scan(&a.ID, &a.Node, &a.Module, &a.Name, &a.AskedBy, &a.Desk, &a.OpenedAt, &a.EndedAt, &a.Outcome); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} diff --git a/internal/inventory/secret_ask_test.go b/internal/inventory/secret_ask_test.go new file mode 100644 index 00000000..51a21af5 --- /dev/null +++ b/internal/inventory/secret_ask_test.go @@ -0,0 +1,60 @@ +package inventory + +import ( + "strings" + "testing" + "time" +) + +// An ask for a module's own secret at a desk is bounded by the record (novox/hq ADR 0277): one open per secret, +// three an hour, and every one says who asked and how it ended. +func TestASecretAskIsOneAtATimeAndFewAnHour(t *testing.T) { + inv := ForTest(t) + ctx := t.Context() + if _, err := inv.AddNode(ctx, "shanks"); err != nil { + t.Fatal(err) + } + first, err := inv.OpenSecretAsk(ctx, "shanks", "mounts", "smb-credentials", "g14/claude-code", "shanks") + if err != nil { + t.Fatal(err) + } + if _, err := inv.OpenSecretAsk(ctx, "shanks", "mounts", "smb-credentials", "laptop/agent", "shanks"); err == nil || + !strings.Contains(err.Error(), "still open") || !strings.Contains(err.Error(), "g14/claude-code") { + t.Errorf("a second ask while one is open: %v", err) + } + // Another secret is its own. + other, err := inv.OpenSecretAsk(ctx, "shanks", "mounts", "other", "laptop/agent", "shanks") + if err != nil { + t.Fatal(err) + } + if err := inv.EndSecretAsk(ctx, other, "dismissed"); err != nil { + t.Fatal(err) + } + if err := inv.EndSecretAsk(ctx, first, "given"); err != nil { + t.Fatal(err) + } + for i := 0; i < SecretAsksPerHour-1; i++ { + id, err := inv.OpenSecretAsk(ctx, "shanks", "mounts", "smb-credentials", "g14/claude-code", "shanks") + if err != nil { + t.Fatalf("ask %d: %v", i+2, err) + } + if err := inv.EndSecretAsk(ctx, id, "timed-out"); err != nil { + t.Fatal(err) + } + } + if _, err := inv.OpenSecretAsk(ctx, "shanks", "mounts", "smb-credentials", "g14/claude-code", "shanks"); err == nil || + !strings.Contains(err.Error(), "times in the last hour") { + t.Errorf("a fourth ask in an hour: %v", err) + } + if _, err := inv.OpenSecretAsk(ctx, "nowhere", "mounts", "smb-credentials", "x", "nowhere"); err == nil { + t.Error("a machine the mesh does not know was taken") + } + asks, err := inv.SecretAsks(ctx, time.Now().Add(-time.Hour)) + if err != nil || len(asks) != SecretAsksPerHour+1 { + t.Fatalf("%d asks, %v", len(asks), err) + } + if a := asks[len(asks)-1]; a.Node != "shanks" || a.Module != "mounts" || a.Name != "smb-credentials" || a.AskedBy != "g14/claude-code" || + a.Outcome != "given" || a.EndedAt == nil { + t.Errorf("the first ask: %+v", a) + } +} diff --git a/module.json b/module.json index 9140f672..ba7c0ad5 100644 --- a/module.json +++ b/module.json @@ -76,6 +76,7 @@ "hand-act", "drill", "warranted", + "secret-ask", "hand-acts", "durations", "conditions",