package main import ( "errors" "flag" "fmt" "os" "strings" "text/tabwriter" "github.com/enmanuel/unibus/pkg/membership" ) // runUserCLI implements `membershipd user ...`, 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 [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) 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: membershipd user add --handle alice --sign-pub <64-hex> --role admin membershipd user add --store kv --handle bob --sign-pub <64-hex> --role member membershipd user list --store kv membershipd user revoke <64-hex> common flags: --db SQLite database path (--store sqlite; default ./local_files/unibus.db) --store kv flags (defaults assume an on-node invocation): --nats-url cluster NATS (default nats://127.0.0.1:4250) --internal-id-file persisted internal service identity (default /opt/unibus/secrets/internal.id) --ca CA cert pinning the data-plane TLS (default /opt/unibus/tls/ca.crt) --kv-replicas KV replication factor, match the cluster (default 3) `) } 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. It delegates to membership.ValidateSignPubHex // so the CLI and the HTTP user-management handlers share one rule. func validateSignPubHex(signPub string) error { return membership.ValidateSignPubHex(signPub) } // 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) { 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") kf := registerKVFlags(fs) _ = 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, kv, closeStore := resolveStore("user add", kf, *dbPath) defer closeStore() 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 ` 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) os.Exit(1) } fmt.Printf("added user %q (%s) role=%s\n", *handle, *signPub, *role) if kv != nil { reportKVReplication(kv.js) } } func userList(args []string) { fs := flag.NewFlagSet("user list", flag.ExitOnError) dbPath := fs.String("db", defaultDBPath, "SQLite database path") kf := registerKVFlags(fs) _ = fs.Parse(args) store, _, closeStore := resolveStore("user list", kf, *dbPath) defer closeStore() 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") kf := registerKVFlags(fs) // Go's flag package stops at the first non-flag argument, so `revoke // --db path` would otherwise leave --db unparsed. Pull a leading positional // (the sign-pub) off the front before parsing so both `revoke --db p` // and `revoke --db p ` 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 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, _, closeStore := resolveStore("user revoke", kf, *dbPath) defer closeStore() 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) }