02c2004ebd
Closes the most valuable 0011 deploy gap: adding users to the running cluster's replicated allowlist with no stop-seed-restart. Under enforce the per-subject ACL confines every bus user to its own rooms, so no ordinary identity may write the control-plane KV buckets; the only identity the authenticator grants full JetStream permissions is membershipd's internal service identity. - main.go: --internal-id-file persists that identity (load-or-create, 0600) instead of a fresh ephemeral key, so the same nkey is available out of process. Empty keeps the ephemeral default (single-node/dev unchanged). - users_kv.go: connectKVStore loads the persisted identity, presents its nkey (recognized as internal -> full perms), opens the KV store and writes. Defaults assume an on-node loopback invocation; a remote target without --ca is refused (allowlist must not travel cleartext, audit N6). Prints KV_UNIBUS_users replication (followers_current) after a write. - users_cli.go: --store kv on add/list/revoke. Re-adding a key is an explicit ErrUserExists (no silent overwrite / role flip); revoke is a status flip. - pkg/client: LoadIdentity (load-only) extracted from LoadOrCreateIdentity, preserving its "corrupt file is an error, not silently regenerated" guard. - kv_useradd_test.go: golden write under enforce, idempotency, unreachable endpoint, and remote-without-CA refusal against an embedded node. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
153 lines
6.0 KiB
Go
153 lines
6.0 KiB
Go
package main
|
|
|
|
// Integration tests for issue 0011 GAP A: `membershipd user add --store kv`
|
|
// adds users to a RUNNING cluster's replicated allowlist via the privileged
|
|
// internal connection, instead of the stop-seed-restart procedure the 0011
|
|
// deploy required. These exercise the real connectKVStore path (load the
|
|
// persisted internal identity from a file, present its nkey, open the KV store,
|
|
// write the user) against an embedded enforce node, plus the idempotency and
|
|
// error semantics the DoD calls for. Multi-node replication and node-down quorum
|
|
// are validated against the live cluster (report 0012).
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"errors"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
cs "fn-registry/functions/cybersecurity"
|
|
|
|
"github.com/enmanuel/unibus/pkg/busauth"
|
|
"github.com/enmanuel/unibus/pkg/client"
|
|
"github.com/enmanuel/unibus/pkg/embeddednats"
|
|
"github.com/enmanuel/unibus/pkg/membership"
|
|
)
|
|
|
|
// startEnforceKVNode boots a single embedded enforce node whose authenticator
|
|
// recognizes internalPubHex as the privileged internal identity, bootstraps the
|
|
// KV control-plane store over the in-process internal connection, and publishes
|
|
// it into the holder — the exact sequence main.go performs for --store kv. It
|
|
// returns the client URL the CLI connects to.
|
|
func startEnforceKVNode(t *testing.T, internalID cs.Identity) string {
|
|
t.Helper()
|
|
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("start enforce node: %v", err)
|
|
}
|
|
t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() })
|
|
|
|
intNC, js, err := connectInternalJS(ns, internalID, true)
|
|
if err != nil {
|
|
t.Fatalf("bootstrap internal connection: %v", err)
|
|
}
|
|
t.Cleanup(intNC.Close)
|
|
kvStore, err := membership.OpenJetStream(js, membership.JetStreamConfig{Replicas: 1, OpTimeout: 3 * time.Second})
|
|
if err != nil {
|
|
t.Fatalf("bootstrap KV store: %v", err)
|
|
}
|
|
holder.set(kvStore)
|
|
return ns.ClientURL()
|
|
}
|
|
|
|
// TestUserAddStoreKV_GoldenAndIdempotent is the GAP A golden + edge-1: the CLI
|
|
// connection (real connectKVStore, loading the internal identity from a file and
|
|
// presenting its nkey) writes a user into the live KV allowlist, the user is
|
|
// authorized afterward, and re-adding the same key is an explicit ErrUserExists
|
|
// with no corruption (the unchanged row is still authorized).
|
|
func TestUserAddStoreKV_GoldenAndIdempotent(t *testing.T) {
|
|
idFile := filepath.Join(t.TempDir(), "internal.id")
|
|
internalID, err := client.LoadOrCreateIdentity(idFile) // persists 0600
|
|
if err != nil {
|
|
t.Fatalf("persist internal identity: %v", err)
|
|
}
|
|
url := startEnforceKVNode(t, internalID)
|
|
|
|
// Golden: connect as the privileged internal identity (loopback, no TLS) and
|
|
// add a new user, exactly as `user add --store kv` does.
|
|
kv, err := connectKVStore(url, idFile, "", 1)
|
|
if err != nil {
|
|
t.Fatalf("connectKVStore (privileged): %v", err)
|
|
}
|
|
defer kv.Close()
|
|
|
|
newUser, err := cs.GenerateIdentity()
|
|
if err != nil {
|
|
t.Fatalf("new user identity: %v", err)
|
|
}
|
|
pub := hex.EncodeToString(newUser.SignPub)
|
|
if err := kv.store.AddUser(pub, "gapcheck_user", membership.RoleMember); err != nil {
|
|
t.Fatalf("add user to live KV: %v", err)
|
|
}
|
|
if !kv.store.IsAuthorized(pub) {
|
|
t.Fatalf("user added to KV must be authorized")
|
|
}
|
|
|
|
// Edge 1: re-adding the same key is a clean, non-destructive ErrUserExists.
|
|
err = kv.store.AddUser(pub, "gapcheck_user", membership.RoleMember)
|
|
if !errors.Is(err, membership.ErrUserExists) {
|
|
t.Fatalf("re-add must return ErrUserExists (idempotent), got %v", err)
|
|
}
|
|
// A different handle/role with the SAME key is also rejected — the row is not
|
|
// silently overwritten (no role flip).
|
|
if err := kv.store.AddUser(pub, "impostor", membership.RoleAdmin); !errors.Is(err, membership.ErrUserExists) {
|
|
t.Fatalf("re-add with a different role must NOT overwrite; want ErrUserExists, got %v", err)
|
|
}
|
|
u, err := kv.store.GetUser(pub)
|
|
if err != nil {
|
|
t.Fatalf("get user: %v", err)
|
|
}
|
|
if u.Handle != "gapcheck_user" || u.Role != membership.RoleMember || u.Status != membership.StatusActive {
|
|
t.Fatalf("idempotent re-add corrupted the row: %+v", u)
|
|
}
|
|
}
|
|
|
|
// TestUserAddStoreKV_RequiresInternalIdentity: --store kv without a usable
|
|
// internal identity file fails loudly (missing file, empty path) rather than
|
|
// silently connecting unprivileged.
|
|
func TestUserAddStoreKV_RequiresInternalIdentity(t *testing.T) {
|
|
if _, err := connectKVStore("nats://127.0.0.1:4250", "", "", 1); err == nil {
|
|
t.Fatalf("empty --internal-id-file must be an error")
|
|
}
|
|
missing := filepath.Join(t.TempDir(), "nope.id")
|
|
if _, err := connectKVStore("nats://127.0.0.1:4250", missing, "", 1); err == nil {
|
|
t.Fatalf("missing internal identity file must be an error")
|
|
}
|
|
}
|
|
|
|
// TestUserAddStoreKV_UnreachableKV is the GAP A error case: pointing --store kv
|
|
// at a dead endpoint yields a clear, handled error (no crash, no silent success).
|
|
func TestUserAddStoreKV_UnreachableKV(t *testing.T) {
|
|
idFile := filepath.Join(t.TempDir(), "internal.id")
|
|
if _, err := client.LoadOrCreateIdentity(idFile); err != nil {
|
|
t.Fatalf("persist internal identity: %v", err)
|
|
}
|
|
// A loopback port with nothing listening: connect must fail fast and wrapped.
|
|
_, err := connectKVStore("nats://127.0.0.1:1/", idFile, "", 1)
|
|
if err == nil {
|
|
t.Fatalf("connecting to a dead endpoint must error")
|
|
}
|
|
}
|
|
|
|
// TestUserAddStoreKV_RemoteWithoutCARefused: a non-loopback target without --ca
|
|
// is refused so the allowlist write never travels in cleartext (audit 0008 N6,
|
|
// same guard as migrate-to-kv).
|
|
func TestUserAddStoreKV_RemoteWithoutCARefused(t *testing.T) {
|
|
idFile := filepath.Join(t.TempDir(), "internal.id")
|
|
if _, err := client.LoadOrCreateIdentity(idFile); err != nil {
|
|
t.Fatalf("persist internal identity: %v", err)
|
|
}
|
|
_, err := connectKVStore("nats://203.0.113.1:4250", idFile, "", 1)
|
|
if err == nil {
|
|
t.Fatalf("remote target without --ca must be refused")
|
|
}
|
|
}
|