feat(membership): single-use invites + hard-delete in the Store (SQLite + KV)
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>
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
package membership
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Invite is a single-use registration token the admin mints so a brand-new
|
||||
// identity can join the bus allowlist WITHOUT the admin ever handling its
|
||||
// private key (the wallet model: the key is born and stays on the user's
|
||||
// device; only the public key is published, via POST /register).
|
||||
//
|
||||
// The admin fixes the handle and role at mint time; the registering client may
|
||||
// NOT change them (no privilege escalation). Token is 32 random bytes in
|
||||
// lowercase hex (64 chars). ExpiresAt and CreatedAt are RFC3339Nano UTC. Used
|
||||
// flips to true the instant the invite is consumed, and an invite can be
|
||||
// consumed at most once. The audit fields (UsedAt/UsedSignPub/UsedKexPub) are
|
||||
// empty until the invite is consumed; they record which keys claimed it, so the
|
||||
// link between an invite and the identity it created stays traceable even though
|
||||
// the allowlist row itself stores only the signing key.
|
||||
type Invite struct {
|
||||
Token string `json:"token"`
|
||||
Handle string `json:"handle"`
|
||||
Role string `json:"role"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
Used bool `json:"used"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
|
||||
// Audit (populated on consume; omitted on the wire while pending).
|
||||
UsedAt string `json:"used_at,omitempty"`
|
||||
UsedSignPub string `json:"used_sign_pub,omitempty"`
|
||||
UsedKexPub string `json:"used_kex_pub,omitempty"`
|
||||
}
|
||||
|
||||
// Invite-flow sentinels. They let callers (and the HTTP layer) map a failed
|
||||
// consume to a precise status code without string-matching: an unknown token is
|
||||
// ErrNotFound (reused from the store), a spent token is ErrInviteUsed, a
|
||||
// past-deadline token is ErrInviteExpired. ErrUserExists (from users.go) is
|
||||
// reused when the presented signing key is already registered.
|
||||
var (
|
||||
ErrInviteUsed = errors.New("membership: invite already used")
|
||||
ErrInviteExpired = errors.New("membership: invite expired")
|
||||
)
|
||||
|
||||
// defaultInviteTTL is the lifetime of an invite when the caller passes a
|
||||
// non-positive ttlSecs. Seven days mirrors a typical "share this link this
|
||||
// week" expectation while keeping the un-authenticated /register window bounded.
|
||||
const defaultInviteTTL = 7 * 24 * time.Hour
|
||||
|
||||
// newInviteToken returns 32 cryptographically-random bytes as lowercase hex (64
|
||||
// chars). The token IS the bearer secret that authorizes /register, so it must
|
||||
// be unguessable; crypto/rand is the only acceptable source.
|
||||
func newInviteToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("membership: generate invite token: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// inviteTTL resolves a caller-supplied ttlSecs into a concrete duration,
|
||||
// defaulting to defaultInviteTTL when non-positive.
|
||||
func inviteTTL(ttlSecs int) time.Duration {
|
||||
if ttlSecs <= 0 {
|
||||
return defaultInviteTTL
|
||||
}
|
||||
return time.Duration(ttlSecs) * time.Second
|
||||
}
|
||||
|
||||
// inviteIsExpired reports whether the RFC3339 expiry has passed. A token whose
|
||||
// expiry cannot be parsed is treated as expired (fail closed): a corrupt
|
||||
// deadline must never widen the unauthenticated registration window.
|
||||
func inviteIsExpired(expiresAt string) bool {
|
||||
exp, err := time.Parse(time.RFC3339Nano, expiresAt)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
return time.Now().UTC().After(exp)
|
||||
}
|
||||
|
||||
// validateInviteRole normalizes and validates the role an invite may carry. It
|
||||
// mirrors AddUser: empty defaults to member, and only admin|member are allowed
|
||||
// (an admin minting an admin invite is deliberate and permitted).
|
||||
func validateInviteRole(role string) (string, error) {
|
||||
if role == "" {
|
||||
return RoleMember, nil
|
||||
}
|
||||
if role != RoleAdmin && role != RoleMember {
|
||||
return "", fmt.Errorf("membership: invalid role %q (want %q or %q)", role, RoleAdmin, RoleMember)
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
|
||||
// ---- SQLite implementation ------------------------------------------------
|
||||
|
||||
// CreateInvite mints a single-use invite for a future user. handle is required;
|
||||
// role defaults to member and must be admin|member. ttlSecs sets the lifetime
|
||||
// (non-positive uses the 7-day default). The token is 32 random bytes in hex.
|
||||
func (s *sqliteStore) CreateInvite(handle, role string, ttlSecs int) (Invite, error) {
|
||||
if handle == "" {
|
||||
return Invite{}, fmt.Errorf("membership: CreateInvite: handle required")
|
||||
}
|
||||
role, err := validateInviteRole(role)
|
||||
if err != nil {
|
||||
return Invite{}, err
|
||||
}
|
||||
token, err := newInviteToken()
|
||||
if err != nil {
|
||||
return Invite{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
inv := Invite{
|
||||
Token: token,
|
||||
Handle: handle,
|
||||
Role: role,
|
||||
ExpiresAt: now.Add(inviteTTL(ttlSecs)).Format(time.RFC3339Nano),
|
||||
Used: false,
|
||||
CreatedAt: now.Format(time.RFC3339Nano),
|
||||
}
|
||||
if _, err := s.db.Exec(
|
||||
`INSERT INTO invites (token, handle, role, expires_at, used, created_at) VALUES (?, ?, ?, ?, 0, ?)`,
|
||||
inv.Token, inv.Handle, inv.Role, inv.ExpiresAt, inv.CreatedAt,
|
||||
); err != nil {
|
||||
return Invite{}, fmt.Errorf("membership: insert invite: %w", err)
|
||||
}
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
// GetInvite returns the invite with the given token, or ErrNotFound (wrapped)
|
||||
// when there is none.
|
||||
func (s *sqliteStore) GetInvite(token string) (Invite, error) {
|
||||
var inv Invite
|
||||
var used int
|
||||
var usedAt, usedSign, usedKex sql.NullString
|
||||
err := s.db.QueryRow(
|
||||
`SELECT token, handle, role, expires_at, used, created_at, used_at, used_sign_pub, used_kex_pub
|
||||
FROM invites WHERE token = ?`, token,
|
||||
).Scan(&inv.Token, &inv.Handle, &inv.Role, &inv.ExpiresAt, &used, &inv.CreatedAt, &usedAt, &usedSign, &usedKex)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Invite{}, fmt.Errorf("membership: get invite %q: %w", token, ErrNotFound)
|
||||
}
|
||||
return Invite{}, fmt.Errorf("membership: get invite %q: %w", token, err)
|
||||
}
|
||||
inv.Used = used != 0
|
||||
inv.UsedAt, inv.UsedSignPub, inv.UsedKexPub = usedAt.String, usedSign.String, usedKex.String
|
||||
return inv, nil
|
||||
}
|
||||
|
||||
// ListInvites returns every invite ordered newest-first (by created_at). It
|
||||
// includes consumed invites so the admin panel can show the full picture; the
|
||||
// caller filters to "pending" when it wants only live links.
|
||||
func (s *sqliteStore) ListInvites() ([]Invite, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT token, handle, role, expires_at, used, created_at, used_at, used_sign_pub, used_kex_pub
|
||||
FROM invites ORDER BY created_at DESC, token`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("membership: list invites: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Invite
|
||||
for rows.Next() {
|
||||
var inv Invite
|
||||
var used int
|
||||
var usedAt, usedSign, usedKex sql.NullString
|
||||
if err := rows.Scan(&inv.Token, &inv.Handle, &inv.Role, &inv.ExpiresAt, &used, &inv.CreatedAt, &usedAt, &usedSign, &usedKex); err != nil {
|
||||
return nil, fmt.Errorf("membership: scan invite: %w", err)
|
||||
}
|
||||
inv.Used = used != 0
|
||||
inv.UsedAt, inv.UsedSignPub, inv.UsedKexPub = usedAt.String, usedSign.String, usedKex.String
|
||||
out = append(out, inv)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ConsumeInvite atomically validates and spends an invite, registering the
|
||||
// presented signing key as a bus user with the invite's handle and role. It is
|
||||
// the ONLY path that adds to the allowlist without an admin signature: the
|
||||
// bearer token is the authorization, so the checks here are the security
|
||||
// boundary.
|
||||
//
|
||||
// Atomicity (single transaction): the invite is marked used FIRST (guarded by
|
||||
// `used = 0`, so two concurrent consumers cannot both win), then the user is
|
||||
// inserted. A token that passes validation is therefore spent exactly once.
|
||||
// Special case: if the signing key is already registered, the user INSERT hits
|
||||
// the PRIMARY KEY and we return ErrUserExists — but the invite stays SPENT (we
|
||||
// commit the mark), matching the JetStream backend's burn-on-claim semantics so
|
||||
// the two stores behave identically. A genuine backend error rolls everything
|
||||
// back, leaving the invite reusable.
|
||||
func (s *sqliteStore) ConsumeInvite(token, signPub, kexPub string) error {
|
||||
signPub = normalizeSignPub(signPub)
|
||||
kexPub = normalizeSignPub(kexPub)
|
||||
if signPub == "" {
|
||||
return fmt.Errorf("membership: ConsumeInvite: sign_pub required")
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("membership: ConsumeInvite: begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var handle, role, expiresAt string
|
||||
var used int
|
||||
err = tx.QueryRow(
|
||||
`SELECT handle, role, expires_at, used FROM invites WHERE token = ?`, token,
|
||||
).Scan(&handle, &role, &expiresAt, &used)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("membership: consume invite %q: %w", token, ErrNotFound)
|
||||
}
|
||||
return fmt.Errorf("membership: consume invite %q: %w", token, err)
|
||||
}
|
||||
if used != 0 {
|
||||
return fmt.Errorf("membership: consume invite %q: %w", token, ErrInviteUsed)
|
||||
}
|
||||
if inviteIsExpired(expiresAt) {
|
||||
return fmt.Errorf("membership: consume invite %q: %w", token, ErrInviteExpired)
|
||||
}
|
||||
|
||||
// Mark used first, guarded by used = 0 so a concurrent consumer that already
|
||||
// flipped it (rows affected = 0) is rejected as used rather than double-spending.
|
||||
now := nowRFC3339()
|
||||
res, err := tx.Exec(
|
||||
`UPDATE invites SET used = 1, used_at = ?, used_sign_pub = ?, used_kex_pub = ? WHERE token = ? AND used = 0`,
|
||||
now, signPub, kexPub, token,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("membership: consume invite %q: mark used: %w", token, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("membership: consume invite %q: rows affected: %w", token, err)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("membership: consume invite %q: %w", token, ErrInviteUsed)
|
||||
}
|
||||
|
||||
// Register the user with the invite-fixed handle and role.
|
||||
_, err = tx.Exec(
|
||||
`INSERT INTO users (sign_pub, handle, role, status, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
signPub, handle, role, StatusActive, now,
|
||||
)
|
||||
if err != nil {
|
||||
// Already-registered key: the invite is still spent (commit the mark) so
|
||||
// the burn-on-claim contract matches the KV store. Any other failure rolls back.
|
||||
if isUniqueViolation(err) {
|
||||
if cErr := tx.Commit(); cErr != nil {
|
||||
return fmt.Errorf("membership: consume invite %q: commit: %w", token, cErr)
|
||||
}
|
||||
return ErrUserExists
|
||||
}
|
||||
return fmt.Errorf("membership: consume invite %q: insert user: %w", token, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("membership: consume invite %q: commit: %w", token, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelInvite removes a pending invite (the admin revoked the link before it
|
||||
// was used). It hard-deletes the row; a consumed invite stays for audit only if
|
||||
// the caller targets a pending token. Deleting an unknown token returns
|
||||
// ErrNotFound so the HTTP layer can answer 404.
|
||||
func (s *sqliteStore) CancelInvite(token string) error {
|
||||
res, err := s.db.Exec(`DELETE FROM invites WHERE token = ?`, token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("membership: cancel invite %q: %w", token, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("membership: cancel invite %q: rows affected: %w", token, err)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("membership: cancel invite %q: %w", token, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isUniqueViolation reports whether err is a SQLite UNIQUE/PRIMARY KEY conflict.
|
||||
// modernc.org/sqlite surfaces it as a message fragment; matching it here keeps
|
||||
// the string-matching in one place (the same fragments AddUser checks inline).
|
||||
func isUniqueViolation(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "UNIQUE constraint") || strings.Contains(msg, "PRIMARY KEY")
|
||||
}
|
||||
Reference in New Issue
Block a user