Files
unibus/cmd/membershipd/users_cli.go
T
agent 6b3ace1d39 feat(0003b): membership.Store interface + JetStream KV implementation
Branch-by-abstraction for the control-plane store (issue 0003b), so the
membership state can move off process-local SQLite onto replicated
JetStream KV without rewriting callers and without breaking master.

pkg/membership:
- Store is now an interface (rooms/members/keys + user allowlist +
  Close). The existing SQLite implementation is renamed sqliteStore and
  stays the default: Open(path) still returns it. openSQLite keeps the
  concrete type for internal callers (the 0003c migration).
- ErrNotFound is a storage-agnostic "no such record" sentinel; both
  backends return it (the SQLite store maps sql.ErrNoRows to it). The
  control plane now branches on ErrNotFound instead of sql.ErrNoRows, so
  server.go no longer imports database/sql.
- jetstreamStore (new) implements Store over five replicated KV buckets:
  rooms, members, rooms_by_member (reverse index for ListRoomsForEndpoint),
  room_keys, users. Replication factor is configurable (R1..R5) for the
  R1->R3 rollout. Every read is bounded by OpTimeout and IsAuthorized /
  HasAdmin FAIL CLOSED on any backend error (a KV quorum loss denies,
  never admits), per the audit's requirement for the decentralized store.

dev/feature_flags.json:
- Add the `decentralized` flag (OFF): sqliteStore default while off,
  jetstreamStore behind it. The membershipd boot wiring that selects the
  KV store is deliberately deferred to 0003e/0003f (the embedded-NATS
  authenticator<->store bootstrap is part of the session/deploy redesign);
  OFF keeps the single-node SQLite control plane unchanged.

Tests (DoD: golden + edges + error path):
- TestJetStreamStoreRoomsCRUD: encrypted room + owner + invited member
  round-trip through every room/member/key method, including latest-epoch
  resolution and rekey.
- TestJetStreamStoreUsers: add/get/authorize/list/revoke + admin gate,
  with case-insensitive key normalization and duplicate rejection.
- TestJetStreamStoreNotFound: ErrNotFound mapping for misses.
- TestJetStreamStoreIsAuthorizedFailClosed: NATS backend shut down ->
  IsAuthorized and HasAdmin both DENY within the bounded timeout.

The full existing suite stays green: sqliteStore is unchanged behavior.
2026-06-07 15:04:52 +02:00

179 lines
5.2 KiB
Go

