mesh/delivery held for a person: merged without a passing check: only a person decides what follows
mesh/delivery-group group fix/1945-credential-when-row-without-seal held: a member is held: novox/mesh-controller@fbf14b68dbab (merged without a passing check:…
mesh/check checking fbf14b68dbab on 40034be479f8
Migration 0108 marks every standing account sealed, ace's stale mesh-build-go rows from 17:19 among them, and the forgetting ran only at a later unassign: the next assignment to ace would have issued nothing again. - The start's look first forgets, from each node's own resolution (assigned and required), the accounts of every module that no longer resolves on its node, and says each; only from a reading that holds assignments, and a node whose resolution is unknown is left alone. - Unassign forgets a module's accounts only once it no longer resolves on the node (a module another requires keeps running), and never the node's own provisioner account; the forgetting moved from the inventory to the act. - Forgetting holds each account's lock across its delete, so an issue in flight never writes a row back after it. - A module whose accounts are not all sealed is granted its sealed accounts alone, and its grant is dropped when none is: an older grant naming ace no longer lets the assigner want it. - An issue's locked section has a deadline of two minutes, since it holds a connection of the store's pool across the vault and the bus. Tests: cases in the existing tables. Research 072: the grants piece (memberships and credentials heard from placement).
430 lines
18 KiB
Go
430 lines
18 KiB
Go
package inventory
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"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).
|
|
|
|
// busPasswordCostForTest, when set, is the bcrypt cost a bus password is hashed at instead of the
|
|
// library's default. Only ForTest sets it, to bcrypt's least (novox/hq issue 591): at the default
|
|
// cost one hash takes most of a second under the race detector, and a hash at the least cost is the
|
|
// same kind of hash, checked by the same comparison and by the bus the same way.
|
|
var busPasswordCostForTest atomic.Int32
|
|
|
|
// busPasswordCost is the bcrypt cost a bus password is hashed at: the library's default, but in a test.
|
|
func busPasswordCost() int {
|
|
if c := busPasswordCostForTest.Load(); c != 0 {
|
|
return int(c)
|
|
}
|
|
return bcrypt.DefaultCost
|
|
}
|
|
|
|
// hashBusPassword is the hash a bus password is kept as, minted, recorded or seeded alike.
|
|
func hashBusPassword(password string) ([]byte, error) {
|
|
return bcrypt.GenerateFromPassword([]byte(password), busPasswordCost())
|
|
}
|
|
|
|
// 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
|
|
// Sealed says its credential reached its node: an issue's mint is not sealed until then (ADR 0543 §2).
|
|
Sealed bool
|
|
}
|
|
|
|
// 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"
|
|
// BusNodeTools is a machine's tool runtime (novox/hq ADR 0175): named like the module it
|
|
// stands for, recorded as what it is.
|
|
BusNodeTools = "node-tools"
|
|
// BusProvisioner is a machine's provisioner client (novox/hq ADR 0349 clause 11, design 57 P3): an account of
|
|
// its own, named for the machine.
|
|
BusProvisioner = "provisioner"
|
|
// BusProcess is one process of a module that declares bus (novox/hq ADR 0436 §1, §2): an account of its
|
|
// own, named for the machine, the module and the process.
|
|
BusProcess = "process"
|
|
)
|
|
|
|
// 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.
|
|
// RecordBusPassword records a hash for a password the caller already holds.
|
|
//
|
|
// **For the one credential the mesh does not choose**: an enrolment token's secret is the password
|
|
// of the user that presents it (novox/hq ADR 0004, design 25 §6), so the token cannot be given a
|
|
// minted password — it already has one, and the machine will connect with exactly that string.
|
|
// Everything else goes through Mint, which chooses and returns the plaintext once.
|
|
func (i *Inventory) RecordBusPassword(ctx context.Context, u BusUser, password string) error {
|
|
if u.Username == "" || u.Kind == "" {
|
|
return errors.New("a bus user needs a username and a kind")
|
|
}
|
|
if password == "" {
|
|
return errors.New("a bus user needs a password")
|
|
}
|
|
hash, err := hashBusPassword(password)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot hash a bus password: %w", err)
|
|
}
|
|
return i.writeBusUser(ctx, u, string(hash))
|
|
}
|
|
|
|
// writeBusUser is the row, whoever chose the password, taken as sealed.
|
|
func (i *Inventory) writeBusUser(ctx context.Context, u BusUser, hash string) error {
|
|
return i.writeBusUserSealed(ctx, u, hash, true)
|
|
}
|
|
|
|
// writeBusUserSealed is the row, sealed now or not yet.
|
|
func (i *Inventory) writeBusUserSealed(ctx context.Context, u BusUser, hash string, sealed bool) error {
|
|
if _, err := i.store.Pool().Exec(ctx,
|
|
`insert into bus_user (username, kind, node, module, password_hash, sealed_at)
|
|
values ($1, $2, $3, $4, $5, case when $6 then now() end)
|
|
on conflict (username) do update
|
|
set kind = excluded.kind, node = excluded.node, module = excluded.module,
|
|
password_hash = excluded.password_hash, minted_at = now(), sealed_at = excluded.sealed_at`,
|
|
u.Username, u.Kind, u.Node, u.Module, hash, sealed); err != nil {
|
|
return fmt.Errorf("cannot record the bus user %s: %w", u.Username, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LockBusAccount holds the store's lock on one account until the answered release is called, so two issuers of it
|
|
// (the assign act, the assignments' watch, a registration's process accounts) never interleave their look, mint and
|
|
// seal: the hash the bus holds is always the password sealed to the node (novox/hq ADR 0543 §2, issue 1945).
|
|
func (i *Inventory) LockBusAccount(ctx context.Context, username string) (func(), error) {
|
|
conn, err := i.store.Pool().Acquire(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the lock on %s cannot be taken: %w", username, err)
|
|
}
|
|
if _, err := conn.Exec(ctx, `select pg_advisory_lock(hashtext($1))`, "bus_user/"+username); err != nil {
|
|
conn.Release()
|
|
return nil, fmt.Errorf("the lock on %s cannot be taken: %w", username, err)
|
|
}
|
|
return func() {
|
|
_, _ = conn.Exec(context.WithoutCancel(ctx), `select pg_advisory_unlock(hashtext($1))`, "bus_user/"+username)
|
|
conn.Release()
|
|
}, nil
|
|
}
|
|
|
|
// ForgetBusAccountsOf forgets the bus accounts of module on node, and the credentials sealed to the node for them (its
|
|
// `broker`, each process's `broker-<id>`, and with nodeOwned its provisioner client's): a module that no longer resolves
|
|
// on a node keeps no account there, so an assignment again mints and seals afresh, never trusting a row whose credential
|
|
// the node no longer holds (issue 1945, live on 2026-10-25 at 20:04: ace's mesh-build-go rows from 17:19 outlived its
|
|
// unassignment, nothing was issued again, and the module found no credential). The provisioner client's account is the
|
|
// node's (`provisioner.<node>`), forgotten only with nodeOwned. Each account's lock is held across its delete, so an issue
|
|
// in flight never writes a row back after it. It answers the accounts forgotten.
|
|
func (i *Inventory) ForgetBusAccountsOf(ctx context.Context, node, module string, nodeOwned bool) ([]string, error) {
|
|
if node == "" || module == "" {
|
|
return nil, errors.New("forgetting the bus accounts of no node or no module would forget others' accounts")
|
|
}
|
|
rows, err := i.store.Pool().Query(ctx,
|
|
`select username from bus_user where node = $1 and module = $2 and kind <> $3 and ($4 or kind <> $5)
|
|
order by username`, node, module, BusController, nodeOwned, BusProvisioner)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var names []string
|
|
for rows.Next() {
|
|
var n string
|
|
if err := rows.Scan(&n); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
names = append(names, n)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, n := range names { // in order, one lock at a time per issuer: no two hold each other's
|
|
release, err := i.LockBusAccount(ctx, n)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer release()
|
|
}
|
|
tx, err := i.store.Pool().Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
|
|
if _, err := tx.Exec(ctx, `delete from bus_user where username = any($1)`, names); err != nil {
|
|
return nil, fmt.Errorf("the bus accounts of %s on %s are not forgotten: %w", module, node, err)
|
|
}
|
|
if _, err := tx.Exec(ctx,
|
|
`delete from module_secret s using node n
|
|
where s.node = n.id and n.name = $1 and s.module = $2
|
|
and (s.name = 'broker' or s.name like 'broker-%' or ($3 and s.name = 'provisioner-broker'))`,
|
|
node, module, nodeOwned); err != nil {
|
|
return nil, fmt.Errorf("the bus credentials of %s on %s are not forgotten: %w", module, node, err)
|
|
}
|
|
return names, tx.Commit(ctx)
|
|
}
|
|
|
|
// MintBusPasswordUnsealed mints u's password, replacing whatever stands, with no seal yet: the account is not issued
|
|
// until MarkBusAccountSealed says its credential reached the node (ADR 0543 §2).
|
|
func (i *Inventory) MintBusPasswordUnsealed(ctx context.Context, u BusUser) (string, error) {
|
|
return i.mintBusPassword(ctx, u, false)
|
|
}
|
|
|
|
// MarkBusAccountSealed says the credential last minted for username is sealed to its node.
|
|
func (i *Inventory) MarkBusAccountSealed(ctx context.Context, username string) error {
|
|
_, err := i.store.Pool().Exec(ctx, `update bus_user set sealed_at = now() where username = $1`, username)
|
|
return err
|
|
}
|
|
|
|
// BusAccountIssued says whether an account is minted, and whether its credential is sealed to its node.
|
|
func (i *Inventory) BusAccountIssued(ctx context.Context, username string) (minted, sealed bool, err error) {
|
|
var at *time.Time
|
|
err = i.store.Pool().QueryRow(ctx, `select sealed_at from bus_user where username = $1`, username).Scan(&at)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return false, false, nil
|
|
}
|
|
return err == nil, err == nil && at != nil, err
|
|
}
|
|
|
|
func (i *Inventory) MintBusPassword(ctx context.Context, u BusUser) (string, error) {
|
|
return i.mintBusPassword(ctx, u, true)
|
|
}
|
|
|
|
// mintBusPassword mints, marked sealed when the caller seals it itself in the same act (every mint but an issue's).
|
|
func (i *Inventory) mintBusPassword(ctx context.Context, u BusUser, sealed bool) (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 := hashBusPassword(password)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot hash a bus password: %w", err)
|
|
}
|
|
|
|
if err := i.writeBusUserSealed(ctx, u, string(hash), sealed); err != nil {
|
|
return "", 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, sealed_at is not null 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, &u.Sealed); 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 := hashBusPassword(password)
|
|
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()
|
|
}
|