A module's own secret is asked for by a verb and typed at the desk, bounded, the prompt naming who asked (hq ADR 0277)
The give verb opened the desk's hidden prompt for a module's own secret, with the desk named. Now the secret-ask verb (secret ask <node> <module> <name> [--at <desk>]) opens the same prompt from anywhere on the mesh, with the desk the module's machine unless named, and give composes the same line. Every ask is recorded in the store before the prompt opens (migration 0091): one open ask per secret, three an hour, so an agent cannot keep a prompt in front of the operator. The prompt names who asked, from the bus's word on the caller cut to a name's characters, never an argument of the call. The value stays typed at the desk, sealed to the one call and then to the module's machine, never in an argument, a log or an event; a secret the mesh makes itself and a trusted party's secret are refused as before.
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user