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