d64b0c052d
Add the data layer for WhatsApp-style accounts on the wallet model: the admin mints a single-use invitation link, the new user redeems it by publishing only its public keys, and the admin can hard-delete a user. - Invite type and lifecycle (invites.go): 32-byte crypto/rand hex token, 7-day default TTL, fail-closed expiry parsing. Methods CreateInvite/GetInvite/ ListInvites/ConsumeInvite/CancelInvite on both backends. ConsumeInvite is atomic and single-use: SQLite uses a transaction guarded by `used = 0`, the KV store uses a compare-and-swap on the entry revision (mark-first). Both burn the token on claim, so an already-registered key surfaces ErrUserExists with the invite spent — identical semantics across backends. - DeleteUser (users.go + jetstream_store.go): hard-delete of the allowlist row, distinct from RevokeUser's status flip. Room memberships of the ex-user are intentionally left inert (they can no longer authenticate); no partial cleanup. - Migration 003_invites.sql (root + embedded copy, byte-identical): additive `invites` table with audit columns, per db_migrations rules. - Store interface gains DeleteUser, CreateInvite, GetInvite, ListInvites, ConsumeInvite, CancelInvite. New UNIBUS_invites KV bucket. - Consistency fix: SQLite GetUser now maps sql.ErrNoRows to ErrNotFound, matching the KV backend and the storage-agnostic contract documented in store.go. - ValidateKexPubHex added alongside ValidateSignPubHex for /register key checks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
230 lines
8.9 KiB
Go
230 lines
8.9 KiB
Go
package membership
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"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
|
|
}
|
|
|
|
// ValidateSignPubHex ensures signPub is exactly a 32-byte Ed25519 public key in
|
|
// hex (64 hex chars). It is the single source of truth for that check, shared by
|
|
// the local admin CLI (which validates before seeding the first admin) and the
|
|
// HTTP user-management handlers (which validate an admin-supplied key before it
|
|
// reaches the store). Catching a malformed key here turns a silent "authorized
|
|
// nobody" into an explicit error at the boundary.
|
|
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
|
|
}
|
|
|
|
// ValidateKexPubHex ensures kexPub is exactly a 32-byte X25519 public key in hex
|
|
// (64 hex chars). It is the registration-side counterpart of ValidateSignPubHex:
|
|
// POST /register receives both the new identity's signing key and its key-exchange
|
|
// key, and both must be well-formed before the invite is consumed. An X25519
|
|
// public key is 32 bytes, identical in length to Ed25519, so the check is the
|
|
// same shape with a key-exchange-specific message.
|
|
func ValidateKexPubHex(kexPub string) error {
|
|
b, err := hex.DecodeString(kexPub)
|
|
if err != nil {
|
|
return fmt.Errorf("kex-pub is not valid hex: %w", err)
|
|
}
|
|
if len(b) != 32 {
|
|
return fmt.Errorf("kex-pub must be a 32-byte X25519 public key (64 hex chars), got %d bytes", len(b))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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. A miss returns
|
|
// ErrNotFound (wrapped), matching the storage-agnostic contract in store.go and
|
|
// the JetStream backend, so callers can branch on ErrNotFound regardless of which
|
|
// store is active (the SQLite-specific sql.ErrNoRows is mapped here).
|
|
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 {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return User{}, fmt.Errorf("membership: get user %q: %w", signPub, ErrNotFound)
|
|
}
|
|
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
|
|
}
|
|
|
|
// DeleteUser hard-deletes a user from the allowlist (admin "remove user", the
|
|
// purge counterpart of RevokeUser's status flip). It removes ONLY the allowlist
|
|
// row: the ex-user can no longer authenticate on either plane, so any room
|
|
// memberships they still hold become inert (they cannot fetch a sealed key, sign
|
|
// a request, or open a NATS connection). We deliberately do NOT chase down and
|
|
// rewrite those room memberships here — that would be a partial, racy cleanup of
|
|
// state owned by each room's owner; a room owner kicks/rekeys to achieve forward
|
|
// secrecy when needed. Deleting an unknown key returns ErrNotFound (wrapped) so
|
|
// the HTTP layer can answer 404.
|
|
func (s *sqliteStore) DeleteUser(signPub string) error {
|
|
signPub = normalizeSignPub(signPub)
|
|
res, err := s.db.Exec(`DELETE FROM users WHERE sign_pub = ?`, signPub)
|
|
if err != nil {
|
|
return fmt.Errorf("membership: delete user %q: %w", signPub, err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("membership: delete user %q: rows affected: %w", signPub, err)
|
|
}
|
|
if n == 0 {
|
|
return fmt.Errorf("membership: delete user %q: %w", signPub, ErrNotFound)
|
|
}
|
|
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
|
|
}
|