From 0d7ab22d4a48acd9193aadc2d667a02cdd0b0a9c Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 7 Jun 2026 12:23:16 +0200 Subject: [PATCH] feat(membershipd): add 'user add/list/revoke' local admin CLI Local administration surface for the user allowlist, dispatched before the server flag set parses os.Args. It opens the SQLite store directly with no network or auth: running on the bus host is trusted by design, which is how the first admin is seeded (breaking the chicken-egg of needing an admin to add an admin). Validates that sign-pub is a 32-byte Ed25519 key in hex and tolerates the sign-pub positional appearing before or after --db. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/membershipd/main.go | 10 ++ cmd/membershipd/users_cli.go | 178 +++++++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 cmd/membershipd/users_cli.go diff --git a/cmd/membershipd/main.go b/cmd/membershipd/main.go index 4fab090..6ed2d59 100644 --- a/cmd/membershipd/main.go +++ b/cmd/membershipd/main.go @@ -22,6 +22,16 @@ import ( ) func main() { + // Subcommand dispatch: `membershipd user ...` is the local administration CLI + // (seed/list/revoke bus users) and must be handled before the server flag set + // parses os.Args. Running the CLI on the bus host is trusted by design (whoever + // has a shell there already controls the service), which is how the first admin + // is seeded without a chicken-egg auth problem. + if len(os.Args) > 1 && os.Args[1] == "user" { + runUserCLI(os.Args[2:]) + return + } + var ( bind = flag.String("bind", "127.0.0.1", "network interface to bind the HTTP API and the embedded NATS to; use 0.0.0.0 to accept LAN/remote peers") natsURL = flag.String("nats-url", "", "external NATS url; empty starts an embedded server") diff --git a/cmd/membershipd/users_cli.go b/cmd/membershipd/users_cli.go new file mode 100644 index 0000000..e21276c --- /dev/null +++ b/cmd/membershipd/users_cli.go @@ -0,0 +1,178 @@ +package main + +import ( + "encoding/hex" + "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) + +examples: + membershipd user add --handle alice --sign-pub <64-hex> --role admin + membershipd user list + membershipd user revoke <64-hex> + +common flags: + --db 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 + // --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 := 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) +}