From c5387028e0fd412282a61777798947cc5c55ec41 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 7 Jun 2026 12:23:11 +0200 Subject: [PATCH 1/4] feat(membership): add 002_users.sql migration and user CRUD store Bus-level user allowlist (issue 0001a): the authoritative directory of Ed25519 signing identities permitted to use the bus, independent of room membership. Migration is additive and mirrored byte-for-byte between the module-root migrations/ and the embedded pkg/membership/migrations/. Store adds AddUser/GetUser/ListUsers/RevokeUser/IsAuthorized/HasAdmin. IsAuthorized is the single fail-closed predicate both the control plane and the NATS data plane will consult, so revocation is a status flip that denies access on both without a restart. Co-Authored-By: Claude Opus 4.8 (1M context) --- migrations/002_users.sql | 22 ++++ pkg/membership/migrations/002_users.sql | 22 ++++ pkg/membership/users.go | 164 ++++++++++++++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 migrations/002_users.sql create mode 100644 pkg/membership/migrations/002_users.sql create mode 100644 pkg/membership/users.go diff --git a/migrations/002_users.sql b/migrations/002_users.sql new file mode 100644 index 0000000..7c0c823 --- /dev/null +++ b/migrations/002_users.sql @@ -0,0 +1,22 @@ +-- 002_users.sql — bus-level user directory (issue 0001a). +-- +-- The authoritative allowlist of identities permitted to use the bus, independent +-- of room membership. A user is identified by its Ed25519 signing public key (the +-- same key that derives the endpoint via frame.EndpointID); roles gate admin-only +-- control-plane operations; status enables revocation without deleting history. +-- +-- Additive and idempotent: safe to apply repeatedly. Never modify this file; +-- further schema changes go in new numbered migrations (see +-- .claude/rules/db_migrations.md). The embedded copy under +-- pkg/membership/migrations/002_users.sql mirrors this file byte-for-byte. + +CREATE TABLE IF NOT EXISTS users ( + sign_pub TEXT PRIMARY KEY, -- Ed25519 public key in lowercase hex (peer identity) + handle TEXT NOT NULL, -- human-readable name (unique recommended, not enforced as PK) + role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member' + status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'revoked' + created_at TEXT NOT NULL, + revoked_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_users_status ON users(status); diff --git a/pkg/membership/migrations/002_users.sql b/pkg/membership/migrations/002_users.sql new file mode 100644 index 0000000..7c0c823 --- /dev/null +++ b/pkg/membership/migrations/002_users.sql @@ -0,0 +1,22 @@ +-- 002_users.sql — bus-level user directory (issue 0001a). +-- +-- The authoritative allowlist of identities permitted to use the bus, independent +-- of room membership. A user is identified by its Ed25519 signing public key (the +-- same key that derives the endpoint via frame.EndpointID); roles gate admin-only +-- control-plane operations; status enables revocation without deleting history. +-- +-- Additive and idempotent: safe to apply repeatedly. Never modify this file; +-- further schema changes go in new numbered migrations (see +-- .claude/rules/db_migrations.md). The embedded copy under +-- pkg/membership/migrations/002_users.sql mirrors this file byte-for-byte. + +CREATE TABLE IF NOT EXISTS users ( + sign_pub TEXT PRIMARY KEY, -- Ed25519 public key in lowercase hex (peer identity) + handle TEXT NOT NULL, -- human-readable name (unique recommended, not enforced as PK) + role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member' + status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'revoked' + created_at TEXT NOT NULL, + revoked_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_users_status ON users(status); diff --git a/pkg/membership/users.go b/pkg/membership/users.go new file mode 100644 index 0000000..7aeb1c5 --- /dev/null +++ b/pkg/membership/users.go @@ -0,0 +1,164 @@ +package membership + +import ( + "database/sql" + "errors" + "fmt" + "strings" +) + +// User roles and statuses. They are stored as free text in the users table so +// new values can be introduced without a schema change; these constants name +// the ones the code reasons about today. +const ( + RoleAdmin = "admin" + RoleMember = "member" + StatusActive = "active" + StatusRevoked = "revoked" +) + +// ErrUserExists is returned by AddUser when a user with the same sign_pub is +// already registered. Callers that want upsert semantics should branch on it. +var ErrUserExists = errors.New("membership: user already exists") + +// User is a bus-level identity in the allowlist: the Ed25519 signing public key +// that authenticates a peer on both the control plane (request signatures) and +// the data plane (NATS nkey), plus its role and revocation status. SignPub is +// the lowercase hex of the 32-byte Ed25519 public key — the same key that +// derives the endpoint id via frame.EndpointID. +type User struct { + SignPub string // Ed25519 public key, lowercase hex + Handle string + Role string // RoleAdmin | RoleMember + Status string // StatusActive | StatusRevoked + CreatedAt string + RevokedAt string // empty unless revoked +} + +// normalizeSignPub lowercases the hex key so lookups are case-insensitive: the +// primary key is stored lowercase and every query normalizes its input the same +// way, so a caller passing uppercase hex still matches. +func normalizeSignPub(signPub string) string { + return strings.ToLower(strings.TrimSpace(signPub)) +} + +// AddUser inserts a new bus user. role defaults to RoleMember when empty. It +// returns ErrUserExists if the sign_pub is already registered (the caller may +// choose to revoke+re-add or ignore). handle and signPub must be non-empty. +func (s *Store) AddUser(signPub, handle, role string) error { + signPub = normalizeSignPub(signPub) + if signPub == "" || handle == "" { + return fmt.Errorf("membership: AddUser: sign_pub and handle required") + } + if role == "" { + role = RoleMember + } + if role != RoleAdmin && role != RoleMember { + return fmt.Errorf("membership: AddUser: invalid role %q (want %q or %q)", role, RoleAdmin, RoleMember) + } + _, err := s.db.Exec( + `INSERT INTO users (sign_pub, handle, role, status, created_at) VALUES (?, ?, ?, ?, ?)`, + signPub, handle, role, StatusActive, nowRFC3339(), + ) + if err != nil { + // modernc.org/sqlite surfaces a UNIQUE/PRIMARY KEY violation as a message + // containing "UNIQUE constraint failed"; translate it into a typed error so + // callers do not have to string-match. + if strings.Contains(err.Error(), "UNIQUE constraint") || strings.Contains(err.Error(), "PRIMARY KEY") { + return ErrUserExists + } + return fmt.Errorf("membership: insert user: %w", err) + } + return nil +} + +// GetUser returns the user with the given signing public key. It returns +// sql.ErrNoRows (wrapped) when there is no such user. +func (s *Store) GetUser(signPub string) (User, error) { + signPub = normalizeSignPub(signPub) + var u User + var revoked sql.NullString + err := s.db.QueryRow( + `SELECT sign_pub, handle, role, status, created_at, revoked_at FROM users WHERE sign_pub = ?`, + signPub, + ).Scan(&u.SignPub, &u.Handle, &u.Role, &u.Status, &u.CreatedAt, &revoked) + if err != nil { + return User{}, fmt.Errorf("membership: get user %q: %w", signPub, err) + } + u.RevokedAt = revoked.String + return u, nil +} + +// ListUsers returns every user ordered by handle then sign_pub (stable output). +func (s *Store) ListUsers() ([]User, error) { + rows, err := s.db.Query( + `SELECT sign_pub, handle, role, status, created_at, revoked_at FROM users ORDER BY handle, sign_pub`, + ) + if err != nil { + return nil, fmt.Errorf("membership: list users: %w", err) + } + defer rows.Close() + + var out []User + for rows.Next() { + var u User + var revoked sql.NullString + if err := rows.Scan(&u.SignPub, &u.Handle, &u.Role, &u.Status, &u.CreatedAt, &revoked); err != nil { + return nil, fmt.Errorf("membership: scan user: %w", err) + } + u.RevokedAt = revoked.String + out = append(out, u) + } + return out, rows.Err() +} + +// RevokeUser marks a user as revoked and stamps revoked_at. Revocation is a +// status flip (not a delete) so the identity stays auditable and IsAuthorized +// immediately denies it on both planes. Revoking an unknown or already-revoked +// user returns an error / is a no-op respectively. +func (s *Store) RevokeUser(signPub string) error { + signPub = normalizeSignPub(signPub) + res, err := s.db.Exec( + `UPDATE users SET status = ?, revoked_at = ? WHERE sign_pub = ? AND status = ?`, + StatusRevoked, nowRFC3339(), signPub, StatusActive, + ) + if err != nil { + return fmt.Errorf("membership: revoke user %q: %w", signPub, err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("membership: revoke user %q: rows affected: %w", signPub, err) + } + if n == 0 { + return fmt.Errorf("membership: revoke user %q: no active user with that key", signPub) + } + return nil +} + +// IsAuthorized reports whether signPub belongs to an active (non-revoked) bus +// user. It is the single authorization predicate consulted by both the control +// plane (HTTP request middleware) and the data plane (NATS nkey authenticator), +// so revoking a user denies access on both without restarting anything. An +// unknown key, a revoked key, or any query error all yield false (fail closed). +func (s *Store) IsAuthorized(signPub string) bool { + signPub = normalizeSignPub(signPub) + if signPub == "" { + return false + } + var one int + err := s.db.QueryRow( + `SELECT 1 FROM users WHERE sign_pub = ? AND status = ?`, signPub, StatusActive, + ).Scan(&one) + return err == nil && one == 1 +} + +// HasAdmin reports whether at least one active admin exists. The control plane +// uses it to gate user-management endpoints: until the host operator seeds the +// first admin via the local CLI, those endpoints stay closed (chicken-egg). +func (s *Store) HasAdmin() bool { + var one int + err := s.db.QueryRow( + `SELECT 1 FROM users WHERE role = ? AND status = ? LIMIT 1`, RoleAdmin, StatusActive, + ).Scan(&one) + return err == nil && one == 1 +} From 0d7ab22d4a48acd9193aadc2d667a02cdd0b0a9c Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 7 Jun 2026 12:23:16 +0200 Subject: [PATCH 2/4] 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) +} From ddc6cabc2447123697ce6332285a07fd7b558b24 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 7 Jun 2026 12:23:23 +0200 Subject: [PATCH 3/4] feat(flags): declare bus-auth and bus-tls feature flags (off) bus-auth carries the off -> soft -> enforce rollout state; bus-tls is a boolean. Both start disabled so master keeps compiling and passing tests while the auth/TLS code lands behind them across phases 0001a-0001e. Co-Authored-By: Claude Opus 4.8 (1M context) --- dev/feature_flags.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 dev/feature_flags.json diff --git a/dev/feature_flags.json b/dev/feature_flags.json new file mode 100644 index 0000000..3027207 --- /dev/null +++ b/dev/feature_flags.json @@ -0,0 +1,19 @@ +{ + "flags": { + "bus-auth": { + "enabled": false, + "state": "off", + "issue": "0001", + "description": "Signed control-plane auth + NATS nkey auth. Rollout: off -> soft (verify+log, allow) -> enforce (reject). 'enabled' mirrors state!=off.", + "added": "2026-06-07", + "enabled_at": null + }, + "bus-tls": { + "enabled": false, + "issue": "0001", + "description": "TLS on the NATS data plane using the project's self-signed CA (deploy/tls/). When enabled the server presents its cert and clients pin the CA.", + "added": "2026-06-07", + "enabled_at": null + } + } +} From 822982b71b8ab2d8777a4ff75a43181777db6299 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 7 Jun 2026 12:23:23 +0200 Subject: [PATCH 4/4] test(membership): cover user store golden/edge/error paths Golden: add -> get -> IsAuthorized true, admin seeded. Edge: empty role defaults to member, case-insensitive hex lookup, list ordering, revoke denies authorization and stamps revoked_at. Error: duplicate key (ErrUserExists), invalid role, empty sign_pub, unknown user not authorized, revoke of unknown/already-revoked. Plus users-table migration idempotency. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/membership/users_test.go | 164 +++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 pkg/membership/users_test.go diff --git a/pkg/membership/users_test.go b/pkg/membership/users_test.go new file mode 100644 index 0000000..0e3d112 --- /dev/null +++ b/pkg/membership/users_test.go @@ -0,0 +1,164 @@ +package membership + +import ( + "errors" + "strings" + "testing" +) + +// a valid-shape Ed25519 public key in hex (64 hex chars). The bytes are +// arbitrary: the store treats sign_pub as an opaque identifier and only the CLI +// validates the length, so any 64-hex string round-trips through the store. +const ( + pubAlice = "1111111111111111111111111111111111111111111111111111111111111111" + pubBob = "2222222222222222222222222222222222222222222222222222222222222222" +) + +// Golden: add a user, read it back, and confirm it authorizes. +func TestAddGetIsAuthorized(t *testing.T) { + s := openTestStore(t) + + if err := s.AddUser(pubAlice, "alice", RoleAdmin); err != nil { + t.Fatalf("AddUser: %v", err) + } + u, err := s.GetUser(pubAlice) + if err != nil { + t.Fatalf("GetUser: %v", err) + } + if u.Handle != "alice" || u.Role != RoleAdmin || u.Status != StatusActive { + t.Fatalf("GetUser mismatch: %+v", u) + } + if u.CreatedAt == "" { + t.Fatalf("CreatedAt not stamped") + } + if u.RevokedAt != "" { + t.Fatalf("RevokedAt should be empty for an active user, got %q", u.RevokedAt) + } + if !s.IsAuthorized(pubAlice) { + t.Fatalf("active user should be authorized") + } + if !s.HasAdmin() { + t.Fatalf("HasAdmin should be true after seeding an admin") + } +} + +// Edge: an empty role defaults to member; case-insensitive lookup; list order. +func TestAddDefaultsAndListing(t *testing.T) { + s := openTestStore(t) + + if err := s.AddUser(pubBob, "bob", ""); err != nil { + t.Fatalf("AddUser bob: %v", err) + } + u, err := s.GetUser(pubBob) + if err != nil { + t.Fatalf("GetUser bob: %v", err) + } + if u.Role != RoleMember { + t.Fatalf("empty role should default to member, got %q", u.Role) + } + // Adding bob (a member only) must not make HasAdmin true. + if s.HasAdmin() { + t.Fatalf("HasAdmin should be false with only a member registered") + } + + // Lookup is case-insensitive: uppercase hex matches the lowercase-stored key. + if !s.IsAuthorized(strings.ToUpper(pubBob)) { + t.Fatalf("IsAuthorized should be case-insensitive on the hex key") + } + + if err := s.AddUser(pubAlice, "alice", RoleAdmin); err != nil { + t.Fatalf("AddUser alice: %v", err) + } + users, err := s.ListUsers() + if err != nil { + t.Fatalf("ListUsers: %v", err) + } + // Ordered by handle: alice before bob. + if len(users) != 2 || users[0].Handle != "alice" || users[1].Handle != "bob" { + t.Fatalf("ListUsers order/content wrong: %+v", users) + } +} + +// Edge: revocation flips status, stamps revoked_at, and denies authorization on +// the spot — the property both planes rely on for revoke-without-restart. +func TestRevokeDeniesAuthorization(t *testing.T) { + s := openTestStore(t) + + if err := s.AddUser(pubAlice, "alice", RoleMember); err != nil { + t.Fatalf("AddUser: %v", err) + } + if !s.IsAuthorized(pubAlice) { + t.Fatalf("precondition: user should be authorized before revoke") + } + if err := s.RevokeUser(pubAlice); err != nil { + t.Fatalf("RevokeUser: %v", err) + } + if s.IsAuthorized(pubAlice) { + t.Fatalf("revoked user must NOT be authorized") + } + u, err := s.GetUser(pubAlice) + if err != nil { + t.Fatalf("GetUser after revoke: %v", err) + } + if u.Status != StatusRevoked || u.RevokedAt == "" { + t.Fatalf("revoke should set status=revoked and stamp revoked_at, got %+v", u) + } +} + +// Error path: duplicate key, unknown user, invalid role, revoke of unknown. +func TestUserErrorPaths(t *testing.T) { + s := openTestStore(t) + + if err := s.AddUser(pubAlice, "alice", RoleAdmin); err != nil { + t.Fatalf("AddUser: %v", err) + } + // Duplicate sign_pub -> typed ErrUserExists. + if err := s.AddUser(pubAlice, "alice2", RoleMember); !errors.Is(err, ErrUserExists) { + t.Fatalf("duplicate AddUser should return ErrUserExists, got %v", err) + } + // Invalid role rejected. + if err := s.AddUser(pubBob, "bob", "superuser"); err == nil { + t.Fatalf("invalid role should error") + } + // Missing handle/sign_pub rejected. + if err := s.AddUser("", "nobody", RoleMember); err == nil { + t.Fatalf("empty sign_pub should error") + } + // Unknown user is not authorized (fail closed) and GetUser errors. + if s.IsAuthorized(pubBob) { + t.Fatalf("unknown user must not be authorized") + } + if _, err := s.GetUser(pubBob); err == nil { + t.Fatalf("GetUser of unknown user should error") + } + // Revoking an unknown (or already-revoked) user errors (no active row). + if err := s.RevokeUser(pubBob); err == nil { + t.Fatalf("revoking unknown user should error") + } + if err := s.RevokeUser(pubAlice); err != nil { + t.Fatalf("first revoke should succeed: %v", err) + } + if err := s.RevokeUser(pubAlice); err == nil { + t.Fatalf("second revoke of same user should error (already revoked)") + } +} + +// Migration safety: the users table and its index exist after Open, and the +// users migration is idempotent on re-apply (mirrors TestMigrationsCreateSchema). +func TestUsersMigrationIdempotent(t *testing.T) { + s := openTestStore(t) + var name string + if err := s.db.QueryRow( + `SELECT name FROM sqlite_master WHERE type='table' AND name='users'`, + ).Scan(&name); err != nil { + t.Fatalf("users table not created: %v", err) + } + if err := s.db.QueryRow( + `SELECT name FROM sqlite_master WHERE type='index' AND name='idx_users_status'`, + ).Scan(&name); err != nil { + t.Fatalf("idx_users_status not created: %v", err) + } + if err := s.applyMigrations(); err != nil { + t.Fatalf("re-apply migrations: %v", err) + } +}