From c5387028e0fd412282a61777798947cc5c55ec41 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 7 Jun 2026 12:23:11 +0200 Subject: [PATCH] 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 00000000..7c0c8231 --- /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 00000000..7c0c8231 --- /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 00000000..7aeb1c5d --- /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 +}