Merge issue/0001a-users: bus user allowlist (store + CLI + migration 002)
Phase 0001a of issue 0001 (bus auth + TLS). Adds the users table, store CRUD (AddUser/GetUser/ListUsers/RevokeUser/IsAuthorized/HasAdmin), the local 'membershipd user' admin CLI for seeding the first admin, and the bus-auth / bus-tls feature flags (both off). No behavior change yet: the allowlist is not consulted until phase 0001b wires the control-plane middleware. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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 (
|
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")
|
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")
|
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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user