feat(membershipd): user add/list/revoke --store kv against a live cluster
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>
This commit is contained in:
@@ -0,0 +1,152 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-3
@@ -24,6 +24,7 @@ import (
|
|||||||
|
|
||||||
"github.com/enmanuel/unibus/pkg/blobstore"
|
"github.com/enmanuel/unibus/pkg/blobstore"
|
||||||
"github.com/enmanuel/unibus/pkg/busauth"
|
"github.com/enmanuel/unibus/pkg/busauth"
|
||||||
|
"github.com/enmanuel/unibus/pkg/client"
|
||||||
"github.com/enmanuel/unibus/pkg/embeddednats"
|
"github.com/enmanuel/unibus/pkg/embeddednats"
|
||||||
"github.com/enmanuel/unibus/pkg/membership"
|
"github.com/enmanuel/unibus/pkg/membership"
|
||||||
)
|
)
|
||||||
@@ -83,6 +84,17 @@ func main() {
|
|||||||
// "kv" puts rooms/members/keys/users in replicated JetStream KV so any node
|
// "kv" puts rooms/members/keys/users in replicated JetStream KV so any node
|
||||||
// in the cluster serves the same state.
|
// in the cluster serves the same state.
|
||||||
storeBackend = flag.String("store", "sqlite", "control-plane store backend: sqlite (default, single-node) | kv (replicated JetStream, decentralized)")
|
storeBackend = flag.String("store", "sqlite", "control-plane store backend: sqlite (default, single-node) | kv (replicated JetStream, decentralized)")
|
||||||
|
// Persisted internal service identity (issue 0011 gaps, GAP A): when set, the
|
||||||
|
// privileged internal identity used to manage JetStream is LOADED from this
|
||||||
|
// file (generated and persisted on first start) instead of being a fresh
|
||||||
|
// ephemeral key each boot. Persisting it is what lets `membershipd user add
|
||||||
|
// --store kv` write the replicated allowlist of a LIVE cluster: that CLI,
|
||||||
|
// run over loopback on a node, loads the SAME identity and presents the nkey
|
||||||
|
// this node's authenticator already grants full permissions. Empty keeps the
|
||||||
|
// ephemeral-per-process behavior (single-node/dev default, unchanged). The
|
||||||
|
// file holds a private key: it is written 0600 and belongs next to the node's
|
||||||
|
// TLS keys (deploy keeps it under secrets/, gitignored).
|
||||||
|
internalIDFile = flag.String("internal-id-file", "", "path to a persisted internal service identity (JSON); enables `membershipd user add --store kv` against the live cluster. Empty = ephemeral per-process identity (dev default)")
|
||||||
)
|
)
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -136,9 +148,21 @@ func main() {
|
|||||||
var internalID cs.Identity
|
var internalID cs.Identity
|
||||||
var internalPubHex string
|
var internalPubHex string
|
||||||
if needJS && enforce && *natsURL == "" {
|
if needJS && enforce && *natsURL == "" {
|
||||||
internalID, err = cs.GenerateIdentity()
|
if *internalIDFile != "" {
|
||||||
if err != nil {
|
// Persisted identity: load it, generating + writing it (0600) on first
|
||||||
log.Fatalf("generate internal identity: %v", err)
|
// start. A stable internal key is what `user add --store kv` presents to
|
||||||
|
// add users to a live cluster (GAP A); rotate it by deleting the file and
|
||||||
|
// restarting.
|
||||||
|
internalID, err = client.LoadOrCreateIdentity(*internalIDFile)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load internal service identity %q: %v", *internalIDFile, err)
|
||||||
|
}
|
||||||
|
log.Printf("internal service identity: persisted (%s)", *internalIDFile)
|
||||||
|
} else {
|
||||||
|
internalID, err = cs.GenerateIdentity()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("generate internal identity: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
internalPubHex = hex.EncodeToString(internalID.SignPub)
|
internalPubHex = hex.EncodeToString(internalID.SignPub)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -50,13 +51,26 @@ commands:
|
|||||||
list List all registered users
|
list List all registered users
|
||||||
revoke Revoke a user (denies access on both planes immediately)
|
revoke Revoke a user (denies access on both planes immediately)
|
||||||
|
|
||||||
|
store backends (--store):
|
||||||
|
sqlite local SQLite database (default; seeds the first admin offline)
|
||||||
|
kv the RUNNING cluster's replicated JetStream KV allowlist, via the
|
||||||
|
privileged internal connection — add users with the cluster live,
|
||||||
|
no stop-seed-restart needed (run over loopback/SSH on a node)
|
||||||
|
|
||||||
examples:
|
examples:
|
||||||
membershipd user add --handle alice --sign-pub <64-hex> --role admin
|
membershipd user add --handle alice --sign-pub <64-hex> --role admin
|
||||||
membershipd user list
|
membershipd user add --store kv --handle bob --sign-pub <64-hex> --role member
|
||||||
|
membershipd user list --store kv
|
||||||
membershipd user revoke <64-hex>
|
membershipd user revoke <64-hex>
|
||||||
|
|
||||||
common flags:
|
common flags:
|
||||||
--db <path> SQLite database path (default ./local_files/unibus.db)
|
--db <path> SQLite database path (--store sqlite; default ./local_files/unibus.db)
|
||||||
|
|
||||||
|
--store kv flags (defaults assume an on-node invocation):
|
||||||
|
--nats-url <url> cluster NATS (default nats://127.0.0.1:4250)
|
||||||
|
--internal-id-file <path> persisted internal service identity (default /opt/unibus/secrets/internal.id)
|
||||||
|
--ca <path> CA cert pinning the data-plane TLS (default /opt/unibus/tls/ca.crt)
|
||||||
|
--kv-replicas <n> KV replication factor, match the cluster (default 3)
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,12 +102,59 @@ func validateSignPubHex(signPub string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// kvFlags holds the connection flags shared by the --store kv path of the user
|
||||||
|
// subcommands. registerKVFlags wires them onto a flag set so add and list expose
|
||||||
|
// an identical interface.
|
||||||
|
type kvFlags struct {
|
||||||
|
store *string
|
||||||
|
natsURL *string
|
||||||
|
internalID *string
|
||||||
|
ca *string
|
||||||
|
replicas *int
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerKVFlags(fs *flag.FlagSet) kvFlags {
|
||||||
|
return kvFlags{
|
||||||
|
store: fs.String("store", "sqlite", "user store backend: sqlite (local DB) | kv (the live cluster's replicated allowlist)"),
|
||||||
|
natsURL: fs.String("nats-url", defaultClusterNatsURL, "cluster NATS url for --store kv"),
|
||||||
|
internalID: fs.String("internal-id-file", defaultInternalIDFile, "persisted internal service identity for --store kv"),
|
||||||
|
ca: fs.String("ca", defaultClusterCAFile, "CA cert pinning TLS on the --store kv NATS connection"),
|
||||||
|
replicas: fs.Int("kv-replicas", 3, "KV replication factor for --store kv (match the cluster)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveStore returns the membership store for the chosen backend plus a cleanup
|
||||||
|
// func. For --store kv it opens the privileged connection to the live cluster; for
|
||||||
|
// sqlite it opens the local file. It exits the process with a clear message on any
|
||||||
|
// failure (a dead NATS, a missing identity file), so a broken --store kv add fails
|
||||||
|
// loudly instead of silently — Error case of the GAP A DoD. The returned *kvConn
|
||||||
|
// is non-nil only for the kv backend (so the caller can report replication).
|
||||||
|
func resolveStore(cmd string, kf kvFlags, dbPath string) (membership.Store, *kvConn, func()) {
|
||||||
|
switch *kf.store {
|
||||||
|
case "sqlite":
|
||||||
|
store := openStore(dbPath)
|
||||||
|
return store, nil, func() { store.Close() }
|
||||||
|
case "kv":
|
||||||
|
kv, err := connectKVStore(*kf.natsURL, *kf.internalID, *kf.ca, *kf.replicas)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "membershipd %s: --store kv: %v\n", cmd, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return kv.store, kv, kv.Close
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "membershipd %s: --store must be \"sqlite\" or \"kv\", got %q\n", cmd, *kf.store)
|
||||||
|
os.Exit(2)
|
||||||
|
return nil, nil, func() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func userAdd(args []string) {
|
func userAdd(args []string) {
|
||||||
fs := flag.NewFlagSet("user add", flag.ExitOnError)
|
fs := flag.NewFlagSet("user add", flag.ExitOnError)
|
||||||
handle := fs.String("handle", "", "human-readable user name (required)")
|
handle := fs.String("handle", "", "human-readable user name (required)")
|
||||||
signPub := fs.String("sign-pub", "", "Ed25519 signing public key in hex (required)")
|
signPub := fs.String("sign-pub", "", "Ed25519 signing public key in hex (required)")
|
||||||
role := fs.String("role", membership.RoleMember, "role: admin or member")
|
role := fs.String("role", membership.RoleMember, "role: admin or member")
|
||||||
dbPath := fs.String("db", defaultDBPath, "SQLite database path")
|
dbPath := fs.String("db", defaultDBPath, "SQLite database path")
|
||||||
|
kf := registerKVFlags(fs)
|
||||||
_ = fs.Parse(args)
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
if *handle == "" || *signPub == "" {
|
if *handle == "" || *signPub == "" {
|
||||||
@@ -105,23 +166,35 @@ func userAdd(args []string) {
|
|||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
store := openStore(*dbPath)
|
store, kv, closeStore := resolveStore("user add", kf, *dbPath)
|
||||||
defer store.Close()
|
defer closeStore()
|
||||||
|
|
||||||
if err := store.AddUser(*signPub, *handle, *role); err != nil {
|
if err := store.AddUser(*signPub, *handle, *role); err != nil {
|
||||||
|
if errors.Is(err, membership.ErrUserExists) {
|
||||||
|
// Idempotency contract (GAP A): re-adding the same key is an EXPLICIT,
|
||||||
|
// non-destructive error — the existing row is left untouched (no silent
|
||||||
|
// upsert that could flip a role or clobber status, which would corrupt the
|
||||||
|
// allowlist). To replace a user, `user revoke <key>` then add again.
|
||||||
|
fmt.Fprintf(os.Stderr, "membershipd user add: user %s already registered (unchanged); revoke it first to replace\n", *signPub)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
fmt.Fprintf(os.Stderr, "membershipd user add: %v\n", err)
|
fmt.Fprintf(os.Stderr, "membershipd user add: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
fmt.Printf("added user %q (%s) role=%s\n", *handle, *signPub, *role)
|
fmt.Printf("added user %q (%s) role=%s\n", *handle, *signPub, *role)
|
||||||
|
if kv != nil {
|
||||||
|
reportKVReplication(kv.js)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func userList(args []string) {
|
func userList(args []string) {
|
||||||
fs := flag.NewFlagSet("user list", flag.ExitOnError)
|
fs := flag.NewFlagSet("user list", flag.ExitOnError)
|
||||||
dbPath := fs.String("db", defaultDBPath, "SQLite database path")
|
dbPath := fs.String("db", defaultDBPath, "SQLite database path")
|
||||||
|
kf := registerKVFlags(fs)
|
||||||
_ = fs.Parse(args)
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
store := openStore(*dbPath)
|
store, _, closeStore := resolveStore("user list", kf, *dbPath)
|
||||||
defer store.Close()
|
defer closeStore()
|
||||||
|
|
||||||
users, err := store.ListUsers()
|
users, err := store.ListUsers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -143,6 +216,7 @@ func userList(args []string) {
|
|||||||
func userRevoke(args []string) {
|
func userRevoke(args []string) {
|
||||||
fs := flag.NewFlagSet("user revoke", flag.ExitOnError)
|
fs := flag.NewFlagSet("user revoke", flag.ExitOnError)
|
||||||
dbPath := fs.String("db", defaultDBPath, "SQLite database path")
|
dbPath := fs.String("db", defaultDBPath, "SQLite database path")
|
||||||
|
kf := registerKVFlags(fs)
|
||||||
|
|
||||||
// Go's flag package stops at the first non-flag argument, so `revoke <key>
|
// Go's flag package stops at the first non-flag argument, so `revoke <key>
|
||||||
// --db path` would otherwise leave --db unparsed. Pull a leading positional
|
// --db path` would otherwise leave --db unparsed. Pull a leading positional
|
||||||
@@ -167,8 +241,8 @@ func userRevoke(args []string) {
|
|||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
store := openStore(*dbPath)
|
store, _, closeStore := resolveStore("user revoke", kf, *dbPath)
|
||||||
defer store.Close()
|
defer closeStore()
|
||||||
|
|
||||||
if err := store.RevokeUser(signPub); err != nil {
|
if err := store.RevokeUser(signPub); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "membershipd user revoke: %v\n", err)
|
fmt.Fprintf(os.Stderr, "membershipd user revoke: %v\n", err)
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/enmanuel/unibus/pkg/busauth"
|
||||||
|
"github.com/enmanuel/unibus/pkg/client"
|
||||||
|
"github.com/enmanuel/unibus/pkg/membership"
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
"github.com/nats-io/nats.go/jetstream"
|
||||||
|
)
|
||||||
|
|
||||||
|
// users_kv.go is the `--store kv` half of the user administration CLI (issue 0011
|
||||||
|
// gaps, GAP A): adding and listing bus users directly against the RUNNING
|
||||||
|
// cluster's replicated JetStream KV allowlist, with no need to stop the cluster,
|
||||||
|
// seed a standalone node, and restart (the procedure the 0011 deploy required).
|
||||||
|
//
|
||||||
|
// The mechanism is the cluster's own privileged internal connection. Under
|
||||||
|
// enforce every bus user is confined by the per-subject ACL to the JetStream API
|
||||||
|
// of its own rooms, so no ordinary identity may touch the control-plane buckets
|
||||||
|
// (KV_UNIBUS_*). The ONLY identity the authenticator grants full JetStream
|
||||||
|
// permissions is membershipd's internal service identity. By persisting that
|
||||||
|
// identity to a file (membershipd --internal-id-file) the same key becomes
|
||||||
|
// available to this CLI, which presents it as its NATS nkey and is therefore
|
||||||
|
// recognized as the privileged internal client and allowed to read/write the KV.
|
||||||
|
//
|
||||||
|
// Intended invocation is over loopback on a cluster node (SSH): the data-plane
|
||||||
|
// TLS certificate's SAN covers 127.0.0.1/localhost and the internal identity file
|
||||||
|
// lives 0600 next to the node's TLS keys. Using the file requires root on the
|
||||||
|
// node, which already implies full control of that node — so co-locating it adds
|
||||||
|
// no practical exposure beyond what the TLS server key and cluster password
|
||||||
|
// already represent.
|
||||||
|
|
||||||
|
// defaultClusterNatsURL is the node-local NATS listener. The CLI is meant to run
|
||||||
|
// on a cluster node over SSH, talking to that node's own embedded server.
|
||||||
|
const defaultClusterNatsURL = "nats://127.0.0.1:4250"
|
||||||
|
|
||||||
|
// Deploy-default paths for the privileged identity and the data-plane CA, so an
|
||||||
|
// on-node invocation needs only --handle/--sign-pub/--role. Override for other
|
||||||
|
// layouts.
|
||||||
|
const (
|
||||||
|
defaultInternalIDFile = "/opt/unibus/secrets/internal.id"
|
||||||
|
defaultClusterCAFile = "/opt/unibus/tls/ca.crt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// kvConn bundles the privileged NATS connection to a live cluster and the
|
||||||
|
// KV-backed control-plane store opened over it. Close releases both.
|
||||||
|
type kvConn struct {
|
||||||
|
nc *nats.Conn
|
||||||
|
js jetstream.JetStream
|
||||||
|
store membership.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *kvConn) Close() {
|
||||||
|
if k == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if k.store != nil {
|
||||||
|
_ = k.store.Close()
|
||||||
|
}
|
||||||
|
if k.nc != nil {
|
||||||
|
k.nc.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectKVStore opens the privileged internal connection to the cluster's NATS
|
||||||
|
// and the JetStream KV control-plane store on top of it. internalIDFile is the
|
||||||
|
// membershipd-persisted internal service identity whose nkey the authenticator
|
||||||
|
// grants full permissions; caPath pins the data-plane TLS (empty only for a
|
||||||
|
// non-TLS dev cluster). A non-loopback target without --ca is refused, mirroring
|
||||||
|
// migrate-to-kv (audit 0008 N6): the allowlist write must not travel in cleartext.
|
||||||
|
func connectKVStore(natsURL, internalIDFile, caPath string, replicas int) (*kvConn, error) {
|
||||||
|
if internalIDFile == "" {
|
||||||
|
return nil, fmt.Errorf("--internal-id-file is required for --store kv (the privileged identity membershipd persists with --internal-id-file)")
|
||||||
|
}
|
||||||
|
// Confidentiality guard: a remote NATS without TLS would expose the allowlist
|
||||||
|
// (handles/roles/sign-pubs) and the privileged nkey handshake in cleartext.
|
||||||
|
if !isLoopbackURL(natsURL) && caPath == "" {
|
||||||
|
return nil, fmt.Errorf("refusing to connect to remote %q without --ca: the allowlist write would travel in cleartext — pin TLS with --ca, or run over a loopback --nats-url on a node", natsURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := client.LoadIdentity(internalIDFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load internal identity: %w", err)
|
||||||
|
}
|
||||||
|
nkeyPub, nkeySign, err := busauth.ClientNkey(id.SignPriv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("derive nkey from internal identity: %w", err)
|
||||||
|
}
|
||||||
|
opts := []nats.Option{
|
||||||
|
nats.Name("membershipd-user-cli"),
|
||||||
|
nats.Nkey(nkeyPub, nkeySign),
|
||||||
|
}
|
||||||
|
if caPath != "" {
|
||||||
|
tlsCfg, err := busauth.LoadCATLSConfig(caPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load CA %q: %w", caPath, err)
|
||||||
|
}
|
||||||
|
opts = append(opts, nats.Secure(tlsCfg))
|
||||||
|
}
|
||||||
|
nc, err := nats.Connect(natsURL, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect cluster NATS %q: %w", natsURL, err)
|
||||||
|
}
|
||||||
|
js, err := jetstream.New(nc)
|
||||||
|
if err != nil {
|
||||||
|
nc.Close()
|
||||||
|
return nil, fmt.Errorf("jetstream: %w", err)
|
||||||
|
}
|
||||||
|
store, err := membership.OpenJetStream(js, membership.JetStreamConfig{Replicas: replicas})
|
||||||
|
if err != nil {
|
||||||
|
nc.Close()
|
||||||
|
return nil, fmt.Errorf("open KV control-plane store: %w", err)
|
||||||
|
}
|
||||||
|
return &kvConn{nc: nc, js: js, store: store}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportKVReplication prints the replication status of the allowlist bucket
|
||||||
|
// stream (KV_UNIBUS_users) right after a write, so the operator sees the add
|
||||||
|
// landed on a quorum and replicated to the followers — executable evidence that
|
||||||
|
// the live-cluster add is HA, not single-node. Best-effort: a read failure is a
|
||||||
|
// note, not an error (the write itself already succeeded).
|
||||||
|
func reportKVReplication(js jetstream.JetStream) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
st, err := js.Stream(ctx, "KV_UNIBUS_users")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "note: could not read KV_UNIBUS_users stream info: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, err := st.Info(ctx)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "note: could not read KV_UNIBUS_users stream info: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if info.Cluster == nil {
|
||||||
|
fmt.Printf("KV_UNIBUS_users: standalone (R1, no cluster replication); msgs=%d\n", info.State.Msgs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current := 0
|
||||||
|
for _, r := range info.Cluster.Replicas {
|
||||||
|
if r.Current {
|
||||||
|
current++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("KV_UNIBUS_users: leader=%s followers_current=%d/%d msgs=%d\n",
|
||||||
|
info.Cluster.Leader, current, len(info.Cluster.Replicas), info.State.Msgs)
|
||||||
|
}
|
||||||
+27
-11
@@ -33,20 +33,36 @@ type identityFile struct {
|
|||||||
KexPriv string `json:"kex_priv"`
|
KexPriv string `json:"kex_priv"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadIdentity loads an existing identity from path. Unlike LoadOrCreateIdentity
|
||||||
|
// it NEVER creates one: a missing or unreadable file is an error. It is for
|
||||||
|
// callers that must consume a specific, pre-provisioned identity rather than mint
|
||||||
|
// a fresh one — for example membershipd's persisted internal service identity,
|
||||||
|
// which `membershipd user add --store kv` reads to present the privileged nkey
|
||||||
|
// the cluster authenticator recognizes.
|
||||||
|
func LoadIdentity(path string) (cs.Identity, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return cs.Identity{}, fmt.Errorf("client: read identity %q: %w", path, err)
|
||||||
|
}
|
||||||
|
var f identityFile
|
||||||
|
if err := json.Unmarshal(data, &f); err != nil {
|
||||||
|
return cs.Identity{}, fmt.Errorf("client: parse identity %q: %w", path, err)
|
||||||
|
}
|
||||||
|
id, err := f.toIdentity()
|
||||||
|
if err != nil {
|
||||||
|
return cs.Identity{}, fmt.Errorf("client: decode identity %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
// LoadOrCreateIdentity loads the identity at path, or generates and persists a
|
// LoadOrCreateIdentity loads the identity at path, or generates and persists a
|
||||||
// new one if the file does not exist. The file is written with 0600
|
// new one if the file does not exist. The file is written with 0600
|
||||||
// permissions because it holds private keys.
|
// permissions because it holds private keys. A file that exists but is
|
||||||
|
// unreadable or corrupt is an error (NOT silently regenerated), so a damaged
|
||||||
|
// identity surfaces instead of minting a new key that cannot decrypt old data.
|
||||||
func LoadOrCreateIdentity(path string) (cs.Identity, error) {
|
func LoadOrCreateIdentity(path string) (cs.Identity, error) {
|
||||||
if data, err := os.ReadFile(path); err == nil {
|
if _, statErr := os.Stat(path); statErr == nil {
|
||||||
var f identityFile
|
return LoadIdentity(path)
|
||||||
if err := json.Unmarshal(data, &f); err != nil {
|
|
||||||
return cs.Identity{}, fmt.Errorf("client: parse identity %q: %w", path, err)
|
|
||||||
}
|
|
||||||
id, err := f.toIdentity()
|
|
||||||
if err != nil {
|
|
||||||
return cs.Identity{}, fmt.Errorf("client: decode identity %q: %w", path, err)
|
|
||||||
}
|
|
||||||
return id, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := cs.GenerateIdentity()
|
id, err := cs.GenerateIdentity()
|
||||||
|
|||||||
Reference in New Issue
Block a user