package main
import (
"encoding/hex"
"flag"
"fmt"
"os"
"strings"
"text/tabwriter"
"github.com/enmanuel/unibus/pkg/membership"
)
// runUserCLI implements `membershipd user <add|list|revoke> ...`, the local
// administration surface for the bus user allowlist. It opens the SQLite store
// directly (no network, no auth): it is meant to run on the bus host, where
// shell access already implies full control. This is the seam that seeds the
// first admin, breaking the chicken-egg of "you need an admin to add an admin".
//
// The function never returns: it exits the process with a non-zero status on
// error so it composes cleanly in shell scripts and systemd ExecStartPre hooks.
func runUserCLI(args []string) {
if len(args) == 0 {
userUsage()
os.Exit(2)
}
sub, rest := args[0], args[1:]
switch sub {
case "add":
userAdd(rest)
case "list":
userList(rest)
case "revoke":
userRevoke(rest)
case "-h", "--help", "help":
userUsage()
os.Exit(0)
default:
fmt.Fprintf(os.Stderr, "membershipd user: unknown subcommand %q\n\n", sub)
userUsage()
os.Exit(2)
}
}
func userUsage() {
fmt.Fprint(os.Stderr, `usage: membershipd user <command> [flags]
commands:
add Register a bus user from their Ed25519 signing public key
list List all registered users
revoke Revoke a user (denies access on both planes immediately)
examples:
membershipd user add --handle alice --sign-pub <64-hex> --role admin
membershipd user list
membershipd user revoke <64-hex>
common flags:
--db <path> SQLite database path (default ./local_files/unibus.db)
`)
}
const defaultDBPath = "./local_files/unibus.db"
// openStore opens the membership store at path, exiting on failure. Migrations
// (including 002_users.sql) are applied by membership.Open, so a fresh database
// gets the users table on first use of the CLI.
func openStore(path string) membership.Store {
store, err := membership.Open(path)
if err != nil {
fmt.Fprintf(os.Stderr, "membershipd user: open store %q: %v\n", path, err)
os.Exit(1)
}
return store
}
// validateSignPubHex ensures the key is exactly a 32-byte Ed25519 public key in
// hex (64 hex chars). Catching this here turns a silent "authorized nobody" into
// an explicit error at seed time.
func validateSignPubHex(signPub string) error {
b, err := hex.DecodeString(signPub)
if err != nil {
return fmt.Errorf("sign-pub is not valid hex: %w", err)
}
if len(b) != 32 {
return fmt.Errorf("sign-pub must be a 32-byte Ed25519 public key (64 hex chars), got %d bytes", len(b))
}
return nil
}
func userAdd(args []string) {
fs := flag.NewFlagSet("user add", flag.ExitOnError)
handle := fs.String("handle", "", "human-readable user name (required)")
signPub := fs.String("sign-pub", "", "Ed25519 signing public key in hex (required)")
role := fs.String("role", membership.RoleMember, "role: admin or member")
dbPath := fs.String("db", defaultDBPath, "SQLite database path")
_ = fs.Parse(args)
if *handle == "" || *signPub == "" {
fmt.Fprintln(os.Stderr, "membershipd user add: --handle and --sign-pub are required")
os.Exit(2)
}
if err := validateSignPubHex(*signPub); err != nil {
fmt.Fprintf(os.Stderr, "membershipd user add: %v\n", err)
os.Exit(2)
}
store := openStore(*dbPath)
defer store.Close()
if err := store.AddUser(*signPub, *handle, *role); err != nil {
fmt.Fprintf(os.Stderr, "membershipd user add: %v\n", err)
os.Exit(1)
}
fmt.Printf("added user %q (%s) role=%s\n", *handle, *signPub, *role)
}
func userList(args []string) {
fs := flag.NewFlagSet("user list", flag.ExitOnError)
dbPath := fs.String("db", defaultDBPath, "SQLite database path")
_ = fs.Parse(args)
store := openStore(*dbPath)
defer store.Close()
users, err := store.ListUsers()
if err != nil {
fmt.Fprintf(os.Stderr, "membershipd user list: %v\n", err)
os.Exit(1)
}
if len(users) == 0 {
fmt.Println("(no users)")
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
fmt.Fprintln(w, "HANDLE\tROLE\tSTATUS\tSIGN_PUB\tCREATED")
for _, u := range users {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", u.Handle, u.Role, u.Status, u.SignPub, u.CreatedAt)
}
_ = w.Flush()
}
func userRevoke(args []string) {
fs := flag.NewFlagSet("user revoke", flag.ExitOnError)
dbPath := fs.String("db", defaultDBPath, "SQLite database path")
// 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
// (the sign-pub) off the front before parsing so both `revoke <key> --db p`
// and `revoke --db p <key>` work for the operator.
var signPub string
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
signPub, args = args[0], args[1:]
}
_ = fs.Parse(args)
if signPub == "" {
if rest := fs.Args(); len(rest) == 1 {
signPub = rest[0]
}
}
if signPub == "" {
fmt.Fprintln(os.Stderr, "membershipd user revoke: exactly one <sign-pub> argument required")
os.Exit(2)
}
if err := validateSignPubHex(signPub); err != nil {
fmt.Fprintf(os.Stderr, "membershipd user revoke: %v\n", err)
os.Exit(2)
}
store := openStore(*dbPath)
defer store.Close()
if err := store.RevokeUser(signPub); err != nil {
fmt.Fprintf(os.Stderr, "membershipd user revoke: %v\n", err)
os.Exit(1)
}
fmt.Printf("revoked user %s\n", signPub)
}