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 }