Two pieces the composer has been waiting for since it was written. **The credential has to outlive its own minting.** On the bus the mesh runs on today an account is a management call: mint a password, hand it over, seal the plaintext to whoever will use it, keep nothing — which works because the broker remembers. Here the users are one file, rewritten whenever any of it changes, so keeping nothing would mean the first person's access change silently blanking every module's password. So a bus user's bcrypt hash is now recorded, keyed by the username the file needs, and the plaintext comes back exactly once. Verified against a real store that the hash verifies the password it was made from, that the password itself is not in there, that minting again rotates rather than adds, and that forgetting a node takes its host's and its modules' credentials with it. **Permissions are not stored, and that is the point.** Only the credential is kept. Authority is derived from what each module declares, every time the file is written (ADR 0043) — a stored permission list would be a second account of a user's authority, able to disagree with the records it came from, and both would look internally consistent while they did. `Users` derives the list: the controller always first and always present, one user per node, one per module per node, one per live token, one per person. Two users with one name is refused where both can be named, rather than left to be whichever one the server happened to read. A user the mesh has never minted a password for is *named* rather than dropped or written as a user anybody is: that is an ordinary situation with an obvious remedy, and the caller decides whether a partial file is worth writing. What remains of 1.7: delivering the file to the node that runs the server, and minting at enrolment and assignment — which is transport-coupled, because a node on the old bus must not be handed a credential for the new one.
139 lines
5.6 KiB
Go
139 lines
5.6 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
|
|
}
|