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:
+27
-3
@@ -24,6 +24,7 @@ import (
|
||||
|
||||
"github.com/enmanuel/unibus/pkg/blobstore"
|
||||
"github.com/enmanuel/unibus/pkg/busauth"
|
||||
"github.com/enmanuel/unibus/pkg/client"
|
||||
"github.com/enmanuel/unibus/pkg/embeddednats"
|
||||
"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
|
||||
// in the cluster serves the same state.
|
||||
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()
|
||||
|
||||
@@ -136,9 +148,21 @@ func main() {
|
||||
var internalID cs.Identity
|
||||
var internalPubHex string
|
||||
if needJS && enforce && *natsURL == "" {
|
||||
internalID, err = cs.GenerateIdentity()
|
||||
if err != nil {
|
||||
log.Fatalf("generate internal identity: %v", err)
|
||||
if *internalIDFile != "" {
|
||||
// Persisted identity: load it, generating + writing it (0600) on first
|
||||
// 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user