e9ad719424
0003 built the JetStream KV store (jetstreamStore) but the binary never selected it: membership.Open (SQLite) was hardcoded and OpenJetStream was only reached by migrate-to-kv. This completes the wiring so a node actually serves its control plane from the replicated KV. - New flag --store kv|sqlite (default sqlite). kv opens the JetStream KV control plane over the privileged internal connection; sqlite is the unchanged baseline (branch-by-abstraction: the full suite's SQLite paths are untouched). - Bootstrap cycle resolved with storeHolder: the authenticator consults the holder (fail-closed until set), so it can be built before the KV store exists. The KV store opens after NATS is up and is published into the holder. The only client that can connect in that window is the internal identity, which bypasses the store by key. In SQLite mode the store is set before StartServer, so the window does not exist. - needJS now covers --store kv as well as --cluster-name; the JetStream client is shared by the KV store and the replicated nonce bucket. - feature_flags.json: decentralized wiring documented as complete, realized via --store kv (opt-in per deploy; default stays sqlite). Fail-closed preserved: jetstreamStore.IsAuthorized already denies on any backend error; the holder denies while unset. Tests: - TestStoreHolderFailClosed: empty holder denies; serves after set. - TestKVStoreBootstrapUnderEnforce: end-to-end decentralized boot — KV-seeded user authenticates over nkey under enforce; outsider denied. - TestKVStoreDecentralizedConsistency: a room/user created on one node's KV store is visible to another's (ends the per-node SQLite divergence, audit 0008 N5). CGO_ENABLED=0 go build/vet/test green; govulncheck 0 reachable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
155 lines
5.2 KiB
Go
155 lines
5.2 KiB
Go
package main
|
|
|
|
// Wiring tests for issue 0006c: --store kv selects the replicated JetStream KV
|
|
// control plane, the authenticator serves from it through the storeHolder, and a
|
|
// new node sees state created by another (the divergence that per-node SQLite
|
|
// caused — audit 0008 N5 — is gone). Branch-by-abstraction is verified elsewhere
|
|
// (the SQLite default path is the unchanged baseline covered by the existing
|
|
// suite).
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"testing"
|
|
"time"
|
|
|
|
cs "fn-registry/functions/cybersecurity"
|
|
|
|
"github.com/enmanuel/unibus/pkg/busauth"
|
|
"github.com/enmanuel/unibus/pkg/embeddednats"
|
|
"github.com/enmanuel/unibus/pkg/membership"
|
|
"github.com/nats-io/nats.go"
|
|
"github.com/nats-io/nats.go/jetstream"
|
|
)
|
|
|
|
// TestKVStoreBootstrapUnderEnforce drives the exact decentralized boot the binary
|
|
// performs: build the authenticator over an empty holder, start NATS, open the
|
|
// privileged internal connection, open the KV store, publish it into the holder,
|
|
// then a real bus user (seeded into the KV store) authenticates over nkey. This
|
|
// proves the bootstrap cycle is broken correctly — the KV-backed control plane
|
|
// authorizes live clients under enforce.
|
|
func TestKVStoreBootstrapUnderEnforce(t *testing.T) {
|
|
internalID, err := cs.GenerateIdentity()
|
|
if err != nil {
|
|
t.Fatalf("internal identity: %v", err)
|
|
}
|
|
holder := &storeHolder{}
|
|
auth := busauth.NewNkeyAuthenticatorACLInternal(
|
|
holder.IsAuthorized,
|
|
busauth.PermissionsFromSubjects(holder.subjectACL),
|
|
hex.EncodeToString(internalID.SignPub),
|
|
)
|
|
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
|
|
StoreDir: t.TempDir(), Host: "127.0.0.1", Port: freePort(t), Auth: auth,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("nats: %v", err)
|
|
}
|
|
t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() })
|
|
|
|
// Privileged internal connection opens the KV store while the holder still
|
|
// denies every normal client.
|
|
intNC, js, err := connectInternalJS(ns, internalID, true)
|
|
if err != nil {
|
|
t.Fatalf("connectInternalJS: %v", err)
|
|
}
|
|
t.Cleanup(intNC.Close)
|
|
kvStore, err := membership.OpenJetStream(js, membership.JetStreamConfig{Replicas: 1, OpTimeout: 3 * time.Second})
|
|
if err != nil {
|
|
t.Fatalf("open kv store: %v", err)
|
|
}
|
|
holder.set(kvStore)
|
|
|
|
// Seed a bus user into the KV control plane.
|
|
alice, err := cs.GenerateIdentity()
|
|
if err != nil {
|
|
t.Fatalf("alice: %v", err)
|
|
}
|
|
if err := kvStore.AddUser(hex.EncodeToString(alice.SignPub), "alice", membership.RoleMember); err != nil {
|
|
t.Fatalf("seed alice: %v", err)
|
|
}
|
|
|
|
// alice authenticates over nkey — authorized via the KV store through the holder.
|
|
pub, sign, err := busauth.ClientNkey(alice.SignPriv)
|
|
if err != nil {
|
|
t.Fatalf("alice nkey: %v", err)
|
|
}
|
|
aliceNC, err := nats.Connect(ns.ClientURL(), nats.Nkey(pub, sign), nats.MaxReconnects(0), nats.Timeout(2*time.Second))
|
|
if err != nil {
|
|
t.Fatalf("alice (KV-authorized) must connect under enforce: %v", err)
|
|
}
|
|
aliceNC.Close()
|
|
|
|
// An outsider not in the KV store is denied (fail closed).
|
|
outsider, err := cs.GenerateIdentity()
|
|
if err != nil {
|
|
t.Fatalf("outsider: %v", err)
|
|
}
|
|
opub, osign, err := busauth.ClientNkey(outsider.SignPriv)
|
|
if err != nil {
|
|
t.Fatalf("outsider nkey: %v", err)
|
|
}
|
|
if oc, err := nats.Connect(ns.ClientURL(), nats.Nkey(opub, osign), nats.MaxReconnects(0), nats.Timeout(2*time.Second)); err == nil {
|
|
oc.Close()
|
|
t.Fatalf("an outsider absent from the KV store must be rejected")
|
|
}
|
|
}
|
|
|
|
// TestKVStoreDecentralizedConsistency: a room/user created via one node's KV store
|
|
// is immediately visible to another node's KV store over the same JetStream — the
|
|
// shared, replicated control plane that ends the per-node SQLite divergence.
|
|
func TestKVStoreDecentralizedConsistency(t *testing.T) {
|
|
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
|
|
StoreDir: t.TempDir(), Host: "127.0.0.1", Port: freePort(t),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("nats: %v", err)
|
|
}
|
|
t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() })
|
|
|
|
open := func() membership.Store {
|
|
nc, err := nats.Connect(ns.ClientURL())
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
t.Cleanup(nc.Close)
|
|
js, err := jetstream.New(nc)
|
|
if err != nil {
|
|
t.Fatalf("jetstream: %v", err)
|
|
}
|
|
st, err := membership.OpenJetStream(js, membership.JetStreamConfig{Replicas: 1, OpTimeout: 3 * time.Second})
|
|
if err != nil {
|
|
t.Fatalf("open kv: %v", err)
|
|
}
|
|
return st
|
|
}
|
|
nodeA := open()
|
|
nodeB := open()
|
|
|
|
owner, err := cs.GenerateIdentity()
|
|
if err != nil {
|
|
t.Fatalf("owner: %v", err)
|
|
}
|
|
ownerPub := hex.EncodeToString(owner.SignPub)
|
|
if err := nodeA.AddUser(ownerPub, "owner", membership.RoleAdmin); err != nil {
|
|
t.Fatalf("nodeA add user: %v", err)
|
|
}
|
|
if err := nodeA.CreateRoom(
|
|
membership.RoomInfo{RoomID: "ROOMX", Subject: "room.shared.x", OwnerEndpoint: "owner-ep"},
|
|
owner.SignPub, owner.KexPub, nil,
|
|
); err != nil {
|
|
t.Fatalf("nodeA create room: %v", err)
|
|
}
|
|
|
|
// nodeB (a different connection, same buckets) sees both immediately.
|
|
if !nodeB.IsAuthorized(ownerPub) {
|
|
t.Fatalf("nodeB must see the user created on nodeA (decentralized state divergence)")
|
|
}
|
|
got, err := nodeB.GetRoom("ROOMX")
|
|
if err != nil {
|
|
t.Fatalf("nodeB must see the room created on nodeA: %v", err)
|
|
}
|
|
if got.Subject != "room.shared.x" {
|
|
t.Fatalf("nodeB read wrong room subject: %q", got.Subject)
|
|
}
|
|
}
|