Files
jschoubben e8aa7ed9e7 The move mints every credential and tells each machine its membership
`rollout mint` gives every principal the new bus will have a credential it does
not yet have and puts each where its owner reads it: a machine's as a membership
— bus address, fingerprint, password, transport — sealed into its declaration
(migration 0041, the `bus-membership` resource the host reads after applying); a
module's as its broker secret, through the same delivery `module issue` uses; the
control plane's own as its `bus` secret. Idempotent, and worked out from where the
bus's module is assigned rather than from this process's environment, because this
process is still on the old bus when it runs and must be.

This is the half of design 28 task 5.2 the first live attempt found missing: a
credential was minted only at enrolment, at `module issue` and for a person, so no
machine already enrolled could ever be moved. `rollout check` was right to refuse;
now there is something to run first.
2026-09-28 00:16:24 +02:00

265 lines
10 KiB
Go

package inventory
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
)
// The bus's own users, as records.
//
// **Only the credential is kept here.** A user's *authority* is derived from what its module
// declares, every time the file is written (novox/hq ADR 0043) — a stored copy of a permission list
// would be a second account of a user's authority, able to disagree with the first, and the
// disagreement would be invisible until somebody compared a composed file with a manifest.
//
// What cannot be derived is the password, and on the bus being built it has to outlive its own
// minting: the whole user list is one file, rewritten whenever any of it changes, so a person's
// access change would blank every module's password if the mesh kept nothing (design 25 §4, and the
// migration beside this).
// BusUser is one user of the bus, as the mesh records it.
type BusUser struct {
Username string
Kind string
Node string
Module string
// PasswordHash is what the composed file carries. The plaintext is returned once, by Mint, and
// then exists only where it was sealed.
PasswordHash string
}
// The kinds of bus user the mesh records. The same words the composer uses, so a row and a
// principal do not need a translation table between them.
const (
BusController = "controller"
BusNode = "node"
BusModule = "module"
BusEnrolment = "enrolment"
BusPerson = "person"
)
// MintBusPassword makes a bus password and records its hash under a username, replacing whatever was
// there, and returns the plaintext **once**.
//
// **Once is the whole contract.** The caller seals it to whoever will use it — into an enrolment
// reply, into a module's sealed environment — and the mesh keeps only the hash, so a credential is
// never recoverable from the store. A caller that loses it must mint again, which is a rotation and
// is meant to feel like one.
func (i *Inventory) MintBusPassword(ctx context.Context, u BusUser) (string, error) {
if u.Username == "" || u.Kind == "" {
return "", errors.New("a bus user needs a username and a kind")
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("cannot generate a bus password: %w", err)
}
password := base64.RawURLEncoding.EncodeToString(raw)
// The cost the server will pay on every connection. Left at the library's default rather than
// raised: a node reconnecting after a network blip pays it, and the mesh's own links reconnect
// far more often than a person logs in anywhere.
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("cannot hash a bus password: %w", err)
}
if _, err := i.store.Pool().Exec(ctx,
`insert into bus_user (username, kind, node, module, password_hash)
values ($1, $2, $3, $4, $5)
on conflict (username) do update
set kind = excluded.kind, node = excluded.node, module = excluded.module,
password_hash = excluded.password_hash, minted_at = now()`,
u.Username, u.Kind, u.Node, u.Module, string(hash)); err != nil {
return "", fmt.Errorf("cannot record the bus user %s: %w", u.Username, err)
}
return password, nil
}
// BusUsers is every user the composed file should contain, by username.
//
// Returned as a map because the composer asks by username: the principals are derived from records
// elsewhere, and this is only what each one's password is. A principal with no row here has no
// password, and the composer refuses it rather than writing a user anybody is.
func (i *Inventory) BusUsers(ctx context.Context) (map[string]BusUser, error) {
rows, err := i.store.Pool().Query(ctx,
`select username, kind, node, module, password_hash from bus_user order by username`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]BusUser{}
for rows.Next() {
var u BusUser
if err := rows.Scan(&u.Username, &u.Kind, &u.Node, &u.Module, &u.PasswordHash); err != nil {
return nil, err
}
out[u.Username] = u
}
return out, rows.Err()
}
// BusUserHash is one user's hash, or false when the mesh has never minted one for it.
func (i *Inventory) BusUserHash(ctx context.Context, username string) (string, bool, error) {
var hash string
err := i.store.Pool().QueryRow(ctx,
`select password_hash from bus_user where username = $1`, username).Scan(&hash)
if errors.Is(err, pgx.ErrNoRows) {
return "", false, nil
}
return hash, err == nil, err
}
// ForgetBusUser removes one user, so the next composition does not contain it.
//
// **Removal is what makes revocation real here.** On a bus with a management call, deleting an
// account ends its connections; here the credential stops working when the file no longer names it,
// which is the next composition — so forgetting the row and composing are one act, and a caller
// that does the first without the second has revoked nothing.
func (i *Inventory) ForgetBusUser(ctx context.Context, username string) error {
_, err := i.store.Pool().Exec(ctx, `delete from bus_user where username = $1`, username)
return err
}
// ForgetBusUsersOf removes every user belonging to one node — its host's, and every module assigned
// to it. What a forgotten node leaves behind on the bus is otherwise a set of credentials for a
// machine the mesh no longer knows.
func (i *Inventory) ForgetBusUsersOf(ctx context.Context, node string) error {
if node == "" {
return errors.New("forgetting the bus users of no node would forget every user that has none")
}
_, err := i.store.Pool().Exec(ctx, `delete from bus_user where node = $1`, node)
return err
}
// SeedBusUser records a hash of a credential the mesh did not mint, so a composition contains it.
//
// **Genesis is the reason this exists.** The controller's own user is created before the controller
// runs — by the installer, at a well-known bootstrap password, the way the store's and the old bus's
// are (`postgres:bootstrap`, `guest:guest`). Nothing minted it, so nothing recorded a hash for it, and
// the controller's first composition would leave itself out of the very file it was writing: a bus
// nothing can connect to, produced by the thing connected to it.
//
// Idempotent, and it does not overwrite. A credential the mesh *did* mint is the one that counts, so
// once there is a row this does nothing — otherwise a restart would put the bootstrap password back
// over a rotated one.
func (i *Inventory) SeedBusUser(ctx context.Context, u BusUser, password string) error {
if u.Username == "" || u.Kind == "" || password == "" {
return errors.New("a bus user needs a username, a kind and the credential it is using")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("cannot hash a bus password: %w", err)
}
_, err = i.store.Pool().Exec(ctx,
`insert into bus_user (username, kind, node, module, password_hash)
values ($1, $2, $3, $4, $5)
on conflict (username) do nothing`,
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 `<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)
}
// PutBusMembership records a machine's membership for the new bus, sealed to it (design 28, 5.2).
// Replaces any earlier one: a machine has one membership per bus, and re-minting is re-telling.
func (i *Inventory) PutBusMembership(ctx context.Context, nodeName, sealed string) error {
node, err := i.NodeByName(ctx, nodeName)
if err != nil {
return err
}
_, err = i.store.Pool().Exec(ctx,
`insert into bus_membership (node, sealed) values ($1, $2)
on conflict (node) do update set sealed = excluded.sealed, since = now()`, node.ID, sealed)
return err
}
// BusMemberships is every machine's sealed membership for the new bus, by node name.
func (i *Inventory) BusMemberships(ctx context.Context) (map[string]string, error) {
rows, err := i.store.Pool().Query(ctx,
`select n.name, b.sealed from bus_membership b join node n on n.id = b.node`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var name, sealed string
if err := rows.Scan(&name, &sealed); err != nil {
return nil, err
}
out[name] = sealed
}
return out, rows.Err()
}