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 }