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 `.`, 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() }