Files
unibus/pkg/membership/users.go
T
agent 6b3ace1d39 feat(0003b): membership.Store interface + JetStream KV implementation
Branch-by-abstraction for the control-plane store (issue 0003b), so the
membership state can move off process-local SQLite onto replicated
JetStream KV without rewriting callers and without breaking master.

pkg/membership:
- Store is now an interface (rooms/members/keys + user allowlist +
  Close). The existing SQLite implementation is renamed sqliteStore and
  stays the default: Open(path) still returns it. openSQLite keeps the
  concrete type for internal callers (the 0003c migration).
- ErrNotFound is a storage-agnostic "no such record" sentinel; both
  backends return it (the SQLite store maps sql.ErrNoRows to it). The
  control plane now branches on ErrNotFound instead of sql.ErrNoRows, so
  server.go no longer imports database/sql.
- jetstreamStore (new) implements Store over five replicated KV buckets:
  rooms, members, rooms_by_member (reverse index for ListRoomsForEndpoint),
  room_keys, users. Replication factor is configurable (R1..R5) for the
  R1->R3 rollout. Every read is bounded by OpTimeout and IsAuthorized /
  HasAdmin FAIL CLOSED on any backend error (a KV quorum loss denies,
  never admits), per the audit's requirement for the decentralized store.

dev/feature_flags.json:
- Add the `decentralized` flag (OFF): sqliteStore default while off,
  jetstreamStore behind it. The membershipd boot wiring that selects the
  KV store is deliberately deferred to 0003e/0003f (the embedded-NATS
  authenticator<->store bootstrap is part of the session/deploy redesign);
  OFF keeps the single-node SQLite control plane unchanged.

Tests (DoD: golden + edges + error path):
- TestJetStreamStoreRoomsCRUD: encrypted room + owner + invited member
  round-trip through every room/member/key method, including latest-epoch
  resolution and rekey.
- TestJetStreamStoreUsers: add/get/authorize/list/revoke + admin gate,
  with case-insensitive key normalization and duplicate rejection.
- TestJetStreamStoreNotFound: ErrNotFound mapping for misses.
- TestJetStreamStoreIsAuthorizedFailClosed: NATS backend shut down ->
  IsAuthorized and HasAdmin both DENY within the bounded timeout.

The full existing suite stays green: sqliteStore is unchanged behavior.
2026-06-07 15:04:52 +02:00

165 lines
6.0 KiB
Go

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 *sqliteStore) 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 *sqliteStore) 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 *sqliteStore) 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 *sqliteStore) 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 *sqliteStore) 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 *sqliteStore) 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
}