novox/hq 04-ISSUES/146. The composed user list names an enrolment user for every machine with a live token and nothing minted a credential for it, so the composer left it out as a user with no password — and every enrolment since the mesh moved to this bus was refused before the mesh heard of it. The comment above the issuing code already said the account is created before the token is handed over; now it is. Recorded rather than minted, because the token's secret is the password. And 'broker accounts', which composes the same list the declaration carries and writes it to standard output. For genesis, where no declaration can reach the machine running the bus because that machine is not yet a node. It says what it composed; whoever is raising the machine places it. A control plane that wrote the file itself would have to learn where the bus keeps its configuration and how to make it reload, which is the module's knowledge.
293 lines
12 KiB
Go
293 lines
12 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.
|
|
// 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 := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
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.
|
|
func (i *Inventory) writeBusUser(ctx context.Context, u BusUser, hash string) error {
|
|
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, hash); err != nil {
|
|
return fmt.Errorf("cannot record the bus user %s: %w", u.Username, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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.writeBusUser(ctx, u, string(hash)); 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 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()
|
|
}
|