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.
475 lines
16 KiB
Go
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
|
|
}
|