The bus on NATS: both transports behind seams, and the rollout switch #87
@@ -0,0 +1,125 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Every user the composed file should contain, derived from what the mesh knows.
|
||||
//
|
||||
// **The list is derived, never kept.** A stored user list would be a second account of who may
|
||||
// reach the bus, able to disagree with the records it came from — and the disagreement would be
|
||||
// invisible, because both would look internally consistent. So this is a pure function of the
|
||||
// mesh's records, run again every time the file is written.
|
||||
//
|
||||
// Records are mirrored into this package's own types rather than imported from the catalogue, for
|
||||
// the reason DeclaredSeat is: composing authority is a different job from parsing a manifest, and
|
||||
// this package stays free of the other's types so a change to a manifest field cannot quietly widen
|
||||
// a permission.
|
||||
|
||||
// Declared is one module on one node, as composing its authority needs it.
|
||||
type Declared struct {
|
||||
Module string
|
||||
Emits []string
|
||||
Consumes []string
|
||||
Serves []string
|
||||
// Holds are the seats this module claims, with the protocol each seat declares. A seat the
|
||||
// mesh defines for itself declares no protocol, so holding one grants nothing on the bus —
|
||||
// which is right: those seats are about who does a job, not about who may say what.
|
||||
Holds []Seat
|
||||
// Uses are the seats this module sends to.
|
||||
Uses []Seat
|
||||
}
|
||||
|
||||
// Records is what composing a user list needs to know about the mesh, and nothing more.
|
||||
type Records struct {
|
||||
// Nodes is every machine the mesh knows. Each gets a host user.
|
||||
Nodes []string
|
||||
// Assigned is the modules on each node, as they declare themselves.
|
||||
Assigned map[string][]Declared
|
||||
// Enrolling is every node with a live token — one enrolment user each, because the inbox an
|
||||
// answer goes to is scoped to the token and a shared one is one machine reading another's
|
||||
// sealed credentials (design 25 §6).
|
||||
Enrolling []string
|
||||
// People is each person's name against the tools they may invoke, `*` for an administrator.
|
||||
People map[string][]string
|
||||
}
|
||||
|
||||
// Users is every user the composed file should contain, in the order it will be written.
|
||||
//
|
||||
// The controller is always first and always present: a mesh whose own controller is not in the file
|
||||
// is a mesh that cannot be told anything, and there is no state of the records in which that is
|
||||
// correct.
|
||||
func Users(r Records) ([]Principal, error) {
|
||||
out := []Principal{{Kind: KindController}}
|
||||
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
out = append(out, Principal{Kind: KindNode, Node: node})
|
||||
for _, d := range r.Assigned[node] {
|
||||
out = append(out, Principal{
|
||||
Kind: KindModule, Node: node, Module: d.Module,
|
||||
Emits: d.Emits, Consumes: d.Consumes, Serves: d.Serves,
|
||||
Holds: d.Holds, Uses: d.Uses,
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, node := range sortedCopy(r.Enrolling) {
|
||||
out = append(out, Principal{Kind: KindEnrolment, Node: node})
|
||||
}
|
||||
for _, person := range sortedNames(r.People) {
|
||||
out = append(out, Principal{Kind: KindPerson, Module: person, Invokes: r.People[person]})
|
||||
}
|
||||
|
||||
// Refused here rather than discovered by the server. Two users with one name is a file the
|
||||
// server reads as one of them, and which one depends on the order — so a module assigned to a
|
||||
// node twice, or a person named after nothing, is a composition that must not be written.
|
||||
seen := map[string]string{}
|
||||
for _, p := range out {
|
||||
name := p.Username()
|
||||
if name == "" || name == "." {
|
||||
return nil, fmt.Errorf("a %s user has no name, so nothing could authenticate as it", p.Kind)
|
||||
}
|
||||
if first, already := seen[name]; already {
|
||||
return nil, fmt.Errorf(
|
||||
"two users would be called %q (a %s and a %s): the server would read the file as "+
|
||||
"one of them, and which one depends on the order", name, first, p.Kind)
|
||||
}
|
||||
seen[name] = string(p.Kind)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// WithPasswords fills each user's hash from what the mesh minted, and says which users have none.
|
||||
//
|
||||
// **Separated from Users because they fail differently.** A user missing from the records is a bug
|
||||
// in deriving them; a user with no password is a step that has not happened yet — a module assigned
|
||||
// but never given a credential, a node enrolled before this existed. The second is ordinary and its
|
||||
// remedy is to mint one, so it is named rather than returned as an error, and the caller decides
|
||||
// whether a partial composition is worth writing.
|
||||
func WithPasswords(principals []Principal, hashes map[string]string) (filled []Principal, missing []string) {
|
||||
for _, p := range principals {
|
||||
hash, ok := hashes[p.Username()]
|
||||
if !ok || hash == "" {
|
||||
missing = append(missing, p.Username())
|
||||
continue
|
||||
}
|
||||
p.PasswordHash = hash
|
||||
filled = append(filled, p)
|
||||
}
|
||||
return filled, missing
|
||||
}
|
||||
|
||||
func sortedCopy(in []string) []string {
|
||||
out := append([]string(nil), in...)
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedNames(in map[string][]string) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
for k := range in {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Deriving the bus's user list from the mesh's records.
|
||||
//
|
||||
// Every test here is about a way the list could be wrong that the server would not tell anybody
|
||||
// about: a user missing, a user named twice, a user with authority it did not declare.
|
||||
|
||||
func someRecords() Records {
|
||||
return Records{
|
||||
Nodes: []string{"two", "one"},
|
||||
Assigned: map[string][]Declared{
|
||||
"one": {{Module: "telegram", Serves: []string{"status"}}},
|
||||
"two": {{Module: "shop", Emits: []string{"order.placed"}}},
|
||||
},
|
||||
Enrolling: []string{"three"},
|
||||
People: map[string][]string{"ada": {"mesh-catalog.catalog_tools"}},
|
||||
}
|
||||
}
|
||||
|
||||
func namesOf(t *testing.T, r Records) []string {
|
||||
t.Helper()
|
||||
users, err := Users(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := make([]string, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, u.Username())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The controller is always there. A mesh whose own controller is not in the file is a mesh that
|
||||
// cannot be told anything, and there is no state of the records in which that is correct.
|
||||
func TestTheControllerIsAlwaysInTheList(t *testing.T) {
|
||||
for _, r := range []Records{{}, someRecords()} {
|
||||
names := namesOf(t, r)
|
||||
if len(names) == 0 || names[0] != "controller" {
|
||||
t.Fatalf("the controller is not first in %v", names)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One user per node, one per module per node, one per live token and one per person — and nothing
|
||||
// else, because a user nobody derived is a user nobody can explain.
|
||||
func TestEveryRecordBecomesExactlyOneUser(t *testing.T) {
|
||||
names := namesOf(t, someRecords())
|
||||
want := []string{
|
||||
"controller",
|
||||
"node.one", "one.telegram",
|
||||
"node.two", "two.shop",
|
||||
"enrol.three",
|
||||
"person.ada",
|
||||
}
|
||||
if strings.Join(names, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("derived %v\n want %v", names, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Two users with one name is a file the server reads as one of them, and which one depends on the
|
||||
// order. Refused here, where both can be named, rather than left to be whichever the server picked.
|
||||
func TestTwoUsersWithOneNameAreRefused(t *testing.T) {
|
||||
r := someRecords()
|
||||
r.Assigned["one"] = append(r.Assigned["one"], Declared{Module: "telegram"})
|
||||
_, err := Users(r)
|
||||
if err == nil {
|
||||
t.Fatal("a module assigned twice to one node composed two users with one name")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "one.telegram") {
|
||||
t.Fatalf("the refusal does not name the user: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A module's authority is what it declared and nothing more, carried through the derivation intact —
|
||||
// because this is the step where a mistake would grant something no manifest asked for.
|
||||
func TestAModulesAuthorityIsWhatItDeclared(t *testing.T) {
|
||||
seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered"}}
|
||||
users, err := Users(Records{
|
||||
Nodes: []string{"one"},
|
||||
Assigned: map[string][]Declared{"one": {{
|
||||
Module: "shop", Emits: []string{"order.placed"}, Uses: []Seat{seat},
|
||||
}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
perms, err := PermissionsFor(users[len(users)-1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
has(t, perms.Publish, "mesh.mod.shop.event.order.placed")
|
||||
has(t, perms.Publish, "mesh.seat.telegram-sender.accept.send")
|
||||
// A seat it uses, not one it holds: it may submit work and may not publish the seat's own
|
||||
// events, or it could lie about outcomes on a role somebody else fills.
|
||||
hasNot(t, perms.Publish, "mesh.seat.telegram-sender.event.delivered")
|
||||
hasNot(t, perms.Subscribe, "mesh.seat.telegram-sender.accept.send")
|
||||
}
|
||||
|
||||
// A user the mesh has never minted a password for is named rather than silently dropped or
|
||||
// composed as a user anybody is. It is an ordinary situation — a module assigned a moment ago — and
|
||||
// the remedy is to mint one, so the caller decides whether to write a partial file.
|
||||
func TestAUserWithNoPasswordIsNamedRatherThanWritten(t *testing.T) {
|
||||
users, err := Users(someRecords())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filled, missing := WithPasswords(users, map[string]string{
|
||||
"controller": "$2a$hash", "node.one": "$2a$hash",
|
||||
})
|
||||
if len(filled) != 2 {
|
||||
t.Fatalf("composed %d users from two hashes", len(filled))
|
||||
}
|
||||
if len(missing) != len(users)-2 {
|
||||
t.Fatalf("%d users are missing a password, of %d: %v", len(missing), len(users), missing)
|
||||
}
|
||||
for _, p := range filled {
|
||||
if p.PasswordHash == "" {
|
||||
t.Fatalf("%s was kept with no password, which is a user anybody is", p.Username())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And the whole thing composes: records in, a file the server would read out.
|
||||
func TestRecordsComposeIntoAFile(t *testing.T) {
|
||||
users, err := Users(someRecords())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hashes := map[string]string{}
|
||||
for _, u := range users {
|
||||
hashes[u.Username()] = "$2a$11$" + strings.Repeat("x", 22)
|
||||
}
|
||||
filled, missing := WithPasswords(users, hashes)
|
||||
if len(missing) != 0 {
|
||||
t.Fatalf("users with no password: %v", missing)
|
||||
}
|
||||
got, err := Compose(Server{ClientPort: 4222, MonitoringPort: 8222, StoreDir: "/data",
|
||||
TLSCert: "/tls/tls.crt", TLSKey: "/tls/tls.key", TLSCA: "/tls/ca.crt"}, filled)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`user: "controller"`, `user: "node.one"`, `user: "one.telegram"`,
|
||||
`user: "enrol.three"`, `user: "person.ada"`,
|
||||
`"_INBOX.enrol.three.>"`, `"mesh.mod.mesh-catalog.tool.catalog_tools"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("the composed file does not contain %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// The bus's users as records — against a real store, because what is being checked is that the
|
||||
// column exists, the upsert behaves, and a plaintext is returned exactly once.
|
||||
|
||||
func aBusUser(module string) BusUser {
|
||||
return BusUser{Username: "one." + module, Kind: BusModule, Node: "one", Module: module}
|
||||
}
|
||||
|
||||
// The plaintext comes back once and the store keeps only a hash that verifies against it. **A
|
||||
// credential recoverable from the mesh's store is one whose blast radius is the store's**, so what
|
||||
// is asserted is that the password is not in there.
|
||||
func TestABusPasswordIsReturnedOnceAndOnlyItsHashIsKept(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
password, err := inv.MintBusPassword(ctx, aBusUser("shop"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if password == "" {
|
||||
t.Fatal("no password came back, so nothing can be sealed to the module")
|
||||
}
|
||||
|
||||
hash, known, err := inv.BusUserHash(ctx, "one.shop")
|
||||
if err != nil || !known {
|
||||
t.Fatalf("the user was not recorded: %v %v", known, err)
|
||||
}
|
||||
if hash == password {
|
||||
t.Fatal("the store holds the password itself")
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
|
||||
t.Fatalf("the recorded hash does not verify the password it was made from: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Minting again replaces what was there rather than failing or adding a second row: that is a
|
||||
// rotation, and the old credential stops working at the next composition.
|
||||
func TestMintingAgainRotatesRatherThanAddsAUser(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
first, err := inv.MintBusPassword(ctx, aBusUser("shop"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := inv.MintBusPassword(ctx, aBusUser("shop"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first == second {
|
||||
t.Fatal("minting twice produced the same password")
|
||||
}
|
||||
users, err := inv.BusUsers(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(users) != 1 {
|
||||
t.Fatalf("%d users after two mints for one name", len(users))
|
||||
}
|
||||
hash := users["one.shop"].PasswordHash
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(second)); err != nil {
|
||||
t.Fatal("the kept hash is not the newest password's")
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(first)) == nil {
|
||||
t.Fatal("the previous password still verifies, so a rotation revoked nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// Forgetting a node takes every credential that belonged to it — its host's and every module
|
||||
// assigned to it. What a forgotten node leaves behind otherwise is a working set of credentials for
|
||||
// a machine the mesh no longer knows.
|
||||
func TestForgettingANodeTakesItsBusUsersWithIt(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, u := range []BusUser{
|
||||
{Username: "node.one", Kind: BusNode, Node: "one"},
|
||||
aBusUser("shop"),
|
||||
{Username: "node.two", Kind: BusNode, Node: "two"},
|
||||
{Username: "controller", Kind: BusController},
|
||||
} {
|
||||
if _, err := inv.MintBusPassword(ctx, u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := inv.ForgetBusUsersOf(ctx, "one"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users, err := inv.BusUsers(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, still := users["node.one"]; still {
|
||||
t.Fatal("a forgotten node's host credential still works")
|
||||
}
|
||||
if _, still := users["one.shop"]; still {
|
||||
t.Fatal("a module on a forgotten node still has a credential")
|
||||
}
|
||||
// And nothing else went with it: the controller has no node, and another machine's user is
|
||||
// another machine's.
|
||||
for _, kept := range []string{"node.two", "controller"} {
|
||||
if _, ok := users[kept]; !ok {
|
||||
t.Fatalf("%s was removed with another node's users", kept)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Forgetting the users of no node would forget every user that has none — the controller and every
|
||||
// person — so it is refused rather than run.
|
||||
func TestForgettingTheUsersOfNoNodeIsRefused(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
if err := inv.ForgetBusUsersOf(context.Background(), ""); err == nil {
|
||||
t.Fatal("forgetting the bus users of no node was allowed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Every bus user's password hash, because the file has to be written again.
|
||||
--
|
||||
-- novox/hq design 25 §4, task 1.7. On the bus the mesh runs on today an account is created by a
|
||||
-- management call: the mesh mints a password, hands it over, seals the plaintext to whoever will
|
||||
-- use it, and keeps nothing. That works because the broker remembers.
|
||||
--
|
||||
-- The bus being built has no management call — its users are a file the controller composes, and
|
||||
-- **the whole file is written every time any of it changes**. So the first person's access change
|
||||
-- would silently blank every module's password. The hash has to outlive its own minting, which is
|
||||
-- state the mesh did not need before and does now.
|
||||
--
|
||||
-- Keyed by username, because the username is exactly what the composed file needs and what a
|
||||
-- principal derives from its own identity. Nothing else about the user is here: **permissions are
|
||||
-- not stored.** They are derived from what each module declares, every time the file is written
|
||||
-- (ADR 0043) — a stored copy 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 file with a
|
||||
-- manifest.
|
||||
--
|
||||
-- The hash and not the password. A file on a node's disk holds the hash, and so does this: a
|
||||
-- credential recoverable from the mesh's store is one whose blast radius is the store's.
|
||||
create table bus_user (
|
||||
username text primary key,
|
||||
-- kind and what it names, so a user whose subject is gone can be found and removed: a module
|
||||
-- unassigned, a node forgotten, a token spent. Recorded rather than parsed back out of the
|
||||
-- username, because a name is for the server and a parser over it would be a second grammar.
|
||||
kind text not null,
|
||||
node text not null default '',
|
||||
module text not null default '',
|
||||
password_hash text not null,
|
||||
minted_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- Finding every user of one kind, and every user belonging to one node — which is what removing a
|
||||
-- node, or composing after an assignment, asks.
|
||||
create index bus_user_kind on bus_user (kind);
|
||||
create index bus_user_node on bus_user (node) where node <> '';
|
||||
Reference in New Issue
Block a user