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) <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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 <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)
|
||||
}
|
||||
Reference in New Issue
Block a user