450ca01baf
Close the last control-plane asymmetry: rooms had a signed HTTP surface
but users were only manageable via the local CLI or direct store access.
Add admin-only HTTP endpoints, symmetric with rooms, executed against the
same privileged store the server already serves (SQLite single-node, the
replicated JetStream KV in cluster) — no new KV connection, no internal
identity, so the admin panel can manage the allowlist by signing as an
admin instead of needing --db / direct KV access.
Endpoints (all behind requireAdmin, on top of the existing
signature+nonce+TLS+enforce middleware):
- GET /users list the full allowlist (incl. revoked)
- POST /users add {sign_pub, handle, role}
- POST /users/{signpub}/revoke revoke (status flip, no hard delete)
requireAdmin is default-deny with no dev relaxation: it allows a request
only when the authenticated signer is confirmed by the store as an active
admin; any other case (no signer, non-admin, revoked, store error) is 403,
fail-closed. The request context now also carries the signer's sign_pub
hex, because the endpoint id is a one-way hash of the key and cannot be
reversed to look the signer up in the allowlist.
Validation/idempotency mirror the CLL: sign_pub must be 64-hex, role must
be admin|member (empty defaults to member), re-adding an existing key is a
409 that leaves the row untouched. The hex check is unified into
membership.ValidateSignPubHex, reused by the CLI and the handlers.
pkg/client gains ListUsers/AddUser/RevokeUser (flat UserInfo type) signed
via doJSON, so the panel plugs in directly.
Tests: non-admin -> 403 on all three endpoints; admin add->list->revoke
roundtrip; validation (400 hex, 400 role, 409 re-add, row untouched); plus
a client test against an embedded membershipd under enforce.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
246 lines
8.7 KiB
Go
246 lines
8.7 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"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)
|
|
|
|
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 <path> SQLite database path (--store sqlite; default ./local_files/unibus.db)
|
|
|
|
--store kv flags (defaults assume an on-node invocation):
|
|
--nats-url <url> cluster NATS (default nats://127.0.0.1:4250)
|
|
--internal-id-file <path> persisted internal service identity (default /opt/unibus/secrets/internal.id)
|
|
--ca <path> CA cert pinning the data-plane TLS (default /opt/unibus/tls/ca.crt)
|
|
--kv-replicas <n> 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 <key>` 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 <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, _, 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)
|
|
}
|