Files
cgalo5758 5fb9ba8dfe Recover from stale Discourse user links
Re-resolve members absent after batch adds by forum user ID. Update
renamed
usernames and quarantine deleted users so they cannot block healthy
group
delivery. Align the fake with Discourse's partial-batch semantics.
2026-07-22 14:02:40 -05:00

475 lines
16 KiB
Go

// Package client is the Discourse integration's thin admin-API client. All
// Discourse HTTP calls flow through it: it enforces a client-side rate cap
// below Discourse's site-wide admin bucket (which is shared with human
// admins — see design.md D4), backs off and retries on 429, and normalizes
// the handful of response shapes the integration consumes. It reads no
// configuration itself; the adapter and workflow layers construct it from
// their own config reads, which keeps it constructible in tests against a
// fake server.
package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"golang.org/x/time/rate"
)
// DefaultRatePerMinute caps this client's request rate. Discourse's default
// admin API limit is 60/min for the whole site — one bucket shared with
// human admins and any other API consumer — so the console deliberately
// claims only half.
const DefaultRatePerMinute = 30
// max429Retries bounds how many times a single logical request is retried
// after HTTP 429 before the error is surfaced to the caller (Temporal
// retries the surrounding activity beyond that).
const max429Retries = 3
// Config configures a Client.
type Config struct {
BaseURL string
APIKey string
APIUsername string
// RatePerMinute overrides DefaultRatePerMinute when > 0.
RatePerMinute int
// HTTPClient overrides http.DefaultClient when non-nil (tests point it
// at a fake server; production passes nil).
HTTPClient *http.Client
Logger *slog.Logger
}
// Client is a rate-limited Discourse admin API client.
type Client struct {
baseURL string
apiKey string
apiUsername string
http *http.Client
limiter *rate.Limiter
logger *slog.Logger
}
// New constructs a Client from cfg.
func New(cfg Config) *Client {
perMinute := cfg.RatePerMinute
if perMinute <= 0 {
perMinute = DefaultRatePerMinute
}
httpClient := cfg.HTTPClient
if httpClient == nil {
httpClient = &http.Client{Timeout: 30 * time.Second}
}
logger := cfg.Logger
if logger == nil {
logger = slog.Default()
}
return &Client{
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
apiKey: cfg.APIKey,
apiUsername: cfg.APIUsername,
http: httpClient,
limiter: rate.NewLimiter(rate.Limit(float64(perMinute)/60.0), 1),
logger: logger,
}
}
// User is the subset of a Discourse user the integration consumes.
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
}
// Group is the subset of a Discourse group the integration consumes.
type Group struct {
ID int64 `json:"id"`
Name string `json:"name"`
Automatic bool `json:"automatic"`
UserCount int64 `json:"user_count"`
}
// APIError is a non-2xx Discourse response.
type APIError struct {
StatusCode int
Errors []string
Body string
}
func (e *APIError) Error() string {
if len(e.Errors) > 0 {
return fmt.Sprintf("discourse API %d: %s", e.StatusCode, strings.Join(e.Errors, "; "))
}
return fmt.Sprintf("discourse API %d: %s", e.StatusCode, e.Body)
}
// IsAllAlreadyMembers reports whether err is the add-members 422 Discourse
// returns when every requested user is already a member of the group —
// convergence treats it as success (spec: idempotent convergence
// operations). The message match is deliberately loose ("already a member");
// live-verified against 3.5.3 (2026-07-20), which answers exactly
// "'<username>' is already a member of this group."
func IsAllAlreadyMembers(err error) bool {
apiErr, ok := err.(*APIError)
if !ok || apiErr.StatusCode != 422 || len(apiErr.Errors) == 0 {
return false
}
for _, msg := range apiErr.Errors {
if !strings.Contains(msg, "already a member") {
return false
}
}
return true
}
// IsUnresolvableUsernames reports whether err is the members-route 400
// Discourse answers when NONE of the requested usernames resolve to a forum
// user: groups_controller raises InvalidParameters(:usernames) only when the
// resolved set is empty, and silently drops unresolvable names from a
// partly-valid batch. Live-verified against 3.5.3 (2026-07-22 incident):
// the body reads "You supplied invalid parameters to the request:
// usernames". Matched on status alone — the message wording is
// version-dependent, and the only 400 this client can earn on the members
// routes is an unresolvable-names one (the request shape is fixed).
// Convergence uses this to route dead links into per-member diagnosis
// instead of failing the sweep.
func IsUnresolvableUsernames(err error) bool {
apiErr, ok := err.(*APIError)
return ok && apiErr.StatusCode == http.StatusBadRequest
}
// do executes one rate-limited request, retrying on 429. Response body is
// returned for 2xx; a typed *APIError otherwise. A nil-body 404 is the
// caller's to interpret (several lookups treat it as "not found, no error").
func (c *Client) do(ctx context.Context, method, path string, query url.Values, body any) (int, []byte, error) {
var payload []byte
if body != nil {
var err error
payload, err = json.Marshal(body)
if err != nil {
return 0, nil, fmt.Errorf("marshal request: %w", err)
}
}
u := c.baseURL + path
if len(query) > 0 {
u += "?" + query.Encode()
}
for attempt := 0; ; attempt++ {
if err := c.limiter.Wait(ctx); err != nil {
return 0, nil, err
}
var reqBody io.Reader
if payload != nil {
reqBody = bytes.NewReader(payload)
}
req, err := http.NewRequestWithContext(ctx, method, u, reqBody)
if err != nil {
return 0, nil, err
}
req.Header.Set("Api-Key", c.apiKey)
req.Header.Set("Api-Username", c.apiUsername)
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return 0, nil, err
}
respBody, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return resp.StatusCode, nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode == http.StatusTooManyRequests && attempt < max429Retries {
delay := backoffDelay(resp.Header.Get("Retry-After"), attempt)
c.logger.Warn("discourse API rate limited; backing off",
slog.String("path", path), slog.Duration("delay", delay), slog.Int("attempt", attempt+1))
select {
case <-ctx.Done():
return resp.StatusCode, nil, ctx.Err()
case <-time.After(delay):
}
continue
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return resp.StatusCode, respBody, nil
}
return resp.StatusCode, respBody, parseAPIError(resp.StatusCode, respBody)
}
}
// backoffDelay honors Retry-After when present, else exponential from 1s.
func backoffDelay(retryAfter string, attempt int) time.Duration {
if retryAfter != "" {
if secs, err := strconv.Atoi(retryAfter); err == nil && secs > 0 {
return time.Duration(secs) * time.Second
}
}
return time.Duration(1<<attempt) * time.Second
}
func parseAPIError(status int, body []byte) *APIError {
var parsed struct {
Errors []string `json:"errors"`
}
_ = json.Unmarshal(body, &parsed)
return &APIError{StatusCode: status, Errors: parsed.Errors, Body: string(body)}
}
// ErrInvalidAPIKey reports that the configured admin API key is dead. It is
// detectable only where a 404 cannot mean "not found": live Discourse
// answers an invalid key with 404 on /admin/* routes (route-hiding) and 403
// on other authenticated routes, so lookups that map 404 to "no record"
// (AdminUser) silently degrade under a dead key.
var ErrInvalidAPIKey = fmt.Errorf("discourse admin API key rejected")
// VerifyKey probes key validity against an endpoint where 404 is impossible
// with a valid key: the admin user list filtered to an unmatchable email
// answers 200 [] when authenticated, 404/403 when the key is dead. Called
// at sweep start so a revoked key fails the sweep loudly instead of reading
// as "every admin record is missing".
func (c *Client) VerifyKey(ctx context.Context) error {
q := url.Values{"email": {"verify-key-probe@invalid.invalid"}}
status, _, err := c.do(ctx, http.MethodGet, "/admin/users/list/active.json", q, nil)
if status == http.StatusNotFound || status == http.StatusForbidden {
return fmt.Errorf("%w (probe answered %d)", ErrInvalidAPIKey, status)
}
return err
}
// FindUserByEmail looks up an active user by exact primary email (Discourse
// downcases the param; matching is case-insensitive, primary email only).
// Returns (nil, nil) when no user matches.
func (c *Client) FindUserByEmail(ctx context.Context, email string) (*User, error) {
q := url.Values{"email": {email}}
_, body, err := c.do(ctx, http.MethodGet, "/admin/users/list/active.json", q, nil)
if err != nil {
return nil, err
}
var users []User
if err := json.Unmarshal(body, &users); err != nil {
return nil, fmt.Errorf("decode user list: %w", err)
}
if len(users) == 0 {
return nil, nil
}
return &users[0], nil
}
// UserByExternalID resolves the Discourse user associated with an external
// identity. provider "" uses the DiscourseConnect route
// (/u/by-external/{id}.json); otherwise the managed-authenticator route
// (/u/by-external/{provider}/{id}.json, e.g. provider "oidc"). Both are
// admin-only server side. Returns (nil, nil) on 404 (no association).
func (c *Client) UserByExternalID(ctx context.Context, provider, externalID string) (*User, error) {
var path string
if provider == "" {
path = "/u/by-external/" + url.PathEscape(externalID) + ".json"
} else {
path = "/u/by-external/" + url.PathEscape(provider) + "/" + url.PathEscape(externalID) + ".json"
}
status, body, err := c.do(ctx, http.MethodGet, path, nil, nil)
if status == http.StatusNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
var parsed struct {
User User `json:"user"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, fmt.Errorf("decode by-external response: %w", err)
}
return &parsed.User, nil
}
// CreateUserParams are the fields for programmatic user creation.
type CreateUserParams struct {
Name string
Email string
Password string
Username string
// ExternalIDs binds provider→uid associations at creation (admin+API
// only); e.g. {"oidc": "<subject>"} in oidc linkage mode.
ExternalIDs map[string]string
}
// CreateUser creates an active, approved user (find-and-link's opt-in
// auto-create path). active=true with an admin key confirms the email
// without a round-trip; approved=true is passed explicitly rather than
// assuming active covers must_approve_users (live-verified: with
// must_approve_users on, approved=true is honored while omitting it lands
// the user in the review queue).
//
// The response identifies nothing: live 3.5.3 returns no user_id, and
// enumeration protection (hide_email_address_taken, default on) reports
// success:true even when a duplicate email means NO user was created.
// Callers must confirm creation with a follow-up lookup (by-external or
// by-email) — see live-verification.md. Note that creating with an
// external_ids pair that another forum user already holds silently
// reassigns the association to the new user; the linkage layer's
// lookup-before-create makes that unreachable outside races.
func (c *Client) CreateUser(ctx context.Context, p CreateUserParams) error {
reqBody := map[string]any{
"name": p.Name,
"email": p.Email,
"password": p.Password,
"username": p.Username,
"active": true,
"approved": true,
}
if len(p.ExternalIDs) > 0 {
reqBody["external_ids"] = p.ExternalIDs
}
_, body, err := c.do(ctx, http.MethodPost, "/users.json", nil, reqBody)
if err != nil {
return err
}
var parsed struct {
Success bool `json:"success"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return fmt.Errorf("decode create-user response: %w", err)
}
if !parsed.Success {
return fmt.Errorf("discourse create user refused: %s", parsed.Message)
}
return nil
}
// AdminUserRecord is the admin-serializer subset the integration consumes:
// the associated-account map that reverse-resolves a forum user to an OIDC
// subject (webhook fast-linking), plus the DiscourseConnect external id.
type AdminUserRecord struct {
ID int64 `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
ExternalIDs map[string]string `json:"external_ids"`
SingleSignOnRecord *struct {
ExternalID string `json:"external_id"`
} `json:"single_sign_on_record"`
}
// AdminUser fetches a user's admin record (/admin/users/{id}.json), which
// exposes external_ids (provider → uid) for managed authenticators.
// Returns (nil, nil) on 404 — which on /admin/* routes is ALSO what an
// invalid API key answers (live-verified; non-admin routes answer 403), so
// a dead key reads as "record missing" here. VerifyKey at sweep start
// bounds how long that ambiguity can persist unnoticed.
func (c *Client) AdminUser(ctx context.Context, id int64) (*AdminUserRecord, error) {
status, body, err := c.do(ctx, http.MethodGet, "/admin/users/"+strconv.FormatInt(id, 10)+".json", nil, nil)
if status == http.StatusNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
var rec AdminUserRecord
if err := json.Unmarshal(body, &rec); err != nil {
return nil, fmt.Errorf("decode admin user response: %w", err)
}
return &rec, nil
}
// GetGroup fetches a group by name. Returns (nil, nil) on 404.
func (c *Client) GetGroup(ctx context.Context, name string) (*Group, error) {
status, body, err := c.do(ctx, http.MethodGet, "/groups/"+url.PathEscape(name)+".json", nil, nil)
if status == http.StatusNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
var parsed struct {
Group Group `json:"group"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, fmt.Errorf("decode group response: %w", err)
}
return &parsed.Group, nil
}
// ListGroupMembers pages through a group's full member list.
func (c *Client) ListGroupMembers(ctx context.Context, groupName string) ([]User, error) {
const pageSize = 100
var members []User
for offset := 0; ; offset += pageSize {
q := url.Values{
"limit": {strconv.Itoa(pageSize)},
"offset": {strconv.Itoa(offset)},
}
_, body, err := c.do(ctx, http.MethodGet, "/groups/"+url.PathEscape(groupName)+"/members.json", q, nil)
if err != nil {
return nil, err
}
var parsed struct {
Members []User `json:"members"`
Meta struct {
Total int `json:"total"`
} `json:"meta"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, fmt.Errorf("decode members response: %w", err)
}
members = append(members, parsed.Members...)
if len(members) >= parsed.Meta.Total || len(parsed.Members) == 0 {
return members, nil
}
}
}
// AddGroupMembers adds usernames to a group. The 422 Discourse returns when
// every requested user is already a member is converted to success here —
// convergence must be repeat-safe (spec: idempotent convergence).
func (c *Client) AddGroupMembers(ctx context.Context, groupID int64, usernames []string) error {
if len(usernames) == 0 {
return nil
}
reqBody := map[string]string{"usernames": strings.Join(usernames, ",")}
_, _, err := c.do(ctx, http.MethodPut, "/groups/"+strconv.FormatInt(groupID, 10)+"/members.json", nil, reqBody)
if err != nil && IsAllAlreadyMembers(err) {
return nil
}
return err
}
// RemoveGroupMembers removes usernames from a group. Removal of a user who
// is not a member is treated as success (already converged), matching
// AddGroupMembers' semantics. Live 3.5.3 already answers 200 with the
// non-members under skipped_usernames; the 422 "not a member" branch below
// covers older Discourse versions.
func (c *Client) RemoveGroupMembers(ctx context.Context, groupID int64, usernames []string) error {
if len(usernames) == 0 {
return nil
}
reqBody := map[string]string{"usernames": strings.Join(usernames, ",")}
_, _, err := c.do(ctx, http.MethodDelete, "/groups/"+strconv.FormatInt(groupID, 10)+"/members.json", nil, reqBody)
if err != nil {
if apiErr, ok := err.(*APIError); ok && apiErr.StatusCode == 422 {
for _, msg := range apiErr.Errors {
if strings.Contains(msg, "not a member") {
return nil
}
}
}
}
return err
}